From e18916c68c6baed4e4295405cf92c293fb2491da Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 17 May 2026 16:07:16 +0800 Subject: [PATCH] feat(figma): text mapper + vector geometry decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/op-figma/src/lib.rs | 4 + crates/op-figma/src/text_mapper.rs | 308 +++++++++++++++ crates/op-figma/src/text_mapper/tests.rs | 141 +++++++ crates/op-figma/src/vector_decoder.rs | 398 ++++++++++++++++++++ crates/op-figma/src/vector_decoder/tests.rs | 125 ++++++ 5 files changed, 976 insertions(+) create mode 100644 crates/op-figma/src/text_mapper.rs create mode 100644 crates/op-figma/src/text_mapper/tests.rs create mode 100644 crates/op-figma/src/vector_decoder.rs create mode 100644 crates/op-figma/src/vector_decoder/tests.rs diff --git a/crates/op-figma/src/lib.rs b/crates/op-figma/src/lib.rs index 76c0ce8a8..5b0d26f6b 100644 --- a/crates/op-figma/src/lib.rs +++ b/crates/op-figma/src/lib.rs @@ -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::{ diff --git a/crates/op-figma/src/text_mapper.rs b/crates/op-figma/src/text_mapper.rs new file mode 100644 index 000000000..b0dfa6bee --- /dev/null +++ b/crates/op-figma/src/text_mapper.rs @@ -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, + pub font_size: Option, + pub font_weight: Option, + pub font_style: Option, + pub letter_spacing: Option, + pub line_height: Option, + pub text_align: Option, + pub text_align_vertical: Option, + pub text_growth: Option, + pub underline: Option, + pub strikethrough: Option, +} + +/// 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 = 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + match v { + Some("WIDTH_AND_HEIGHT") => Some(TextGrowth::Auto), + Some("HEIGHT") => Some(TextGrowth::FixedWidth), + Some("NONE") => Some(TextGrowth::FixedWidthHeight), + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/op-figma/src/text_mapper/tests.rs b/crates/op-figma/src/text_mapper/tests.rs new file mode 100644 index 000000000..794fd9241 --- /dev/null +++ b/crates/op-figma/src/text_mapper/tests.rs @@ -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:?}"), + } +} diff --git a/crates/op-figma/src/vector_decoder.rs b/crates/op-figma/src/vector_decoder.rs new file mode 100644 index 000000000..0f3349424 --- /dev/null +++ b/crates/op-figma/src/vector_decoder.rs @@ -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 { + 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 { + 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 { + 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 { + if blob.len() < 9 { + return None; + } + let mut parts: Vec = 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> = (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> = (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 { + 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::() { + 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 { + 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 = None; + let mut body = String::new(); + let flush = |letter: Option, + 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 { + 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 = 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 { + 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 = 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> = HashMap::new(); + for (i, seg) in segments.iter().enumerate() { + adj.entry(seg.start).or_default().push(i); + } + + let mut parts: Vec = 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(¤t) { + 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, +) { + 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; diff --git a/crates/op-figma/src/vector_decoder/tests.rs b/crates/op-figma/src/vector_decoder/tests.rs new file mode 100644 index 000000000..dc3728d79 --- /dev/null +++ b/crates/op-figma/src/vector_decoder/tests.rs @@ -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, v: f32) { + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn push_u32(buf: &mut Vec, 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"); +}