M1: let local text values and instance overrides win over a shared text style in .fig import

A text layer that links a shared text style keeps its own, possibly stale, copy
of the typography. The style's typography now beats that copy, a case,
decoration, paragraph spacing or paragraph indent set on the layer itself beats
the style (so "original", "none" and zero clear the style's value), and an
override row of an instance beats both. An explicit "no style" on a text layer
unlinks it even when the older style field still names one.

Letter spacing given as a percentage of the font size stays a percentage. A text
property value on an instance no longer inherits the character styles written
for the main's text, which count other characters.
This commit is contained in:
Skyfuller1303
2026-10-16 03:32:39 +02:00
parent 4b29d8c34d
commit d2606b3f51
4 changed files with 363 additions and 21 deletions
+63 -21
View File
@@ -167,6 +167,16 @@ impl<'a> View<'a> {
v
}
/// This view with `style` just above the node itself: the style beats the node's own copy of
/// its fields (stale when the style changed since), and override rows beat both.
fn styled(&self, style: Option<&'a Kv<'a>>) -> Self {
let mut v = self.clone();
if let Some(s) = style {
v.layers.insert(v.layers.len().min(1), s);
}
v
}
fn get(&self, k: &str) -> Option<&'a Kv<'a>> {
self.layers.iter().rev().find_map(|l| l.get(k))
}
@@ -1095,19 +1105,30 @@ impl<'a> Mapper<'a> {
}
fn text(&mut self, o: &mut Obj, v: &View<'a>) {
let style_node = v
.get("styleIdForText")
.and_then(ref_guid)
.or_else(|| v.get("inheritTextStyleID").and_then(guid))
.and_then(|s| self.nodes.get(&s).copied());
let sv = v.with(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")));
let style_node = style_link(v, "styleIdForText", "inheritTextStyleID").and_then(|s| self.nodes.get(&s).copied());
// Instance overrides beat the shared style; the style beats the layer's own copy.
let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")));
let td = v.get("textData");
let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or("");
o.insert("characters".into(), json!(chars));
let mut style = text_style(&sv, None);
// The style's typography beats the layer's own copy of it (the copy is stale when the style
// changed). The file cannot tell a stale copy of these four values from a deliberate local
// one, and "original", "none" and 0 could never be set locally if the style always won, so
// a value the layer or an override sets itself is taken as deliberate.
if let Some(decoration @ ("NONE" | "UNDERLINE" | "STRIKETHROUGH")) = v.str("textDecoration") {
style.insert("textDecoration".into(), json!(decoration));
}
if let Some(case @ ("ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED")) = v.str("textCase") {
style.insert("textCase".into(), json!(case));
}
for k in ["textAlignHorizontal", "textAlignVertical", "textAutoResize", "textTruncation"] {
copy_str(&mut style, v, k, k);
}
// Paragraph settings belong to the effective text layer, including explicit zero clears.
for k in ["paragraphSpacing", "paragraphIndent"] {
copy_num(&mut style, v, k, k);
}
if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) {
style.insert("maxLines".into(), json!(n));
}
@@ -1117,20 +1138,7 @@ impl<'a> Mapper<'a> {
}
o.insert("style".into(), Value::Object(style));
let Some(td) = td else { return };
let ids: Vec<u64> = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect();
if ids.iter().any(|i| *i != 0) {
let mut table = Obj::new();
for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() {
let Some(sid) = num(e, "styleID") else { continue };
let mut s = text_style(&View::of(e), size);
if let Some(fills) = e.get("fillPaints") {
s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect()));
}
table.insert((sid as u64).to_string(), Value::Object(s));
}
o.insert("characterStyleOverrides".into(), json!(ids));
o.insert("styleOverrideTable".into(), Value::Object(table));
}
char_styles(o, td, size);
let lines: Vec<&str> = td
.get("lines")
.map(Kv::list)
@@ -1318,6 +1326,16 @@ impl<'a> Mapper<'a> {
"characters" => {
if let Some(t) = prop_text(val) {
o.insert("characters".into(), json!(t));
// Run styles count the chars they were made for: for other chars only
// the value's own apply.
if own_text(v) != Some(t) {
o.remove("characterStyleOverrides");
o.remove("styleOverrideTable");
if let Some(tv) = val.get("textValue") {
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
char_styles(o, tv, size);
}
}
}
}
_ => {}
@@ -2026,6 +2044,26 @@ fn variable_value(d: &Kv) -> Option<Value> {
v.get("textValue").and_then(Kv::str).map(|s| json!(s))
}
/// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST
/// `characterStyleOverrides` + `styleOverrideTable`; `size`: the base font size.
fn char_styles(o: &mut Obj, td: &Kv, size: Option<f64>) {
let ids: Vec<u64> = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect();
if ids.iter().all(|i| *i == 0) {
return;
}
let mut table = Obj::new();
for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() {
let Some(sid) = num(e, "styleID") else { continue };
let mut s = text_style(&View::of(e), size);
if let Some(fills) = e.get("fillPaints") {
s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect()));
}
table.insert((sid as u64).to_string(), Value::Object(s));
}
o.insert("characterStyleOverrides".into(), json!(ids));
o.insert("styleOverrideTable".into(), Value::Object(table));
}
/// The text of a component property value.
fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> {
v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str)
@@ -2063,7 +2101,11 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
let val = num(ls, "value").unwrap_or(0.0);
let px = match ls.get("units").and_then(Kv::str) {
Some("PIXELS") => val,
Some("PERCENT") => val / 100.0 * font_size,
Some("PERCENT") => {
// Private (read by `rest/text.rs`): REST spacing is in pixels; keep the percent.
s.insert("_letterSpacingPercent".into(), json!(val));
val / 100.0 * font_size
}
_ => val * font_size,
};
s.insert("letterSpacing".into(), json!(px));
+2
View File
@@ -24,6 +24,8 @@ mod network;
mod source_identity_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod text_style_tests;
use std::io::Read;
+292
View File
@@ -0,0 +1,292 @@
//! Text layers and their shared text styles on import: which of the style, the layer's own
//! values and an instance's overrides win, on messages built in code.
use figcraft_doc::{Document, LetterSpacing, StyleKind, TextCase, TextDecoration};
use serde_json::Value;
use super::kiwi::Kv;
type Fields = Vec<(&'static str, Kv<'static>)>;
const NO_STYLE: u64 = 4_294_967_295;
fn object(fields: Fields) -> Kv<'static> {
Kv::Object(fields)
}
fn guid(id: u64) -> Kv<'static> {
object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(id))])
}
fn link(id: u64) -> Kv<'static> {
object(vec![("guid", guid(id))])
}
fn node(id: u64, ty: &'static str, name: &str, parent: Option<u64>, mut more: Fields) -> Kv<'static> {
let mut fields = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))];
if let Some(parent) = parent {
fields.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))])));
}
fields.append(&mut more);
object(fields)
}
fn font(family: &str) -> Kv<'static> {
object(vec![("family", Kv::Str(family.into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))])
}
fn chars(text: &str) -> Kv<'static> {
object(vec![("characters", Kv::Str(text.into()))])
}
/// The REST JSON of a page holding `nodes`.
fn rest(nodes: Vec<Kv<'static>>) -> Value {
let base = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])];
let message = object(vec![("nodeChanges", Kv::Array(base.into_iter().chain(nodes).collect()))]);
super::map::to_rest(&message, "Synthetic text").0
}
fn import(value: &Value) -> Document {
let (doc, warnings) = crate::rest::import_fig(value, &|_| None).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
doc
}
/// Depth-first search for a REST node by name below `v`.
fn find<'v>(v: &'v Value, name: &str) -> Option<&'v Value> {
if v["name"] == name {
return Some(v);
}
v["children"].as_array()?.iter().find_map(|c| find(c, name))
}
/// A shared text style (Inter, 20 px, with `shared` fields) and a layer linking it whose own
/// copy of the typography is stale (40 px, another family) and which sets `local`.
fn linked(shared: Fields, local: Fields) -> Document {
let mut definition = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(20.0))];
definition.extend(shared);
let mut layer = vec![("fontName", font("Stale copy")), ("fontSize", Kv::Float(40.0)), ("styleIdForText", link(10)), ("textData", chars("a\nb"))];
layer.extend(local);
import(&rest(vec![node(10, "TEXT", "Shared", Some(2), definition), node(11, "TEXT", "Label", Some(2), layer)]))
}
struct Seen {
case: TextCase,
decoration: TextDecoration,
spacing: f64,
indent: f64,
shared_case: TextCase,
}
fn seen(doc: &Document) -> Seen {
let label = doc.nodes().find(|n| n.name == "Label").unwrap();
let text = label.kind.text().unwrap();
// The shared style's typography beats the layer's stale copy, whatever the layer sets locally.
assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0));
let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap();
assert_eq!(linked.name, "Shared");
let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") };
Seen {
case: text.style.case,
decoration: text.style.decoration,
spacing: text.paragraph_spacing,
indent: text.paragraph_indent,
shared_case: style.case,
}
}
#[test]
fn local_case_decoration_and_paragraph_values_beat_the_shared_style() {
let shared = vec![
("textCase", Kv::Enum("UPPER")),
("textDecoration", Kv::Enum("UNDERLINE")),
("paragraphSpacing", Kv::Float(24.0)),
("paragraphIndent", Kv::Float(12.0)),
];
// Nothing set on the layer: the style's values stay.
let s = seen(&linked(shared.clone(), vec![]));
assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::Upper, TextDecoration::Underline, 24.0, 12.0));
// Explicit values on the layer win, and "original", "none" and zero clear the style's.
let cleared = vec![
("textCase", Kv::Enum("ORIGINAL")),
("textDecoration", Kv::Enum("NONE")),
("paragraphSpacing", Kv::Float(0.0)),
("paragraphIndent", Kv::Float(0.0)),
];
let s = seen(&linked(shared.clone(), cleared));
assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::Original, TextDecoration::None, 0.0, 0.0));
assert_eq!(s.shared_case, TextCase::Upper, "a local value must not change the definition");
let local = vec![
("textCase", Kv::Enum("SMALL_CAPS_FORCED")),
("textDecoration", Kv::Enum("STRIKETHROUGH")),
("paragraphSpacing", Kv::Float(6.0)),
("paragraphIndent", Kv::Float(3.0)),
];
let s = seen(&linked(vec![], local));
assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::SmallCaps, TextDecoration::Strikethrough, 6.0, 3.0));
// A value the importer does not know leaves the style's in place.
let s = seen(&linked(shared, vec![("textCase", Kv::Enum("UNSUPPORTED_CASE")), ("textDecoration", Kv::Enum("WAVY"))]));
assert_eq!((s.case, s.decoration), (TextCase::Upper, TextDecoration::Underline));
}
#[test]
fn an_instance_override_beats_the_shared_style_and_a_detach_unlinks_it() {
let value = rest(vec![
node(10, "TEXT", "Body", Some(2), vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(14.0))]),
node(20, "SYMBOL", "Main", Some(2), vec![]),
node(
21,
"TEXT",
"Label",
Some(20),
vec![("fontName", font("Inter")), ("fontSize", Kv::Float(14.0)), ("styleIdForText", link(10)), ("textData", chars("Hi"))],
),
node(
30,
"INSTANCE",
"Inst",
Some(2),
vec![(
"symbolData",
object(vec![
("symbolID", guid(20)),
(
"symbolOverrides",
Kv::Array(vec![object(vec![
("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])),
("fontSize", Kv::Float(30.0)),
])]),
),
]),
)],
),
// An explicit "no style" detaches, even when the older field still names a style.
node(
40,
"TEXT",
"Detached",
Some(2),
vec![
("fontName", font("Roboto")),
("fontSize", Kv::Float(40.0)),
("styleIdForText", object(vec![("guid", object(vec![("sessionID", Kv::Uint(NO_STYLE)), ("localID", Kv::Uint(NO_STYLE))]))])),
("inheritTextStyleID", guid(10)),
("textData", chars("ab")),
],
),
]);
let label = find(find(&value["document"], "Inst").unwrap(), "Label").unwrap();
assert_eq!(label["style"]["fontSize"].as_f64(), Some(30.0), "the override beats the style");
let detached = find(&value["document"], "Detached").unwrap();
assert!(detached["styles"].get("text").is_none(), "{}", detached["styles"]);
assert_eq!(detached["style"]["fontSize"].as_f64(), Some(40.0));
}
#[test]
fn a_text_property_value_drops_the_run_styles_made_for_the_main_text() {
let ids = [0u64, 0, 0, 1, 1, 1].map(Kv::Uint).to_vec();
let value = rest(vec![
node(
20,
"SYMBOL",
"Main",
Some(2),
vec![(
"componentPropDefs",
Kv::Array(vec![object(vec![
("id", guid(70)),
("name", Kv::Str("Text".into())),
("type", Kv::Enum("TEXT")),
("initialValue", object(vec![("textValue", chars("Hi you"))])),
])]),
)],
),
node(
21,
"TEXT",
"Label",
Some(20),
vec![
("fontName", font("Inter")),
("fontSize", Kv::Float(12.0)),
(
"textData",
object(vec![
("characters", Kv::Str("Hi you".into())),
("characterStyleIDs", Kv::Array(ids)),
("styleOverrideTable", Kv::Array(vec![object(vec![("styleID", Kv::Uint(1)), ("fontSize", Kv::Float(30.0))])])),
]),
),
(
"parameterConsumptionMap",
object(vec![(
"entries",
Kv::Array(vec![object(vec![
("variableField", Kv::Enum("TEXT_DATA")),
("variableData", object(vec![("value", object(vec![("propRefValue", object(vec![("defId", guid(70))]))]))])),
])]),
)]),
),
],
),
node(
30,
"INSTANCE",
"Inst",
Some(2),
vec![
("symbolData", object(vec![("symbolID", guid(20))])),
(
"componentPropAssignments",
Kv::Array(vec![object(vec![("defID", guid(70)), ("value", object(vec![("textValue", chars("Goodbye"))]))])]),
),
],
),
]);
let main = find(find(&value["document"], "Main").unwrap(), "Label").unwrap();
let kept: Vec<u64> = main["characterStyleOverrides"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
assert_eq!(kept, [0, 0, 0, 1, 1, 1], "the main keeps its run styles");
let inst = find(&value["document"], "Inst").unwrap();
let layer = find(inst, "Label").unwrap();
assert_eq!(layer["characters"], "Goodbye");
assert!(layer.get("characterStyleOverrides").is_none(), "positions of the main's runs do not fit the property text");
}
#[test]
fn percent_letter_spacing_stays_a_percentage() {
let spacing = |units: &'static str, value: f32| object(vec![("value", Kv::Float(value)), ("units", Kv::Enum(units))]);
let layer = |id, name: &str, letter: Kv<'static>| {
node(
id,
"TEXT",
name,
Some(2),
vec![("fontName", font("Inter")), ("fontSize", Kv::Float(20.0)), ("letterSpacing", letter), ("textData", chars("Hello"))],
)
};
let doc = import(&rest(vec![layer(10, "Percent", spacing("PERCENT", 5.0)), layer(11, "Pixels", spacing("PIXELS", 2.0))]));
let spacing = |name: &str| doc.nodes().find(|n| n.name == name).unwrap().kind.text().unwrap().style.letter_spacing;
assert_eq!(spacing("Percent"), LetterSpacing::Percent(5.0));
assert_eq!(spacing("Pixels"), LetterSpacing::Pixels(2.0));
}
#[test]
fn the_private_percent_is_read_for_fig_imports_only() {
let value = rest(vec![node(
10,
"TEXT",
"Label",
Some(2),
vec![
("fontName", font("Inter")),
("fontSize", Kv::Float(20.0)),
("letterSpacing", object(vec![("value", Kv::Float(5.0)), ("units", Kv::Enum("PERCENT"))])),
("textData", chars("Hello")),
],
)]);
let spacing = |doc: &Document| doc.nodes().find(|n| n.name == "Label").unwrap().kind.text().unwrap().style.letter_spacing;
assert_eq!(spacing(&import(&value)), LetterSpacing::Percent(5.0));
// The same JSON through the public REST import is pixels.
let (public, _) = crate::rest::import(&value, &|_| None).unwrap();
assert_eq!(spacing(&public), LetterSpacing::Pixels(1.0));
}
+6
View File
@@ -106,6 +106,12 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle {
if let Some(ls) = num(s, "letterSpacing") {
t.letter_spacing = if ls == 0.0 { LetterSpacing::default() } else { LetterSpacing::Pixels(ls.clamp(-1e4, 1e4)) };
}
// Private, from the .fig mapper: the spacing was a share of the font size.
if cx.fig_sources.is_some()
&& let Some(p) = num(s, "_letterSpacingPercent")
{
t.letter_spacing = LetterSpacing::Percent(p.clamp(-1e4, 1e4));
}
match text(s, "textCase") {
Some("ORIGINAL") => t.case = TextCase::Original,
Some("UPPER") => t.case = TextCase::Upper,