Merge fix/656

This commit is contained in:
bflatastic
2026-10-17 14:36:06 +07:00
2 changed files with 89 additions and 4 deletions
+41 -3
View File
@@ -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>,
+48 -1
View File
@@ -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());