diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 745b6d81..f5306acc 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -140,6 +140,41 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) { } } +/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through. +fn text_num(v: &View, key: &str) -> Option { + v.layers.iter().rev().find_map(|layer| { + layer.get(key).and_then(|value| match value { + Kv::Bool(_) => None, + _ => value.f64(), + }) + }) +} + +fn text_flag(v: &View, key: &str) -> Option { + v.layers.iter().rev().find_map(|layer| layer.get(key).and_then(Kv::bool)) +} + +fn insert_text_options(style: &mut Obj, v: &View) { + if let Some(value) = text_num(v, "listSpacing") { + style.insert("listSpacing".into(), json!(value)); + } + for k in ["hangingList", "hangingPunctuation"] { + if let Some(value) = text_flag(v, k) { + style.insert(k.into(), json!(value)); + } + } + if let Some(value) = leading_trim(v) { + style.insert("leadingTrim".into(), json!(value)); + } +} + +fn leading_trim<'a>(v: &View<'a>) -> Option<&'a str> { + v.layers.iter().rev().find_map(|layer| match layer.get("leadingTrim").and_then(Kv::str) { + Some(value @ ("NONE" | "CAP_HEIGHT")) => Some(value), + _ => None, + }) +} + /// A node seen through override layers: the last layer with a field wins. #[derive(Clone)] struct View<'a> { @@ -1132,6 +1167,7 @@ impl<'a> Mapper<'a> { for k in ["paragraphSpacing", "paragraphIndent"] { copy_num(&mut style, v, k, k); } + insert_text_options(&mut style, v); if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) { style.insert("maxLines".into(), json!(n)); } @@ -2097,6 +2133,7 @@ fn text_style(v: &View, size: Option) -> Obj { copy_num(&mut s, v, "fontSize", "fontSize"); copy_num(&mut s, v, "paragraphSpacing", "paragraphSpacing"); copy_num(&mut s, v, "paragraphIndent", "paragraphIndent"); + insert_text_options(&mut s, v); copy_str(&mut s, v, "textCase", "textCase"); copy_str(&mut s, v, "textDecoration", "textDecoration"); if let Some(skip_ink) = v.flag("textDecorationSkipInk") { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 8b3f02a0..4ae8c67a 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -25,6 +25,8 @@ mod source_identity_tests; #[cfg(test)] mod tests; #[cfg(test)] +mod text_paragraph_tests; +#[cfg(test)] mod text_skip_ink_tests; #[cfg(test)] mod text_style_tests; diff --git a/crates/format/src/fig/text_paragraph_tests.rs b/crates/format/src/fig/text_paragraph_tests.rs new file mode 100644 index 00000000..b3f05b05 --- /dev/null +++ b/crates/format/src/fig/text_paragraph_tests.rs @@ -0,0 +1,288 @@ +//! Paragraph options (list spacing, hanging list and punctuation, leading trim) in encoded `.fig` +//! files: the highest valid value wins, invalid instance rows fall through, native saves keep them. + +use super::kiwi::Kv; +use super::{export, import}; + +fn paragraph_fig(shared: Vec<(&'static str, Kv<'static>)>, local: Vec<(&'static str, Kv<'static>)>) -> Vec { + use super::kiwi::{Schema, encode}; + + let schema = Schema::parse( + "enum NodeType NONE DOCUMENT CANVAS TEXT\n\ + enum StyleType NONE FILL TEXT\n\ + enum LeadingTrim NONE CAP_HEIGHT\n\ + struct GUID uint sessionID, uint localID\n\ + struct ParentIndex GUID guid, string position\n\ + struct FontName string family, string style, string postscript\n\ + struct StyleId GUID guid\n\ + message TextData string characters\n\ + message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, StyleType styleType, FontName fontName, float fontSize, StyleId styleIdForText, TextData textData, float paragraphSpacing, float paragraphIndent, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim\n\ + message Message NodeChange[] nodeChanges", + ) + .unwrap(); + fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) + } + fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(7)), ("localID", Kv::Uint(id))]) + } + fn node(id: u64, ty: &'static str, name: &str, parent: Option, 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.extend(fields); + object(out) + } + fn font(family: &str) -> Kv<'static> { + object(vec![("family", Kv::Str(family.into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) + } + let mut base = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(20.0))]; + base.extend(shared); + let mut layer = vec![ + ("fontName", font("Stale")), + ("fontSize", Kv::Float(40.0)), + ("styleIdForText", object(vec![("guid", guid(10))])), + ("textData", object(vec![("characters", Kv::Str("a\nb".into()))])), + ]; + layer.extend(local); + let msg = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node(10, "TEXT", "Shared", Some(2), base), + node(11, "TEXT", "Label", Some(2), layer), + ]), + )]); + let root = schema.find("Message").unwrap(); + let mut deflated = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + use std::io::Write; + deflated.write_all(&encode::schema(&schema)).unwrap(); + let schema_chunk = deflated.finish().unwrap(); + let message = encode::message(&schema, root, &msg).unwrap(); + let message_chunk = ruzstd::encoding::compress_to_vec(message.as_slice(), ruzstd::encoding::CompressionLevel::Fastest); + let mut bytes = b"fig-kiwi".to_vec(); + bytes.extend_from_slice(&101u32.to_le_bytes()); + for chunk in [schema_chunk, message_chunk] { + bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&chunk); + } + bytes +} + +#[test] +fn encoded_fig_maps_paragraph_list_fields_through_native_round_trip() { + let shared = vec![ + ("listSpacing", Kv::Float(18.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("CAP_HEIGHT")), + ]; + let (inherited, warnings) = import(¶graph_fig(shared.clone(), vec![]), "Synthetic paragraph options").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let text = inherited.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + assert_eq!(text.style.font_family, "Inter"); + + let reset = vec![ + ("listSpacing", Kv::Float(0.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (cleared, warnings) = import(¶graph_fig(shared, reset), "Synthetic paragraph resets").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let text = cleared.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 0.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + assert_eq!(text.style.font_family, "Inter"); + + let bytes = crate::save_bytes(&inherited, None).unwrap(); + let (back, warnings) = crate::load_bytes(&bytes).unwrap(); + assert!(warnings.is_empty(), "native round trip: {warnings:?}"); + let text = back.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + + let (_, export_warnings) = export(&inherited, None).unwrap(); + assert!( + export_warnings.iter().any(|warning| warning.contains("list spacing, hanging punctuation, leading trim")), + "existing .fig export loss must remain visible: {export_warnings:?}" + ); +} + +fn instance_paragraph_fig(overrides: Vec<(&'static str, Kv<'static>)>, nonfinite_spacing: bool) -> Vec { + use super::kiwi::{Schema, encode}; + + let schema = Schema::parse( + "enum NodeType NONE DOCUMENT CANVAS TEXT SYMBOL INSTANCE\n\ + enum StyleType NONE FILL TEXT\n\ + enum LeadingTrim NONE CAP_HEIGHT\n\ + struct GUID uint sessionID, uint localID\n\ + struct ParentIndex GUID guid, string position\n\ + struct FontName string family, string style, string postscript\n\ + struct StyleId GUID guid\n\ + struct GUIDPath GUID[] guids\n\ + struct SymbolOverride GUIDPath guidPath, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim\n\ + struct SymbolData GUID symbolID, SymbolOverride[] symbolOverrides\n\ + message TextData string characters\n\ + message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, StyleType styleType, FontName fontName, float fontSize, StyleId styleIdForText, TextData textData, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim, SymbolData symbolData\n\ + message Message NodeChange[] nodeChanges", + ) + .unwrap(); + fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) + } + fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(7)), ("localID", Kv::Uint(id))]) + } + fn node(id: u64, ty: &'static str, name: &str, parent: Option, 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.extend(fields); + object(out) + } + fn font() -> Kv<'static> { + object(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) + } + let main_text = node( + 21, + "TEXT", + "Label", + Some(20), + vec![ + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("styleIdForText", object(vec![("guid", guid(10))])), + ("textData", object(vec![("characters", Kv::Str("a\nb".into()))])), + ], + ); + let mut override_fields = vec![("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))]))]; + override_fields.extend(overrides); + let instance = node( + 30, + "INSTANCE", + "Copy", + Some(2), + vec![("symbolData", object(vec![("symbolID", guid(20)), ("symbolOverrides", Kv::Array(vec![object(override_fields)]))]))], + ); + let message = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node( + 10, + "TEXT", + "Shared", + Some(2), + vec![ + ("styleType", Kv::Enum("TEXT")), + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("listSpacing", Kv::Float(18.0)), + ("hangingList", Kv::Bool(true)), + ("hangingPunctuation", Kv::Bool(true)), + ("leadingTrim", Kv::Enum("CAP_HEIGHT")), + ], + ), + node(20, "SYMBOL", "Main", Some(2), vec![]), + main_text, + instance, + ]), + )]); + let root = schema.find("Message").unwrap(); + let mut deflated = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + use std::io::Write; + deflated.write_all(&encode::schema(&schema)).unwrap(); + let schema_chunk = deflated.finish().unwrap(); + let mut message = encode::message(&schema, root, &message).unwrap(); + if nonfinite_spacing { + let finite = 37.0_f32.to_bits().rotate_right(23).to_le_bytes(); + let nan = f32::NAN.to_bits().rotate_right(23).to_le_bytes(); + let matches: Vec = message.windows(finite.len()).enumerate().filter_map(|(i, w)| (w == finite).then_some(i)).collect(); + assert_eq!(matches.len(), 1, "finite test token must identify the instance listSpacing field"); + let offset = matches[0]; + message[offset..offset + nan.len()].copy_from_slice(&nan); + } + let message_chunk = ruzstd::encoding::compress_to_vec(message.as_slice(), ruzstd::encoding::CompressionLevel::Fastest); + let mut bytes = b"fig-kiwi".to_vec(); + bytes.extend_from_slice(&101u32.to_le_bytes()); + for chunk in [schema_chunk, message_chunk] { + bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&chunk); + } + bytes +} + +#[test] +fn encoded_instance_paragraph_overrides_fall_back_only_when_invalid() { + let valid = vec![ + ("listSpacing", Kv::Float(24.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(valid, false), "Synthetic instance paragraph values").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 24.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + + let reset = vec![ + ("listSpacing", Kv::Float(0.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(reset, false), "Synthetic instance paragraph resets").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 0.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + + let invalid = vec![ + ("listSpacing", Kv::Float(37.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Uint(77)), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(invalid, true), "Synthetic malformed instance paragraph values").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + + let bytes = crate::save_bytes(&doc, None).unwrap(); + let (back, warnings) = crate::load_bytes(&bytes).unwrap(); + assert!(warnings.is_empty(), "native round trip: {warnings:?}"); + let copy = back.nodes().find(|node| node.name == "Copy").unwrap(); + let label = back.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); +}