mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
- Publish (`library.publish {description?, include?, exclude?}`, Libraries > Publish library…):
the file's components, component sets and styles (by default all but names starting with `.` or
`_`, or what the last publish offered) and every component and style they use go into a snapshot
with a manifest (library id, version, per-asset version stamps). An asset keeps the version it
last changed in; publishing without changes is refused.
- .figcraft: `library/manifest.json`, `library/document.json` (the snapshot) and
`library/images/<sha256>` for images only the library still uses. Older files and readers are
unaffected (extra members); a damaged library is skipped and reported when the author's file
opens. Files using a library read only these members (size-limited), never unpublished edits.
- Version history: the library entries are written and read alongside the versions (a library
survives saving with versions and reopening); restoring a version keeps what the file published.
- Enable: `library.add {path}` / `library.remove {id}` / `library.list {assets?}`; the document
keeps its enabled libraries (id, name, path, last seen version) in its data. The session caches
each library file and reads it again when it changes on disk; in the browser these commands
return a clear error (no file system).
- Use: `library.place {library, component, x?, y?}` / `library.import` copy the component (with
the components and styles it uses) into a hidden library store (a page-like root outside the
page list) as read-only main components and place an instance; instances behave like any other
(overrides, properties, variants, swap, reset, detach). `library.applyStyle` copies a style in as
a linked remote style. Editing the copies or library styles is refused; Go to main component
names the library. Importing a file's pages brings its library copies along (shared with copies
the document already has).
- Updates: `library.updates` and `library.acceptUpdates {ids?}` re-import changed copies in place
(layer ids kept, so instance overrides survive, also when layers are added); unused copies are
dropped when a library is removed or updated.
- Assets panel: each enabled library's components (thumbnails, search, click or drag to place,
shared with the local tile code) and styles (click to apply, paint swatches), collapsible per
library; "Library updates available" banner with Update; a libraries view (book button,
Libraries menu) to publish this file (choose assets, description) and add or remove library
files. Local styles in the panels list only this file's styles.
- Tests: manifest round trip and versions, add/place/update keeping overrides (incl. a structural
update), variants/swap/undo, library styles and importing pages, missing/corrupt/truncated/
oversized/never-published files; format tests for damaged and oversized members.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
6.8 KiB
Rust
144 lines
6.8 KiB
Rust
//! Published libraries inside `.figcraft` archives.
|
|
|
|
use std::io::{Read, Write};
|
|
|
|
use super::*;
|
|
use crate::{add_image, load_bytes, save_bytes};
|
|
use figcraft_doc::color::{ImageScaleMode, Paint};
|
|
use figcraft_doc::{Document, LibraryAsset, NodeKind};
|
|
|
|
fn png() -> Vec<u8> {
|
|
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 128, 255, 255]));
|
|
let mut out = Vec::new();
|
|
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
|
out
|
|
}
|
|
|
|
/// A published file: component "Tile" with an image fill; the file itself no longer uses the
|
|
/// image (it only lives on in the library).
|
|
fn published() -> (Document, figcraft_doc::NodeId, String) {
|
|
let mut d = Document::new("Kit");
|
|
let p = d.first_page().unwrap();
|
|
let tile = d.create(p, NodeKind::Component { description: String::new(), props: Default::default() }, "Tile", 0.0, 0.0, 10.0, 10.0).unwrap();
|
|
let (hash, _, _) = add_image(&mut d, png()).unwrap();
|
|
d.get_mut(tile).unwrap().fills = vec![Paint::image(hash.clone(), ImageScaleMode::Fill)];
|
|
let snap = d.clone();
|
|
d.images.clear();
|
|
d.get_mut(tile).unwrap().fills.clear();
|
|
let manifest = LibraryManifest {
|
|
id: "kit-1".into(),
|
|
name: "Kit".into(),
|
|
version: 3,
|
|
components: vec![
|
|
LibraryAsset { id: tile, name: "Tile".into(), set: false, version: 2, published: true },
|
|
LibraryAsset { id: figcraft_doc::NodeId(999), name: "Gone".into(), set: false, version: 1, published: true },
|
|
],
|
|
..Default::default()
|
|
};
|
|
d.published = Some(Arc::new(PublishedLibrary { manifest, doc: Arc::new(snap) }));
|
|
(d, tile, hash)
|
|
}
|
|
|
|
#[test]
|
|
fn library_roundtrip_with_its_own_images() {
|
|
let (d, tile, hash) = published();
|
|
let bytes = save_bytes(&d, None).unwrap();
|
|
// The author's file: its library comes back, with the image only the library uses.
|
|
let (back, issues) = load_bytes(&bytes).unwrap();
|
|
assert!(issues.is_empty(), "{issues:?}");
|
|
assert!(back.images.is_empty(), "the file itself doesn't use the image");
|
|
let lib = back.published.unwrap();
|
|
assert_eq!(lib.manifest.version, 3);
|
|
assert!(lib.doc.images.get(&hash).is_some_and(|i| (i.width, i.height) == (2, 2)));
|
|
assert!(lib.manifest.component(figcraft_doc::NodeId(999)).is_none(), "assets the snapshot lacks are dropped");
|
|
assert!(lib.manifest.component(tile).is_some());
|
|
// Files using it read only the library entries.
|
|
let read = read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
|
|
assert_eq!(read.manifest, lib.manifest);
|
|
assert!(read.doc.images.contains_key(&hash));
|
|
// Saving the loaded file again keeps everything.
|
|
let again = save_bytes(&back_with(&bytes), None).unwrap();
|
|
assert_eq!(read_library(std::io::Cursor::new(&again)).unwrap().unwrap().manifest, lib.manifest);
|
|
}
|
|
|
|
fn back_with(bytes: &[u8]) -> Document {
|
|
load_bytes(bytes).unwrap().0
|
|
}
|
|
|
|
/// `zip_bytes` with member `name` replaced by `bytes` (or removed when `None`).
|
|
fn replace_member(zip_bytes: &[u8], name: &str, bytes: Option<&[u8]>) -> Vec<u8> {
|
|
let mut src = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
|
|
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
|
for i in 0..src.len() {
|
|
let mut f = src.by_index(i).unwrap();
|
|
let n = f.name().to_string();
|
|
let mut data = Vec::new();
|
|
f.read_to_end(&mut data).unwrap();
|
|
let data = if n == name { bytes.map(<[u8]>::to_vec) } else { Some(data) };
|
|
if let Some(data) = data {
|
|
out.start_file(n.as_str(), zip::write::SimpleFileOptions::default()).unwrap();
|
|
out.write_all(&data).unwrap();
|
|
}
|
|
}
|
|
out.finish().unwrap().into_inner()
|
|
}
|
|
|
|
#[test]
|
|
fn damaged_libraries_are_errors_never_crashes() {
|
|
let (d, _, hash) = published();
|
|
let good = save_bytes(&d, None).unwrap();
|
|
let cases: [(&str, Option<&[u8]>); 6] = [
|
|
("library/manifest.json", Some(b"{nope")),
|
|
("library/manifest.json", Some(br#"{"id": "../../evil", "version": 1}"#)),
|
|
("library/manifest.json", Some(br#"{"id": "ok", "components": [{"id": "x"}]}"#)),
|
|
("library/document.json", Some(b"[1, 2]")),
|
|
("library/document.json", Some(br#"{"version": 999}"#)),
|
|
("library/document.json", None),
|
|
];
|
|
for (member, bytes) in cases {
|
|
let damaged = replace_member(&good, member, bytes);
|
|
assert!(read_library(std::io::Cursor::new(&damaged)).is_err(), "{member} {bytes:?}");
|
|
// The author's own file still opens: the library is skipped and reported.
|
|
let (doc, issues) = load_bytes(&damaged).unwrap();
|
|
assert!(doc.published.is_none() && issues.iter().any(|i| i.contains("library")), "{issues:?}");
|
|
}
|
|
// A missing image shows as a placeholder; the library still reads.
|
|
let no_image = replace_member(&good, &format!("library/images/{hash}"), None);
|
|
let lib = read_library(std::io::Cursor::new(&no_image)).unwrap().unwrap();
|
|
assert!(lib.doc.images.is_empty());
|
|
// Not an archive at all.
|
|
assert!(read_library(std::io::Cursor::new(b"PK\x03\x04 nope")).is_err());
|
|
assert!(read_library(std::io::Cursor::new(b"")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn oversized_members_are_refused() {
|
|
let (d, _, _) = published();
|
|
let good = save_bytes(&d, None).unwrap();
|
|
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&good)).unwrap();
|
|
assert!(read_member(&mut z, MANIFEST, 4).is_err_and(|e| e.to_string().contains("too large")));
|
|
assert!(read_member(&mut z, MANIFEST, MAX_MANIFEST).is_ok());
|
|
assert!(read_member(&mut z, "library/none.json", MAX_MANIFEST).is_err());
|
|
assert!(is_valid_library_id("a1_B-2") && !is_valid_library_id("") && !is_valid_library_id("a/b") && !is_valid_library_id(&"x".repeat(65)));
|
|
}
|
|
|
|
#[test]
|
|
fn library_survives_a_save_with_versions() {
|
|
let (d, tile, hash) = published();
|
|
let mut old = d.clone();
|
|
old.published = None;
|
|
let versions = [crate::Version::new("v1", "Before", "", 1, Arc::new(old))];
|
|
let bytes = crate::save_bytes_with_versions(&d, None, &versions).unwrap();
|
|
let loaded = crate::load_bytes_with_versions(&bytes).unwrap();
|
|
assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
|
|
assert_eq!(loaded.versions.len(), 1);
|
|
let lib = loaded.doc.published.clone().unwrap();
|
|
assert_eq!((lib.manifest.id.as_str(), lib.manifest.version), ("kit-1", 3));
|
|
assert!(lib.doc.images.contains_key(&hash) && lib.manifest.component(tile).is_some());
|
|
assert!(read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap().doc.images.contains_key(&hash));
|
|
// Saved again with its versions, it is still a library.
|
|
let again = crate::save_bytes_with_versions(&loaded.doc, None, &loaded.versions).unwrap();
|
|
assert_eq!(read_library(std::io::Cursor::new(&again)).unwrap().unwrap().manifest, lib.manifest);
|
|
assert_eq!(crate::load_bytes_with_versions(&again).unwrap().versions.len(), 1);
|
|
}
|