Files
figcraft/crates/format/src/sketch/mod.rs
T
Jindřich ŠaršonandClaude Opus 5.5 a0542e581c M6: Sketch import: text with styled runs and font names
Text layers keep their string and styled runs (font, size, colour, line
height, letter spacing, decorations, text transform; ranges in UTF-16
units, clamped), paragraph alignment and spacing, vertical alignment and
how the box sizes. Fonts named by PostScript name become family and
style; the engine then matches families to installed ones ignoring
spaces (PingFangSC → PingFang SC). Fonts embedded in newer files are
named in the import report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:27:20 +02:00

185 lines
8.5 KiB
Rust

//! 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;
mod path;
#[cfg(test)]
mod path_tests;
mod style;
#[cfg(test)]
mod style_tests;
#[cfg(test)]
mod tests;
mod text;
#[cfg(test)]
mod text_tests;
mod tree;
#[cfg(test)]
mod tree_tests;
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, max_nodes);
if map.too_old {
return Err(FormatError::Io(TOO_OLD.into()));
}
let mut warnings = archive.warnings.clone();
warnings.extend(map.warnings);
if let Some(note) = embedded_fonts(&archive.document) {
warnings.push(note);
}
let images = |r: &str| archive.image(r);
let (mut doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?;
if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) {
doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3;
}
warnings.extend(more);
Ok((doc, warnings))
}
/// The note about fonts the file carries (Sketch 7x and newer can embed them): FigCraft doesn't
/// use fonts from inside documents, so their text shows in installed fonts.
fn embedded_fonts(document: &Value) -> Option<String> {
let refs = document.get("fontReferences").and_then(Value::as_array)?;
let families: BTreeSet<String> =
refs.iter().filter_map(|r| r.get("fontFamilyName").and_then(Value::as_str)).map(|f| f.chars().take(64).collect()).take(20).collect();
if families.is_empty() {
return None;
}
let list: Vec<&str> = families.iter().map(String::as_str).collect();
Some(format!("the file has embedded fonts FigCraft doesn't use ({}); install them to see the text as designed", list.join(", ")))
}
fn corrupt(msg: impl Into<String>) -> FormatError {
FormatError::Corrupt(format!("Sketch file: {}", msg.into()))
}
/// The parts of a `.sketch` archive: the document, the pages in document order and the images.
pub(crate) struct Archive<'b> {
pub document: Value,
/// `(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 { document, 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'_')
}