mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ff9d966ea6
commit
a0542e581c
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user