feat(figma): text mapper + vector geometry decoder

Stage F part 2 — ports figma-text-mapper.ts + figma-vector-decoder.ts.

- text_mapper.rs: map_figma_text_props → TextProps. Builds TextContent
  (plain | per-run StyledTextSegment[] from characterStyleIDs +
  styleOverrideTable, UTF-16-indexed), parses font weight from the
  style name (ordered substring match), line-height → multiplier,
  letter-spacing → px, align / vertical-align / growth enums, and
  applies textCase (UPPER / LOWER / TITLE).
- vector_decoder.rs:
  - decode_figma_path_blob — the opcode command stream (Z/M/L/Q/C,
    f32-LE operands, graceful truncation).
  - compute_svg_path_bounds — coordinate-pair bbox.
  - decode_figma_vector_path — geometry-blob path (stroke centerline
    preferred for stroke-only shapes).
  - decode_vector_network_blob — vertex/segment table fallback, chain-
    walked into M/L/C subpaths, scaled by nodeSize/normalizedSize.

op-figma 68 tests green (+16).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kayshen-X 2026-05-17 16:07:16 +08:00
parent 1e32c6f6cf
commit e18916c68c
5 changed files with 976 additions and 0 deletions

View file

@ -18,6 +18,10 @@ mod kiwi;
#[allow(dead_code)]
mod mappers;
#[allow(dead_code)]
mod text_mapper;
#[allow(dead_code)]
mod vector_decoder;
#[allow(dead_code)]
mod zip_reader;
use jian_ops_schema::node::{

View file

@ -0,0 +1,308 @@
//! Figma text mapper — ports `figma-text-mapper.ts`. Converts a
//! Figma TEXT node's style fields into canonical text props +
//! builds the `TextContent` (plain string or per-run styled
//! segments) from `characters` + `characterStyleIDs`.
use crate::color::figma_color_to_hex;
use crate::figma_types::FigColor;
use crate::kiwi::FigValue;
use jian_ops_schema::node::text::{TextAlign, TextAlignVertical, TextContent, TextGrowth};
use jian_ops_schema::style::{FontStyleKind, StyledTextSegment};
/// Mapped text props — spread onto a `TextNode` by the converter.
#[derive(Debug, Clone, PartialEq)]
pub struct TextProps {
pub content: TextContent,
pub font_family: Option<String>,
pub font_size: Option<f64>,
pub font_weight: Option<u32>,
pub font_style: Option<FontStyleKind>,
pub letter_spacing: Option<f64>,
pub line_height: Option<f64>,
pub text_align: Option<TextAlign>,
pub text_align_vertical: Option<TextAlignVertical>,
pub text_growth: Option<TextGrowth>,
pub underline: Option<bool>,
pub strikethrough: Option<bool>,
}
/// Map a Figma TEXT node's fields to [`TextProps`].
pub fn map_figma_text_props(node: &FigValue) -> TextProps {
let font_name = node.get("fontName");
let style_lower = font_name
.and_then(|f| f.get_str("style"))
.unwrap_or("")
.to_lowercase();
let mut props = TextProps {
content: apply_text_case(build_content(node), node.get_str("textCase")),
font_family: font_name
.and_then(|f| f.get_str("family"))
.map(|s| s.to_string()),
font_size: node.get_f64("fontSize"),
font_weight: parse_font_weight(&style_lower),
font_style: if style_lower.contains("italic") {
Some(FontStyleKind::Italic)
} else {
None
},
letter_spacing: map_letter_spacing(node),
line_height: map_line_height(node),
text_align: map_text_align(node.get_str("textAlignHorizontal")),
text_align_vertical: map_text_align_vertical(node.get_str("textAlignVertical")),
text_growth: map_text_growth(node.get_str("textAutoResize")),
underline: None,
strikethrough: None,
};
match node.get_str("textDecoration") {
Some("UNDERLINE") => props.underline = Some(true),
Some("STRIKETHROUGH") => props.strikethrough = Some(true),
_ => {}
}
props
}
/// Build the text content — plain string, or per-run styled segments
/// when `characterStyleIDs` + `styleOverrideTable` carry overrides.
fn build_content(node: &FigValue) -> TextContent {
let Some(text_data) = node.get("textData") else {
return TextContent::Plain(String::new());
};
let characters = text_data.get_str("characters").unwrap_or("");
if characters.is_empty() {
return TextContent::Plain(String::new());
}
let (Some(style_ids), Some(table)) = (
text_data.get_array("characterStyleIDs"),
text_data.get_array("styleOverrideTable"),
) else {
return TextContent::Plain(characters.to_string());
};
if style_ids.is_empty() || table.is_empty() {
return TextContent::Plain(characters.to_string());
}
// Figma indexes characterStyleIDs by UTF-16 code unit.
let units: Vec<u16> = characters.encode_utf16().collect();
let style_at = |i: usize| -> i32 {
style_ids
.get(i)
.and_then(|v| v.as_f64())
.map(|v| v as i32)
.unwrap_or(-1)
};
let mut segments: Vec<StyledTextSegment> = Vec::new();
let mut current = style_ids.first().and_then(|v| v.as_f64()).unwrap_or(0.0) as i32;
let mut seg_start = 0usize;
for i in 1..=units.len() {
let sid = style_at(i);
if sid != current || i == units.len() {
if i > seg_start {
let seg_text = String::from_utf16_lossy(&units[seg_start..i]);
segments.push(build_segment(&seg_text, current, table));
}
current = sid;
seg_start = i;
}
}
let has_override = segments.iter().any(|s| {
s.font_family.is_some()
|| s.font_size.is_some()
|| s.font_weight.is_some()
|| s.fill.is_some()
});
if has_override {
TextContent::Styled(segments)
} else {
TextContent::Plain(characters.to_string())
}
}
/// Build one styled segment from a style-override-table entry.
fn build_segment(text: &str, style_id: i32, table: &[FigValue]) -> StyledTextSegment {
let mut seg = StyledTextSegment {
text: text.to_string(),
font_family: None,
font_size: None,
font_weight: None,
font_style: None,
fill: None,
underline: None,
strikethrough: None,
href: None,
};
if style_id <= 0 {
return seg;
}
let idx = style_id as usize;
let Some(ov) = table.get(idx).or_else(|| table.get(idx - 1)) else {
return seg;
};
let font_name = ov.get("fontName");
if let Some(family) = font_name.and_then(|f| f.get_str("family")) {
if !family.is_empty() {
seg.font_family = Some(family.to_string());
}
}
if let Some(size) = ov.get_f64("fontSize") {
if size != 0.0 {
seg.font_size = Some(size as f32);
}
}
let style_lower = font_name
.and_then(|f| f.get_str("style"))
.unwrap_or("")
.to_lowercase();
if let Some(w) = parse_font_weight(&style_lower) {
seg.font_weight = Some(w);
}
if style_lower.contains("italic") {
seg.font_style = Some(FontStyleKind::Italic);
}
match ov.get_str("textDecoration") {
Some("UNDERLINE") => seg.underline = Some(true),
Some("STRIKETHROUGH") => seg.strikethrough = Some(true),
_ => {}
}
if let Some(color) = ov
.get_array("fillPaints")
.and_then(|p| p.first())
.and_then(|p| p.get("color"))
.and_then(FigColor::from_value)
{
seg.fill = Some(figma_color_to_hex(&color));
}
seg
}
/// Apply Figma `textCase` (UPPER / LOWER / TITLE) to the content.
fn apply_text_case(content: TextContent, text_case: Option<&str>) -> TextContent {
let transform: fn(&str) -> String = match text_case {
Some("UPPER") => |s| s.to_uppercase(),
Some("LOWER") => |s| s.to_lowercase(),
Some("TITLE") => title_case,
_ => return content,
};
match content {
TextContent::Plain(s) => TextContent::Plain(transform(&s)),
TextContent::Styled(segs) => TextContent::Styled(
segs.into_iter()
.map(|mut s| {
s.text = transform(&s.text);
s
})
.collect(),
),
}
}
/// Uppercase the first word character after every word boundary —
/// `\b\w` with ASCII `\w` (`[A-Za-z0-9_]`), matching JS.
fn title_case(s: &str) -> String {
let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_';
let mut out = String::with_capacity(s.len());
let mut prev_word = false;
for c in s.chars() {
if is_word(c) && !prev_word {
out.extend(c.to_uppercase());
} else {
out.push(c);
}
prev_word = is_word(c);
}
out
}
/// Figma font-style string → numeric weight. First substring match
/// wins; order matters (`extrabold` before `bold`, `extralight`
/// before `light`).
fn parse_font_weight(style: &str) -> Option<u32> {
if style.contains("thin") || style.contains("hairline") {
Some(100)
} else if style.contains("extralight") || style.contains("ultralight") {
Some(200)
} else if style.contains("light") {
Some(300)
} else if style.contains("regular") || style.contains("normal") {
Some(400)
} else if style.contains("medium") {
Some(500)
} else if style.contains("semibold") || style.contains("demibold") {
Some(600)
} else if style.contains("extrabold") || style.contains("ultrabold") {
Some(800)
} else if style.contains("bold") {
Some(700)
} else if style.contains("black") || style.contains("heavy") {
Some(900)
} else {
None
}
}
/// Line height → unitless multiplier (3-decimal rounded).
fn map_line_height(node: &FigValue) -> Option<f64> {
let lh = node.get("lineHeight")?;
let value = lh.get_f64("value")?;
if value == 0.0 {
return None;
}
let font_size = node.get_f64("fontSize").unwrap_or(14.0);
let mul = match lh.get_str("units")? {
"PIXELS" => value / font_size,
"PERCENT" => value / 100.0,
"RAW" => value,
_ => return None,
};
Some((mul * 1000.0).round() / 1000.0)
}
/// Letter spacing → pixels (percent units resolved against font size).
fn map_letter_spacing(node: &FigValue) -> Option<f64> {
let ls = node.get("letterSpacing")?;
let value = ls.get_f64("value")?;
if value == 0.0 {
return None;
}
match ls.get_str("units")? {
"PIXELS" => Some(value),
"PERCENT" => {
let font_size = node.get_f64("fontSize").unwrap_or(14.0);
Some(((font_size * value / 100.0) * 100.0).round() / 100.0)
}
_ => None,
}
}
fn map_text_align(v: Option<&str>) -> Option<TextAlign> {
match v {
Some("LEFT") => Some(TextAlign::Left),
Some("CENTER") => Some(TextAlign::Center),
Some("RIGHT") => Some(TextAlign::Right),
Some("JUSTIFIED") => Some(TextAlign::Justify),
_ => None,
}
}
fn map_text_align_vertical(v: Option<&str>) -> Option<TextAlignVertical> {
match v {
Some("TOP") => Some(TextAlignVertical::Top),
Some("CENTER") => Some(TextAlignVertical::Middle),
Some("BOTTOM") => Some(TextAlignVertical::Bottom),
_ => None,
}
}
fn map_text_growth(v: Option<&str>) -> Option<TextGrowth> {
match v {
Some("WIDTH_AND_HEIGHT") => Some(TextGrowth::Auto),
Some("HEIGHT") => Some(TextGrowth::FixedWidth),
Some("NONE") => Some(TextGrowth::FixedWidthHeight),
_ => None,
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,141 @@
//! Text-mapper tests.
use super::*;
fn obj(pairs: Vec<(&str, FigValue)>) -> FigValue {
FigValue::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}
#[test]
fn plain_text_when_no_style_runs() {
let node = obj(vec![(
"textData",
obj(vec![("characters", FigValue::Str("Hello".into()))]),
)]);
let props = map_figma_text_props(&node);
assert_eq!(props.content, TextContent::Plain("Hello".into()));
}
#[test]
fn font_weight_parsed_from_style_name() {
assert_eq!(parse_font_weight("semibold italic"), Some(600));
// "extra light" contains "light" → 300 (no "extralight" token).
assert_eq!(parse_font_weight("extra light"), Some(300));
assert_eq!(parse_font_weight("extralight"), Some(200));
assert_eq!(parse_font_weight("extrabold"), Some(800));
assert_eq!(parse_font_weight("bold"), Some(700));
assert_eq!(parse_font_weight("regular"), Some(400));
}
#[test]
fn font_props_and_italic_detected() {
let node = obj(vec![
(
"fontName",
obj(vec![
("family", FigValue::Str("Inter".into())),
("style", FigValue::Str("Bold Italic".into())),
]),
),
("fontSize", FigValue::Float(18.0)),
(
"textData",
obj(vec![("characters", FigValue::Str("x".into()))]),
),
]);
let props = map_figma_text_props(&node);
assert_eq!(props.font_family.as_deref(), Some("Inter"));
assert_eq!(props.font_size, Some(18.0));
assert_eq!(props.font_weight, Some(700));
assert_eq!(props.font_style, Some(FontStyleKind::Italic));
}
#[test]
fn line_height_pixels_becomes_multiplier() {
let node = obj(vec![
("fontSize", FigValue::Float(20.0)),
(
"lineHeight",
obj(vec![
("units", FigValue::Str("PIXELS".into())),
("value", FigValue::Float(30.0)),
]),
),
]);
// 30 / 20 = 1.5.
assert_eq!(map_line_height(&node), Some(1.5));
}
#[test]
fn text_case_upper_applies() {
let node = obj(vec![
("textCase", FigValue::Str("UPPER".into())),
(
"textData",
obj(vec![("characters", FigValue::Str("hello".into()))]),
),
]);
assert_eq!(
map_figma_text_props(&node).content,
TextContent::Plain("HELLO".into())
);
}
#[test]
fn title_case_capitalizes_word_starts() {
assert_eq!(title_case("hello world-foo"), "Hello World-Foo");
}
#[test]
fn align_and_growth_mapped() {
let node = obj(vec![
("textAlignHorizontal", FigValue::Str("CENTER".into())),
("textAlignVertical", FigValue::Str("CENTER".into())),
("textAutoResize", FigValue::Str("HEIGHT".into())),
("textDecoration", FigValue::Str("UNDERLINE".into())),
]);
let props = map_figma_text_props(&node);
assert_eq!(props.text_align, Some(TextAlign::Center));
assert_eq!(props.text_align_vertical, Some(TextAlignVertical::Middle));
assert_eq!(props.text_growth, Some(TextGrowth::FixedWidth));
assert_eq!(props.underline, Some(true));
}
#[test]
fn styled_segments_built_from_style_runs() {
// 4 chars: first 2 style 0, last 2 style 1 (bold override).
let node = obj(vec![(
"textData",
obj(vec![
("characters", FigValue::Str("abcd".into())),
(
"characterStyleIDs",
FigValue::Array(vec![
FigValue::Int(0),
FigValue::Int(0),
FigValue::Int(1),
FigValue::Int(1),
]),
),
(
"styleOverrideTable",
FigValue::Array(vec![
obj(vec![]),
obj(vec![(
"fontName",
obj(vec![("style", FigValue::Str("Bold".into()))]),
)]),
]),
),
]),
)]);
match map_figma_text_props(&node).content {
TextContent::Styled(segs) => {
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].text, "ab");
assert_eq!(segs[1].text, "cd");
assert_eq!(segs[1].font_weight, Some(700));
}
TextContent::Plain(p) => panic!("expected styled, got plain {p:?}"),
}
}

View file

@ -0,0 +1,398 @@
//! Figma vector geometry decoder — ports `figma-vector-decoder.ts`.
//! Decodes the two Figma path blob formats — the opcode command
//! stream (`fillGeometry` / `strokeGeometry`) and the vertex/segment
//! vector-network table — into SVG path `d` strings.
use crate::figma_types::BlobOrString;
use crate::kiwi::FigValue;
use std::collections::HashMap;
/// Approximate path bounding box (control points included).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PathBounds {
pub min_x: f64,
pub min_y: f64,
pub max_x: f64,
pub max_y: f64,
}
/// Format a coordinate: snap near-zero to `0`, else 4-decimal round
/// with trailing zeros stripped.
fn r(n: f64) -> String {
if n.abs() < 5e-5 {
return "0".to_string();
}
let rounded: f64 = format!("{n:.4}").parse().unwrap_or(n);
format!("{rounded}")
}
fn f32_le(blob: &[u8], off: usize) -> Option<f64> {
let s = blob.get(off..off + 4)?;
Some(f32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f64)
}
fn u32_le(blob: &[u8], off: usize) -> Option<u32> {
let s = blob.get(off..off + 4)?;
Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
fn join_parts(parts: &[String]) -> Option<String> {
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
/// Decode the opcode command stream — `0x00`=Z, `0x01`=M, `0x02`=L,
/// `0x03`=Q (quadratic), `0x04`=C (cubic); operands are f32-LE. A
/// truncated operand buffer or unknown opcode returns the prefix
/// decoded so far.
pub fn decode_figma_path_blob(blob: &[u8]) -> Option<String> {
if blob.len() < 9 {
return None;
}
let mut parts: Vec<String> = Vec::new();
let mut off = 0usize;
while off < blob.len() {
let cmd = blob[off];
off += 1;
match cmd {
0x00 => parts.push("Z".to_string()),
0x01 | 0x02 => {
let (Some(x), Some(y)) = (f32_le(blob, off), f32_le(blob, off + 4)) else {
return join_parts(&parts);
};
off += 8;
if x.is_finite() && y.is_finite() {
let letter = if cmd == 0x01 { "M" } else { "L" };
parts.push(format!("{letter}{} {}", r(x), r(y)));
}
}
0x03 => {
let coords: Option<Vec<f64>> = (0..4).map(|i| f32_le(blob, off + i * 4)).collect();
let Some(c) = coords else {
return join_parts(&parts);
};
off += 16;
if c.iter().all(|v| v.is_finite()) {
parts.push(format!("Q{} {} {} {}", r(c[0]), r(c[1]), r(c[2]), r(c[3])));
}
}
0x04 => {
let coords: Option<Vec<f64>> = (0..6).map(|i| f32_le(blob, off + i * 4)).collect();
let Some(c) = coords else {
return join_parts(&parts);
};
off += 24;
if c.iter().all(|v| v.is_finite()) {
parts.push(format!(
"C{} {} {} {} {} {}",
r(c[0]),
r(c[1]),
r(c[2]),
r(c[3]),
r(c[4]),
r(c[5])
));
}
}
_ => return join_parts(&parts),
}
}
join_parts(&parts)
}
/// Scan signed decimal numbers (`-?\d+\.?\d*`) out of a path-command
/// body. Lone `-` and exponents are not matched — `r()` never emits
/// either.
fn scan_numbers(body: &str) -> Vec<f64> {
let bytes = body.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'-' || c.is_ascii_digit() {
let start = i;
if c == b'-' {
i += 1;
}
let digit_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i > digit_start && i < bytes.len() && bytes[i] == b'.' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
}
if i > digit_start {
if let Ok(v) = body[start..i].parse::<f64>() {
out.push(v);
}
}
} else {
i += 1;
}
}
out
}
/// Approximate the bounding box of an SVG path `d` string using its
/// raw coordinate pairs (control points included; no extrema math).
pub fn compute_svg_path_bounds(d: &str) -> Option<PathBounds> {
let is_cmd = |c: char| matches!(c, 'M' | 'L' | 'C' | 'Q' | 'Z' | 'm' | 'l' | 'c' | 'q' | 'z');
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
let mut letter: Option<char> = None;
let mut body = String::new();
let flush = |letter: Option<char>,
body: &str,
mnx: &mut f64,
mny: &mut f64,
mxx: &mut f64,
mxy: &mut f64| {
let Some(l) = letter else { return };
if l.eq_ignore_ascii_case(&'Z') {
return;
}
let nums = scan_numbers(body);
let mut i = 0;
while i + 1 < nums.len() {
let (x, y) = (nums[i], nums[i + 1]);
if x.is_finite() && y.is_finite() {
*mnx = mnx.min(x);
*mny = mny.min(y);
*mxx = mxx.max(x);
*mxy = mxy.max(y);
}
i += 2;
}
};
for c in d.chars() {
if is_cmd(c) {
flush(
letter, &body, &mut min_x, &mut min_y, &mut max_x, &mut max_y,
);
letter = Some(c);
body.clear();
} else {
body.push(c);
}
}
flush(
letter, &body, &mut min_x, &mut min_y, &mut max_x, &mut max_y,
);
if min_x.is_finite() {
Some(PathBounds {
min_x,
min_y,
max_x,
max_y,
})
} else {
None
}
}
/// Whether any paint in the array is visible (`visible != false`).
fn any_visible(paints: Option<&[FigValue]>) -> bool {
paints
.map(|p| p.iter().any(|x| x.get_bool("visible") != Some(false)))
.unwrap_or(false)
}
/// Decode a Figma vector node into an SVG path string. Prefers
/// geometry blobs (stroke centerline for stroke-only shapes), falling
/// back to the vector-network table.
pub fn decode_figma_vector_path(node: &FigValue, blobs: &[BlobOrString]) -> Option<String> {
let has_fills = any_visible(node.get_array("fillPaints"));
let has_strokes = any_visible(node.get_array("strokePaints"));
let geometries = if !has_fills && has_strokes {
node.get_array("strokeGeometry")
.or_else(|| node.get_array("fillGeometry"))
} else {
node.get_array("fillGeometry")
.or_else(|| node.get_array("strokeGeometry"))
};
let Some(geometries) = geometries.filter(|g| !g.is_empty()) else {
return decode_vector_network_blob(node, blobs);
};
let mut path_parts: Vec<String> = Vec::new();
for geom in geometries {
let Some(idx) = geom.get_f64("commandsBlob") else {
continue;
};
if let Some(BlobOrString::Bytes(bytes)) = blobs.get(idx as usize) {
if let Some(decoded) = decode_figma_path_blob(bytes) {
path_parts.push(decoded);
}
}
}
if path_parts.is_empty() {
return decode_vector_network_blob(node, blobs);
}
// Geometry coords are already node-local — no scaling.
Some(path_parts.join(" "))
}
struct VnSegment {
start: usize,
end: usize,
ts: (f64, f64),
te: (f64, f64),
}
/// Decode the vertex/segment vector-network blob — the fallback when
/// no geometry blob is present. Coordinates are scaled by
/// `nodeSize / normalizedSize`; tangents are start/end-relative.
pub fn decode_vector_network_blob(node: &FigValue, blobs: &[BlobOrString]) -> Option<String> {
let vector_data = node.get("vectorData")?;
let blob_idx = vector_data.get_f64("vectorNetworkBlob")? as usize;
let BlobOrString::Bytes(blob) = blobs.get(blob_idx)? else {
return None;
};
if blob.len() < 8 {
return None;
}
let mut off = 0usize;
let vertex_count = u32_le(blob, off)? as usize;
off += 4;
if vertex_count > 100_000 || off + vertex_count * 8 > blob.len() {
return None;
}
let mut vertices: Vec<(f64, f64)> = Vec::with_capacity(vertex_count);
for _ in 0..vertex_count {
let x = f32_le(blob, off)?;
let y = f32_le(blob, off + 4)?;
off += 8;
vertices.push((x, y));
}
let segment_count = u32_le(blob, off)? as usize;
off += 4;
if segment_count > 100_000 {
return None;
}
let mut segments: Vec<VnSegment> = Vec::new();
for _ in 0..segment_count {
if off + 24 > blob.len() {
break;
}
let start = u32_le(blob, off)? as usize;
let end = u32_le(blob, off + 4)? as usize;
let ts = (f32_le(blob, off + 8)?, f32_le(blob, off + 12)?);
let te = (f32_le(blob, off + 16)?, f32_le(blob, off + 20)?);
off += 24;
if start < vertex_count && end < vertex_count {
segments.push(VnSegment { start, end, ts, te });
}
}
if segments.is_empty() || vertices.is_empty() {
return None;
}
let norm = vector_data.get("normalizedSize");
let norm_w = norm.and_then(|n| n.get_f64("x")).unwrap_or(1.0);
let norm_h = norm.and_then(|n| n.get_f64("y")).unwrap_or(1.0);
let size = node.get("size");
let node_w = size.and_then(|s| s.get_f64("x")).unwrap_or(norm_w);
let node_h = size.and_then(|s| s.get_f64("y")).unwrap_or(norm_h);
let sx = if norm_w > 0.001 { node_w / norm_w } else { 1.0 };
let sy = if norm_h > 0.001 { node_h / norm_h } else { 1.0 };
// Adjacency: segment indices keyed by their start vertex.
let mut adj: HashMap<usize, Vec<usize>> = HashMap::new();
for (i, seg) in segments.iter().enumerate() {
adj.entry(seg.start).or_default().push(i);
}
let mut parts: Vec<String> = Vec::new();
let mut used = vec![false; segments.len()];
for i in 0..segments.len() {
if used[i] {
continue;
}
let seg = &segments[i];
let sv = vertices[seg.start];
parts.push(format!("M{} {}", r(sv.0 * sx), r(sv.1 * sy)));
used[i] = true;
emit_segment(seg, &vertices, sx, sy, &mut parts);
let chain_start = seg.start;
let mut current = seg.end;
loop {
let mut found = false;
if let Some(nexts) = adj.get(&current) {
for &ni in nexts {
if used[ni] {
continue;
}
used[ni] = true;
emit_segment(&segments[ni], &vertices, sx, sy, &mut parts);
current = segments[ni].end;
found = true;
break;
}
}
if !found {
break;
}
}
if current == chain_start {
parts.push("Z".to_string());
}
}
let result = parts.join(" ");
if result.is_empty() {
None
} else {
Some(result)
}
}
fn emit_segment(
seg: &VnSegment,
vertices: &[(f64, f64)],
sx: f64,
sy: f64,
parts: &mut Vec<String>,
) {
let sv = vertices[seg.start];
let ev = vertices[seg.end];
let straight = seg.ts.0.abs() < 1e-4
&& seg.ts.1.abs() < 1e-4
&& seg.te.0.abs() < 1e-4
&& seg.te.1.abs() < 1e-4;
if straight {
parts.push(format!("L{} {}", r(ev.0 * sx), r(ev.1 * sy)));
} else {
let cp1x = (sv.0 + seg.ts.0) * sx;
let cp1y = (sv.1 + seg.ts.1) * sy;
let cp2x = (ev.0 + seg.te.0) * sx;
let cp2y = (ev.1 + seg.te.1) * sy;
parts.push(format!(
"C{} {} {} {} {} {}",
r(cp1x),
r(cp1y),
r(cp2x),
r(cp2y),
r(ev.0 * sx),
r(ev.1 * sy)
));
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,125 @@
//! Vector-decoder tests.
use super::*;
fn obj(pairs: Vec<(&str, FigValue)>) -> FigValue {
FigValue::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}
fn push_f32(buf: &mut Vec<u8>, v: f32) {
buf.extend_from_slice(&v.to_le_bytes());
}
fn push_u32(buf: &mut Vec<u8>, v: u32) {
buf.extend_from_slice(&v.to_le_bytes());
}
#[test]
fn decodes_command_blob() {
let mut blob = Vec::new();
blob.push(0x01); // M
push_f32(&mut blob, 1.0);
push_f32(&mut blob, 2.0);
blob.push(0x02); // L
push_f32(&mut blob, 3.0);
push_f32(&mut blob, 4.0);
blob.push(0x00); // Z
assert_eq!(
decode_figma_path_blob(&blob).as_deref(),
Some("M1 2 L3 4 Z")
);
}
#[test]
fn decodes_cubic_command() {
let mut blob = Vec::new();
blob.push(0x01);
push_f32(&mut blob, 0.0);
push_f32(&mut blob, 0.0);
blob.push(0x04); // C
for v in [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0] {
push_f32(&mut blob, v);
}
assert_eq!(
decode_figma_path_blob(&blob).as_deref(),
Some("M0 0 C1 2 3 4 5 6")
);
}
#[test]
fn short_blob_is_none() {
assert!(decode_figma_path_blob(&[0x01, 0, 0]).is_none());
}
#[test]
fn path_bounds_from_coordinates() {
let b = compute_svg_path_bounds("M1 2 L3 4 L-5 10 Z").expect("bounds");
assert_eq!(b.min_x, -5.0);
assert_eq!(b.min_y, 2.0);
assert_eq!(b.max_x, 3.0);
assert_eq!(b.max_y, 10.0);
}
#[test]
fn path_bounds_empty_string_none() {
assert!(compute_svg_path_bounds("").is_none());
}
#[test]
fn vector_path_from_fill_geometry() {
let mut blob = Vec::new();
blob.push(0x01);
push_f32(&mut blob, 0.0);
push_f32(&mut blob, 0.0);
blob.push(0x02);
push_f32(&mut blob, 8.0);
push_f32(&mut blob, 0.0);
let node = obj(vec![
(
"fillPaints",
FigValue::Array(vec![obj(vec![("type", FigValue::Str("SOLID".into()))])]),
),
(
"fillGeometry",
FigValue::Array(vec![obj(vec![("commandsBlob", FigValue::Uint(0))])]),
),
]);
let blobs = [BlobOrString::Bytes(blob)];
assert_eq!(
decode_figma_vector_path(&node, &blobs).as_deref(),
Some("M0 0 L8 0")
);
}
#[test]
fn vector_network_straight_segment() {
let mut blob = Vec::new();
push_u32(&mut blob, 2); // vertexCount
push_f32(&mut blob, 0.0);
push_f32(&mut blob, 0.0); // v0
push_f32(&mut blob, 10.0);
push_f32(&mut blob, 0.0); // v1
push_u32(&mut blob, 1); // segmentCount
push_u32(&mut blob, 0); // start
push_u32(&mut blob, 1); // end
for _ in 0..4 {
push_f32(&mut blob, 0.0); // zero tangents → straight
}
let node = obj(vec![(
"vectorData",
obj(vec![("vectorNetworkBlob", FigValue::Uint(0))]),
)]);
let blobs = [BlobOrString::Bytes(blob)];
assert_eq!(
decode_vector_network_blob(&node, &blobs).as_deref(),
Some("M0 0 L10 0")
);
}
#[test]
fn r_strips_trailing_zeros() {
assert_eq!(r(1.5), "1.5");
assert_eq!(r(2.0), "2");
assert_eq!(r(0.00001), "0");
assert_eq!(r(-0.0), "0");
}