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.
This commit is contained in:
Skyfuller1303
2026-10-16 05:03:29 +02:00
parent a52e2bb6b3
commit 120b6830ff
6 changed files with 637 additions and 3 deletions
+93 -2
View File
@@ -759,7 +759,7 @@ impl<'a> Mapper<'a> {
"VARIABLE" | "VARIABLE_SET" => return None,
t => stand.map_or(t, |s| s.0),
};
if let Some((_, what)) = stand {
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
}
self.count += 1;
@@ -774,7 +774,12 @@ impl<'a> Mapper<'a> {
copy_str(&mut o, &view, "name", "name");
self.common(&mut o, &view, ty, space, parent);
match ty {
"TEXT" => self.text(&mut o, &view),
"TEXT" => {
self.text(&mut o, &view);
if own == "TEXT_PATH" {
self.text_path(&mut o, &view);
}
}
"VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view),
"ELLIPSE" => {
if let Some(a) = view.get("arcData") {
@@ -1155,6 +1160,92 @@ impl<'a> Mapper<'a> {
}
}
/// Carry supported text-path data through the private .fig REST ingress only.
fn text_path(&mut self, o: &mut Obj, v: &View<'a>) {
let fail = |this: &mut Self, reason: &str| {
this.warn(format!("text path {reason}; text imports off its path"));
};
let Some(start) = v.get("textPathStart") else {
fail(self, "has no start data");
return;
};
let (Some(t), Some(forward)) = (num(start, "tValue"), start.get("forward").and_then(Kv::bool)) else {
fail(self, "has invalid start data");
return;
};
let Some(vd) = v.get("vectorData") else {
fail(self, "has no vector data");
return;
};
let Some(blob) = num(vd, "vectorNetworkBlob")
.filter(|i| i.is_finite() && *i >= 0.0 && i.fract() == 0.0)
.and_then(|i| usize::try_from(i as u64).ok())
.and_then(|i| self.blobs.get(i))
.copied()
else {
fail(self, "has no vector network");
return;
};
let Some((net, _)) = network::decode_styled(blob) else {
fail(self, "has damaged vector data");
return;
};
let Some(norm) = vd.get("normalizedSize") else {
fail(self, "has invalid normalized size");
return;
};
let Some(size) = v.get("size") else {
fail(self, "has invalid dimensions");
return;
};
let (Some(nx), Some(ny), Some(w), Some(h)) = (num(norm, "x"), num(norm, "y"), num(size, "x"), num(size, "y")) else {
fail(self, "has invalid dimensions");
return;
};
let axis_scale = |size: f64, normalized: f64, values: &[f64]| -> Option<f64> {
if !size.is_finite() || size < 0.0 || !normalized.is_finite() || normalized < 0.0 {
return None;
}
if normalized > 1e-9 {
if size <= 0.0 {
return None;
}
return Some(size / normalized);
}
(size == 0.0 && values.iter().all(|v| *v == 0.0)).then_some(1.0)
};
let xs: Vec<f64> = net.vertices.iter().map(|p| p.x).chain(net.segments.iter().flat_map(|s| [s.ta.0, s.tb.0])).collect();
let ys: Vec<f64> = net.vertices.iter().map(|p| p.y).chain(net.segments.iter().flat_map(|s| [s.ta.1, s.tb.1])).collect();
let sx = axis_scale(w, nx, &xs);
let sy = axis_scale(h, ny, &ys);
let (Some(sx), Some(sy)) = (sx, sy) else {
fail(self, "has invalid path scale");
return;
};
if v.str("textAlignVertical").is_some_and(|x| x != "TOP") {
fail(self, "uses unsupported vertical placement");
return;
}
if v.get("textData").and_then(|td| td.get("characters")).and_then(Kv::str).is_some_and(|s| s.chars().any(|c| matches!(c, '\n' | '\r'))) {
fail(self, "contains multiple lines");
return;
}
let path = match super::text_path::from_network(&net, sx, sy, t, forward) {
Ok(path) => path,
Err(reason) => {
fail(self, reason);
return;
}
};
let has_decoration = |style: &Value| style.get("textDecoration").and_then(Value::as_str).is_some_and(|x| x != "NONE");
let decorated = o.get("style").is_some_and(has_decoration)
|| o.get("styleOverrideTable").and_then(Value::as_object).into_iter().flat_map(|styles| styles.values()).any(has_decoration);
if decorated {
self.warn("text path underline or strikethrough decoration is not rendered");
}
o.insert("_figTextPath".into(), json!({"d": path.d, "start": path.start, "flip": path.flip}));
}
/// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters.
fn geometry(&mut self, o: &mut Obj, v: &View<'a>) {
for key in ["fillGeometry", "strokeGeometry"] {
+3
View File
@@ -24,6 +24,9 @@ mod network;
mod source_identity_tests;
#[cfg(test)]
mod tests;
mod text_path;
#[cfg(test)]
mod text_path_tests;
#[cfg(test)]
mod text_style_tests;
+315
View File
@@ -0,0 +1,315 @@
//! Convert the simple vector network used by text-on-path layers in `.fig` files into a native text path.
use figcraft_doc::{
TextPath,
geom::{
BezPath,
kurbo::{CubicBez, Line, ParamCurve, ParamCurveArclen, Point},
},
};
use super::network::{Network, Segment};
const START_SEGMENT_STYLE: u32 = 0x01ff_ffff;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Use {
segment: usize,
forward: bool,
}
/// Build a native path from a validated connected chain or cycle. The marked source segment is
/// oriented a→b; `t` is local to that line/cubic. Coordinates are scaled into layer space.
pub(super) fn from_network(net: &Network, sx: f64, sy: f64, t: f64, forward: bool) -> Result<TextPath, &'static str> {
if !t.is_finite() || !(0.0..=1.0).contains(&t) {
return Err("invalid start parameter");
}
if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 {
return Err("invalid path scale");
}
let markers: Vec<usize> = net.segments.iter().enumerate().filter_map(|(i, s)| (s.style == START_SEGMENT_STYLE).then_some(i)).collect();
let [marked] = markers.as_slice() else {
return Err("missing or ambiguous start segment marker");
};
let (uses, closed) = ordered_chain(net, *marked)?;
let mut d = BezPath::new();
let mut total = 0.0;
let mut prefix = 0.0;
let mut start_segment_length = 0.0;
for use_ in &uses {
let (p0, p1, p2, p3) = cubic(net, *use_, sx, sy)?;
if d.elements().is_empty() {
d.move_to((p0.0, p0.1));
}
let line = p1 == p0 && p2 == p3;
let (length, partial) = if line {
d.line_to((p3.0, p3.1));
let line = Line::new(Point::new(p0.0, p0.1), Point::new(p3.0, p3.1));
(line.arclen(1e-9), line.arclen(1e-9) * t)
} else {
d.curve_to((p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1));
let curve = CubicBez::new((p0.0, p0.1), (p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1));
let partial = if t <= 0.0 { 0.0 } else { curve.subsegment(0.0..t).arclen(1e-9) };
(curve.arclen(1e-9), partial)
};
if !length.is_finite() || length <= 1e-10 {
return Err("zero-length path segment");
}
if use_.segment == *marked {
start_segment_length = partial;
if !start_segment_length.is_finite() {
return Err("invalid path length");
}
prefix = total;
}
total += length;
}
if closed {
d.close_path();
}
if !total.is_finite() || total <= 1e-10 {
return Err("zero-length path");
}
let mut start = ((prefix + start_segment_length) / total).clamp(0.0, 1.0);
let flip = !forward;
if flip {
start = 1.0 - start;
}
let path = TextPath { d: d.to_svg(), start, flip };
let lines = figcraft_doc::geom::measure::polylines(&path.path()).ok_or("cannot be measured by text renderer")?;
if lines.first().is_none_or(|line| line.length() <= 1e-9) {
return Err("cannot be measured by text renderer");
}
Ok(path)
}
/// Return every segment in chain order. Open chains are oriented so the marked segment runs a→b;
/// cycles start at the marked segment's a vertex and traverse it first.
fn ordered_chain(net: &Network, marked: usize) -> Result<(Vec<Use>, bool), &'static str> {
let marker = net.segments.get(marked).ok_or("invalid start segment index")?;
let mut adjacency = vec![Vec::<usize>::new(); net.vertices.len()];
for (i, s) in net.segments.iter().enumerate() {
if s.a >= adjacency.len() || s.b >= adjacency.len() {
return Err("invalid path vertex index");
}
if s.a == s.b {
return Err("path contains a segment that starts and ends at the same vertex");
}
adjacency[s.a].push(i);
adjacency[s.b].push(i);
}
let mut endpoints = Vec::new();
for edges in &adjacency {
if edges.len() > 2 {
return Err("branched path network");
}
if edges.len() == 1 {
endpoints.push(edges[0]);
}
}
if !endpoints.is_empty() && endpoints.len() != 2 {
return Err("disconnected or malformed path network");
}
let cycle = endpoints.is_empty();
let mut used = vec![false; net.segments.len()];
let mut out = Vec::with_capacity(net.segments.len());
if cycle {
out.push(Use { segment: marked, forward: true });
used[marked] = true;
let mut at = marker.b;
let mut visited = vec![false; net.vertices.len()];
visited[marker.a] = true;
visited[marker.b] = true;
while at != marker.a {
let next = adjacency[at].iter().copied().find(|i| !used[*i]).ok_or("disconnected path cycle")?;
used[next] = true;
let s = &net.segments[next];
let is_forward = s.a == at;
at = if is_forward { s.b } else { s.a };
if at != marker.a && std::mem::replace(&mut visited[at], true) {
return Err("path cycle is not simple");
}
out.push(Use { segment: next, forward: is_forward });
if out.len() > net.segments.len() {
return Err("invalid path cycle");
}
}
} else {
let endpoint = adjacency.iter().position(|e| e.len() == 1).ok_or("missing path endpoint")?;
let mut at = endpoint;
while let Some(next) = adjacency[at].iter().copied().find(|i| !used[*i]) {
used[next] = true;
let s = &net.segments[next];
let is_forward = s.a == at;
at = if is_forward { s.b } else { s.a };
out.push(Use { segment: next, forward: is_forward });
}
let Some(marker_use) = out.iter().find(|u| u.segment == marked) else { return Err("disconnected start segment") };
if !marker_use.forward {
out.reverse();
for u in &mut out {
u.forward = !u.forward;
}
}
}
if !used.iter().all(|x| *x) {
return Err("disconnected path network");
}
Ok((out, cycle))
}
type P = (f64, f64);
fn cubic(net: &Network, use_: Use, sx: f64, sy: f64) -> Result<(P, P, P, P), &'static str> {
let s: &Segment = net.segments.get(use_.segment).ok_or("invalid path segment index")?;
let a = net.vertices.get(s.a).ok_or("invalid path vertex index")?;
let b = net.vertices.get(s.b).ok_or("invalid path vertex index")?;
let p0 = (a.x * sx, a.y * sy);
let p3 = (b.x * sx, b.y * sy);
let ta = (s.ta.0 * sx, s.ta.1 * sy);
let tb = (s.tb.0 * sx, s.tb.1 * sy);
if [p0.0, p0.1, p3.0, p3.1, ta.0, ta.1, tb.0, tb.1].iter().any(|x| !x.is_finite()) {
return Err("invalid path coordinates");
}
let (p0, p1, p2, p3) =
if ta == (0.0, 0.0) && tb == (0.0, 0.0) { (p0, p0, p3, p3) } else { (p0, (p0.0 + ta.0, p0.1 + ta.1), (p3.0 + tb.0, p3.1 + tb.1), p3) };
if use_.forward { Ok((p0, p1, p2, p3)) } else { Ok((p3, p2, p1, p0)) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fig::network::{Segment, Vertex};
use figcraft_doc::geom::kurbo::PathEl;
fn vertex(x: f64, y: f64) -> Vertex {
Vertex { style: 0, x, y }
}
fn segment(style: u32, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment {
Segment { style, a, ta, b, tb }
}
fn curved() -> Network {
Network {
vertices: vec![vertex(0.0, 82.5), vertex(300.0, 82.5), vertex(600.0, 82.5)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (70.0, -110.0), (-70.0, -110.0)), segment(0, 1, 2, (70.0, 110.0), (-70.0, 110.0))],
..Network::default()
}
}
#[test]
fn local_cubic_parameter_converts_to_path_distance() {
let path = from_network(&curved(), 1.0, 1.0, 0.0086954795, true).unwrap();
assert!((path.start - 0.004744078).abs() < 0.00001);
// Cubic t is not its fraction of total path length.
assert!((path.start - 0.0086954795).abs() > 0.003);
let elements = path.path().elements().to_vec();
assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 82.5));
assert!(matches!(elements.get(1), Some(PathEl::CurveTo(..))));
}
#[test]
fn second_segment_marker_and_t_are_respected() {
let mut net = curved();
net.segments[0].style = 0;
net.segments[1].style = START_SEGMENT_STYLE;
let path = from_network(&net, 1.0, 1.0, 0.49800667, true).unwrap();
assert!((path.start - 0.7490379757).abs() < 0.00002);
}
#[test]
fn reverse_direction_inverts_normalized_start() {
let net = Network {
vertices: vec![vertex(0.0, 0.0), vertex(600.0, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
let path = from_network(&net, 1.0, 1.0, 0.83986014, false).unwrap();
assert!(path.flip);
assert!((path.start - 0.16013986).abs() < 1e-8);
assert!(matches!(path.path().elements().get(1), Some(PathEl::LineTo(p)) if p.x == 600.0 && p.y == 0.0));
}
#[test]
fn rejects_missing_duplicate_and_zero_length_segments() {
let mut net = curved();
net.segments[0].style = 0;
assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker"));
net.segments[1].style = START_SEGMENT_STYLE;
net.segments[0].style = START_SEGMENT_STYLE;
assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker"));
let zero = Network {
vertices: vec![vertex(0.0, 0.0), vertex(0.0, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
assert_eq!(from_network(&zero, 1.0, 1.0, 0.5, true), Err("zero-length path segment"));
}
#[test]
fn rejects_branching_and_disconnected_segments() {
let branch = Network {
vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(1.0, 1.0)],
segments: vec![
segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)),
segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)),
segment(0, 1, 3, (0.0, 0.0), (0.0, 0.0)),
],
..Network::default()
};
assert_eq!(from_network(&branch, 1.0, 1.0, 0.5, true), Err("branched path network"));
let disconnected = Network {
vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(3.0, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), segment(0, 2, 3, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
assert_eq!(from_network(&disconnected, 1.0, 1.0, 0.5, true), Err("disconnected or malformed path network"));
}
#[test]
fn scales_geometry_and_accepts_closed_cycles() {
let cycle = Network {
vertices: vec![vertex(0.0, 0.0), vertex(100.0, 0.0), vertex(100.0, 100.0)],
segments: vec![
segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)),
segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)),
segment(0, 2, 0, (0.0, 0.0), (0.0, 0.0)),
],
..Network::default()
};
let path = from_network(&cycle, 2.0, 3.0, 0.5, true).unwrap();
let elements = path.path().elements().to_vec();
assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 0.0));
assert!(matches!(elements.get(1), Some(PathEl::LineTo(p)) if p.x == 200.0 && p.y == 0.0));
let total = 200.0 + 300.0 + 130_000.0_f64.sqrt();
assert!((path.start - 100.0 / total).abs() < 1e-8);
assert!(matches!(elements.last(), Some(PathEl::ClosePath)));
}
#[test]
fn rejects_paths_the_native_renderer_cannot_measure() {
let count = 40_000usize;
let many_lines = Network {
vertices: (0..=count).map(|i| vertex(i as f64, 0.0)).collect(),
segments: (0..count).map(|i| segment(if i == 0 { START_SEGMENT_STYLE } else { 0 }, i, i + 1, (0.0, 0.0), (0.0, 0.0))).collect(),
..Network::default()
};
assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
let huge = Network {
vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
assert_eq!(from_network(&huge, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
let tiny = Network {
vertices: vec![vertex(0.0, 0.0), vertex(5e-10, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
assert_eq!(from_network(&tiny, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
}
}
+211
View File
@@ -0,0 +1,211 @@
//! 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:?}");
}
+11 -1
View File
@@ -5,7 +5,7 @@ use std::collections::BTreeMap;
use figcraft_doc::{
DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase,
TextData, TextDecoration, TextStyle,
TextData, TextDecoration, TextPath, TextStyle,
};
use serde_json::Value;
@@ -29,6 +29,16 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData {
td.runs = runs(cx, v, &td);
td.normalize();
lists(v, &mut td);
if cx.fig_sources.is_some()
&& let Some(path) = v.get("_figTextPath")
{
match (path.get("d").and_then(Value::as_str), path.get("start").and_then(Value::as_f64), path.get("flip").and_then(Value::as_bool)) {
(Some(d), Some(start), Some(flip)) if !d.is_empty() && start.is_finite() && (0.0..=1.0).contains(&start) => {
td.path = Some(Box::new(TextPath { d: d.to_owned(), start, flip }));
}
_ => cx.warn("invalid internal .fig text path data was ignored"),
}
}
td
}
+4
View File
@@ -41,6 +41,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
## Entries
### 1. `.fig` files: the import's tail
- **Text paths (2026-10-15):** local `.fig` import maps supported connected `TEXT_PATH` networks
to existing editable native text paths, preserving the start segment, direction and styled
runs. Other vertical placements, multiline/ambiguous networks, path decorations, public REST
text paths and `.fig` text-path export remain limited.
- **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`,
`file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved
over.