mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge fix/638
This commit is contained in:
@@ -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() {
|
||||
@@ -2393,6 +2392,13 @@ fn key_codes(key: &str) -> Option<(Vec<i64>, bool)> {
|
||||
Some((codes, false))
|
||||
}
|
||||
|
||||
/// Whether the decoration look has more than skip ink, which the file can't hold.
|
||||
fn unsupported_decoration_look(st: &TextStyle) -> bool {
|
||||
let mut look = st.look();
|
||||
look.skip_ink = false;
|
||||
look != Default::default()
|
||||
}
|
||||
|
||||
/// The text style fields of a layer or of an override table entry.
|
||||
fn style_fields(st: &TextStyle, f: &mut Fields) {
|
||||
let line_height = match st.line_height {
|
||||
@@ -2425,6 +2431,9 @@ fn style_fields(st: &TextStyle, f: &mut Fields) {
|
||||
("letterSpacing", letter_spacing),
|
||||
("textCase", e(case)),
|
||||
("textDecoration", e(decoration)),
|
||||
// Skip ink only shows on an underline; other text gets the files' default (on), the value
|
||||
// the import leaves out again.
|
||||
("textDecorationSkipInk", b(st.look().skip_ink || st.decoration != TextDecoration::Underline)),
|
||||
]);
|
||||
if let Some(url) = &st.link {
|
||||
f.push(("hyperlink", o(vec![("url", s(url))])));
|
||||
|
||||
@@ -146,6 +146,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<f64> {
|
||||
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<bool> {
|
||||
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> {
|
||||
@@ -1431,6 +1466,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));
|
||||
}
|
||||
@@ -1441,6 +1479,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));
|
||||
}
|
||||
@@ -2529,8 +2568,12 @@ fn text_style(v: &View, size: Option<f64>) -> 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") {
|
||||
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);
|
||||
|
||||
@@ -29,6 +29,10 @@ 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;
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u64>, 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<u8> {
|
||||
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<u64>, 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<usize> = 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);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. Skip
|
||||
//! ink only shows on an underline, so the fixtures are underlined.
|
||||
|
||||
use super::kiwi::Kv;
|
||||
use super::{export, import};
|
||||
use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextDecoration, 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)),
|
||||
("textDecoration", Kv::Enum("UNDERLINE")),
|
||||
(
|
||||
"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)), ("textDecoration", Kv::Enum("UNDERLINE"))];
|
||||
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)),
|
||||
("textDecoration", Kv::Enum("UNDERLINE")),
|
||||
("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.decoration = TextDecoration::Underline;
|
||||
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 { decoration: TextDecoration::Underline, ..TextStyle::default() };
|
||||
shared.set_look(|look| look.skip_ink = true);
|
||||
let mut local = TextStyle { decoration: TextDecoration::Underline, ..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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_ink_without_an_underline_stays_out_of_the_look_and_exports_the_default() {
|
||||
// Plain text with the files' default value imports with the default look...
|
||||
let mut value = raw(Some(Kv::Bool(true)), vec![], "ab", vec![0, 0], None);
|
||||
value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration");
|
||||
let doc = imported(&value);
|
||||
assert_eq!(label(&doc).style.decoration, TextDecoration::None);
|
||||
assert!(label(&doc).style.decoration_look.is_none());
|
||||
|
||||
// ...an underlined run of it keeps the layer's value...
|
||||
let mut value =
|
||||
raw(Some(Kv::Bool(true)), vec![object(vec![("styleID", Kv::Uint(1)), ("textDecoration", Kv::Enum("UNDERLINE"))])], "ab", vec![1, 0], None);
|
||||
value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration");
|
||||
let doc = imported(&value);
|
||||
assert_eq!((0..2).map(|i| skip_at(label(&doc), i)).collect::<Vec<_>>(), vec![true, false]);
|
||||
|
||||
// ...and default native text writes the default, which reads back as the default look.
|
||||
let mut doc = Document::new("Plain");
|
||||
doc.create(doc.first_page().unwrap(), NodeKind::Text(TextData::new("x")), "Label", 0.0, 0.0, 100.0, 30.0).unwrap();
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
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"], true);
|
||||
let (back, _) = import(&bytes, "Plain").unwrap();
|
||||
assert!(label(&back).style.decoration_look.is_none());
|
||||
}
|
||||
@@ -27,6 +27,13 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData {
|
||||
let mut td = TextData { characters, style: base, ..TextData::default() };
|
||||
paragraph(style_json, &mut td);
|
||||
td.runs = runs(cx, v, &td);
|
||||
// Skip ink only shows on an underline. A file can carry it on any text, so elsewhere it stays out
|
||||
// of the look (runs have inherited the base value by now).
|
||||
for st in std::iter::once(&mut td.style).chain(td.runs.iter_mut().map(|r| &mut r.style)) {
|
||||
if st.decoration != TextDecoration::Underline && st.look().skip_ink {
|
||||
st.set_look(|l| l.skip_ink = false);
|
||||
}
|
||||
}
|
||||
td.normalize();
|
||||
lists(v, &mut td);
|
||||
td
|
||||
|
||||
Reference in New Issue
Block a user