From a52e2bb6b3773e9ebc328104c887aab2bf58bfdf Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:34:42 +0200 Subject: [PATCH 1/3] M1: distinguish unresolved vector segment style metadata in .fig import The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model. --- crates/format/src/fig/export.rs | 8 +++- crates/format/src/fig/map.rs | 25 ++++++++--- crates/format/src/fig/network.rs | 11 ++--- crates/format/src/fig/tail_tests.rs | 64 ++++++++++++++++++++++------- 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..7443c8de 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1669,7 +1669,13 @@ impl<'d> Exporter<'d> { let segments = net .segments .iter() - .map(|sg| network::Segment { a: sg.a, ta: (finite(sg.ta.0), finite(sg.ta.1)), b: sg.b, tb: (finite(sg.tb.0), finite(sg.tb.1)) }) + .map(|sg| network::Segment { + style: 0, + a: sg.a, + ta: (finite(sg.ta.0), finite(sg.ta.1)), + b: sg.b, + tb: (finite(sg.tb.0), finite(sg.tb.1)), + }) .collect(); let regions = net .regions diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..05fa8566 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1192,12 +1192,29 @@ impl<'a> Mapper<'a> { fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; - let Some((net, styled_segments)) = network::decode_styled(blob) else { + let Some((net, _)) = network::decode_styled(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; - if styled_segments > 0 { - self.warn("stroke styles of single vector network segments were left out"); + let styles: BTreeMap = + vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); + let mut has_uninterpreted = false; + let mut has_missing = false; + for segment in &net.segments { + if segment.style == 0 { + continue; + } + if styles.contains_key(&u64::from(segment.style)) { + has_uninterpreted = true; + } else { + has_missing = true; + } + } + if has_uninterpreted { + self.warn("metadata for styled vector network segments was not interpreted"); + } + if has_missing { + self.warn("vector network segments reference missing style entries"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = @@ -1205,8 +1222,6 @@ impl<'a> Mapper<'a> { if net.segments.is_empty() || (net.regions.is_empty() && fills_shown && o.contains_key("fillGeometry")) { return false; } - let styles: BTreeMap = - vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index d251dff1..4e8c3ad7 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -17,6 +17,7 @@ pub(super) struct Vertex { } pub(super) struct Segment { + pub style: u32, pub a: usize, pub ta: (f64, f64), pub b: usize, @@ -70,8 +71,7 @@ pub(super) fn decode(b: &[u8]) -> Option { decode_styled(b).map(|(net, _)| net) } -/// The network in blob `b` and how many of its segments have a style id of their own (per-segment -/// stroke overrides, which the document model can't hold); `None` as for `decode`. +/// The network in blob `b` and how many of its segments have a nonzero style id; `None` as for `decode`. pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { let mut r = Reader { b, pos: 0 }; let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); @@ -84,10 +84,11 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { } let mut styled = 0usize; for _ in 0..ns { - if r.u32()? != 0 { + let style = r.u32()?; + if style != 0 { styled += 1; } - net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); + net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; for _ in 0..nr { @@ -120,7 +121,7 @@ pub(super) fn encode(net: &Network) -> Vec { fl(v.y, &mut out); } for s in &net.segments { - u(0, &mut out); + u(s.style, &mut out); u(s.a as u32, &mut out); fl(s.ta.0, &mut out); fl(s.ta.1, &mut out); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..657ff6ff 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -9,7 +9,7 @@ use super::super::network::{self, Network, Region, Segment, Vertex}; use super::*; fn seg(a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { - Segment { a, ta, b, tb } + Segment { style: 0, a, ta, b, tb } } fn vtx(style: u32, x: f64, y: f64) -> Vertex { @@ -264,21 +264,33 @@ fn vectors_import_as_editable_networks() { #[test] fn per_segment_stroke_styles_are_reported() { - let mut blob = square_blob(); - // The first segment's style id follows the counts and the vertices (12 bytes each). - blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&7u32.to_le_bytes()); - let (net, styled) = network::decode_styled(&blob).unwrap(); - assert_eq!((net.segments.len(), styled), (4, 1)); + let styled_import = |segment_style: u32| { + let mut blob = square_blob(); + // The first segment's style id follows the counts and the vertices (12 bytes each). + blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&segment_style.to_le_bytes()); + let (net, styled) = network::decode_styled(&blob).unwrap(); + assert_eq!((net.segments.len(), net.segments[0].style, styled), (4, segment_style, 1)); + let mut message = tail_message(); + let Kv::Object(fields) = &mut message else { panic!() }; + let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; + blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); + let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); + assert_eq!(network_of(&doc, "Wave").segments.len(), 4); + warnings + }; assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); let plain = import(&tail_canvas(), "Tail").unwrap().1; - assert!(!plain.iter().any(|w| w.contains("stroke styles of single")), "{plain:?}"); - let mut message = tail_message(); - let Kv::Object(fields) = &mut message else { panic!() }; - let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; - blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); - let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); - assert!(warnings.iter().any(|w| w.contains("stroke styles of single vector network segments were left out")), "{warnings:?}"); - assert_eq!(network_of(&doc, "Wave").segments.len(), 4); + assert!(!plain.iter().any(|w| w.contains("vector network segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("metadata for styled vector network segments was not interpreted"); + let missing = |w: &String| w.contains("vector network segments reference missing style entries"); + // Style 1 has a row in the layer's override table; its contents are not applied. + let known = styled_import(1); + assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); + assert!(!known.iter().any(missing), "{known:?}"); + // Style 7 names no row at all. + let unknown = styled_import(7); + assert_eq!(unknown.iter().filter(|w| missing(w)).count(), 1, "{unknown:?}"); + assert!(!unknown.iter().any(uninterpreted), "{unknown:?}"); } #[test] @@ -385,6 +397,30 @@ fn damaged_network_blobs_never_panic() { } } +#[test] +fn segment_styles_round_trip_without_changing_geometry() { + let original = Network { + vertices: vec![vtx(0, 2.0, 3.0), vtx(0, 10.0, 20.0), vtx(0, 40.0, 50.0), vtx(0, 60.0, 70.0)], + segments: vec![ + seg(0, 1, (1.0, 2.0), (3.0, 4.0)), + Segment { style: 17, a: 1, ta: (5.0, 6.0), b: 2, tb: (7.0, 8.0) }, + Segment { style: 27, a: 2, ta: (9.0, 10.0), b: 3, tb: (11.0, 12.0) }, + ], + regions: vec![], + }; + let bytes = network::encode(&original); + let (decoded, styled) = network::decode_styled(&bytes).unwrap(); + assert_eq!(styled, 2); + assert_eq!(decoded.segments.len(), 3); + for (got, want) in decoded.segments.iter().zip(&original.segments) { + assert_eq!(got.style, want.style); + assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb)); + } + for cut in 0..bytes.len() { + assert!(network::decode_styled(&bytes[..cut]).is_none(), "truncated at {cut}"); + } +} + #[test] fn mangled_tail_files_never_panic() { let c = tail_canvas(); From 120b6830ff94385e6d8c3738673570199fceb466 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:36:24 +0200 Subject: [PATCH 2/3] 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. --- crates/format/src/fig/map.rs | 95 ++++++- crates/format/src/fig/mod.rs | 3 + crates/format/src/fig/text_path.rs | 315 +++++++++++++++++++++++ crates/format/src/fig/text_path_tests.rs | 211 +++++++++++++++ crates/format/src/rest/text.rs | 12 +- docs/gaps.md | 4 + 6 files changed, 637 insertions(+), 3 deletions(-) create mode 100644 crates/format/src/fig/text_path.rs create mode 100644 crates/format/src/fig/text_path_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 05fa8566..1dc8e138 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -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 { + 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 = net.vertices.iter().map(|p| p.x).chain(net.segments.iter().flat_map(|s| [s.ta.0, s.tb.0])).collect(); + let ys: Vec = 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"] { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..2ad6e276 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -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; diff --git a/crates/format/src/fig/text_path.rs b/crates/format/src/fig/text_path.rs new file mode 100644 index 00000000..8a93f7e4 --- /dev/null +++ b/crates/format/src/fig/text_path.rs @@ -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 { + 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 = 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, bool), &'static str> { + let marker = net.segments.get(marked).ok_or("invalid start segment index")?; + let mut adjacency = vec![Vec::::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")); + } +} diff --git a/crates/format/src/fig/text_path_tests.rs b/crates/format/src/fig/text_path_tests.rs new file mode 100644 index 00000000..66148930 --- /dev/null +++ b/crates/format/src/fig/text_path_tests.rs @@ -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::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, 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 { + let mut bytes = Vec::new(); + let u32le = |out: &mut Vec, n: u32| out.extend_from_slice(&n.to_le_bytes()); + let f32le = |out: &mut Vec, 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 { + 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:?}"); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 3875204f..9b8e9be4 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -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 } diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..a5cd6a3b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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. From d8e64a73bf0686277c0a06c2fdec2bb51261d064 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:15:25 +0200 Subject: [PATCH 3/3] M1: reword the vector segment style import notes --- crates/format/src/fig/map.rs | 4 ++-- crates/format/src/fig/tail_tests.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 1dc8e138..712a90fd 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1302,10 +1302,10 @@ impl<'a> Mapper<'a> { } } if has_uninterpreted { - self.warn("metadata for styled vector network segments was not interpreted"); + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); } if has_missing { - self.warn("vector network segments reference missing style entries"); + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 657ff6ff..bc4f31cc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -280,9 +280,9 @@ fn per_segment_stroke_styles_are_reported() { }; assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); let plain = import(&tail_canvas(), "Tail").unwrap().1; - assert!(!plain.iter().any(|w| w.contains("vector network segments")), "{plain:?}"); - let uninterpreted = |w: &String| w.contains("metadata for styled vector network segments was not interpreted"); - let missing = |w: &String| w.contains("vector network segments reference missing style entries"); + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); // Style 1 has a row in the layer's override table; its contents are not applied. let known = styled_import(1); assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}");