Files
figcraft/crates/engine/src/sketch_import_tests.rs
T
bflatastic 7a1b9d8414 #1021: a named style's optical size follows the text size only for .sketch imports
Existing text keeps the named instance's optical size, so documents render as before; the
Sketch import stores the size as the text's opsz value instead.
2026-10-26 09:12:40 +07:00

530 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `.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 figcraft_doc::NodeKind;
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> {
sketch_with(app_version, pages, &[])
}
/// [`sketch`] with `layers` on the first page.
fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> Vec<u8> {
sketch_with_doc(app_version, pages, layers, json!({}))
}
/// [`sketch_with`] with the fields of `extra` added to `document.json` (shared styles…).
fn sketch_with_doc(app_version: &str, pages: &[(&str, &str)], layers: &[Value], extra: Value) -> Vec<u8> {
let refs: Vec<Value> = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect();
let mut document = json!({"_class": "document", "pages": refs});
for (k, v) in extra.as_object().into_iter().flatten() {
document[k] = v.clone();
}
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(document.to_string().as_bytes()).unwrap();
z.start_file("meta.json", o).unwrap();
// The format version that Sketch writes (103 for 50; 110 and up from 52).
let version = if app_version.split('.').next().and_then(|v| v.parse::<u32>().ok()).is_some_and(|v| v >= 52) { 125 } else { 103 };
z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": version}).to_string().as_bytes()).unwrap();
for (i, (id, name)) in pages.iter().enumerate() {
let ls = if i == 0 { layers.to_vec() } else { Vec::new() };
z.start_file(format!("pages/{id}.json"), o).unwrap();
z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": ls}).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);
}
fn shape(class: &str, name: &str, frame: [f64; 4], extra: Value) -> Value {
let mut l = json!({"_class": class, "do_objectID": name, "name": name, "isVisible": true, "resizingConstraint": 63,
"frame": {"x": frame[0], "y": frame[1], "width": frame[2], "height": frame[3]}, "style": {"fills": []}});
for (k, v) in extra.as_object().unwrap() {
l[k] = v.clone();
}
l
}
fn fill(grey: f64) -> Value {
json!({"style": {"fills": [{"isEnabled": true, "fillType": 0, "color": {"red": grey, "green": grey, "blue": grey, "alpha": 1}}]}})
}
/// Sketch draws a mask's own fill under what it clips; the clipped layers show inside it.
#[test]
fn sketch_masks_show_their_content() {
let oval = shape("oval", "Ring", [0.0, 0.0, 60.0, 60.0], json!({"booleanOperation": -1, "points": []}));
let mut mask = shape("shapeGroup", "Circle", [0.0, 0.0, 60.0, 60.0], fill(0.8));
mask["hasClippingMask"] = json!(true);
mask["layers"] = json!([oval]);
let pts = ["{0, 0}", "{1, 0}", "{1, 1}", "{0, 1}"].map(|p| json!({"point": p, "curveFrom": p, "curveTo": p, "curveMode": 1}));
let body = shape("shapePath", "Square", [0.0, 0.0, 20.0, 20.0], json!({"points": pts, "isClosed": true, "booleanOperation": -1}));
let mut content = shape("shapeGroup", "Body", [20.0, 20.0, 20.0, 20.0], fill(0.2));
content["layers"] = json!([body]);
let icon = shape("group", "Icon", [0.0, 0.0, 60.0, 60.0], json!({"layers": [mask, content]}));
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[icon]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let icon = doc.nodes().find(|n| n.name == "Icon").unwrap().id;
let pix = figcraft_render::render_node(doc, icon, 1.0).unwrap();
let px = |x: u32, y: u32| pix.pixel(x, y).unwrap();
// The square in the middle, dark grey.
let c = px(30, 30);
assert!(c.alpha() > 200 && c.red() < 100, "content: {c:?}");
// The mask's own fill around it, light grey; outside the circle nothing.
let m = px(30, 8);
assert!(m.alpha() > 200 && m.red() > 150, "mask fill: {m:?}");
assert!(px(2, 2).alpha() < 50);
}
/// Family names guessed from PostScript names find the installed family whatever its spaces.
#[test]
fn sketch_font_families_match_installed_ones() {
let installed = ["PingFang SC".to_string(), "Helvetica Neue".to_string(), "IBM Plex Sans".to_string()];
let m = |f: &str| crate::cmd::file::installed_family(f, &installed);
assert_eq!(m("Ping Fang SC").as_deref(), Some("PingFang SC"));
assert_eq!(m("Helvetica Neue"), None, "already right");
assert_eq!(m("IBMPlex Sans").as_deref(), Some("IBM Plex Sans"));
assert_eq!(m("Comic Sans"), None);
}
/// Editing a master changes its instances, and a resized instance's layout isn't an override.
#[test]
fn sketch_instances_stay_live() {
let tag = shape("rectangle", "Tag", [80.0, 0.0, 20.0, 40.0], json!({"resizingConstraint": 60, "points": []}));
let master = shape("symbolMaster", "Bar", [0.0, 0.0, 100.0, 40.0], json!({"symbolID": "S", "layers": [tag]}));
let inst = shape("symbolInstance", "Wide", [0.0, 100.0, 200.0, 40.0], json!({"symbolID": "S", "overrideValues": []}));
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[master, inst]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let find = |s: &Session, inst: bool| {
let doc = s.doc().unwrap();
let root = doc
.nodes()
.find(|n| if inst { matches!(n.kind, NodeKind::Instance { .. }) } else { matches!(n.kind, NodeKind::Component { .. }) })
.unwrap()
.id;
doc.subtree(root).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Tag")).unwrap()
};
let (main_tag, inst_tag) = (find(&s, false), find(&s, true));
assert!(s.doc().unwrap().node(inst_tag).unwrap().overrides.is_empty(), "{:?}", s.doc().unwrap().node(inst_tag).unwrap().overrides);
s.execute("node.set", &json!({"id": main_tag, "props": {"fill": "#ff0000"}})).unwrap();
let doc = s.doc().unwrap();
let fill = &doc.node(inst_tag).unwrap().fills;
assert!(fill.iter().any(|p| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r > 0.99 && color.g < 0.01)), "{fill:?}");
let b = doc.world_bounds(inst_tag).unwrap();
assert!((b.x0 - 180.0).abs() < 1e-6, "{b:?}");
}
/// Local only (`FIGCRAFT_SKETCH_SAMPLES` names a folder of `.sketch` files): each opens (Sketch 48
/// and newer) with its instances linked and finite geometry, or is refused as too old.
#[test]
fn local_sketch_samples_open() {
let Ok(dir) = std::env::var("FIGCRAFT_SKETCH_SAMPLES") else { return };
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect();
files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch")));
files.sort();
assert!(!files.is_empty(), "no .sketch files in {dir}");
for path in files {
let started = std::time::Instant::now();
let mut s = Session::new();
match s.execute("file.open", &json!({"path": path.to_string_lossy()})) {
Err(e) => {
let e = e.to_string();
assert!(e.contains("older than 48"), "{}: {e}", path.display());
eprintln!("{}: refused as too old", path.display());
}
Ok(r) => {
let doc = s.doc().unwrap();
let (mut layers, mut instances, mut unlinked) = (0, 0, 0);
for n in doc.nodes() {
layers += 1;
assert!(
n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()),
"{}: {}",
path.display(),
n.name
);
if let NodeKind::Instance { component, .. } = n.kind {
instances += 1;
if !doc.contains(component) {
unlinked += 1;
}
}
}
assert_eq!(unlinked, 0, "{}", path.display());
let notes = r.get("warnings").and_then(|w| w.as_array()).map_or(0, Vec::len);
eprintln!("{}: {layers} layers, {instances} instances, {notes} notes, {:?}", path.display(), started.elapsed());
}
}
}
}
/// Local only (`FIGCRAFT_SKETCH_SAMPLES` and `FIGCRAFT_SKETCHTOOL`, the path of Sketch's
/// `sketchtool`): every artboard of each sample (Sketch 48 and newer) drawn by FigCraft is compared
/// with Sketch's own export of it. Prints the share of pixels that differ per artboard (a channel
/// off by more than 40, on white); fails past 10% (a broken artboard, not antialiasing or fonts).
#[test]
fn local_sketch_samples_match_sketch() {
let (Ok(dir), Ok(tool)) = (std::env::var("FIGCRAFT_SKETCH_SAMPLES"), std::env::var("FIGCRAFT_SKETCHTOOL")) else { return };
// Text draws in the fonts Sketch uses only once the system fonts are known.
figcraft_text::fontdb::scan_system_fonts();
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect();
files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch")));
files.sort();
let mut worst: Vec<(f64, String)> = Vec::new();
for path in files {
let mut s = Session::new();
if s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err() {
continue;
}
let out = std::env::temp_dir().join(format!("figcraft-sketch-ref-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&out);
let status = std::process::Command::new(&tool)
.args(["export", "artboards"])
.arg(&path)
.arg(format!("--output={}", out.display()))
.args(["--formats=png", "--scales=1", "--use-id-for-name=YES", "--include-symbols=NO"])
.output()
.unwrap();
assert!(status.status.success(), "sketchtool failed on {}", path.display());
// Artboards by page and order, from the file itself.
let archive = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap();
let mut archive = archive;
let mut read = |name: &str| -> Value {
let mut s = String::new();
std::io::Read::read_to_string(&mut archive.by_name(name).unwrap(), &mut s).unwrap();
serde_json::from_str(&s).unwrap()
};
let document = read("document.json");
let doc = s.doc().unwrap();
let refs: Vec<String> = document["pages"].as_array().unwrap().iter().map(|r| r["_ref"].as_str().unwrap().to_string()).collect();
for (page_ref, page_id) in refs.iter().zip(doc.pages.iter()) {
let page = read(&format!("{page_ref}.json"));
let boards: Vec<String> = page["layers"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["_class"] == "artboard")
.map(|l| l["do_objectID"].as_str().unwrap().to_string())
.collect();
let frames: Vec<figcraft_doc::NodeId> =
doc.node(*page_id).unwrap().children.iter().copied().filter(|c| matches!(doc.node(*c).unwrap().kind, NodeKind::Frame)).collect();
assert_eq!(boards.len(), frames.len(), "{}: artboards of {page_ref}", path.display());
for (id, frame) in boards.iter().zip(frames) {
let Ok(reference) = image::open(out.join(format!("{id}.png"))) else { continue };
let reference = reference.to_rgba8();
let ours = figcraft_render::render_node(doc, frame, 1.0).unwrap();
let (w, h) = (reference.width().min(ours.width()), reference.height().min(ours.height()));
let ours_rgba = figcraft_render::to_rgba8(&ours);
// Both on white, straight alpha.
let on_white = |c: &[u8], i: usize| {
let a = f64::from(c[3]) / 255.0;
f64::from(c[i]) * a + 255.0 * (1.0 - a)
};
let mut bad = 0u64;
for y in 0..h {
for x in 0..w {
let r = reference.get_pixel(x, y).0;
let at = ((y * ours.width() + x) * 4) as usize;
let Some(o) = ours_rgba.get(at..at + 4) else { continue };
if (0..3).any(|i| (on_white(&r, i) - on_white(o, i)).abs() > 40.0) {
bad += 1;
}
}
}
let share = bad as f64 * 100.0 / (f64::from(w) * f64::from(h)).max(1.0);
let name = doc.node(frame).unwrap().name.clone();
eprintln!("{share:6.2}% {} {name}", path.file_name().unwrap().to_string_lossy());
worst.push((share, format!("{}: {name}", path.display())));
}
}
let _ = std::fs::remove_dir_all(&out);
}
worst.sort_by(|a, b| b.0.total_cmp(&a.0));
assert!(worst.first().is_none_or(|w| w.0 < 10.0), "{worst:?}");
}
/// Sketch draws Apple's fonts with the tracking their `trak` table gives at each size (the
/// platform does it on its own); the imported text carries it as letter spacing (macOS only).
#[test]
fn sketch_text_keeps_the_platform_tracking() {
if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() {
return;
}
figcraft_text::fontdb::scan_system_fonts();
let font = json!({"name": "SFUIText-Regular", "size": 13});
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
let t = shape("text", "Label", [0.0, 0.0, 100.0, 20.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0}));
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") };
let ls = td.style.letter_spacing.resolve(13.0);
assert!((ls - (-12.0 * 13.0 / 2048.0)).abs() < 1e-6, "{ls}");
// A named style's optical size follows the size there: the text carries it.
let f = figcraft_text::fontdb::find_face(&td.style.font_family, &td.style.font_style).unwrap();
let named = f.axis("opsz").is_some() && f.instance(&td.style.font_style).is_some();
assert_eq!(td.style.variations.get("opsz").copied(), named.then_some(13.0), "{:?}", td.style);
}
/// A text an override changed is measured again, and the stack around it re-flows: a hugging chip
/// grows to its new label.
#[test]
fn sketch_overridden_text_reflows_its_stack() {
let font = json!({"name": "Helvetica", "size": 12});
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
let label = shape(
"text",
"Label",
[20.0, 4.0, 14.0, 16.0],
json!({"attributedString": {"string": "Go", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}),
);
let dot = shape("oval", "Dot", [8.0, 9.0, 6.0, 6.0], json!({"points": []}));
let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 0, "alignItems": 1, "allGuttersGap": 6, "stackingOrder": 0});
let master = shape(
"symbolMaster",
"Chip",
[0.0, 0.0, 44.0, 24.0],
json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1,
"leftPadding": 8, "rightPadding": 10, "topPadding": 4, "bottomPadding": 4, "layers": [label, dot]}),
);
let ov = json!([{"_class": "overrideValue", "overrideName": "Label_stringValue", "value": "A much longer label here"}]);
let inst = shape(
"symbolInstance",
"Long",
[0.0, 100.0, 160.0, 24.0],
json!({"symbolID": "S", "overrideValues": ov, "horizontalSizing": 1, "verticalSizing": 1}),
);
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let inst = doc.nodes().find(|n| n.name == "Long").unwrap().id;
let label = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Label")).unwrap();
let dot = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Dot")).unwrap();
let (i, l, d) = (doc.world_bounds(inst).unwrap(), doc.world_bounds(label).unwrap(), doc.world_bounds(dot).unwrap());
assert!(l.x1 - l.x0 > 60.0, "the label was measured: {l:?}");
assert!((d.x0 - i.x0 - 8.0).abs() < 0.5, "the dot after the padding: {d:?} in {i:?}");
assert!((l.x0 - d.x1 - 6.0).abs() < 0.5, "the label after the gap: {l:?}");
assert!((i.x1 - l.x1 - 10.0).abs() < 0.5, "the chip hugs: {i:?} {l:?}");
}
/// An instance sized apart from its master lays its master's stack out at its own size: a wide
/// button keeps its label and icon together in the middle.
#[test]
fn sketch_resized_stack_instances_reflow() {
let a = shape("rectangle", "A", [22.0, 12.0, 40.0, 20.0], json!({"points": []}));
let b = shape("rectangle", "B", [70.0, 12.0, 20.0, 20.0], json!({"points": []}));
let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 1, "alignItems": 1, "allGuttersGap": 8, "stackingOrder": 0});
let master = shape(
"symbolMaster",
"Button",
[0.0, 0.0, 112.0, 44.0],
json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1,
"leftPadding": 22, "rightPadding": 22, "topPadding": 12, "bottomPadding": 12, "layers": [b, a]}),
);
let inst = shape(
"symbolInstance",
"Wide",
[0.0, 100.0, 342.0, 44.0],
json!({"symbolID": "S", "overrideValues": [], "horizontalSizing": 0, "verticalSizing": 1}),
);
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let inst = doc.nodes().find(|n| n.name == "Wide").unwrap().id;
let at = |name: &str| doc.world_bounds(doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == name)).unwrap()).unwrap();
let (a, b) = (at("A"), at("B"));
// 40 + 8 + 20 = 68 wide, centred in 342: from 137.
assert!((a.x0 - 137.0).abs() < 0.5, "{a:?}");
assert!((b.x0 - a.x1 - 8.0).abs() < 0.5, "{a:?} {b:?}");
assert!((doc.world_bounds(inst).unwrap().x1 - 342.0).abs() < 1e-6);
}
/// Sketch's natural line: the font's ascent and descent each rounded to whole pixels, without its
/// line gap, the baseline at the rounded ascent; the classic Mac fonts get a fifth more, all of it
/// above the text.
#[test]
fn sketch_natural_line_rule() {
use crate::cmd::file::sketch_line;
// Helvetica Neue Medium (975 + 217 of 1000, a gap of 29 left out) at 17 px: 16.575 → 17 and
// 3.689 → 4, a line of 21 with the baseline at 17. FigCraft puts the baseline at
// (21 − 20.264) / 2 + 16.575 = 16.943, so the text moves down 0.057.
let (line, down) = sketch_line(975.0, 217.0, 1000.0, "Helvetica Neue", 17.0);
assert!((line - 21.0).abs() < 1e-9 && (down - 0.057).abs() < 1e-9, "{line} {down}");
// Helvetica at 100 px: 77 + 23 and a fifth more, 120, all of it above: the baseline at 97
// where FigCraft centres it at 87.
let (line, down) = sketch_line(1577.0, 471.0, 2048.0, "helvetica", 100.0);
assert!((line - 120.0).abs() < 1e-9 && (down - 10.0).abs() < 0.05, "{line} {down}");
assert_eq!(sketch_line(0.0, 0.0, 0.0, "X", 12.0), (0.0, 0.0), "no metrics, no change");
assert_eq!(sketch_line(900.0, 200.0, 1000.0, "X", f64::NAN), (0.0, 0.0));
}
/// Text without a fixed line height gets Sketch's natural line as a share of its size, in the
/// layer and in shared text styles.
#[test]
fn sketch_text_gets_sketchs_natural_line() {
if !std::path::Path::new("/System/Library/Fonts/HelveticaNeue.ttc").exists() {
return;
}
figcraft_text::fontdb::scan_system_fonts();
let font = json!({"name": "HelveticaNeue", "size": 100});
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
let t = shape("text", "Label", [0.0, 0.0, 200.0, 120.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0}));
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") };
// 95.2 → 95 and 21.3 → 21: 116 px, as a share of the size.
let figcraft_doc::LineHeight::Percent(p) = td.style.line_height else { panic!("{:?}", td.style.line_height) };
assert!((p - 116.0).abs() < 1e-9, "{p}");
}
/// A text an auto layout places isn't moved to Sketch's baseline (the next layout pass would take
/// the move back): laying the stack out again leaves it where the import put it.
#[test]
fn sketch_text_in_a_stack_stays_put() {
if !std::path::Path::new("/System/Library/Fonts/Helvetica.ttc").exists() {
return;
}
figcraft_text::fontdb::scan_system_fonts();
let font = json!({"name": "Helvetica", "size": 40});
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
let label = shape(
"text",
"Label",
[10.0, 10.0, 50.0, 48.0],
json!({"attributedString": {"string": "Hi", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}),
);
let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 1, "justifyContent": 0, "alignItems": 0, "allGuttersGap": 0, "stackingOrder": 0});
let stack = shape(
"group",
"Stack",
[0.0, 0.0, 200.0, 68.0],
json!({"groupBehavior": 1, "groupLayout": layout, "leftPadding": 10, "topPadding": 10, "rightPadding": 10, "bottomPadding": 10, "layers": [label]}),
);
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[stack]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let mut doc = s.doc().unwrap().clone();
let stack = doc.nodes().find(|n| n.name == "Stack").unwrap().id;
let label = doc.nodes().find(|n| n.name == "Label").unwrap().id;
let before = doc.world_bounds(label).unwrap();
figcraft_layout::layout_frame(&mut doc, stack, &crate::measure::TextMeasure);
let after = doc.world_bounds(label).unwrap();
assert!((before.y0 - after.y0).abs() < 1e-6, "{before:?} → {after:?}");
}
/// Shared text styles get what their linked layers get on import: the installed family's name and
/// the platform's tracking (a layer and its style stay alike).
#[test]
fn sketch_shared_text_styles_match_their_layers() {
if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() {
return;
}
figcraft_text::fontdb::scan_system_fonts();
let attrs = json!({"MSAttributedStringFontAttribute": {"attributes": {"name": "SFUIText-Regular", "size": 13}}});
let style = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": attrs.clone()}});
let shared = json!({"_class": "sharedStyle", "do_objectID": "TS", "name": "Body", "value": style.clone()});
let run = json!({"location": 0, "length": 2, "attributes": attrs});
let mut linked = style;
linked["sharedObjectID"] = json!("TS");
let t = shape(
"text",
"Label",
[0.0, 0.0, 100.0, 20.0],
json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0, "style": linked}),
);
let extra = json!({"layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": [shared]}});
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with_doc("64", &[("A", "Page")], &[t], extra));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") };
let st = doc
.styles
.iter()
.find_map(|st| match &st.kind {
figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()),
_ => None,
})
.unwrap();
assert_eq!(st.font_family, td.style.font_family);
assert_eq!(st.letter_spacing.resolve(13.0), td.style.letter_spacing.resolve(13.0), "{st:?}");
}