M6: open Sketch files (48+): detection, pages, version cut, wiring

.sketch archives (document.json, meta.json, pages/*.json) open through
file.open, file.import, drag and drop, the CLI and MCP; files saved by a
Sketch older than 48 are refused with a message saying how to convert
them. Layers come in the next commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jindřich Šaršon
2026-10-22 00:27:20 +02:00
co-authored by Claude Opus 5.5
parent c1a4f2d954
commit a30f846ee3
13 changed files with 597 additions and 5 deletions
+1
View File
@@ -31,5 +31,6 @@ regex = { workspace = true }
web-time = "1.1.0"
[dev-dependencies]
zip = { workspace = true }
wat = { workspace = true }
image = { workspace = true }
+13 -1
View File
@@ -536,6 +536,9 @@ fn load_with(
if let Some(r) = load_fig(bytes, &file_stem(name_hint)) {
return Ok((r?, true));
}
if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) {
return Ok((r?, true));
}
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
link_instances(&mut doc);
@@ -551,6 +554,15 @@ fn load_with(
}
}
/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try.
fn load_sketch(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
let r = figcraft_format::sketch::try_import(bytes, name)?;
Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| {
link_instances(&mut doc);
figcraft_format::Loaded { issues: warnings, ..doc.into() }
}))
}
/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try.
pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
let r = figcraft_format::fig::try_import(bytes, name)?;
@@ -1293,7 +1305,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(out)
}
"figcraft" | "json" | "fig" => {
"figcraft" | "json" | "fig" | "sketch" => {
let resolver = path.as_deref().map(rest_images);
let images: figcraft_format::rest::ImageResolver = match &resolver {
Some(r) => r,
+2
View File
@@ -1839,6 +1839,8 @@ mod shapes_behaviour_tests;
#[cfg(test)]
mod shortcut_parity_tests;
#[cfg(test)]
mod sketch_import_tests;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod stroke_style_tests;
+89
View File
@@ -0,0 +1,89 @@
//! `.sketch` files through the file commands. The importer's own tests (fixtures built in code)
//! are in `figcraft_format::sketch`.
use std::io::Write;
use base64::Engine;
use serde_json::{Value, json};
use crate::Session;
/// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`.
fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec<u8> {
let refs: Vec<Value> = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect();
let mut out = Vec::new();
{
let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
let o = zip::write::SimpleFileOptions::default();
z.start_file("document.json", o).unwrap();
z.write_all(json!({"_class": "document", "pages": refs}).to_string().as_bytes()).unwrap();
z.start_file("meta.json", o).unwrap();
z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": 103}).to_string().as_bytes()).unwrap();
for (id, name) in pages {
z.start_file(format!("pages/{id}.json"), o).unwrap();
z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": []}).to_string().as_bytes()).unwrap();
}
z.finish().unwrap();
}
out
}
fn temp(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("figcraft-sketch-test-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
(dir, path)
}
fn page_names(s: &Session) -> Vec<String> {
let doc = s.doc().unwrap();
doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect()
}
#[test]
fn opened_sketch_files_have_their_pages_and_no_path() {
let (dir, path) = temp("design.sketch", &sketch("50.2", &[("A", "Screens"), ("B", "Symbols")]));
let mut s = Session::new();
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(r.get("summary").is_some());
assert_eq!(page_names(&s), ["Screens", "Symbols"]);
// Never saved over: the document has no path, so Save asks for one.
assert!(s.docs[s.active].path.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sketch_import_appends_pages() {
let (dir, path) = temp("more.sketch", &sketch("83.2", &[("A", "Imported")]));
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let before = page_names(&s).len();
s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap();
let names = page_names(&s);
assert_eq!(names.len(), before + 1);
assert_eq!(names.last().unwrap(), "Imported");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sketch_data_opens_with_a_summary() {
let data = base64::engine::general_purpose::STANDARD.encode(sketch("50.2", &[("A", "Page")]));
let mut s = Session::new();
let r = s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
assert!(r.get("summary").is_some());
assert_eq!(page_names(&s), ["Page"]);
}
#[test]
fn old_and_damaged_sketch_files_fail_cleanly() {
let mut s = Session::new();
let (dir, path) = temp("old.sketch", &sketch("47.1", &[("A", "P")]));
let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("older than 48"), "{e}");
let mut broken = sketch("50.2", &[("A", "P")]);
broken.truncate(broken.len() / 2);
std::fs::write(&path, &broken).unwrap();
assert!(s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+3
View File
@@ -416,6 +416,9 @@ fn every_command_survives_junk_params() {
// Online files: ids, servers, refreshes, actions and names as junk.
json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}),
json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}),
// Files as base64 data: a damaged `.sketch` (zip magic, then junk) and an old one (SQLite).
json!({"data": "UEsDBBQAAAAIAP////8=", "name": "x.sketch"}),
json!({"data": "U1FMaXRlIGZvcm1hdCAzAA==", "name": "x.sketch", "path": null}),
// Selection color slots: malformed indexes, ranges and paints.
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
+1
View File
@@ -20,6 +20,7 @@
pub mod fig;
pub mod library;
pub mod rest;
pub mod sketch;
pub mod thumbnail;
pub use library::{is_valid_library_id, read_library, read_library_within};
+11
View File
@@ -185,6 +185,17 @@ pub(crate) fn import_with_networks(
import_parts(v, None, images, networks, max_nodes, true)
}
/// [`import_with_networks`] for imports other than `.fig` (no `.fig` source identities): the
/// `.sketch` import.
pub(crate) fn import_with_networks_plain(
v: &Value,
images: ImageResolver,
networks: Vec<Option<VectorNetwork>>,
max_nodes: usize,
) -> Result<(Document, Vec<String>), FormatError> {
import_parts(v, None, images, networks, max_nodes, false)
}
fn import_parts(
v: &Value,
variables: Option<&Value>,
+121
View File
@@ -0,0 +1,121 @@
//! Synthetic `.sketch` files for the tests: the JSON of a document, its pages and `meta.json`,
//! zipped the way Sketch writes them. Layers are built with small helpers and adjusted with
//! [`with`].
use std::io::Write;
use serde_json::{Value, json};
/// A `.sketch` zip: `document.json`, `meta.json` saying `app_version` / `version`, one
/// `pages/<id>.json` per page and any other `files` (images, fonts).
pub(crate) fn sketch_zip(app_version: &str, version: u64, document: Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec<u8> {
let meta = json!({
"app": "com.bohemiancoding.sketch3",
"appVersion": app_version,
"version": version,
"compatibilityVersion": 99,
"pagesAndArtboards": {},
});
raw_zip(&document, &meta, pages, files)
}
/// A `.sketch` zip with the given `meta.json` as is.
pub(crate) fn raw_zip(document: &Value, meta: &Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = Vec::new();
{
let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("document.json", opts).unwrap();
z.write_all(document.to_string().as_bytes()).unwrap();
z.start_file("meta.json", opts).unwrap();
z.write_all(meta.to_string().as_bytes()).unwrap();
for p in pages {
let id = p["do_objectID"].as_str().unwrap_or("page");
z.start_file(format!("pages/{id}.json"), opts).unwrap();
z.write_all(p.to_string().as_bytes()).unwrap();
}
for (name, bytes) in files {
z.start_file(*name, opts).unwrap();
z.write_all(bytes).unwrap();
}
z.finish().unwrap();
}
out
}
/// `document.json` listing `pages` (ids) in order, with empty shared-style containers.
pub(crate) fn document(pages: &[&str]) -> Value {
json!({
"_class": "document",
"do_objectID": "DOC",
"colorSpace": 1,
"currentPageIndex": 0,
"foreignLayerStyles": [],
"foreignSymbols": [],
"foreignTextStyles": [],
"layerStyles": {"_class": "sharedStyleContainer", "objects": []},
"layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": []},
"layerSymbols": {"_class": "symbolContainer", "objects": []},
"pages": pages.iter().map(|p| json!({
"_class": "MSJSONFileReference",
"_ref_class": "MSImmutablePage",
"_ref": format!("pages/{p}"),
})).collect::<Vec<_>>(),
})
}
fn frame(x: f64, y: f64, w: f64, h: f64) -> Value {
json!({"_class": "rect", "constrainProportions": false, "x": x, "y": y, "width": w, "height": h})
}
fn layer(class: &str, id: &str, name: &str, x: f64, y: f64, w: f64, h: f64) -> Value {
json!({
"_class": class,
"do_objectID": id,
"name": name,
"frame": frame(x, y, w, h),
"isVisible": true,
"isLocked": false,
"rotation": 0,
"isFlippedHorizontal": false,
"isFlippedVertical": false,
"resizingConstraint": 63,
"style": {"_class": "style", "fills": [], "borders": [], "shadows": [], "innerShadows": []},
})
}
pub(crate) fn page(id: &str, name: &str, layers: Vec<Value>) -> Value {
with(layer("page", id, name, 0.0, 0.0, 0.0, 0.0), "layers", Value::Array(layers))
}
#[allow(dead_code)] // used by the layer tree tests
pub(crate) fn artboard(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec<Value>) -> Value {
with(layer("artboard", id, name, x, y, w, h), "layers", Value::Array(layers))
}
pub(crate) fn group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec<Value>) -> Value {
with(layer("group", id, id, x, y, w, h), "layers", Value::Array(layers))
}
/// A rectangle with its four generated corner points.
pub(crate) fn rect(id: &str, x: f64, y: f64, w: f64, h: f64) -> Value {
let pts = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].map(|(px, py)| point(px, py));
let mut l = with(layer("rectangle", id, id, x, y, w, h), "points", Value::Array(pts.to_vec()));
l["isClosed"] = json!(true);
l["fixedRadius"] = json!(0);
l
}
/// A straight curve point at `(x, y)` in 0–1 of the layer's frame.
pub(crate) fn point(x: f64, y: f64) -> Value {
let p = format!("{{{x}, {y}}}");
json!({"_class": "curvePoint", "cornerRadius": 0, "curveFrom": p, "curveTo": p, "curveMode": 1, "hasCurveFrom": false, "hasCurveTo": false, "point": p})
}
/// `l` with `key` set to `value`.
pub(crate) fn with(mut l: Value, key: &str, value: Value) -> Value {
if let Some(m) = l.as_object_mut() {
m.insert(key.to_string(), value);
}
l
}
+153
View File
@@ -0,0 +1,153 @@
//! Import of `.sketch` files saved by Sketch 48 or newer (best effort), written from the public
//! description of the format only.
//!
//! A `.sketch` file is a zip archive holding `document.json` (the document: its pages in order,
//! shared styles, swatches and library symbols copied into it), `meta.json` (the app version and
//! format version that saved it), one `pages/<id>.json` per page (the layer tree), `images/` and,
//! in newer files, `fonts/`. Every object carries its kind in `_class`. [`tree`] maps the layers
//! onto the REST API file JSON shape and the REST importer builds the document, so this import
//! shares its mapping, budgets and import report with `.fig` and file JSON imports. Files older
//! than Sketch 48 (a SQLite database before 43, text as binary property lists until 47) are
//! refused with a message saying how to convert them. Anything not understood is skipped with a
//! warning; malformed input never panics.
#[cfg(test)]
mod fixture;
#[cfg(test)]
mod tests;
mod tree;
use std::collections::BTreeSet;
use figcraft_doc::Document;
use serde_json::Value;
use crate::FormatError;
/// The oldest Sketch whose files open: from 48 on, text is plain JSON too.
pub const MIN_SKETCH: u32 = 48;
/// The newest `meta.json` format version checked against real files (Sketch 83); newer files
/// open with a warning.
pub const NEWEST_TESTED_VERSION: u64 = 139;
/// What opening a file saved by an older Sketch says.
pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again";
/// Largest JSON member read (a real page of a few thousand layers takes 33 MB).
const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024;
/// Largest image member read.
const MAX_IMAGE: u64 = 256 * 1024 * 1024;
/// Most pages read from one file.
const MAX_PAGES: usize = 1000;
/// Whether `bytes` look like a `.sketch` file: a zip holding `document.json` and `meta.json`, or
/// the SQLite database Sketch wrote before version 43 (so that it gets the "too old" message).
pub fn is_sketch(bytes: &[u8]) -> bool {
bytes.starts_with(b"SQLite format 3\0")
|| (bytes.starts_with(b"PK")
&& zip::ZipArchive::new(std::io::Cursor::new(bytes)).is_ok_and(|z| {
let names: BTreeSet<&str> = z.file_names().collect();
names.contains("document.json") && names.contains("meta.json")
}))
}
/// Import `bytes` when they are a `.sketch` file; `None` when they are something else.
pub fn try_import(bytes: &[u8], name: &str) -> Option<Result<(Document, Vec<String>), FormatError>> {
is_sketch(bytes).then(|| import(bytes, name))
}
/// Build a document from a `.sketch` file. Returns it with the warnings about what was skipped or
/// approximated. `name`: the document name.
pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), FormatError> {
import_within(bytes, name, MAX_JSON_MEMBER)
}
/// [`import`] reading pages of at most `max_member` bytes of JSON each.
pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result<(Document, Vec<String>), FormatError> {
if bytes.starts_with(b"SQLite format 3\0") {
return Err(FormatError::Io(TOO_OLD.into()));
}
let archive = Archive::open(bytes, max_member)?;
let max_nodes = crate::fig::layer_budget(bytes.len());
let map = tree::to_rest(&archive, name);
if map.too_old {
return Err(FormatError::Io(TOO_OLD.into()));
}
let mut warnings = archive.warnings.clone();
warnings.extend(map.warnings);
let images = |r: &str| archive.image(r);
let (doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?;
warnings.extend(more);
Ok((doc, warnings))
}
fn corrupt(msg: impl Into<String>) -> FormatError {
FormatError::Corrupt(format!("Sketch file: {}", msg.into()))
}
/// The parts of a `.sketch` archive: the pages in document order and the images.
pub(crate) struct Archive<'b> {
/// `(id, page JSON)` in the document's order.
pub pages: Vec<(String, Value)>,
/// Problems met while reading (pages skipped).
pub warnings: Vec<String>,
zip: zip::ZipArchive<std::io::Cursor<&'b [u8]>>,
}
impl<'b> Archive<'b> {
fn open(bytes: &'b [u8], max_member: u64) -> Result<Self, FormatError> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?;
let json = |z: &mut zip::ZipArchive<std::io::Cursor<&'b [u8]>>, member: &str, limit: u64| -> Result<Value, FormatError> {
let data = crate::read_member(z, member, limit)?;
serde_json::from_slice(&data).map_err(|e| corrupt(format!("{member}: {e}")))
};
let meta = json(&mut z, "meta.json", MAX_JSON_MEMBER)?;
if !meta.is_object() {
return Err(corrupt("meta.json is not an object"));
}
if app_major(&meta).is_some_and(|v| v < MIN_SKETCH) {
return Err(FormatError::Io(TOO_OLD.into()));
}
let document = json(&mut z, "document.json", MAX_JSON_MEMBER)?;
let mut warnings = Vec::new();
if meta.get("version").and_then(Value::as_u64).is_some_and(|v| v > NEWEST_TESTED_VERSION) {
warnings.push("the file was saved by a newer Sketch than the import was checked with; some layers may differ".to_string());
}
let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
let mut pages = Vec::new();
for r in refs.iter().take(MAX_PAGES) {
let Some(id) = r.get("_ref").and_then(Value::as_str).and_then(|s| s.strip_prefix("pages/")).filter(|id| valid_id(id)) else {
warnings.push("pages the document lists without a valid reference were skipped".into());
continue;
};
match json(&mut z, &format!("pages/{id}.json"), max_member) {
Ok(page) if page.is_object() => pages.push((id.to_string(), page)),
Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()),
Err(e) => warnings.push(format!("a page was skipped: {}", e.to_string().trim_start_matches("not a valid FigCraft file: "))),
}
}
if refs.len() > MAX_PAGES {
warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped"));
}
Ok(Archive { pages, warnings, zip: z })
}
/// The bytes of the image a layer references (`images/<name>`).
pub(crate) fn image(&self, r: &str) -> Option<Vec<u8>> {
let name = r.strip_prefix("images/").unwrap_or(r);
if !valid_id(name.split('.').next().unwrap_or("")) || name.split('.').count() > 2 {
return None;
}
// A clone shares the parsed directory and the bytes; the resolver only has shared access.
let mut z = self.zip.clone();
crate::read_member(&mut z, &format!("images/{name}"), MAX_IMAGE).ok()
}
}
/// The major version of the Sketch that saved the file (`meta.json` `appVersion`, "50.2" → 50).
fn app_major(meta: &Value) -> Option<u32> {
meta.get("appVersion").and_then(Value::as_str)?.split('.').next()?.trim().parse().ok()
}
/// Ids name archive members: letters, digits, `-` and `_` only, never a path.
fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
+111
View File
@@ -0,0 +1,111 @@
//! `.sketch` import: detection, the archive, the version cut and pages, on fixtures built in code.
use serde_json::json;
use super::fixture::*;
use super::*;
fn page_names(doc: &Document) -> Vec<String> {
doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect()
}
#[test]
fn detects_sketch_and_not_others() {
let z = sketch_zip("50.2", 103, document(&["P1"]), &[page("P1", "Page 1", vec![])], &[]);
assert!(is_sketch(&z));
assert!(!is_sketch(b"fig-kiwi...."));
assert!(!is_sketch(b"{}"));
assert!(!is_sketch(b""));
// A zip that isn't a Sketch file.
let mut other = Vec::new();
{
use std::io::Write;
let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut other));
w.start_file("canvas.fig", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(b"x").unwrap();
w.finish().unwrap();
}
assert!(!is_sketch(&other));
}
#[test]
fn pages_become_pages_in_document_order() {
let z = sketch_zip("50.2", 103, document(&["B", "A"]), &[page("A", "Alpha", vec![]), page("B", "Beta", vec![])], &[]);
let (doc, _) = import(&z, "f").unwrap();
assert_eq!(page_names(&doc), ["Beta", "Alpha"]);
}
#[test]
fn older_than_48_is_refused() {
let z = sketch_zip("47.1", 95, document(&["P"]), &[page("P", "P", vec![])], &[]);
let e = import(&z, "f").unwrap_err().to_string();
assert!(e.contains(TOO_OLD), "{e}");
let mut sqlite = b"SQLite format 3\0".to_vec();
sqlite.resize(512, 0);
assert!(is_sketch(&sqlite));
assert!(import(&sqlite, "f").unwrap_err().to_string().contains(TOO_OLD));
}
#[test]
fn archived_text_without_meta_version_is_refused() {
let text = json!({
"_class": "text", "do_objectID": "T", "name": "T",
"frame": {"_class": "rect", "x": 0, "y": 0, "width": 10, "height": 10},
"attributedString": {"_class": "MSAttributedString", "archivedAttributedString": {"_archive": "YnBsaXN0MDA="}},
});
let z = raw_zip(&document(&["P"]), &json!({"app": "com.bohemiancoding.sketch3"}), &[page("P", "P", vec![text])], &[]);
let e = import(&z, "f").unwrap_err().to_string();
assert!(e.contains(TOO_OLD), "{e}");
}
#[test]
fn newer_than_tested_imports_with_one_warning() {
let z = sketch_zip("2099.1", NEWEST_TESTED_VERSION + 1, document(&["P"]), &[page("P", "P", vec![])], &[]);
let (_, warnings) = import(&z, "f").unwrap();
assert_eq!(warnings.iter().filter(|w| w.contains("newer Sketch")).count(), 1, "{warnings:?}");
}
#[test]
fn junk_never_panics() {
let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", vec![rect("R", 0.0, 0.0, 10.0, 10.0)])], &[]);
for cut in (0..z.len()).step_by(7) {
let _ = import(&z[..cut], "f");
}
let mut seed = 0x2545_f491_u32;
for _ in 0..200 {
let mut b = z.clone();
for _ in 0..8 {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
let i = seed as usize % b.len();
b[i] = (seed >> 8) as u8;
}
let _ = import(&b, "f");
}
for doc in [json!([]), json!(null), json!({"pages": 5}), json!({"pages": [{"_ref": 7}, {"_ref": "pages/../../x"}]})] {
let _ = import(&sketch_zip("50.2", 103, doc, &[json!(5), json!({"do_objectID": "Q", "layers": "x"})], &[]), "f");
}
let _ = import(&raw_zip(&document(&["P"]), &json!("x"), &[], &[]), "f");
}
#[test]
fn deep_nesting_does_not_fail_the_file() {
let mut deep = rect("R", 0.0, 0.0, 1.0, 1.0);
for i in 0..300 {
deep = group(&format!("G{i}"), 0.0, 0.0, 1.0, 1.0, vec![deep]);
}
let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Deep", vec![deep]), page("B", "Normal", vec![])], &[]);
let (doc, warnings) = import(&z, "f").unwrap();
assert!(page_names(&doc).contains(&"Normal".to_string()));
assert!(!warnings.is_empty());
}
#[test]
fn members_over_budget_are_skipped() {
let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Big", vec![]), page("B", "Small", vec![])], &[]);
let (doc, warnings) = import_within(&z, "f", 200).unwrap();
// Each page JSON is larger than 200 bytes: both are skipped, with a warning; nothing panics.
assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}");
assert!(!page_names(&doc).contains(&"Big".to_string()));
}
+81
View File
@@ -0,0 +1,81 @@
//! Sketch pages and layers → REST API file JSON nodes.
use figcraft_doc::VectorNetwork;
use serde_json::{Map as JsonMap, Value, json};
use super::Archive;
/// Deepest layer nesting followed (deeper layers are skipped with a warning).
const MAX_DEPTH: usize = 200;
/// The REST JSON of a Sketch file, with what the mapping noticed.
pub(crate) struct Map {
pub json: Value,
pub warnings: Vec<String>,
/// Vector networks the JSON references by `vectorNetworkRef`.
pub networks: Vec<Option<VectorNetwork>>,
/// A text layer stored the pre-48 way was met: the file is refused.
pub too_old: bool,
}
/// The mapping's state while walking the file.
pub(crate) struct Cx {
pub warnings: Vec<String>,
pub networks: Vec<Option<VectorNetwork>>,
pub too_old: bool,
}
impl Cx {
pub(crate) fn warn(&mut self, msg: impl Into<String>) {
let msg = msg.into();
if !self.warnings.contains(&msg) {
self.warnings.push(msg);
}
}
}
/// Map the pages of `archive` to a REST file JSON named `name`.
pub(crate) fn to_rest(archive: &Archive, name: &str) -> Map {
let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false };
let mut canvases = Vec::new();
for (id, page) in &archive.pages {
let mut node = JsonMap::new();
node.insert("type".into(), json!("CANVAS"));
node.insert("id".into(), json!(id));
node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page")));
let kids: Vec<Value> = layers(page).iter().filter_map(|l| layer(&mut cx, l, 0)).collect();
node.insert("children".into(), Value::Array(kids));
canvases.push(Value::Object(node));
}
let json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}});
Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old }
}
/// One Sketch layer as a REST node (with its children), or `None` when it is skipped.
pub(crate) fn layer(cx: &mut Cx, l: &Value, depth: usize) -> Option<Value> {
if depth > MAX_DEPTH {
cx.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
return None;
}
if is_archived_text(l) {
cx.too_old = true;
return None;
}
let class = str_field(l, "_class")?;
cx.warn(format!("Sketch {class} layers are not supported yet and were skipped"));
None
}
/// A text layer whose string is a binary property list (Sketch 43–47).
fn is_archived_text(l: &Value) -> bool {
l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some())
}
/// The child layers of a page or layer.
pub(crate) fn layers(l: &Value) -> &[Value] {
l.get("layers").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default()
}
pub(crate) fn str_field<'v>(v: &'v Value, key: &str) -> Option<&'v str> {
v.get(key).and_then(Value::as_str)
}
+7
View File
@@ -900,3 +900,10 @@ fn dropping_an_unsupported_file_on_the_home_screen_opens_nothing() {
assert!(u.app.ui.home);
assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .json, .svg or image file."));
}
#[test]
fn sketch_files_open_and_drop_as_documents() {
let p = std::path::Path::new("/tmp/Design.SKETCH");
assert!(crate::opens(p));
assert!(crate::is_document(p));
}
+4 -4
View File
@@ -1201,7 +1201,7 @@ impl FigApp {
let exts: &[&str] = if place {
&["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]
} else {
&["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"]
&["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig", "sketch"]
};
let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") };
self.pick_file(title, exts, true, |app, file| {
@@ -1823,17 +1823,17 @@ impl FigApp {
}
/// The file types File ▸ Open and a drop on the home screen open.
pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"];
pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"];
/// Whether `path` names a file type that opens as a document (the extension, in any case).
pub(crate) fn opens(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG:
/// Whether `path` is a whole document (`.figcraft`, `.fig`, `.sketch`, file JSON) rather than an image or SVG:
/// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one.
pub(crate) fn is_document(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e)))
path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "sketch", "json"].iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// Reads the contents of dropped file `name` that has no file on disk to read by path.