diff --git a/crates/op-figma/src/common.rs b/crates/op-figma/src/common.rs new file mode 100644 index 000000000..e41a4562d --- /dev/null +++ b/crates/op-figma/src/common.rs @@ -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, + /// Figma SYMBOL guid → its subtree (for instance inlining). + pub symbol_tree: HashMap, + /// Non-fatal conversion notes. + pub warnings: Vec, + /// Sequential `fig_N` id allocator. + pub id_counter: u32, + /// Geometry / image blob pool. + pub blobs: Vec, + 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 { + 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, Option) { + 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 { + 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 { + 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 { + 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> { + 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); + } +} diff --git a/crates/op-figma/src/converters.rs b/crates/op-figma/src/converters.rs new file mode 100644 index 000000000..c538b8ab1 --- /dev/null +++ b/crates/op-figma/src/converters.rs @@ -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 { + let parent_stack_mode: Option = 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 { + 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 = 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, + has_auto_layout: bool, + ctx: &ConversionContext, +) -> Vec { + 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, Option, Option) { + 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) +} diff --git a/crates/op-figma/src/instance.rs b/crates/op-figma/src/instance.rs new file mode 100644 index 000000000..9c5d2f038 --- /dev/null +++ b/crates/op-figma/src/instance.rs @@ -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 { + let guids = entry.get("guidPath")?.get_array("guids")?; + if guids.is_empty() { + return None; + } + let parts: Vec = 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, +) -> Vec { + 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 = HashMap::new(); + let mut node_derived: HashMap = HashMap::new(); + let mut nested_override: HashMap> = HashMap::new(); + let mut nested_derived: HashMap> = 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 { + let guids = entry.get("guidPath")?.get_array("guids")?; + if guids.len() < 2 { + return None; + } + let mut copy = entry.clone(); + let rest: Vec = 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, + node_derived: &HashMap, + nested_override: &HashMap>, + nested_derived: &HashMap>, +) -> 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 = 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(), + } +} diff --git a/crates/op-figma/src/kiwi.rs b/crates/op-figma/src/kiwi.rs index 5803afcd0..a67690fb2 100644 --- a/crates/op-figma/src/kiwi.rs +++ b/crates/op-figma/src/kiwi.rs @@ -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 ─────────────────────────────────────────────── diff --git a/crates/op-figma/src/lib.rs b/crates/op-figma/src/lib.rs index 8b1dc62ac..74a1e813b 100644 --- a/crates/op-figma/src/lib.rs +++ b/crates/op-figma/src/lib.rs @@ -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; diff --git a/crates/op-figma/src/node_build.rs b/crates/op-figma/src/node_build.rs new file mode 100644 index 000000000..a14fcef46 --- /dev/null +++ b/crates/op-figma/src/node_build.rs @@ -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>, + reusable: Option, +) -> 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>, +) -> 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>, + stroke: Option, + effects: Option>, + start_angle: Option, + sweep_angle: Option, + inner_radius: Option, +) -> 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, + effects: Option>, +) -> 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, + icon_id: Option, + width: SizingBehavior, + height: SizingBehavior, + fill: Option>, + stroke: Option, + effects: Option>, +) -> 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>, + effects: Option>, +) -> 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, + }) +} diff --git a/crates/op-figma/src/node_mapper.rs b/crates/op-figma/src/node_mapper.rs new file mode 100644 index 000000000..868f540f4 --- /dev/null +++ b/crates/op-figma/src/node_mapper.rs @@ -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, + /// In-blob image bytes keyed by blob index. + pub image_blobs: HashMap>, +} + +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) -> PenDocument { + PenDocument { + pages: Some(pages), + ..empty_document(name) + } +} + +fn pen_page(id: String, name: String, children: Vec) -> 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 = 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, +) -> 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) { + // 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 = HashMap::new(); + let mut symbol_tree: HashMap = 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 = HashMap::new(); + let mut symbol_tree: HashMap = 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 { + 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 = 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 = HashMap::new(); + let mut symbol_tree: HashMap = 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, + } +}