feat(figma): node converters + figma-to-document mapper

Stages D+E — ports converters/* + figma-node-mapper.ts.

- common.rs: ConversionContext, JS-rounding helpers, transform →
  position/rotation/flip extraction, corner-radius mapping,
  common_props, resolve_width/height, scale_tree_children,
  collect_image_blobs, SKIPPED_TYPES.
- node_build.rs: PenNode constructors (frame/group/rectangle/ellipse/
  line/path/text/ref) — fill the behaviour-trait slots with None.
- converters.rs: convert_node dispatch + per-type converters
  (frame/group/component/instance/rectangle/ellipse/line/text/
  vector); auto-layout child ordering, arc-data flip absorption,
  zero-size vector bounds derivation, stroke-only-outline handling.
- instance.rs: apply_instance_overrides (size-scale fast path +
  direct-GUID override/derived resolution + nested forwarding) +
  merge_symbol_props.
- node_mapper.rs: resolve_style_references (inline style refs) +
  figma_to_pen_document / figma_all_pages_to_pen_document /
  get_figma_pages / figma_node_changes_to_pen_nodes entry points.

Whole pipeline compiles clean; op-figma 77 tests green (+5).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kayshen-X 2026-05-17 16:17:26 +08:00
parent 203e424950
commit 7f41eeeeda
7 changed files with 1862 additions and 0 deletions

View file

@ -0,0 +1,374 @@
//! Shared converter helpers — ports `converters/common.ts`. Position
//! / rotation / flip extraction from Figma transforms, corner-radius
//! mapping, base-prop assembly, the conversion context, and the
//! SYMBOL-subtree rescaler.
use crate::color::figma_color_to_hex;
use crate::figma_types::{BlobOrString, FigColor, FigMatrix};
use crate::kiwi::FigValue;
use crate::mappers::{map_height_sizing, map_width_sizing};
use crate::tree::TreeNode;
use jian_ops_schema::node::base::{NumberOrExpression, PenNodeBase};
use jian_ops_schema::node::container::CornerRadius;
use jian_ops_schema::sizing::SizingBehavior;
use std::collections::HashMap;
/// Whether OpenPencil layout semantics or verbatim Figma geometry is
/// produced.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FigLayoutMode {
/// Keep Figma's fixed pixel sizes.
Preserve,
/// Resolve auto-layout into OpenPencil sizing keywords.
OpenPencil,
}
/// Node types Figma emits that have no OpenPencil equivalent.
pub const SKIPPED_TYPES: &[&str] = &[
"SLICE",
"CONNECTOR",
"SHAPE_WITH_TEXT",
"STICKY",
"STAMP",
"HIGHLIGHT",
"WASHI_TAPE",
"CODE_BLOCK",
"MEDIA",
"WIDGET",
"SECTION_OVERLAY",
"NONE",
];
/// State threaded through the whole conversion pass.
pub struct ConversionContext {
/// Figma SYMBOL guid → minted `fig_N` ref id.
pub component_map: HashMap<String, String>,
/// Figma SYMBOL guid → its subtree (for instance inlining).
pub symbol_tree: HashMap<String, TreeNode>,
/// Non-fatal conversion notes.
pub warnings: Vec<String>,
/// Sequential `fig_N` id allocator.
pub id_counter: u32,
/// Geometry / image blob pool.
pub blobs: Vec<BlobOrString>,
pub layout_mode: FigLayoutMode,
}
impl ConversionContext {
/// Mint the next sequential `fig_N` id.
pub fn generate_id(&mut self) -> String {
let id = format!("fig_{}", self.id_counter);
self.id_counter += 1;
id
}
}
// ── Rounding (JS `Math.round` = floor(x + 0.5)) ───────────────────
pub fn js_round(x: f64) -> f64 {
(x + 0.5).floor()
}
pub fn round2(v: f64) -> f64 {
js_round(v * 100.0) / 100.0
}
pub fn round3(v: f64) -> f64 {
js_round(v * 1000.0) / 1000.0
}
/// Normalise an angle into `[0, 360)`, 2-decimal rounded.
pub fn normalize_angle(deg: f64) -> f64 {
let mut a = deg % 360.0;
if a < 0.0 {
a += 360.0;
}
round2(a)
}
// ── Transform → geometry ──────────────────────────────────────────
/// Recover a node's pre-transform top-left in parent space. For
/// rotated / flipped nodes the (invariant) centre is used; for
/// axis-aligned nodes `(m02, m12)` is the top-left directly.
pub fn extract_position(figma: &FigValue) -> (f64, f64) {
let Some(m) = figma.get("transform").and_then(FigMatrix::from_value) else {
return (0.0, 0.0);
};
let has_rotation = m.m01.abs() > 0.001 || m.m10.abs() > 0.001;
let has_flip = m.m00 < -0.001 || m.m11 < -0.001;
if has_rotation || has_flip {
if let Some(size) = figma.get("size") {
let w = size.get_f64("x").unwrap_or(0.0);
let h = size.get_f64("y").unwrap_or(0.0);
let cx = m.m00 * w / 2.0 + m.m01 * h / 2.0 + m.m02;
let cy = m.m10 * w / 2.0 + m.m11 * h / 2.0 + m.m12;
return (round2(cx - w / 2.0), round2(cy - h / 2.0));
}
}
(round2(m.m02), round2(m.m12))
}
/// Rotation in whole degrees (`abs(m00)` ignores horizontal flip).
/// `None` when zero.
pub fn extract_rotation(figma: &FigValue) -> Option<f64> {
let m = figma.get("transform").and_then(FigMatrix::from_value)?;
let angle = m.m10.atan2(m.m00.abs()) * (180.0 / std::f64::consts::PI);
let rounded = js_round(angle);
if rounded != 0.0 {
Some(rounded)
} else {
None
}
}
/// Single-axis flip recovered from the transform determinant. At most
/// one of `(flipX, flipY)` is ever set.
pub fn extract_flip(figma: &FigValue) -> (Option<bool>, Option<bool>) {
let Some(m) = figma.get("transform").and_then(FigMatrix::from_value) else {
return (None, None);
};
let det = m.m00 * m.m11 - m.m01 * m.m10;
if det < -0.001 {
if m.m00 < 0.0 {
(Some(true), None)
} else {
(None, Some(true))
}
} else {
(None, None)
}
}
/// Corner radius — uniform number, per-corner `[tl, tr, br, bl]`, or
/// nothing.
pub fn map_corner_radius(figma: &FigValue) -> Option<CornerRadius> {
if figma.get_bool("rectangleCornerRadiiIndependent") == Some(true) {
let tl = figma.get_f64("rectangleTopLeftCornerRadius").unwrap_or(0.0);
let tr = figma
.get_f64("rectangleTopRightCornerRadius")
.unwrap_or(0.0);
let br = figma
.get_f64("rectangleBottomRightCornerRadius")
.unwrap_or(0.0);
let bl = figma
.get_f64("rectangleBottomLeftCornerRadius")
.unwrap_or(0.0);
if tl == tr && tr == br && br == bl {
return if tl > 0.0 {
Some(CornerRadius::Uniform(tl))
} else {
None
};
}
return Some(CornerRadius::PerCorner([tl, tr, br, bl]));
}
match figma.get_f64("cornerRadius") {
Some(cr) if cr > 0.0 => Some(CornerRadius::Uniform(cr)),
_ => None,
}
}
/// Assemble the shared base props spread into every PenNode.
pub fn common_props(figma: &FigValue, id: String) -> PenNodeBase {
let (x, y) = extract_position(figma);
let (flip_x, flip_y) = extract_flip(figma);
PenNodeBase {
id,
name: figma
.get_str("name")
.filter(|s| !s.is_empty())
.map(|s| s.to_string()),
role: None,
explain: None,
x: Some(x),
y: Some(y),
rotation: extract_rotation(figma),
opacity: figma
.get_f64("opacity")
.filter(|o| *o < 1.0)
.map(NumberOrExpression::Number),
enabled: None,
visible: None,
locked: if figma.get_bool("locked") == Some(true) {
Some(true)
} else {
None
},
flip_x,
flip_y,
theme: None,
}
}
/// Width as a `SizingBehavior` — fixed pixels in preserve mode, the
/// auto-layout resolution otherwise.
pub fn resolve_width(
figma: &FigValue,
parent_stack_mode: Option<&str>,
ctx: &ConversionContext,
) -> SizingBehavior {
match ctx.layout_mode {
FigLayoutMode::Preserve => SizingBehavior::Number(
figma
.get("size")
.and_then(|s| s.get_f64("x"))
.unwrap_or(100.0),
),
FigLayoutMode::OpenPencil => map_width_sizing(figma, parent_stack_mode),
}
}
/// Height as a `SizingBehavior` — see [`resolve_width`].
pub fn resolve_height(
figma: &FigValue,
parent_stack_mode: Option<&str>,
ctx: &ConversionContext,
) -> SizingBehavior {
match ctx.layout_mode {
FigLayoutMode::Preserve => SizingBehavior::Number(
figma
.get("size")
.and_then(|s| s.get_f64("y"))
.unwrap_or(100.0),
),
FigLayoutMode::OpenPencil => map_height_sizing(figma, parent_stack_mode),
}
}
/// First visible solid fill colour as `#RRGGBB` (no alpha) — used as
/// the icon-stroke fallback in `convert_vector`.
pub fn figma_fill_color(figma: &FigValue) -> Option<String> {
let paint = figma
.get_array("fillPaints")?
.iter()
.find(|f| f.get_bool("visible") != Some(false) && f.get_str("type") == Some("SOLID"))?;
let color = paint.get("color").and_then(FigColor::from_value)?;
let hex = figma_color_to_hex(&FigColor {
r: color.r,
g: color.g,
b: color.b,
a: None, // force 6-digit
});
Some(hex)
}
/// Recursively rescale a SYMBOL subtree to fit a differently-sized
/// instance. Only translation (`m02`/`m12`) + size + stroke weight
/// scale; the rotation/scale matrix cells are untouched.
pub fn scale_tree_children(children: &[TreeNode], sx: f64, sy: f64) -> Vec<TreeNode> {
if (sx - 1.0).abs() < 0.001 && (sy - 1.0).abs() < 0.001 {
return children.to_vec();
}
let stroke_scale = sx.min(sy);
children
.iter()
.map(|child| {
let mut figma = child.figma.clone();
if let Some(t) = figma.get("transform").cloned() {
let mut t = t;
let m02 = t.get_f64("m02").unwrap_or(0.0) * sx;
let m12 = t.get_f64("m12").unwrap_or(0.0) * sy;
t.set("m02", FigValue::Float(m02 as f32));
t.set("m12", FigValue::Float(m12 as f32));
figma.set("transform", t);
}
if let Some(size) = figma.get("size").cloned() {
let mut size = size;
let x = size.get_f64("x").unwrap_or(0.0) * sx;
let y = size.get_f64("y").unwrap_or(0.0) * sy;
size.set("x", FigValue::Float(x as f32));
size.set("y", FigValue::Float(y as f32));
figma.set("size", size);
}
if let Some(sw) = figma.get_f64("strokeWeight") {
if stroke_scale < 0.99 {
figma.set(
"strokeWeight",
FigValue::Float(round2(sw * stroke_scale) as f32),
);
}
}
TreeNode {
figma,
children: scale_tree_children(&child.children, sx, sy),
}
})
.collect()
}
/// Detect which decoded blobs are images (by magic bytes), keyed by
/// blob index.
pub fn collect_image_blobs(blobs: &[BlobOrString]) -> HashMap<u32, Vec<u8>> {
let mut map = HashMap::new();
for (i, blob) in blobs.iter().enumerate() {
if let BlobOrString::Bytes(b) = blob {
if b.len() > 8 {
let is_image = (b[0] == 0x89 && b[1] == 0x50)
|| (b[0] == 0xFF && b[1] == 0xD8)
|| (b[0] == 0x47 && b[1] == 0x49)
|| (b[0] == 0x52 && b[1] == 0x49);
if is_image {
map.insert(i as u32, b.clone());
}
}
}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
fn obj(pairs: Vec<(&str, FigValue)>) -> FigValue {
FigValue::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}
fn matrix(m00: f32, m01: f32, m02: f32, m10: f32, m11: f32, m12: f32) -> FigValue {
obj(vec![
("m00", FigValue::Float(m00)),
("m01", FigValue::Float(m01)),
("m02", FigValue::Float(m02)),
("m10", FigValue::Float(m10)),
("m11", FigValue::Float(m11)),
("m12", FigValue::Float(m12)),
])
}
#[test]
fn axis_aligned_position_is_translation() {
let n = obj(vec![("transform", matrix(1.0, 0.0, 30.0, 0.0, 1.0, 40.0))]);
assert_eq!(extract_position(&n), (30.0, 40.0));
}
#[test]
fn rotation_extracted_in_degrees() {
// 90° rotation: m00=0, m10=1.
let n = obj(vec![("transform", matrix(0.0, -1.0, 0.0, 1.0, 0.0, 0.0))]);
assert_eq!(extract_rotation(&n), Some(90.0));
}
#[test]
fn horizontal_flip_detected() {
// m00 negative, det negative → flipX.
let n = obj(vec![("transform", matrix(-1.0, 0.0, 0.0, 0.0, 1.0, 0.0))]);
assert_eq!(extract_flip(&n), (Some(true), None));
}
#[test]
fn corner_radius_uniform_collapse() {
let n = obj(vec![("cornerRadius", FigValue::Float(8.0))]);
assert!(matches!(
map_corner_radius(&n),
Some(CornerRadius::Uniform(8.0))
));
}
#[test]
fn js_round_is_floor_half_up() {
assert_eq!(js_round(2.5), 3.0);
assert_eq!(js_round(-0.5), 0.0);
assert_eq!(js_round(-1.5), -1.0);
}
}

View file

@ -0,0 +1,574 @@
//! Figma node converters — ports `converters/index.ts` and the
//! per-type converter modules. Walks the [`TreeNode`] tree and emits
//! canonical `PenNode`s.
use crate::common::{
common_props, extract_position, map_corner_radius, normalize_angle, resolve_height,
resolve_width, round2, round3, ConversionContext, FigLayoutMode, SKIPPED_TYPES,
};
use crate::figma_types::FigVec2;
use crate::instance::{apply_instance_overrides, merge_symbol_props};
use crate::kiwi::FigValue;
use crate::mappers::{
map_figma_effects, map_figma_fills, map_figma_layout, map_figma_stroke, LayoutProps,
};
use crate::node_build::{
ellipse_node, frame_node, group_node, line_node, path_node, rectangle_node, ref_node, text_node,
};
use crate::text_mapper::map_figma_text_props;
use crate::tree::{guid_to_string, TreeNode};
use crate::vector_decoder::{compute_svg_path_bounds, decode_figma_vector_path};
use jian_ops_schema::node::base::NumberOrExpression;
use jian_ops_schema::node::container::ContainerProps;
use jian_ops_schema::node::PenNode;
use jian_ops_schema::sizing::SizingBehavior;
/// Spread mapped [`LayoutProps`] onto a `ContainerProps`.
fn apply_layout(l: LayoutProps, c: &mut ContainerProps) {
if l.layout.is_some() {
c.layout = l.layout;
}
if let Some(g) = l.gap {
c.gap = Some(NumberOrExpression::Number(g));
}
if l.padding.is_some() {
c.padding = l.padding;
}
if l.justify_content.is_some() {
c.justify_content = l.justify_content;
}
if l.align_items.is_some() {
c.align_items = l.align_items;
}
if l.clip_content.is_some() {
c.clip_content = l.clip_content;
}
}
fn is_string_sizing(s: &SizingBehavior) -> bool {
matches!(
s,
SizingBehavior::Keyword(_) | SizingBehavior::Expression(_)
)
}
/// Convert a parent's children, skipping invisible / transparent
/// nodes. `parentStackMode` passed down is `None` in preserve mode.
pub fn convert_children(parent: &TreeNode, ctx: &mut ConversionContext) -> Vec<PenNode> {
let parent_stack_mode: Option<String> = match ctx.layout_mode {
FigLayoutMode::Preserve => None,
FigLayoutMode::OpenPencil => parent.figma.get_str("stackMode").map(|s| s.to_string()),
};
let mut result = Vec::new();
for child in &parent.children {
if child.figma.get_bool("visible") == Some(false) {
continue;
}
if child.figma.get_f64("opacity").map(|o| o <= 0.0) == Some(true) {
continue;
}
if let Some(node) = convert_node(child, parent_stack_mode.as_deref(), ctx) {
result.push(node);
}
}
result
}
/// Dispatch one tree node to its type-specific converter.
pub fn convert_node(
tree: &TreeNode,
parent_stack_mode: Option<&str>,
ctx: &mut ConversionContext,
) -> Option<PenNode> {
let ty = tree.figma.get_str("type")?;
if SKIPPED_TYPES.contains(&ty) {
return None;
}
Some(match ty {
"FRAME" | "SECTION" => convert_frame(tree, parent_stack_mode, ctx),
"GROUP" => convert_group(tree, parent_stack_mode, ctx),
"SYMBOL" => convert_component(tree, parent_stack_mode, ctx),
"INSTANCE" => convert_instance(tree, parent_stack_mode, ctx),
"RECTANGLE" | "ROUNDED_RECTANGLE" => convert_rectangle(tree, parent_stack_mode, ctx),
"ELLIPSE" => convert_ellipse(tree, parent_stack_mode, ctx),
"LINE" => convert_line(tree, ctx),
"VECTOR" | "STAR" | "REGULAR_POLYGON" | "BOOLEAN_OPERATION" => {
convert_vector(tree, parent_stack_mode, ctx)
}
"TEXT" => convert_text(tree, parent_stack_mode, ctx),
_ => {
if !tree.children.is_empty() {
convert_frame(tree, parent_stack_mode, ctx)
} else {
ctx.warnings.push(format!(
"Skipped unsupported node type: {ty} ({})",
tree.figma.get_str("name").unwrap_or("")
));
return None;
}
}
})
}
/// Build the `{ width, height, layout?, cornerRadius, fill, stroke,
/// effects }` container shared by frame / component.
fn build_container(
figma: &FigValue,
parent_stack_mode: Option<&str>,
has_auto_layout: bool,
ctx: &ConversionContext,
) -> ContainerProps {
let mut container = ContainerProps {
width: Some(resolve_width(figma, parent_stack_mode, ctx)),
height: Some(resolve_height(figma, parent_stack_mode, ctx)),
..ContainerProps::default()
};
let layout: Option<LayoutProps> = match ctx.layout_mode {
FigLayoutMode::Preserve => {
if has_auto_layout {
Some(map_figma_layout(figma))
} else if figma.get_bool("frameMaskDisabled") != Some(true) {
Some(LayoutProps {
clip_content: Some(true),
..LayoutProps::default()
})
} else {
None
}
}
FigLayoutMode::OpenPencil => Some(map_figma_layout(figma)),
};
if let Some(l) = layout {
apply_layout(l, &mut container);
}
container.corner_radius = map_corner_radius(figma);
container.fill = map_figma_fills(figma.get_array("fillPaints"))
.or_else(|| map_figma_fills(figma.get_array("backgroundPaints")));
container.stroke = map_figma_stroke(figma);
container.effects = map_figma_effects(figma.get_array("effects"));
container
}
/// Auto-layout flow order is ascending; the tree builder sorted
/// descending, so preserve-mode reverses it back.
fn order_children(
children: Vec<PenNode>,
has_auto_layout: bool,
ctx: &ConversionContext,
) -> Vec<PenNode> {
if has_auto_layout && ctx.layout_mode == FigLayoutMode::Preserve && children.len() > 1 {
children.into_iter().rev().collect()
} else {
children
}
}
fn convert_frame(
tree: &TreeNode,
parent_stack_mode: Option<&str>,
ctx: &mut ConversionContext,
) -> PenNode {
let id = ctx.generate_id();
let children = convert_children(tree, ctx);
let figma = &tree.figma;
let has_auto_layout = figma
.get_str("stackMode")
.map(|s| s != "NONE")
.unwrap_or(false);
let container = build_container(figma, parent_stack_mode, has_auto_layout, ctx);
let ordered = order_children(children, has_auto_layout, ctx);
let base = common_props(figma, id);
frame_node(
base,
container,
if ordered.is_empty() {
None
} else {
Some(ordered)
},
None,
)
}
fn convert_component(
tree: &TreeNode,
parent_stack_mode: Option<&str>,
ctx: &mut ConversionContext,
) -> PenNode {
let figma_id = tree
.figma
.get("guid")
.and_then(guid_to_string)
.unwrap_or_default();
let id = ctx
.component_map
.get(&figma_id)
.cloned()
.unwrap_or_else(|| ctx.generate_id());
let children = convert_children(tree, ctx);
let figma = &tree.figma;
let has_auto_layout = figma
.get_str("stackMode")
.map(|s| s != "NONE")
.unwrap_or(false);
let container = build_container(figma, parent_stack_mode, has_auto_layout, ctx);
let ordered = order_children(children, has_auto_layout, ctx);
let base = common_props(figma, id);
frame_node(
base,
container,
if ordered.is_empty() {
None
} else {
Some(ordered)
},
Some(true),
)
}
fn convert_group(
tree: &TreeNode,
parent_stack_mode: Option<&str>,
ctx: &mut ConversionContext,
) -> PenNode {
let id = ctx.generate_id();
let children = convert_children(tree, ctx);
let figma = &tree.figma;
let container = ContainerProps {
width: Some(resolve_width(figma, parent_stack_mode, ctx)),
height: Some(resolve_height(figma, parent_stack_mode, ctx)),
..ContainerProps::default()
};
let base = common_props(figma, id);
group_node(
base,
container,
if children.is_empty() {
None
} else {
Some(children)
},
)
}
/// Whether a symbol instance carries visual (non-layout) overrides.
fn has_visual_overrides(figma: &FigValue) -> bool {
figma
.get("symbolData")
.and_then(|s| s.get_array("symbolOverrides"))
.map(|ovs| {
ovs.iter().any(|ov| {
ov.get_array("fillPaints").map(|p| !p.is_empty()) == Some(true)
|| ov.get_array("strokePaints").map(|p| !p.is_empty()) == Some(true)
|| ov.get("arcData").is_some()
|| ov.get("textData").is_some()
|| ov.get("fontSize").is_some()
})
})
.unwrap_or(false)
}
fn convert_instance(
tree: &TreeNode,
parent_stack_mode: Option<&str>,
ctx: &mut ConversionContext,
) -> PenNode {
let figma = &tree.figma;
let component_guid = figma
.get("overriddenSymbolID")
.or_else(|| figma.get("symbolData").and_then(|s| s.get("symbolID")))
.and_then(guid_to_string);
let inline =
component_guid.is_some() && (tree.children.is_empty() || has_visual_overrides(figma));
if inline {
let key = component_guid.clone().unwrap();
if let Some(symbol_node) = ctx.symbol_tree.get(&key).cloned() {
if !symbol_node.children.is_empty() {
let overrides = figma
.get("symbolData")
.and_then(|s| s.get_array("symbolOverrides"))
.map(|a| a.to_vec());
let derived = figma.get_array("derivedSymbolData").map(|a| a.to_vec());
let instance_size = figma.get("size").and_then(FigVec2::from_value);
let children = apply_instance_overrides(
&symbol_node,
overrides.as_deref(),
derived.as_deref(),
instance_size,
);
let merged = merge_symbol_props(&tree.figma, &symbol_node.figma);
let synthetic = TreeNode {
figma: merged,
children,
};
return convert_frame(&synthetic, parent_stack_mode, ctx);
}
}
}
// No inlining — emit a ref when the component is registered.
if let Some(pen_id) = component_guid
.as_ref()
.and_then(|g| ctx.component_map.get(g))
.cloned()
{
let id = ctx.generate_id();
return ref_node(common_props(&tree.figma, id), pen_id);
}
convert_frame(tree, parent_stack_mode, ctx)
}
fn convert_rectangle(
tree: &TreeNode,
parent_stack_mode: Option<&str>,
ctx: &mut ConversionContext,
) -> PenNode {
let id = ctx.generate_id();
let figma = &tree.figma;
let container = ContainerProps {
width: Some(resolve_width(figma, parent_stack_mode, ctx)),
height: Some(resolve_height(figma, parent_stack_mode, ctx)),
corner_radius: map_corner_radius(figma),
fill: map_figma_fills(figma.get_array("fillPaints")),
stroke: map_figma_stroke(figma),
effects: map_figma_effects(figma.get_array("effects")),
..ContainerProps::default()
};
rectangle_node(common_props(figma, id), container)
}
/// Figma arc (radians, start+end) → Pen arc (degrees, start+sweep).
fn map_figma_arc_data(arc: &FigValue) -> (Option<f64>, Option<f64>, Option<f64>) {
let start_rad = arc.get_f64("startingAngle").unwrap_or(0.0);
let end_rad = arc
.get_f64("endingAngle")
.unwrap_or(std::f64::consts::PI * 2.0);
let inner = arc.get_f64("innerRadius").unwrap_or(0.0);
let (actual_start, sweep_rad) = if end_rad >= start_rad {
(start_rad, end_rad - start_rad)
} else {
(end_rad, start_rad - end_rad)
};
let start_deg = actual_start * 180.0 / std::f64::consts::PI;
let sweep_deg = sweep_rad * 180.0 / std::f64::consts::PI;
(
if start_deg.abs() > 0.1 {
Some(round2(start_deg))
} else {
None
},
if (sweep_deg - 360.0).abs() > 0.1 {
Some(round2(sweep_deg))
} else {
None
},
if inner > 0.001 {
Some(round3(inner))
} else {
None
},
)
}
fn convert_ellipse(
tree: &TreeNode,
parent_stack_mode: Option<&str>,
ctx: &mut ConversionContext,
) -> PenNode {
let id = ctx.generate_id();
let figma = &tree.figma;
let width = resolve_width(figma, parent_stack_mode, ctx);
let height = resolve_height(figma, parent_stack_mode, ctx);
let mut base = common_props(figma, id);
let (mut start_angle, mut sweep_angle, inner_radius) = figma
.get("arcData")
.map(map_figma_arc_data)
.unwrap_or((None, None, None));
// Absorb a flip into the arc angles (extract_flip sets at most one).
if start_angle.is_some() || sweep_angle.is_some() || inner_radius.is_some() {
let start = start_angle.unwrap_or(0.0);
let sweep = sweep_angle.unwrap_or(360.0);
if base.flip_x == Some(true) {
start_angle = Some(normalize_angle(180.0 - start - sweep));
sweep_angle = Some(sweep);
base.flip_x = None;
}
if base.flip_y == Some(true) {
start_angle = Some(normalize_angle(360.0 - start - sweep));
sweep_angle = Some(sweep);
base.flip_y = None;
}
}
ellipse_node(
base,
width,
height,
map_figma_fills(figma.get_array("fillPaints")),
map_figma_stroke(figma),
map_figma_effects(figma.get_array("effects")),
start_angle,
sweep_angle,
inner_radius,
)
}
fn convert_line(tree: &TreeNode, ctx: &mut ConversionContext) -> PenNode {
let id = ctx.generate_id();
let figma = &tree.figma;
let (x, y) = extract_position(figma);
let w = figma
.get("size")
.and_then(|s| s.get_f64("x"))
.unwrap_or(100.0);
// Line builds its base manually — no flip / locked.
let mut base = common_props(figma, id);
base.flip_x = None;
base.flip_y = None;
base.locked = None;
line_node(
base,
x + w,
y,
map_figma_stroke(figma),
map_figma_effects(figma.get_array("effects")),
)
}
fn convert_text(
tree: &TreeNode,
parent_stack_mode: Option<&str>,
ctx: &mut ConversionContext,
) -> PenNode {
let id = ctx.generate_id();
let figma = &tree.figma;
let mut props = map_figma_text_props(figma);
let width = resolve_width(figma, parent_stack_mode, ctx);
let height = resolve_height(figma, parent_stack_mode, ctx);
// Reconcile textGrowth with the resolved width.
use jian_ops_schema::node::text::TextGrowth;
if props.text_growth.is_none() {
if is_string_sizing(&width) || figma.get_str("textAutoResize").is_none() {
props.text_growth = Some(TextGrowth::FixedWidth);
}
} else if props.text_growth == Some(TextGrowth::Auto) && is_string_sizing(&width) {
props.text_growth = Some(TextGrowth::FixedWidth);
}
text_node(
common_props(figma, id),
width,
height,
props,
map_figma_fills(figma.get_array("fillPaints")),
map_figma_effects(figma.get_array("effects")),
)
}
fn any_visible(paints: Option<&[FigValue]>) -> bool {
paints
.map(|p| p.iter().any(|x| x.get_bool("visible") != Some(false)))
.unwrap_or(false)
}
fn convert_vector(
tree: &TreeNode,
parent_stack_mode: Option<&str>,
ctx: &mut ConversionContext,
) -> PenNode {
let id = ctx.generate_id();
let figma = &tree.figma;
let path_d = decode_figma_vector_path(figma, &ctx.blobs).unwrap_or_default();
if !path_d.is_empty() {
let mut base = common_props(figma, id);
let mut width = resolve_width(figma, parent_stack_mode, ctx);
let mut height = resolve_height(figma, parent_stack_mode, ctx);
// Zero-size vectors: derive extent from the path bounds.
let size_x = figma
.get("size")
.and_then(|s| s.get_f64("x"))
.unwrap_or(0.0);
let size_y = figma
.get("size")
.and_then(|s| s.get_f64("y"))
.unwrap_or(0.0);
if (size_x < 0.01 || size_y < 0.01)
&& matches!(width, SizingBehavior::Number(_))
&& matches!(height, SizingBehavior::Number(_))
{
if let Some(bounds) = compute_svg_path_bounds(&path_d) {
let path_w = bounds.max_x - bounds.min_x;
let path_h = bounds.max_y - bounds.min_y;
if size_x < 0.01 && path_w > 0.01 {
width = SizingBehavior::Number(round2(path_w));
base.x = Some(round2(base.x.unwrap_or(0.0) + bounds.min_x));
}
if size_y < 0.01 && path_h > 0.01 {
height = SizingBehavior::Number(round2(path_h));
base.y = Some(round2(base.y.unwrap_or(0.0) + bounds.min_y));
}
}
}
// Stroke-only outline: Figma strokeGeometry is an expanded
// outline, so treat the stroke paints as the fill.
let has_visible_fills = any_visible(figma.get_array("fillPaints"));
let has_visible_strokes = any_visible(figma.get_array("strokePaints"));
let is_stroke_only = !has_visible_fills
&& has_visible_strokes
&& figma
.get_array("fillGeometry")
.map(|g| g.is_empty())
.unwrap_or(true)
&& figma
.get_array("strokeGeometry")
.map(|g| !g.is_empty())
.unwrap_or(false);
if is_stroke_only {
return path_node(
base,
Some(path_d),
None,
width,
height,
map_figma_fills(figma.get_array("strokePaints")),
None,
map_figma_effects(figma.get_array("effects")),
);
}
return path_node(
base,
Some(path_d),
None,
width,
height,
map_figma_fills(figma.get_array("fillPaints")),
map_figma_stroke(figma),
map_figma_effects(figma.get_array("effects")),
);
}
// Fallback — undecodable vector becomes a rectangle.
ctx.warnings.push(format!(
"Vector node \"{}\" converted as rectangle (path data not decodable)",
figma.get_str("name").unwrap_or("")
));
let container = ContainerProps {
width: Some(resolve_width(figma, parent_stack_mode, ctx)),
height: Some(resolve_height(figma, parent_stack_mode, ctx)),
fill: map_figma_fills(figma.get_array("fillPaints")),
stroke: map_figma_stroke(figma),
effects: map_figma_effects(figma.get_array("effects")),
..ContainerProps::default()
};
rectangle_node(common_props(figma, id), container)
}

View file

@ -0,0 +1,313 @@
//! Component-instance override application — ports the
//! `applyInstanceOverrides` / `mergeSymbolProps` parts of
//! `frame-converter.ts`.
//!
//! Scope note: the direct-GUID resolution strategy (the common case)
//! plus the size-scaling fast path are ported. Figma's virtual-GUID
//! positional fallbacks (strategies 1–3 in the TS) are not — they
//! only apply to files whose override `guidPath`s do not name real
//! subtree nodes, which is rare.
use crate::common::round2;
use crate::figma_types::FigVec2;
use crate::kiwi::FigValue;
use crate::tree::{guid_to_string, TreeNode};
use std::collections::HashMap;
/// Layout keys an instance inherits from its master SYMBOL.
const LAYOUT_KEYS: &[&str] = &[
"stackMode",
"stackSpacing",
"stackPadding",
"stackHorizontalPadding",
"stackVerticalPadding",
"stackPaddingRight",
"stackPaddingBottom",
"stackPrimaryAlignItems",
"stackCounterAlignItems",
"stackPrimarySizing",
"stackCounterSizing",
"stackChildPrimaryGrow",
"stackChildAlignSelf",
"frameMaskDisabled",
];
/// Visual keys an instance inherits from its master SYMBOL.
const VISUAL_KEYS: &[&str] = &[
"fillPaints",
"strokePaints",
"strokeWeight",
"strokeAlign",
"cornerRadius",
"rectangleCornerRadiiIndependent",
"rectangleTopLeftCornerRadius",
"rectangleTopRightCornerRadius",
"rectangleBottomLeftCornerRadius",
"rectangleBottomRightCornerRadius",
];
/// Keys that must never be copied off an override entry onto a node.
const OVERRIDE_SKIP_KEYS: &[&str] = &[
"guidPath",
"guid",
"parentIndex",
"type",
"phase",
"symbolData",
"derivedSymbolData",
"componentKey",
"variableConsumptionMap",
"parameterConsumptionMap",
"prototypeInteractions",
"styleIdForFill",
"styleIdForStrokeFill",
"styleIdForText",
"overrideLevel",
"componentPropAssignments",
"proportionsConstrained",
"fontVersion",
];
/// Copy SYMBOL props onto an instance where the instance lacks them
/// (the instance's own values win).
pub fn merge_symbol_props(instance: &FigValue, symbol: &FigValue) -> FigValue {
let mut merged = instance.clone();
for key in LAYOUT_KEYS.iter().chain(VISUAL_KEYS.iter()) {
if merged.get(key).is_none() {
if let Some(v) = symbol.get(key) {
merged.set(key, v.clone());
}
}
}
merged
}
/// `guidPath.guids` joined into a `/`-separated path key.
fn guid_path_key(entry: &FigValue) -> Option<String> {
let guids = entry.get("guidPath")?.get_array("guids")?;
if guids.is_empty() {
return None;
}
let parts: Vec<String> = guids.iter().filter_map(guid_to_string).collect();
if parts.len() == guids.len() {
Some(parts.join("/"))
} else {
None
}
}
/// Apply a SYMBOL instance's overrides + derived data onto a clone of
/// the SYMBOL subtree, returning the modified children.
pub fn apply_instance_overrides(
symbol_node: &TreeNode,
overrides: Option<&[FigValue]>,
derived: Option<&[FigValue]>,
instance_size: Option<FigVec2>,
) -> Vec<TreeNode> {
let overrides = overrides.unwrap_or(&[]);
let derived = derived.unwrap_or(&[]);
// Fast path — nothing to apply: just rescale to the instance size.
if derived.is_empty() && overrides.is_empty() {
if let (Some(size), Some(sym_size)) = (
instance_size,
symbol_node.figma.get("size").map(FigVec2::from_value),
) {
if let Some(sym_size) = sym_size {
if sym_size.x != 0.0 && sym_size.y != 0.0 {
let sx = size.x / sym_size.x;
let sy = size.y / sym_size.y;
return crate::common::scale_tree_children(&symbol_node.children, sx, sy);
}
}
}
return symbol_node.children.clone();
}
// Direct-GUID resolution: map every override / derived entry whose
// single-segment guidPath names a real node in the subtree.
let mut node_override: HashMap<String, FigValue> = HashMap::new();
let mut node_derived: HashMap<String, FigValue> = HashMap::new();
let mut nested_override: HashMap<String, Vec<FigValue>> = HashMap::new();
let mut nested_derived: HashMap<String, Vec<FigValue>> = HashMap::new();
for entry in overrides {
match guid_path_key(entry) {
Some(key) if !key.contains('/') => {
node_override.insert(key, entry.clone());
}
Some(key) => {
let head = key.split('/').next().unwrap_or("").to_string();
if let Some(rest) = strip_first_guid(entry) {
nested_override.entry(head).or_default().push(rest);
}
}
None => {}
}
}
for entry in derived {
match guid_path_key(entry) {
Some(key) if !key.contains('/') => {
node_derived.insert(key, entry.clone());
}
Some(key) => {
let head = key.split('/').next().unwrap_or("").to_string();
if let Some(rest) = strip_first_guid(entry) {
nested_derived.entry(head).or_default().push(rest);
}
}
None => {}
}
}
symbol_node
.children
.iter()
.map(|c| {
apply_to_node(
c,
&node_override,
&node_derived,
&nested_override,
&nested_derived,
)
})
.collect()
}
/// Build a copy of `entry` with the first `guidPath` segment dropped.
fn strip_first_guid(entry: &FigValue) -> Option<FigValue> {
let guids = entry.get("guidPath")?.get_array("guids")?;
if guids.len() < 2 {
return None;
}
let mut copy = entry.clone();
let rest: Vec<FigValue> = guids[1..].to_vec();
let mut path = FigValue::Object(Vec::new());
path.set("guids", FigValue::Array(rest));
copy.set("guidPath", path);
Some(copy)
}
/// Recursively clone the subtree, applying derived data + overrides to
/// each node keyed by its guid.
fn apply_to_node(
node: &TreeNode,
node_override: &HashMap<String, FigValue>,
node_derived: &HashMap<String, FigValue>,
nested_override: &HashMap<String, Vec<FigValue>>,
nested_derived: &HashMap<String, Vec<FigValue>>,
) -> TreeNode {
let key = node
.figma
.get("guid")
.and_then(guid_to_string)
.unwrap_or_default();
let d = node_derived.get(&key);
let ov = node_override.get(&key);
let nested_ov = nested_override.get(&key);
let nested_d = nested_derived.get(&key);
if d.is_none() && ov.is_none() && nested_ov.is_none() && nested_d.is_none() {
return TreeNode {
figma: node.figma.clone(),
children: node
.children
.iter()
.map(|c| {
apply_to_node(
c,
node_override,
node_derived,
nested_override,
nested_derived,
)
})
.collect(),
};
}
let mut figma = node.figma.clone();
// Derived data — scale stroke weight before overwriting size.
if let Some(d) = d {
if let (Some(dsize), Some(nsize)) = (
d.get("size").and_then(FigVec2::from_value),
node.figma.get("size").and_then(FigVec2::from_value),
) {
if let Some(sw) = figma.get_f64("strokeWeight") {
if nsize.x != 0.0 && nsize.y != 0.0 {
let scale = (dsize.x / nsize.x).min(dsize.y / nsize.y);
if scale < 0.99 {
figma.set("strokeWeight", FigValue::Float(round2(sw * scale) as f32));
}
}
}
}
if let Some(size) = d.get("size") {
figma.set("size", size.clone());
}
if let Some(t) = d.get("transform") {
figma.set("transform", t.clone());
}
if let Some(fs) = d.get("fontSize") {
figma.set("fontSize", fs.clone());
}
if let Some(dtd) = d.get("derivedTextData") {
if dtd.get("characters").is_some() {
figma.set("textData", dtd.clone());
}
}
}
// Override props — copy every non-blacklisted, present key.
if let Some(ov) = ov {
if let FigValue::Object(pairs) = ov {
for (k, v) in pairs {
if !OVERRIDE_SKIP_KEYS.contains(&k.as_str()) && !matches!(v, FigValue::Null) {
figma.set(k, v.clone());
}
}
}
}
// Forward nested entries into nested INSTANCE nodes.
let is_instance =
figma.get_str("type") == Some("INSTANCE") || figma.get("symbolData").is_some();
if is_instance {
if let Some(nested) = nested_ov {
let mut existing: Vec<FigValue> = figma
.get("symbolData")
.and_then(|s| s.get_array("symbolOverrides"))
.map(|a| a.to_vec())
.unwrap_or_default();
existing.extend(nested.clone());
let mut symbol_data = figma
.get("symbolData")
.cloned()
.unwrap_or(FigValue::Object(Vec::new()));
symbol_data.set("symbolOverrides", FigValue::Array(existing));
figma.set("symbolData", symbol_data);
}
if let Some(nested) = nested_d {
figma.set("derivedSymbolData", FigValue::Array(nested.clone()));
}
}
TreeNode {
figma,
children: node
.children
.iter()
.map(|c| {
apply_to_node(
c,
node_override,
node_derived,
nested_override,
nested_derived,
)
})
.collect(),
}
}

View file

@ -354,6 +354,18 @@ impl FigValue {
pub fn get_array(&self, key: &str) -> Option<&[FigValue]> {
self.get(key).and_then(|v| v.as_array())
}
/// Insert or overwrite a field on an object value (no-op for
/// non-objects).
pub fn set(&mut self, key: &str, value: FigValue) {
if let FigValue::Object(pairs) = self {
if let Some(slot) = pairs.iter_mut().find(|(k, _)| k == key) {
slot.1 = value;
} else {
pairs.push((key.to_string(), value));
}
}
}
}
// ── Dynamic decoder ───────────────────────────────────────────────

View file

@ -10,14 +10,24 @@
#[allow(dead_code)]
mod color;
#[allow(dead_code)]
mod common;
#[allow(dead_code)]
mod container;
#[allow(dead_code)]
mod converters;
#[allow(dead_code)]
mod figma_types;
#[allow(dead_code)]
mod instance;
#[allow(dead_code)]
mod kiwi;
#[allow(dead_code)]
mod mappers;
#[allow(dead_code)]
mod node_build;
#[allow(dead_code)]
mod node_mapper;
#[allow(dead_code)]
mod text_mapper;
#[allow(dead_code)]
mod tree;

View file

@ -0,0 +1,216 @@
//! PenNode constructor helpers — assemble canonical
//! `jian_ops_schema` nodes from the meaningful converter fields,
//! filling the behaviour-trait slots (state / events / lifecycle …)
//! with `None`.
use crate::text_mapper::TextProps;
use jian_ops_schema::node::text::{FontStyleKind as TextFontStyle, FontWeight};
use jian_ops_schema::node::{
ContainerProps, EllipseNode, FrameNode, GroupNode, LineNode, PathNode, PenNode, PenNodeBase,
RectangleNode, RefNode, TextNode,
};
use jian_ops_schema::sizing::SizingBehavior;
use jian_ops_schema::style::{FontStyleKind as StyleFontStyle, PenEffect, PenFill, PenStroke};
pub fn frame_node(
base: PenNodeBase,
container: ContainerProps,
children: Option<Vec<PenNode>>,
reusable: Option<bool>,
) -> PenNode {
PenNode::Frame(FrameNode {
base,
container,
children,
reusable,
slot: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
pub fn group_node(
base: PenNodeBase,
container: ContainerProps,
children: Option<Vec<PenNode>>,
) -> PenNode {
PenNode::Group(GroupNode {
base,
container,
children,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
pub fn rectangle_node(base: PenNodeBase, container: ContainerProps) -> PenNode {
PenNode::Rectangle(RectangleNode {
base,
container,
children: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
/// Ellipse — includes the optional arc fields.
#[allow(clippy::too_many_arguments)]
pub fn ellipse_node(
base: PenNodeBase,
width: SizingBehavior,
height: SizingBehavior,
fill: Option<Vec<PenFill>>,
stroke: Option<PenStroke>,
effects: Option<Vec<PenEffect>>,
start_angle: Option<f64>,
sweep_angle: Option<f64>,
inner_radius: Option<f64>,
) -> PenNode {
PenNode::Ellipse(EllipseNode {
base,
width: Some(width),
height: Some(height),
corner_radius: None,
inner_radius,
start_angle,
sweep_angle,
fill,
stroke,
effects,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
pub fn line_node(
base: PenNodeBase,
x2: f64,
y2: f64,
stroke: Option<PenStroke>,
effects: Option<Vec<PenEffect>>,
) -> PenNode {
PenNode::Line(LineNode {
base,
x2: Some(x2),
y2: Some(y2),
stroke,
effects,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn path_node(
base: PenNodeBase,
d: Option<String>,
icon_id: Option<String>,
width: SizingBehavior,
height: SizingBehavior,
fill: Option<Vec<PenFill>>,
stroke: Option<PenStroke>,
effects: Option<Vec<PenEffect>>,
) -> PenNode {
PenNode::Path(PathNode {
base,
icon_id,
d,
anchors: None,
closed: None,
width: Some(width),
height: Some(height),
fill,
stroke,
effects,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
/// Text — spreads the mapped [`TextProps`] onto a `TextNode`.
pub fn text_node(
base: PenNodeBase,
width: SizingBehavior,
height: SizingBehavior,
props: TextProps,
fill: Option<Vec<PenFill>>,
effects: Option<Vec<PenEffect>>,
) -> PenNode {
let font_style = props.font_style.map(|s| match s {
StyleFontStyle::Normal => TextFontStyle::Normal,
StyleFontStyle::Italic => TextFontStyle::Italic,
});
PenNode::Text(TextNode {
base,
width: Some(width),
height: Some(height),
content: props.content,
font_family: props.font_family,
font_size: props.font_size,
font_weight: props.font_weight.map(FontWeight::Number),
font_style,
letter_spacing: props.letter_spacing,
line_height: props.line_height,
text_align: props.text_align,
text_align_vertical: props.text_align_vertical,
text_growth: props.text_growth,
underline: props.underline,
strikethrough: props.strikethrough,
fill,
effects,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
/// Component-instance reference.
pub fn ref_node(base: PenNodeBase, target: String) -> PenNode {
PenNode::Ref(RefNode {
base,
target,
descendants: None,
children: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}

View file

@ -0,0 +1,363 @@
//! Figma → PenDocument top-level mapping — ports `figma-node-mapper.ts`.
//! Resolves style references, builds the tree, and converts each
//! user page into a `PenDocument`.
use crate::common::{collect_image_blobs, ConversionContext, FigLayoutMode};
use crate::converters::{convert_children, convert_node};
use crate::figma_types::FigmaDecodedFile;
use crate::kiwi::FigValue;
use crate::tree::{
build_tree, build_tree_for_clipboard, collect_components, collect_symbol_tree, guid_to_string,
is_user_page, TreeNode,
};
use jian_ops_schema::document::PenDocument;
use jian_ops_schema::node::PenNode;
use jian_ops_schema::page::PenPage;
use std::collections::HashMap;
/// Outcome of a Figma import.
pub struct FigmaImportResult {
pub document: PenDocument,
pub warnings: Vec<String>,
/// In-blob image bytes keyed by blob index.
pub image_blobs: HashMap<u32, Vec<u8>>,
}
fn empty_document(name: &str) -> PenDocument {
PenDocument {
version: "1".to_string(),
name: Some(name.to_string()),
themes: None,
variables: None,
pages: Some(Vec::new()),
children: Vec::new(),
format_version: None,
id: None,
app: None,
routes: None,
state: None,
lifecycle: None,
logic_modules: None,
}
}
fn document_with_pages(name: &str, pages: Vec<PenPage>) -> PenDocument {
PenDocument {
pages: Some(pages),
..empty_document(name)
}
}
fn pen_page(id: String, name: String, children: Vec<PenNode>) -> PenPage {
PenPage {
id,
name,
children,
state: None,
lifecycle: None,
}
}
/// Copy style-node values inline onto the nodes that reference them,
/// so downstream converters never chase a style ref. Mutates the
/// node-change list in place.
pub fn resolve_style_references(node_changes: &mut [FigValue]) {
let mut style_map: HashMap<String, FigValue> = HashMap::new();
for nc in node_changes.iter() {
if nc.get("styleType").is_some() {
if let Some(key) = nc.get("guid").and_then(guid_to_string) {
style_map.insert(key, nc.clone());
}
}
}
if style_map.is_empty() {
return;
}
for nc in node_changes.iter_mut() {
resolve_on_node(nc, &style_map);
}
}
fn lookup_style<'a>(
nc: &FigValue,
field: &str,
style_map: &'a HashMap<String, FigValue>,
) -> Option<&'a FigValue> {
let key = nc.get(field)?.get("guid").and_then(guid_to_string)?;
style_map.get(&key)
}
fn non_empty_array(v: &FigValue, key: &str) -> bool {
v.get_array(key).map(|a| !a.is_empty()).unwrap_or(false)
}
fn resolve_on_node(nc: &mut FigValue, style_map: &HashMap<String, FigValue>) {
// FILL.
if let Some(fs) = lookup_style(nc, "styleIdForFill", style_map) {
if non_empty_array(fs, "fillPaints") {
if let Some(v) = fs.get("fillPaints").cloned() {
nc.set("fillPaints", v);
}
}
}
// STROKE — reads the style node's fillPaints, writes strokePaints.
if let Some(ss) = lookup_style(nc, "styleIdForStrokeFill", style_map) {
if non_empty_array(ss, "fillPaints") {
if let Some(v) = ss.get("fillPaints").cloned() {
nc.set("strokePaints", v);
}
}
}
// TEXT — only fills a field the node lacks.
if let Some(ts) = lookup_style(nc, "styleIdForText", style_map).cloned() {
for key in [
"fontName",
"fontSize",
"lineHeight",
"letterSpacing",
"textAlignHorizontal",
"textDecoration",
"textCase",
] {
if nc.get(key).is_none() {
if let Some(v) = ts.get(key).cloned() {
nc.set(key, v);
}
}
}
if !non_empty_array(nc, "fillPaints") && non_empty_array(&ts, "fillPaints") {
if let Some(v) = ts.get("fillPaints").cloned() {
nc.set("fillPaints", v);
}
}
}
// EFFECT.
if let Some(es) = lookup_style(nc, "styleIdForEffect", style_map) {
if non_empty_array(es, "effects") && !non_empty_array(nc, "effects") {
if let Some(v) = es.get("effects").cloned() {
nc.set("effects", v);
}
}
}
}
/// Convert every user page of a decoded `.fig` into one multi-page
/// `PenDocument`.
pub fn figma_all_pages_to_pen_document(
mut decoded: FigmaDecodedFile,
file_name: &str,
layout_mode: FigLayoutMode,
) -> FigmaImportResult {
resolve_style_references(&mut decoded.node_changes);
let image_blobs = collect_image_blobs(&decoded.blobs);
let Some(tree) = build_tree(&decoded.node_changes) else {
return FigmaImportResult {
document: empty_document(file_name),
warnings: vec!["No document root found".to_string()],
image_blobs,
};
};
let pages: Vec<&TreeNode> = tree
.children
.iter()
.filter(|c| c.figma.get_str("type") == Some("CANVAS"))
.filter(|c| is_user_page(c))
.collect();
if pages.is_empty() {
return FigmaImportResult {
document: empty_document(file_name),
warnings: vec!["No pages found in Figma file".to_string()],
image_blobs,
};
}
let mut component_map: HashMap<String, String> = HashMap::new();
let mut symbol_tree: HashMap<String, TreeNode> = HashMap::new();
let mut counter: u32 = 1;
for page in &pages {
collect_components(page, &mut component_map, &mut counter);
}
collect_symbol_tree(&tree, &mut symbol_tree);
let mut ctx = ConversionContext {
component_map,
symbol_tree,
warnings: Vec::new(),
id_counter: counter,
blobs: decoded.blobs,
layout_mode,
};
let mut pen_pages = Vec::new();
for (i, page) in pages.iter().enumerate() {
let children = convert_children(page, &mut ctx);
let name = page
.figma
.get_str("name")
.map(|s| s.to_string())
.unwrap_or_else(|| format!("Page {}", i + 1));
pen_pages.push(pen_page(format!("figma-page-{i}"), name, children));
}
FigmaImportResult {
document: document_with_pages(file_name, pen_pages),
warnings: ctx.warnings,
image_blobs,
}
}
/// Convert a single page (`page_index`) of a decoded `.fig`.
pub fn figma_to_pen_document(
mut decoded: FigmaDecodedFile,
file_name: &str,
page_index: usize,
layout_mode: FigLayoutMode,
) -> FigmaImportResult {
resolve_style_references(&mut decoded.node_changes);
let image_blobs = collect_image_blobs(&decoded.blobs);
let Some(tree) = build_tree(&decoded.node_changes) else {
return FigmaImportResult {
document: empty_document(file_name),
warnings: vec!["No document root found".to_string()],
image_blobs,
};
};
let pages: Vec<&TreeNode> = tree.children.iter().filter(|c| is_user_page(c)).collect();
let Some(page) = pages.get(page_index).or_else(|| pages.first()) else {
return FigmaImportResult {
document: empty_document(file_name),
warnings: vec!["No pages found in Figma file".to_string()],
image_blobs,
};
};
let mut component_map: HashMap<String, String> = HashMap::new();
let mut symbol_tree: HashMap<String, TreeNode> = HashMap::new();
let mut counter: u32 = 1;
collect_components(page, &mut component_map, &mut counter);
collect_symbol_tree(&tree, &mut symbol_tree);
let mut ctx = ConversionContext {
component_map,
symbol_tree,
warnings: Vec::new(),
id_counter: counter,
blobs: decoded.blobs,
layout_mode,
};
let children = convert_children(page, &mut ctx);
let name = page
.figma
.get_str("name")
.map(|s| s.to_string())
.unwrap_or_else(|| "Page 1".to_string());
let pen = pen_page(format!("figma-page-{page_index}"), name, children);
FigmaImportResult {
document: document_with_pages(file_name, vec![pen]),
warnings: ctx.warnings,
image_blobs,
}
}
/// Page summaries for a decoded file — id / name / child count.
pub struct FigmaPageInfo {
pub id: String,
pub name: String,
pub child_count: usize,
}
/// List the user pages of a decoded `.fig` without converting.
pub fn get_figma_pages(decoded: &FigmaDecodedFile) -> Vec<FigmaPageInfo> {
let Some(tree) = build_tree(&decoded.node_changes) else {
return Vec::new();
};
tree.children
.iter()
.filter(|c| is_user_page(c))
.map(|c| FigmaPageInfo {
id: c
.figma
.get("guid")
.and_then(guid_to_string)
.unwrap_or_default(),
name: c.figma.get_str("name").unwrap_or("Page").to_string(),
child_count: c.children.len(),
})
.collect()
}
/// Convert clipboard node changes into a flat `PenNode` list (no
/// document wrapper).
pub fn figma_node_changes_to_pen_nodes(
mut decoded: FigmaDecodedFile,
layout_mode: FigLayoutMode,
) -> FigmaImportResult {
resolve_style_references(&mut decoded.node_changes);
let image_blobs = collect_image_blobs(&decoded.blobs);
let tree = build_tree(&decoded.node_changes);
let top_nodes: Vec<TreeNode> = if let Some(tree) = &tree {
let pages: Vec<&TreeNode> = tree.children.iter().filter(|c| is_user_page(c)).collect();
if let Some(page) = pages.first() {
page.children.clone()
} else if !tree.children.is_empty() {
tree.children.clone()
} else {
Vec::new()
}
} else {
build_tree_for_clipboard(&decoded.node_changes)
};
if top_nodes.is_empty() {
return FigmaImportResult {
document: empty_document("clipboard"),
warnings: vec!["No convertible nodes found".to_string()],
image_blobs,
};
}
let mut component_map: HashMap<String, String> = HashMap::new();
let mut symbol_tree: HashMap<String, TreeNode> = HashMap::new();
let mut counter: u32 = 1;
for node in &top_nodes {
collect_components(node, &mut component_map, &mut counter);
}
if let Some(tree) = &tree {
collect_symbol_tree(tree, &mut symbol_tree);
}
for node in &top_nodes {
collect_symbol_tree(node, &mut symbol_tree);
}
let mut ctx = ConversionContext {
component_map,
symbol_tree,
warnings: Vec::new(),
id_counter: counter,
blobs: decoded.blobs,
layout_mode,
};
let mut nodes = Vec::new();
for tree_node in &top_nodes {
if tree_node.figma.get_bool("visible") == Some(false) {
continue;
}
if let Some(node) = convert_node(tree_node, None, &mut ctx) {
nodes.push(node);
}
}
FigmaImportResult {
document: document_with_pages(
"clipboard",
vec![pen_page("clipboard".into(), "Clipboard".into(), nodes)],
),
warnings: ctx.warnings,
image_blobs,
}
}