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>
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 ff9d966ea6
commit a0542e581c
8 changed files with 442 additions and 1 deletions
+35
View File
@@ -558,11 +558,46 @@ fn load_with(
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)| {
match_font_families(&mut doc);
link_instances(&mut doc);
figcraft_format::Loaded { issues: warnings, ..doc.into() }
}))
}
/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from
/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and
/// case.
fn match_font_families(doc: &mut Document) {
let installed = figcraft_text::fontdb::family_names();
let mut cache: std::collections::BTreeMap<String, Option<String>> = std::collections::BTreeMap::new();
let mut fix = |style: &mut figcraft_doc::TextStyle| {
let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed));
if let Some(f) = found {
style.font_family = f.clone();
}
};
let ids: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect();
for id in ids {
if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) {
fix(&mut td.style);
for r in &mut td.runs {
fix(&mut r.style);
}
}
}
}
/// The installed family `family` means when it isn't installed under that exact name: the one
/// whose name is the same without spaces, ignoring case.
pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option<String> {
let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::<String>();
if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) {
return None;
}
let want = key(family);
installed.iter().find(|f| key(f) == want).cloned()
}
/// 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)?;
+11
View File
@@ -134,3 +134,14 @@ fn sketch_masks_show_their_content() {
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);
}
+1 -1
View File
@@ -115,7 +115,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
/// A font style under the name the format uses: the standard weight names as words ("SemiBold"
/// → "Semi Bold", "ExtraBoldItalic" → "Extra Bold Italic", "Regular Italic" → "Italic"). Other
/// names stay as they are.
fn style_name(style: &str) -> String {
pub(crate) fn style_name(style: &str) -> String {
const WEIGHTS: [&str; 12] =
["Thin", "Extra Light", "Ultra Light", "Light", "Regular", "Medium", "Semi Bold", "Demi Bold", "Bold", "Extra Bold", "Ultra Bold", "Black"];
let compact = |s: &str| s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect::<String>();
+19
View File
@@ -149,3 +149,22 @@ pub(crate) fn styled(mut l: Value, key: &str, value: Value) -> Value {
l["style"][key] = value;
l
}
/// A text layer showing `string`, one run per `(location, length, font, size, rgba)`; locations and
/// lengths count UTF-16 code units, as Sketch does.
pub(crate) fn text(id: &str, x: f64, y: f64, w: f64, h: f64, string: &str, runs: &[(usize, usize, &str, f64, [f64; 4])]) -> Value {
let attrs: Vec<Value> = runs
.iter()
.map(|(loc, len, font, size, [r, g, b, a])| {
json!({"_class": "stringAttribute", "location": loc, "length": len, "attributes": {
"MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": font, "size": size}},
"MSAttributedStringColorAttribute": color(*r, *g, *b, *a),
"paragraphStyle": {"_class": "paragraphStyle", "alignment": 0},
}})
})
.collect();
let mut l = layer("text", id, id, x, y, w, h);
l["attributedString"] = json!({"_class": "attributedString", "string": string, "attributes": attrs});
l["textBehaviour"] = json!(1);
l
}
+19
View File
@@ -21,6 +21,9 @@ mod style;
mod style_tests;
#[cfg(test)]
mod tests;
mod text;
#[cfg(test)]
mod text_tests;
mod tree;
#[cfg(test)]
mod tree_tests;
@@ -81,6 +84,9 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result
}
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) {
@@ -90,6 +96,19 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result
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()))
}
+165
View File
@@ -0,0 +1,165 @@
//! Sketch text layers → REST text nodes.
//!
//! A text layer's `attributedString` holds the `string` and `attributes`: runs given by `location`
//! and `length` in UTF-16 code units (like the REST importer's per-character style ids), each with
//! a font (PostScript name and size), colour, kerning, paragraph style, decorations and text
//! transform. `textBehaviour` sizes the box (0 auto width, 1 fixed width, 2 fixed); the style's
//! `textStyle` gives the vertical alignment.
use serde_json::{Map as JsonMap, Value, json};
use super::tree::{Cx, color};
/// Most characters read from one text layer (the REST importer's own limit).
const MAX_CHARS: usize = 1 << 20;
/// Most runs read from one text layer.
const MAX_RUNS: usize = 10_000;
/// Fill `node` (a TEXT node) from text layer `l`.
pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap<String, Value>) {
let a = l.get("attributedString");
let string: String = a.and_then(|a| a.get("string")).and_then(Value::as_str).unwrap_or("").chars().take(MAX_CHARS).collect();
let units = string.encode_utf16().count();
let runs: &[Value] = a.and_then(|a| a.get("attributes")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
if runs.len() > MAX_RUNS {
cx.warn(format!("text with more than {MAX_RUNS} styled runs keeps the first ones"));
}
let fallback = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("encodedAttributes"));
let styles: Vec<(usize, usize, Value)> = runs
.iter()
.take(MAX_RUNS)
.filter_map(|r| {
let start = usize::try_from(r.get("location")?.as_u64()?).ok()?.min(units);
let len = usize::try_from(r.get("length")?.as_u64()?).unwrap_or(usize::MAX);
Some((start, start.saturating_add(len).min(units), type_style(cx, r.get("attributes")?)))
})
.collect();
let base = styles.first().map(|s| s.2.clone()).or_else(|| fallback.map(|f| type_style(cx, f))).unwrap_or_else(|| type_style(cx, &json!({})));
let mut style = base.clone();
let mut fills = None;
if let Some(m) = style.as_object_mut() {
fills = m.remove("fills");
// Paragraph settings are the first run's.
let paragraph = runs.first().and_then(|r| r.get("attributes")).or(fallback).and_then(|a| a.get("paragraphStyle"));
let align = match paragraph.and_then(|p| p.get("alignment")).and_then(Value::as_i64) {
Some(1) => "RIGHT",
Some(2) => "CENTER",
Some(3) => "JUSTIFIED",
_ => "LEFT",
};
m.insert("textAlignHorizontal".into(), json!(align));
if let Some(sp) = paragraph.and_then(|p| p.get("paragraphSpacing")).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) {
m.insert("paragraphSpacing".into(), json!(sp.min(1e4)));
}
let vertical = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("verticalAlignment")).and_then(Value::as_i64);
m.insert(
"textAlignVertical".into(),
json!(match vertical {
Some(1) => "CENTER",
Some(2) => "BOTTOM",
_ => "TOP",
}),
);
m.insert(
"textAutoResize".into(),
json!(match l.get("textBehaviour").and_then(Value::as_i64) {
Some(1) => "HEIGHT",
Some(2) => "NONE",
_ => "WIDTH_AND_HEIGHT",
}),
);
}
node.insert("characters".into(), json!(string));
node.insert("style".into(), style);
if let Some(f) = fills {
node.insert("fills".into(), f);
}
// Runs that differ from the first: a style id per UTF-16 unit.
let mut ids = vec![0u64; units];
let mut table = JsonMap::new();
for (start, end, s) in &styles {
if *s == base {
continue;
}
let id = table.len() as u64 + 1;
table.insert(id.to_string(), s.clone());
for slot in ids.get_mut(*start..*end).into_iter().flatten() {
*slot = id;
}
}
if !table.is_empty() {
node.insert("characterStyleOverrides".into(), json!(ids));
node.insert("styleOverrideTable".into(), Value::Object(table));
}
}
/// A run's attributes as a REST type style (with its colour as `fills`).
fn type_style(cx: &mut Cx, a: &Value) -> Value {
let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes"));
let ps = font.and_then(|f| f.get("name")).and_then(Value::as_str).unwrap_or("");
let size = font.and_then(|f| f.get("size")).and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(12.0);
let (family, style) = font_name(ps);
if ps.is_empty() {
cx.warn("text without a font uses Helvetica");
}
let mut s = json!({"fontFamily": family, "fontStyle": style, "fontSize": size.min(10_000.0)});
let p = a.get("paragraphStyle");
let line = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
if let Some(lh) = line("maximumLineHeight").or_else(|| line("minimumLineHeight")) {
s["lineHeightPx"] = json!(lh.min(1e4));
s["lineHeightUnit"] = json!("PIXELS");
}
if let Some(k) = a.get("kerning").and_then(Value::as_f64).filter(|k| k.is_finite()) {
s["letterSpacing"] = json!(k);
}
let set = |k: &str| a.get(k).and_then(Value::as_i64).is_some_and(|v| v != 0);
s["textDecoration"] = json!(if set("underlineStyle") {
"UNDERLINE"
} else if set("strikethroughStyle") {
"STRIKETHROUGH"
} else {
"NONE"
});
s["textCase"] = json!(match a.get("MSAttributedStringTextTransformAttribute").and_then(Value::as_i64) {
Some(1) => "UPPER",
Some(2) => "LOWER",
_ => "ORIGINAL",
});
let c = a.get("MSAttributedStringColorAttribute").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}));
s["fills"] = json!([{"type": "SOLID", "color": c}]);
s
}
/// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue",
/// "Bold"). Words of the family are split at case changes, which can't always match the real name
/// ("PingFangSC" → "Ping Fang SC"); the engine matches families against the installed fonts ignoring
/// spaces. Apple's system fonts take the SF Pro names.
pub(crate) fn font_name(ps: &str) -> (String, String) {
let (family, style) = ps.split_once('-').unwrap_or((ps, ""));
let style = if style.is_empty() { "Regular".to_string() } else { crate::fig::style_name(style) };
let family = match family {
"" => "Helvetica".to_string(),
".SFNSText" | ".SFUIText" | "SanFranciscoText" | ".SFProText" => "SF Pro Text".into(),
".SFNSDisplay" | ".SFUIDisplay" | "SanFranciscoDisplay" | ".SFProDisplay" => "SF Pro Display".into(),
f => words(f.trim_start_matches('.')),
};
(family, style)
}
/// "IBMPlexSans" → "IBM Plex Sans": a space before a capital that follows a small letter, and
/// before the last capital of a run of capitals followed by a small letter.
fn words(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len() + 4);
for (i, c) in chars.iter().enumerate() {
let prev = i.checked_sub(1).and_then(|p| chars.get(p));
let next = chars.get(i + 1);
let split = c.is_uppercase()
&& (prev.is_some_and(|p| p.is_lowercase()) || (prev.is_some_and(|p| p.is_uppercase()) && next.is_some_and(|n| n.is_lowercase())));
if split {
out.push(' ');
}
out.push(*c);
}
out
}
+188
View File
@@ -0,0 +1,188 @@
//! Sketch text layers → text with styled runs; font PostScript names → family and style.
use figcraft_doc::color::PaintKind;
use figcraft_doc::{Document, LetterSpacing, LineHeight, NodeKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration};
use serde_json::{Value, json};
use super::fixture::*;
use super::import;
use super::text::font_name;
const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0];
const RED: [f64; 4] = [1.0, 0.0, 0.0, 1.0];
fn open(layers: Vec<Value>) -> (Document, Vec<String>) {
let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]);
import(&z, "f").unwrap()
}
fn data<'d>(doc: &'d Document, name: &str) -> &'d TextData {
match &doc.nodes().find(|n| n.name == name).unwrap().kind {
NodeKind::Text(td) => td,
other => panic!("{name}: {other:?}"),
}
}
fn red(fills: &Option<Vec<figcraft_doc::color::Paint>>) -> bool {
fills.as_ref().is_some_and(|f| f.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0)))
}
#[test]
fn two_runs() {
let t = text("Hi", 0.0, 0.0, 200.0, 20.0, "Hello world", &[(0, 5, "HelveticaNeue-Bold", 16.0, RED), (5, 6, "HelveticaNeue", 12.0, BLACK)]);
let (doc, _) = open(vec![t]);
let td = data(&doc, "Hi");
assert_eq!(td.characters, "Hello world");
let style_at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style);
let (a, b) = (style_at(0), style_at(7));
assert_eq!((a.font_family.as_str(), a.font_style.as_str(), a.font_size), ("Helvetica Neue", "Bold", 16.0));
assert_eq!((b.font_family.as_str(), b.font_style.as_str(), b.font_size), ("Helvetica Neue", "Regular", 12.0));
let node = doc.nodes().find(|n| n.name == "Hi").unwrap();
assert!(red(&a.fills) || (red(&Some(node.fills.clone())) && !red(&b.fills)));
}
#[test]
fn utf16_ranges() {
// "a😀b": the emoji takes two UTF-16 units.
let t = text(
"Emoji",
0.0,
0.0,
200.0,
20.0,
"a😀b",
&[(0, 1, "Helvetica", 10.0, BLACK), (1, 2, "Helvetica", 20.0, BLACK), (3, 1, "Helvetica", 30.0, BLACK)],
);
let (doc, _) = open(vec![t]);
let td = data(&doc, "Emoji");
let size_at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(td.style.font_size, |r| r.style.font_size);
assert_eq!((size_at(0), size_at(1), size_at(2)), (10.0, 20.0, 30.0));
// A range splitting the emoji and one past the end: no panic, the text stays whole.
let t = text(
"Split",
0.0,
50.0,
200.0,
20.0,
"a😀b",
&[(0, 2, "Helvetica", 10.0, BLACK), (2, 99, "Helvetica", 20.0, BLACK), (500, 3, "Helvetica", 5.0, BLACK)],
);
let (doc, _) = open(vec![t]);
assert_eq!(data(&doc, "Split").characters, "a😀b");
}
#[test]
fn paragraph_and_spacing() {
let mut t = text("Para", 0.0, 0.0, 200.0, 40.0, "Centered", &[(0, 8, "Helvetica", 14.0, BLACK)]);
let a = &mut t["attributedString"]["attributes"][0]["attributes"];
a["paragraphStyle"] =
json!({"_class": "paragraphStyle", "alignment": 2, "maximumLineHeight": 24, "minimumLineHeight": 24, "paragraphSpacing": 8});
a["kerning"] = json!(0.5);
let (doc, _) = open(vec![t]);
let td = data(&doc, "Para");
assert_eq!(td.align_h, TextAlignH::Center);
assert_eq!(td.style.line_height, LineHeight::Pixels(24.0));
assert_eq!(td.paragraph_spacing, 8.0);
assert_eq!(td.style.letter_spacing, LetterSpacing::Pixels(0.5));
for (code, want) in [(0, TextAlignH::Left), (1, TextAlignH::Right), (3, TextAlignH::Justified), (4, TextAlignH::Left)] {
let mut t = text("A", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]);
t["attributedString"]["attributes"][0]["attributes"]["paragraphStyle"]["alignment"] = json!(code);
let (doc, _) = open(vec![t]);
assert_eq!(data(&doc, "A").align_h, want, "alignment {code}");
}
}
#[test]
fn behaviour_and_vertical_alignment() {
let cases = [(0, TextAutoResize::WidthAndHeight), (1, TextAutoResize::Height), (2, TextAutoResize::None)];
for (b, want) in cases {
let mut t = text("T", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]);
t["textBehaviour"] = json!(b);
let (doc, _) = open(vec![t]);
assert_eq!(data(&doc, "T").auto_resize, want, "behaviour {b}");
}
for (v, want) in [(0, TextAlignV::Top), (1, TextAlignV::Middle), (2, TextAlignV::Bottom)] {
let mut t = text("V", 0.0, 0.0, 100.0, 60.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]);
t["textBehaviour"] = json!(2);
t["style"]["textStyle"] = json!({"_class": "textStyle", "verticalAlignment": v, "encodedAttributes": {}});
let (doc, _) = open(vec![t]);
assert_eq!(data(&doc, "V").align_v, want, "vertical {v}");
}
}
#[test]
fn decorations_and_case() {
let mut t = text(
"Deco",
0.0,
0.0,
200.0,
20.0,
"abcdef",
&[(0, 2, "Helvetica", 14.0, BLACK), (2, 2, "Helvetica", 14.0, BLACK), (4, 2, "Helvetica", 14.0, BLACK)],
);
t["attributedString"]["attributes"][0]["attributes"]["underlineStyle"] = json!(1);
t["attributedString"]["attributes"][1]["attributes"]["strikethroughStyle"] = json!(1);
t["attributedString"]["attributes"][2]["attributes"]["MSAttributedStringTextTransformAttribute"] = json!(1);
let (doc, _) = open(vec![t]);
let td = data(&doc, "Deco");
let at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style);
assert_eq!(at(0).decoration, TextDecoration::Underline);
assert_eq!(at(2).decoration, TextDecoration::Strikethrough);
assert_eq!(at(4).case, TextCase::Upper);
}
#[test]
fn text_layer_fill_wins_over_run_colour() {
let t = styled(text("Filled", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)]));
let (doc, _) = open(vec![t]);
let n = doc.nodes().find(|n| n.name == "Filled").unwrap();
assert!(red(&Some(n.fills.clone())));
}
#[test]
fn font_names() {
let cases = [
("HelveticaNeue-Bold", "Helvetica Neue", "Bold"),
("HelveticaNeue", "Helvetica Neue", "Regular"),
("HelveticaNeue-Medium", "Helvetica Neue", "Medium"),
("PingFangSC-Semibold", "Ping Fang SC", "Semi Bold"),
(".SFNSText", "SF Pro Text", "Regular"),
(".SFNSDisplay-Bold", "SF Pro Display", "Bold"),
("SanFranciscoText-Semibold", "SF Pro Text", "Semi Bold"),
("Impact", "Impact", "Regular"),
("IBMPlexSans-MediumItalic", "IBM Plex Sans", "Medium Italic"),
("Roboto-Italic", "Roboto", "Italic"),
("", "Helvetica", "Regular"),
];
for (ps, family, style) in cases {
assert_eq!(font_name(ps), (family.to_string(), style.to_string()), "{ps}");
}
}
#[test]
fn long_and_odd_text_is_safe() {
let long = "x".repeat(10_000);
let t = text("Long", 0.0, 0.0, 100.0, 20.0, &long, &[(0, usize::MAX >> 1, "Helvetica", 14.0, BLACK)]);
let mut odd = text("Odd", 0.0, 0.0, 100.0, 20.0, "abc", &[(0, 3, "Helvetica", -5.0, BLACK)]);
odd["attributedString"]["attributes"][0]["attributes"]["MSAttributedStringFontAttribute"] = json!("junk");
let mut empty = text("Empty", 0.0, 0.0, 100.0, 20.0, "", &[]);
empty["attributedString"]["string"] = json!(5);
let (doc, _) = open(vec![t, odd, empty]);
assert_eq!(data(&doc, "Long").characters.len(), 10_000);
assert!(data(&doc, "Odd").style.font_size > 0.0);
}
#[test]
fn embedded_fonts_are_reported() {
let mut d = document(&["P"]);
d["fontReferences"] = json!([
{"_class": "fontReference", "fontFamilyName": "IBM Plex Sans", "fontFileName": "IBMPlexSans-Regular.otf", "fontData": {"_ref": "fonts/abc"}},
{"_class": "fontReference", "fontFamilyName": "IBM Plex Sans", "fontFileName": "IBMPlexSans-Bold.otf", "fontData": {"_ref": "fonts/def"}},
{"_class": "fontReference", "fontFamilyName": "IBM Plex Mono", "fontData": {"_ref": "fonts/ghi"}},
]);
let z = sketch_zip("83.2", 139, d, &[page("P", "Page", vec![])], &[("fonts/abc", b"x")]);
let (_, warnings) = import(&z, "f").unwrap();
let w = warnings.iter().find(|w| w.contains("embedded")).expect("a note about embedded fonts");
assert!(w.contains("IBM Plex Mono") && w.contains("IBM Plex Sans"), "{w}");
}
+4
View File
@@ -80,6 +80,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root:
"oval" => "ELLIPSE",
"slice" => "SLICE",
"shapePath" | "star" | "polygon" | "triangle" => "VECTOR",
"text" => "TEXT",
other => {
cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other)));
return None;
@@ -91,6 +92,9 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root:
let (w, h) = size(l);
let world = parent * local(l, w, h);
let mut node = base(l, ty, parent, world, w, h, root);
if ty == "TEXT" {
super::text::node(cx, l, &mut node);
}
if ty == "VECTOR" {
let shape = super::path::shape(cx, l, w, h)?;
super::path::put_shape(cx, shape, &mut node);