Files
figcraft/crates/format/src/fig/text_path_tests.rs
T
Skyfuller1303 6233b90ba7 M1: import editable text on a path from .fig files
Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path.

The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged.
2026-10-17 13:49:58 +07:00

212 lines
8.8 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");
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");
// 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");
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");
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:?}");
}