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()); +}