Files
figcraft/crates/format/src/fig/text_path_tests.rs
T

212 lines
8.9 KiB
Rust

//! Mapper → private REST ingress tests for source text-path fields.
use super::kiwi::Kv;
const SCHEMA: &str = "
enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH
enum StyleType NONE TEXT
enum TextDecoration NONE UNDERLINE STRIKETHROUGH
struct GUID uint sessionID, uint localID
struct StyleId GUID guid
struct ParentIndex GUID guid, string position
struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
struct FontName string family, string style
message TextOverride uint styleID, FontName fontName, float fontSize
message TextData string characters, uint[] characterStyleIDs, TextOverride[] styleOverrideTable
message TextPathStart float tValue, bool forward
message VectorData uint vectorNetworkBlob, Vector normalizedSize
message Blob byte[] bytes
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration
message Message NodeChange[] nodeChanges, Blob[] blobs
";
use figcraft_doc::{NodeKind, TextPath};
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn a(values: Vec<Kv<'static>>) -> Kv<'static> {
Kv::Array(values)
}
fn f(value: f32) -> Kv<'static> {
Kv::Float(value)
}
fn u(value: u64) -> Kv<'static> {
Kv::Uint(value)
}
fn s(value: &'static str) -> Kv<'static> {
Kv::Str(value.into())
}
fn point(x: f32, y: f32) -> Kv<'static> {
o(vec![("x", f(x)), ("y", f(y))])
}
fn guid(id: u64) -> Kv<'static> {
o(vec![("sessionID", u(1)), ("localID", u(id))])
}
fn node(id: u64, parent: Option<u64>, ty: &'static str, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut node = vec![("guid", guid(id)), ("type", s(ty))];
if let Some(parent) = parent {
node.push(("parentIndex", o(vec![("guid", guid(parent)), ("position", s("!"))])));
}
node.extend(fields);
o(node)
}
fn blob() -> Vec<u8> {
let mut bytes = Vec::new();
let u32le = |out: &mut Vec<u8>, n: u32| out.extend_from_slice(&n.to_le_bytes());
let f32le = |out: &mut Vec<u8>, n: f32| out.extend_from_slice(&n.to_le_bytes());
u32le(&mut bytes, 2); // vertices
u32le(&mut bytes, 1); // segments
u32le(&mut bytes, 0); // regions
for (x, y) in [(0.0, 0.0), (100.0, 0.0)] {
u32le(&mut bytes, 0);
f32le(&mut bytes, x);
f32le(&mut bytes, y);
}
u32le(&mut bytes, 0x01ff_ffff); // source start-segment marker
u32le(&mut bytes, 0);
f32le(&mut bytes, 0.0);
f32le(&mut bytes, 0.0);
u32le(&mut bytes, 1);
f32le(&mut bytes, 0.0);
f32le(&mut bytes, 0.0);
bytes
}
fn canvas() -> Vec<u8> {
let schema = super::kiwi::Schema::parse(SCHEMA).unwrap();
let message = message(true);
let message = super::kiwi::encode::message(&schema, schema.find("Message").unwrap(), &message).unwrap();
let mut out = b"fig-kiwi\0\0\0\0".to_vec();
for raw in [super::kiwi::encode::schema(&schema), message] {
let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, &raw).unwrap();
let compressed = encoder.finish().unwrap();
out.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
out.extend_from_slice(&compressed);
}
out
}
fn message(with_path: bool) -> Kv<'static> {
let mut nodes = vec![node(0, None, "DOCUMENT", vec![]), node(1, Some(0), "CANVAS", vec![])];
let mut blobs = Vec::new();
if with_path {
blobs.push(o(vec![("bytes", Kv::Bytes(blob()))]));
let text = o(vec![
("characters", s("alpha")),
("characterStyleIDs", a(vec![u(0), u(0), u(0), u(2), u(2)])),
(
"styleOverrideTable",
a(vec![o(vec![("styleID", u(2)), ("fontName", o(vec![("family", s("Inter")), ("style", s("Medium"))])), ("fontSize", f(30.0))])]),
),
]);
nodes.push(node(
2,
Some(1),
"TEXT_PATH",
vec![
("name", s("alpha path")),
("size", point(100.0, 0.0)),
("transform", o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))])),
("textData", text),
("fontSize", f(24.0)),
("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])),
("textPathStart", o(vec![("tValue", f(0.5)), ("forward", Kv::Bool(true))])),
("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(100.0, 0.0))])),
],
));
} else {
nodes.push(node(2, Some(1), "TEXT_PATH", vec![("name", s("broken path")), ("textData", o(vec![("characters", s("fallback"))]))]));
}
o(vec![("nodeChanges", a(nodes)), ("blobs", a(blobs))])
}
fn linked_underline_message() -> Kv<'static> {
let Kv::Object(mut root) = message(true) else { panic!("expected message object") };
let (_, Kv::Array(nodes)) = root.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!("expected node changes array") };
let text = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("alpha path")).unwrap();
let Kv::Object(fields) = text else { panic!("expected text node object") };
fields.push(("styleIdForText", o(vec![("guid", guid(3))])));
nodes.push(node(
3,
Some(1),
"TEXT",
vec![
("name", s("Shared style")),
("styleType", s("TEXT")),
("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])),
("fontSize", f(24.0)),
("textDecoration", s("UNDERLINE")),
],
));
Kv::Object(root)
}
#[test]
fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() {
let message = message(true);
let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}");
let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap();
assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}");
let node = doc.nodes().find(|n| n.name == "alpha path").unwrap();
let NodeKind::Text(text) = &node.kind else { panic!("expected text") };
let path = text.path.as_deref().unwrap();
assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false });
assert_eq!(text.characters, "alpha");
assert_eq!(text.runs.len(), 1);
assert_eq!(text.runs[0].start, 3);
assert_eq!(text.runs[0].end, 5);
}
#[test]
fn public_rest_import_ignores_private_fig_text_path_field() {
let message = message(true);
let (json, _, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
// The mapper's private field is accepted only by import_fig, never by public REST import.
let path = json["document"]["children"][0]["children"][0]["_figTextPath"].clone();
assert!(path.is_object());
let (doc, _) = crate::rest::import(&json, &|_| None).unwrap();
let node = doc.nodes().find(|n| n.name == "alpha path").unwrap();
let NodeKind::Text(text) = &node.kind else { panic!("expected text") };
assert!(text.path.is_none());
}
#[test]
fn serialized_kiwi_canvas_import_decodes_start_and_builds_native_path() {
let (doc, warnings) = super::import(&canvas(), "Serialized text path").unwrap();
let node = doc.nodes().find(|n| n.name == "alpha path").unwrap();
let NodeKind::Text(text) = &node.kind else { panic!("expected text") };
let path = text.path.as_deref().unwrap();
assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false });
assert_eq!(text.characters, "alpha");
assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}");
}
#[test]
fn malformed_text_path_falls_back_with_specific_warning() {
let message = message(false);
let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
assert!(warnings.iter().any(|w| w.contains("text path has no start data") && w.contains("text imports off its path")), "{warnings:?}");
let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap();
let node = doc.nodes().find(|n| n.name == "broken path").unwrap();
assert!(matches!(&node.kind, NodeKind::Text(text) if text.path.is_none()));
}
#[test]
fn linked_text_style_decoration_warns_from_effective_mapped_style() {
let message = linked_underline_message();
let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE");
assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}");
}