<?php
/**
* Core converter: Figma tree → logical rows/columns with depth-budget flattening.
*/
if ( ! defined( 'ABSPATH' ) ) exit;
class FTE_Converter {
private FTE_Row_Detector $detector;
private FTE_Widget_Mapper $mapper;
private string $flatten_strategy;
public function __construct( string $flatten_strategy = 'serialize' ) {
$this->detector = new FTE_Row_Detector();
$this->mapper = new FTE_Widget_Mapper();
$this->flatten_strategy = $flatten_strategy;
}
/**
* Convert a Figma frame into a flat list of logical rows (= Elementor sections).
*
* @param array $root_frame Top-level Figma frame node.
* @return array[]
*/
public function convert( array $root_frame ): array {
$rows = $this->detector->detect_rows( $root_frame );
return array_map(
fn( $row ) => $this->convert_row( $row, $root_frame, 2 ),
$rows
);
}
// -----------------------------------------------------------------------
// Recursive descent with depth budget
//
// Budget 2 = can emit Section (top-level)
// Budget 1 = can emit Inner Section
// Budget 0 = must flatten to widgets only
// -----------------------------------------------------------------------
private function convert_row( array $row, array $parent, int $depth_budget ): array {
$columns = $this->detector->detect_columns( $row, $parent );
$logical_cols = [];
foreach ( $columns as $col ) {
$child_nodes = $this->convert_node_children( $col['node'], $depth_budget - 1 );
$logical_cols[] = [
'type' => 'column',
'widthPct' => $col['widthPct'],
'children' => $child_nodes,
'settings' => $this->extract_column_settings( $col['node'] ),
];
}
return [
'type' => 'row',
'children' => $logical_cols,
'gap' => $parent['itemSpacing'] ?? 0,
'settings' => $this->extract_row_settings( $parent ),
];
}
/**
* Convert a node's children into logical nodes.
* Decides whether to create sub-rows, flatten, or produce widgets.
*/
private function convert_node_children( array $node, int $depth_budget ): array {
if ( $this->detector->is_leaf_widget( $node ) ) {
return [ $this->mapper->map( $node ) ];
}
if ( in_array( $node['type'] ?? '', [ 'TEXT', 'VECTOR' ], true ) ) {
return [ $this->mapper->map( $node ) ];
}
if ( $this->mapper->is_vector_group( $node ) ) {
return [ $this->mapper->map( $node ) ];
}
$sub_rows = $this->detector->detect_rows( $node );
// Depth exhausted → flatten
if ( $depth_budget <= 0 ) {
return $this->flatten( $node, $sub_rows );
}
$result = [];
foreach ( $sub_rows as $sub_row ) {
if (
count( $sub_row['children'] ) === 1
&& $this->detector->is_leaf_widget( $sub_row['children'][0] )
) {
$result[] = $this->mapper->map( $sub_row['children'][0] );
} elseif (
count( $sub_row['children'] ) === 1
&& $this->is_simple_container( $sub_row['children'][0] )
) {
// Collapse transparent wrappers
$inner = $this->convert_node_children( $sub_row['children'][0], $depth_budget );
array_push( $result, ...$inner );
} else {
$result[] = $this->convert_row( $sub_row, $node, $depth_budget );
}
}
return $result;
}
// -----------------------------------------------------------------------
// Flattening strategies
// -----------------------------------------------------------------------
private function flatten( array $node, array $rows ): array {
if ( $this->flatten_strategy === 'rasterize' ) {
$box = $node['absoluteBoundingBox'] ?? [ 'width' => 300, 'height' => 200 ];
return [ [
'type' => 'rasterized',
'figmaNodeId' => $node['id'],
'width' => $box['width'],
'height' => $box['height'],
] ];
}
// Default: serialize (depth-first widget collection)
return $this->serialize_to_widgets( $rows );
}
private function serialize_to_widgets( array $rows ): array {
$widgets = [];
foreach ( $rows as $row ) {
foreach ( $row['children'] as $child ) {
if ( $this->detector->is_leaf_widget( $child ) ) {
$widgets[] = $this->mapper->map( $child );
} else {
array_push( $widgets, ...$this->collect_leaf_widgets( $child ) );
}
}
}
return $widgets;
}
private function collect_leaf_widgets( array $node ): array {
if ( $this->detector->is_leaf_widget( $node ) ) {
return [ $this->mapper->map( $node ) ];
}
if ( $this->mapper->is_vector_group( $node ) ) {
return [ $this->mapper->map( $node ) ];
}
$results = [];
foreach ( $node['children'] ?? [] as $child ) {
if ( ( $child['visible'] ?? true ) === false ) continue;
array_push( $results, ...$this->collect_leaf_widgets( $child ) );
}
return $results;
}
// -----------------------------------------------------------------------
// Settings extraction
// -----------------------------------------------------------------------
private function extract_row_settings( array $node ): array {
$s = [];
foreach ( $node['fills'] ?? [] as $fill ) {
if ( $fill['type'] === 'SOLID' && isset( $fill['color'] ) ) {
$s['backgroundColor'] = FTE_Widget_Mapper::figma_color_to_hex( $fill['color'] );
break;
}
}
foreach ( [ 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight' ] as $key ) {
if ( ! empty( $node[ $key ] ) ) $s[ $key ] = $node[ $key ];
}
if ( ! empty( $node['counterAxisAlignItems'] ) ) {
$map = [ 'MIN' => 'top', 'CENTER' => 'center', 'MAX' => 'bottom' ];
$s['contentPosition'] = $map[ $node['counterAxisAlignItems'] ] ?? 'top';
}
$gap = $node['itemSpacing'] ?? 0;
if ( $gap === 0 ) $s['gap'] = 'no';
elseif ( $gap <= 5 ) $s['gap'] = 'narrow';
elseif ( $gap <= 15 ) $s['gap'] = 'default';
elseif ( $gap <= 25 ) $s['gap'] = 'extended';
elseif ( $gap <= 35 ) $s['gap'] = 'wide';
else $s['gap'] = 'wider';
return $s;
}
private function extract_column_settings( array $node ): array {
$s = [];
foreach ( $node['fills'] ?? [] as $fill ) {
if ( $fill['type'] === 'SOLID' && isset( $fill['color'] ) ) {
$s['backgroundColor'] = FTE_Widget_Mapper::figma_color_to_hex( $fill['color'] );
break;
}
}
foreach ( [ 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight' ] as $key ) {
if ( ! empty( $node[ $key ] ) ) $s[ $key ] = $node[ $key ];
}
if ( ! empty( $node['primaryAxisAlignItems'] ) && ( $node['layoutMode'] ?? '' ) === 'VERTICAL' ) {
$map = [ 'MIN' => 'top', 'CENTER' => 'middle', 'MAX' => 'bottom' ];
$s['verticalAlign'] = $map[ $node['primaryAxisAlignItems'] ] ?? 'top';
}
return $s;
}
private function is_simple_container( array $node ): bool {
if ( ! in_array( $node['type'] ?? '', [ 'FRAME', 'GROUP' ], true ) ) return false;
$has_bg = false;
foreach ( $node['fills'] ?? [] as $f ) {
if ( $f['type'] === 'SOLID' && isset( $f['color'] ) && ( $f['color']['a'] ?? 1 ) > 0 ) {
$has_bg = true; break;
}
}
return ! $has_bg && ( $node['strokeWeight'] ?? 0 ) == 0;
}
}