mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 04:46:50 -06:00
Canvas previews zoomed out to 50% or less draw levels of detail (crates/render/src/lod.rs): subtrees under 2 device px are splatted as their averaged paint colours (box filter, no paths or effects), text under 4 px is greeked into bars, small solid rectangles are splatted and rectangular clip layers are composited without the blend pipeline. Image paints and masks keep the exact path. Exports, thumbnails and every other render stay exact (Options::lod is off by default); render bands are assembled with a row copy instead of a pixmap draw. .figcraft documents are serialized straight into the compressor and parsed straight from the text (a header pass reads version and image index without building a value tree); layer and text fields at their default values are left out (63 -> 22 MB of JSON at 50k layers, older files still load); JSON members use deflate level 3. At 50k layers: zoomed-to-fit render ~58 -> ~8 ms, save ~1.0 s -> ~0.09 s, load ~0.86 s -> ~0.16 s. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
5.1 KiB
Rust
106 lines
5.1 KiB
Rust
//! A published library inside a `.figcraft` archive.
|
||
//!
|
||
//! `library/manifest.json` holds the [`LibraryManifest`]; `library/document.json` the snapshot of
|
||
//! the published assets (a `document.json` of its own). Images the snapshot uses are read from
|
||
//! `images/` (the file's own images) or, when the file itself no longer uses them,
|
||
//! `library/images/<sha256>`. Readers that don't know about libraries ignore these entries; files
|
||
//! without them load unchanged.
|
||
//!
|
||
//! Files using a library read only these entries ([`read_library`]), with size limits on each,
|
||
//! so a missing, damaged or oversized library file is an error, never a crash.
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::io::{Read, Seek, Write};
|
||
use std::sync::Arc;
|
||
|
||
use figcraft_doc::{ImageData, LibraryManifest, NodeKind, PublishedLibrary, used_images};
|
||
|
||
use crate::{FormatError, MAX_MEMBER, from_json, image_data, io_err, json_options, put, put_json, read_member};
|
||
|
||
const MANIFEST: &str = "library/manifest.json";
|
||
const SNAPSHOT: &str = "library/document.json";
|
||
const IMAGES: &str = "library/images/";
|
||
/// Largest manifest read.
|
||
const MAX_MANIFEST: u64 = 16 * 1024 * 1024;
|
||
/// Most bytes of images read for one library.
|
||
const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024;
|
||
/// Longest library id and name kept.
|
||
const MAX_ID: usize = 64;
|
||
const MAX_NAME: usize = 200;
|
||
const MAX_DESCRIPTION: usize = 2000;
|
||
|
||
fn corrupt(e: impl std::fmt::Display) -> FormatError {
|
||
FormatError::Corrupt(e.to_string())
|
||
}
|
||
|
||
/// Whether `id` can be a library id: 1–64 ASCII letters, digits, `-` and `_`.
|
||
pub fn is_valid_library_id(id: &str) -> bool {
|
||
(1..=MAX_ID).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||
}
|
||
|
||
/// Image hashes become archive member names: only plain hex-like names are looked up.
|
||
fn is_valid_hash(h: &str) -> bool {
|
||
(1..=128).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric())
|
||
}
|
||
|
||
/// Write the published library's entries. `written` are the images already in `images/`.
|
||
pub(crate) fn write<W: Write + Seek>(z: &mut zip::ZipWriter<W>, lib: &PublishedLibrary, written: &BTreeSet<&String>) -> Result<(), FormatError> {
|
||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||
put(z, MANIFEST, json_options(), &serde_json::to_vec(&lib.manifest).map_err(io_err)?)?;
|
||
put_json(z, SNAPSHOT, &lib.doc)?;
|
||
for (hash, img) in &lib.doc.images {
|
||
if !written.contains(hash) && is_valid_hash(hash) {
|
||
put(z, &format!("{IMAGES}{hash}"), stored, &img.bytes)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The published library in an archive (`None` when the file was never published). `own` are
|
||
/// the images already read from `images/`.
|
||
pub(crate) fn read<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: &BTreeMap<String, ImageData>) -> Result<Option<PublishedLibrary>, FormatError> {
|
||
if z.index_for_name(MANIFEST).is_none() {
|
||
return Ok(None);
|
||
}
|
||
let mut manifest: LibraryManifest = serde_json::from_slice(&read_member(z, MANIFEST, MAX_MANIFEST)?).map_err(corrupt)?;
|
||
if !is_valid_library_id(&manifest.id) {
|
||
return Err(corrupt("the library has an invalid id"));
|
||
}
|
||
let text = String::from_utf8(read_member(z, SNAPSHOT, MAX_MEMBER)?).map_err(corrupt)?;
|
||
let (mut doc, _) = from_json(&text)?;
|
||
let mut budget = MAX_IMAGE_BYTES;
|
||
for hash in used_images(&doc) {
|
||
if !is_valid_hash(&hash) {
|
||
continue;
|
||
}
|
||
if let Some(img) = own.get(&hash) {
|
||
doc.images.insert(hash, img.clone());
|
||
continue;
|
||
}
|
||
let name = [format!("{IMAGES}{hash}"), format!("images/{hash}")].into_iter().find(|n| z.index_for_name(n).is_some());
|
||
// A missing or oversized image shows as a placeholder, like in any other file.
|
||
let Some(data) = name.and_then(|n| read_member(z, &n, budget.min(MAX_MEMBER)).ok()) else { continue };
|
||
budget = budget.saturating_sub(data.len() as u64);
|
||
doc.images.insert(hash, image_data(data));
|
||
}
|
||
// Keep what the snapshot really holds; clip what people see.
|
||
manifest.components.retain(|a| doc.node(a.id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })));
|
||
manifest.styles.retain(|s| doc.styles.iter().any(|x| x.id == s.id));
|
||
manifest.collections.retain(|c| doc.variables.collections.iter().any(|x| x.id == c.id));
|
||
manifest.variables.retain(|v| doc.variables.variables.iter().any(|x| x.id == v.id && x.collection == v.collection));
|
||
manifest.name = manifest.name.chars().take(MAX_NAME).collect();
|
||
manifest.description = manifest.description.chars().take(MAX_DESCRIPTION).collect();
|
||
Ok(Some(PublishedLibrary { manifest, doc: Arc::new(doc) }))
|
||
}
|
||
|
||
/// Read the published library of a `.figcraft` file (only its library entries are read): `None`
|
||
/// when the file was never published, an error when it isn't a FigCraft archive or is damaged.
|
||
pub fn read_library<R: Read + Seek>(r: R) -> Result<Option<PublishedLibrary>, FormatError> {
|
||
let mut z = zip::ZipArchive::new(r).map_err(corrupt)?;
|
||
read(&mut z, &BTreeMap::new())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "library_tests.rs"]
|
||
mod tests;
|