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:
parent
203e424950
commit
7f41eeeeda
374
crates/op-figma/src/common.rs
Normal file
374
crates/op-figma/src/common.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
574
crates/op-figma/src/converters.rs
Normal file
574
crates/op-figma/src/converters.rs
Normal 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)
|
||||
}
|
||||
313
crates/op-figma/src/instance.rs
Normal file
313
crates/op-figma/src/instance.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
216
crates/op-figma/src/node_build.rs
Normal file
216
crates/op-figma/src/node_build.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
363
crates/op-figma/src/node_mapper.rs
Normal file
363
crates/op-figma/src/node_mapper.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue