Files
figcraft/crates/format/src/library.rs
T
BFlatandClaude Opus 5.5 5f54fecca2 M8: 50k-layer zoomed-out renders and faster save/load (#63)
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>
2026-10-10 10:27:51 +07:00

106 lines
5.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;