mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 00:39:44 -06:00
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>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
//! `document.json` at scale: a large document round-trips exactly, layer fields at their defaults
|
||||
//! are left out (and come back), files that spell every field out still open, and the header is
|
||||
//! read without building the document.
|
||||
|
||||
use figcraft_doc::color::{BlendMode, Color, Paint};
|
||||
use figcraft_doc::geom::Affine;
|
||||
use figcraft_doc::{
|
||||
AutoLayout, ChildLayout, Document, Effect, EffectKind, ExportSetting, Node, NodeKind, Shadow, Sizing, StrokeAlign, TextAlignH, TextData,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// About `screens` × 25 layers of every common kind, with most layer fields off their defaults
|
||||
/// somewhere.
|
||||
fn large(screens: usize) -> Document {
|
||||
let mut d = Document::new("Large");
|
||||
let page = d.first_page().unwrap();
|
||||
let button =
|
||||
d.create(page, NodeKind::Component { description: "A button".into(), props: Default::default() }, "Button", 0.0, -80.0, 96.0, 32.0).unwrap();
|
||||
let label = d.create(button, NodeKind::Text(TextData::new("OK")), "Label", 8.0, 8.0, 40.0, 16.0).unwrap();
|
||||
d.get_mut(label).unwrap().plugin_data.insert("agent".into(), "note".into());
|
||||
for s in 0..screens {
|
||||
let screen = d.create(page, NodeKind::Frame, &format!("Screen {s}"), (s % 20) as f64 * 400.0, (s / 20) as f64 * 300.0, 360.0, 240.0).unwrap();
|
||||
let n = d.get_mut(screen).unwrap();
|
||||
n.clip = true;
|
||||
n.layout = Some(AutoLayout { gap: 6.0, padding: [4.0; 4], ..AutoLayout::default() });
|
||||
n.exports.push(ExportSetting::default());
|
||||
n.corners.radii = [4.0, 0.0, 4.0, 0.0];
|
||||
for k in 0..20 {
|
||||
let kind = match k % 4 {
|
||||
0 => NodeKind::Rectangle,
|
||||
1 => NodeKind::Ellipse { start: 0.5, sweep: 3.0, inner: 0.25 },
|
||||
2 => {
|
||||
let mut t = TextData::new(format!("Item {k}\nsecond line"));
|
||||
t.align_h = TextAlignH::Center;
|
||||
t.paragraph_spacing = 4.0;
|
||||
t.features.insert("liga".into(), false);
|
||||
NodeKind::Text(t)
|
||||
}
|
||||
_ => NodeKind::Line,
|
||||
};
|
||||
let id = d.create(screen, kind, &format!("Layer {k}"), 10.0 + k as f64 * 16.0, 50.0, 14.0, 14.0).unwrap();
|
||||
let n = d.get_mut(id).unwrap();
|
||||
match k % 5 {
|
||||
0 => {
|
||||
n.opacity = 0.5;
|
||||
n.blend = BlendMode::Multiply;
|
||||
}
|
||||
1 => {
|
||||
n.visible = false;
|
||||
n.locked = true;
|
||||
}
|
||||
2 => {
|
||||
n.transform = Affine::rotate(0.3) * n.transform;
|
||||
n.strokes = vec![Paint::solid(Color::from_hex("336699").unwrap())];
|
||||
n.stroke.weight = 2.0;
|
||||
n.stroke.align = StrokeAlign::Center;
|
||||
n.stroke.dashes = vec![2.0, 1.0];
|
||||
}
|
||||
3 => {
|
||||
n.effects.push(Effect {
|
||||
kind: EffectKind::DropShadow(Shadow { offset: (1.0, 2.0), radius: 3.0, ..Shadow::default() }),
|
||||
visible: true,
|
||||
});
|
||||
n.child_layout = ChildLayout { sizing_h: Sizing::Fill, min_w: Some(4.0), ..ChildLayout::default() };
|
||||
n.constrain_proportions = true;
|
||||
}
|
||||
_ => {
|
||||
n.is_mask = k % 2 == 0;
|
||||
n.fills.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
let props = [("Label".to_string(), json!("Go"))].into_iter().collect();
|
||||
let inst = d.create(screen, NodeKind::Instance { component: button, props }, "Button", 10.0, 200.0, 96.0, 32.0).unwrap();
|
||||
let n = d.get_mut(inst).unwrap();
|
||||
n.source = Some(button);
|
||||
n.overrides.insert("fills".into());
|
||||
n.prop_refs.insert("visible".into(), "Show".into());
|
||||
let l = d.create(inst, NodeKind::Text(TextData::new("Go")), "Label", 8.0, 8.0, 40.0, 16.0).unwrap();
|
||||
d.get_mut(l).unwrap().source = Some(label);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Every layer of `a` is in `b`, identical, and vice versa; the document-level fields too.
|
||||
fn assert_same_doc(a: &Document, b: &Document) {
|
||||
assert_eq!(a.node_count(), b.node_count());
|
||||
for n in a.nodes() {
|
||||
assert_eq!(Some(n), b.node(n.id), "layer {} `{}`", n.id.0, n.name);
|
||||
}
|
||||
assert_eq!((&a.name, &a.pages, &a.styles, &a.variables, &a.current_page), (&b.name, &b.pages, &b.styles, &b.variables, &b.current_page));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_document_roundtrips() {
|
||||
let d = large(200);
|
||||
assert!(d.node_count() > 4000);
|
||||
let bytes = save_bytes(&d, None).unwrap();
|
||||
let (back, issues) = load_bytes(&bytes).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
assert_same_doc(&d, &back);
|
||||
// Saving what was read writes the same document again.
|
||||
let again = save_bytes(&back, None).unwrap();
|
||||
let json = |b: &[u8]| {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(b)).unwrap();
|
||||
read_member(&mut z, "document.json", MAX_MEMBER).unwrap()
|
||||
};
|
||||
assert_eq!(json(&bytes), json(&again));
|
||||
// The streamed JSON and the JSON value the document converts to hold the same document (the
|
||||
// value spells `f32`s out at `f64` precision).
|
||||
let mut streamed = Vec::new();
|
||||
write_json(&d, &mut streamed).unwrap();
|
||||
let (a, _) = from_json(std::str::from_utf8(&streamed).unwrap()).unwrap();
|
||||
let (b, _) = from_json(&serde_json::to_string(&to_json(&d).unwrap()).unwrap()).unwrap();
|
||||
assert_same_doc(&a, &b);
|
||||
assert_same_doc(&d, &a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_left_out_and_come_back() {
|
||||
let mut d = Document::new("Defaults");
|
||||
let p = d.first_page().unwrap();
|
||||
let r = d.create(p, NodeKind::Rectangle, "Plain", 5.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let t = d.create(p, NodeKind::Text(TextData::new("Hi")), "Text", 0.0, 20.0, 10.0, 10.0).unwrap();
|
||||
let v = to_json(&d).unwrap();
|
||||
let node = |id: figcraft_doc::NodeId| v["nodes"][id.0.to_string()].as_object().unwrap().clone();
|
||||
let plain = node(r);
|
||||
for field in [
|
||||
"visible",
|
||||
"locked",
|
||||
"opacity",
|
||||
"children",
|
||||
"strokes",
|
||||
"stroke",
|
||||
"effects",
|
||||
"corners",
|
||||
"constraints",
|
||||
"layout",
|
||||
"child_layout",
|
||||
"clip",
|
||||
"is_mask",
|
||||
"grids",
|
||||
"exports",
|
||||
"interactions",
|
||||
"overflow",
|
||||
"scroll_position",
|
||||
"styles",
|
||||
"bound",
|
||||
"var_modes",
|
||||
"constrain_proportions",
|
||||
"source",
|
||||
"overrides",
|
||||
"prop_refs",
|
||||
"plugin_data",
|
||||
] {
|
||||
assert!(!plain.contains_key(field), "`{field}` is at its default and left out");
|
||||
}
|
||||
for field in ["id", "name", "type", "parent", "transform", "width", "height", "fills", "blend"] {
|
||||
assert!(plain.contains_key(field), "`{field}` is written");
|
||||
}
|
||||
let text = node(t);
|
||||
for field in ["runs", "paragraph_spacing", "paragraph_indent", "list", "max_lines", "features"] {
|
||||
assert!(!text.contains_key(field), "text `{field}` is at its default and left out");
|
||||
}
|
||||
assert!(text.contains_key("characters") && text.contains_key("style"));
|
||||
let (back, _) = from_json(&serde_json::to_string(&v).unwrap()).unwrap();
|
||||
assert_same_doc(&d, &back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_with_every_field_spelled_out_still_open() {
|
||||
// A layer as earlier versions wrote it: every field, defaults included.
|
||||
let old = r#"{"format":"figcraft","version":1,"imageIndex":[],"name":"Old","pages":[1],"next_id":3,"current_page":1,
|
||||
"nodes":{
|
||||
"1":{"id":1,"name":"Page 1","type":"page","parent":null,"children":[2],"visible":true,"locked":false,"opacity":1.0,"blend":"passThrough","transform":[1.0,0.0,0.0,1.0,0.0,0.0],"width":0.0,"height":0.0,"fills":[],"strokes":[],"effects":[]},
|
||||
"2":{"id":2,"name":"Card background","type":"rectangle","parent":1,"children":[],"visible":true,"locked":false,"opacity":1.0,"blend":"normal","transform":[1.0,0.0,0.0,1.0,0.0,0.0],"width":160.0,"height":64.0,"fills":[{"type":"solid","color":{"r":0.93333334,"g":0.95686275,"b":1.0,"a":1.0},"opacity":1.0,"visible":true,"blend":"normal"}],"strokes":[],"stroke":{"weight":1.0,"align":"inside","sides":null,"cap_start":"none","cap_end":"none","join":"miter","miter_limit":4.0,"dashes":[]},"effects":[],"corners":{"radii":[0.0,0.0,0.0,0.0],"smoothing":0.0},"constraints":{"horizontal":"left","vertical":"top"},"layout":null,"child_layout":{"sizing_h":"fixed","sizing_v":"fixed","absolute":false,"min_w":null,"max_w":null,"min_h":null,"max_h":null,"grid_column":null,"grid_row":null,"grid_column_span":0,"grid_row_span":0},"clip":false,"is_mask":false,"grids":[],"exports":[],"interactions":[],"overflow":"none","scroll_position":"scrolls","styles":{"fill":null,"stroke":null,"effect":null,"grid":null,"text":null},"bound":{},"var_modes":{},"constrain_proportions":false,"source":null,"overrides":[],"prop_refs":{},"plugin_data":{}}
|
||||
}}"#;
|
||||
let (d, issues) = load_bytes(old.as_bytes()).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
let n: &Node = d.node(figcraft_doc::NodeId(2)).unwrap();
|
||||
assert_eq!((n.name.as_str(), n.width, n.height, n.visible, n.opacity), ("Card background", 160.0, 64.0, true, 1.0));
|
||||
assert_eq!(n.fills.len(), 1);
|
||||
// Written again, it's the short form, and reads back the same.
|
||||
let (again, _) = from_json(&serde_json::to_string(&to_json(&d).unwrap()).unwrap()).unwrap();
|
||||
assert_same_doc(&d, &again);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_is_read_on_its_own() {
|
||||
// A newer file is refused before its document is looked at, whatever the version's size.
|
||||
assert!(matches!(from_json(r#"{"nodes": "not a map", "version": 2}"#), Err(FormatError::Newer(2))));
|
||||
assert!(matches!(from_json(r#"{"version": 99999999999}"#), Err(FormatError::Newer(u32::MAX))));
|
||||
// A version that isn't a number is no version; anything but an object is no document.
|
||||
let d = serde_json::to_string(&to_json(&Document::new("x")).unwrap()).unwrap();
|
||||
let odd = d.replacen("\"version\":1", "\"version\":\"one\"", 1);
|
||||
assert!(from_json(&odd).is_ok());
|
||||
for junk in ["[]", "[2]", "3", "null", "\"doc\"", "{", ""] {
|
||||
assert!(matches!(from_json(junk), Err(FormatError::Corrupt(_))), "{junk}");
|
||||
}
|
||||
// The image index is read from the header too.
|
||||
let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap();
|
||||
assert_eq!(index, Some(["ab".to_string()].into_iter().collect()));
|
||||
}
|
||||
+110
-24
@@ -1,7 +1,8 @@
|
||||
//! The native `.figcraft` format.
|
||||
//!
|
||||
//! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version`
|
||||
//! header), `images/<sha256>` for every image the document (or a saved version) uses, an optional
|
||||
//! header; layer fields at their default values are left out and read back as those defaults),
|
||||
//! `images/<sha256>` for every image the document (or a saved version) uses, an optional
|
||||
//! `thumbnail.png` and an optional version history: `versions/index.json` (`{"versions": [{id,
|
||||
//! name, description, time, images}]}`, oldest first) plus one `versions/<id>.json` snapshot per
|
||||
//! version (a `document.json` of its own). A published file also holds its library (see
|
||||
@@ -22,6 +23,8 @@ use std::io::{Read, Seek, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{Document, ImageData};
|
||||
use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor};
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::Digest;
|
||||
|
||||
@@ -39,6 +42,8 @@ pub const MAX_VERSION_DESCRIPTION: usize = 2000;
|
||||
const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024;
|
||||
const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
const VERSION_INDEX: &str = "versions/index.json";
|
||||
/// Deflate level of JSON members (see [`json_options`]).
|
||||
const JSON_LEVEL: i64 = 3;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FormatError {
|
||||
@@ -168,16 +173,43 @@ pub(crate) fn image_data(data: Vec<u8>) -> ImageData {
|
||||
ImageData { bytes: Arc::new(data), width: w, height: h }
|
||||
}
|
||||
|
||||
/// `document.json`: the format header, the image index and the document's own fields, written
|
||||
/// straight from the document (no intermediate JSON tree).
|
||||
#[derive(Serialize)]
|
||||
struct DocumentJson<'a> {
|
||||
format: &'static str,
|
||||
version: u32,
|
||||
#[serde(rename = "imageIndex", serialize_with = "image_index")]
|
||||
images: &'a BTreeMap<String, ImageData>,
|
||||
#[serde(flatten)]
|
||||
doc: &'a Document,
|
||||
}
|
||||
|
||||
impl<'a> DocumentJson<'a> {
|
||||
fn new(doc: &'a Document) -> Self {
|
||||
Self { format: FORMAT, version: VERSION, images: &doc.images, doc }
|
||||
}
|
||||
}
|
||||
|
||||
/// The `imageIndex`: `[{hash, width, height}]` for every image the document holds.
|
||||
fn image_index<S: Serializer>(images: &&BTreeMap<String, ImageData>, s: S) -> Result<S::Ok, S::Error> {
|
||||
#[derive(Serialize)]
|
||||
struct Entry<'a> {
|
||||
hash: &'a str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
s.collect_seq(images.iter().map(|(hash, i)| Entry { hash, width: i.width, height: i.height }))
|
||||
}
|
||||
|
||||
/// Serialize the document (without images) to the JSON value stored in `document.json`.
|
||||
pub fn to_json(doc: &Document) -> Result<Value, FormatError> {
|
||||
let mut v = serde_json::to_value(doc).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("format".into(), json!(FORMAT));
|
||||
o.insert("version".into(), json!(VERSION));
|
||||
let images: Vec<Value> = doc.images.iter().map(|(k, i)| json!({"hash": k, "width": i.width, "height": i.height})).collect();
|
||||
o.insert("imageIndex".into(), Value::Array(images));
|
||||
}
|
||||
Ok(v)
|
||||
serde_json::to_value(DocumentJson::new(doc)).map_err(io_err)
|
||||
}
|
||||
|
||||
/// Write the document (without images) as `document.json` content to `w`.
|
||||
pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> {
|
||||
serde_json::to_writer(w, &DocumentJson::new(doc)).map_err(io_err)
|
||||
}
|
||||
|
||||
/// Parse `document.json` content.
|
||||
@@ -188,24 +220,62 @@ pub fn from_json(text: &str) -> Result<(Document, Vec<String>), FormatError> {
|
||||
/// The image hashes a `document.json` lists in its `imageIndex` (`None` without one).
|
||||
type ImageIndex = Option<BTreeSet<String>>;
|
||||
|
||||
/// Parse `document.json` content; also returns its [`ImageIndex`].
|
||||
/// The header of a `document.json` (its format version and image index), read in a pass that
|
||||
/// skips over everything else without building it.
|
||||
#[derive(Default)]
|
||||
struct Header {
|
||||
version: Option<u64>,
|
||||
images: ImageIndex,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Header {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct HeaderVisitor;
|
||||
impl<'de> Visitor<'de> for HeaderVisitor {
|
||||
type Value = Header;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a FigCraft document object")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Header, A::Error> {
|
||||
let mut h = Header::default();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
match key.as_str() {
|
||||
"version" => h.version = map.next_value::<Value>()?.as_u64(),
|
||||
"imageIndex" => {
|
||||
h.images = map
|
||||
.next_value::<Value>()?
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|i| i.get("hash").and_then(Value::as_str).map(str::to_string)).collect());
|
||||
}
|
||||
_ => {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(h)
|
||||
}
|
||||
}
|
||||
d.deserialize_map(HeaderVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `document.json` content; also returns its [`ImageIndex`]. The header is checked first
|
||||
/// (a newer file is refused before its document is read), then the document is deserialized
|
||||
/// straight from the text.
|
||||
fn parse_json(text: &str) -> Result<(Document, Vec<String>, ImageIndex), FormatError> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
if let Some(ver) = v.get("version").and_then(Value::as_u64)
|
||||
let corrupt = |e: serde_json::Error| FormatError::Corrupt(e.to_string());
|
||||
let header: Header = serde_json::from_str(text).map_err(corrupt)?;
|
||||
if let Some(ver) = header.version
|
||||
&& ver > u64::from(VERSION)
|
||||
{
|
||||
return Err(FormatError::Newer(ver as u32));
|
||||
return Err(FormatError::Newer(u32::try_from(ver).unwrap_or(u32::MAX)));
|
||||
}
|
||||
let index = v
|
||||
.get("imageIndex")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|i| i.get("hash").and_then(Value::as_str).map(str::to_string)).collect());
|
||||
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let mut doc: Document = serde_json::from_str(text).map_err(corrupt)?;
|
||||
let issues = doc.validate();
|
||||
if !issues.is_empty() {
|
||||
doc.repair();
|
||||
}
|
||||
Ok((doc, issues, index))
|
||||
Ok((doc, issues, header.images))
|
||||
}
|
||||
|
||||
fn io_err(e: impl std::fmt::Display) -> FormatError {
|
||||
@@ -218,6 +288,21 @@ fn put<W: Write + Seek>(z: &mut zip::ZipWriter<W>, name: &str, opts: zip::write:
|
||||
z.write_all(bytes).map_err(io_err)
|
||||
}
|
||||
|
||||
/// Add `doc` as a `document.json`-style member, serialized straight into the compressor.
|
||||
fn put_json<W: Write + Seek>(z: &mut zip::ZipWriter<W>, name: &str, doc: &Document) -> Result<(), FormatError> {
|
||||
z.start_file(name, json_options()).map_err(io_err)?;
|
||||
// The serializer writes many small pieces: batch them before they reach the compressor.
|
||||
let mut w = std::io::BufWriter::with_capacity(1 << 16, z);
|
||||
write_json(doc, &mut w)?;
|
||||
w.flush().map_err(io_err)
|
||||
}
|
||||
|
||||
/// How JSON members are compressed. Document JSON is very repetitive: a fast level compresses it
|
||||
/// nearly as well as the default one (about 15% larger) in half the time.
|
||||
pub(crate) fn json_options() -> zip::write::SimpleFileOptions {
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).compression_level(Some(JSON_LEVEL))
|
||||
}
|
||||
|
||||
/// Write a `.figcraft` archive to bytes.
|
||||
pub fn save_bytes(doc: &Document, thumbnail_png: Option<&[u8]>) -> Result<Vec<u8>, FormatError> {
|
||||
save_bytes_with_versions(doc, thumbnail_png, &[])
|
||||
@@ -228,9 +313,8 @@ pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, ve
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
put(&mut z, "document.json", deflated, &serde_json::to_vec(&to_json(doc)?).map_err(io_err)?)?;
|
||||
put_json(&mut z, "document.json", doc)?;
|
||||
// Every image once, whether the document or a version uses it.
|
||||
let mut written = BTreeSet::new();
|
||||
for (hash, img) in std::iter::once(&doc.images).chain(versions.iter().map(Version::images)).flatten() {
|
||||
@@ -250,8 +334,8 @@ pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, ve
|
||||
}
|
||||
let name = format!("versions/{}.json", v.id);
|
||||
match &v.snapshot {
|
||||
Snapshot::Doc(d) => put(&mut z, &name, deflated, &serde_json::to_vec(&to_json(d)?).map_err(io_err)?)?,
|
||||
Snapshot::Stored { json, .. } => put(&mut z, &name, deflated, json)?,
|
||||
Snapshot::Doc(d) => put_json(&mut z, &name, d)?,
|
||||
Snapshot::Stored { json, .. } => put(&mut z, &name, json_options(), json)?,
|
||||
}
|
||||
let images: Vec<&String> = v.images().keys().collect();
|
||||
let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images});
|
||||
@@ -260,7 +344,7 @@ pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, ve
|
||||
}
|
||||
index.push(entry);
|
||||
}
|
||||
put(&mut z, VERSION_INDEX, deflated, &serde_json::to_vec(&json!({"versions": index})).map_err(io_err)?)?;
|
||||
put(&mut z, VERSION_INDEX, json_options(), &serde_json::to_vec(&json!({"versions": index})).map_err(io_err)?)?;
|
||||
}
|
||||
if let Some(lib) = &doc.published {
|
||||
library::write(&mut z, lib, &written)?;
|
||||
@@ -393,6 +477,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
|
||||
load_bytes(&bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod large_tests;
|
||||
#[cfg(test)]
|
||||
mod version_tests;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{ImageData, LibraryManifest, NodeKind, PublishedLibrary, used_images};
|
||||
|
||||
use crate::{FormatError, MAX_MEMBER, from_json, image_data, io_err, put, read_member, to_json};
|
||||
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";
|
||||
@@ -45,10 +45,9 @@ fn is_valid_hash(h: &str) -> bool {
|
||||
|
||||
/// 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 deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
put(z, MANIFEST, deflated, &serde_json::to_vec(&lib.manifest).map_err(io_err)?)?;
|
||||
put(z, SNAPSHOT, deflated, &serde_json::to_vec(&to_json(&lib.doc)?).map_err(io_err)?)?;
|
||||
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)?;
|
||||
|
||||
Reference in New Issue
Block a user