mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
.fig files open through file.open, file.import, drag and drop, the dialogs, the CLI and MCP. A generic reader written from the public format description decodes the zip, the deflate/zstd chunks and the kiwi schema and message, then maps nodes by field name onto the REST importer: layer tree, geometry, paints with styles resolved, strokes, effects, text runs, vectors as outlines, auto layout including grid, constraints, components, sets, instances expanded from their main with overrides, styles, variables with modes and bindings, prototyping and image fills. Unsupported layers come back as warnings. Fuzzed; tested on fixtures generated in code. New dependency: ruzstd (pure-Rust zstd decoder).
742 lines
32 KiB
Rust
742 lines
32 KiB
Rust
//! `.fig` import tests on fixtures built in code: a small schema written in a compact text form,
|
||
//! a message encoded with our own kiwi encoder, compressed (deflate and zstd), framed and zipped.
|
||
|
||
use std::io::Write;
|
||
|
||
use figcraft_doc::{NodeKind, VariableType};
|
||
use serde_json::Value;
|
||
|
||
use super::kiwi::{Def, Field, Kind, Kv, Schema, Type, encode};
|
||
use super::*;
|
||
|
||
/// The fixture schema: `enum Name A B …`, `struct|message Name type field, type field[] …`.
|
||
/// Message field ids count from 1; enum values from 0.
|
||
const SCHEMA: &str = "
|
||
enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET
|
||
enum PaintType SOLID GRADIENT_LINEAR IMAGE
|
||
enum WindingRule NONZERO ODD
|
||
enum StyleType NONE FILL TEXT
|
||
enum StackMode NONE HORIZONTAL VERTICAL
|
||
enum PropType BOOL TEXT
|
||
enum VariableField MISSING VISIBLE TEXT_DATA
|
||
enum DataType BOOLEAN FLOAT STRING ALIAS COLOR PROP_REF
|
||
enum ResolvedType BOOLEAN FLOAT STRING COLOR
|
||
enum InteractionType ON_CLICK ON_HOVER
|
||
enum ConnectionType NONE INTERNAL_NODE URL
|
||
enum NavigationType NAVIGATE OVERLAY
|
||
enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE
|
||
enum EasingType IN_CUBIC OUT_CUBIC
|
||
struct GUID uint sessionID, uint localID
|
||
struct Color float r, float g, float b, float a
|
||
struct Vector float x, float y
|
||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||
struct ColorStop Color color, float position
|
||
message Image byte[] hash, string name
|
||
message VariableID GUID guid
|
||
message PropRefValue GUID defId
|
||
message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue
|
||
message VariableData AnyValue value, DataType dataType
|
||
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar
|
||
message ParentIndex GUID guid, string position
|
||
message FontName string family, string style, string postscript
|
||
message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable
|
||
message Path WindingRule windingRule, uint commandsBlob
|
||
message GUIDPath GUID[] guids
|
||
message SymbolData GUID symbolID, NodeChange[] symbolOverrides
|
||
message StyleId GUID guid
|
||
message PropValue bool boolValue, TextData textValue
|
||
message PropDef GUID id, string name, PropValue initialValue, PropType type
|
||
message PropAssignment GUID defID, PropValue value
|
||
message MapEntry VariableData variableData, VariableField variableField
|
||
message VariableDataMap MapEntry[] entries
|
||
message SetMode GUID id, string name, string sortPosition
|
||
message ValuesEntry GUID modeID, VariableData variableData
|
||
message Values ValuesEntry[] entries
|
||
message ModeEntry VariableID variableSetID, GUID variableModeID
|
||
message ModeMap ModeEntry[] entries
|
||
message Event InteractionType interactionType
|
||
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType
|
||
message Interaction Event event, Action[] actions
|
||
message StartingPoint string name, string position
|
||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale
|
||
message Blob byte[] bytes
|
||
message Message NodeChange[] nodeChanges, Blob[] blobs
|
||
";
|
||
|
||
fn schema() -> Schema {
|
||
let lines: Vec<Vec<&str>> = SCHEMA.lines().map(|l| l.split_whitespace().collect()).filter(|l: &Vec<&str>| !l.is_empty()).collect();
|
||
let index = |name: &str| lines.iter().position(|l| l[1] == name);
|
||
let builtin = |t: &str| match t {
|
||
"bool" => Some(Type::Bool),
|
||
"byte" => Some(Type::Byte),
|
||
"int" => Some(Type::Int),
|
||
"uint" => Some(Type::Uint),
|
||
"float" => Some(Type::Float),
|
||
"string" => Some(Type::Str),
|
||
"int64" => Some(Type::Int64),
|
||
"uint64" => Some(Type::Uint64),
|
||
_ => None,
|
||
};
|
||
let defs = lines
|
||
.iter()
|
||
.map(|l| {
|
||
let kind = match l[0] {
|
||
"enum" => Kind::Enum,
|
||
"struct" => Kind::Struct,
|
||
_ => Kind::Message,
|
||
};
|
||
let fields = if kind == Kind::Enum {
|
||
l[2..].iter().enumerate().map(|(i, n)| Field { name: n.to_string(), ty: None, array: false, value: i as u32 }).collect()
|
||
} else {
|
||
l[2..]
|
||
.join(" ")
|
||
.split(',')
|
||
.enumerate()
|
||
.map(|(i, f)| {
|
||
let (t, n) = f.trim().split_once(' ').unwrap();
|
||
let array = t.ends_with("[]");
|
||
let t = t.trim_end_matches("[]");
|
||
let ty = builtin(t).unwrap_or_else(|| Type::Def(index(t).unwrap_or_else(|| panic!("type {t}"))));
|
||
Field { name: n.to_string(), ty: Some(ty), array, value: i as u32 + 1 }
|
||
})
|
||
.collect()
|
||
};
|
||
Def { name: l[1].to_string(), kind, fields }
|
||
})
|
||
.collect();
|
||
Schema { defs }
|
||
}
|
||
|
||
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||
Kv::Object(fields)
|
||
}
|
||
fn f(x: f64) -> Kv<'static> {
|
||
Kv::Float(x as f32)
|
||
}
|
||
fn u(x: u64) -> Kv<'static> {
|
||
Kv::Uint(x)
|
||
}
|
||
fn e(n: &'static str) -> Kv<'static> {
|
||
Kv::Enum(n)
|
||
}
|
||
fn s(x: &str) -> Kv<'static> {
|
||
Kv::Str(x.into())
|
||
}
|
||
fn g(l: u64) -> Kv<'static> {
|
||
o(vec![("sessionID", u(1)), ("localID", u(l))])
|
||
}
|
||
fn arr(v: Vec<Kv<'static>>) -> Kv<'static> {
|
||
Kv::Array(v)
|
||
}
|
||
fn xy(x: f64, y: f64) -> Kv<'static> {
|
||
o(vec![("x", f(x)), ("y", f(y))])
|
||
}
|
||
fn at(x: f64, y: f64) -> Kv<'static> {
|
||
o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))])
|
||
}
|
||
fn rgb(r: f64, gr: f64, b: f64) -> Kv<'static> {
|
||
o(vec![("r", f(r)), ("g", f(gr)), ("b", f(b)), ("a", f(1.0))])
|
||
}
|
||
fn solid(r: f64, gr: f64, b: f64) -> Kv<'static> {
|
||
o(vec![("type", e("SOLID")), ("color", rgb(r, gr, b)), ("opacity", f(1.0)), ("visible", Kv::Bool(true))])
|
||
}
|
||
fn root_guid() -> Kv<'static> {
|
||
o(vec![("sessionID", u(0)), ("localID", u(0))])
|
||
}
|
||
fn alias_data(l: u64) -> Kv<'static> {
|
||
o(vec![("value", o(vec![("alias", o(vec![("guid", g(l))]))])), ("dataType", e("ALIAS"))])
|
||
}
|
||
fn prop_ref(def: u64, field: &'static str) -> Kv<'static> {
|
||
let data = o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", e("PROP_REF"))]);
|
||
o(vec![("entries", arr(vec![o(vec![("variableData", data), ("variableField", e(field))])]))])
|
||
}
|
||
|
||
/// A node: id (0: the document), parent (id, position), type, name and further fields.
|
||
fn node(id: u64, par: Option<(u64, &str)>, ty: &'static str, name: &str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||
let mut v = vec![("guid", if id == 0 { root_guid() } else { g(id) })];
|
||
if let Some((p, pos)) = par {
|
||
v.push(("parentIndex", o(vec![("guid", if p == 0 { root_guid() } else { g(p) }), ("position", s(pos))])));
|
||
}
|
||
v.push(("type", e(ty)));
|
||
v.push(("name", s(name)));
|
||
v.append(&mut more);
|
||
o(v)
|
||
}
|
||
|
||
/// A path blob: a closed triangle.
|
||
fn triangle_blob() -> Vec<u8> {
|
||
let mut b = Vec::new();
|
||
for (cmd, pts) in [(1u8, vec![0.0f32, 0.0]), (2, vec![10.0, 0.0]), (2, vec![5.0, 8.0]), (0, vec![])] {
|
||
b.push(cmd);
|
||
for p in pts {
|
||
b.extend_from_slice(&p.to_le_bytes());
|
||
}
|
||
}
|
||
b
|
||
}
|
||
|
||
/// A 2×1 PNG.
|
||
fn png() -> Vec<u8> {
|
||
let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255]));
|
||
let mut out = Vec::new();
|
||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||
out
|
||
}
|
||
|
||
const HASH: [u8; 20] = [0xab; 20];
|
||
|
||
/// The fixture message: a page with a frame holding a rectangle (image fill, click interaction),
|
||
/// a two-style text, a group with a triangle vector and a star, a layer with a linked fill style,
|
||
/// one with a variable-bound fill, and an instance of a component with a boolean property
|
||
/// (property off, child renamed); a flow and an explicit variable mode; an unsupported sticky
|
||
/// note; styles and variables on the internal page.
|
||
fn message() -> Kv<'static> {
|
||
let modes = arr(vec![
|
||
o(vec![("id", g(90)), ("name", s("Light")), ("sortPosition", s("!"))]),
|
||
o(vec![("id", g(91)), ("name", s("Dark")), ("sortPosition", s("\""))]),
|
||
]);
|
||
let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]);
|
||
let values = o(vec![(
|
||
"entries",
|
||
arr(vec![
|
||
o(vec![("modeID", g(90)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]),
|
||
o(vec![("modeID", g(91)), ("variableData", color_value(rgb(0.0, 0.0, 0.0)))]),
|
||
]),
|
||
)]);
|
||
let click = o(vec![
|
||
("event", o(vec![("interactionType", e("ON_CLICK"))])),
|
||
(
|
||
"actions",
|
||
arr(vec![o(vec![
|
||
("transitionNodeID", g(20)),
|
||
("transitionType", e("SMART_ANIMATE")),
|
||
("transitionDuration", f(0.25)),
|
||
("easingType", e("OUT_CUBIC")),
|
||
("connectionType", e("INTERNAL_NODE")),
|
||
("navigationType", e("NAVIGATE")),
|
||
])]),
|
||
),
|
||
]);
|
||
let font = |style: &str| o(vec![("family", s("Inter")), ("style", s(style)), ("postscript", s(""))]);
|
||
let text = o(vec![
|
||
("characters", s("Hi there")),
|
||
("characterStyleIDs", arr(vec![u(0), u(0), u(0), u(5), u(5), u(5)])),
|
||
("styleOverrideTable", arr(vec![o(vec![("fontName", font("Bold")), ("styleID", u(5))])])),
|
||
]);
|
||
let nodes = vec![
|
||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||
node(1, Some((0, "!")), "CANVAS", "Page A", vec![]),
|
||
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
|
||
node(3, Some((2, "!")), "RECTANGLE", "Brand", vec![("styleType", e("FILL")), ("fillPaints", arr(vec![solid(0.0, 0.0, 1.0)]))]),
|
||
node(4, Some((2, "\"")), "VARIABLE_SET", "Theme", vec![("variableSetModes", modes)]),
|
||
node(
|
||
5,
|
||
Some((2, "#")),
|
||
"VARIABLE",
|
||
"surface",
|
||
vec![("variableSetID", o(vec![("guid", g(4))])), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)],
|
||
),
|
||
// Listed before the frame, but its position puts it second.
|
||
node(
|
||
20,
|
||
Some((1, "\"")),
|
||
"SYMBOL",
|
||
"Button",
|
||
vec![
|
||
("size", xy(100.0, 40.0)),
|
||
("transform", at(500.0, 0.0)),
|
||
("fillPaints", arr(vec![solid(0.0, 1.0, 0.0)])),
|
||
(
|
||
"componentPropDefs",
|
||
arr(vec![o(vec![
|
||
("id", g(80)),
|
||
("name", s("Show icon")),
|
||
("initialValue", o(vec![("boolValue", Kv::Bool(true))])),
|
||
("type", e("BOOL")),
|
||
])]),
|
||
),
|
||
("stackMode", e("HORIZONTAL")),
|
||
("stackSpacing", f(8.0)),
|
||
("stackHorizontalPadding", f(12.0)),
|
||
("stackVerticalPadding", f(4.0)),
|
||
],
|
||
),
|
||
node(
|
||
21,
|
||
Some((20, "!")),
|
||
"ELLIPSE",
|
||
"Icon",
|
||
vec![("size", xy(16.0, 16.0)), ("transform", at(12.0, 12.0)), ("parameterConsumptionMap", prop_ref(80, "VISIBLE"))],
|
||
),
|
||
node(
|
||
10,
|
||
Some((1, "!")),
|
||
"FRAME",
|
||
"Screen",
|
||
vec![
|
||
("size", xy(400.0, 300.0)),
|
||
("fillPaints", arr(vec![solid(1.0, 1.0, 1.0)])),
|
||
("prototypeStartingPoint", o(vec![("name", s("Main flow")), ("position", s("!"))])),
|
||
(
|
||
"variableModeBySetMap",
|
||
o(vec![("entries", arr(vec![o(vec![("variableSetID", o(vec![("guid", g(4))])), ("variableModeID", g(91))])]))]),
|
||
),
|
||
],
|
||
),
|
||
node(
|
||
11,
|
||
Some((10, "!")),
|
||
"ROUNDED_RECTANGLE",
|
||
"Photo",
|
||
vec![
|
||
("size", xy(50.0, 40.0)),
|
||
("transform", at(10.0, 20.0)),
|
||
("cornerRadius", f(6.0)),
|
||
("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))])])),
|
||
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
|
||
("strokeWeight", f(2.0)),
|
||
("prototypeInteractions", arr(vec![click])),
|
||
],
|
||
),
|
||
node(
|
||
12,
|
||
Some((10, "\"")),
|
||
"TEXT",
|
||
"Title",
|
||
vec![
|
||
("size", xy(120.0, 20.0)),
|
||
("transform", at(10.0, 80.0)),
|
||
("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])),
|
||
("fontSize", f(16.0)),
|
||
("fontName", font("Regular")),
|
||
("textData", text),
|
||
],
|
||
),
|
||
node(13, Some((10, "#")), "FRAME", "Group", vec![("size", xy(30.0, 30.0)), ("transform", at(100.0, 100.0)), ("resizeToFit", Kv::Bool(true))]),
|
||
node(
|
||
14,
|
||
Some((13, "!")),
|
||
"VECTOR",
|
||
"Tri",
|
||
vec![
|
||
("size", xy(10.0, 8.0)),
|
||
("transform", at(5.0, 6.0)),
|
||
("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])),
|
||
("fillGeometry", arr(vec![o(vec![("windingRule", e("NONZERO")), ("commandsBlob", u(0))])])),
|
||
],
|
||
),
|
||
node(
|
||
15,
|
||
Some((13, "\"")),
|
||
"STAR",
|
||
"Star",
|
||
vec![("size", xy(20.0, 20.0)), ("transform", at(10.0, 10.0)), ("count", u(6)), ("starInnerScale", f(0.5))],
|
||
),
|
||
node(
|
||
16,
|
||
Some((10, "$")),
|
||
"ROUNDED_RECTANGLE",
|
||
"Styled",
|
||
vec![
|
||
("size", xy(10.0, 10.0)),
|
||
("transform", at(0.0, 200.0)),
|
||
// A stale copy of the style's paint: the style wins.
|
||
("fillPaints", arr(vec![solid(0.5, 0.5, 0.5)])),
|
||
("styleIdForFill", o(vec![("guid", g(3))])),
|
||
],
|
||
),
|
||
node(
|
||
17,
|
||
Some((10, "%")),
|
||
"ROUNDED_RECTANGLE",
|
||
"Card",
|
||
vec![
|
||
("size", xy(10.0, 10.0)),
|
||
("transform", at(20.0, 200.0)),
|
||
("fillPaints", arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 0.0)), ("colorVar", alias_data(5))])])),
|
||
],
|
||
),
|
||
node(
|
||
18,
|
||
Some((10, "&")),
|
||
"INSTANCE",
|
||
"Button copy",
|
||
vec![
|
||
("size", xy(100.0, 40.0)),
|
||
("transform", at(200.0, 200.0)),
|
||
("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])),
|
||
("stackMode", e("HORIZONTAL")),
|
||
(
|
||
"symbolData",
|
||
o(vec![
|
||
("symbolID", g(20)),
|
||
("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(21)]))])), ("name", s("Renamed icon"))])])),
|
||
]),
|
||
),
|
||
("componentPropAssignments", arr(vec![o(vec![("defID", g(80)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])),
|
||
],
|
||
),
|
||
node(19, Some((10, "'")), "STICKY", "Note", vec![]),
|
||
];
|
||
o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(triangle_blob()))])]))])
|
||
}
|
||
|
||
/// `canvas.fig`: magic, version, deflated schema, zstd message.
|
||
fn canvas(schema: &Schema, msg: &Kv) -> Vec<u8> {
|
||
let root = schema.find("Message").unwrap();
|
||
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||
z.write_all(&encode::schema(schema)).unwrap();
|
||
let schema_chunk = z.finish().unwrap();
|
||
let msg_chunk = ruzstd::encoding::compress_to_vec(&encode::message(schema, root, msg)[..], ruzstd::encoding::CompressionLevel::Fastest);
|
||
let mut out = b"fig-kiwi".to_vec();
|
||
out.extend_from_slice(&101u32.to_le_bytes());
|
||
for c in [schema_chunk, msg_chunk] {
|
||
out.extend_from_slice(&(c.len() as u32).to_le_bytes());
|
||
out.extend_from_slice(&c);
|
||
}
|
||
out
|
||
}
|
||
|
||
fn zip_file(canvas: &[u8]) -> Vec<u8> {
|
||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||
let image_name = format!("images/{}", HASH.iter().map(|b| format!("{b:02x}")).collect::<String>());
|
||
for (name, bytes) in [("canvas.fig", canvas.to_vec()), ("meta.json", br#"{"file_name":"Fixture"}"#.to_vec()), (image_name.as_str(), png())] {
|
||
z.start_file(name, stored).unwrap();
|
||
z.write_all(&bytes).unwrap();
|
||
}
|
||
z.finish().unwrap().into_inner()
|
||
}
|
||
|
||
fn fixture() -> Vec<u8> {
|
||
zip_file(&canvas(&schema(), &message()))
|
||
}
|
||
|
||
fn named<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::Node {
|
||
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"))
|
||
}
|
||
|
||
#[test]
|
||
fn kiwi_round_trip() {
|
||
let sc = schema();
|
||
let back = Schema::decode(&encode::schema(&sc)).unwrap();
|
||
assert_eq!(back.defs.len(), sc.defs.len());
|
||
for (a, b) in sc.defs.iter().zip(&back.defs) {
|
||
assert_eq!((a.name.as_str(), a.kind), (b.name.as_str(), b.kind));
|
||
let fields = |d: &Def| d.fields.iter().map(|f| (f.name.clone(), f.ty, f.array, f.value)).collect::<Vec<_>>();
|
||
assert_eq!(fields(a), fields(b));
|
||
}
|
||
let msg = message();
|
||
let root = sc.find("Message").unwrap();
|
||
assert_eq!(back.decode_message(root, &encode::message(&sc, root, &msg)).unwrap(), msg);
|
||
}
|
||
|
||
#[test]
|
||
fn kiwi_scalars() {
|
||
let names = ["f", "i", "u", "i64", "u64", "s", "b"];
|
||
let types = [Type::Float, Type::Int, Type::Uint, Type::Int64, Type::Uint64, Type::Str, Type::Bool];
|
||
let fields = names
|
||
.iter()
|
||
.zip(types)
|
||
.enumerate()
|
||
.map(|(i, (n, t))| Field { name: n.to_string(), ty: Some(t), array: false, value: i as u32 + 1 })
|
||
.collect();
|
||
let sc = Schema { defs: vec![Def { name: "M".into(), kind: Kind::Message, fields }] };
|
||
let cases = [
|
||
(0.0f32, 0i32, 0u32, 0i64, 0u64),
|
||
(-1.5, -7, 300, i64::MIN, u64::MAX),
|
||
(3.25e7, i32::MAX, u32::MAX, i64::MAX, 1 << 60),
|
||
(1e-40, i32::MIN, 1, -1, 127),
|
||
];
|
||
for (fv, iv, uv, i64v, u64v) in cases {
|
||
// Written by hand: the generic encoder goes through f64, which can't hold every 64-bit value.
|
||
let mut w = encode::Writer::default();
|
||
w.var_uint(1);
|
||
w.var_float(fv);
|
||
w.var_uint(2);
|
||
w.var_int(iv);
|
||
w.var_uint(3);
|
||
w.var_uint(uv);
|
||
w.var_uint(4);
|
||
w.var_int64(i64v);
|
||
w.var_uint(5);
|
||
w.var_uint64(u64v);
|
||
w.var_uint(6);
|
||
w.string("héllo");
|
||
w.var_uint(7);
|
||
w.byte(1);
|
||
w.var_uint(0);
|
||
let got = sc.decode_message(0, &w.0).unwrap();
|
||
// Denormals are stored as zero.
|
||
let want_f = if fv.abs() < f32::MIN_POSITIVE { 0.0 } else { fv };
|
||
assert_eq!(got.get("f"), Some(&Kv::Float(want_f)));
|
||
assert_eq!(got.get("i"), Some(&Kv::Int(i64::from(iv))));
|
||
assert_eq!(got.get("u"), Some(&Kv::Uint(u64::from(uv))));
|
||
assert_eq!(got.get("i64"), Some(&Kv::Int(i64v)));
|
||
assert_eq!(got.get("u64"), Some(&Kv::Uint(u64v)));
|
||
assert_eq!(got.get("s"), Some(&s("héllo")));
|
||
assert_eq!(got.get("b"), Some(&Kv::Bool(true)));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn imports_the_fixture() {
|
||
let bytes = fixture();
|
||
assert!(is_fig(&bytes));
|
||
let (doc, warnings) = import(&bytes, "fallback").unwrap();
|
||
assert_eq!(doc.name, "Fixture");
|
||
// The internal page is skipped.
|
||
assert_eq!(doc.pages.len(), 1);
|
||
let page = doc.node(doc.pages[0]).unwrap();
|
||
assert_eq!(page.name, "Page A");
|
||
// Siblings by position: the frame comes first even though it's listed after the component.
|
||
assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen");
|
||
let screen = named(&doc, "Screen");
|
||
let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
|
||
assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy"]);
|
||
// Image fill from the archive.
|
||
let photo = named(&doc, "Photo");
|
||
let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };
|
||
assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1)));
|
||
assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0));
|
||
// Text with a bold run over "the".
|
||
let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") };
|
||
assert_eq!(t.characters, "Hi there");
|
||
assert_eq!(t.style.font_size, 16.0);
|
||
assert_eq!(t.runs.len(), 1);
|
||
assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold"));
|
||
// The group's children keep their place on the page (group at 100, 100).
|
||
assert!(matches!(named(&doc, "Group").kind, NodeKind::Group));
|
||
let tri = named(&doc, "Tri");
|
||
assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty()));
|
||
let abs = doc.world_transform(tri.id).translation();
|
||
assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}");
|
||
assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. }));
|
||
// The linked style supplies the paint.
|
||
let styled = named(&doc, "Styled");
|
||
assert_eq!(styled.styles.fill.as_deref(), Some("1:3"));
|
||
assert!(doc.styles.iter().any(|s| s.name == "Brand"));
|
||
let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") };
|
||
assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0));
|
||
// Variables, the fill binding and the explicit mode.
|
||
assert_eq!(doc.variables.collections.len(), 1);
|
||
assert_eq!(doc.variables.variables[0].kind, VariableType::Color);
|
||
assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));
|
||
assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91"));
|
||
// The component keeps its property; the instance is expanded with the value applied.
|
||
let button = named(&doc, "Button");
|
||
assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon")));
|
||
let inst = named(&doc, "Button copy");
|
||
assert!(
|
||
matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false)))
|
||
);
|
||
let icon = doc.node(inst.children[0]).unwrap();
|
||
assert_eq!(icon.name, "Renamed icon");
|
||
assert!(!icon.visible);
|
||
// Prototype: the click navigates to the component; the flow starts at the screen.
|
||
let it = &photo.interactions[0];
|
||
assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id));
|
||
assert_eq!(it.duration_ms, 250);
|
||
let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") };
|
||
assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id));
|
||
// The sticky note isn't supported: a warning.
|
||
assert!(warnings.iter().any(|w| w.contains("STICKY")), "{warnings:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn bare_canvas_and_zlib_chunks_open() {
|
||
let sc = schema();
|
||
let c = canvas(&sc, &message());
|
||
let (doc, w) = import(&c, "Bare").unwrap();
|
||
assert_eq!(doc.name, "Bare");
|
||
// Without the archive the image is a placeholder, reported.
|
||
assert!(w.iter().any(|w| w.contains("isn't in the file")), "{w:?}");
|
||
// A zlib-wrapped schema chunk reads too.
|
||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||
z.write_all(&encode::schema(&sc)).unwrap();
|
||
let zl = z.finish().unwrap();
|
||
let parts = chunks(&c).unwrap();
|
||
let mut out = c[..12].to_vec();
|
||
for ch in [zl.as_slice(), parts[1]] {
|
||
out.extend_from_slice(&(ch.len() as u32).to_le_bytes());
|
||
out.extend_from_slice(ch);
|
||
}
|
||
assert!(import(&out, "Z").is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn other_files_are_not_fig() {
|
||
assert!(try_import(b"{\"document\": {}}", "x").is_none());
|
||
assert!(try_import(b"", "x").is_none());
|
||
let native = crate::save_bytes(&figcraft_doc::Document::new("N"), None).unwrap();
|
||
assert!(try_import(&native, "x").is_none());
|
||
}
|
||
|
||
/// A cheap deterministic generator for the mangle tests.
|
||
struct Lcg(u64);
|
||
impl Lcg {
|
||
fn next(&mut self) -> u64 {
|
||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||
self.0 >> 33
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mangled_files_never_panic() {
|
||
let bytes = fixture();
|
||
let c = canvas(&schema(), &message());
|
||
let mut r = Lcg(7);
|
||
for src in [&bytes, &c] {
|
||
for cut in (0..src.len()).step_by(37) {
|
||
let _ = import(&src[..cut], "x");
|
||
}
|
||
for _ in 0..300 {
|
||
let mut m = src.clone();
|
||
for _ in 0..1 + r.next() % 8 {
|
||
let i = (r.next() as usize) % m.len();
|
||
m[i] ^= 1 << (r.next() % 8);
|
||
}
|
||
let _ = import(&m, "x");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mangled_kiwi_never_panics() {
|
||
let sc = schema();
|
||
let root = sc.find("Message").unwrap();
|
||
let schema_bytes = encode::schema(&sc);
|
||
let msg = encode::message(&sc, root, &message());
|
||
let mut r = Lcg(11);
|
||
for cut in 0..schema_bytes.len() {
|
||
let _ = Schema::decode(&schema_bytes[..cut]);
|
||
}
|
||
for cut in (0..msg.len()).step_by(3) {
|
||
if let Ok(kv) = sc.decode_message(root, &msg[..cut]) {
|
||
let _ = map::to_rest(&kv, "x");
|
||
}
|
||
}
|
||
for _ in 0..2000 {
|
||
let mut m = msg.clone();
|
||
for _ in 0..1 + r.next() % 4 {
|
||
let i = (r.next() as usize) % m.len();
|
||
m[i] = r.next() as u8;
|
||
}
|
||
if let Ok(kv) = sc.decode_message(root, &m) {
|
||
let (json, _) = map::to_rest(&kv, "x");
|
||
let _ = crate::rest::import(&json, &crate::rest::no_images);
|
||
}
|
||
let mut sbytes = schema_bytes.clone();
|
||
let i = (r.next() as usize) % sbytes.len();
|
||
sbytes[i] = r.next() as u8;
|
||
if let Ok(s2) = Schema::decode(&sbytes)
|
||
&& let Some(rt) = s2.find("Message")
|
||
{
|
||
let _ = s2.decode_message(rt, &msg);
|
||
}
|
||
}
|
||
// Pure junk, bare and framed.
|
||
for _ in 0..500 {
|
||
let junk: Vec<u8> = (0..(r.next() % 200)).map(|_| r.next() as u8).collect();
|
||
let _ = Schema::decode(&junk);
|
||
let _ = sc.decode_message(root, &junk);
|
||
let mut framed = b"fig-kiwi\0\0\0\0".to_vec();
|
||
framed.extend_from_slice(&junk);
|
||
let _ = import(&framed, "x");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_input_is_refused() {
|
||
// A message that contains itself, nested past the depth cap.
|
||
let field = |ty, array| vec![Field { name: "m".into(), ty: Some(ty), array, value: 1 }];
|
||
let nested = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: field(Type::Def(0), false) }] };
|
||
assert!(nested.decode_message(0, &[1u8; 10_000]).is_err());
|
||
// An array count larger than the data.
|
||
let array = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: field(Type::Uint, true) }] };
|
||
assert!(array.decode_message(0, &[1, 0xff, 0xff, 0xff, 0xff, 0x0f]).is_err());
|
||
// A field type naming a missing definition.
|
||
let mut w = encode::Writer::default();
|
||
w.var_uint(1);
|
||
w.string("Message");
|
||
w.byte(2);
|
||
w.var_uint(1);
|
||
w.string("x");
|
||
w.var_int(5);
|
||
w.byte(0);
|
||
w.var_uint(1);
|
||
assert!(Schema::decode(&w.0).is_err());
|
||
// A schema without a Message definition.
|
||
let c = canvas(&Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: vec![] }] }, &Kv::Object(vec![]));
|
||
let renamed: Vec<u8> = {
|
||
let sc = Schema { defs: vec![Def { name: "Other".into(), kind: Kind::Message, fields: vec![] }] };
|
||
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||
z.write_all(&encode::schema(&sc)).unwrap();
|
||
let sch = z.finish().unwrap();
|
||
let mut out = c[..12].to_vec();
|
||
out.extend_from_slice(&(sch.len() as u32).to_le_bytes());
|
||
out.extend_from_slice(&sch);
|
||
out.extend_from_slice(&c[12 + 4 + chunks(&c).unwrap()[0].len()..]);
|
||
out
|
||
};
|
||
assert!(import(&c, "x").is_ok());
|
||
assert!(import(&renamed, "x").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn instances_inside_their_own_main_stop() {
|
||
let sym = node(5, Some((1, "!")), "SYMBOL", "Loop", vec![("size", xy(10.0, 10.0))]);
|
||
let inner = node(6, Some((5, "!")), "INSTANCE", "Self", vec![("symbolData", o(vec![("symbolID", g(5))]))]);
|
||
let doc_nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![]), sym, inner];
|
||
let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(doc_nodes))])), "x").unwrap();
|
||
assert!(doc.nodes().any(|n| n.name == "Self"));
|
||
}
|
||
|
||
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
|
||
fn samples() -> Vec<std::path::PathBuf> {
|
||
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
|
||
let dir = std::fs::read_dir(dir).into_iter().flatten().flatten();
|
||
dir.map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")).collect()
|
||
}
|
||
|
||
/// Local only (`FIGCRAFT_FIG_SAMPLES`): every file imports without warnings.
|
||
#[test]
|
||
fn local_samples_import() {
|
||
for p in samples() {
|
||
let bytes = std::fs::read(&p).unwrap();
|
||
let (doc, warnings) = import(&bytes, "sample").unwrap_or_else(|e| panic!("{}: {e}", p.display()));
|
||
assert!(warnings.is_empty(), "{}: {warnings:?}", p.display());
|
||
assert!(doc.node_count() > 5, "{}", p.display());
|
||
}
|
||
}
|
||
|
||
/// Local only: dumps each sample's schema, decoded message and REST JSON when `FIG_DUMP` names a
|
||
/// folder (for working on the importer).
|
||
#[test]
|
||
fn dump_samples() {
|
||
let Ok(out) = std::env::var("FIG_DUMP") else { return };
|
||
for p in samples() {
|
||
let bytes = std::fs::read(&p).unwrap();
|
||
let a = Archive::open(&bytes).unwrap();
|
||
let (schema, message) = decode_canvas(&a.canvas).unwrap();
|
||
let stem = p.file_stem().unwrap().to_string_lossy().to_string();
|
||
let mut txt = String::new();
|
||
for d in &schema.defs {
|
||
txt += &format!("{:?} {}\n", d.kind, d.name);
|
||
for f in &d.fields {
|
||
let ty = match f.ty {
|
||
Some(Type::Def(i)) => schema.defs[i].name.clone(),
|
||
Some(t) => format!("{t:?}"),
|
||
None => String::new(),
|
||
};
|
||
txt += &format!(" {} {}{} = {}\n", f.name, ty, if f.array { "[]" } else { "" }, f.value);
|
||
}
|
||
}
|
||
std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap();
|
||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||
std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap();
|
||
let (rest, _) = map::to_rest(&msg, &stem);
|
||
std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap();
|
||
}
|
||
}
|