From e74476f14119706e63a3c7bd443c5529ec10fe4d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:59:00 -0500 Subject: [PATCH] craft-penpot: a typed model of the export's JSON members (#1039) Manifest, file header, page (guides, flows), shape (geometry, fills with gradients and images, strokes, shadows, blurs, radii, constraints, flex and grid layout fields, component links, applied tokens) and the library records (colours, typographies, components, media), named as the files name their fields. Every record keeps the fields it has no name for, so a member read and written loses nothing but nulls. Archive::typed reads a member as any of them within the archive's budgets. --- Cargo.lock | 1 + crates/penpot/Cargo.toml | 1 + crates/penpot/README.md | 27 +- crates/penpot/src/lib.rs | 10 + crates/penpot/src/model.rs | 465 +++++++++++++++++++++++++++++++ crates/penpot/src/model_tests.rs | 141 ++++++++++ 6 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 crates/penpot/src/model.rs create mode 100644 crates/penpot/src/model_tests.rs diff --git a/Cargo.lock b/Cargo.lock index d16faa2cd..3a66892d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1613,6 +1613,7 @@ name = "craft-penpot" version = "0.1.0" dependencies = [ "craft-zip", + "serde", "serde_json", "thiserror 2.0.21", "zip", diff --git a/crates/penpot/Cargo.toml b/crates/penpot/Cargo.toml index d693f771b..ad42b3992 100644 --- a/crates/penpot/Cargo.toml +++ b/crates/penpot/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true workspace = true [dependencies] +serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } craft-zip = { workspace = true } diff --git a/crates/penpot/README.md b/crates/penpot/README.md index cfcb6da96..95b0ba9df 100644 --- a/crates/penpot/README.md +++ b/crates/penpot/README.md @@ -91,8 +91,29 @@ fail the archive. Ids are checked to be UUIDs before they are used in a member p The archive is read with the `zip` crate directly, with the bounds above applied to each member. A shared bounded ZIP reader is planned (craft-libs #126); this crate moves to it when it lands. +## Typed members (`model`) + +`model` types the members as the files name their fields: `Manifest`, `FileHeader`, `Page` (with +its `Guide`s and `Flow`s), `Shape` (geometry, `SelRect`, `Matrix`, points, `Fill`s with colours, +`Gradient`s and `ImageRef`s, `Stroke`s, `Shadow`s, `Blur`s, corner radii, constraints, flex and +grid layout fields, component links, applied tokens), and the library records `LibraryColor`, +`Typography`, `Component` and `Media`. `ShapeType`, `GradientType` and `BlendMode` keep values +they don't know (`Other`). + +```rust +let page: craft_penpot::model::Page = archive.typed(&format!("files/{file}/pages/{page_id}.json"))?; +let shape: craft_penpot::model::Shape = archive.typed(&shape_path)?; +let bytes = serde_json::to_vec(&shape)?; // written back as the file had it +``` + +Every record keeps the fields it has no name for in `extra` and writes them back: a member read +and written loses only `null` fields, and whole numbers come back as floats. Kept as JSON: text +and path `content`, SVG attributes, interactions, display grids, grid tracks and cells, tokens +(`tokens.json`), and typography sizes (numbers or numeric strings in the files). A field of the +wrong type fails `typed`, naming the member; a reader that must skip bad fields one by one reads +`json` instead. `typed` reads within the same byte and value budgets as `json`. Shapes are flat +(children by id), so no record nests deeper than the JSON itself. + ## Not here yet -A typed model of the shapes (frames, groups, paths, text content, fills and strokes, layout, -components and variants, tokens, interactions). FigCraft maps the JSON straight onto its own -document today; a model mirroring the file's JSON is the next step. +Typed text and path `content`, interactions and tokens. diff --git a/crates/penpot/src/lib.rs b/crates/penpot/src/lib.rs index ac6b045f3..aafc555c3 100644 --- a/crates/penpot/src/lib.rs +++ b/crates/penpot/src/lib.rs @@ -24,6 +24,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod model; +#[cfg(test)] +mod model_tests; #[cfg(test)] mod tests; @@ -247,6 +250,13 @@ impl<'b> Archive<'b> { if found.len() > max { Err(found.into_iter().take(max).collect()) } else { Ok(found) } } + /// JSON member `path` read as a record of [`model`] (or any type serde reads), within the + /// same budgets as [`Self::json`]. A field of the wrong type fails the read. + pub fn typed(&mut self, path: &str) -> Result { + let value = self.json(path)?; + serde_json::from_value(value).map_err(|e| corrupt(format!("{path}: {e}"))) + } + /// JSON member `path`, within [`MAX_JSON`] and the archive's budgets. pub fn json(&mut self, path: &str) -> Result { let data = self.read(path, MAX_JSON, MAX_JSON, || format!("JSON member too large: {path}"))?; diff --git a/crates/penpot/src/model.rs b/crates/penpot/src/model.rs new file mode 100644 index 000000000..19553d4ab --- /dev/null +++ b/crates/penpot/src/model.rs @@ -0,0 +1,465 @@ +//! A typed model of the JSON members of a Penpot export, named as the files name their fields. +//! +//! Every record keeps the fields it has no name for in `extra`, in the JSON they came in, and +//! writes them back: a member read and written loses nothing but `null` fields (left out), and +//! whole numbers come back as floats (`10` as `10.0`). Enumerations keep values they don't know +//! (`Other`). A field of the wrong type fails the read; a reader that must skip bad fields one by +//! one reads the [`serde_json::Value`] instead. +//! +//! Typography sizes and spacings are numbers or numeric strings in the files; they are kept as +//! [`serde_json::Value`]. Text and path `content`, SVG attributes, interactions and layout grid +//! cells are kept as JSON too. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// A record: every field optional, plus `extra` for the fields this model has no name for. +macro_rules! record { + ($(#[$meta:meta])* $name:ident { $($(#[$fmeta:meta])* $field:ident: $ty:ty,)* }) => { + $(#[$meta])* + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct $name { + $( + $(#[$fmeta])* + #[serde(default, skip_serializing_if = "Option::is_none")] + pub $field: Option<$ty>, + )* + /// Fields this model has no name for, as the JSON they came in. + #[serde(flatten)] + pub extra: Map, + } + }; +} + +/// An enumeration Penpot may add values to: the values known here, and `Other` for the rest, +/// written back as it was read. +macro_rules! open_enum { + ($(#[$meta:meta])* $name:ident { $($variant:ident = $text:literal,)* }) => { + $(#[$meta])* + #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(from = "String", into = "String")] + pub enum $name { + $($variant,)* + /// A value this model has no name for. + Other(String), + } + + impl $name { + /// The value as the files write it. + pub fn as_str(&self) -> &str { + match self { + $($name::$variant => $text,)* + $name::Other(s) => s, + } + } + } + + impl From for $name { + fn from(s: String) -> Self { + match s.as_str() { + $($text => $name::$variant,)* + _ => $name::Other(s), + } + } + } + + impl From<$name> for String { + fn from(v: $name) -> String { + match v { + $name::Other(s) => s, + known => known.as_str().to_string(), + } + } + } + }; +} + +open_enum! { + /// A shape's `type`. + ShapeType { + Frame = "frame", Group = "group", Rect = "rect", Circle = "circle", Path = "path", Text = "text", Image = "image", + SvgRaw = "svg-raw", Bool = "bool", + } +} + +open_enum! { + /// A gradient's `type`. + GradientType { Linear = "linear", Radial = "radial", } +} + +open_enum! { + /// How a shape or a shadow blends with what is under it. + BlendMode { + Normal = "normal", Darken = "darken", Multiply = "multiply", ColorBurn = "color-burn", Lighten = "lighten", Screen = "screen", + ColorDodge = "color-dodge", Overlay = "overlay", SoftLight = "soft-light", HardLight = "hard-light", Difference = "difference", + Exclusion = "exclusion", Hue = "hue", Saturation = "saturation", Color = "color", Luminosity = "luminosity", + } +} + +/// A point. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +/// An affine matrix: `[a c e; b d f]`. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Matrix { + pub a: f64, + pub b: f64, + pub c: f64, + pub d: f64, + pub e: f64, + pub f: f64, +} + +impl Default for Matrix { + fn default() -> Self { + Matrix { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0 } + } +} + +record! { + /// A selection rectangle: its box and its corners. + SelRect { + x: f64, + y: f64, + width: f64, + height: f64, + x1: f64, + y1: f64, + x2: f64, + y2: f64, + } +} + +record! { + /// A gradient stop. + Stop { + /// `#rrggbb`. + color: String, + opacity: f64, + offset: f64, + } +} + +record! { + /// A linear or radial gradient, in 0–1 of the shape's box. + Gradient { + #[serde(rename = "type")] + kind: GradientType, + start_x: f64, + start_y: f64, + end_x: f64, + end_y: f64, + width: f64, + stops: Vec, + } +} + +record! { + /// An image a fill or stroke shows: `id` names the media record (`files//media/.json`). + ImageRef { + id: String, + name: String, + width: f64, + height: f64, + mtype: String, + keep_aspect_ratio: bool, + } +} + +record! { + /// One fill. + Fill { + /// `#rrggbb`. + fill_color: String, + fill_opacity: f64, + fill_color_gradient: Gradient, + fill_image: ImageRef, + /// A library colour the fill uses: its id and file. + fill_color_ref_id: String, + fill_color_ref_file: String, + } +} + +record! { + /// One stroke. + Stroke { + stroke_color: String, + stroke_opacity: f64, + stroke_width: f64, + /// `inner`, `center` or `outer`. + stroke_alignment: String, + /// `solid`, `dotted`, `dashed`, `mixed` or `none`. + stroke_style: String, + /// `round`, `square`, `line-arrow`, `triangle-arrow`, `circle-marker`, `diamond-marker`, … + stroke_cap_start: String, + stroke_cap_end: String, + stroke_color_gradient: Gradient, + stroke_image: ImageRef, + stroke_color_ref_id: String, + stroke_color_ref_file: String, + } +} + +record! { + /// A shadow's colour. + ShadowColor { + color: String, + opacity: f64, + } +} + +record! { + /// One shadow. + Shadow { + id: String, + /// `drop-shadow` or `inner-shadow`. + style: String, + offset_x: f64, + offset_y: f64, + blur: f64, + spread: f64, + hidden: bool, + color: ShadowColor, + } +} + +record! { + /// A blur. + Blur { + id: String, + /// `layer-blur` or `background-blur`. + #[serde(rename = "type")] + kind: String, + value: f64, + hidden: bool, + } +} + +record! { + /// One shape (`files//pages//.json`). One record for every shape type: which + /// fields a shape carries follows from its `type`. + Shape { + id: String, + name: String, + #[serde(rename = "type")] + kind: ShapeType, + /// The shape it is in, and the frame it is in (both the page's root frame on top). + parent_id: String, + frame_id: String, + page_id: String, + /// Its children's ids, bottom first (frames, groups and booleans). + shapes: Vec, + x: f64, + y: f64, + width: f64, + height: f64, + /// In degrees. + rotation: f64, + selrect: SelRect, + /// Its corners on the page: top left, top right, bottom right, bottom left. + points: Vec, + transform: Matrix, + transform_inverse: Matrix, + flip_x: bool, + flip_y: bool, + proportion_lock: bool, + hidden: bool, + blocked: bool, + opacity: f64, + blend_mode: BlendMode, + fills: Vec, + strokes: Vec, + shadow: Vec, + blur: Blur, + background_blur: Blur, + /// Corner radii: top left, top right, bottom right, bottom left. + r1: f64, + r2: f64, + r3: f64, + r4: f64, + rx: f64, + ry: f64, + /// Text (a tree of root, paragraph sets, paragraphs and spans) or path commands. + content: Value, + /// Texts: `fixed`, `auto-width` or `auto-height`. + grow_type: String, + /// Booleans: `union`, `difference`, `intersection` or `exclude`. + bool_type: String, + masked_group: bool, + /// `left`, `right`, `leftright`, `center`, `scale`; and `top`, `bottom`, `topbottom`, …. + constraints_h: String, + constraints_v: String, + /// Display grids. + grids: Vec, + /// Flex or grid layout: `flex` or `grid`. + layout: String, + /// `row`, `row-reverse`, `column`, `column-reverse`. + layout_flex_dir: String, + layout_gap: Value, + layout_gap_type: String, + layout_padding: Value, + layout_padding_type: String, + layout_wrap_type: String, + layout_justify_content: String, + layout_align_items: String, + layout_align_content: String, + layout_grid_dir: String, + layout_grid_rows: Vec, + layout_grid_columns: Vec, + layout_grid_cells: Value, + layout_item_h_sizing: String, + layout_item_v_sizing: String, + layout_item_absolute: bool, + layout_item_margin: Value, + layout_item_align_self: String, + /// Components: the component a copy is of, its file, and whether this is the main. + component_id: String, + component_file: String, + component_root: bool, + main_instance: bool, + shape_ref: String, + svg_attrs: Value, + interactions: Vec, + /// Design tokens applied to the shape's properties: property → token name. + applied_tokens: BTreeMap, + hide_fill_on_export: bool, + } +} + +record! { + /// A ruler guide of a page. + Guide { + id: String, + /// `x` (vertical) or `y` (horizontal). + axis: String, + position: f64, + frame_id: String, + } +} + +record! { + /// A prototype flow of a page. + Flow { + id: String, + name: String, + starting_frame: String, + } +} + +record! { + /// A page (`files//pages/.json`). + Page { + id: String, + name: String, + index: f64, + /// `#rrggbb`. + background: String, + guides: BTreeMap, + flows: BTreeMap, + options: Value, + } +} + +record! { + /// A file's header (`files/.json`). + FileHeader { + id: String, + name: String, + features: Vec, + version: f64, + migrations: Vec, + options: Value, + is_shared: bool, + created_at: String, + modified_at: String, + revn: f64, + vern: f64, + } +} + +record! { + /// One entry of the manifest's `files`. + ManifestFile { + id: String, + name: String, + features: Vec, + } +} + +record! { + /// `manifest.json`. + Manifest { + #[serde(rename = "type")] + kind: String, + version: f64, + generated_by: String, + refer: String, + files: Vec, + relations: Vec, + } +} + +record! { + /// A library colour (`files//colors/.json`). + LibraryColor { + id: String, + name: String, + path: String, + color: String, + opacity: f64, + gradient: Gradient, + image: ImageRef, + } +} + +record! { + /// A library typography (`files//typographies/.json`). Sizes are numbers or numeric + /// strings. + Typography { + id: String, + name: String, + path: String, + font_id: String, + font_family: String, + font_variant_id: String, + font_size: Value, + font_weight: Value, + font_style: String, + line_height: Value, + letter_spacing: Value, + text_transform: String, + } +} + +record! { + /// A component (`files//components/.json`). + Component { + id: String, + name: String, + path: String, + main_instance_id: String, + main_instance_page: String, + variant_id: String, + variant_properties: Vec, + } +} + +record! { + /// A media record (`files//media/.json`): `media_id` names the stored object. + Media { + id: String, + name: String, + width: f64, + height: f64, + mtype: String, + media_id: String, + is_local: bool, + } +} diff --git a/crates/penpot/src/model_tests.rs b/crates/penpot/src/model_tests.rs new file mode 100644 index 000000000..55ca03573 --- /dev/null +++ b/crates/penpot/src/model_tests.rs @@ -0,0 +1,141 @@ +//! The typed model on members written by hand in the shape Penpot exports them. +#![allow(clippy::unwrap_used, clippy::panic)] + +use serde_json::{Value, json}; + +use super::model::*; +use super::*; + +const FILE: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; +const PAGE: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; +const SHAPE: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + +/// A rectangle as an export writes it: geometry, paints, effects, layout child fields, tokens, +/// and fields this model has no name for. +fn rect() -> Value { + json!({ + "id": SHAPE, "name": "Card", "type": "rect", "parentId": ROOT_ID, "frameId": ROOT_ID, "pageId": PAGE, + "x": 10.0, "y": 20.0, "width": 100.0, "height": 50.0, "rotation": 0.0, + "selrect": {"x": 10.0, "y": 20.0, "width": 100.0, "height": 50.0, "x1": 10.0, "y1": 20.0, "x2": 110.0, "y2": 70.0}, + "points": [{"x": 10.0, "y": 20.0}, {"x": 110.0, "y": 20.0}, {"x": 110.0, "y": 70.0}, {"x": 10.0, "y": 70.0}], + "transform": {"a": 1.0, "b": 0.0, "c": 0.0, "d": 1.0, "e": 0.0, "f": 0.0}, + "transformInverse": {"a": 1.0, "b": 0.0, "c": 0.0, "d": 1.0, "e": 0.0, "f": 0.0}, + "proportionLock": false, "hidden": false, "blocked": false, "opacity": 0.9, "blendMode": "multiply", + "fills": [ + {"fillColor": "#ff0000", "fillOpacity": 1.0}, + {"fillColorGradient": {"type": "linear", "startX": 0.5, "startY": 0.0, "endX": 0.5, "endY": 1.0, "width": 1.0, + "stops": [{"color": "#000000", "opacity": 1.0, "offset": 0.0}, {"color": "#ffffff", "opacity": 0.0, "offset": 1.0}]}}, + {"fillImage": {"id": "dddddddd-dddd-dddd-dddd-dddddddddddd", "width": 2.0, "height": 1.0, "mtype": "image/png", "keepAspectRatio": true}}, + ], + "strokes": [{"strokeColor": "#000000", "strokeOpacity": 1.0, "strokeWidth": 2.0, "strokeAlignment": "inner", "strokeStyle": "dashed"}], + "shadow": [{"id": "e1", "style": "drop-shadow", "offsetX": 0.0, "offsetY": 2.0, "blur": 4.0, "spread": 0.0, "hidden": false, + "color": {"color": "#000000", "opacity": 0.25}}], + "blur": {"id": "e2", "type": "layer-blur", "value": 3.0, "hidden": false}, + "r1": 8.0, "r2": 8.0, "r3": 0.0, "r4": 0.0, + "constraintsH": "leftright", "constraintsV": "top", + "layoutItemHSizing": "fill", "layoutItemVSizing": "fix", "layoutItemMargin": {"m1": 4, "m2": 0, "m3": 4, "m4": 0}, + "appliedTokens": {"fill": "color.brand", "r1": "radius.md"}, + "interactions": [{"eventType": "click", "actionType": "navigate", "destination": PAGE}], + "pluginData": {"anything": "kept"}, + "flipX": null, + }) +} + +#[test] +fn a_shape_reads_into_the_typed_records() { + let s: Shape = serde_json::from_value(rect()).unwrap(); + assert_eq!((s.kind.clone(), s.parent_id.as_deref()), (Some(ShapeType::Rect), Some(ROOT_ID))); + assert_eq!(s.selrect.as_ref().unwrap().x2, Some(110.0)); + assert_eq!(s.points.as_ref().map(Vec::len), Some(4)); + assert_eq!(s.transform, Some(Matrix::default())); + assert_eq!(s.blend_mode, Some(BlendMode::Multiply)); + let fills = s.fills.as_ref().unwrap(); + assert_eq!(fills[0].fill_color.as_deref(), Some("#ff0000")); + let g = fills[1].fill_color_gradient.as_ref().unwrap(); + assert_eq!((g.kind.clone(), g.stops.as_ref().map(Vec::len)), (Some(GradientType::Linear), Some(2))); + assert_eq!(fills[2].fill_image.as_ref().unwrap().keep_aspect_ratio, Some(true)); + assert_eq!(s.strokes.as_ref().unwrap()[0].stroke_style.as_deref(), Some("dashed")); + assert_eq!(s.shadow.as_ref().unwrap()[0].color.as_ref().unwrap().opacity, Some(0.25)); + assert_eq!(s.blur.as_ref().unwrap().kind.as_deref(), Some("layer-blur")); + assert_eq!((s.r1, s.r4), (Some(8.0), Some(0.0))); + assert_eq!(s.applied_tokens.as_ref().unwrap()["fill"], "color.brand"); + assert_eq!(s.layout_item_margin.as_ref().unwrap()["m1"], 4); + assert_eq!(s.extra["pluginData"]["anything"], "kept"); + assert_eq!(s.flip_x, None); +} + +#[test] +fn nothing_but_nulls_is_lost_between_reading_and_writing() { + let original = rect(); + let s: Shape = serde_json::from_value(original.clone()).unwrap(); + let mut want = original; + want.as_object_mut().unwrap().remove("flipX"); + assert_eq!(serde_json::to_value(&s).unwrap(), want); + // Values this model doesn't know stay as they were. + let odd = json!({"type": "hologram", "blendMode": "plus-darker", "fills": [{"fillColorGradient": {"type": "conic"}}]}); + let s: Shape = serde_json::from_value(odd.clone()).unwrap(); + assert_eq!(s.kind, Some(ShapeType::Other("hologram".into()))); + assert_eq!(serde_json::to_value(&s).unwrap(), odd); +} + +#[test] +fn pages_libraries_and_the_manifest_read() { + let page: Page = serde_json::from_value(json!({"id": PAGE, "name": "Canvas", "index": 0, "background": "#f0f0f0", + "guides": {"g1": {"id": "g1", "axis": "x", "position": 120.0}}, + "flows": {"f1": {"id": "f1", "name": "Flow 1", "startingFrame": SHAPE}}})) + .unwrap(); + assert_eq!(page.guides.as_ref().unwrap()["g1"].axis.as_deref(), Some("x")); + assert_eq!(page.flows.as_ref().unwrap()["f1"].starting_frame.as_deref(), Some(SHAPE)); + // Typography sizes come as numbers or as strings. + let t: Typography = + serde_json::from_value(json!({"id": "t", "name": "Body", "fontFamily": "Inter", "fontSize": "14", "fontWeight": 400, "lineHeight": "1.2"})) + .unwrap(); + assert_eq!((t.font_size, t.font_weight), (Some(json!("14")), Some(json!(400)))); + let m: Manifest = serde_json::from_value(manifest("craft-penpot/test", &[(FILE, "File", json!(["components/v2"]))])).unwrap(); + assert_eq!((m.kind.as_deref(), m.version), (Some(MANIFEST_TYPE), Some(1.0))); + assert_eq!(m.files.as_ref().unwrap()[0].features.as_deref(), Some(&["components/v2".to_string()][..])); + let c: Component = serde_json::from_value(json!({"id": "k", "name": "Button", "mainInstanceId": SHAPE, "mainInstancePage": PAGE})).unwrap(); + assert_eq!(c.main_instance_id.as_deref(), Some(SHAPE)); + let media: Media = + serde_json::from_value(json!({"id": "m", "mediaId": "o", "mtype": "image/png", "width": 2, "height": 1, "isLocal": true})).unwrap(); + assert_eq!((media.media_id.as_deref(), media.width), (Some("o"), Some(2.0))); +} + +#[test] +fn a_field_of_the_wrong_type_fails_the_read_and_says_which() { + for (bad, want) in [ + (json!({"x": "ten"}), "f64"), + (json!({"shapes": "abc"}), "sequence"), + (json!({"fills": [{"fillOpacity": []}]}), "f64"), + (json!({"transform": {"a": 1}}), "missing field"), + (json!({"appliedTokens": {"fill": 3}}), "string"), + ] { + let e = serde_json::from_value::(bad).unwrap_err().to_string(); + assert!(e.contains(want), "{e}"); + } +} + +#[test] +fn the_archive_reads_and_writes_typed_members() { + let shape: Shape = serde_json::from_value(rect()).unwrap(); + let root = Shape { id: Some(ROOT_ID.into()), kind: Some(ShapeType::Frame), shapes: Some(vec![SHAPE.into()]), ..Shape::default() }; + let page = Page { id: Some(PAGE.into()), name: Some("Canvas".into()), index: Some(0.0), ..Page::default() }; + let members: Vec<(String, Vec)> = vec![ + ("manifest.json".into(), serde_json::to_vec(&manifest("craft-penpot/test", &[(FILE, "File", json!([]))])).unwrap()), + (format!("files/{FILE}/pages/{PAGE}.json"), serde_json::to_vec(&page).unwrap()), + (format!("files/{FILE}/pages/{PAGE}/{ROOT_ID}.json"), serde_json::to_vec(&root).unwrap()), + (format!("files/{FILE}/pages/{PAGE}/{SHAPE}.json"), serde_json::to_vec(&shape).unwrap()), + ("bad.json".into(), br#"{"x": "ten"}"#.to_vec()), + ]; + let bytes = write_archive(members.iter().map(|(p, b)| (p.as_str(), b.as_slice()))).unwrap(); + let mut a = Archive::open(&bytes).unwrap(); + assert_eq!(a.typed::(&format!("files/{FILE}/pages/{PAGE}.json")).unwrap(), page); + assert_eq!(a.typed::(&format!("files/{FILE}/pages/{PAGE}/{SHAPE}.json")).unwrap(), shape); + let m: Manifest = a.typed("manifest.json").unwrap(); + assert_eq!(m.files.unwrap()[0].id.as_deref(), Some(FILE)); + // Within the archive's budgets, and failing on a wrong type with the member's name. + let e = a.typed::("bad.json").unwrap_err().to_string(); + assert!(e.starts_with("Penpot: bad.json: "), "{e}"); + assert!(a.typed::("missing.json").is_err()); + assert!(a.check().is_ok()); +}