mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 00:41:06 +00:00
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:
1 parent
d2606b3f51
commit
3723fcffbd
5 files changed
+255
-3
No files matched your search
@@ -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))])));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user