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:
parent
1e32c6f6cf
commit
e18916c68c
|
|
@ -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::{
|
||||
|
|
|
|||
308
crates/op-figma/src/text_mapper.rs
Normal file
308
crates/op-figma/src/text_mapper.rs
Normal 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;
|
||||
141
crates/op-figma/src/text_mapper/tests.rs
Normal file
141
crates/op-figma/src/text_mapper/tests.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
398
crates/op-figma/src/vector_decoder.rs
Normal file
398
crates/op-figma/src/vector_decoder.rs
Normal 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(¤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<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;
|
||||
125
crates/op-figma/src/vector_decoder/tests.rs
Normal file
125
crates/op-figma/src/vector_decoder/tests.rs
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue