<?php
/**
* Row detection & column clustering.
*
* Converts Figma's tree (auto-layout or absolute-positioned) into logical
* rows and columns with percentage-based widths.
*/
if ( ! defined( 'ABSPATH' ) ) exit;
class FTE_Row_Detector {
private float $overlap_threshold;
private int $max_columns;
public function __construct( float $overlap_threshold = 0.5, int $max_columns = 10 ) {
$this->overlap_threshold = $overlap_threshold;
$this->max_columns = $max_columns;
}
// -----------------------------------------------------------------------
// Row detection
// -----------------------------------------------------------------------
/**
* Detect rows from a Figma node's children.
*
* @param array $parent Figma node.
* @return array[] Each row is [ 'children' => [...], 'boundingBox' => [...] ]
*/
public function detect_rows( array $parent ): array {
$children = $this->get_visible_children( $parent );
if ( empty( $children ) ) return [];
$layout_mode = $parent['layoutMode'] ?? 'NONE';
switch ( $layout_mode ) {
case 'VERTICAL':
return $this->detect_rows_vertical( $parent, $children );
case 'HORIZONTAL':
return [ $this->wrap_as_one_row( $children ) ];
default:
return $this->detect_rows_absolute( $children );
}
}
/**
* Split a detected row into columns with percentage widths.
*
* @param array $row A detected row.
* @param array $parent The parent Figma node.
* @return array[] Each col is [ 'node' => ..., 'widthPct' => int ]
*/
public function detect_columns( array $row, array $parent ): array {
$children = $row['children'];
if ( empty( $children ) ) return [];
if ( count( $children ) === 1 ) {
return [ [ 'node' => $children[0], 'widthPct' => 100 ] ];
}
// Sort left-to-right
usort( $children, function( $a, $b ) {
return ( $a['absoluteBoundingBox']['x'] ?? 0 ) <=> ( $b['absoluteBoundingBox']['x'] ?? 0 );
});
$parent_box = $parent['absoluteBoundingBox'];
$layout_mode = $parent['layoutMode'] ?? 'NONE';
if ( $layout_mode === 'HORIZONTAL' ) {
$raw_widths = $this->compute_auto_layout_widths( $parent, $children );
} else {
$raw_widths = $this->compute_absolute_widths( $parent_box, $children );
}
$percentages = $this->normalize_to_percentages( $raw_widths );
$columns = [];
foreach ( $children as $i => $child ) {
$columns[] = [
'node' => $child,
'widthPct' => $percentages[ $i ],
];
}
return $columns;
}
// -----------------------------------------------------------------------
// Row detection strategies
// -----------------------------------------------------------------------
private function detect_rows_vertical( array $parent, array $children ): array {
$rows = [];
foreach ( $children as $child ) {
if ( ( $child['layoutPositioning'] ?? '' ) === 'ABSOLUTE' ) continue;
$rows[] = [
'children' => [ $child ],
'boundingBox' => $child['absoluteBoundingBox'] ?? $parent['absoluteBoundingBox'],
];
}
return $rows;
}
private function wrap_as_one_row( array $children ): array {
$boxes = array_filter( array_map( fn( $c ) => $c['absoluteBoundingBox'] ?? null, $children ) );
if ( empty( $boxes ) ) {
return [ 'children' => $children, 'boundingBox' => [ 'x' => 0, 'y' => 0, 'width' => 0, 'height' => 0 ] ];
}
$min_x = min( array_column( $boxes, 'x' ) );
$min_y = min( array_column( $boxes, 'y' ) );
$max_x = max( array_map( fn( $b ) => $b['x'] + $b['width'], $boxes ) );
$max_y = max( array_map( fn( $b ) => $b['y'] + $b['height'], $boxes ) );
return [
'children' => $children,
'boundingBox' => [ 'x' => $min_x, 'y' => $min_y, 'width' => $max_x - $min_x, 'height' => $max_y - $min_y ],
];
}
/**
* Y-coordinate sweep-line clustering for absolutely positioned children.
*/
private function detect_rows_absolute( array $children ): array {
usort( $children, function( $a, $b ) {
return ( $a['absoluteBoundingBox']['y'] ?? 0 ) <=> ( $b['absoluteBoundingBox']['y'] ?? 0 );
});
$clusters = [];
$bounds = [];
foreach ( $children as $child ) {
$child_box = $child['absoluteBoundingBox'];
$merged = false;
if ( ! empty( $clusters ) ) {
$last_idx = count( $clusters ) - 1;
$last_bound = $bounds[ $last_idx ];
$overlap = $this->vertical_overlap( $last_bound, $child_box );
$min_h = min( $last_bound['height'], $child_box['height'] );
if ( $min_h > 0 && $overlap / $min_h >= $this->overlap_threshold ) {
$clusters[ $last_idx ][] = $child;
$bounds[ $last_idx ] = $this->expand_bounds( $last_bound, $child_box );
$merged = true;
}
}
if ( ! $merged ) {
$clusters[] = [ $child ];
$bounds[] = $child_box;
}
}
return array_map( function( $cluster, $i ) use ( $bounds ) {
return [ 'children' => $cluster, 'boundingBox' => $bounds[ $i ] ];
}, $clusters, array_keys( $clusters ) );
}
// -----------------------------------------------------------------------
// Column width computation
// -----------------------------------------------------------------------
private function compute_auto_layout_widths( array $parent, array $sorted_children ): array {
$parent_box = $parent['absoluteBoundingBox'];
$pad_l = $parent['paddingLeft'] ?? 0;
$pad_r = $parent['paddingRight'] ?? 0;
$gap = $parent['itemSpacing'] ?? 0;
$content_width = $parent_box['width'] - $pad_l - $pad_r;
$total_gaps = $gap * max( 0, count( $sorted_children ) - 1 );
$total_grow = 0;
$fixed_space = 0;
foreach ( $sorted_children as $c ) {
$grow = $c['layoutGrow'] ?? 0;
$total_grow += $grow;
if ( $grow == 0 ) {
$fixed_space += $c['absoluteBoundingBox']['width'] ?? 0;
}
}
$flex_space = max( 0, $content_width - $fixed_space - $total_gaps );
$widths = [];
foreach ( $sorted_children as $c ) {
$grow = $c['layoutGrow'] ?? 0;
if ( $grow > 0 && $total_grow > 0 ) {
$widths[] = ( $flex_space * $grow ) / $total_grow;
} else {
$widths[] = $c['absoluteBoundingBox']['width'] ?? 0;
}
}
return $widths;
}
private function compute_absolute_widths( array $parent_box, array $sorted_children ): array {
// Use each child's actual rendered width as its raw column width.
// The old implementation used x_next - x_current (spans) which collapsed
// columns to tiny slivers when Figma children were not tiled edge-to-edge,
// forcing downstream widget wrap and massive vertical inflation.
$widths = array_map(
fn( $c ) => max( 1.0, (float) ( $c['absoluteBoundingBox']['width'] ?? 0 ) ),
$sorted_children
);
// Absorb any leading slack (parent left edge to first child) into col[0]
// so the row visually starts where the parent does.
if ( ! empty( $sorted_children ) ) {
$first_x = $sorted_children[0]['absoluteBoundingBox']['x'] ?? $parent_box['x'];
$left_margin = $first_x - $parent_box['x'];
if ( $left_margin > 1 ) {
$widths[0] += $left_margin;
}
}
return $widths;
}
// -----------------------------------------------------------------------
// Percentage normalization (largest-remainder method)
// -----------------------------------------------------------------------
/**
* @param float[] $raw_widths
* @return int[] Percentages summing to exactly 100.
*/
public function normalize_to_percentages( array $raw_widths ): array {
if ( empty( $raw_widths ) ) return [];
if ( count( $raw_widths ) === 1 ) return [ 100 ];
// Merge if too many columns
while ( count( $raw_widths ) > $this->max_columns ) {
$min_sum = PHP_FLOAT_MAX;
$min_idx = 0;
for ( $i = 0; $i < count( $raw_widths ) - 1; $i++ ) {
$sum = $raw_widths[ $i ] + $raw_widths[ $i + 1 ];
if ( $sum < $min_sum ) { $min_sum = $sum; $min_idx = $i; }
}
$merged = $raw_widths[ $min_idx ] + $raw_widths[ $min_idx + 1 ];
array_splice( $raw_widths, $min_idx, 2, [ $merged ] );
}
$total = array_sum( $raw_widths );
if ( $total == 0 ) {
$each = (int) floor( 100 / count( $raw_widths ) );
$result = array_fill( 0, count( $raw_widths ), $each );
$result[ count( $result ) - 1 ] += 100 - $each * count( $raw_widths );
return $result;
}
$exact = array_map( fn( $w ) => ( $w / $total ) * 100, $raw_widths );
$floored = array_map( 'intval', array_map( 'floor', $exact ) );
$remainders = [];
for ( $i = 0; $i < count( $exact ); $i++ ) {
$remainders[] = [ 'r' => $exact[ $i ] - $floored[ $i ], 'i' => $i ];
}
usort( $remainders, fn( $a, $b ) => $b['r'] <=> $a['r'] );
$deficit = 100 - array_sum( $floored );
foreach ( $remainders as $item ) {
if ( $deficit <= 0 ) break;
$floored[ $item['i'] ]++;
$deficit--;
}
// Ensure no 0% column
for ( $i = 0; $i < count( $floored ); $i++ ) {
if ( $floored[ $i ] === 0 ) {
$floored[ $i ] = 1;
$max_idx = array_search( max( $floored ), $floored );
$floored[ $max_idx ]--;
}
}
return $floored;
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
public function get_visible_children( array $node ): array {
$children = $node['children'] ?? [];
return array_values( array_filter( $children, fn( $c ) => ( $c['visible'] ?? true ) !== false ) );
}
public function is_leaf_widget( array $node ): bool {
$leaf_types = [ 'TEXT', 'VECTOR', 'LINE', 'STAR', 'POLYGON', 'ELLIPSE', 'BOOLEAN_OPERATION', 'RECTANGLE' ];
if ( in_array( $node['type'] ?? '', $leaf_types, true ) ) return true;
return empty( $this->get_visible_children( $node ) );
}
private function vertical_overlap( array $a, array $b ): float {
$top_a = $a['y'];
$bottom_a = $a['y'] + $a['height'];
$top_b = $b['y'];
$bottom_b = $b['y'] + $b['height'];
return max( 0, min( $bottom_a, $bottom_b ) - max( $top_a, $top_b ) );
}
private function expand_bounds( array $a, array $b ): array {
$x = min( $a['x'], $b['x'] );
$y = min( $a['y'], $b['y'] );
return [
'x' => $x,
'y' => $y,
'width' => max( $a['x'] + $a['width'], $b['x'] + $b['width'] ) - $x,
'height' => max( $a['y'] + $a['height'], $b['y'] + $b['height'] ) - $y,
];
}
}