<?php
/**
* Maps Figma leaf nodes to Elementor widget settings.
*/
if ( ! defined( 'ABSPATH' ) ) exit;
class FTE_Widget_Mapper {
/**
* Convert a Figma leaf node into a logical widget array.
*
* @param array $node Figma node.
* @return array [ 'type' => 'widget', 'widgetType' => ..., 'settings' => [...] ]
*/
public function map( array $node ): array {
$type = $node['type'] ?? '';
switch ( $type ) {
case 'TEXT':
return $this->map_text( $node );
case 'RECTANGLE':
return $this->map_rectangle( $node );
case 'ELLIPSE':
case 'STAR':
case 'POLYGON':
case 'LINE':
case 'VECTOR':
case 'BOOLEAN_OPERATION':
return $this->map_vector( $node );
case 'FRAME':
case 'GROUP':
case 'COMPONENT':
case 'INSTANCE':
return $this->map_container( $node );
default:
return $this->map_fallback( $node );
}
}
// -----------------------------------------------------------------------
// Text
// -----------------------------------------------------------------------
private function map_text( array $node ): array {
$style = $node['style'] ?? [];
$text = $node['characters'] ?? '';
$size = $style['fontSize'] ?? 14;
$is_heading = $size >= 20 || strpos( $text, "\n" ) === false;
if ( $is_heading && ! empty( $style ) ) {
$settings = [
'title' => $text,
'header_size' => $this->infer_header_size( $size ),
'align' => $this->map_text_align( $style['textAlignHorizontal'] ?? '' ),
'title_color' => $this->extract_text_color( $node ),
'typography_typography' => 'custom',
'typography_font_family' => $style['fontFamily'] ?? 'Inter',
'typography_font_size' => [ 'size' => round( $size ), 'unit' => 'px' ],
'typography_font_weight' => (string) ( $style['fontWeight'] ?? 400 ),
];
if ( ! empty( $style['lineHeightPx'] ) ) {
$settings['typography_line_height'] = [ 'size' => round( $style['lineHeightPx'] ), 'unit' => 'px' ];
}
if ( ! empty( $style['letterSpacing'] ) ) {
$settings['typography_letter_spacing'] = [ 'size' => round( $style['letterSpacing'] ), 'unit' => 'px' ];
}
return [ 'type' => 'widget', 'widgetType' => 'heading', 'settings' => $settings ];
}
// Rich text
return [
'type' => 'widget',
'widgetType' => 'text-editor',
'settings' => [ 'editor' => $this->wrap_in_html( $text, $node ) ],
];
}
private function infer_header_size( float $size ): string {
if ( $size >= 48 ) return 'h1';
if ( $size >= 36 ) return 'h2';
if ( $size >= 28 ) return 'h3';
if ( $size >= 22 ) return 'h4';
if ( $size >= 18 ) return 'h5';
return 'h6';
}
private function map_text_align( string $align ): string {
return match( $align ) {
'CENTER' => 'center',
'RIGHT' => 'right',
'JUSTIFIED' => 'justify',
default => 'left',
};
}
private function extract_text_color( array $node ): ?string {
foreach ( $node['fills'] ?? [] as $fill ) {
if ( $fill['type'] === 'SOLID' && isset( $fill['color'] ) ) {
return self::figma_color_to_hex( $fill['color'] );
}
}
return null;
}
private function wrap_in_html( string $text, array $node ): string {
$style = $node['style'] ?? [];
$lines = explode( "\n", $text );
$html = implode( "<br>\n", array_map( fn( $l ) => $l ?: '<br>', $lines ) );
$css = [];
if ( ! empty( $style['fontFamily'] ) ) $css[] = 'font-family: ' . $style['fontFamily'];
if ( ! empty( $style['fontSize'] ) ) $css[] = 'font-size: ' . $style['fontSize'] . 'px';
if ( ! empty( $style['fontWeight'] ) ) $css[] = 'font-weight: ' . $style['fontWeight'];
if ( ! empty( $style['lineHeightPx'] ) ) $css[] = 'line-height: ' . $style['lineHeightPx'] . 'px';
$color = $this->extract_text_color( $node );
if ( $color ) $css[] = 'color: ' . $color;
$style_attr = ! empty( $css ) ? " style='" . esc_attr( implode( '; ', $css ) ) . "'" : '';
return '<p' . $style_attr . '>' . $html . '</p>';
}
// -----------------------------------------------------------------------
// Rectangle
// -----------------------------------------------------------------------
private function map_rectangle( array $node ): array {
$box = $node['absoluteBoundingBox'] ?? [];
$fills = $node['fills'] ?? [];
// Image fill → Image widget
foreach ( $fills as $fill ) {
if ( $fill['type'] === 'IMAGE' ) {
$ref = $fill['imageRef'] ?? $node['id'];
return [
'type' => 'widget',
'widgetType' => 'image',
'settings' => [
'image' => [ 'url' => '__FIGMA_IMAGE_REF__:' . $ref, 'id' => '' ],
'image_size' => 'full',
],
];
}
}
// Thin horizontal rectangle → Divider
$h = $box['height'] ?? 0;
$w = $box['width'] ?? 0;
if ( $h <= 5 && $w > $h * 10 ) {
return [
'type' => 'widget',
'widgetType' => 'divider',
'settings' => [
'style' => 'solid',
'weight' => [ 'size' => round( $h ), 'unit' => 'px' ],
'color' => $this->extract_fill_color( $fills ),
],
];
}
// Colored box → HTML widget; transparent → Spacer
$bg = $this->extract_fill_color( $fills );
if ( ! $bg || $bg === '#FFFFFF' ) {
return [
'type' => 'widget',
'widgetType' => 'spacer',
'settings' => [ 'space' => [ 'size' => round( $h ?: 20 ), 'unit' => 'px' ] ],
];
}
$radius = $node['cornerRadius'] ?? 0;
return [
'type' => 'widget',
'widgetType' => 'html',
'settings' => [
'html' => sprintf(
"<div style='width:100%%;height:%dpx;background-color:%s;border-radius:%dpx;'></div>",
(int) round( min( $h ?: 100, 400 ) ), esc_attr( $bg ), round( $radius )
),
],
];
}
// -----------------------------------------------------------------------
// Vector / Shape
// -----------------------------------------------------------------------
private function map_vector( array $node ): array {
return [
'type' => 'widget',
'widgetType' => 'image',
'settings' => [
'image' => [ 'url' => '__FIGMA_IMAGE_REF__:' . $node['id'], 'id' => '' ],
'_requires_image_export' => true,
],
];
}
// -----------------------------------------------------------------------
// Container as widget (depth budget exhausted)
// -----------------------------------------------------------------------
/**
* Detect a vector-group container (logo/icon): a FRAME/GROUP/COMPONENT/INSTANCE
* whose direct visible children are mostly VECTOR/LINE/STAR/POLYGON/ELLIPSE/
* BOOLEAN_OPERATION. Such groups should be exported as ONE image using the
* container's own node id, not sliced into per-letter image widgets.
*/
public function is_vector_group( array $node ): bool {
$type = $node['type'] ?? '';
if ( ! in_array( $type, [ 'FRAME', 'GROUP', 'COMPONENT', 'INSTANCE' ], true ) ) return false;
$kids = array_values( array_filter( $node['children'] ?? [], fn( $c ) => ( $c['visible'] ?? true ) !== false ) );
if ( count( $kids ) < 2 ) return false;
$vector_types = [ 'VECTOR', 'LINE', 'STAR', 'POLYGON', 'ELLIPSE', 'BOOLEAN_OPERATION' ];
$vec_count = 0;
foreach ( $kids as $k ) {
if ( in_array( $k['type'] ?? '', $vector_types, true ) ) $vec_count++;
}
return ( $vec_count / count( $kids ) ) >= 0.6;
}
private function emit_container_as_image( array $node ): array {
return [
'type' => 'widget',
'widgetType' => 'image',
'settings' => [
'image' => [ 'url' => '__FIGMA_IMAGE_REF__:' . ( $node['id'] ?? '' ), 'id' => '' ],
'_requires_image_export' => true,
'image_size' => 'full',
],
];
}
private function map_container( array $node ): array {
if ( $this->is_vector_group( $node ) ) {
return $this->emit_container_as_image( $node );
}
// Container frames are mostly layout, not leaves. Their background/padding are
// captured by the converter via extract_row_settings / extract_column_settings,
// so this method is only called when the converter has already decided this node
// is a leaf (e.g., a decorative background frame with no useful children).
//
// Historical behavior emitted an HTML rectangle with the frame's literal height.
// For tall outer frames (e.g., a 3730px hero wrapper) that inflated the parent
// column by 3000+px and pushed sibling sections far below, producing the flat
// stacked-sections-same-Y layout. We cap the emitted visual height so a
// leaf-classified container can contribute color but never inflate geometry.
$box = $node['absoluteBoundingBox'] ?? [ 'height' => 20 ];
$height = (float) ( $box['height'] ?? 20 );
$bg = $this->extract_fill_color( $node['fills'] ?? [] );
$radius = (float) ( $node['cornerRadius'] ?? 0 );
$capped = (int) round( min( $height, 80 ) );
if ( $bg ) {
return [
'type' => 'widget',
'widgetType' => 'html',
'settings' => [
'html' => sprintf(
"<div style='width:100%%;height:%dpx;background:%s;border-radius:%dpx'></div>",
$capped, esc_attr( $bg ), (int) round( $radius )
),
],
];
}
return [
'type' => 'widget',
'widgetType' => 'spacer',
'settings' => [ 'space' => [ 'size' => (int) round( min( $height ?: 20, 20 ) ), 'unit' => 'px' ] ],
];
}
private function map_fallback( array $node ): array {
return [
'type' => 'widget',
'widgetType' => 'html',
'settings' => [ 'html' => '<!-- Unsupported: ' . esc_html( $node['type'] ?? 'unknown' ) . ' -->' ],
];
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private function extract_fill_color( array $fills ): ?string {
foreach ( $fills as $fill ) {
if ( ( $fill['type'] ?? '' ) === 'SOLID' && isset( $fill['color'] ) ) {
return self::figma_color_to_hex( $fill['color'] );
}
}
return null;
}
public static function figma_color_to_hex( array $color ): string {
$r = (int) round( ( $color['r'] ?? 0 ) * 255 );
$g = (int) round( ( $color['g'] ?? 0 ) * 255 );
$b = (int) round( ( $color['b'] ?? 0 ) * 255 );
$hex = sprintf( '#%02X%02X%02X', $r, $g, $b );
$a = $color['a'] ?? 1;
if ( $a < 1 ) {
$hex .= sprintf( '%02X', (int) round( $a * 255 ) );
}
return $hex;
}
}