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.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 20:59:00 -05:00
1 parent bdf06f1029
commit e74476f141
6 files changed
+642 -3

No files matched your search

Generated
+1
View File
@@ -1613,6 +1613,7 @@ name = "craft-penpot"
version = "0.1.0"
dependencies = [
"craft-zip",
"serde",
"serde_json",
"thiserror 2.0.21",
"zip",
+1
View File
@@ -11,6 +11,7 @@ repository.workspace = true
workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
craft-zip = { workspace = true }
+24 -3
View File
@@ -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.
+10
View File
@@ -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<T: serde::de::DeserializeOwned>(&mut self, path: &str) -> Result<T, Error> {
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<Value, Error> {
let data = self.read(path, MAX_JSON, MAX_JSON, || format!("JSON member too large: {path}"))?;
+465
View File
@@ -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<String, Value>,
}
};
}
/// 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<String> 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<Stop>,
}
}
record! {
/// An image a fill or stroke shows: `id` names the media record (`files/<file>/media/<id>.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/<file>/pages/<page>/<shape>.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<String>,
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<Point>,
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<Fill>,
strokes: Vec<Stroke>,
shadow: Vec<Shadow>,
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<Value>,
/// 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<Value>,
layout_grid_columns: Vec<Value>,
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<Value>,
/// Design tokens applied to the shape's properties: property → token name.
applied_tokens: BTreeMap<String, String>,
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/<file>/pages/<page>.json`).
Page {
id: String,
name: String,
index: f64,
/// `#rrggbb`.
background: String,
guides: BTreeMap<String, Guide>,
flows: BTreeMap<String, Flow>,
options: Value,
}
}
record! {
/// A file's header (`files/<file>.json`).
FileHeader {
id: String,
name: String,
features: Vec<String>,
version: f64,
migrations: Vec<Value>,
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<String>,
}
}
record! {
/// `manifest.json`.
Manifest {
#[serde(rename = "type")]
kind: String,
version: f64,
generated_by: String,
refer: String,
files: Vec<ManifestFile>,
relations: Vec<Value>,
}
}
record! {
/// A library colour (`files/<file>/colors/<id>.json`).
LibraryColor {
id: String,
name: String,
path: String,
color: String,
opacity: f64,
gradient: Gradient,
image: ImageRef,
}
}
record! {
/// A library typography (`files/<file>/typographies/<id>.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/<file>/components/<id>.json`).
Component {
id: String,
name: String,
path: String,
main_instance_id: String,
main_instance_page: String,
variant_id: String,
variant_properties: Vec<Value>,
}
}
record! {
/// A media record (`files/<file>/media/<id>.json`): `media_id` names the stored object.
Media {
id: String,
name: String,
width: f64,
height: f64,
mtype: String,
media_id: String,
is_local: bool,
}
}
+141
View File
@@ -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::<Shape>(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<u8>)> = 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::<Page>(&format!("files/{FILE}/pages/{PAGE}.json")).unwrap(), page);
assert_eq!(a.typed::<Shape>(&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::<Shape>("bad.json").unwrap_err().to_string();
assert!(e.starts_with("Penpot: bad.json: "), "{e}");
assert!(a.typed::<Shape>("missing.json").is_err());
assert!(a.check().is_ok());
}