mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
Merge fix/656
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
//! zstd: a binary schema in the kiwi format ([`kiwi`]), then a message encoded with it. Every file
|
||||
//! carries its own schema, so the message is decoded generically and read by field name: a list
|
||||
//! of node changes (each with its id, parent id and position among its siblings) that make up the
|
||||
//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`]). [`map`]
|
||||
//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`], and the bytes
|
||||
//! of images that travel inside the message, [`embedded_images`]). [`map`]
|
||||
//! turns that into the REST API file JSON shape and the REST importer builds the document, so both
|
||||
//! imports share one mapping. Layer kinds the editor doesn't have (sticky notes, connectors,
|
||||
//! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped
|
||||
@@ -23,6 +24,7 @@ mod network;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
|
||||
use figcraft_doc::Document;
|
||||
@@ -30,7 +32,7 @@ use figcraft_doc::Document;
|
||||
pub use export::export;
|
||||
|
||||
use crate::FormatError;
|
||||
use kiwi::Schema;
|
||||
use kiwi::{Kv, Schema};
|
||||
|
||||
/// Largest `canvas.fig` member read, and largest decompressed chunk.
|
||||
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
|
||||
@@ -63,7 +65,8 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
|
||||
let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?;
|
||||
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
|
||||
let (json, mut warnings) = map::to_rest(&msg, file_name);
|
||||
let images = |r: &str| archive.image(r);
|
||||
let embedded = embedded_images(&msg);
|
||||
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
|
||||
let (doc, more) = crate::rest::import(&json, &images)?;
|
||||
warnings.extend(more.into_iter().filter_map(map::adapt_warning));
|
||||
Ok((doc, warnings))
|
||||
@@ -94,6 +97,41 @@ fn corrupt(msg: impl Into<String>) -> FormatError {
|
||||
FormatError::Corrupt(format!(".fig file: {}", msg.into()))
|
||||
}
|
||||
|
||||
/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's
|
||||
/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed
|
||||
/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The
|
||||
/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The
|
||||
/// bytes stay borrowed from the message; only images a paint asks for are copied.
|
||||
fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap<String, &'m [u8]> {
|
||||
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
|
||||
let mut found = BTreeMap::new();
|
||||
let mut pending = vec![msg];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
Kv::Object(fields) => {
|
||||
if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
|
||||
found.entry(hash).or_insert(bytes);
|
||||
}
|
||||
pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field));
|
||||
}
|
||||
Kv::Array(items) => pending.extend(items),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob.
|
||||
fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> {
|
||||
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?;
|
||||
let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None };
|
||||
let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?;
|
||||
if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE {
|
||||
return None;
|
||||
}
|
||||
Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes))
|
||||
}
|
||||
|
||||
/// The parts of a `.fig` archive the import reads.
|
||||
struct Archive<'b> {
|
||||
canvas: Vec<u8>,
|
||||
|
||||
@@ -39,7 +39,7 @@ struct Color float r, float g, float b, float a
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
struct ColorStop Color color, float position
|
||||
message Image byte[] hash, string name
|
||||
message Image byte[] hash, string name, uint dataBlob
|
||||
message AssetRef string key, string version
|
||||
message VariableID GUID guid, AssetRef assetRef
|
||||
message PropRefValue GUID defId
|
||||
@@ -542,6 +542,53 @@ fn bare_canvas_and_zlib_chunks_open() {
|
||||
assert!(import(&out, "Z").is_ok());
|
||||
}
|
||||
|
||||
/// The fixture message with the photo's image bytes in its own blobs, as copies written by plugins
|
||||
/// and tools carry them: the PNG goes at the end of `blobs` and `image.dataBlob` points `shift`
|
||||
/// places past it (0 is the PNG itself).
|
||||
fn message_with_image_in_blobs(shift: u64) -> Kv<'static> {
|
||||
fn point_images_at(value: &mut Kv<'static>, blob: u64) {
|
||||
match value {
|
||||
Kv::Object(fields) => {
|
||||
for (name, field) in fields.iter_mut() {
|
||||
match field {
|
||||
Kv::Object(image) if *name == "image" => image.push(("dataBlob", u(blob))),
|
||||
_ => point_images_at(field, blob),
|
||||
}
|
||||
}
|
||||
}
|
||||
Kv::Array(items) => items.iter_mut().for_each(|item| point_images_at(item, blob)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut msg = message();
|
||||
let index = {
|
||||
let Kv::Object(fields) = &mut msg else { panic!("the message is an object") };
|
||||
let Some((_, Kv::Array(blobs))) = fields.iter_mut().find(|(name, _)| *name == "blobs") else { panic!("no blobs") };
|
||||
blobs.push(o(vec![("bytes", Kv::Bytes(png()))]));
|
||||
blobs.len() as u64 - 1
|
||||
};
|
||||
point_images_at(&mut msg, index + shift);
|
||||
msg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_canvas_reads_images_carried_in_its_blobs() {
|
||||
// The clipboard holds a bare canvas (no `images/`); the image comes from `image.dataBlob`.
|
||||
let c = canvas(&schema(), &message_with_image_in_blobs(0));
|
||||
let (doc, warnings) = import(&c, "Pasted").unwrap();
|
||||
let photo = named(&doc, "Photo");
|
||||
let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };
|
||||
assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1)));
|
||||
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_blob_past_the_blobs_stays_a_placeholder() {
|
||||
let c = canvas(&schema(), &message_with_image_in_blobs(5));
|
||||
let (_, warnings) = import(&c, "Pasted").unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_files_are_not_fig() {
|
||||
assert!(try_import(b"{\"document\": {}}", "x").is_none());
|
||||
|
||||
Reference in New Issue
Block a user