Spaces:
Sleeping
Sleeping
Download bridge/src/Figma.php from acecalisto3/UIKitV3-Automator: direct link, hf CLI and curl.
- Browser
- Download file 13 kB
-
https://huggingface.co/spaces/acecalisto3/UIKitV3-Automator/resolve/main/bridge/src/Figma.php
- Command line
-
hf download hf://spaces/acecalisto3/UIKitV3-Automator/bridge/src/Figma.php
-
curl -L -o Figma.php https://huggingface.co/spaces/acecalisto3/UIKitV3-Automator/resolve/main/bridge/src/Figma.php
13 kB
| /** | |
| * Reads a Figma document tree — REST `GET /v1/files/:key` output, or the same | |
| * shape emitted by the Style Bridge plugin — and yields both halves of an import: | |
| * the design tokens, and the frames that become YOOtheme layouts. | |
| * | |
| * Tokens are inferred from usage here, because a document tree carries fills and | |
| * text runs rather than named variables. When a file does have variables, the | |
| * plugin sends them separately and those win. | |
| */ | |
| namespace Fyoo\FigmaBridge; | |
| class Figma | |
| { | |
| private array $document; | |
| private array $textNodes = []; | |
| private array $fillCounts = []; | |
| private array $radii = []; | |
| private array $shadows = []; | |
| private ?array $pageFrame = null; | |
| public function __construct(array $document) | |
| { | |
| // Accept a whole REST response, a plugin payload, or a bare node. | |
| $this->document = $document['document'] ?? $document; | |
| if (isset($this->document['document'])) { | |
| $this->document = $this->document['document']; | |
| } | |
| if (!isset($this->document['children']) && !isset($this->document['type'])) { | |
| throw new \InvalidArgumentException('Not a Figma document: no node tree found.'); | |
| } | |
| $this->document['name'] = $document['name'] ?? $this->document['name'] ?? 'Figma Import'; | |
| $this->walk($this->document, 0); | |
| } | |
| public static function fromJson(string $json): self | |
| { | |
| $data = json_decode($json, true); | |
| if (!is_array($data)) { | |
| throw new \InvalidArgumentException('Figma payload is not valid JSON.'); | |
| } | |
| return new self($data); | |
| } | |
| /** Fetches a file over the REST API with a personal access token. */ | |
| public static function fetch(string $fileKeyOrUrl, string $token): self | |
| { | |
| if (preg_match('~figma\.com/(?:file|design|proto)/([A-Za-z0-9]+)~', $fileKeyOrUrl, $m)) { | |
| $fileKeyOrUrl = $m[1]; | |
| } | |
| if (!preg_match('~^[A-Za-z0-9]{10,}$~', $fileKeyOrUrl)) { | |
| throw new \InvalidArgumentException('Not a Figma file key or URL.'); | |
| } | |
| $context = stream_context_create(['http' => [ | |
| 'method' => 'GET', | |
| 'header' => "X-Figma-Token: $token\r\n", | |
| 'timeout' => 90, | |
| 'ignore_errors' => true, | |
| ]]); | |
| $body = @file_get_contents("https://api.figma.com/v1/files/$fileKeyOrUrl", false, $context); | |
| if ($body === false) { | |
| throw new \RuntimeException('Could not reach the Figma API.'); | |
| } | |
| $data = json_decode($body, true); | |
| if (!is_array($data)) { | |
| throw new \RuntimeException('Figma API returned an unreadable response.'); | |
| } | |
| if (isset($data['err']) && $data['err']) { | |
| throw new \RuntimeException('Figma API: ' . $data['err']); | |
| } | |
| return new self($data); | |
| } | |
| public function name(): string | |
| { | |
| return (string) ($this->document['name'] ?? 'Figma Import'); | |
| } | |
| private function walk(array $node, int $depth): void | |
| { | |
| $type = $node['type'] ?? ''; | |
| if ($type === 'TEXT' && isset($node['style']['fontSize'])) { | |
| $this->textNodes[] = $node; | |
| } | |
| // Weighted by area: a colour covering a hero counts for more than one chip. | |
| foreach ($this->solidFills($node) as $color) { | |
| $this->fillCounts[$color] = ($this->fillCounts[$color] ?? 0) + $this->area($node); | |
| } | |
| if (!empty($node['cornerRadius']) && is_numeric($node['cornerRadius'])) { | |
| $radius = (int) $node['cornerRadius']; | |
| $this->radii[$radius] = ($this->radii[$radius] ?? 0) + 1; | |
| } | |
| foreach ($node['effects'] ?? [] as $effect) { | |
| if (($effect['type'] ?? '') === 'DROP_SHADOW' && ($effect['visible'] ?? true)) { | |
| $shadow = self::shadow($effect); | |
| $this->shadows[$shadow] = ($this->shadows[$shadow] ?? 0) + 1; | |
| } | |
| } | |
| if ($depth <= 2 && $type === 'FRAME' && ($node['absoluteBoundingBox']['width'] ?? 0) >= 600) { | |
| if ($this->pageFrame === null || $this->area($node) > $this->area($this->pageFrame)) { | |
| $this->pageFrame = $node; | |
| } | |
| } | |
| foreach ($node['children'] ?? [] as $child) { | |
| $this->walk($child, $depth + 1); | |
| } | |
| } | |
| private function area(array $node): int | |
| { | |
| $box = $node['absoluteBoundingBox'] ?? null; | |
| return $box ? max(1, (int) (($box['width'] ?? 1) * ($box['height'] ?? 1) / 1000)) : 1; | |
| } | |
| /** @return string[] hex colours of a node's visible solid fills */ | |
| private function solidFills(array $node): array | |
| { | |
| $colors = []; | |
| foreach ($node['fills'] ?? [] as $fill) { | |
| if (($fill['type'] ?? '') === 'SOLID' && ($fill['visible'] ?? true)) { | |
| $colors[] = self::hex($fill['color'], $fill['opacity'] ?? 1); | |
| } | |
| } | |
| if (!$colors && isset($node['backgroundColor'])) { | |
| $colors[] = self::hex($node['backgroundColor']); | |
| } | |
| return $colors; | |
| } | |
| public static function hex(array $color, float $opacity = 1.0): string | |
| { | |
| $channel = fn(float $value): string => str_pad(dechex((int) round(max(0, min(1, $value)) * 255)), 2, '0', STR_PAD_LEFT); | |
| $hex = '#' . $channel($color['r'] ?? 0) . $channel($color['g'] ?? 0) . $channel($color['b'] ?? 0); | |
| $alpha = ($color['a'] ?? 1) * $opacity; | |
| return strtoupper($alpha >= 0.999 ? $hex : $hex . $channel($alpha)); | |
| } | |
| private static function shadow(array $effect): string | |
| { | |
| return sprintf( | |
| '%dpx %dpx %dpx %dpx %s', | |
| (int) ($effect['offset']['x'] ?? 0), | |
| (int) ($effect['offset']['y'] ?? 0), | |
| (int) ($effect['radius'] ?? 0), | |
| (int) ($effect['spread'] ?? 0), | |
| self::hex($effect['color'] ?? []) | |
| ); | |
| } | |
| public static function luminance(string $hex): float | |
| { | |
| $hex = substr(ltrim($hex, '#'), 0, 6); | |
| if (strlen($hex) < 6) { | |
| return 0.5; | |
| } | |
| [$r, $g, $b] = array_map('hexdec', str_split($hex, 2)); | |
| return (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255; | |
| } | |
| public function tokens(): Tokens | |
| { | |
| arsort($this->fillCounts); | |
| $surface = $this->pageFrame ? ($this->solidFills($this->pageFrame)[0] ?? null) : null; | |
| $color = []; | |
| if ($surface !== null) { | |
| $color['surface'] = $surface; | |
| } | |
| $accents = []; | |
| $lights = []; | |
| foreach (array_keys($this->fillCounts) as $hex) { | |
| if ($hex === $surface || strlen($hex) > 7) { | |
| continue; | |
| } | |
| $luminance = self::luminance($hex); | |
| if ($luminance > 0.06 && $luminance < 0.80) { | |
| $accents[] = $hex; | |
| } elseif ($luminance >= 0.80) { | |
| $lights[] = $hex; | |
| } | |
| } | |
| if (isset($accents[0])) { | |
| $color['brand-primary'] = $accents[0]; | |
| $color['on-primary'] = self::luminance($accents[0]) > 0.55 ? '#000000' : '#FFFFFF'; | |
| } | |
| if (isset($accents[1])) { | |
| $color['brand-secondary'] = $accents[1]; | |
| } | |
| $muted = array_values(array_filter($lights, fn($c) => !in_array($c, ['#FFF', '#FFFFFF'], true))); | |
| if (isset($muted[0])) { | |
| $color['surface-muted'] = $muted[0]; | |
| } | |
| $textColors = []; | |
| foreach ($this->textNodes as $node) { | |
| foreach ($this->solidFills($node) as $hex) { | |
| $bucket = (float) $node['style']['fontSize'] <= 20 ? 'text' : 'heading'; | |
| $textColors[$bucket][$hex] = ($textColors[$bucket][$hex] ?? 0) + 1; | |
| } | |
| } | |
| foreach (['text', 'heading'] as $bucket) { | |
| if (!empty($textColors[$bucket])) { | |
| arsort($textColors[$bucket]); | |
| $color[$bucket] = (string) array_key_first($textColors[$bucket]); | |
| } | |
| } | |
| $typography = $this->typography(); | |
| arsort($this->radii); | |
| $radius = $this->radii ? ['card' => array_key_first($this->radii) . 'px'] : []; | |
| $shadowList = array_keys($this->shadows); | |
| usort($shadowList, fn($a, $b) => strlen($a) <=> strlen($b)); | |
| $shadow = []; | |
| foreach (['shadow-md', 'shadow-lg', 'shadow-xl'] as $index => $key) { | |
| if (isset($shadowList[$index])) { | |
| $shadow[$key] = $shadowList[$index]; | |
| } | |
| } | |
| $spacing = []; | |
| if ($this->pageFrame) { | |
| if ($width = (int) ($this->pageFrame['absoluteBoundingBox']['width'] ?? 0)) { | |
| $spacing['container'] = min($width, 1600) . 'px'; | |
| } | |
| if ($padding = (int) ($this->pageFrame['paddingTop'] ?? 0)) { | |
| $spacing['section'] = $padding . 'px'; | |
| } | |
| } | |
| return new Tokens(Tokens::validate([ | |
| 'name' => $this->name(), | |
| 'source' => 'figma', | |
| 'version' => '0.1', | |
| 'generatedAt' => gmdate('c'), | |
| 'tokens' => [ | |
| 'color' => $color, | |
| 'typography' => $typography, | |
| 'spacing' => $spacing, | |
| 'radius' => $radius, | |
| 'shadow' => $shadow, | |
| ], | |
| 'components' => $this->components(), | |
| 'assets' => [], | |
| ])); | |
| } | |
| /** The most-used text run is the body copy; every larger size is a heading step. */ | |
| private function typography(): array | |
| { | |
| $styles = []; | |
| foreach ($this->textNodes as $node) { | |
| $style = $node['style']; | |
| $size = (int) round((float) $style['fontSize']); | |
| $key = $size . '/' . ($style['fontWeight'] ?? 400); | |
| if (!isset($styles[$key])) { | |
| $lineHeight = isset($style['lineHeightPercentFontSize']) | |
| ? round($style['lineHeightPercentFontSize'] / 100, 3) | |
| : (isset($style['lineHeightPx']) ? round($style['lineHeightPx'] / max(1, $size), 3) : 1.5); | |
| $styles[$key] = [ | |
| 'size' => $size, | |
| 'count' => 0, | |
| 'style' => array_filter([ | |
| 'fontFamily' => $style['fontFamily'] ?? null, | |
| 'fontSize' => $size . 'px', | |
| 'fontWeight' => isset($style['fontWeight']) ? (string) (int) $style['fontWeight'] : null, | |
| 'lineHeight' => (string) $lineHeight, | |
| 'letterSpacing' => isset($style['letterSpacing']) && abs((float) $style['letterSpacing']) > 0.01 | |
| ? round((float) $style['letterSpacing'] / max(1, $size), 3) . 'em' | |
| : null, | |
| ]), | |
| ]; | |
| } | |
| $styles[$key]['count']++; | |
| } | |
| if (!$styles) { | |
| return []; | |
| } | |
| uasort($styles, fn($a, $b) => $b['count'] <=> $a['count']); | |
| $body = reset($styles); | |
| $typography = ['body' => $body['style']]; | |
| $headings = array_values(array_filter($styles, fn($s) => $s['size'] > $body['size'])); | |
| usort($headings, fn($a, $b) => $b['size'] <=> $a['size']); | |
| $names = ['heading-xl', 'heading-lg', 'heading-md', 'heading-sm', 'heading-xs', 'heading-2xs']; | |
| foreach (array_slice($headings, 0, 6) as $index => $heading) { | |
| $typography[$names[$index]] = $heading['style']; | |
| } | |
| return $typography; | |
| } | |
| /** Component and instance names, so the mapper can bind them to YOOtheme elements. */ | |
| private function components(): array | |
| { | |
| $names = []; | |
| $collect = function (array $node) use (&$collect, &$names): void { | |
| if (in_array($node['type'] ?? '', ['COMPONENT', 'COMPONENT_SET', 'INSTANCE'], true)) { | |
| $names[$node['name']] = ['role' => '', 'target' => '', 'overrides' => []]; | |
| } | |
| foreach ($node['children'] ?? [] as $child) { | |
| $collect($child); | |
| } | |
| }; | |
| $collect($this->document); | |
| return array_slice($names, 0, 100, true); | |
| } | |
| /** Top-level frames wide enough to be pages, in canvas order. */ | |
| public function frames(): array | |
| { | |
| $frames = []; | |
| foreach ($this->document['children'] ?? [] as $canvas) { | |
| $nodes = ($canvas['type'] ?? '') === 'CANVAS' ? ($canvas['children'] ?? []) : [$canvas]; | |
| foreach ($nodes as $node) { | |
| if (($node['type'] ?? '') === 'FRAME' && ($node['absoluteBoundingBox']['width'] ?? 0) >= 600) { | |
| $frames[] = $node; | |
| } | |
| } | |
| } | |
| if (!$frames && ($this->document['type'] ?? '') === 'FRAME') { | |
| $frames[] = $this->document; | |
| } | |
| return $frames; | |
| } | |
| } | |