M1: keep text decoration skip ink from .fig files

Text layers, styled runs and text styles in .fig files carry a skip-ink boolean for their decoration. The importer now reads it into the native decoration look: an omitted run value inherits the layer's, a local value overrides a linked shared text style without changing the style itself, and instance overrides keep an explicit false over a true main. A malformed value counts as unset. The exporter writes the actual value of the layer and of each styled run instead of a constant true, and a decoration look that differs from the default only in skip ink no longer triggers the unsupported-decoration note, so default native text exports its real false value.
This commit is contained in:
Skyfuller1303
2026-10-16 05:03:34 +02:00
parent d2606b3f51
commit 3723fcffbd
5 changed files with 255 additions and 3 deletions
+8 -2
View File
@@ -1502,7 +1502,6 @@ impl<'d> Exporter<'d> {
("textExplicitLayoutVersion", u(1)),
("textBidiVersion", u(1)),
("textTracking", fl(0.0)),
("textDecorationSkipInk", b(true)),
("emojiImageSet", e("APPLE")),
]);
if td.auto_resize == TextAutoResize::Truncate {
@@ -1589,7 +1588,7 @@ impl<'d> Exporter<'d> {
self.warn("nested list levels were written as the first level");
}
let styles = std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style));
if styles.clone().any(|st| st.decoration_look.is_some() || !st.variations.is_empty()) {
if styles.clone().any(|st| unsupported_decoration_look(st) || !st.variations.is_empty()) {
self.warn("text decoration looks and variable font settings were left out");
}
if td.path.is_some() {
@@ -2394,6 +2393,12 @@ fn key_codes(key: &str) -> Option<(Vec<i64>, bool)> {
}
/// The text style fields of a layer or of an override table entry.
fn unsupported_decoration_look(st: &TextStyle) -> bool {
let mut look = st.look();
look.skip_ink = false;
look != Default::default()
}
fn style_fields(st: &TextStyle, f: &mut Fields) {
let line_height = match st.line_height {
// Percent units count from the font's own line height (100 % is "auto"); a share of the
@@ -2425,6 +2430,7 @@ fn style_fields(st: &TextStyle, f: &mut Fields) {
("letterSpacing", letter_spacing),
("textCase", e(case)),
("textDecoration", e(decoration)),
("textDecorationSkipInk", b(st.look().skip_ink)),
]);
if let Some(url) = &st.link {
f.push(("hyperlink", o(vec![("url", s(url))])));
+2 -1
View File
@@ -187,7 +187,8 @@ fn text_carries_its_layout_settings_and_a_line_per_paragraph() {
with_changes(&sample(), |of, _| {
let t = of("TEXT");
assert!(!flag(&t, "autoRename"));
assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures") && flag(&t, "textDecorationSkipInk"));
assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures"));
assert!(!flag(&t, "textDecorationSkipInk"), "default native text exports its actual false value");
assert_eq!(name(&t, "fontVersion"), "");
assert_eq!((num(&t, "textUserLayoutVersion"), num(&t, "textExplicitLayoutVersion"), num(&t, "textBidiVersion")), (5.0, 1.0, 1.0));
assert_eq!(num(&t, "textTracking"), 0.0);
+6
View File
@@ -1119,6 +1119,9 @@ impl<'a> Mapper<'a> {
if let Some(decoration @ ("NONE" | "UNDERLINE" | "STRIKETHROUGH")) = v.str("textDecoration") {
style.insert("textDecoration".into(), json!(decoration));
}
if let Some(skip_ink) = v.flag("textDecorationSkipInk") {
style.insert("textDecorationSkipInk".into(), json!(skip_ink));
}
if let Some(case @ ("ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED")) = v.str("textCase") {
style.insert("textCase".into(), json!(case));
}
@@ -2096,6 +2099,9 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
copy_num(&mut s, v, "paragraphIndent", "paragraphIndent");
copy_str(&mut s, v, "textCase", "textCase");
copy_str(&mut s, v, "textDecoration", "textDecoration");
if let Some(skip_ink) = v.flag("textDecorationSkipInk") {
s.insert("textDecorationSkipInk".into(), json!(skip_ink));
}
let font_size = v.num("fontSize").or(size).unwrap_or(12.0);
if let Some(ls) = v.get("letterSpacing") {
let val = num(ls, "value").unwrap_or(0.0);
+2
View File
@@ -25,6 +25,8 @@ mod source_identity_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod text_skip_ink_tests;
#[cfg(test)]
mod text_style_tests;
use std::io::Read;
@@ -0,0 +1,237 @@
//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state.
use super::kiwi::Kv;
use super::{export, import};
use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextStyle};
use serde_json::Value;
fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn guid(id: u64) -> Kv<'static> {
object(vec![("sessionID", Kv::Uint(8)), ("localID", Kv::Uint(id))])
}
fn node(id: u64, ty: &'static str, name: &str, parent: Option<u64>, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))];
if let Some(parent) = parent {
out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))])));
}
out.append(&mut fields);
object(out)
}
fn font() -> Kv<'static> {
object(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))])
}
fn raw(base: Option<Kv<'static>>, runs: Vec<Kv<'static>>, chars: &str, ids: Vec<u64>, shared: Option<Option<Kv<'static>>>) -> Value {
let mut layer = vec![
("fontName", font()),
("fontSize", Kv::Float(20.0)),
(
"textData",
object(vec![
("characters", Kv::Str(chars.into())),
("characterStyleIDs", Kv::Array(ids.into_iter().map(Kv::Uint).collect())),
("styleOverrideTable", Kv::Array(runs)),
]),
),
];
if let Some(value) = base {
layer.push(("textDecorationSkipInk", value));
}
let mut nodes = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])];
if let Some(shared) = shared {
let mut fields = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0))];
if let Some(value) = shared {
fields.push(("textDecorationSkipInk", value));
}
nodes.push(node(10, "TEXT", "Body", Some(2), fields));
layer.push(("styleIdForText", object(vec![("guid", guid(10))])));
}
nodes.push(node(11, "TEXT", "Label", Some(2), layer));
let message = object(vec![("nodeChanges", Kv::Array(nodes))]);
super::map::to_rest(&message, "Synthetic skip ink").0
}
fn imported(value: &Value) -> Document {
let (doc, warnings) = crate::rest::import_fig(value, &|_| None).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
doc
}
fn label(doc: &Document) -> &TextData {
doc.nodes().find(|n| n.name == "Label").unwrap().kind.text().unwrap()
}
fn skip_at(text: &TextData, index: usize) -> bool {
text.runs.iter().find(|r| r.start <= index && index < r.end).map_or_else(|| text.style.look().skip_ink, |r| r.style.look().skip_ink)
}
#[test]
fn node_boolean_false_true_omitted_and_malformed_keep_typed_semantics() {
for (raw_value, expected) in [(Some(Kv::Bool(true)), true), (Some(Kv::Bool(false)), false), (None, false), (Some(Kv::Str("true".into())), false)]
{
let doc = imported(&raw(raw_value.clone(), vec![], "A", vec![0], None));
assert_eq!(label(&doc).style.look().skip_ink, expected, "source value {raw_value:?}");
}
}
#[test]
fn runs_inherit_explicit_false_and_true_by_utf16_character_ids() {
let table = vec![
object(vec![("styleID", Kv::Uint(1))]),
object(vec![("styleID", Kv::Uint(2)), ("textDecorationSkipInk", Kv::Bool(false))]),
object(vec![("styleID", Kv::Uint(3)), ("textDecorationSkipInk", Kv::Bool(true))]),
];
let doc = imported(&raw(Some(Kv::Bool(true)), table, "A😀BC", vec![0, 1, 1, 2, 3], None));
let text = label(&doc);
assert_eq!(text.characters.chars().count(), 4);
assert_eq!(text.characters.encode_utf16().count(), 5, "the emoji occupies two source style-ID units");
assert_eq!((0..text.char_count()).map(|i| skip_at(text, i)).collect::<Vec<_>>(), vec![true, true, false, true]);
let false_base = imported(&raw(
Some(Kv::Bool(false)),
vec![object(vec![("styleID", Kv::Uint(1)), ("textDecorationSkipInk", Kv::Bool(true))])],
"ab",
vec![1, 0],
None,
));
assert_eq!((0..2).map(|i| skip_at(label(&false_base), i)).collect::<Vec<_>>(), vec![true, false]);
}
#[test]
fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() {
for (local, expected) in [(Some(false), false), (None, true)] {
let value = raw(local.map(Kv::Bool), vec![], "ab", vec![0, 0], Some(Some(Kv::Bool(true))));
let doc = imported(&value);
let node = doc.nodes().find(|n| n.name == "Label").unwrap();
assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected);
let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap();
let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") };
assert!(style.look().skip_ink, "local value must not alter definition");
}
}
#[test]
fn instance_path_override_preserves_explicit_false_over_true_main() {
let main_text = node(
21,
"TEXT",
"Label",
Some(20),
vec![
("fontName", font()),
("fontSize", Kv::Float(20.0)),
("textDecorationSkipInk", Kv::Bool(true)),
("textData", object(vec![("characters", Kv::Str("A".into()))])),
],
);
let instance = node(
30,
"INSTANCE",
"Copy",
Some(2),
vec![(
"symbolData",
object(vec![
("symbolID", guid(20)),
(
"symbolOverrides",
Kv::Array(vec![object(vec![
("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])),
("textDecorationSkipInk", Kv::Bool(false)),
])]),
),
]),
)],
);
let message = object(vec![(
"nodeChanges",
Kv::Array(vec![
node(1, "DOCUMENT", "Document", None, vec![]),
node(2, "CANVAS", "Page", Some(1), vec![]),
node(20, "SYMBOL", "Main", Some(2), vec![]),
main_text,
instance,
]),
)]);
let value = super::map::to_rest(&message, "Instance skip ink").0;
let copy = value["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == "Copy").unwrap();
let label = copy["children"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap();
assert_eq!(label["style"]["textDecorationSkipInk"], false);
let doc = imported(&value);
let copy = doc.nodes().find(|n| n.name == "Copy").unwrap();
let instance_label = doc.node(*copy.children.first().unwrap()).unwrap();
assert!(!instance_label.kind.text().unwrap().style.look().skip_ink);
}
#[test]
fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only_warning() {
let mut doc = Document::new("Skip ink export");
let mut text = TextData::new("abcd");
text.style.set_look(|look| look.skip_ink = false);
let mut skipped = text.style.clone();
skipped.set_look(|look| look.skip_ink = true);
text.runs = vec![StyleRun { start: 1, end: 3, style: skipped }];
doc.create(doc.first_page().unwrap(), NodeKind::Text(text), "Label", 0.0, 0.0, 100.0, 30.0).unwrap();
let (bytes, warnings) = export(&doc, None).unwrap();
assert!(warnings.is_empty(), "skip-ink is supported: {warnings:?}");
let archive = super::Archive::open(&bytes).unwrap();
let (schema, body) = super::decode_canvas(&archive.canvas).unwrap();
let message = schema.decode_message(schema.find("Message").unwrap(), &body).unwrap();
let value = super::debug_json(&message);
let layer = value["nodeChanges"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap();
assert_eq!(layer["textDecorationSkipInk"], false);
assert_eq!(layer.as_object().unwrap().keys().filter(|k| *k == "textDecorationSkipInk").count(), 1);
let rows = layer["textData"]["styleOverrideTable"].as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["textDecorationSkipInk"], true);
let (back, warnings) = import(&bytes, "Skip ink export").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!((0..4).map(|i| skip_at(label(&back), i)).collect::<Vec<_>>(), vec![false, true, true, false]);
let mut custom = TextData::new("x");
custom.style.set_look(|look| look.skip_ink = true);
custom.style.set_look(|look| look.thickness = figcraft_doc::DecorationLength::Pixels(2.0));
let mut custom_doc = Document::new("Unsupported decoration");
custom_doc.create(custom_doc.first_page().unwrap(), NodeKind::Text(custom), "Custom", 0.0, 0.0, 100.0, 30.0).unwrap();
let (_, warnings) = export(&custom_doc, None).unwrap();
assert!(warnings.iter().any(|w| w.contains("text decoration looks")), "custom look warning missing: {warnings:?}");
let mut variable = TextData::new("x");
variable.style.variations.insert("wght".into(), 400.0);
let mut variable_doc = Document::new("Variable font warning");
variable_doc.create(variable_doc.first_page().unwrap(), NodeKind::Text(variable), "Variable", 0.0, 0.0, 100.0, 30.0).unwrap();
let (_, warnings) = export(&variable_doc, None).unwrap();
assert!(warnings.iter().any(|w| w.contains("variable font settings")), "variation warning missing: {warnings:?}");
let mut shared = TextStyle::default();
shared.set_look(|look| look.skip_ink = true);
let mut local = TextStyle::default();
local.set_look(|look| look.skip_ink = false);
let mut linked_doc = Document::new("Shared skip ink");
linked_doc.styles.push(figcraft_doc::Style {
id: "body".into(),
name: "Body".into(),
description: String::new(),
kind: StyleKind::Text { style: shared },
bound: Default::default(),
});
let id = linked_doc
.create(linked_doc.first_page().unwrap(), NodeKind::Text(TextData { style: local, ..TextData::new("x") }), "Label", 0.0, 0.0, 100.0, 30.0)
.unwrap();
linked_doc.node_mut(id).unwrap().styles.text = Some("body".into());
let (bytes, warnings) = export(&linked_doc, None).unwrap();
assert!(warnings.is_empty(), "skip-ink-only shared styles are supported: {warnings:?}");
let (back, warnings) = import(&bytes, "Shared skip ink").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let node = back.nodes().find(|n| n.name == "Label").unwrap();
assert!(!node.kind.text().unwrap().style.look().skip_ink);
let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap();
let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") };
assert!(style.look().skip_ink, "local false must not alter the shared definition");
}