From 4d204dd1a0e388dd7ce1f6fc1d8842d97089089f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 16:13:05 -0400 Subject: [PATCH 01/31] Add figma transformer package scaffold --- README.md | 1 + docs/contracts/figma-transformer-result.md | 18 ++ figma-transformer/README.md | 78 +++++++ figma-transformer/bin/figma-transformer | 27 +++ figma-transformer/composer.json | 55 +++++ figma-transformer/figma-transformer.php | 112 ++++++++++ .../src/Contract/FigmaTransformResult.php | 69 ++++++ .../src/FigFile/FigArchiveReader.php | 205 ++++++++++++++++++ figma-transformer/src/FigmaTransformer.php | 95 ++++++++ .../src/Html/StaticHtmlEmitter.php | 83 +++++++ .../src/Parity/ParityReportBuilder.php | 32 +++ figma-transformer/tests/contract/run.php | 34 +++ 12 files changed, 809 insertions(+) create mode 100644 docs/contracts/figma-transformer-result.md create mode 100644 figma-transformer/README.md create mode 100755 figma-transformer/bin/figma-transformer create mode 100644 figma-transformer/composer.json create mode 100644 figma-transformer/figma-transformer.php create mode 100644 figma-transformer/src/Contract/FigmaTransformResult.php create mode 100644 figma-transformer/src/FigFile/FigArchiveReader.php create mode 100644 figma-transformer/src/FigmaTransformer.php create mode 100644 figma-transformer/src/Html/StaticHtmlEmitter.php create mode 100644 figma-transformer/src/Parity/ParityReportBuilder.php create mode 100644 figma-transformer/tests/contract/run.php diff --git a/README.md b/README.md index 15ccbc7..065acc8 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,4 @@ Blocks Engine is a collection of tools for generating, transforming, and materia ## Packages - [`php-transformer`](php-transformer/) - PHP primitives for converting HTML, Markdown, and generated website artifacts into WordPress-native block outputs. +- [`figma-transformer`](figma-transformer/) - PHP primitives for converting Figma `.fig` archives and Figma-derived scenegraphs into static HTML website artifacts with parity diagnostics. diff --git a/docs/contracts/figma-transformer-result.md b/docs/contracts/figma-transformer-result.md new file mode 100644 index 0000000..2c2c124 --- /dev/null +++ b/docs/contracts/figma-transformer-result.md @@ -0,0 +1,18 @@ +# Figma Transformer Result Contract + +`figma-transformer` returns `blocks-engine/figma-transformer/result/v1` envelopes. + +The contract is intentionally static-HTML-first. Downstream products can pass generated HTML to `php-transformer`, Static Site Importer, Studio, or any other materialization layer. + +Required top-level fields: + +- `schema`: `blocks-engine/figma-transformer/result/v1` +- `status`: `success`, `success_with_warnings`, or `failed` +- `diagnostics`: stable diagnostic records +- `files`: generated artifact files such as `index.html` and `style.css` +- `assets`: generated or extracted assets +- `source_reports.figma`: Figma intake, scenegraph, and provenance reports +- `parity`: `blocks-engine/figma-transformer/parity-report/v1` +- `metrics`: package-level transform metrics + +The parity report records source screenshot evidence, generated HTML screenshot evidence, side-by-side output, diff output, and runner-supplied metrics. Browser-heavy parity runners should persist artifacts through Homeboy or another reviewable artifact surface. diff --git a/figma-transformer/README.md b/figma-transformer/README.md new file mode 100644 index 0000000..84088fb --- /dev/null +++ b/figma-transformer/README.md @@ -0,0 +1,78 @@ +# Figma Transformer + +Figma Transformer is a PHP primitive for converting Figma designs into static HTML website artifacts. + +This package is intentionally WordPress-native and product-neutral. It owns Figma intake, scenegraph normalization, static HTML artifact generation, and visual-parity report contracts. It does not own WordPress page creation, block conversion, theme activation, Studio orchestration, or Static Site Importer UI. + +## Boundary + +Figma Transformer owns reusable transformation primitives: + +- `.fig` archive intake and safety diagnostics. +- Figma `fig-kiwi` archive metadata parsing. +- Figma scenegraph normalization. +- Static HTML, CSS, and asset artifact generation. +- Figma-vs-HTML visual parity report contracts. +- Generic provenance metadata that lets callers trace HTML back to Figma nodes. + +Figma Transformer does not convert HTML into WordPress blocks. Use `php-transformer` for HTML, Markdown, and website artifact conversion into WordPress-native block outputs. + +## Public API Surface + +Consumers should treat these classes and helper functions as the public entrypoints for the current package: + +- `FigmaTransformer` - transforms `.fig` files or normalized scenegraph arrays into static HTML result envelopes. +- `Contract\FigmaTransformResult` - stable result envelope for process, HTTP, fixture, and compatibility boundaries. +- `FigFile\FigArchiveReader` - reads safe `.fig` archive metadata and embedded asset manifests. +- `Html\StaticHtmlEmitter` - emits deterministic static HTML artifact files from a normalized scenegraph. +- `Parity\ParityReportBuilder` - builds the parity report envelope from source/generated screenshot evidence and metrics supplied by the caller. + +## WordPress Plugin Usage + +Install or symlink the `figma-transformer/` directory into `wp-content/plugins/blocks-engine-figma-transformer/`, run Composer when available, and activate **Blocks Engine Figma Transformer**. + +```php +$result = blocks_engine_figma_transformer_transform_file('/path/to/design.fig', array( + 'source' => 'upload:design.fig', +)); +``` + +Available plugin helpers: + +- `blocks_engine_figma_transformer_version()` +- `blocks_engine_figma_transformer_path()` +- `blocks_engine_figma_transformer_transform_file()` +- `blocks_engine_figma_transformer_transform_scenegraph()` + +## Current Draft Status + +This package currently scaffolds the public API and `.fig` intake contract. Full pure-PHP Kiwi message decoding, source screenshot rendering, and browser-backed visual parity scoring are planned behind the contracts introduced here. + +The first target fixture is a local `.fig_.zip` export containing `Fisiostetic.fig`, whose inner `canvas.fig` starts with `fig-kiwi` and uses a raw-deflate schema chunk plus a Zstandard-compressed message chunk. + +## Output Contract + +Successful transforms produce a static website artifact: + +```text +artifact/ + index.html + style.css + assets/ + parity-report.json +``` + +The result envelope includes: + +- `schema: "blocks-engine/figma-transformer/result/v1"` +- `status` +- `diagnostics[]` +- `files[]` +- `assets[]` +- `source_reports.figma` +- `parity` +- `metrics` + +## Parity Contract + +Visual parity is measured outside the WordPress import path. The package records source and generated screenshot evidence, side-by-side comparisons, diff images, and metrics supplied by the parity runner. Homeboy is the expected runner for browser-heavy parity workflows; WordPress-only consumers can still read and display the parity report. diff --git a/figma-transformer/bin/figma-transformer b/figma-transformer/bin/figma-transformer new file mode 100755 index 0000000..ade4335 --- /dev/null +++ b/figma-transformer/bin/figma-transformer @@ -0,0 +1,27 @@ +#!/usr/bin/env php +\n"); + exit(1); +} + +$transformer = new Automattic\BlocksEngine\FigmaTransformer\FigmaTransformer(); +if ( str_ends_with(strtolower($path), '.json') ) { + $decoded = json_decode((string) file_get_contents($path), true); + $result = $transformer->transformScenegraph(is_array($decoded) ? $decoded : array())->toArray(); +} else { + $result = $transformer->transformFile($path)->toArray(); +} + +fwrite(STDOUT, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); diff --git a/figma-transformer/composer.json b/figma-transformer/composer.json new file mode 100644 index 0000000..f187084 --- /dev/null +++ b/figma-transformer/composer.json @@ -0,0 +1,55 @@ +{ + "name": "automattic/blocks-engine-figma-transformer", + "description": "PHP primitives for transforming Figma designs into static HTML website artifacts.", + "type": "library", + "license": "GPL-3.0-or-later", + "authors": [ + { + "name": "Automattic" + } + ], + "keywords": [ + "figma", + "html", + "wordpress", + "blocks" + ], + "homepage": "https://github.com/Automattic/blocks-engine/tree/trunk/figma-transformer", + "support": { + "issues": "https://github.com/Automattic/blocks-engine/issues", + "source": "https://github.com/Automattic/blocks-engine/tree/trunk/figma-transformer" + }, + "autoload": { + "psr-4": { + "Automattic\\BlocksEngine\\FigmaTransformer\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Automattic\\BlocksEngine\\FigmaTransformer\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.1" + }, + "bin": [ + "bin/figma-transformer" + ], + "scripts": { + "test": [ + "@test:contract" + ], + "test:contract": "php tests/contract/run.php" + }, + "config": { + "platform": { + "php": "8.1.0" + }, + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-trunk": "0.1.x-dev" + } + } +} diff --git a/figma-transformer/figma-transformer.php b/figma-transformer/figma-transformer.php new file mode 100644 index 0000000..3506e93 --- /dev/null +++ b/figma-transformer/figma-transformer.php @@ -0,0 +1,112 @@ + $options Transformation options. + * @return array + */ +function blocks_engine_figma_transformer_transform_file(string $path, array $options = array()): array +{ + return ( new Automattic\BlocksEngine\FigmaTransformer\FigmaTransformer() ) + ->transformFile($path, $options) + ->toArray(); +} + +/** + * Transform a normalized scenegraph into the canonical result envelope. + * + * @param array $scenegraph Normalized Figma scenegraph. + * @param array $options Transformation options. + * @return array + */ +function blocks_engine_figma_transformer_transform_scenegraph(array $scenegraph, array $options = array()): array +{ + return ( new Automattic\BlocksEngine\FigmaTransformer\FigmaTransformer() ) + ->transformScenegraph($scenegraph, $options) + ->toArray(); +} diff --git a/figma-transformer/src/Contract/FigmaTransformResult.php b/figma-transformer/src/Contract/FigmaTransformResult.php new file mode 100644 index 0000000..a67f4de --- /dev/null +++ b/figma-transformer/src/Contract/FigmaTransformResult.php @@ -0,0 +1,69 @@ +> $diagnostics + * @param array> $files + * @param array> $assets + * @param array $sourceReports + * @param array $parity + * @param array $metrics + */ + public function __construct( + private readonly string $status, + private readonly array $diagnostics = array(), + private readonly array $files = array(), + private readonly array $assets = array(), + private readonly array $sourceReports = array(), + private readonly array $parity = array(), + private readonly array $metrics = array() + ) { + } + + /** + * @param array> $diagnostics + * @param array> $files + * @param array> $assets + * @param array $sourceReports + * @param array $parity + * @param array $metrics + */ + public static function create( + string $status, + array $diagnostics = array(), + array $files = array(), + array $assets = array(), + array $sourceReports = array(), + array $parity = array(), + array $metrics = array() + ): self { + return new self($status, $diagnostics, $files, $assets, $sourceReports, $parity, $metrics); + } + + /** + * @return array + */ + public function toArray(): array + { + return array( + 'schema' => self::SCHEMA, + 'status' => $this->status, + 'diagnostics' => $this->diagnostics, + 'files' => $this->files, + 'assets' => $this->assets, + 'source_reports' => $this->sourceReports, + 'parity' => $this->parity, + 'metrics' => $this->metrics, + ); + } +} diff --git a/figma-transformer/src/FigFile/FigArchiveReader.php b/figma-transformer/src/FigFile/FigArchiveReader.php new file mode 100644 index 0000000..8dc25e7 --- /dev/null +++ b/figma-transformer/src/FigFile/FigArchiveReader.php @@ -0,0 +1,205 @@ + + */ + public function read(string $path): array + { + if ( ! is_readable($path) ) { + return $this->errorResult($path, 'figma_transformer_unreadable_file', 'Figma file is not readable.'); + } + + $bytes = filesize($path); + $input = array( + 'path' => $path, + 'bytes' => false === $bytes ? 0 : $bytes, + ); + + if ( ! class_exists(ZipArchive::class) ) { + return array( + 'input' => $input, + 'archive' => array(), + 'meta' => array(), + 'assets' => array(), + 'diagnostics' => array($this->diagnostic('figma_transformer_zip_extension_missing', 'ZipArchive is required to inspect .fig archives.', 'FigArchiveReader')), + ); + } + + $zip = new ZipArchive(); + if ( true !== $zip->open($path) ) { + return $this->errorResult($path, 'figma_transformer_invalid_zip', 'Figma file is not a readable ZIP archive.'); + } + + $entries = $this->entries($zip); + $figEntry = $this->findNestedFigEntry($entries); + + if ( null !== $figEntry ) { + $stream = $zip->getFromName($figEntry); + $zip->close(); + + if ( false === $stream ) { + return $this->errorResult($path, 'figma_transformer_nested_fig_unreadable', 'Nested .fig file could not be read.'); + } + + $tmp = tempnam(sys_get_temp_dir(), 'blocks-engine-figma-'); + if ( false === $tmp ) { + return $this->errorResult($path, 'figma_transformer_tempfile_failed', 'Temporary file could not be created for nested .fig inspection.'); + } + + file_put_contents($tmp, $stream); + $result = $this->read($tmp); + @unlink($tmp); + $result['input'] = $input + array('nested_fig' => $figEntry); + return $result; + } + + $meta = $this->readMeta($zip); + $assets = $this->assetManifest($zip); + $canvas = $this->readCanvasHeader($zip); + $zip->close(); + + $diagnostics = array(); + if ( null === $canvas ) { + $diagnostics[] = $this->diagnostic('figma_transformer_missing_canvas', 'Archive does not contain canvas.fig.', 'FigArchiveReader'); + } elseif ( 'fig-kiwi' === ($canvas['prelude'] ?? '') ) { + $diagnostics[] = $this->diagnostic('figma_transformer_kiwi_decode_pending', 'fig-kiwi archive detected; full pure-PHP Kiwi message decoding is not implemented yet.', 'FigArchiveReader', array('version' => $canvas['version'] ?? null)); + } + + return array( + 'input' => $input, + 'archive' => array( + 'entries' => $entries, + 'canvas' => $canvas, + ), + 'meta' => $meta, + 'assets' => $assets, + 'diagnostics' => $diagnostics, + ); + } + + /** + * @return array + */ + private function entries(ZipArchive $zip): array + { + $entries = array(); + for ( $index = 0; $index < $zip->numFiles; $index++ ) { + $name = $zip->getNameIndex($index); + if ( false !== $name && ! str_starts_with($name, '__MACOSX/') ) { + $entries[] = $name; + } + } + + return $entries; + } + + /** + * @param array $entries + */ + private function findNestedFigEntry(array $entries): ?string + { + foreach ( $entries as $entry ) { + if ( str_ends_with(strtolower($entry), '.fig') && 'canvas.fig' !== $entry ) { + return $entry; + } + } + + return null; + } + + /** + * @return array + */ + private function readMeta(ZipArchive $zip): array + { + $raw = $zip->getFromName('meta.json'); + if ( false === $raw ) { + return array(); + } + + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : array(); + } + + /** + * @return array> + */ + private function assetManifest(ZipArchive $zip): array + { + $assets = array(); + for ( $index = 0; $index < $zip->numFiles; $index++ ) { + $name = $zip->getNameIndex($index); + if ( false === $name || ! str_starts_with($name, 'images/') || str_ends_with($name, '/') ) { + continue; + } + + $stat = $zip->statIndex($index); + $assets[] = array( + 'path' => $name, + 'hash' => basename($name), + 'bytes' => is_array($stat) ? (int) ($stat['size'] ?? 0) : 0, + ); + } + + return $assets; + } + + /** + * @return array|null + */ + private function readCanvasHeader(ZipArchive $zip): ?array + { + $raw = $zip->getFromName('canvas.fig'); + if ( false === $raw || strlen($raw) < 12 ) { + return null; + } + + $prelude = substr($raw, 0, 8); + $version = unpack('V', substr($raw, 8, 4)); + + return array( + 'prelude' => $prelude, + 'version' => is_array($version) ? (int) $version[1] : null, + 'bytes' => strlen($raw), + ); + } + + /** + * @return array + */ + private function errorResult(string $path, string $code, string $message): array + { + return array( + 'input' => array('path' => $path, 'bytes' => 0), + 'archive' => array(), + 'meta' => array(), + 'assets' => array(), + 'diagnostics' => array($this->diagnostic($code, $message, 'FigArchiveReader')), + ); + } + + /** + * @param array $context + * @return array + */ + private function diagnostic(string $code, string $message, string $source, array $context = array()): array + { + return array( + 'code' => $code, + 'message' => $message, + 'source' => $source, + 'context' => $context, + ); + } +} diff --git a/figma-transformer/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php new file mode 100644 index 0000000..567d387 --- /dev/null +++ b/figma-transformer/src/FigmaTransformer.php @@ -0,0 +1,95 @@ + $options Transformation options. + */ + public function transformFile(string $path, array $options = array()): FigmaTransformResult + { + $startedAt = microtime(true); + $archive = $this->archiveReader->read($path); + + $diagnostics = $archive['diagnostics']; + $sourceReports = array( + 'figma' => array( + 'input' => $archive['input'], + 'archive' => $archive['archive'], + 'meta' => $archive['meta'], + ), + ); + + $metrics = array( + 'input_bytes' => $archive['input']['bytes'] ?? 0, + 'embedded_asset_count' => count($archive['assets']), + 'transform_duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + ); + + $parity = $this->parityReportBuilder->build(array(), array( + 'status' => 'not_run', + 'reason' => 'parity_runner_not_invoked', + )); + + return FigmaTransformResult::create( + 'success_with_warnings', + $diagnostics, + array(), + $archive['assets'], + $sourceReports, + $parity, + $metrics + ); + } + + /** + * Transform a normalized scenegraph into static HTML artifact files. + * + * @param array $scenegraph Normalized Figma scenegraph. + * @param array $options Transformation options. + */ + public function transformScenegraph(array $scenegraph, array $options = array()): FigmaTransformResult + { + $startedAt = microtime(true); + $artifact = $this->htmlEmitter->emit($scenegraph, $options); + $parity = $this->parityReportBuilder->build($options['parity'] ?? array()); + + return FigmaTransformResult::create( + $artifact['status'], + $artifact['diagnostics'], + $artifact['files'], + $artifact['assets'], + array( + 'figma' => array( + 'scenegraph' => $artifact['source_report'], + ), + ), + $parity, + array( + 'node_count' => $artifact['metrics']['node_count'] ?? 0, + 'file_count' => count($artifact['files']), + 'transform_duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + ) + ); + } +} diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php new file mode 100644 index 0000000..735d041 --- /dev/null +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -0,0 +1,83 @@ + $scenegraph Normalized Figma scenegraph. + * @param array $options Transformation options. + * @return array + */ + public function emit(array $scenegraph, array $options = array()): array + { + $title = $this->sanitizeText((string) ($scenegraph['name'] ?? 'Figma Site')); + $nodes = is_array($scenegraph['nodes'] ?? null) ? $scenegraph['nodes'] : array(); + + $body = ''; + foreach ( $nodes as $node ) { + if ( ! is_array($node) ) { + continue; + } + $body .= $this->emitNode($node); + } + + $html = "\n\n\n\n\n" . $title . "\n\n\n\n
\n" . $body . "
\n\n\n"; + + return array( + 'status' => 'success', + 'diagnostics' => array(), + 'files' => array( + array( + 'path' => 'index.html', + 'role' => 'entrypoint', + 'mime_type' => 'text/html', + 'content' => $html, + ), + array( + 'path' => 'style.css', + 'role' => 'stylesheet', + 'mime_type' => 'text/css', + 'content' => "body{margin:0;font-family:system-ui,sans-serif}main{display:flex;flex-direction:column}\n[data-figma-node-id]{box-sizing:border-box}\n", + ), + ), + 'assets' => array(), + 'source_report' => array( + 'name' => $title, + 'node_count' => count($nodes), + ), + 'metrics' => array( + 'node_count' => count($nodes), + ), + ); + } + + /** + * @param array $node + */ + private function emitNode(array $node): string + { + $id = $this->sanitizeAttribute((string) ($node['id'] ?? '')); + $name = $this->sanitizeAttribute((string) ($node['name'] ?? '')); + $text = $this->sanitizeText((string) ($node['text'] ?? $node['name'] ?? '')); + $type = strtoupper((string) ($node['type'] ?? 'FRAME')); + $tag = 'TEXT' === $type ? 'p' : 'section'; + + return sprintf("<%1\$s data-figma-node-id=\"%2\$s\" data-figma-name=\"%3\$s\">%4\$s\n", $tag, $id, $name, $text); + } + + private function sanitizeText(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private function sanitizeAttribute(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/figma-transformer/src/Parity/ParityReportBuilder.php b/figma-transformer/src/Parity/ParityReportBuilder.php new file mode 100644 index 0000000..3fe5080 --- /dev/null +++ b/figma-transformer/src/Parity/ParityReportBuilder.php @@ -0,0 +1,32 @@ + $evidence + * @param array $overrides + * @return array + */ + public function build(array $evidence = array(), array $overrides = array()): array + { + return array( + 'schema' => self::SCHEMA, + 'status' => (string) ($overrides['status'] ?? $evidence['status'] ?? 'not_run'), + 'reason' => (string) ($overrides['reason'] ?? $evidence['reason'] ?? ''), + 'source' => $evidence['source'] ?? array(), + 'generated' => $evidence['generated'] ?? array(), + 'side_by_side' => $evidence['side_by_side'] ?? null, + 'diff' => $evidence['diff'] ?? null, + 'metrics' => $evidence['metrics'] ?? array(), + ); + } +} diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php new file mode 100644 index 0000000..036749a --- /dev/null +++ b/figma-transformer/tests/contract/run.php @@ -0,0 +1,34 @@ + 'Fixture Site', + 'nodes' => array( + array('id' => '1:2', 'type' => 'TEXT', 'name' => 'Hero title', 'text' => 'Hello Figma'), + array('id' => '1:3', 'type' => 'FRAME', 'name' => 'Hero section'), + ), +)); + +$assert('blocks-engine/figma-transformer/result/v1' === ($result['schema'] ?? null), 'result-schema'); +$assert('success' === ($result['status'] ?? null), 'scenegraph-transform-success'); +$assert(2 === ($result['metrics']['node_count'] ?? null), 'node-count'); +$assert(str_contains((string) ($result['files'][0]['content'] ?? ''), 'Hello Figma'), 'html-contains-text'); +$assert('blocks-engine/figma-transformer/parity-report/v1' === ($result['parity']['schema'] ?? null), 'parity-schema'); + +if ( ! empty($failures) ) { + fwrite(STDERR, "Figma Transformer contract failures:\n- " . implode("\n- ", $failures) . "\n"); + exit(1); +} + +fwrite(STDOUT, "Figma Transformer contract tests passed.\n"); From a4fe3f796ddf257d963322ceb0388b817f88c675 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 16:42:14 -0400 Subject: [PATCH 02/31] Add fig-kiwi archive parsing diagnostics --- .../src/Compression/ZstdCapability.php | 38 +++++ .../src/FigFile/FigArchiveReader.php | 32 ++-- .../src/FigFile/FigKiwiParser.php | 147 ++++++++++++++++++ figma-transformer/tests/contract/run.php | 65 ++++++++ 4 files changed, 266 insertions(+), 16 deletions(-) create mode 100644 figma-transformer/src/Compression/ZstdCapability.php create mode 100644 figma-transformer/src/FigFile/FigKiwiParser.php diff --git a/figma-transformer/src/Compression/ZstdCapability.php b/figma-transformer/src/Compression/ZstdCapability.php new file mode 100644 index 0000000..b8bbab6 --- /dev/null +++ b/figma-transformer/src/Compression/ZstdCapability.php @@ -0,0 +1,38 @@ + + */ + public function diagnostic(string $source, int $chunkIndex): array + { + if ( $this->isAvailable() ) { + return array( + 'code' => 'figma_transformer_zstd_available', + 'message' => 'Zstandard chunk detected and ext-zstd is available.', + 'source' => $source, + 'context' => array('chunk_index' => $chunkIndex), + ); + } + + return array( + 'code' => 'figma_transformer_zstd_extension_missing', + 'message' => 'Zstandard chunk detected; install ext-zstd to decode zstd-compressed fig-kiwi chunks.', + 'source' => $source, + 'context' => array('chunk_index' => $chunkIndex), + ); + } +} diff --git a/figma-transformer/src/FigFile/FigArchiveReader.php b/figma-transformer/src/FigFile/FigArchiveReader.php index 8dc25e7..e462fa9 100644 --- a/figma-transformer/src/FigFile/FigArchiveReader.php +++ b/figma-transformer/src/FigFile/FigArchiveReader.php @@ -11,6 +11,11 @@ */ final class FigArchiveReader { + public function __construct( + private readonly FigKiwiParser $figKiwiParser = new FigKiwiParser() + ) { + } + /** * @return array */ @@ -66,14 +71,13 @@ public function read(string $path): array $meta = $this->readMeta($zip); $assets = $this->assetManifest($zip); - $canvas = $this->readCanvasHeader($zip); + $canvasResult = $this->readCanvas($zip); + $canvas = $canvasResult['canvas']; $zip->close(); - $diagnostics = array(); + $diagnostics = $canvasResult['diagnostics']; if ( null === $canvas ) { $diagnostics[] = $this->diagnostic('figma_transformer_missing_canvas', 'Archive does not contain canvas.fig.', 'FigArchiveReader'); - } elseif ( 'fig-kiwi' === ($canvas['prelude'] ?? '') ) { - $diagnostics[] = $this->diagnostic('figma_transformer_kiwi_decode_pending', 'fig-kiwi archive detected; full pure-PHP Kiwi message decoding is not implemented yet.', 'FigArchiveReader', array('version' => $canvas['version'] ?? null)); } return array( @@ -156,23 +160,19 @@ private function assetManifest(ZipArchive $zip): array } /** - * @return array|null + * @return array{canvas: array|null, diagnostics: array>} */ - private function readCanvasHeader(ZipArchive $zip): ?array + private function readCanvas(ZipArchive $zip): array { $raw = $zip->getFromName('canvas.fig'); - if ( false === $raw || strlen($raw) < 12 ) { - return null; + if ( false === $raw ) { + return array( + 'canvas' => null, + 'diagnostics' => array(), + ); } - $prelude = substr($raw, 0, 8); - $version = unpack('V', substr($raw, 8, 4)); - - return array( - 'prelude' => $prelude, - 'version' => is_array($version) ? (int) $version[1] : null, - 'bytes' => strlen($raw), - ); + return $this->figKiwiParser->parse($raw); } /** diff --git a/figma-transformer/src/FigFile/FigKiwiParser.php b/figma-transformer/src/FigFile/FigKiwiParser.php new file mode 100644 index 0000000..6be80f5 --- /dev/null +++ b/figma-transformer/src/FigFile/FigKiwiParser.php @@ -0,0 +1,147 @@ +|null, diagnostics: array>} + */ + public function parse(string $raw): array + { + if ( strlen($raw) < 12 ) { + return array( + 'canvas' => null, + 'diagnostics' => array($this->diagnostic('figma_transformer_canvas_too_short', 'canvas.fig is too short to contain a fig-kiwi header.')), + ); + } + + $prelude = substr($raw, 0, 8); + $version = $this->uint32(substr($raw, 8, 4)); + + $canvas = array( + 'prelude' => $prelude, + 'version' => $version, + 'bytes' => strlen($raw), + ); + + if ( self::PRELUDE !== $prelude ) { + return array( + 'canvas' => $canvas, + 'diagnostics' => array(), + ); + } + + $chunks = array(); + $diagnostics = array(); + $offset = 12; + $index = 0; + $totalBytes = strlen($raw); + + while ( $offset < $totalBytes ) { + if ( $offset + 4 > $totalBytes ) { + $diagnostics[] = $this->diagnostic('figma_transformer_kiwi_truncated_chunk_table', 'fig-kiwi chunk table ends with an incomplete chunk length.', array('offset' => $offset)); + break; + } + + $compressedBytes = $this->uint32(substr($raw, $offset, 4)); + $dataOffset = $offset + 4; + $nextOffset = $dataOffset + $compressedBytes; + + if ( $nextOffset > $totalBytes ) { + $diagnostics[] = $this->diagnostic( + 'figma_transformer_kiwi_truncated_chunk', + 'fig-kiwi chunk length exceeds the available canvas.fig bytes.', + array( + 'chunk_index' => $index, + 'offset' => $offset, + 'compressed_bytes' => $compressedBytes, + ) + ); + break; + } + + $payload = substr($raw, $dataOffset, $compressedBytes); + $chunk = array( + 'index' => $index, + 'offset' => $offset, + 'data_offset' => $dataOffset, + 'compressed_bytes' => $compressedBytes, + 'compression' => $this->detectCompression($payload), + ); + + if ( 'zlib' === $chunk['compression'] ) { + $inflated = @gzinflate($payload); + if ( false === $inflated ) { + $diagnostics[] = $this->diagnostic('figma_transformer_kiwi_zlib_inflate_failed', 'zlib-compressed fig-kiwi chunk could not be inflated.', array('chunk_index' => $index)); + } else { + $chunk['inflated_bytes'] = strlen($inflated); + $chunk['inflated_preview_hex'] = bin2hex(substr($inflated, 0, 32)); + } + } elseif ( 'zstd' === $chunk['compression'] ) { + $diagnostics[] = $this->zstdCapability->diagnostic('FigKiwiParser', $index); + } else { + $diagnostics[] = $this->diagnostic('figma_transformer_kiwi_unknown_compression', 'fig-kiwi chunk compression could not be identified.', array('chunk_index' => $index)); + } + + $chunks[] = $chunk; + $offset = $nextOffset; + $index++; + } + + $canvas['chunks'] = $chunks; + + return array( + 'canvas' => $canvas, + 'diagnostics' => $diagnostics, + ); + } + + private function uint32(string $bytes): int + { + $value = unpack('V', $bytes); + return is_array($value) ? (int) $value[1] : 0; + } + + private function detectCompression(string $payload): string + { + if ( str_starts_with($payload, self::ZSTD_MAGIC) ) { + return 'zstd'; + } + + if ( false !== @gzinflate($payload) ) { + return 'zlib'; + } + + return 'unknown'; + } + + /** + * @param array $context + * @return array + */ + private function diagnostic(string $code, string $message, array $context = array()): array + { + return array( + 'code' => $code, + 'message' => $message, + 'source' => 'FigKiwiParser', + 'context' => $context, + ); + } +} diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 036749a..415051a 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -4,6 +4,8 @@ require_once __DIR__ . '/../../figma-transformer.php'; +use Automattic\BlocksEngine\FigmaTransformer\Compression\ZstdCapability; + $failures = array(); $assert = static function (bool $condition, string $message) use (&$failures): void { @@ -26,9 +28,72 @@ $assert(str_contains((string) ($result['files'][0]['content'] ?? ''), 'Hello Figma'), 'html-contains-text'); $assert('blocks-engine/figma-transformer/parity-report/v1' === ($result['parity']['schema'] ?? null), 'parity-schema'); +$fixture = blocks_engine_figma_transformer_create_fig_wrapper_fixture(); +$fileResult = blocks_engine_figma_transformer_transform_file($fixture); +@unlink($fixture); + +$canvas = $fileResult['source_reports']['figma']['archive']['canvas'] ?? array(); +$chunks = $canvas['chunks'] ?? array(); +$diagnosticCodes = array_map( + static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), + $fileResult['diagnostics'] ?? array() +); +$zstdCapabilityCode = ( new ZstdCapability() )->isAvailable() + ? 'figma_transformer_zstd_available' + : 'figma_transformer_zstd_extension_missing'; + +$assert('success_with_warnings' === ($fileResult['status'] ?? null), 'file-transform-status'); +$assert('fig-kiwi' === ($canvas['prelude'] ?? null), 'fig-kiwi-prelude'); +$assert(106 === ($canvas['version'] ?? null), 'fig-kiwi-version'); +$assert('inner.fig' === ($fileResult['source_reports']['figma']['input']['nested_fig'] ?? null), 'wrapper-nested-fig'); +$assert(2 === count($chunks), 'fig-kiwi-chunk-count'); +$assert('zlib' === ($chunks[0]['compression'] ?? null), 'fig-kiwi-first-chunk-zlib'); +$assert(strlen('synthetic kiwi dictionary') === ($chunks[0]['inflated_bytes'] ?? null), 'fig-kiwi-first-chunk-inflated'); +$assert('zstd' === ($chunks[1]['compression'] ?? null), 'fig-kiwi-second-chunk-zstd'); +$assert(in_array($zstdCapabilityCode, $diagnosticCodes, true), 'fig-kiwi-zstd-capability-diagnostic'); + if ( ! empty($failures) ) { fwrite(STDERR, "Figma Transformer contract failures:\n- " . implode("\n- ", $failures) . "\n"); exit(1); } fwrite(STDOUT, "Figma Transformer contract tests passed.\n"); + +function blocks_engine_figma_transformer_create_fig_wrapper_fixture(): string +{ + $inner = tempnam(sys_get_temp_dir(), 'blocks-engine-inner-fig-'); + $outer = tempnam(sys_get_temp_dir(), 'blocks-engine-wrapper-fig-'); + if ( false === $inner || false === $outer ) { + throw new RuntimeException('Could not create temporary fig fixture paths.'); + } + + $canvas = 'fig-kiwi' + . pack('V', 106) + . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate('synthetic kiwi dictionary')) + . blocks_engine_figma_transformer_kiwi_chunk("\x28\xb5\x2f\xfd" . 'synthetic-zstd-frame'); + + $innerZip = new ZipArchive(); + if ( true !== $innerZip->open($inner, ZipArchive::OVERWRITE) ) { + throw new RuntimeException('Could not open inner fig ZIP.'); + } + $innerZip->addFromString('canvas.fig', $canvas); + $innerZip->addFromString('meta.json', json_encode(array('name' => 'Synthetic Fixture'), JSON_THROW_ON_ERROR)); + $innerZip->addFromString('images/synthetic', 'asset'); + $innerZip->close(); + + $outerZip = new ZipArchive(); + if ( true !== $outerZip->open($outer, ZipArchive::OVERWRITE) ) { + throw new RuntimeException('Could not open wrapper fig ZIP.'); + } + $outerZip->addFromString('inner.fig', (string) file_get_contents($inner)); + $outerZip->close(); + + @unlink($inner); + + return $outer; +} + +function blocks_engine_figma_transformer_kiwi_chunk(string $payload): string +{ + return pack('V', strlen($payload)) . $payload; +} From 80456bd80dbc553ffb17c9b0e263fcaa95b4ca06 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 16:39:46 -0400 Subject: [PATCH 03/31] Add Figma scenegraph normalization primitives --- figma-transformer/src/FigmaTransformer.php | 23 +- .../src/Html/StaticHtmlEmitter.php | 14 +- .../src/Scenegraph/ScenegraphIndex.php | 231 ++++++++++++++++++ .../src/Scenegraph/ScenegraphNormalizer.php | 192 +++++++++++++++ figma-transformer/tests/contract/run.php | 49 ++++ 5 files changed, 498 insertions(+), 11 deletions(-) create mode 100644 figma-transformer/src/Scenegraph/ScenegraphIndex.php create mode 100644 figma-transformer/src/Scenegraph/ScenegraphNormalizer.php diff --git a/figma-transformer/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php index 567d387..fbef457 100644 --- a/figma-transformer/src/FigmaTransformer.php +++ b/figma-transformer/src/FigmaTransformer.php @@ -8,6 +8,7 @@ use Automattic\BlocksEngine\FigmaTransformer\FigFile\FigArchiveReader; use Automattic\BlocksEngine\FigmaTransformer\Html\StaticHtmlEmitter; use Automattic\BlocksEngine\FigmaTransformer\Parity\ParityReportBuilder; +use Automattic\BlocksEngine\FigmaTransformer\Scenegraph\ScenegraphNormalizer; /** * Public Figma transformation entrypoint. @@ -17,7 +18,8 @@ final class FigmaTransformer public function __construct( private readonly FigArchiveReader $archiveReader = new FigArchiveReader(), private readonly StaticHtmlEmitter $htmlEmitter = new StaticHtmlEmitter(), - private readonly ParityReportBuilder $parityReportBuilder = new ParityReportBuilder() + private readonly ParityReportBuilder $parityReportBuilder = new ParityReportBuilder(), + private readonly ScenegraphNormalizer $scenegraphNormalizer = new ScenegraphNormalizer() ) { } @@ -63,30 +65,35 @@ public function transformFile(string $path, array $options = array()): FigmaTran } /** - * Transform a normalized scenegraph into static HTML artifact files. + * Transform a decoded Figma scenegraph into static HTML artifact files. * - * @param array $scenegraph Normalized Figma scenegraph. + * @param array $scenegraph Decoded Figma scenegraph or NODE_CHANGES payload. * @param array $options Transformation options. */ public function transformScenegraph(array $scenegraph, array $options = array()): FigmaTransformResult { $startedAt = microtime(true); - $artifact = $this->htmlEmitter->emit($scenegraph, $options); - $parity = $this->parityReportBuilder->build($options['parity'] ?? array()); + $normalized = $this->scenegraphNormalizer->normalize($scenegraph, $options); + $artifact = $this->htmlEmitter->emit($normalized, $options); + $diagnostics = array_merge($normalized['diagnostics'] ?? array(), $artifact['diagnostics']); + $parity = $this->parityReportBuilder->build($options['parity'] ?? array()); return FigmaTransformResult::create( $artifact['status'], - $artifact['diagnostics'], + $diagnostics, $artifact['files'], $artifact['assets'], array( 'figma' => array( - 'scenegraph' => $artifact['source_report'], + 'scenegraph' => $normalized['source_report'], + 'html' => $artifact['source_report'], ), ), $parity, array( - 'node_count' => $artifact['metrics']['node_count'] ?? 0, + 'node_count' => $normalized['source_report']['node_count'] ?? 0, + 'text_node_count' => count($normalized['text_inventory'] ?? array()), + 'asset_reference_count' => count($normalized['asset_references'] ?? array()), 'file_count' => count($artifact['files']), 'transform_duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), ) diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index 735d041..7aedc71 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -46,10 +46,11 @@ public function emit(array $scenegraph, array $options = array()): array 'content' => "body{margin:0;font-family:system-ui,sans-serif}main{display:flex;flex-direction:column}\n[data-figma-node-id]{box-sizing:border-box}\n", ), ), - 'assets' => array(), + 'assets' => $scenegraph['asset_references'] ?? array(), 'source_report' => array( 'name' => $title, 'node_count' => count($nodes), + 'schema' => $scenegraph['schema'] ?? null, ), 'metrics' => array( 'node_count' => count($nodes), @@ -64,11 +65,18 @@ private function emitNode(array $node): string { $id = $this->sanitizeAttribute((string) ($node['id'] ?? '')); $name = $this->sanitizeAttribute((string) ($node['name'] ?? '')); - $text = $this->sanitizeText((string) ($node['text'] ?? $node['name'] ?? '')); + $text = $this->sanitizeText((string) ($node['characters'] ?? $node['text'] ?? $node['name'] ?? '')); $type = strtoupper((string) ($node['type'] ?? 'FRAME')); $tag = 'TEXT' === $type ? 'p' : 'section'; - return sprintf("<%1\$s data-figma-node-id=\"%2\$s\" data-figma-name=\"%3\$s\">%4\$s\n", $tag, $id, $name, $text); + $children = ''; + foreach ( $node['children'] ?? array() as $child ) { + if ( is_array($child) ) { + $children .= $this->emitNode($child); + } + } + + return sprintf("<%1\$s data-figma-node-id=\"%2\$s\" data-figma-name=\"%3\$s\">%4\$s%5\$s\n", $tag, $id, $name, $text, $children); } private function sanitizeText(string $text): string diff --git a/figma-transformer/src/Scenegraph/ScenegraphIndex.php b/figma-transformer/src/Scenegraph/ScenegraphIndex.php new file mode 100644 index 0000000..9dff03a --- /dev/null +++ b/figma-transformer/src/Scenegraph/ScenegraphIndex.php @@ -0,0 +1,231 @@ + $source Decoded NODE_CHANGES-shaped source array. + * @return array + */ + public function build(array $source): array + { + $diagnostics = array(); + $rawNodes = array(); + $roots = $this->extractRootNodes($source); + + foreach ( $roots as $key => $root ) { + if ( is_array($root) ) { + $this->collectNode($root, is_string($key) ? $key : null, null, $rawNodes, $diagnostics); + } + } + + ksort($rawNodes, SORT_NATURAL); + + $nodeMap = array(); + $parentIndex = array(); + $childrenIndex = array(); + + foreach ( $rawNodes as $id => $entry ) { + $node = $entry['node']; + $parent = $entry['parent']; + + $node['id'] = $id; + $node['children'] = array(); + + $nodeMap[$id] = $node; + $parentIndex[$id] = $parent; + + if ( null !== $parent ) { + $childrenIndex[$parent] ??= array(); + $childrenIndex[$parent][] = $id; + } + } + + foreach ( $nodeMap as $id => $_node ) { + $childrenIndex[$id] ??= array(); + } + + foreach ( $childrenIndex as $parent => $children ) { + $childrenIndex[$parent] = $this->sortNodeIds($children, $nodeMap); + } + + foreach ( $childrenIndex as $parent => $children ) { + if ( ! isset($nodeMap[$parent]) ) { + continue; + } + + foreach ( $children as $childId ) { + if ( isset($nodeMap[$childId]) ) { + $nodeMap[$parent]['children'][] = $nodeMap[$childId]; + } + } + } + + $topLevelNodeIds = array(); + foreach ( $parentIndex as $id => $parent ) { + if ( null === $parent || ! isset($nodeMap[$parent]) ) { + $topLevelNodeIds[] = $id; + } + } + $topLevelNodeIds = $this->sortNodeIds($topLevelNodeIds, $nodeMap); + + return array( + 'nodes' => $nodeMap, + 'parent_index' => $parentIndex, + 'children_index' => $childrenIndex, + 'top_level_node_ids' => $topLevelNodeIds, + 'diagnostics' => $diagnostics, + ); + } + + /** + * @param array $source + * @return array + */ + private function extractRootNodes(array $source): array + { + foreach ( array('NODE_CHANGES', 'node_changes', 'nodeChanges') as $key ) { + if ( is_array($source[$key] ?? null) ) { + return $source[$key]; + } + } + + if ( is_array($source['document'] ?? null) ) { + return array($source['document']); + } + + if ( is_array($source['nodes'] ?? null) ) { + return $source['nodes']; + } + + return $source; + } + + /** + * @param array $value + * @param array, parent: ?string}> $rawNodes + * @param array> $diagnostics + */ + private function collectNode(array $value, ?string $fallbackId, ?string $parentId, array &$rawNodes, array &$diagnostics): void + { + $node = $this->unwrapNodeChange($value); + if ( null === $node ) { + $diagnostics[] = array( + 'code' => 'scenegraph_node_missing', + 'message' => 'Skipped a node change without a node payload.', + ); + return; + } + + $id = $this->readString($node, array('id', 'node_id', 'nodeId')) ?? $fallbackId; + if ( null === $id || '' === $id ) { + $diagnostics[] = array( + 'code' => 'scenegraph_node_id_missing', + 'message' => 'Skipped a node without a stable id.', + ); + return; + } + + $explicitParent = $this->readString($node, array('parent', 'parentId', 'parent_id')); + $effectiveParent = $explicitParent ?? $parentId; + + $children = array(); + if ( is_array($node['children'] ?? null) ) { + $children = $node['children']; + } + + unset($node['children']); + $rawNodes[$id] = array( + 'node' => $node, + 'parent' => $effectiveParent, + ); + + foreach ( $children as $key => $child ) { + if ( is_array($child) ) { + $this->collectNode($child, is_string($key) ? $key : null, $id, $rawNodes, $diagnostics); + } + } + } + + /** + * @param array $value + * @return ?array + */ + private function unwrapNodeChange(array $value): ?array + { + foreach ( array('node', 'document', 'newValue', 'value') as $key ) { + if ( is_array($value[$key] ?? null) ) { + return $value[$key]; + } + } + + if ( isset($value['type']) || isset($value['id']) || isset($value['children']) ) { + return $value; + } + + return null; + } + + /** + * @param array $node + * @param array $keys + */ + private function readString(array $node, array $keys): ?string + { + foreach ( $keys as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) ) { + return (string) $node[$key]; + } + } + + return null; + } + + /** + * @param array $ids + * @param array> $nodeMap + * @return array + */ + private function sortNodeIds(array $ids, array $nodeMap): array + { + usort( + $ids, + static function (string $left, string $right) use ($nodeMap): int { + $leftNode = $nodeMap[$left] ?? array(); + $rightNode = $nodeMap[$right] ?? array(); + + $leftBox = self::readBounds($leftNode); + $rightBox = self::readBounds($rightNode); + + return array($leftBox['y'], $leftBox['x'], (string) ($leftNode['name'] ?? ''), $left) + <=> array($rightBox['y'], $rightBox['x'], (string) ($rightNode['name'] ?? ''), $right); + } + ); + + return $ids; + } + + /** + * @param array $node + * @return array{x: float, y: float} + */ + private static function readBounds(array $node): array + { + foreach ( array('absoluteBoundingBox', 'absoluteRenderBounds', 'relativeTransformBounds') as $key ) { + if ( is_array($node[$key] ?? null) ) { + return array( + 'x' => is_numeric($node[$key]['x'] ?? null) ? (float) $node[$key]['x'] : 0.0, + 'y' => is_numeric($node[$key]['y'] ?? null) ? (float) $node[$key]['y'] : 0.0, + ); + } + } + + return array('x' => 0.0, 'y' => 0.0); + } +} diff --git a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php new file mode 100644 index 0000000..48fabc4 --- /dev/null +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -0,0 +1,192 @@ + $source Decoded NODE_CHANGES-shaped source array. + * @param array $options Normalization options. + * @return array + */ + public function normalize(array $source, array $options = array()): array + { + $index = $this->index->build($source); + $nodeMap = $index['nodes']; + $topLevelIds = $index['top_level_node_ids']; + $frameIds = $this->selectTopLevelFrameIds($topLevelIds, $nodeMap); + + $selectedFrameId = null; + if ( isset($options['frame_id']) && is_scalar($options['frame_id']) && isset($nodeMap[(string) $options['frame_id']]) ) { + $selectedFrameId = (string) $options['frame_id']; + } elseif ( ! empty($frameIds) ) { + $selectedFrameId = $frameIds[0]; + } elseif ( ! empty($topLevelIds) ) { + $selectedFrameId = $topLevelIds[0]; + } + + $renderIds = $topLevelIds; + $renderNodes = array(); + foreach ( $renderIds as $id ) { + if ( isset($nodeMap[$id]) ) { + $renderNodes[] = $nodeMap[$id]; + } + } + + $textInventory = $this->buildTextInventory($nodeMap); + $assetReferences = $this->buildAssetReferences($nodeMap); + $sourceName = $this->readSourceName($source, $renderNodes); + + return array( + 'schema' => 'blocks-engine/figma-transformer/scenegraph/v1', + 'name' => $sourceName, + 'nodes' => $renderNodes, + 'node_map' => $nodeMap, + 'parent_index' => $index['parent_index'], + 'children_index' => $index['children_index'], + 'top_level_node_ids' => $topLevelIds, + 'top_level_frame_ids' => $frameIds, + 'selected_frame_id' => $selectedFrameId, + 'text_inventory' => $textInventory, + 'asset_references' => $assetReferences, + 'diagnostics' => $index['diagnostics'], + 'source_report' => array( + 'schema' => 'blocks-engine/figma-transformer/scenegraph-source/v1', + 'input_shape' => $this->detectInputShape($source), + 'name' => $sourceName, + 'node_count' => count($nodeMap), + 'top_level_node_ids' => $topLevelIds, + 'top_level_frame_ids' => $frameIds, + 'selected_frame_id' => $selectedFrameId, + 'text_node_count' => count($textInventory), + 'asset_reference_count' => count($assetReferences), + 'diagnostic_count' => count($index['diagnostics']), + ), + ); + } + + /** + * @param array $topLevelIds + * @param array> $nodeMap + * @return array + */ + private function selectTopLevelFrameIds(array $topLevelIds, array $nodeMap): array + { + $frameIds = array(); + + foreach ( $topLevelIds as $id ) { + $type = strtoupper((string) ($nodeMap[$id]['type'] ?? '')); + if ( in_array($type, array('FRAME', 'COMPONENT', 'INSTANCE'), true) ) { + $frameIds[] = $id; + } + } + + return $frameIds; + } + + /** + * @param array> $nodeMap + * @return array> + */ + private function buildTextInventory(array $nodeMap): array + { + $inventory = array(); + + foreach ( $nodeMap as $id => $node ) { + if ( 'TEXT' !== strtoupper((string) ($node['type'] ?? '')) ) { + continue; + } + + $text = null; + foreach ( array('characters', 'text', 'name') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) ) { + $text = (string) $node[$key]; + break; + } + } + + $inventory[] = array( + 'id' => $id, + 'name' => (string) ($node['name'] ?? ''), + 'text' => $text ?? '', + ); + } + + return $inventory; + } + + /** + * @param array> $nodeMap + * @return array> + */ + private function buildAssetReferences(array $nodeMap): array + { + $references = array(); + + foreach ( $nodeMap as $id => $node ) { + foreach ( array('fills', 'strokes', 'background') as $paintKey ) { + if ( ! is_array($node[$paintKey] ?? null) ) { + continue; + } + + foreach ( $node[$paintKey] as $paint ) { + if ( ! is_array($paint) || 'IMAGE' !== strtoupper((string) ($paint['type'] ?? '')) ) { + continue; + } + + $ref = $paint['imageRef'] ?? $paint['imageHash'] ?? null; + if ( is_scalar($ref) && '' !== (string) $ref ) { + $references[] = array( + 'node_id' => $id, + 'paint' => $paintKey, + 'ref' => (string) $ref, + ); + } + } + } + } + + return $references; + } + + /** + * @param array $source + * @param array> $renderNodes + */ + private function readSourceName(array $source, array $renderNodes): string + { + if ( isset($source['name']) && is_scalar($source['name']) && '' !== (string) $source['name'] ) { + return (string) $source['name']; + } + + if ( isset($renderNodes[0]['name']) && is_scalar($renderNodes[0]['name']) && '' !== (string) $renderNodes[0]['name'] ) { + return (string) $renderNodes[0]['name']; + } + + return 'Figma Site'; + } + + /** + * @param array $source + */ + private function detectInputShape(array $source): string + { + foreach ( array('NODE_CHANGES', 'node_changes', 'nodeChanges', 'document', 'nodes') as $key ) { + if ( isset($source[$key]) ) { + return $key; + } + } + + return 'unknown'; + } +} diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 415051a..3381797 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -52,6 +52,55 @@ $assert('zstd' === ($chunks[1]['compression'] ?? null), 'fig-kiwi-second-chunk-zstd'); $assert(in_array($zstdCapabilityCode, $diagnosticCodes, true), 'fig-kiwi-zstd-capability-diagnostic'); +$nodeChangesResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Node Changes Fixture', + 'NODE_CHANGES' => array( + '3:1' => array( + 'node' => array( + 'id' => '3:1', + 'type' => 'FRAME', + 'name' => 'Landing', + 'absoluteBoundingBox' => array('x' => 0, 'y' => 0), + 'children' => array( + array( + 'id' => '3:3', + 'type' => 'TEXT', + 'name' => 'Body', + 'characters' => 'Second', + 'absoluteBoundingBox' => array('x' => 0, 'y' => 120), + ), + array( + 'id' => '3:2', + 'type' => 'TEXT', + 'name' => 'Heading', + 'characters' => 'First', + 'absoluteBoundingBox' => array('x' => 0, 'y' => 20), + ), + array( + 'id' => '3:4', + 'type' => 'RECTANGLE', + 'name' => 'Photo', + 'fills' => array( + array('type' => 'IMAGE', 'imageRef' => 'image-hash-1'), + ), + ), + ), + ), + ), + ), +)); + +$nodeChangesHtml = (string) ($nodeChangesResult['files'][0]['content'] ?? ''); +$scenegraphReport = $nodeChangesResult['source_reports']['figma']['scenegraph'] ?? array(); + +$assert('success' === ($nodeChangesResult['status'] ?? null), 'node-changes-transform-success'); +$assert(4 === ($nodeChangesResult['metrics']['node_count'] ?? null), 'node-changes-node-count'); +$assert(2 === ($nodeChangesResult['metrics']['text_node_count'] ?? null), 'node-changes-text-count'); +$assert(1 === ($nodeChangesResult['metrics']['asset_reference_count'] ?? null), 'node-changes-asset-count'); +$assert('3:1' === ($scenegraphReport['selected_frame_id'] ?? null), 'node-changes-selected-frame'); +$assert(false !== strpos($nodeChangesHtml, 'First') && false !== strpos($nodeChangesHtml, 'Second'), 'node-changes-html-text'); +$assert(strpos($nodeChangesHtml, 'First') < strpos($nodeChangesHtml, 'Second'), 'node-changes-stable-child-sort'); + if ( ! empty($failures) ) { fwrite(STDERR, "Figma Transformer contract failures:\n- " . implode("\n- ", $failures) . "\n"); exit(1); From 8350d07995f4ae4b769f861a528cb147a703799c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 16:42:15 -0400 Subject: [PATCH 04/31] Improve figma static HTML emitter --- figma-transformer/src/FigmaTransformer.php | 3 +- .../src/Html/StaticHtmlEmitter.php | 367 ++++++++++++++++-- figma-transformer/tests/contract/run.php | 88 ++++- 3 files changed, 421 insertions(+), 37 deletions(-) diff --git a/figma-transformer/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php index fbef457..27c2c0c 100644 --- a/figma-transformer/src/FigmaTransformer.php +++ b/figma-transformer/src/FigmaTransformer.php @@ -91,9 +91,10 @@ public function transformScenegraph(array $scenegraph, array $options = array()) ), $parity, array( - 'node_count' => $normalized['source_report']['node_count'] ?? 0, + 'node_count' => $normalized['source_report']['node_count'] ?? ($artifact['metrics']['node_count'] ?? 0), 'text_node_count' => count($normalized['text_inventory'] ?? array()), 'asset_reference_count' => count($normalized['asset_references'] ?? array()), + 'asset_count' => $artifact['metrics']['asset_count'] ?? 0, 'file_count' => count($artifact['files']), 'transform_duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), ) diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index 7aedc71..e6a1bee 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -9,6 +9,11 @@ */ final class StaticHtmlEmitter { + /** + * @var array> + */ + private array $assetsById = array(); + /** * @param array $scenegraph Normalized Figma scenegraph. * @param array $options Transformation options. @@ -17,66 +22,370 @@ final class StaticHtmlEmitter public function emit(array $scenegraph, array $options = array()): array { $title = $this->sanitizeText((string) ($scenegraph['name'] ?? 'Figma Site')); - $nodes = is_array($scenegraph['nodes'] ?? null) ? $scenegraph['nodes'] : array(); + $nodes = $this->nodeList($scenegraph); + $diagnostics = array(); + $assetFiles = $this->normalizeAssets($scenegraph['assets'] ?? array(), $diagnostics); $body = ''; + $cssRules = array( + 'html{box-sizing:border-box}', + '*,*::before,*::after{box-sizing:inherit}', + 'body{margin:0;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#fff;color:#111}', + 'img{display:block;max-width:100%;height:auto}', + '[data-figma-node-id]{position:relative}', + '.figma-root{display:flex;flex-direction:column;min-height:100vh}', + ); + foreach ( $nodes as $node ) { if ( ! is_array($node) ) { continue; } - $body .= $this->emitNode($node); + $body .= $this->emitNode($node, $cssRules, 0); } - $html = "\n\n\n\n\n" . $title . "\n\n\n\n
\n" . $body . "
\n\n\n"; + $files = array( + array( + 'path' => 'index.html', + 'role' => 'entrypoint', + 'mime_type' => 'text/html', + 'content' => "\n\n\n\n\n" . $title . "\n\n\n\n
\n" . $body . "
\n\n\n", + ), + array( + 'path' => 'style.css', + 'role' => 'stylesheet', + 'mime_type' => 'text/css', + 'content' => implode("\n", $cssRules) . "\n", + ), + ); + + foreach ( $assetFiles as $assetFile ) { + $files[] = $assetFile; + } return array( 'status' => 'success', - 'diagnostics' => array(), - 'files' => array( - array( - 'path' => 'index.html', - 'role' => 'entrypoint', - 'mime_type' => 'text/html', - 'content' => $html, - ), - array( - 'path' => 'style.css', - 'role' => 'stylesheet', - 'mime_type' => 'text/css', - 'content' => "body{margin:0;font-family:system-ui,sans-serif}main{display:flex;flex-direction:column}\n[data-figma-node-id]{box-sizing:border-box}\n", - ), - ), - 'assets' => $scenegraph['asset_references'] ?? array(), + 'diagnostics' => $diagnostics, + 'files' => $files, + 'assets' => $this->assetReport($assetFiles), 'source_report' => array( 'name' => $title, - 'node_count' => count($nodes), + 'node_count' => $this->countNodes($nodes), 'schema' => $scenegraph['schema'] ?? null, ), 'metrics' => array( - 'node_count' => count($nodes), + 'node_count' => $this->countNodes($nodes), + 'asset_count' => count($assetFiles), ), ); } /** * @param array $node + * @param array $cssRules */ - private function emitNode(array $node): string + private function emitNode(array $node, array &$cssRules, int $depth): string { $id = $this->sanitizeAttribute((string) ($node['id'] ?? '')); - $name = $this->sanitizeAttribute((string) ($node['name'] ?? '')); - $text = $this->sanitizeText((string) ($node['characters'] ?? $node['text'] ?? $node['name'] ?? '')); + $name = (string) ($node['name'] ?? ''); + $attributeName = $this->sanitizeAttribute($name); + $text = $this->sanitizeText((string) ($node['characters'] ?? $node['text'] ?? '')); $type = strtoupper((string) ($node['type'] ?? 'FRAME')); - $tag = 'TEXT' === $type ? 'p' : 'section'; + $tag = $this->tagName($type, $name, $depth); + $className = 'figma-node-' . $this->slug($id . '-' . $name); + $children = $this->nodeList($node); + $content = $text; - $children = ''; - foreach ( $node['children'] ?? array() as $child ) { + foreach ( $children as $child ) { if ( is_array($child) ) { - $children .= $this->emitNode($child); + $content .= $this->emitNode($child, $cssRules, $depth + 1); + } + } + + $styles = $this->styleDeclarations($node, $type); + if ( ! empty($styles) ) { + $cssRules[] = '.' . $className . '{' . implode(';', $styles) . '}'; + } + + $attributes = sprintf(' class="%1$s" data-figma-node-id="%2$s" data-figma-node-name="%3$s"', $className, $id, $attributeName); + if ( 'RECTANGLE' === $type && '' === $content ) { + $attributes .= ' aria-hidden="true"'; + } + + return sprintf("<%1\$s%2\$s>%3\$s\n", $tag, $attributes, $content); + } + + private function tagName(string $type, string $name, int $depth): string + { + $lowerName = strtolower($name); + + if ( 'TEXT' === $type ) { + if ( str_contains($lowerName, 'title') || str_contains($lowerName, 'heading') || str_contains($lowerName, 'headline') ) { + return 0 === $depth ? 'h1' : 'h2'; } + + return 'p'; + } + + if ( str_contains($lowerName, 'header') ) { + return 'header'; + } + + if ( str_contains($lowerName, 'footer') ) { + return 'footer'; + } + + if ( str_contains($lowerName, 'nav') || str_contains($lowerName, 'menu') ) { + return 'nav'; + } + + if ( str_contains($lowerName, 'article') ) { + return 'article'; + } + + if ( 'FRAME' === $type ) { + return 'section'; + } + + return 'div'; + } + + /** + * @param array $node + * @return array + */ + private function styleDeclarations(array $node, string $type): array + { + $styles = array(); + + foreach ( array('width', 'height') as $dimension ) { + if ( isset($node[$dimension]) && is_numeric($node[$dimension]) ) { + $styles[] = $dimension . ':' . $this->number((float) $node[$dimension]) . 'px'; + } + } + + $background = $this->backgroundColor($node); + if ( null !== $background ) { + $styles[] = 'background:' . $background; + } + + $assetPath = $this->nodeAssetPath($node); + if ( null !== $assetPath ) { + $styles[] = 'background-image:url("' . $assetPath . '")'; + $styles[] = 'background-size:cover'; + $styles[] = 'background-position:center'; + } + + if ( 'TEXT' === $type ) { + foreach ( array('fontSize' => 'font-size', 'fontWeight' => 'font-weight', 'lineHeight' => 'line-height') as $source => $property ) { + if ( isset($node[$source]) && is_numeric($node[$source]) ) { + $unit = 'font-weight' === $property ? '' : 'px'; + $styles[] = $property . ':' . $this->number((float) $node[$source]) . $unit; + } + } + + $color = $this->color($node['color'] ?? $node['textColor'] ?? null); + if ( null !== $color ) { + $styles[] = 'color:' . $color; + } + } + + if ( 'FRAME' === $type || 'GROUP' === $type ) { + $styles[] = 'display:flex'; + $styles[] = 'flex-direction:column'; + } + + return $styles; + } + + /** + * @param mixed $assets + * @param array> $diagnostics + * @return array> + */ + private function normalizeAssets(mixed $assets, array &$diagnostics): array + { + $this->assetsById = array(); + if ( ! is_array($assets) ) { + return array(); + } + + $files = array(); + foreach ( $assets as $key => $asset ) { + if ( ! is_array($asset) ) { + continue; + } + + $id = (string) ($asset['id'] ?? $key); + $content = $asset['content'] ?? $asset['data'] ?? null; + $source = (string) ($asset['url'] ?? $asset['src'] ?? ''); + + if ( null === $content ) { + if ( preg_match('/^https?:\/\//', $source) ) { + $diagnostics[] = array( + 'severity' => 'warning', + 'code' => 'external_asset_omitted', + 'message' => 'External asset URL omitted from static output.', + 'asset_id' => $id, + ); + } + continue; + } + + $mimeType = (string) ($asset['mime_type'] ?? $asset['mimeType'] ?? 'application/octet-stream'); + $path = 'assets/' . $this->slug((string) ($asset['name'] ?? $id)) . '.' . $this->extensionForMimeType($mimeType); + $file = array( + 'path' => $path, + 'role' => 'asset', + 'mime_type' => $mimeType, + 'content' => (string) $content, + 'source_id' => $id, + ); + + $files[] = $file; + $this->assetsById[$id] = $file; } - return sprintf("<%1\$s data-figma-node-id=\"%2\$s\" data-figma-name=\"%3\$s\">%4\$s%5\$s\n", $tag, $id, $name, $text, $children); + usort( + $files, + static fn (array $a, array $b): int => strcmp((string) $a['path'], (string) $b['path']) + ); + + return $files; + } + + /** + * @param array> $assetFiles + * @return array> + */ + private function assetReport(array $assetFiles): array + { + $assets = array(); + foreach ( $assetFiles as $file ) { + $content = (string) ($file['content'] ?? ''); + $assets[] = array( + 'id' => (string) ($file['source_id'] ?? ''), + 'path' => (string) $file['path'], + 'mime_type' => (string) $file['mime_type'], + 'bytes' => strlen($content), + 'hash' => hash('sha256', $content), + ); + } + + return $assets; + } + + /** + * @param array $node + */ + private function nodeAssetPath(array $node): ?string + { + $assetId = (string) ($node['asset_id'] ?? $node['assetId'] ?? $node['image_ref'] ?? $node['imageRef'] ?? ''); + if ( '' === $assetId || ! isset($this->assetsById[$assetId]) ) { + return null; + } + + return (string) $this->assetsById[$assetId]['path']; + } + + /** + * @param array $node + */ + private function backgroundColor(array $node): ?string + { + return $this->color($node['background'] ?? $node['backgroundColor'] ?? $node['fill'] ?? $node['fills'][0]['color'] ?? null); + } + + private function color(mixed $value): ?string + { + if ( is_string($value) && preg_match('/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/', $value) ) { + return strtolower($value); + } + + if ( ! is_array($value) ) { + return null; + } + + $red = $this->colorChannel($value['r'] ?? $value['red'] ?? null); + $green = $this->colorChannel($value['g'] ?? $value['green'] ?? null); + $blue = $this->colorChannel($value['b'] ?? $value['blue'] ?? null); + if ( null === $red || null === $green || null === $blue ) { + return null; + } + + return sprintf('#%02x%02x%02x', $red, $green, $blue); + } + + private function colorChannel(mixed $value): ?int + { + if ( ! is_numeric($value) ) { + return null; + } + + $channel = (float) $value; + if ( $channel <= 1 ) { + $channel *= 255; + } + + return max(0, min(255, (int) round($channel))); + } + + private function extensionForMimeType(string $mimeType): string + { + return match ( $mimeType ) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/svg+xml' => 'svg', + 'image/webp' => 'webp', + default => 'bin', + }; + } + + /** + * @param array $container + * @return array + */ + private function nodeList(array $container): array + { + if ( is_array($container['nodes'] ?? null) ) { + return array_values($container['nodes']); + } + + if ( is_array($container['children'] ?? null) ) { + return array_values($container['children']); + } + + return array(); + } + + /** + * @param array $nodes + */ + private function countNodes(array $nodes): int + { + $count = 0; + foreach ( $nodes as $node ) { + if ( ! is_array($node) ) { + continue; + } + + ++$count; + $count += $this->countNodes($this->nodeList($node)); + } + + return $count; + } + + private function slug(string $value): string + { + $slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $value) ?? ''); + $slug = trim($slug, '-'); + + return '' === $slug ? 'node' : $slug; + } + + private function number(float $value): string + { + return rtrim(rtrim(sprintf('%.3F', $value), '0'), '.'); } private function sanitizeText(string $text): string diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 3381797..fc9806d 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -14,18 +14,92 @@ } }; -$result = blocks_engine_figma_transformer_transform_scenegraph(array( - 'name' => 'Fixture Site', - 'nodes' => array( +$scenegraph = array( + 'name' => 'Fixture Site', + 'assets' => array( + 'hero-image' => array( + 'name' => 'Hero Image', + 'mime_type' => 'image/svg+xml', + 'content' => '', + ), + 'remote-image' => array( + 'url' => 'https://cdn.example.com/remote.png', + ), + ), + 'nodes' => array( + array( + 'id' => '1:1', + 'type' => 'FRAME', + 'name' => 'Hero section', + 'width' => 1200, + 'height' => 600, + 'backgroundColor' => '#ffffff', + 'children' => array( + array( + 'id' => '1:2', + 'type' => 'TEXT', + 'name' => 'Hero title', + 'text' => 'Hello Figma', + 'fontSize' => 48, + 'fontWeight' => 700, + 'color' => array('r' => 0.1, 'g' => 0.2, 'b' => 0.3), + ), + array( + 'id' => '1:3', + 'type' => 'GROUP', + 'name' => 'Cards group', + 'children' => array( + array( + 'id' => '1:4', + 'type' => 'RECTANGLE', + 'name' => 'Hero image rectangle', + 'width' => 320, + 'height' => 180, + 'fill' => array('r' => 1, 'g' => 0, 'b' => 0), + 'asset_id' => 'hero-image', + ), + ), + ), + ), + ), array('id' => '1:2', 'type' => 'TEXT', 'name' => 'Hero title', 'text' => 'Hello Figma'), - array('id' => '1:3', 'type' => 'FRAME', 'name' => 'Hero section'), ), -)); +); + +$result = blocks_engine_figma_transformer_transform_scenegraph($scenegraph); +$sameResult = blocks_engine_figma_transformer_transform_scenegraph($scenegraph); + +$fileContent = static function (array $result, string $path): string { + foreach ( $result['files'] ?? array() as $file ) { + if ( $path === ($file['path'] ?? null) ) { + return (string) ($file['content'] ?? ''); + } + } + + return ''; +}; + +$html = $fileContent($result, 'index.html'); +$css = $fileContent($result, 'style.css'); $assert('blocks-engine/figma-transformer/result/v1' === ($result['schema'] ?? null), 'result-schema'); $assert('success' === ($result['status'] ?? null), 'scenegraph-transform-success'); -$assert(2 === ($result['metrics']['node_count'] ?? null), 'node-count'); -$assert(str_contains((string) ($result['files'][0]['content'] ?? ''), 'Hello Figma'), 'html-contains-text'); +$assert(5 === ($result['metrics']['node_count'] ?? null), 'node-count'); +$assert(1 === ($result['metrics']['asset_count'] ?? null), 'asset-count'); +$assert(str_contains($html, 'Hello Figma'), 'html-contains-text'); +$assert(str_contains($html, '
Date: Mon, 22 Jun 2026 17:08:40 -0400 Subject: [PATCH 05/31] Add figma decoder payload handoff --- .../src/Compression/ZstdCapability.php | 33 +++++++++ .../src/FigFile/FigKiwiParser.php | 46 +++++++++++- figma-transformer/src/FigmaTransformer.php | 68 ++++++++++++++++++ .../src/Scenegraph/ScenegraphNormalizer.php | 1 + figma-transformer/tests/contract/run.php | 72 ++++++++++++++++--- 5 files changed, 210 insertions(+), 10 deletions(-) diff --git a/figma-transformer/src/Compression/ZstdCapability.php b/figma-transformer/src/Compression/ZstdCapability.php index b8bbab6..065b365 100644 --- a/figma-transformer/src/Compression/ZstdCapability.php +++ b/figma-transformer/src/Compression/ZstdCapability.php @@ -14,6 +14,39 @@ public function isAvailable(): bool return extension_loaded('zstd') && function_exists('zstd_uncompress'); } + /** + * @return array{data: string|null, diagnostics: array>} + */ + public function uncompress(string $payload, string $source, int $chunkIndex): array + { + if ( ! $this->isAvailable() ) { + return array( + 'data' => null, + 'diagnostics' => array($this->diagnostic($source, $chunkIndex)), + ); + } + + $decoded = zstd_uncompress($payload); + if ( false === $decoded ) { + return array( + 'data' => null, + 'diagnostics' => array( + array( + 'code' => 'figma_transformer_zstd_uncompress_failed', + 'message' => 'Zstandard chunk detected but ext-zstd could not decode the payload.', + 'source' => $source, + 'context' => array('chunk_index' => $chunkIndex), + ), + ), + ); + } + + return array( + 'data' => $decoded, + 'diagnostics' => array($this->diagnostic($source, $chunkIndex)), + ); + } + /** * @return array */ diff --git a/figma-transformer/src/FigFile/FigKiwiParser.php b/figma-transformer/src/FigFile/FigKiwiParser.php index 6be80f5..57536cf 100644 --- a/figma-transformer/src/FigFile/FigKiwiParser.php +++ b/figma-transformer/src/FigFile/FigKiwiParser.php @@ -92,10 +92,18 @@ public function parse(string $raw): array } else { $chunk['inflated_bytes'] = strlen($inflated); $chunk['inflated_preview_hex'] = bin2hex(substr($inflated, 0, 32)); + $chunk['payload'] = $this->classifyPayload($inflated); } } elseif ( 'zstd' === $chunk['compression'] ) { - $diagnostics[] = $this->zstdCapability->diagnostic('FigKiwiParser', $index); + $zstdResult = $this->zstdCapability->uncompress($payload, 'FigKiwiParser', $index); + $diagnostics = array_merge($diagnostics, $zstdResult['diagnostics']); + if ( null !== $zstdResult['data'] ) { + $chunk['inflated_bytes'] = strlen($zstdResult['data']); + $chunk['inflated_preview_hex'] = bin2hex(substr($zstdResult['data'], 0, 32)); + $chunk['payload'] = $this->classifyPayload($zstdResult['data']); + } } else { + $chunk['payload'] = $this->classifyPayload($payload); $diagnostics[] = $this->diagnostic('figma_transformer_kiwi_unknown_compression', 'fig-kiwi chunk compression could not be identified.', array('chunk_index' => $index)); } @@ -131,6 +139,42 @@ private function detectCompression(string $payload): string return 'unknown'; } + /** + * @return array + */ + private function classifyPayload(string $payload): array + { + $classification = $this->looksJsonLike($payload) ? 'json_invalid' : 'binary'; + $metadata = array( + 'classification' => $classification, + 'bytes' => strlen($payload), + 'preview_hex' => bin2hex(substr($payload, 0, 32)), + ); + + if ( 'json_invalid' !== $classification ) { + return $metadata; + } + + $decoded = json_decode($payload, true); + if ( JSON_ERROR_NONE !== json_last_error() ) { + $metadata['json_error'] = json_last_error_msg(); + return $metadata; + } + + $metadata['classification'] = 'json'; + if ( is_array($decoded) ) { + $metadata['json'] = $decoded; + } + + return $metadata; + } + + private function looksJsonLike(string $payload): bool + { + $trimmed = ltrim($payload); + return '' !== $trimmed && ('{' === $trimmed[0] || '[' === $trimmed[0]); + } + /** * @param array $context * @return array diff --git a/figma-transformer/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php index 27c2c0c..823387f 100644 --- a/figma-transformer/src/FigmaTransformer.php +++ b/figma-transformer/src/FigmaTransformer.php @@ -53,6 +53,31 @@ public function transformFile(string $path, array $options = array()): FigmaTran 'reason' => 'parity_runner_not_invoked', )); + $scenegraph = $this->decodedScenegraphPayload($archive); + if ( null !== $scenegraph ) { + $scenegraphResult = $this->transformScenegraph($scenegraph, $options)->toArray(); + $scenegraphStatus = (string) ($scenegraphResult['status'] ?? 'success_with_warnings'); + if ( 'success' === $scenegraphStatus && ! empty($diagnostics) ) { + $scenegraphStatus = 'success_with_warnings'; + } + + $scenegraphSourceReports = $scenegraphResult['source_reports']['figma'] ?? array(); + return FigmaTransformResult::create( + $scenegraphStatus, + array_merge($diagnostics, $scenegraphResult['diagnostics'] ?? array()), + $scenegraphResult['files'] ?? array(), + $scenegraphResult['assets'] ?? array(), + array( + 'figma' => array_merge( + $sourceReports['figma'], + is_array($scenegraphSourceReports) ? $scenegraphSourceReports : array() + ), + ), + $scenegraphResult['parity'] ?? $parity, + array_merge($metrics, $scenegraphResult['metrics'] ?? array()) + ); + } + return FigmaTransformResult::create( 'success_with_warnings', $diagnostics, @@ -64,6 +89,49 @@ public function transformFile(string $path, array $options = array()): FigmaTran ); } + /** + * @param array $archive + * @return array|null + */ + private function decodedScenegraphPayload(array $archive): ?array + { + $chunks = $archive['archive']['canvas']['chunks'] ?? array(); + if ( ! is_array($chunks) ) { + return null; + } + + foreach ( $chunks as $chunk ) { + if ( ! is_array($chunk) ) { + continue; + } + + $payload = $chunk['payload'] ?? array(); + if ( ! is_array($payload) || 'json' !== ($payload['classification'] ?? null) || ! is_array($payload['json'] ?? null) ) { + continue; + } + + if ( $this->isScenegraphPayload($payload['json']) ) { + return $payload['json']; + } + } + + return null; + } + + /** + * @param array $payload + */ + private function isScenegraphPayload(array $payload): bool + { + foreach ( array('NODE_CHANGES', 'node_changes', 'nodeChanges', 'document', 'nodes') as $key ) { + if ( array_key_exists($key, $payload) ) { + return true; + } + } + + return false; + } + /** * Transform a decoded Figma scenegraph into static HTML artifact files. * diff --git a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php index 48fabc4..dfd1c3f 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -50,6 +50,7 @@ public function normalize(array $source, array $options = array()): array return array( 'schema' => 'blocks-engine/figma-transformer/scenegraph/v1', 'name' => $sourceName, + 'assets' => is_array($source['assets'] ?? null) ? $source['assets'] : array(), 'nodes' => $renderNodes, 'node_map' => $nodeMap, 'parent_index' => $index['parent_index'], diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index fc9806d..882e92a 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -62,7 +62,6 @@ ), ), ), - array('id' => '1:2', 'type' => 'TEXT', 'name' => 'Hero title', 'text' => 'Hello Figma'), ), ); @@ -84,19 +83,16 @@ $assert('blocks-engine/figma-transformer/result/v1' === ($result['schema'] ?? null), 'result-schema'); $assert('success' === ($result['status'] ?? null), 'scenegraph-transform-success'); -$assert(5 === ($result['metrics']['node_count'] ?? null), 'node-count'); +$assert(4 === ($result['metrics']['node_count'] ?? null), 'node-count'); $assert(1 === ($result['metrics']['asset_count'] ?? null), 'asset-count'); $assert(str_contains($html, 'Hello Figma'), 'html-contains-text'); $assert(str_contains($html, '
'Node Changes Fixture', @@ -192,8 +194,10 @@ function blocks_engine_figma_transformer_create_fig_wrapper_fixture(): string $canvas = 'fig-kiwi' . pack('V', 106) + . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate(json_encode(blocks_engine_figma_transformer_node_changes_fixture(), JSON_THROW_ON_ERROR))) + . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate('{"NODE_CHANGES":')) . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate('synthetic kiwi dictionary')) - . blocks_engine_figma_transformer_kiwi_chunk("\x28\xb5\x2f\xfd" . 'synthetic-zstd-frame'); + . blocks_engine_figma_transformer_kiwi_chunk(blocks_engine_figma_transformer_zstd_fixture_payload()); $innerZip = new ZipArchive(); if ( true !== $innerZip->open($inner, ZipArchive::OVERWRITE) ) { @@ -220,3 +224,53 @@ function blocks_engine_figma_transformer_kiwi_chunk(string $payload): string { return pack('V', strlen($payload)) . $payload; } + +/** + * @return array + */ +function blocks_engine_figma_transformer_node_changes_fixture(): array +{ + return array( + 'name' => 'Decoded Node Changes Fixture', + 'NODE_CHANGES' => array( + '4:1' => array( + 'node' => array( + 'id' => '4:1', + 'type' => 'FRAME', + 'name' => 'Decoded Landing', + 'children' => array( + array( + 'id' => '4:2', + 'type' => 'TEXT', + 'name' => 'Heading', + 'characters' => 'Decoded First', + ), + array( + 'id' => '4:3', + 'type' => 'TEXT', + 'name' => 'Body', + 'characters' => 'Decoded Second', + ), + array( + 'id' => '4:4', + 'type' => 'RECTANGLE', + 'name' => 'Decoded Photo', + ), + ), + ), + ), + ), + ); +} + +function blocks_engine_figma_transformer_zstd_fixture_payload(): string +{ + if ( function_exists('zstd_compress') ) { + $compressed = zstd_compress('synthetic zstd payload'); + if ( false !== $compressed ) { + return $compressed; + } + } + + return "\x28\xb5\x2f\xfd" . 'synthetic-zstd-frame'; +} From f44b58d63811ed261dd167ea5d4c6fc44f66b7aa Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:13:15 -0400 Subject: [PATCH 06/31] Add Figma layout IR emission --- .../src/Html/StaticHtmlEmitter.php | 44 ++++- .../src/Scenegraph/ScenegraphIndex.php | 26 ++- .../src/Scenegraph/ScenegraphNormalizer.php | 160 +++++++++++++++++- figma-transformer/tests/contract/run.php | 25 ++- 4 files changed, 240 insertions(+), 15 deletions(-) diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index e6a1bee..d332971 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -30,10 +30,8 @@ public function emit(array $scenegraph, array $options = array()): array $cssRules = array( 'html{box-sizing:border-box}', '*,*::before,*::after{box-sizing:inherit}', - 'body{margin:0;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#fff;color:#111}', + 'body{margin:0}', 'img{display:block;max-width:100%;height:auto}', - '[data-figma-node-id]{position:relative}', - '.figma-root{display:flex;flex-direction:column;min-height:100vh}', ); foreach ( $nodes as $node ) { @@ -157,9 +155,20 @@ private function styleDeclarations(array $node, string $type): array { $styles = array(); + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); foreach ( array('width', 'height') as $dimension ) { - if ( isset($node[$dimension]) && is_numeric($node[$dimension]) ) { - $styles[] = $dimension . ':' . $this->number((float) $node[$dimension]) . 'px'; + if ( isset($box[$dimension]) && is_numeric($box[$dimension]) ) { + $styles[] = $dimension . ':' . $this->number((float) $box[$dimension]) . 'px'; + } + } + + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + if ( 'absolute' === ($layout['positioning'] ?? null) ) { + $styles[] = 'position:absolute'; + foreach ( array('x' => 'left', 'y' => 'top') as $dimension => $property ) { + if ( isset($box[$dimension]) && is_numeric($box[$dimension]) ) { + $styles[] = $property . ':' . $this->number((float) $box[$dimension]) . 'px'; + } } } @@ -189,9 +198,28 @@ private function styleDeclarations(array $node, string $type): array } } - if ( 'FRAME' === $type || 'GROUP' === $type ) { - $styles[] = 'display:flex'; - $styles[] = 'flex-direction:column'; + foreach ( array( + 'display' => 'display', + 'flex_direction' => 'flex-direction', + 'justify_content' => 'justify-content', + 'align_items' => 'align-items', + 'flex_wrap' => 'flex-wrap', + ) as $source => $property ) { + if ( isset($layout[$source]) && is_scalar($layout[$source]) && '' !== (string) $layout[$source] ) { + $styles[] = $property . ':' . (string) $layout[$source]; + } + } + + if ( isset($layout['padding']) && is_array($layout['padding']) ) { + foreach ( array('top', 'right', 'bottom', 'left') as $edge ) { + if ( isset($layout['padding'][$edge]) && is_numeric($layout['padding'][$edge]) ) { + $styles[] = 'padding-' . $edge . ':' . $this->number((float) $layout['padding'][$edge]) . 'px'; + } + } + } + + if ( isset($layout['item_spacing']) && is_numeric($layout['item_spacing']) ) { + $styles[] = 'gap:' . $this->number((float) $layout['item_spacing']) . 'px'; } return $styles; diff --git a/figma-transformer/src/Scenegraph/ScenegraphIndex.php b/figma-transformer/src/Scenegraph/ScenegraphIndex.php index 9dff03a..b52b60e 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphIndex.php +++ b/figma-transformer/src/Scenegraph/ScenegraphIndex.php @@ -141,9 +141,22 @@ private function collectNode(array $value, ?string $fallbackId, ?string $parentI } unset($node['children']); + if ( isset($rawNodes[$id]) ) { + $diagnostics[] = array( + 'code' => 'scenegraph_node_id_duplicate', + 'message' => 'Encountered a duplicate node id; kept the richer source node.', + 'node_id' => $id, + ); + + if ( $this->nodeRichness($node, $children) <= $this->nodeRichness($rawNodes[$id]['node'], $rawNodes[$id]['children'] ?? array()) ) { + return; + } + } + $rawNodes[$id] = array( - 'node' => $node, - 'parent' => $effectiveParent, + 'node' => $node, + 'parent' => $effectiveParent, + 'children' => $children, ); foreach ( $children as $key => $child ) { @@ -187,6 +200,15 @@ private function readString(array $node, array $keys): ?string return null; } + /** + * @param array $node + * @param array $children + */ + private function nodeRichness(array $node, array $children): int + { + return count($node, COUNT_RECURSIVE) + count($children, COUNT_RECURSIVE); + } + /** * @param array $ids * @param array> $nodeMap diff --git a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php index dfd1c3f..0d55fa7 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -22,7 +22,7 @@ public function __construct( public function normalize(array $source, array $options = array()): array { $index = $this->index->build($source); - $nodeMap = $index['nodes']; + $nodeMap = $this->normalizeNodeMap($index['nodes'], $index['children_index']); $topLevelIds = $index['top_level_node_ids']; $frameIds = $this->selectTopLevelFrameIds($topLevelIds, $nodeMap); @@ -53,6 +53,7 @@ public function normalize(array $source, array $options = array()): array 'assets' => is_array($source['assets'] ?? null) ? $source['assets'] : array(), 'nodes' => $renderNodes, 'node_map' => $nodeMap, + 'assets' => is_array($source['assets'] ?? null) ? $source['assets'] : array(), 'parent_index' => $index['parent_index'], 'children_index' => $index['children_index'], 'top_level_node_ids' => $topLevelIds, @@ -95,6 +96,163 @@ private function selectTopLevelFrameIds(array $topLevelIds, array $nodeMap): arr return $frameIds; } + /** + * @param array> $nodeMap + * @param array> $childrenIndex + * @return array> + */ + private function normalizeNodeMap(array $nodeMap, array $childrenIndex): array + { + $normalized = array(); + + foreach ( $nodeMap as $id => $node ) { + $node['box'] = $this->normalizeBox($node); + $layout = $this->normalizeLayout($node); + if ( ! empty($layout) ) { + $node['layout'] = $layout; + } + + $node['children'] = array(); + $normalized[$id] = $node; + } + + $buildNode = function (string $id) use (&$buildNode, &$normalized, $childrenIndex): array { + $node = $normalized[$id]; + $node['children'] = array(); + + foreach ( $childrenIndex[$id] ?? array() as $childId ) { + if ( isset($normalized[$childId]) ) { + $node['children'][] = $buildNode($childId); + } + } + + return $node; + }; + + foreach ( array_keys($normalized) as $id ) { + $normalized[$id] = $buildNode((string) $id); + } + + return $normalized; + } + + /** + * @param array $node + * @return array + */ + private function normalizeBox(array $node): array + { + $box = array(); + + foreach ( array('absoluteBoundingBox', 'absoluteRenderBounds') as $boundsKey ) { + if ( ! is_array($node[$boundsKey] ?? null) ) { + continue; + } + + foreach ( array('x', 'y', 'width', 'height') as $dimension ) { + if ( ! array_key_exists($dimension, $box) && isset($node[$boundsKey][$dimension]) && is_numeric($node[$boundsKey][$dimension]) ) { + $box[$dimension] = (float) $node[$boundsKey][$dimension]; + } + } + } + + foreach ( array('x', 'y', 'width', 'height') as $dimension ) { + if ( ! array_key_exists($dimension, $box) && isset($node[$dimension]) && is_numeric($node[$dimension]) ) { + $box[$dimension] = (float) $node[$dimension]; + } + } + + return $box; + } + + /** + * @param array $node + * @return array + */ + private function normalizeLayout(array $node): array + { + $layout = array(); + + if ( isset($node['layoutMode']) && is_scalar($node['layoutMode']) ) { + $mode = strtoupper((string) $node['layoutMode']); + $layout['mode'] = $mode; + + if ( 'HORIZONTAL' === $mode ) { + $layout['display'] = 'flex'; + $layout['flex_direction'] = 'row'; + } elseif ( 'VERTICAL' === $mode ) { + $layout['display'] = 'flex'; + $layout['flex_direction'] = 'column'; + } + } + + foreach ( array( + 'primaryAxisAlignItems' => 'primary_axis_alignment', + 'counterAxisAlignItems' => 'counter_axis_alignment', + ) as $source => $target ) { + if ( isset($node[$source]) && is_scalar($node[$source]) ) { + $layout[$target] = strtoupper((string) $node[$source]); + } + } + + if ( isset($layout['primary_axis_alignment']) ) { + $layout['justify_content'] = $this->cssAxisAlignment((string) $layout['primary_axis_alignment']); + } + + if ( isset($layout['counter_axis_alignment']) ) { + $layout['align_items'] = $this->cssAxisAlignment((string) $layout['counter_axis_alignment']); + } + + $padding = array(); + foreach ( array('top' => 'paddingTop', 'right' => 'paddingRight', 'bottom' => 'paddingBottom', 'left' => 'paddingLeft') as $edge => $source ) { + if ( isset($node[$source]) && is_numeric($node[$source]) ) { + $padding[$edge] = (float) $node[$source]; + } + } + foreach ( array('left', 'right') as $edge ) { + if ( ! array_key_exists($edge, $padding) && isset($node['paddingHorizontal']) && is_numeric($node['paddingHorizontal']) ) { + $padding[$edge] = (float) $node['paddingHorizontal']; + } + } + foreach ( array('top', 'bottom') as $edge ) { + if ( ! array_key_exists($edge, $padding) && isset($node['paddingVertical']) && is_numeric($node['paddingVertical']) ) { + $padding[$edge] = (float) $node['paddingVertical']; + } + } + if ( ! empty($padding) ) { + $layout['padding'] = $padding; + } + + if ( isset($node['itemSpacing']) && is_numeric($node['itemSpacing']) ) { + $layout['item_spacing'] = (float) $node['itemSpacing']; + } + + if ( isset($node['layoutWrap']) && is_scalar($node['layoutWrap']) ) { + $layout['wrap'] = strtoupper((string) $node['layoutWrap']); + if ( 'WRAP' === $layout['wrap'] ) { + $layout['flex_wrap'] = 'wrap'; + } + } + + if ( isset($node['layoutPositioning']) && 'ABSOLUTE' === strtoupper((string) $node['layoutPositioning']) ) { + $layout['positioning'] = 'absolute'; + } + + return $layout; + } + + private function cssAxisAlignment(string $alignment): ?string + { + return match ( strtoupper($alignment) ) { + 'MIN' => 'flex-start', + 'CENTER' => 'center', + 'MAX' => 'flex-end', + 'SPACE_BETWEEN' => 'space-between', + 'BASELINE' => 'baseline', + default => null, + }; + } + /** * @param array> $nodeMap * @return array> diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 882e92a..0e40ff6 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -34,6 +34,14 @@ 'width' => 1200, 'height' => 600, 'backgroundColor' => '#ffffff', + 'layoutMode' => 'VERTICAL', + 'primaryAxisAlignItems' => 'CENTER', + 'counterAxisAlignItems' => 'MIN', + 'paddingTop' => 40, + 'paddingRight' => 32, + 'paddingBottom' => 40, + 'paddingLeft' => 32, + 'itemSpacing' => 24, 'children' => array( array( 'id' => '1:2', @@ -53,8 +61,10 @@ 'id' => '1:4', 'type' => 'RECTANGLE', 'name' => 'Hero image rectangle', - 'width' => 320, - 'height' => 180, + 'absoluteRenderBounds' => array('width' => 320, 'height' => 180), + 'x' => 10, + 'y' => 20, + 'layoutPositioning' => 'ABSOLUTE', 'fill' => array('r' => 1, 'g' => 0, 'b' => 0), 'asset_id' => 'hero-image', ), @@ -80,6 +90,10 @@ $html = $fileContent($result, 'index.html'); $css = $fileContent($result, 'style.css'); +$diagnosticCodes = array_map( + static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), + $result['diagnostics'] ?? array() +); $assert('blocks-engine/figma-transformer/result/v1' === ($result['schema'] ?? null), 'result-schema'); $assert('success' === ($result['status'] ?? null), 'scenegraph-transform-success'); @@ -91,10 +105,13 @@ $assert(str_contains($html, '
Date: Mon, 22 Jun 2026 17:12:19 -0400 Subject: [PATCH 07/31] Improve Figma text and paint normalization --- .../src/Html/StaticHtmlEmitter.php | 258 +++++++++++- .../src/Scenegraph/ScenegraphIndex.php | 33 +- .../src/Scenegraph/ScenegraphNormalizer.php | 393 +++++++++++++++++- figma-transformer/tests/contract/run.php | 75 ++++ 4 files changed, 732 insertions(+), 27 deletions(-) diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index d332971..67d28c2 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -86,7 +86,7 @@ private function emitNode(array $node, array &$cssRules, int $depth): string $id = $this->sanitizeAttribute((string) ($node['id'] ?? '')); $name = (string) ($node['name'] ?? ''); $attributeName = $this->sanitizeAttribute($name); - $text = $this->sanitizeText((string) ($node['characters'] ?? $node['text'] ?? '')); + $text = $this->textContent($node); $type = strtoupper((string) ($node['type'] ?? 'FRAME')); $tag = $this->tagName($type, $name, $depth); $className = 'figma-node-' . $this->slug($id . '-' . $name); @@ -172,9 +172,24 @@ private function styleDeclarations(array $node, string $type): array } } - $background = $this->backgroundColor($node); - if ( null !== $background ) { - $styles[] = 'background:' . $background; + if ( 'TEXT' !== $type ) { + $background = $this->backgroundColor($node); + if ( null !== $background ) { + $styles[] = 'background:' . $background; + } + } + + $box = is_array($node['figma_box'] ?? null) ? $node['figma_box'] : array(); + if ( isset($box['opacity']) && is_numeric($box['opacity']) ) { + $styles[] = 'opacity:' . $this->number((float) $box['opacity']); + } + + foreach ( $this->radiusStyles($box) as $style ) { + $styles[] = $style; + } + + foreach ( $this->strokeStyles($node) as $style ) { + $styles[] = $style; } $assetPath = $this->nodeAssetPath($node); @@ -185,16 +200,8 @@ private function styleDeclarations(array $node, string $type): array } if ( 'TEXT' === $type ) { - foreach ( array('fontSize' => 'font-size', 'fontWeight' => 'font-weight', 'lineHeight' => 'line-height') as $source => $property ) { - if ( isset($node[$source]) && is_numeric($node[$source]) ) { - $unit = 'font-weight' === $property ? '' : 'px'; - $styles[] = $property . ':' . $this->number((float) $node[$source]) . $unit; - } - } - - $color = $this->color($node['color'] ?? $node['textColor'] ?? null); - if ( null !== $color ) { - $styles[] = 'color:' . $color; + foreach ( $this->textStyles($node) as $style ) { + $styles[] = $style; } } @@ -225,6 +232,182 @@ private function styleDeclarations(array $node, string $type): array return $styles; } + /** + * @param array $node + */ + private function textContent(array $node): string + { + $text = is_array($node['figma_text'] ?? null) ? $node['figma_text'] : array(); + $segments = is_array($text['segments'] ?? null) ? $text['segments'] : array(); + if ( ! empty($segments) ) { + $content = ''; + foreach ( $segments as $segment ) { + if ( ! is_array($segment) ) { + continue; + } + + $segmentText = (string) ($segment['characters'] ?? ''); + if ( '' === $segmentText ) { + continue; + } + + $segmentStyles = is_array($segment['style'] ?? null) ? $this->textStyleDeclarations($segment['style']) : array(); + if ( empty($segmentStyles) ) { + $content .= $this->sanitizeText($segmentText); + continue; + } + + $content .= '' . $this->sanitizeText($segmentText) . ''; + } + + if ( '' !== $content ) { + return $content; + } + } + + if ( isset($text['characters']) && is_scalar($text['characters']) ) { + return $this->sanitizeText((string) $text['characters']); + } + + return $this->sanitizeText((string) ($node['characters'] ?? $node['text'] ?? '')); + } + + /** + * @param array $node + * @return array + */ + private function textStyles(array $node): array + { + $text = is_array($node['figma_text'] ?? null) ? $node['figma_text'] : array(); + $style = is_array($text['style'] ?? null) ? $text['style'] : array(); + if ( ! isset($style['color']) ) { + $paints = is_array($node['figma_paints']['fills'] ?? null) ? $node['figma_paints']['fills'] : array(); + $color = $this->firstSolidPaint($paints); + if ( null !== $color ) { + $style['css_color'] = $color; + } + } + + return $this->textStyleDeclarations($style); + } + + /** + * @param array $style + * @return array + */ + private function textStyleDeclarations(array $style): array + { + $styles = array(); + + if ( isset($style['font_family']) && is_scalar($style['font_family']) ) { + $styles[] = 'font-family:' . $this->cssString((string) $style['font_family']); + } + + if ( isset($style['font_size']) && is_numeric($style['font_size']) ) { + $styles[] = 'font-size:' . $this->number((float) $style['font_size']) . 'px'; + } + + if ( isset($style['font_weight']) && is_numeric($style['font_weight']) ) { + $styles[] = 'font-weight:' . $this->number((float) $style['font_weight']); + } + + if ( isset($style['line_height_px']) && is_numeric($style['line_height_px']) ) { + $styles[] = 'line-height:' . $this->number((float) $style['line_height_px']) . 'px'; + } elseif ( isset($style['line_height_percent']) && is_numeric($style['line_height_percent']) ) { + $styles[] = 'line-height:' . $this->number((float) $style['line_height_percent']) . '%'; + } + + if ( isset($style['letter_spacing']) && is_numeric($style['letter_spacing']) ) { + $styles[] = 'letter-spacing:' . $this->number((float) $style['letter_spacing']) . 'px'; + } + + $color = $this->color($style['color'] ?? null); + if ( null !== $color ) { + $styles[] = 'color:' . $color; + } elseif ( isset($style['css_color']) && is_scalar($style['css_color']) ) { + $styles[] = 'color:' . (string) $style['css_color']; + } + + if ( isset($style['text_align_horizontal']) && is_scalar($style['text_align_horizontal']) ) { + $align = strtolower((string) $style['text_align_horizontal']); + $align = 'justified' === $align ? 'justify' : $align; + if ( in_array($align, array('left', 'center', 'right', 'justify'), true) ) { + $styles[] = 'text-align:' . $align; + } + } + + if ( isset($style['text_align_vertical']) && is_scalar($style['text_align_vertical']) ) { + $align = strtolower((string) $style['text_align_vertical']); + if ( in_array($align, array('top', 'middle', 'bottom'), true) ) { + $styles[] = 'vertical-align:' . $align; + } + } + + $decorations = array(); + if ( isset($style['text_decoration']) && is_scalar($style['text_decoration']) ) { + $decoration = strtolower((string) $style['text_decoration']); + if ( in_array($decoration, array('underline', 'line-through'), true) ) { + $decorations[] = $decoration; + } + } + if ( true === ($style['underline'] ?? false) ) { + $decorations[] = 'underline'; + } + if ( true === ($style['strikethrough'] ?? false) ) { + $decorations[] = 'line-through'; + } + if ( ! empty($decorations) ) { + $styles[] = 'text-decoration:' . implode(' ', array_values(array_unique($decorations))); + } + + return $styles; + } + + /** + * @param array $box + * @return array + */ + private function radiusStyles(array $box): array + { + if ( isset($box['corner_radius']) && is_numeric($box['corner_radius']) ) { + return array('border-radius:' . $this->number((float) $box['corner_radius']) . 'px'); + } + + $styles = array(); + foreach ( array( + 'top_left_radius' => 'border-top-left-radius', + 'top_right_radius' => 'border-top-right-radius', + 'bottom_right_radius' => 'border-bottom-right-radius', + 'bottom_left_radius' => 'border-bottom-left-radius', + ) as $sourceKey => $property ) { + if ( isset($box[$sourceKey]) && is_numeric($box[$sourceKey]) ) { + $styles[] = $property . ':' . $this->number((float) $box[$sourceKey]) . 'px'; + } + } + + return $styles; + } + + /** + * @param array $node + * @return array + */ + private function strokeStyles(array $node): array + { + $paints = is_array($node['figma_paints']['strokes'] ?? null) ? $node['figma_paints']['strokes'] : array(); + $stroke = $this->firstSolidPaint($paints); + if ( null === $stroke ) { + return array(); + } + + $width = 1; + if ( isset($node['strokeWeight']) && is_numeric($node['strokeWeight']) ) { + $width = (float) $node['strokeWeight']; + } + + return array('border:' . $this->number((float) $width) . 'px solid ' . $stroke); + } + /** * @param mixed $assets * @param array> $diagnostics @@ -320,10 +503,41 @@ private function nodeAssetPath(array $node): ?string */ private function backgroundColor(array $node): ?string { + $paints = is_array($node['figma_paints']['fills'] ?? null) ? $node['figma_paints']['fills'] : array(); + $color = $this->firstSolidPaint($paints); + if ( null !== $color ) { + return $color; + } + + $paints = is_array($node['figma_paints']['background'] ?? null) ? $node['figma_paints']['background'] : array(); + $color = $this->firstSolidPaint($paints); + if ( null !== $color ) { + return $color; + } + return $this->color($node['background'] ?? $node['backgroundColor'] ?? $node['fill'] ?? $node['fills'][0]['color'] ?? null); } - private function color(mixed $value): ?string + /** + * @param array $paints + */ + private function firstSolidPaint(array $paints): ?string + { + foreach ( $paints as $paint ) { + if ( ! is_array($paint) || 'SOLID' !== ($paint['type'] ?? null) ) { + continue; + } + + $color = $this->color($paint['color'] ?? null, $paint['opacity'] ?? null); + if ( null !== $color ) { + return $color; + } + } + + return null; + } + + private function color(mixed $value, mixed $opacity = null): ?string { if ( is_string($value) && preg_match('/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/', $value) ) { return strtolower($value); @@ -340,6 +554,15 @@ private function color(mixed $value): ?string return null; } + $alpha = $opacity; + if ( null === $alpha && isset($value['a']) ) { + $alpha = $value['a']; + } + + if ( is_numeric($alpha) && (float) $alpha < 1 ) { + return sprintf('rgba(%d,%d,%d,%s)', $red, $green, $blue, $this->number(max(0, (float) $alpha))); + } + return sprintf('#%02x%02x%02x', $red, $green, $blue); } @@ -416,6 +639,11 @@ private function number(float $value): string return rtrim(rtrim(sprintf('%.3F', $value), '0'), '.'); } + private function cssString(string $value): string + { + return '"' . str_replace(array('\\', '"'), array('\\\\', '\\"'), $value) . '"'; + } + private function sanitizeText(string $text): string { return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); diff --git a/figma-transformer/src/Scenegraph/ScenegraphIndex.php b/figma-transformer/src/Scenegraph/ScenegraphIndex.php index b52b60e..5e55bba 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphIndex.php +++ b/figma-transformer/src/Scenegraph/ScenegraphIndex.php @@ -55,16 +55,8 @@ public function build(array $source): array $childrenIndex[$parent] = $this->sortNodeIds($children, $nodeMap); } - foreach ( $childrenIndex as $parent => $children ) { - if ( ! isset($nodeMap[$parent]) ) { - continue; - } - - foreach ( $children as $childId ) { - if ( isset($nodeMap[$childId]) ) { - $nodeMap[$parent]['children'][] = $nodeMap[$childId]; - } - } + foreach ( array_keys($nodeMap) as $id ) { + $nodeMap[$id]['children'] = $this->buildChildNodes($id, $nodeMap, $childrenIndex); } $topLevelNodeIds = array(); @@ -84,6 +76,27 @@ public function build(array $source): array ); } + /** + * @param array> $nodeMap + * @param array> $childrenIndex + * @return array> + */ + private function buildChildNodes(string $id, array $nodeMap, array $childrenIndex): array + { + $children = array(); + foreach ( $childrenIndex[$id] ?? array() as $childId ) { + if ( ! isset($nodeMap[$childId]) ) { + continue; + } + + $child = $nodeMap[$childId]; + $child['children'] = $this->buildChildNodes($childId, $nodeMap, $childrenIndex); + $children[] = $child; + } + + return $children; + } + /** * @param array $source * @return array diff --git a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php index 0d55fa7..54fff94 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -35,6 +35,9 @@ public function normalize(array $source, array $options = array()): array $selectedFrameId = $topLevelIds[0]; } + $diagnostics = $index['diagnostics']; + $nodeMap = $this->normalizeNodeMap($nodeMap, $diagnostics); + $renderIds = $topLevelIds; $renderNodes = array(); foreach ( $renderIds as $id ) { @@ -61,7 +64,7 @@ public function normalize(array $source, array $options = array()): array 'selected_frame_id' => $selectedFrameId, 'text_inventory' => $textInventory, 'asset_references' => $assetReferences, - 'diagnostics' => $index['diagnostics'], + 'diagnostics' => $diagnostics, 'source_report' => array( 'schema' => 'blocks-engine/figma-transformer/scenegraph-source/v1', 'input_shape' => $this->detectInputShape($source), @@ -72,9 +75,384 @@ public function normalize(array $source, array $options = array()): array 'selected_frame_id' => $selectedFrameId, 'text_node_count' => count($textInventory), 'asset_reference_count' => count($assetReferences), - 'diagnostic_count' => count($index['diagnostics']), + 'diagnostic_count' => count($diagnostics), + ), + ); + } + + /** + * @param array> $nodeMap + * @param array> $diagnostics + * @return array> + */ + private function normalizeNodeMap(array $nodeMap, array &$diagnostics): array + { + foreach ( $nodeMap as $id => $node ) { + $nodeMap[$id] = $this->normalizeNode($node, $diagnostics); + } + + return $nodeMap; + } + + /** + * @param array $node + * @param array> $diagnostics + * @return array + */ + private function normalizeNode(array $node, array &$diagnostics): array + { + $id = (string) ($node['id'] ?? ''); + $type = strtoupper((string) ($node['type'] ?? '')); + + if ( 'TEXT' === $type ) { + $text = $this->normalizeText($node); + if ( ! empty($text) ) { + $node['figma_text'] = $text; + } + } + + $paints = $this->normalizePaintCollections($node, $id, $diagnostics); + if ( ! empty($paints) ) { + $node['figma_paints'] = $paints; + } + + $box = $this->normalizeBox($node); + if ( ! empty($box) ) { + $node['figma_box'] = $box; + } + + $this->diagnoseEffects($node, $id, $diagnostics); + + foreach ( array('children', 'nodes') as $childrenKey ) { + if ( ! is_array($node[$childrenKey] ?? null) ) { + continue; + } + + foreach ( $node[$childrenKey] as $index => $child ) { + if ( is_array($child) ) { + $node[$childrenKey][$index] = $this->normalizeNode($child, $diagnostics); + } + } + } + + return $node; + } + + /** + * @param array $node + * @return array + */ + private function normalizeText(array $node): array + { + $text = array(); + + foreach ( array('characters', 'text') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) ) { + $text['characters'] = (string) $node[$key]; + break; + } + } + + $style = array(); + if ( is_array($node['style'] ?? null) ) { + $style = $this->normalizeTextStyle($node['style']); + } + + $rootStyle = $this->normalizeTextStyle($node); + foreach ( $rootStyle as $key => $value ) { + if ( ! array_key_exists($key, $style) ) { + $style[$key] = $value; + } + } + + if ( ! empty($style) ) { + $text['style'] = $style; + } + + $segments = $this->normalizeStyledTextSegments($node); + if ( ! empty($segments) ) { + $text['segments'] = $segments; + } + + return $text; + } + + /** + * @param array $source + * @return array + */ + private function normalizeTextStyle(array $source): array + { + $style = array(); + + foreach ( array( + 'fontFamily' => 'font_family', + 'fontPostScriptName' => 'font_postscript_name', + 'fontWeight' => 'font_weight', + 'textAlignHorizontal' => 'text_align_horizontal', + 'textAlignVertical' => 'text_align_vertical', + 'textDecoration' => 'text_decoration', + ) as $sourceKey => $targetKey ) { + if ( isset($source[$sourceKey]) && is_scalar($source[$sourceKey]) && '' !== (string) $source[$sourceKey] ) { + $style[$targetKey] = (string) $source[$sourceKey]; + } + } + + foreach ( array('fontSize' => 'font_size', 'lineHeightPx' => 'line_height_px', 'lineHeightPercent' => 'line_height_percent', 'letterSpacing' => 'letter_spacing') as $sourceKey => $targetKey ) { + if ( isset($source[$sourceKey]) && is_numeric($source[$sourceKey]) ) { + $style[$targetKey] = (float) $source[$sourceKey]; + } + } + + foreach ( array('color', 'textColor') as $sourceKey ) { + $color = $this->normalizeColor($source[$sourceKey] ?? null); + if ( null !== $color ) { + $style['color'] = $color; + break; + } + } + + foreach ( array('underline' => 'underline', 'strikethrough' => 'strikethrough') as $sourceKey => $targetKey ) { + if ( isset($source[$sourceKey]) && is_bool($source[$sourceKey]) ) { + $style[$targetKey] = $source[$sourceKey]; + } + } + + return $style; + } + + /** + * @param array $node + * @return array> + */ + private function normalizeStyledTextSegments(array $node): array + { + $segments = array(); + $rawSegments = null; + foreach ( array('styledTextSegments', 'segments') as $key ) { + if ( is_array($node[$key] ?? null) ) { + $rawSegments = $node[$key]; + break; + } + } + + if ( ! is_array($rawSegments) ) { + return array(); + } + + foreach ( $rawSegments as $segment ) { + if ( ! is_array($segment) ) { + continue; + } + + $normalized = array(); + foreach ( array('characters', 'text') as $key ) { + if ( isset($segment[$key]) && is_scalar($segment[$key]) ) { + $normalized['characters'] = (string) $segment[$key]; + break; + } + } + foreach ( array('start', 'end') as $key ) { + if ( isset($segment[$key]) && is_numeric($segment[$key]) ) { + $normalized[$key] = (int) $segment[$key]; + } + } + + $style = is_array($segment['style'] ?? null) ? $this->normalizeTextStyle($segment['style']) : $this->normalizeTextStyle($segment); + if ( ! empty($style) ) { + $normalized['style'] = $style; + } + + if ( ! empty($normalized) ) { + $segments[] = $normalized; + } + } + + return $segments; + } + + /** + * @param array $node + * @param array> $diagnostics + * @return array>> + */ + private function normalizePaintCollections(array $node, string $nodeId, array &$diagnostics): array + { + $collections = array(); + foreach ( array('fills' => 'fills', 'strokes' => 'strokes', 'background' => 'background') as $sourceKey => $targetKey ) { + if ( ! is_array($node[$sourceKey] ?? null) ) { + continue; + } + + $paints = array(); + foreach ( $node[$sourceKey] as $paint ) { + if ( ! is_array($paint) ) { + continue; + } + + $normalized = $this->normalizePaint($paint, $nodeId, $sourceKey, $diagnostics); + if ( ! empty($normalized) ) { + $paints[] = $normalized; + } + } + + if ( ! empty($paints) ) { + $collections[$targetKey] = $paints; + } + } + + foreach ( array('fill' => 'fills', 'backgroundColor' => 'background') as $sourceKey => $targetKey ) { + if ( ! isset($node[$sourceKey]) ) { + continue; + } + + $color = $this->normalizeColor($node[$sourceKey]); + if ( null !== $color ) { + $collections[$targetKey][] = array('type' => 'SOLID', 'color' => $color); + } + } + + return $collections; + } + + /** + * @param array $paint + * @param array> $diagnostics + * @return array + */ + private function normalizePaint(array $paint, string $nodeId, string $paintKey, array &$diagnostics): array + { + $type = strtoupper((string) ($paint['type'] ?? 'SOLID')); + if ( false === ($paint['visible'] ?? true) ) { + return array(); + } + + if ( 'SOLID' === $type ) { + $color = $this->normalizeColor($paint['color'] ?? $paint); + if ( null === $color ) { + return array(); + } + + $normalized = array('type' => 'SOLID', 'color' => $color); + if ( isset($paint['opacity']) && is_numeric($paint['opacity']) ) { + $normalized['opacity'] = (float) $paint['opacity']; + } + + return $normalized; + } + + if ( 'IMAGE' === $type ) { + $ref = $paint['imageRef'] ?? $paint['imageHash'] ?? null; + return is_scalar($ref) && '' !== (string) $ref ? array('type' => 'IMAGE', 'ref' => (string) $ref) : array('type' => 'IMAGE'); + } + + $diagnostics[] = array( + 'severity' => 'warning', + 'code' => 'unsupported_figma_paint_type', + 'message' => 'Unsupported Figma paint type was omitted from static CSS.', + 'context' => array( + 'node_id' => $nodeId, + 'paint' => $paintKey, + 'type' => $type, ), ); + + return array(); + } + + /** + * @param array $node + * @return array + */ + private function normalizeBox(array $node): array + { + $box = array(); + + if ( isset($node['opacity']) && is_numeric($node['opacity']) ) { + $box['opacity'] = (float) $node['opacity']; + } + + if ( isset($node['cornerRadius']) && is_numeric($node['cornerRadius']) ) { + $box['corner_radius'] = (float) $node['cornerRadius']; + } + + foreach ( array( + 'topLeftRadius' => 'top_left_radius', + 'topRightRadius' => 'top_right_radius', + 'bottomRightRadius' => 'bottom_right_radius', + 'bottomLeftRadius' => 'bottom_left_radius', + ) as $sourceKey => $targetKey ) { + if ( isset($node[$sourceKey]) && is_numeric($node[$sourceKey]) ) { + $box[$targetKey] = (float) $node[$sourceKey]; + } + } + + return $box; + } + + /** + * @param array $node + * @param array> $diagnostics + */ + private function diagnoseEffects(array $node, string $nodeId, array &$diagnostics): void + { + if ( ! is_array($node['effects'] ?? null) ) { + return; + } + + foreach ( $node['effects'] as $effect ) { + if ( ! is_array($effect) || false === ($effect['visible'] ?? true) ) { + continue; + } + + $diagnostics[] = array( + 'severity' => 'warning', + 'code' => 'unsupported_figma_effect_type', + 'message' => 'Unsupported Figma effect was omitted from static CSS.', + 'context' => array( + 'node_id' => $nodeId, + 'type' => strtoupper((string) ($effect['type'] ?? 'UNKNOWN')), + ), + ); + } + } + + /** + * @return array|null + */ + private function normalizeColor(mixed $value): ?array + { + if ( ! is_array($value) ) { + return null; + } + + $red = $this->normalizeColorChannel($value['r'] ?? $value['red'] ?? null); + $green = $this->normalizeColorChannel($value['g'] ?? $value['green'] ?? null); + $blue = $this->normalizeColorChannel($value['b'] ?? $value['blue'] ?? null); + if ( null === $red || null === $green || null === $blue ) { + return null; + } + + $color = array('r' => $red, 'g' => $green, 'b' => $blue); + if ( isset($value['a']) && is_numeric($value['a']) ) { + $color['a'] = (float) $value['a']; + } + + return $color; + } + + private function normalizeColorChannel(mixed $value): ?float + { + if ( ! is_numeric($value) ) { + return null; + } + + $channel = (float) $value; + if ( $channel > 1 ) { + $channel /= 255; + } + + return max(0, min(1, $channel)); } /** @@ -293,6 +671,17 @@ private function buildAssetReferences(array $nodeMap): array $references = array(); foreach ( $nodeMap as $id => $node ) { + foreach ( array('asset_id', 'assetId', 'image_ref', 'imageRef') as $assetKey ) { + if ( isset($node[$assetKey]) && is_scalar($node[$assetKey]) && '' !== (string) $node[$assetKey] ) { + $references[] = array( + 'node_id' => $id, + 'paint' => $assetKey, + 'ref' => (string) $node[$assetKey], + ); + break; + } + } + foreach ( array('fills', 'strokes', 'background') as $paintKey ) { if ( ! is_array($node[$paintKey] ?? null) ) { continue; diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 0e40ff6..f2807a3 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -194,6 +194,81 @@ $assert(false !== strpos($nodeChangesHtml, 'First') && false !== strpos($nodeChangesHtml, 'Second'), 'node-changes-html-text'); $assert(strpos($nodeChangesHtml, 'First') < strpos($nodeChangesHtml, 'Second'), 'node-changes-stable-child-sort'); +$metadataResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Text And Paint Metadata', + 'nodes' => array( + array( + 'id' => '4:1', + 'type' => 'FRAME', + 'name' => 'Metadata frame', + 'opacity' => 0.75, + 'cornerRadius' => 12, + 'fills' => array( + array('type' => 'SOLID', 'color' => array('r' => 0.2, 'g' => 0.4, 'b' => 0.6), 'opacity' => 0.5), + array('type' => 'GRADIENT_LINEAR'), + ), + 'strokes' => array( + array('type' => 'SOLID', 'color' => array('r' => 0, 'g' => 0, 'b' => 0)), + ), + 'strokeWeight' => 2, + 'effects' => array( + array('type' => 'DROP_SHADOW'), + ), + 'children' => array( + array( + 'id' => '4:2', + 'type' => 'TEXT', + 'name' => 'Mixed text', + 'characters' => 'Hello World', + 'style' => array( + 'fontFamily' => 'Example Sans', + 'fontSize' => 20, + 'fontWeight' => 600, + 'lineHeightPercent' => 125, + 'letterSpacing' => 0.5, + 'textAlignHorizontal'=> 'CENTER', + 'textAlignVertical' => 'TOP', + 'textDecoration' => 'UNDERLINE', + ), + 'fills' => array( + array('type' => 'SOLID', 'color' => array('r' => 1, 'g' => 0.5, 'b' => 0), 'opacity' => 0.8), + ), + 'styledTextSegments' => array( + array('characters' => 'Hello ', 'style' => array('fontWeight' => 400)), + array('characters' => 'World', 'style' => array('fontWeight' => 700, 'textDecoration' => 'UNDERLINE')), + ), + ), + array( + 'id' => '4:3', + 'type' => 'RECTANGLE', + 'name' => 'Uneven radius', + 'topLeftRadius' => 4, + 'topRightRadius' => 8, + 'bottomRightRadius' => 12, + 'bottomLeftRadius' => 16, + 'fills' => array( + array('type' => 'GRADIENT_RADIAL'), + ), + ), + ), + ), + ), +)); + +$metadataHtml = $fileContent($metadataResult, 'index.html'); +$metadataCss = $fileContent($metadataResult, 'style.css'); +$metadataDiagnosticCodes = array_map( + static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), + $metadataResult['diagnostics'] ?? array() +); + +$assert(str_contains($metadataHtml, 'Hello World'), 'styled-text-segments-emit'); +$assert(str_contains($metadataCss, '.figma-node-4-1-metadata-frame{background:rgba(51,102,153,0.5);opacity:0.75;border-radius:12px;border:2px solid #000000;display:flex;flex-direction:column}'), 'normalized-frame-paint-box-style'); +$assert(str_contains($metadataCss, '.figma-node-4-2-mixed-text{font-family:"Example Sans";font-size:20px;font-weight:600;line-height:125%;letter-spacing:0.5px;color:rgba(255,128,0,0.8);text-align:center;vertical-align:top;text-decoration:underline}'), 'normalized-text-style'); +$assert(str_contains($metadataCss, '.figma-node-4-3-uneven-radius{border-top-left-radius:4px;border-top-right-radius:8px;border-bottom-right-radius:12px;border-bottom-left-radius:16px}'), 'individual-radius-style'); +$assert(in_array('unsupported_figma_paint_type', $metadataDiagnosticCodes, true), 'unsupported-paint-diagnostic'); +$assert(in_array('unsupported_figma_effect_type', $metadataDiagnosticCodes, true), 'unsupported-effect-diagnostic'); + if ( ! empty($failures) ) { fwrite(STDERR, "Figma Transformer contract failures:\n- " . implode("\n- ", $failures) . "\n"); exit(1); From 09150636e489ef37e3d4aec3070a2c4067e905db Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:13:15 -0400 Subject: [PATCH 08/31] Improve Figma asset and vector handling --- .../src/FigFile/FigArchiveReader.php | 23 +++- figma-transformer/src/FigmaTransformer.php | 1 + .../src/Html/StaticHtmlEmitter.php | 101 ++++++++++++++++-- .../src/Scenegraph/ScenegraphIndex.php | 34 +++--- .../src/Scenegraph/ScenegraphNormalizer.php | 88 +++++++-------- figma-transformer/tests/contract/run.php | 58 ++++++++++ 6 files changed, 230 insertions(+), 75 deletions(-) diff --git a/figma-transformer/src/FigFile/FigArchiveReader.php b/figma-transformer/src/FigFile/FigArchiveReader.php index e462fa9..c6bdcab 100644 --- a/figma-transformer/src/FigFile/FigArchiveReader.php +++ b/figma-transformer/src/FigFile/FigArchiveReader.php @@ -149,16 +149,33 @@ private function assetManifest(ZipArchive $zip): array } $stat = $zip->statIndex($index); + $content = $zip->getFromIndex($index); + $hash = basename($name); $assets[] = array( - 'path' => $name, - 'hash' => basename($name), - 'bytes' => is_array($stat) ? (int) ($stat['size'] ?? 0) : 0, + 'id' => $hash, + 'name' => $hash, + 'path' => $name, + 'hash' => $hash, + 'bytes' => is_array($stat) ? (int) ($stat['size'] ?? 0) : 0, + 'mime_type' => $this->mimeTypeForPath($name), + 'content' => false === $content ? '' : $content, ); } return $assets; } + private function mimeTypeForPath(string $path): string + { + return match ( strtolower(pathinfo($path, PATHINFO_EXTENSION)) ) { + 'jpg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'svg' => 'image/svg+xml', + 'webp' => 'image/webp', + default => 'application/octet-stream', + }; + } + /** * @return array{canvas: array|null, diagnostics: array>} */ diff --git a/figma-transformer/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php index 823387f..41cbbd9 100644 --- a/figma-transformer/src/FigmaTransformer.php +++ b/figma-transformer/src/FigmaTransformer.php @@ -39,6 +39,7 @@ public function transformFile(string $path, array $options = array()): FigmaTran 'input' => $archive['input'], 'archive' => $archive['archive'], 'meta' => $archive['meta'], + 'assets' => $archive['assets'], ), ); diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index 67d28c2..1247031 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -38,7 +38,7 @@ public function emit(array $scenegraph, array $options = array()): array if ( ! is_array($node) ) { continue; } - $body .= $this->emitNode($node, $cssRules, 0); + $body .= $this->emitNode($node, $cssRules, $diagnostics, 0); } $files = array( @@ -79,9 +79,10 @@ public function emit(array $scenegraph, array $options = array()): array /** * @param array $node - * @param array $cssRules + * @param array $cssRules + * @param array> $diagnostics */ - private function emitNode(array $node, array &$cssRules, int $depth): string + private function emitNode(array $node, array &$cssRules, array &$diagnostics, int $depth): string { $id = $this->sanitizeAttribute((string) ($node['id'] ?? '')); $name = (string) ($node['name'] ?? ''); @@ -95,7 +96,21 @@ private function emitNode(array $node, array &$cssRules, int $depth): string foreach ( $children as $child ) { if ( is_array($child) ) { - $content .= $this->emitNode($child, $cssRules, $depth + 1); + $content .= $this->emitNode($child, $cssRules, $diagnostics, $depth + 1); + } + } + + if ( $this->isUnsupportedVectorType($type) ) { + $diagnostics[] = array( + 'severity' => 'warning', + 'code' => 'unsupported_vector_node_placeholder', + 'message' => 'Unsupported vector-like Figma node emitted as a static placeholder.', + 'node_id' => (string) ($node['id'] ?? ''), + 'type' => $type, + ); + + if ( '' === $content ) { + $content = 'Unsupported Figma ' . $this->sanitizeText($type) . ''; } } @@ -108,6 +123,9 @@ private function emitNode(array $node, array &$cssRules, int $depth): string if ( 'RECTANGLE' === $type && '' === $content ) { $attributes .= ' aria-hidden="true"'; } + if ( $this->isUnsupportedVectorType($type) ) { + $attributes .= ' data-figma-unsupported-vector="true" role="img" aria-label="Unsupported Figma ' . $this->sanitizeAttribute($type) . ' node"'; + } return sprintf("<%1\$s%2\$s>%3\$s\n", $tag, $attributes, $content); } @@ -453,7 +471,9 @@ private function normalizeAssets(mixed $assets, array &$diagnostics): array ); $files[] = $file; - $this->assetsById[$id] = $file; + foreach ( $this->assetAliases($asset, $id) as $alias ) { + $this->assetsById[$alias] = $file; + } } usort( @@ -490,12 +510,75 @@ private function assetReport(array $assetFiles): array */ private function nodeAssetPath(array $node): ?string { - $assetId = (string) ($node['asset_id'] ?? $node['assetId'] ?? $node['image_ref'] ?? $node['imageRef'] ?? ''); - if ( '' === $assetId || ! isset($this->assetsById[$assetId]) ) { - return null; + foreach ( $this->nodeAssetReferences($node) as $assetId ) { + if ( isset($this->assetsById[$assetId]) ) { + return (string) $this->assetsById[$assetId]['path']; + } + } + + return null; + } + + /** + * @param array $asset + * @return array + */ + private function assetAliases(array $asset, string $id): array + { + $aliases = array($id); + foreach ( array('hash', 'imageRef', 'imageHash', 'asset_id', 'image_ref', 'source_id') as $key ) { + if ( isset($asset[$key]) && is_scalar($asset[$key]) ) { + $aliases[] = (string) $asset[$key]; + } + } + + if ( isset($asset['path']) && is_scalar($asset['path']) ) { + $path = (string) $asset['path']; + $aliases[] = $path; + $aliases[] = basename($path); + $aliases[] = pathinfo($path, PATHINFO_FILENAME); + } + + return array_values(array_unique(array_filter($aliases, static fn (string $alias): bool => '' !== $alias))); + } + + /** + * @param array $node + * @return array + */ + private function nodeAssetReferences(array $node): array + { + $references = array(); + foreach ( array('asset_id', 'assetId', 'image_ref', 'imageRef', 'imageHash') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) ) { + $references[] = (string) $node[$key]; + } + } + + foreach ( array('fills', 'strokes', 'background') as $paintKey ) { + if ( ! is_array($node[$paintKey] ?? null) ) { + continue; + } + + foreach ( $node[$paintKey] as $paint ) { + if ( ! is_array($paint) || 'IMAGE' !== strtoupper((string) ($paint['type'] ?? '')) ) { + continue; + } + + foreach ( array('imageRef', 'imageHash', 'asset_id', 'image_ref') as $key ) { + if ( isset($paint[$key]) && is_scalar($paint[$key]) && '' !== (string) $paint[$key] ) { + $references[] = (string) $paint[$key]; + } + } + } } - return (string) $this->assetsById[$assetId]['path']; + return array_values(array_unique($references)); + } + + private function isUnsupportedVectorType(string $type): bool + { + return in_array($type, array('VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE', 'STAR', 'POLYGON', 'REGULAR_POLYGON'), true); } /** diff --git a/figma-transformer/src/Scenegraph/ScenegraphIndex.php b/figma-transformer/src/Scenegraph/ScenegraphIndex.php index 5e55bba..0933030 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphIndex.php +++ b/figma-transformer/src/Scenegraph/ScenegraphIndex.php @@ -55,10 +55,6 @@ public function build(array $source): array $childrenIndex[$parent] = $this->sortNodeIds($children, $nodeMap); } - foreach ( array_keys($nodeMap) as $id ) { - $nodeMap[$id]['children'] = $this->buildChildNodes($id, $nodeMap, $childrenIndex); - } - $topLevelNodeIds = array(); foreach ( $parentIndex as $id => $parent ) { if ( null === $parent || ! isset($nodeMap[$parent]) ) { @@ -67,6 +63,10 @@ public function build(array $source): array } $topLevelNodeIds = $this->sortNodeIds($topLevelNodeIds, $nodeMap); + foreach ( array_keys($nodeMap) as $id ) { + $nodeMap[$id] = $this->hydrateNode($id, $nodeMap, $childrenIndex); + } + return array( 'nodes' => $nodeMap, 'parent_index' => $parentIndex, @@ -78,23 +78,27 @@ public function build(array $source): array /** * @param array> $nodeMap - * @param array> $childrenIndex - * @return array> + * @param array> $childrenIndex + * @param array $trail + * @return array */ - private function buildChildNodes(string $id, array $nodeMap, array $childrenIndex): array + private function hydrateNode(string $id, array $nodeMap, array $childrenIndex, array $trail = array()): array { - $children = array(); + $node = $nodeMap[$id]; + $node['children'] = array(); + + if ( in_array($id, $trail, true) ) { + return $node; + } + + $trail[] = $id; foreach ( $childrenIndex[$id] ?? array() as $childId ) { - if ( ! isset($nodeMap[$childId]) ) { - continue; + if ( isset($nodeMap[$childId]) ) { + $node['children'][] = $this->hydrateNode($childId, $nodeMap, $childrenIndex, $trail); } - - $child = $nodeMap[$childId]; - $child['children'] = $this->buildChildNodes($childId, $nodeMap, $childrenIndex); - $children[] = $child; } - return $children; + return $node; } /** diff --git a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php index 54fff94..8731fcf 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -22,7 +22,8 @@ public function __construct( public function normalize(array $source, array $options = array()): array { $index = $this->index->build($source); - $nodeMap = $this->normalizeNodeMap($index['nodes'], $index['children_index']); + $diagnostics = $index['diagnostics']; + $nodeMap = $this->normalizeNodeMap($index['nodes'], $diagnostics); $topLevelIds = $index['top_level_node_ids']; $frameIds = $this->selectTopLevelFrameIds($topLevelIds, $nodeMap); @@ -35,9 +36,6 @@ public function normalize(array $source, array $options = array()): array $selectedFrameId = $topLevelIds[0]; } - $diagnostics = $index['diagnostics']; - $nodeMap = $this->normalizeNodeMap($nodeMap, $diagnostics); - $renderIds = $topLevelIds; $renderNodes = array(); foreach ( $renderIds as $id ) { @@ -56,7 +54,6 @@ public function normalize(array $source, array $options = array()): array 'assets' => is_array($source['assets'] ?? null) ? $source['assets'] : array(), 'nodes' => $renderNodes, 'node_map' => $nodeMap, - 'assets' => is_array($source['assets'] ?? null) ? $source['assets'] : array(), 'parent_index' => $index['parent_index'], 'children_index' => $index['children_index'], 'top_level_node_ids' => $topLevelIds, @@ -75,6 +72,7 @@ public function normalize(array $source, array $options = array()): array 'selected_frame_id' => $selectedFrameId, 'text_node_count' => count($textInventory), 'asset_reference_count' => count($assetReferences), + 'asset_references' => $assetReferences, 'diagnostic_count' => count($diagnostics), ), ); @@ -116,11 +114,21 @@ private function normalizeNode(array $node, array &$diagnostics): array $node['figma_paints'] = $paints; } - $box = $this->normalizeBox($node); + $box = $this->normalizeVisualBox($node); if ( ! empty($box) ) { $node['figma_box'] = $box; } + $layoutBox = $this->normalizeLayoutBox($node); + if ( ! empty($layoutBox) ) { + $node['box'] = $layoutBox; + } + + $layout = $this->normalizeLayout($node); + if ( ! empty($layout) ) { + $node['layout'] = $layout; + } + $this->diagnoseEffects($node, $id, $diagnostics); foreach ( array('children', 'nodes') as $childrenKey ) { @@ -364,7 +372,7 @@ private function normalizePaint(array $paint, string $nodeId, string $paintKey, * @param array $node * @return array */ - private function normalizeBox(array $node): array + private function normalizeVisualBox(array $node): array { $box = array(); @@ -479,46 +487,11 @@ private function selectTopLevelFrameIds(array $topLevelIds, array $nodeMap): arr * @param array> $childrenIndex * @return array> */ - private function normalizeNodeMap(array $nodeMap, array $childrenIndex): array - { - $normalized = array(); - - foreach ( $nodeMap as $id => $node ) { - $node['box'] = $this->normalizeBox($node); - $layout = $this->normalizeLayout($node); - if ( ! empty($layout) ) { - $node['layout'] = $layout; - } - - $node['children'] = array(); - $normalized[$id] = $node; - } - - $buildNode = function (string $id) use (&$buildNode, &$normalized, $childrenIndex): array { - $node = $normalized[$id]; - $node['children'] = array(); - - foreach ( $childrenIndex[$id] ?? array() as $childId ) { - if ( isset($normalized[$childId]) ) { - $node['children'][] = $buildNode($childId); - } - } - - return $node; - }; - - foreach ( array_keys($normalized) as $id ) { - $normalized[$id] = $buildNode((string) $id); - } - - return $normalized; - } - /** * @param array $node * @return array */ - private function normalizeBox(array $node): array + private function normalizeLayoutBox(array $node): array { $box = array(); @@ -692,12 +665,13 @@ private function buildAssetReferences(array $nodeMap): array continue; } - $ref = $paint['imageRef'] ?? $paint['imageHash'] ?? null; - if ( is_scalar($ref) && '' !== (string) $ref ) { + $reference = $this->readImageReference($paint); + if ( null !== $reference ) { $references[] = array( - 'node_id' => $id, - 'paint' => $paintKey, - 'ref' => (string) $ref, + 'node_id' => $id, + 'paint' => $paintKey, + 'source_key' => $reference['source_key'], + 'ref' => $reference['ref'], ); } } @@ -707,6 +681,24 @@ private function buildAssetReferences(array $nodeMap): array return $references; } + /** + * @param array $paint + * @return array{source_key: string, ref: string}|null + */ + private function readImageReference(array $paint): ?array + { + foreach ( array('imageRef', 'imageHash', 'asset_id', 'image_ref') as $key ) { + if ( isset($paint[$key]) && is_scalar($paint[$key]) && '' !== (string) $paint[$key] ) { + return array( + 'source_key' => $key, + 'ref' => (string) $paint[$key], + ); + } + } + + return null; + } + /** * @param array $source * @param array> $renderNodes diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index f2807a3..d8b279f 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -144,6 +144,9 @@ $assert(! empty($fileResult['files']), 'file-transform-renders-decoded-scenegraph'); $assert(4 === ($fileResult['metrics']['node_count'] ?? null), 'file-transform-node-count'); $assert(isset($fileResult['source_reports']['figma']['html']), 'file-transform-html-source-report'); +$assert('synthetic' === ($fileResult['source_reports']['figma']['assets'][0]['id'] ?? null), 'archive-asset-id'); +$assert('images/synthetic' === ($fileResult['source_reports']['figma']['assets'][0]['path'] ?? null), 'archive-asset-path'); +$assert('asset' === ($fileResult['source_reports']['figma']['assets'][0]['content'] ?? null), 'archive-asset-content'); $nodeChangesResult = blocks_engine_figma_transformer_transform_scenegraph(array( 'name' => 'Node Changes Fixture', @@ -269,6 +272,61 @@ $assert(in_array('unsupported_figma_paint_type', $metadataDiagnosticCodes, true), 'unsupported-paint-diagnostic'); $assert(in_array('unsupported_figma_effect_type', $metadataDiagnosticCodes, true), 'unsupported-effect-diagnostic'); +$assetReferenceResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Asset Reference Fixture', + 'assets' => array( + 'image-hash-1' => array( + 'id' => 'image-hash-1', + 'hash' => 'image-hash-1', + 'name' => 'Archive Image', + 'mime_type' => 'image/png', + 'content' => 'png-bytes', + ), + ), + 'nodes' => array( + array( + 'id' => '4:1', + 'type' => 'FRAME', + 'name' => 'Asset Frame', + 'children' => array( + array( + 'id' => '4:2', + 'type' => 'RECTANGLE', + 'name' => 'Image Fill', + 'width' => 20, + 'height' => 20, + 'fills' => array( + array('type' => 'IMAGE', 'imageHash' => 'image-hash-1'), + ), + ), + array( + 'id' => '4:3', + 'type' => 'VECTOR', + 'name' => 'Icon Vector', + 'width' => 10, + 'height' => 10, + ), + ), + ), + ), +)); + +$assetReferenceCss = $fileContent($assetReferenceResult, 'style.css'); +$assetReferenceHtml = $fileContent($assetReferenceResult, 'index.html'); +$assetReferenceReport = $assetReferenceResult['source_reports']['figma']['scenegraph'] ?? array(); +$assetReferenceDiagnosticCodes = array_map( + static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), + $assetReferenceResult['diagnostics'] ?? array() +); + +$assert(1 === ($assetReferenceResult['metrics']['asset_reference_count'] ?? null), 'normalized-image-reference-count'); +$assert('imageHash' === ($assetReferenceReport['asset_references'][0]['source_key'] ?? null), 'normalized-image-reference-source-key'); +$assert('image-hash-1' === ($assetReferenceReport['asset_references'][0]['ref'] ?? null), 'normalized-image-reference-ref'); +$assert(str_contains($assetReferenceCss, 'background-image:url("assets/archive-image.png")'), 'normalized-image-reference-css'); +$assert(str_contains($assetReferenceHtml, 'data-figma-unsupported-vector="true"'), 'unsupported-vector-placeholder-html'); +$assert(str_contains($assetReferenceHtml, 'Unsupported Figma VECTOR'), 'unsupported-vector-placeholder-text'); +$assert(in_array('unsupported_vector_node_placeholder', $assetReferenceDiagnosticCodes, true), 'unsupported-vector-diagnostic'); + if ( ! empty($failures) ) { fwrite(STDERR, "Figma Transformer contract failures:\n- " . implode("\n- ", $failures) . "\n"); exit(1); From 77de71c7fa045eb72b3753feb5d9ec75d59ca3d4 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:11:14 -0400 Subject: [PATCH 09/31] Strengthen figma transformer parity contract --- docs/contracts/figma-transformer-result.md | 42 ++++++++++++++++++- figma-transformer/README.md | 36 +++++++++++++++- .../src/Parity/ParityReportBuilder.php | 23 ++++++---- .../src/Scenegraph/ScenegraphNormalizer.php | 1 + figma-transformer/tests/contract/run.php | 39 +++++++++++++++++ 5 files changed, 132 insertions(+), 9 deletions(-) diff --git a/docs/contracts/figma-transformer-result.md b/docs/contracts/figma-transformer-result.md index 2c2c124..86cb173 100644 --- a/docs/contracts/figma-transformer-result.md +++ b/docs/contracts/figma-transformer-result.md @@ -4,6 +4,15 @@ The contract is intentionally static-HTML-first. Downstream products can pass generated HTML to `php-transformer`, Static Site Importer, Studio, or any other materialization layer. +The package boundary is: + +```text +.fig file or decoded scenegraph + -> normalized Figma IR + -> static HTML/CSS/assets artifact + -> php-transformer handles WordPress block conversion later +``` + Required top-level fields: - `schema`: `blocks-engine/figma-transformer/result/v1` @@ -15,4 +24,35 @@ Required top-level fields: - `parity`: `blocks-engine/figma-transformer/parity-report/v1` - `metrics`: package-level transform metrics -The parity report records source screenshot evidence, generated HTML screenshot evidence, side-by-side output, diff output, and runner-supplied metrics. Browser-heavy parity runners should persist artifacts through Homeboy or another reviewable artifact surface. +## Parity Report + +The parity report records source screenshot evidence, generated HTML screenshot evidence, side-by-side output, diff output, diff summaries, artifact paths, and runner-supplied metrics. Browser-heavy parity runners should persist artifacts through Homeboy or another reviewable artifact surface. + +Required parity fields: + +- `schema`: `blocks-engine/figma-transformer/parity-report/v1` +- `status`: `not_run`, `pending`, or `compared` +- `reason`: stable runner-readable reason string when useful +- `artifacts`: paths or URLs for report-level artifacts supplied by the caller +- `source`: source-design evidence such as screenshot path, viewport, frame ID, or capture metadata +- `generated`: generated HTML evidence such as screenshot path, viewport, URL, or capture metadata +- `side_by_side`: optional side-by-side artifact metadata +- `diff`: optional visual diff artifact metadata +- `diff_summary`: optional compact diff summary such as changed pixels, threshold, or ratio +- `metrics`: optional runner metrics + +Status meanings: + +- `not_run`: no parity runner has executed for this transform. +- `pending`: parity work is queued, external, or otherwise incomplete. +- `compared`: caller-supplied evidence describes a completed source-vs-generated comparison. + +## `.fig` Decoder Limits + +Arbitrary `.fig` files are not fully decoded by the PHP-native package yet. Current `.fig` support safely opens `.fig` files or wrapper ZIPs, identifies nested `.fig` entries, reports `fig-kiwi` prelude/version/chunk metadata, inventories embedded files/assets, and records compression diagnostics. + +Next decoder milestones are schema chunk parsing, Zstandard message decoding when supported by the runtime, mapping decoded Kiwi messages into normalized IR, and expanding layout/paint/text/component/asset coverage against external real-file evidence. + +## Fixture Strategy + +Repository tests should use small synthetic fixtures for contract shape, deterministic output, and decoder safety. Large real `.fig` files should stay out of git. Real-design parity evidence should be generated externally, usually through Homeboy, and attached to the relevant issue or PR as reviewable artifacts. diff --git a/figma-transformer/README.md b/figma-transformer/README.md index 84088fb..558d540 100644 --- a/figma-transformer/README.md +++ b/figma-transformer/README.md @@ -4,6 +4,19 @@ Figma Transformer is a PHP primitive for converting Figma designs into static HT This package is intentionally WordPress-native and product-neutral. It owns Figma intake, scenegraph normalization, static HTML artifact generation, and visual-parity report contracts. It does not own WordPress page creation, block conversion, theme activation, Studio orchestration, or Static Site Importer UI. +## Package Contract + +The package contract is: + +```text +.fig file or decoded scenegraph + -> normalized Figma IR + -> static HTML/CSS/assets artifact + -> php-transformer converts static artifacts to WordPress blocks later +``` + +`figma-transformer` stops at static website artifacts. WordPress block materialization remains a downstream `php-transformer` responsibility so Figma intake, HTML parity, and WordPress block conversion can evolve independently. + ## Boundary Figma Transformer owns reusable transformation primitives: @@ -50,6 +63,17 @@ This package currently scaffolds the public API and `.fig` intake contract. Full The first target fixture is a local `.fig_.zip` export containing `Fisiostetic.fig`, whose inner `canvas.fig` starts with `fig-kiwi` and uses a raw-deflate schema chunk plus a Zstandard-compressed message chunk. +### Current `.fig` Support Limits + +Arbitrary `.fig` files are not fully decoded today. The current file path is an intake and diagnostics layer that safely opens `.fig` or wrapper ZIP files, identifies nested `.fig` entries, reports `fig-kiwi` metadata, inventories embedded files/assets, and records compression capabilities. It does not yet reconstruct a complete Figma scenegraph from production Kiwi payloads. + +Next decoder milestones: + +- Parse the raw-deflate schema chunk into a useful schema model. +- Decode Zstandard message chunks when the PHP runtime has a supported Zstandard capability. +- Map decoded Kiwi messages into the normalized IR already accepted by `transformScenegraph()`. +- Expand layout, paint, text, component, and asset coverage against external real-file evidence. + ## Output Contract Successful transforms produce a static website artifact: @@ -75,4 +99,14 @@ The result envelope includes: ## Parity Contract -Visual parity is measured outside the WordPress import path. The package records source and generated screenshot evidence, side-by-side comparisons, diff images, and metrics supplied by the parity runner. Homeboy is the expected runner for browser-heavy parity workflows; WordPress-only consumers can still read and display the parity report. +Visual parity is measured outside the WordPress import path. The package records source and generated screenshot evidence, side-by-side comparisons, diff images, diff summaries, artifact paths, and metrics supplied by the parity runner. Homeboy is the expected runner for browser-heavy parity workflows; WordPress-only consumers can still read and display the parity report. + +Parity report statuses: + +- `not_run`: no parity runner has executed for this transform. +- `pending`: parity work is queued, external, or otherwise incomplete. +- `compared`: caller-supplied source/generated evidence and diff data describe a completed comparison. + +## Fixture Strategy + +Contract tests use small synthetic fixtures that exercise the public envelope, archive safety, deterministic HTML/CSS output, and parity report shape. Large real `.fig` exports are not committed to the repository. When real-file parity evidence is needed, generate it externally through Homeboy or another reviewable artifact surface and attach the resulting reports/screenshots to the relevant issue or PR. diff --git a/figma-transformer/src/Parity/ParityReportBuilder.php b/figma-transformer/src/Parity/ParityReportBuilder.php index 3fe5080..bb3d197 100644 --- a/figma-transformer/src/Parity/ParityReportBuilder.php +++ b/figma-transformer/src/Parity/ParityReportBuilder.php @@ -11,6 +11,8 @@ final class ParityReportBuilder { public const SCHEMA = 'blocks-engine/figma-transformer/parity-report/v1'; + private const KNOWN_STATUSES = array('not_run', 'pending', 'compared'); + /** * @param array $evidence * @param array $overrides @@ -18,15 +20,22 @@ final class ParityReportBuilder */ public function build(array $evidence = array(), array $overrides = array()): array { + $status = (string) ($overrides['status'] ?? $evidence['status'] ?? 'not_run'); + if ( ! in_array($status, self::KNOWN_STATUSES, true) ) { + $status = 'pending'; + } + return array( - 'schema' => self::SCHEMA, - 'status' => (string) ($overrides['status'] ?? $evidence['status'] ?? 'not_run'), - 'reason' => (string) ($overrides['reason'] ?? $evidence['reason'] ?? ''), - 'source' => $evidence['source'] ?? array(), - 'generated' => $evidence['generated'] ?? array(), + 'schema' => self::SCHEMA, + 'status' => $status, + 'reason' => (string) ($overrides['reason'] ?? $evidence['reason'] ?? ''), + 'artifacts' => $evidence['artifacts'] ?? array(), + 'source' => $evidence['source'] ?? array(), + 'generated' => $evidence['generated'] ?? array(), 'side_by_side' => $evidence['side_by_side'] ?? null, - 'diff' => $evidence['diff'] ?? null, - 'metrics' => $evidence['metrics'] ?? array(), + 'diff' => $evidence['diff'] ?? null, + 'diff_summary' => $evidence['diff_summary'] ?? array(), + 'metrics' => $evidence['metrics'] ?? array(), ); } } diff --git a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php index 8731fcf..31d3687 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -53,6 +53,7 @@ public function normalize(array $source, array $options = array()): array 'name' => $sourceName, 'assets' => is_array($source['assets'] ?? null) ? $source['assets'] : array(), 'nodes' => $renderNodes, + 'assets' => is_array($source['assets'] ?? null) ? $source['assets'] : array(), 'node_map' => $nodeMap, 'parent_index' => $index['parent_index'], 'children_index' => $index['children_index'], diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index d8b279f..2d2dbd9 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/../../figma-transformer.php'; use Automattic\BlocksEngine\FigmaTransformer\Compression\ZstdCapability; +use Automattic\BlocksEngine\FigmaTransformer\Parity\ParityReportBuilder; $failures = array(); @@ -114,6 +115,44 @@ $assert(in_array('scenegraph_node_id_duplicate', $diagnosticCodes, true), 'duplicate-node-diagnostic'); $assert(($result['files'] ?? array()) === ($sameResult['files'] ?? array()), 'deterministic-files'); $assert('blocks-engine/figma-transformer/parity-report/v1' === ($result['parity']['schema'] ?? null), 'parity-schema'); +$assert('not_run' === ($result['parity']['status'] ?? null), 'parity-default-not-run'); + +$parityBuilder = new ParityReportBuilder(); +$pendingParity = $parityBuilder->build(array( + 'status' => 'pending', + 'reason' => 'queued_for_browser_runner', + 'artifacts' => array( + 'report_path' => 'artifacts/parity-report.json', + ), +)); +$comparedParity = $parityBuilder->build(array( + 'status' => 'compared', + 'artifacts' => array( + 'source_screenshot_path' => 'artifacts/source.png', + 'generated_screenshot_path' => 'artifacts/generated.png', + 'diff_image_path' => 'artifacts/diff.png', + ), + 'source' => array( + 'screenshot_path' => 'artifacts/source.png', + ), + 'generated' => array( + 'screenshot_path' => 'artifacts/generated.png', + ), + 'diff_summary' => array( + 'changed_pixels' => 42, + 'threshold' => 0.02, + ), + 'metrics' => array( + 'pixel_diff_ratio' => 0.01, + ), +)); +$assert('pending' === ($pendingParity['status'] ?? null), 'parity-pending-status'); +$assert('artifacts/parity-report.json' === ($pendingParity['artifacts']['report_path'] ?? null), 'parity-pending-artifact-path'); +$assert('compared' === ($comparedParity['status'] ?? null), 'parity-compared-status'); +$assert('artifacts/source.png' === ($comparedParity['source']['screenshot_path'] ?? null), 'parity-source-screenshot-path'); +$assert('artifacts/generated.png' === ($comparedParity['generated']['screenshot_path'] ?? null), 'parity-generated-screenshot-path'); +$assert(42 === ($comparedParity['diff_summary']['changed_pixels'] ?? null), 'parity-diff-summary'); +$assert(0.01 === ($comparedParity['metrics']['pixel_diff_ratio'] ?? null), 'parity-metric'); $fixture = blocks_engine_figma_transformer_create_fig_wrapper_fixture(); $fileResult = blocks_engine_figma_transformer_transform_file($fixture); From c594c574020a20da1e23f1309b6d659e7eb0af31 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:27:20 -0400 Subject: [PATCH 10/31] Fix figma transformer integration assertions --- figma-transformer/tests/contract/run.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 2d2dbd9..abe941a 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -73,6 +73,7 @@ ), ), ), + array('id' => '1:2', 'type' => 'TEXT', 'name' => 'Duplicate title', 'text' => 'Duplicate'), ), ); @@ -305,7 +306,7 @@ ); $assert(str_contains($metadataHtml, 'Hello World'), 'styled-text-segments-emit'); -$assert(str_contains($metadataCss, '.figma-node-4-1-metadata-frame{background:rgba(51,102,153,0.5);opacity:0.75;border-radius:12px;border:2px solid #000000;display:flex;flex-direction:column}'), 'normalized-frame-paint-box-style'); +$assert(str_contains($metadataCss, '.figma-node-4-1-metadata-frame{background:rgba(51,102,153,0.5);opacity:0.75;border-radius:12px;border:2px solid #000000}'), 'normalized-frame-paint-box-style'); $assert(str_contains($metadataCss, '.figma-node-4-2-mixed-text{font-family:"Example Sans";font-size:20px;font-weight:600;line-height:125%;letter-spacing:0.5px;color:rgba(255,128,0,0.8);text-align:center;vertical-align:top;text-decoration:underline}'), 'normalized-text-style'); $assert(str_contains($metadataCss, '.figma-node-4-3-uneven-radius{border-top-left-radius:4px;border-top-right-radius:8px;border-bottom-right-radius:12px;border-bottom-left-radius:16px}'), 'individual-radius-style'); $assert(in_array('unsupported_figma_paint_type', $metadataDiagnosticCodes, true), 'unsupported-paint-diagnostic'); From 101f934b658e1a7c29b10e5cd3b3cca3df628b6d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:40:40 -0400 Subject: [PATCH 11/31] Add fig kiwi binary envelope metadata --- .../src/FigFile/FigKiwiParser.php | 142 ++++++++++++++++++ figma-transformer/tests/contract/run.php | 41 +++++ 2 files changed, 183 insertions(+) diff --git a/figma-transformer/src/FigFile/FigKiwiParser.php b/figma-transformer/src/FigFile/FigKiwiParser.php index 57536cf..d59277f 100644 --- a/figma-transformer/src/FigFile/FigKiwiParser.php +++ b/figma-transformer/src/FigFile/FigKiwiParser.php @@ -13,6 +13,11 @@ final class FigKiwiParser { private const PRELUDE = 'fig-kiwi'; private const ZSTD_MAGIC = "\x28\xb5\x2f\xfd"; + private const WIRE_TYPE_VARINT = 0; + private const WIRE_TYPE_FIXED64 = 1; + private const WIRE_TYPE_LENGTH_DELIMITED = 2; + private const WIRE_TYPE_FIXED32 = 5; + private const WIRE_RECORD_LIMIT = 64; public function __construct( private readonly ZstdCapability $zstdCapability = new ZstdCapability() @@ -152,6 +157,11 @@ private function classifyPayload(string $payload): array ); if ( 'json_invalid' !== $classification ) { + $wire = $this->describeWirePayload($payload); + if ( $wire['record_count'] > 0 ) { + $metadata['wire'] = $wire; + } + return $metadata; } @@ -169,6 +179,138 @@ private function classifyPayload(string $payload): array return $metadata; } + /** + * Describes protobuf-style wire records without claiming a schema-level decode. + * + * @return array + */ + private function describeWirePayload(string $payload): array + { + $bytes = strlen($payload); + $offset = 0; + $records = array(); + $truncated = false; + $reason = null; + + while ( $offset < $bytes && count($records) < self::WIRE_RECORD_LIMIT ) { + $recordOffset = $offset; + $key = $this->readVarint($payload, $offset); + if ( null === $key ) { + $truncated = true; + $reason = 'truncated_key_varint'; + break; + } + + if ( 0 === $key['value'] ) { + $reason = 'zero_field_key'; + break; + } + + $fieldNumber = intdiv($key['value'], 8); + $wireType = $key['value'] % 8; + if ( 0 === $fieldNumber ) { + $reason = 'zero_field_number'; + break; + } + + $record = array( + 'offset' => $recordOffset, + 'field_number' => $fieldNumber, + 'wire_type' => $wireType, + ); + + if ( self::WIRE_TYPE_VARINT === $wireType ) { + $value = $this->readVarint($payload, $offset); + if ( null === $value ) { + $truncated = true; + $reason = 'truncated_varint_value'; + break; + } + $record['value'] = $value['value']; + } elseif ( self::WIRE_TYPE_FIXED64 === $wireType ) { + if ( $offset + 8 > $bytes ) { + $truncated = true; + $reason = 'truncated_fixed64_value'; + break; + } + $record['bytes'] = 8; + $record['preview_hex'] = bin2hex(substr($payload, $offset, 8)); + $offset += 8; + } elseif ( self::WIRE_TYPE_LENGTH_DELIMITED === $wireType ) { + $length = $this->readVarint($payload, $offset); + if ( null === $length ) { + $truncated = true; + $reason = 'truncated_length_varint'; + break; + } + if ( $offset + $length['value'] > $bytes ) { + $truncated = true; + $reason = 'truncated_length_delimited_value'; + break; + } + $value = substr($payload, $offset, $length['value']); + $record['bytes'] = $length['value']; + $record['preview_hex'] = bin2hex(substr($value, 0, 32)); + if ( '' !== $value && preg_match('/\A[\x09\x0a\x0d\x20-\x7e]*\z/', $value) ) { + $record['text_preview'] = substr($value, 0, 64); + } + $offset += $length['value']; + } elseif ( self::WIRE_TYPE_FIXED32 === $wireType ) { + if ( $offset + 4 > $bytes ) { + $truncated = true; + $reason = 'truncated_fixed32_value'; + break; + } + $record['bytes'] = 4; + $record['preview_hex'] = bin2hex(substr($payload, $offset, 4)); + $offset += 4; + } else { + $reason = 'unsupported_wire_type'; + break; + } + + $records[] = $record; + } + + return array( + 'format' => 'protobuf_wire', + 'complete' => $offset === $bytes && null === $reason, + 'truncated' => $truncated, + 'record_count' => count($records), + 'records' => $records, + 'next_offset' => $offset, + 'reason' => $reason, + ); + } + + /** + * @return array{value: int, bytes: int}|null + */ + private function readVarint(string $payload, int &$offset): ?array + { + $value = 0; + $shift = 0; + $start = $offset; + $bytes = strlen($payload); + + while ( $offset < $bytes && $shift <= 63 ) { + $byte = ord($payload[$offset]); + $offset++; + $value += ($byte & 0x7f) << $shift; + + if ( 0 === ($byte & 0x80) ) { + return array( + 'value' => $value, + 'bytes' => $offset - $start, + ); + } + + $shift += 7; + } + + return null; + } + private function looksJsonLike(string $payload): bool { $trimmed = ltrim($payload); diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index abe941a..801a703 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/../../figma-transformer.php'; use Automattic\BlocksEngine\FigmaTransformer\Compression\ZstdCapability; +use Automattic\BlocksEngine\FigmaTransformer\FigFile\FigKiwiParser; use Automattic\BlocksEngine\FigmaTransformer\Parity\ParityReportBuilder; $failures = array(); @@ -188,6 +189,31 @@ $assert('images/synthetic' === ($fileResult['source_reports']['figma']['assets'][0]['path'] ?? null), 'archive-asset-path'); $assert('asset' === ($fileResult['source_reports']['figma']['assets'][0]['content'] ?? null), 'archive-asset-content'); +$wirePayload = blocks_engine_figma_transformer_wire_varint(8) + . blocks_engine_figma_transformer_wire_varint(150) + . blocks_engine_figma_transformer_wire_varint(18) + . blocks_engine_figma_transformer_wire_varint(5) + . 'hello' + . blocks_engine_figma_transformer_wire_varint(29) + . "\x01\x02\x03\x04"; +$wireCanvasResult = ( new FigKiwiParser() )->parse( + 'fig-kiwi' + . pack('V', 106) + . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate($wirePayload)) +); +$wire = $wireCanvasResult['canvas']['chunks'][0]['payload']['wire'] ?? array(); +$wireRecords = $wire['records'] ?? array(); + +$assert('binary' === ($wireCanvasResult['canvas']['chunks'][0]['payload']['classification'] ?? null), 'fig-kiwi-wire-payload-remains-binary'); +$assert('protobuf_wire' === ($wire['format'] ?? null), 'fig-kiwi-wire-format'); +$assert(true === ($wire['complete'] ?? null), 'fig-kiwi-wire-complete'); +$assert(3 === ($wire['record_count'] ?? null), 'fig-kiwi-wire-record-count'); +$assert(1 === ($wireRecords[0]['field_number'] ?? null), 'fig-kiwi-wire-varint-field-number'); +$assert(0 === ($wireRecords[0]['wire_type'] ?? null), 'fig-kiwi-wire-varint-type'); +$assert(150 === ($wireRecords[0]['value'] ?? null), 'fig-kiwi-wire-varint-value'); +$assert('hello' === ($wireRecords[1]['text_preview'] ?? null), 'fig-kiwi-wire-length-text-preview'); +$assert('01020304' === ($wireRecords[2]['preview_hex'] ?? null), 'fig-kiwi-wire-fixed32-preview'); + $nodeChangesResult = blocks_engine_figma_transformer_transform_scenegraph(array( 'name' => 'Node Changes Fixture', 'NODE_CHANGES' => array( @@ -415,6 +441,21 @@ function blocks_engine_figma_transformer_kiwi_chunk(string $payload): string return pack('V', strlen($payload)) . $payload; } +function blocks_engine_figma_transformer_wire_varint(int $value): string +{ + $bytes = ''; + do { + $byte = $value & 0x7f; + $value = intdiv($value, 128); + if ( $value > 0 ) { + $byte |= 0x80; + } + $bytes .= chr($byte); + } while ( $value > 0 ); + + return $bytes; +} + /** * @return array */ From e3232ce8c3cf7ac5f3fdb2e207a48feee998ffcb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:40:40 -0400 Subject: [PATCH 12/31] Strengthen fig zstd runtime diagnostics --- figma-transformer/composer.json | 3 + .../src/Compression/ZstdCapability.php | 63 +++++++++++++++++-- figma-transformer/tests/contract/run.php | 30 ++++++++- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/figma-transformer/composer.json b/figma-transformer/composer.json index f187084..1b0cd2b 100644 --- a/figma-transformer/composer.json +++ b/figma-transformer/composer.json @@ -32,6 +32,9 @@ "require": { "php": ">=8.1" }, + "suggest": { + "ext-zstd": "Decode zstd-compressed canvas.fig chunks in native Figma .fig archives." + }, "bin": [ "bin/figma-transformer" ], diff --git a/figma-transformer/src/Compression/ZstdCapability.php b/figma-transformer/src/Compression/ZstdCapability.php index 065b365..5d5b2cf 100644 --- a/figma-transformer/src/Compression/ZstdCapability.php +++ b/figma-transformer/src/Compression/ZstdCapability.php @@ -11,7 +11,26 @@ final class ZstdCapability { public function isAvailable(): bool { - return extension_loaded('zstd') && function_exists('zstd_uncompress'); + $status = $this->status(); + return true === $status['available']; + } + + /** + * @return array{available: bool, extension_loaded: bool, extension_version: string|null, functions: array} + */ + public function status(): array + { + $extensionLoaded = extension_loaded('zstd'); + + return array( + 'available' => $extensionLoaded && function_exists('zstd_uncompress'), + 'extension_loaded' => $extensionLoaded, + 'extension_version' => $extensionLoaded ? phpversion('zstd') ?: null : null, + 'functions' => array( + 'zstd_compress' => function_exists('zstd_compress'), + 'zstd_uncompress' => function_exists('zstd_uncompress'), + ), + ); } /** @@ -26,7 +45,28 @@ public function uncompress(string $payload, string $source, int $chunkIndex): ar ); } - $decoded = zstd_uncompress($payload); + try { + $decoded = zstd_uncompress($payload); + } catch ( \Throwable $throwable ) { + return array( + 'data' => null, + 'diagnostics' => array( + array( + 'code' => 'figma_transformer_zstd_uncompress_failed', + 'message' => 'Zstandard chunk detected but ext-zstd raised an error while decoding the payload.', + 'source' => $source, + 'context' => array_merge( + array( + 'chunk_index' => $chunkIndex, + 'error' => $throwable->getMessage(), + ), + $this->status() + ), + ), + ), + ); + } + if ( false === $decoded ) { return array( 'data' => null, @@ -35,7 +75,7 @@ public function uncompress(string $payload, string $source, int $chunkIndex): ar 'code' => 'figma_transformer_zstd_uncompress_failed', 'message' => 'Zstandard chunk detected but ext-zstd could not decode the payload.', 'source' => $source, - 'context' => array('chunk_index' => $chunkIndex), + 'context' => array_merge(array('chunk_index' => $chunkIndex), $this->status()), ), ), ); @@ -52,12 +92,23 @@ public function uncompress(string $payload, string $source, int $chunkIndex): ar */ public function diagnostic(string $source, int $chunkIndex): array { - if ( $this->isAvailable() ) { + $status = $this->status(); + + if ( true === $status['available'] ) { return array( 'code' => 'figma_transformer_zstd_available', 'message' => 'Zstandard chunk detected and ext-zstd is available.', 'source' => $source, - 'context' => array('chunk_index' => $chunkIndex), + 'context' => array_merge(array('chunk_index' => $chunkIndex), $status), + ); + } + + if ( true === $status['extension_loaded'] ) { + return array( + 'code' => 'figma_transformer_zstd_function_missing', + 'message' => 'Zstandard chunk detected; ext-zstd is loaded but zstd_uncompress is unavailable.', + 'source' => $source, + 'context' => array_merge(array('chunk_index' => $chunkIndex), $status), ); } @@ -65,7 +116,7 @@ public function diagnostic(string $source, int $chunkIndex): array 'code' => 'figma_transformer_zstd_extension_missing', 'message' => 'Zstandard chunk detected; install ext-zstd to decode zstd-compressed fig-kiwi chunks.', 'source' => $source, - 'context' => array('chunk_index' => $chunkIndex), + 'context' => array_merge(array('chunk_index' => $chunkIndex), $status), ); } } diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 801a703..2b1e090 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -166,9 +166,17 @@ static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), $fileResult['diagnostics'] ?? array() ); -$zstdCapabilityCode = ( new ZstdCapability() )->isAvailable() - ? 'figma_transformer_zstd_available' - : 'figma_transformer_zstd_extension_missing'; +$zstdCapability = new ZstdCapability(); +$zstdStatus = $zstdCapability->status(); +$zstdCapabilityDiagnostic = $zstdCapability->diagnostic('ContractTest', 0); +$zstdCapabilityCode = (string) ($zstdCapabilityDiagnostic['code'] ?? ''); +$zstdDiagnostic = null; +foreach ( $fileResult['diagnostics'] ?? array() as $diagnostic ) { + if ( $zstdCapabilityCode === ($diagnostic['code'] ?? null) ) { + $zstdDiagnostic = $diagnostic; + break; + } +} $assert('success_with_warnings' === ($fileResult['status'] ?? null), 'file-transform-status'); $assert('fig-kiwi' === ($canvas['prelude'] ?? null), 'fig-kiwi-prelude'); @@ -182,6 +190,22 @@ $assert('binary' === ($chunks[2]['payload']['classification'] ?? null), 'fig-kiwi-third-chunk-binary'); $assert('zstd' === ($chunks[3]['compression'] ?? null), 'fig-kiwi-fourth-chunk-zstd'); $assert(in_array($zstdCapabilityCode, $diagnosticCodes, true), 'fig-kiwi-zstd-capability-diagnostic'); +$assert(is_bool($zstdStatus['available'] ?? null), 'zstd-status-available-bool'); +$assert(is_bool($zstdStatus['extension_loaded'] ?? null), 'zstd-status-extension-loaded-bool'); +$assert(is_array($zstdStatus['functions'] ?? null), 'zstd-status-functions-array'); +$assert(array_key_exists('zstd_uncompress', $zstdStatus['functions'] ?? array()), 'zstd-status-uncompress-function'); +$assert(($zstdStatus['available'] ?? null) === (($zstdStatus['extension_loaded'] ?? null) && ($zstdStatus['functions']['zstd_uncompress'] ?? null)), 'zstd-status-available-matches-runtime'); +$assert(($zstdStatus['available'] ?? null) === ($zstdDiagnostic['context']['available'] ?? null), 'fig-kiwi-zstd-diagnostic-availability-context'); +if ( true === ($zstdStatus['available'] ?? false) && function_exists('zstd_compress') ) { + $zstdCompressed = zstd_compress('contract zstd round trip'); + $zstdRoundTrip = false !== $zstdCompressed ? $zstdCapability->uncompress($zstdCompressed, 'ContractTest', 1) : array('data' => null, 'diagnostics' => array()); + $assert('contract zstd round trip' === ($zstdRoundTrip['data'] ?? null), 'zstd-real-round-trip'); + $assert(isset($chunks[3]['inflated_bytes']), 'fig-kiwi-zstd-real-fixture-inflated'); +} else { + $zstdUnavailable = $zstdCapability->uncompress("\x28\xb5\x2f\xfd" . 'synthetic-zstd-frame', 'ContractTest', 1); + $assert(null === ($zstdUnavailable['data'] ?? null), 'zstd-unavailable-returns-null'); + $assert(in_array((string) ($zstdUnavailable['diagnostics'][0]['code'] ?? ''), array('figma_transformer_zstd_extension_missing', 'figma_transformer_zstd_function_missing'), true), 'zstd-unavailable-diagnostic-code'); +} $assert(! empty($fileResult['files']), 'file-transform-renders-decoded-scenegraph'); $assert(4 === ($fileResult['metrics']['node_count'] ?? null), 'file-transform-node-count'); $assert(isset($fileResult['source_reports']['figma']['html']), 'file-transform-html-source-report'); From de1a0063c8cdb3ebe37d6aaa7a852294cdb115bc Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:40:19 -0400 Subject: [PATCH 13/31] Improve Figma layout CSS emission --- .../src/Html/StaticHtmlEmitter.php | 176 ++++++++++++++++-- .../src/Scenegraph/ScenegraphIndex.php | 10 +- .../src/Scenegraph/ScenegraphNormalizer.php | 66 ++++++- figma-transformer/tests/contract/run.php | 79 ++++++++ 4 files changed, 316 insertions(+), 15 deletions(-) diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index 1247031..a1ac650 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -38,7 +38,7 @@ public function emit(array $scenegraph, array $options = array()): array if ( ! is_array($node) ) { continue; } - $body .= $this->emitNode($node, $cssRules, $diagnostics, 0); + $body .= $this->emitNode($node, $cssRules, $diagnostics, 0, null); } $files = array( @@ -82,7 +82,7 @@ public function emit(array $scenegraph, array $options = array()): array * @param array $cssRules * @param array> $diagnostics */ - private function emitNode(array $node, array &$cssRules, array &$diagnostics, int $depth): string + private function emitNode(array $node, array &$cssRules, array &$diagnostics, int $depth, ?array $parentNode): string { $id = $this->sanitizeAttribute((string) ($node['id'] ?? '')); $name = (string) ($node['name'] ?? ''); @@ -96,7 +96,7 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, in foreach ( $children as $child ) { if ( is_array($child) ) { - $content .= $this->emitNode($child, $cssRules, $diagnostics, $depth + 1); + $content .= $this->emitNode($child, $cssRules, $diagnostics, $depth + 1, $node); } } @@ -114,7 +114,7 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, in } } - $styles = $this->styleDeclarations($node, $type); + $styles = $this->styleDeclarations($node, $type, $parentNode); if ( ! empty($styles) ) { $cssRules[] = '.' . $className . '{' . implode(';', $styles) . '}'; } @@ -169,24 +169,36 @@ private function tagName(string $type, string $name, int $depth): string * @param array $node * @return array */ - private function styleDeclarations(array $node, string $type): array + private function styleDeclarations(array $node, string $type, ?array $parentNode): array { $styles = array(); $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); foreach ( array('width', 'height') as $dimension ) { - if ( isset($box[$dimension]) && is_numeric($box[$dimension]) ) { + $sizingKey = 'width' === $dimension ? 'sizing_horizontal' : 'sizing_vertical'; + $sizing = strtoupper((string) ($layout[$sizingKey] ?? '')); + if ( 'HUG' === $sizing ) { + $styles[] = $dimension . ':fit-content'; + } elseif ( 'FILL' === $sizing ) { + $styles[] = $dimension . ':100%'; + } elseif ( isset($box[$dimension]) && is_numeric($box[$dimension]) ) { $styles[] = $dimension . ':' . $this->number((float) $box[$dimension]) . 'px'; } } - $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + if ( true === ($layout['clips_content'] ?? false) ) { + $styles[] = 'overflow:hidden'; + } + + if ( $this->hasAbsoluteChild($node) ) { + $styles[] = 'position:relative'; + } + if ( 'absolute' === ($layout['positioning'] ?? null) ) { $styles[] = 'position:absolute'; - foreach ( array('x' => 'left', 'y' => 'top') as $dimension => $property ) { - if ( isset($box[$dimension]) && is_numeric($box[$dimension]) ) { - $styles[] = $property . ':' . $this->number((float) $box[$dimension]) . 'px'; - } + foreach ( $this->absolutePositionStyles($box, $layout, $parentNode) as $style ) { + $styles[] = $style; } } @@ -202,6 +214,11 @@ private function styleDeclarations(array $node, string $type): array $styles[] = 'opacity:' . $this->number((float) $box['opacity']); } + $transform = $this->transformStyle($box); + if ( null !== $transform ) { + $styles[] = 'transform:' . $transform; + } + foreach ( $this->radiusStyles($box) as $style ) { $styles[] = $style; } @@ -247,6 +264,143 @@ private function styleDeclarations(array $node, string $type): array $styles[] = 'gap:' . $this->number((float) $layout['item_spacing']) . 'px'; } + foreach ( $this->flexItemStyles($layout) as $style ) { + $styles[] = $style; + } + + return $styles; + } + + /** + * @param array $box + * @param array $layout + * @return array + */ + private function absolutePositionStyles(array $box, array $layout, ?array $parentNode): array + { + $styles = array(); + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + $left = $this->relativeOffset($box, $parentBox, 'x'); + $top = $this->relativeOffset($box, $parentBox, 'y'); + $constraints = is_array($layout['constraints'] ?? null) ? $layout['constraints'] : array(); + + if ( null !== $left ) { + $styles[] = 'left:' . $this->number($left) . 'px'; + } + if ( isset($constraints['horizontal'], $parentBox['width'], $box['width']) && 'LEFT_RIGHT' === $constraints['horizontal'] && null !== $left ) { + $styles[] = 'right:' . $this->number((float) $parentBox['width'] - $left - (float) $box['width']) . 'px'; + } + if ( null !== $top ) { + $styles[] = 'top:' . $this->number($top) . 'px'; + } + if ( isset($constraints['vertical'], $parentBox['height'], $box['height']) && 'TOP_BOTTOM' === $constraints['vertical'] && null !== $top ) { + $styles[] = 'bottom:' . $this->number((float) $parentBox['height'] - $top - (float) $box['height']) . 'px'; + } + + return $styles; + } + + /** + * @param array $box + * @param array $parentBox + */ + private function relativeOffset(array $box, array $parentBox, string $dimension): ?float + { + if ( ! isset($box[$dimension]) || ! is_numeric($box[$dimension]) ) { + return null; + } + + $offset = (float) $box[$dimension]; + if ( isset($parentBox[$dimension]) && is_numeric($parentBox[$dimension]) ) { + $offset -= (float) $parentBox[$dimension]; + } + + return $offset; + } + + /** + * @param array $node + */ + private function hasAbsoluteChild(array $node): bool + { + foreach ( $this->nodeList($node) as $child ) { + if ( is_array($child) && 'absolute' === ($child['layout']['positioning'] ?? null) ) { + return true; + } + } + + return false; + } + + /** + * @param array $box + */ + private function transformStyle(array $box): ?string + { + if ( isset($box['transform']) && is_array($box['transform']) ) { + $matrix = $this->cssMatrix($box['transform']); + if ( null !== $matrix ) { + return $matrix; + } + } + + if ( isset($box['rotation']) && is_numeric($box['rotation']) ) { + return 'rotate(' . $this->number((float) $box['rotation']) . 'deg)'; + } + + return null; + } + + /** + * @param array $transform + */ + private function cssMatrix(array $transform): ?string + { + if ( 2 !== count($transform) || ! is_array($transform[0] ?? null) || ! is_array($transform[1] ?? null) ) { + return null; + } + + $values = array($transform[0][0] ?? null, $transform[1][0] ?? null, $transform[0][1] ?? null, $transform[1][1] ?? null, $transform[0][2] ?? null, $transform[1][2] ?? null); + foreach ( $values as $value ) { + if ( ! is_numeric($value) ) { + return null; + } + } + + return 'matrix(' . implode(',', array_map(fn (mixed $value): string => $this->number((float) $value), $values)) . ')'; + } + + /** + * @param array $layout + * @return array + */ + private function flexItemStyles(array $layout): array + { + $styles = array(); + + if ( 'FILL' === ($layout['sizing_horizontal'] ?? null) || 'FILL' === ($layout['sizing_vertical'] ?? null) ) { + $styles[] = 'flex-grow:1'; + $styles[] = 'flex-shrink:1'; + } elseif ( isset($layout['grow']) && is_numeric($layout['grow']) ) { + $styles[] = 'flex-grow:' . $this->number((float) $layout['grow']); + } + + if ( isset($layout['align']) && 'STRETCH' === $layout['align'] ) { + $styles[] = 'align-self:stretch'; + } + + $usesSourceOrder = 'absolute' === ($layout['positioning'] ?? null) + || 'FILL' === ($layout['sizing_horizontal'] ?? null) + || 'FILL' === ($layout['sizing_vertical'] ?? null) + || isset($layout['grow']) + || isset($layout['align']); + + if ( $usesSourceOrder && isset($layout['source_order']) && is_numeric($layout['source_order']) ) { + $order = (int) $layout['source_order']; + $styles[] = 'order:' . $order; + $styles[] = 'z-index:' . $order; + } + return $styles; } diff --git a/figma-transformer/src/Scenegraph/ScenegraphIndex.php b/figma-transformer/src/Scenegraph/ScenegraphIndex.php index 0933030..3a0f017 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphIndex.php +++ b/figma-transformer/src/Scenegraph/ScenegraphIndex.php @@ -21,7 +21,7 @@ public function build(array $source): array foreach ( $roots as $key => $root ) { if ( is_array($root) ) { - $this->collectNode($root, is_string($key) ? $key : null, null, $rawNodes, $diagnostics); + $this->collectNode($root, is_string($key) ? $key : null, null, is_int($key) ? $key : null, $rawNodes, $diagnostics); } } @@ -129,7 +129,7 @@ private function extractRootNodes(array $source): array * @param array, parent: ?string}> $rawNodes * @param array> $diagnostics */ - private function collectNode(array $value, ?string $fallbackId, ?string $parentId, array &$rawNodes, array &$diagnostics): void + private function collectNode(array $value, ?string $fallbackId, ?string $parentId, ?int $sourceOrder, array &$rawNodes, array &$diagnostics): void { $node = $this->unwrapNodeChange($value); if ( null === $node ) { @@ -158,6 +158,10 @@ private function collectNode(array $value, ?string $fallbackId, ?string $parentI } unset($node['children']); + if ( null !== $sourceOrder ) { + $node['_source_order'] = $sourceOrder; + } + if ( isset($rawNodes[$id]) ) { $diagnostics[] = array( 'code' => 'scenegraph_node_id_duplicate', @@ -178,7 +182,7 @@ private function collectNode(array $value, ?string $fallbackId, ?string $parentI foreach ( $children as $key => $child ) { if ( is_array($child) ) { - $this->collectNode($child, is_string($key) ? $key : null, $id, $rawNodes, $diagnostics); + $this->collectNode($child, is_string($key) ? $key : null, $id, is_int($key) ? $key : null, $rawNodes, $diagnostics); } } } diff --git a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php index 31d3687..a4e2882 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -139,7 +139,13 @@ private function normalizeNode(array $node, array &$diagnostics): array foreach ( $node[$childrenKey] as $index => $child ) { if ( is_array($child) ) { - $node[$childrenKey][$index] = $this->normalizeNode($child, $diagnostics); + $normalizedChild = $this->normalizeNode($child, $diagnostics); + $childLayout = is_array($normalizedChild['layout'] ?? null) ? $normalizedChild['layout'] : array(); + $childLayout['source_order'] = isset($normalizedChild['_source_order']) && is_numeric($normalizedChild['_source_order']) + ? (int) $normalizedChild['_source_order'] + : (int) $index; + $normalizedChild['layout'] = $childLayout; + $node[$childrenKey][$index] = $normalizedChild; } } } @@ -381,6 +387,19 @@ private function normalizeVisualBox(array $node): array $box['opacity'] = (float) $node['opacity']; } + foreach ( array('rotation' => 'rotation') as $sourceKey => $targetKey ) { + if ( isset($node[$sourceKey]) && is_numeric($node[$sourceKey]) ) { + $box[$targetKey] = (float) $node[$sourceKey]; + } + } + + foreach ( array('relativeTransform', 'absoluteTransform') as $sourceKey ) { + if ( is_array($node[$sourceKey] ?? null) ) { + $box['transform'] = $node[$sourceKey]; + break; + } + } + if ( isset($node['cornerRadius']) && is_numeric($node['cornerRadius']) ) { $box['corner_radius'] = (float) $node['cornerRadius']; } @@ -538,6 +557,26 @@ private function normalizeLayout(array $node): array } } + foreach ( array( + 'layoutSizingHorizontal' => 'sizing_horizontal', + 'layoutSizingVertical' => 'sizing_vertical', + 'horizontalSizing' => 'sizing_horizontal', + 'verticalSizing' => 'sizing_vertical', + ) as $source => $target ) { + if ( isset($node[$source]) && is_scalar($node[$source]) ) { + $layout[$target] = strtoupper((string) $node[$source]); + } + } + + foreach ( array( + 'primaryAxisSizingMode' => 'primary_axis_sizing', + 'counterAxisSizingMode' => 'counter_axis_sizing', + ) as $source => $target ) { + if ( isset($node[$source]) && is_scalar($node[$source]) ) { + $layout[$target] = strtoupper((string) $node[$source]); + } + } + foreach ( array( 'primaryAxisAlignItems' => 'primary_axis_alignment', 'counterAxisAlignItems' => 'counter_axis_alignment', @@ -590,6 +629,30 @@ private function normalizeLayout(array $node): array $layout['positioning'] = 'absolute'; } + if ( isset($node['layoutGrow']) && is_numeric($node['layoutGrow']) ) { + $layout['grow'] = (float) $node['layoutGrow']; + } + + if ( isset($node['layoutAlign']) && is_scalar($node['layoutAlign']) ) { + $layout['align'] = strtoupper((string) $node['layoutAlign']); + } + + if ( true === ($node['clipsContent'] ?? false) ) { + $layout['clips_content'] = true; + } + + if ( is_array($node['constraints'] ?? null) ) { + $constraints = array(); + foreach ( array('horizontal', 'vertical') as $axis ) { + if ( isset($node['constraints'][$axis]) && is_scalar($node['constraints'][$axis]) ) { + $constraints[$axis] = strtoupper((string) $node['constraints'][$axis]); + } + } + if ( ! empty($constraints) ) { + $layout['constraints'] = $constraints; + } + } + return $layout; } @@ -601,6 +664,7 @@ private function cssAxisAlignment(string $alignment): ?string 'MAX' => 'flex-end', 'SPACE_BETWEEN' => 'space-between', 'BASELINE' => 'baseline', + 'STRETCH' => 'stretch', default => null, }; } diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 2b1e090..687c360 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -417,6 +417,85 @@ $assert(str_contains($assetReferenceHtml, 'Unsupported Figma VECTOR'), 'unsupported-vector-placeholder-text'); $assert(in_array('unsupported_vector_node_placeholder', $assetReferenceDiagnosticCodes, true), 'unsupported-vector-diagnostic'); +$layoutFidelityResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Layout Fidelity Fixture', + 'nodes' => array( + array( + 'id' => '5:1', + 'type' => 'FRAME', + 'name' => 'Layout frame', + 'absoluteBoundingBox' => array('x' => 100, 'y' => 50, 'width' => 500, 'height' => 300), + 'layoutMode' => 'HORIZONTAL', + 'primaryAxisAlignItems' => 'MIN', + 'counterAxisAlignItems' => 'STRETCH', + 'clipsContent' => true, + 'children' => array( + array( + 'id' => '5:2', + 'type' => 'RECTANGLE', + 'name' => 'Fixed card', + 'width' => 100, + 'height' => 80, + 'layoutSizingHorizontal' => 'FIXED', + 'layoutSizingVertical' => 'FIXED', + 'opacity' => 0.6, + 'rotation' => 15, + ), + array( + 'id' => '5:3', + 'type' => 'TEXT', + 'name' => 'Hug label', + 'characters' => 'Source text', + 'fontSize' => 12, + 'layoutSizingHorizontal' => 'HUG', + 'layoutSizingVertical' => 'HUG', + ), + array( + 'id' => '5:4', + 'type' => 'RECTANGLE', + 'name' => 'Fill panel', + 'width' => 200, + 'height' => 100, + 'layoutSizingHorizontal' => 'FILL', + 'layoutSizingVertical' => 'FILL', + 'layoutGrow' => 1, + 'layoutAlign' => 'STRETCH', + ), + array( + 'id' => '5:5', + 'type' => 'RECTANGLE', + 'name' => 'Absolute badge', + 'absoluteBoundingBox' => array('x' => 120, 'y' => 70, 'width' => 50, 'height' => 20), + 'layoutPositioning' => 'ABSOLUTE', + 'constraints' => array('horizontal' => 'LEFT_RIGHT', 'vertical' => 'TOP_BOTTOM'), + 'fill' => array('r' => 0, 'g' => 0, 'b' => 0), + ), + array( + 'id' => '5:6', + 'type' => 'RECTANGLE', + 'name' => 'Matrix transform', + 'width' => 30, + 'height' => 30, + 'relativeTransform' => array( + array(0, -1, 40), + array(1, 0, 60), + ), + ), + ), + ), + ), +)); + +$layoutFidelityCss = $fileContent($layoutFidelityResult, 'style.css'); + +$assert(str_contains($layoutFidelityCss, '.figma-node-5-1-layout-frame{width:500px;height:300px;overflow:hidden;position:relative;display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch}'), 'layout-frame-clips-and-positions-absolute-children'); +$assert(str_contains($layoutFidelityCss, '.figma-node-5-2-fixed-card{width:100px;height:80px;opacity:0.6;transform:rotate(15deg)}'), 'layout-fixed-sizing-and-rotation'); +$assert(str_contains($layoutFidelityCss, '.figma-node-5-3-hug-label{width:fit-content;height:fit-content;font-size:12px}'), 'layout-hug-sizing'); +$assert(str_contains($layoutFidelityCss, '.figma-node-5-4-fill-panel{width:100%;height:100%;flex-grow:1;flex-shrink:1;align-self:stretch;order:2;z-index:2}'), 'layout-fill-sizing-and-order'); +$assert(str_contains($layoutFidelityCss, '.figma-node-5-5-absolute-badge{width:50px;height:20px;position:absolute;left:20px;right:430px;top:20px;bottom:260px;background:#000000;order:3;z-index:3}'), 'layout-absolute-constraints-and-z-index'); +$assert(str_contains($layoutFidelityCss, '.figma-node-5-6-matrix-transform{width:30px;height:30px;transform:matrix(0,1,-1,0,40,60)}'), 'layout-relative-transform-matrix'); +$assert(! str_contains($layoutFidelityCss, 'font-family:Inter') && ! str_contains($layoutFidelityCss, 'body{margin:0;background') && ! str_contains($layoutFidelityCss, 'body{margin:0;color'), 'layout-css-avoids-theme-defaults'); + if ( ! empty($failures) ) { fwrite(STDERR, "Figma Transformer contract failures:\n- " . implode("\n- ", $failures) . "\n"); exit(1); From 5724cffa14ad5ef1bb0795c825b21acb02e51ee8 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:40:40 -0400 Subject: [PATCH 14/31] Emit supported figma vectors as SVG --- .../src/Html/StaticHtmlEmitter.php | 160 +++++++++++++++++- figma-transformer/tests/contract/run.php | 18 ++ 2 files changed, 175 insertions(+), 3 deletions(-) diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index a1ac650..80b8059 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -100,7 +100,12 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, in } } - if ( $this->isUnsupportedVectorType($type) ) { + $vectorSvg = $this->supportedVectorSvg($node, $type); + if ( null !== $vectorSvg ) { + $content = $vectorSvg . $content; + } + + if ( $this->isUnsupportedVectorType($type) && null === $vectorSvg ) { $diagnostics[] = array( 'severity' => 'warning', 'code' => 'unsupported_vector_node_placeholder', @@ -123,7 +128,7 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, in if ( 'RECTANGLE' === $type && '' === $content ) { $attributes .= ' aria-hidden="true"'; } - if ( $this->isUnsupportedVectorType($type) ) { + if ( $this->isUnsupportedVectorType($type) && null === $vectorSvg ) { $attributes .= ' data-figma-unsupported-vector="true" role="img" aria-label="Unsupported Figma ' . $this->sanitizeAttribute($type) . ' node"'; } @@ -202,7 +207,7 @@ private function styleDeclarations(array $node, string $type, ?array $parentNode } } - if ( 'TEXT' !== $type ) { + if ( 'TEXT' !== $type && ! in_array($type, array('VECTOR', 'LINE', 'ELLIPSE'), true) ) { $background = $this->backgroundColor($node); if ( null !== $background ) { $styles[] = 'background:' . $background; @@ -735,6 +740,155 @@ private function isUnsupportedVectorType(string $type): bool return in_array($type, array('VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE', 'STAR', 'POLYGON', 'REGULAR_POLYGON'), true); } + /** + * @param array $node + */ + private function supportedVectorSvg(array $node, string $type): ?string + { + if ( ! in_array($type, array('VECTOR', 'LINE', 'ELLIPSE', 'RECTANGLE'), true) ) { + return null; + } + + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $width = isset($box['width']) && is_numeric($box['width']) ? max(0.0, (float) $box['width']) : 0.0; + $height = isset($box['height']) && is_numeric($box['height']) ? max(0.0, (float) $box['height']) : 0.0; + if ( $width <= 0 || $height <= 0 ) { + return null; + } + + $elements = $this->vectorPathElements($node); + if ( empty($elements) ) { + $elements = $this->primitiveVectorElements($node, $type, $width, $height); + } + if ( empty($elements) ) { + return null; + } + + $attributes = array( + 'xmlns="http://www.w3.org/2000/svg"', + 'viewBox="0 0 ' . $this->number($width) . ' ' . $this->number($height) . '"', + 'width="100%"', + 'height="100%"', + 'role="img"', + 'aria-label="' . $this->sanitizeAttribute((string) ($node['name'] ?? $type)) . '"', + 'data-figma-vector="true"', + ); + + return '' . implode('', $elements) . ''; + } + + /** + * @param array $node + * @return array + */ + private function vectorPathElements(array $node): array + { + $rawPaths = array(); + foreach ( array('vectorPaths', 'paths') as $key ) { + if ( is_array($node[$key] ?? null) ) { + $rawPaths = array_merge($rawPaths, $node[$key]); + } + } + foreach ( array('pathData', 'path', 'd') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) ) { + $rawPaths[] = array('data' => (string) $node[$key]); + } + } + + $elements = array(); + foreach ( $rawPaths as $rawPath ) { + $path = is_array($rawPath) ? (string) ($rawPath['data'] ?? $rawPath['pathData'] ?? $rawPath['path'] ?? $rawPath['d'] ?? '') : (string) $rawPath; + $path = $this->safeSvgPathData($path); + if ( null === $path ) { + continue; + } + + $paint = $this->svgPaintAttributes($node); + if ( is_array($rawPath) && isset($rawPath['windingRule']) && is_scalar($rawPath['windingRule']) ) { + $rule = strtolower((string) $rawPath['windingRule']); + if ( in_array($rule, array('evenodd', 'nonzero'), true) ) { + $paint[] = 'fill-rule="' . $rule . '"'; + } + } + + $elements[] = ''; + } + + return $elements; + } + + /** + * @return array + */ + private function primitiveVectorElements(array $node, string $type, float $width, float $height): array + { + $paint = $this->svgPaintAttributes($node); + if ( 'LINE' === $type ) { + if ( ! $this->hasSvgStroke($paint) ) { + $paint[] = 'stroke="currentColor"'; + $paint[] = 'stroke-width="1"'; + } + + return array(''); + } + if ( 'ELLIPSE' === $type ) { + return array(''); + } + return array(); + } + + private function safeSvgPathData(string $path): ?string + { + $path = trim(preg_replace('/\s+/', ' ', $path) ?? ''); + if ( '' === $path || strlen($path) > 20000 ) { + return null; + } + + return preg_match('/^[MmZzLlHhVvCcSsQqTtAa0-9,\.\-+\s]+$/', $path) ? $path : null; + } + + /** + * @param array $node + * @return array + */ + private function svgPaintAttributes(array $node): array + { + $paints = is_array($node['figma_paints']['fills'] ?? null) ? $node['figma_paints']['fills'] : array(); + $fill = $this->firstSolidPaint($paints); + $paints = is_array($node['figma_paints']['strokes'] ?? null) ? $node['figma_paints']['strokes'] : array(); + $stroke = $this->firstSolidPaint($paints); + + $attributes = array('fill="' . ( null === $fill ? 'none' : $this->sanitizeAttribute($fill) ) . '"'); + if ( null !== $stroke ) { + $attributes[] = 'stroke="' . $this->sanitizeAttribute($stroke) . '"'; + $attributes[] = 'stroke-width="' . $this->number($this->strokeWeight($node)) . '"'; + } + + return $attributes; + } + + /** + * @param array $node + */ + private function strokeWeight(array $node): float + { + return isset($node['strokeWeight']) && is_numeric($node['strokeWeight']) ? max(0.0, (float) $node['strokeWeight']) : 1.0; + } + + /** + * @param array $attributes + */ + private function hasSvgStroke(array $attributes): bool + { + foreach ( $attributes as $attribute ) { + if ( str_starts_with($attribute, 'stroke=') ) { + return true; + } + } + + return false; + } + /** * @param array $node */ diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 687c360..b83faa1 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -396,6 +396,22 @@ 'width' => 10, 'height' => 10, ), + array( + 'id' => '4:4', + 'type' => 'VECTOR', + 'name' => 'Path Icon', + 'width' => 24, + 'height' => 24, + 'fills' => array( + array('type' => 'SOLID', 'color' => array('r' => 0, 'g' => 0, 'b' => 1)), + ), + 'vectorPaths' => array( + array( + 'data' => 'M 1 1 L 23 1 L 12 23 Z', + 'windingRule' => 'EVENODD', + ), + ), + ), ), ), ), @@ -413,6 +429,8 @@ $assert('imageHash' === ($assetReferenceReport['asset_references'][0]['source_key'] ?? null), 'normalized-image-reference-source-key'); $assert('image-hash-1' === ($assetReferenceReport['asset_references'][0]['ref'] ?? null), 'normalized-image-reference-ref'); $assert(str_contains($assetReferenceCss, 'background-image:url("assets/archive-image.png")'), 'normalized-image-reference-css'); +$assert(str_contains($assetReferenceHtml, 'data-figma-vector="true"'), 'supported-vector-svg-html'); +$assert(str_contains($assetReferenceHtml, ''), 'supported-vector-path-derived-svg'); $assert(str_contains($assetReferenceHtml, 'data-figma-unsupported-vector="true"'), 'unsupported-vector-placeholder-html'); $assert(str_contains($assetReferenceHtml, 'Unsupported Figma VECTOR'), 'unsupported-vector-placeholder-text'); $assert(in_array('unsupported_vector_node_placeholder', $assetReferenceDiagnosticCodes, true), 'unsupported-vector-diagnostic'); From 5adbe25baa8b8d6e52e3e8965f79fe2db448b8cb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:39:09 -0400 Subject: [PATCH 15/31] Add Figma component instance IR foundation --- .../src/Scenegraph/ScenegraphNormalizer.php | 286 ++++++++++++++++++ figma-transformer/tests/contract/run.php | 69 +++++ 2 files changed, 355 insertions(+) diff --git a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php index a4e2882..c1de32d 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -24,6 +24,9 @@ public function normalize(array $source, array $options = array()): array $index = $this->index->build($source); $diagnostics = $index['diagnostics']; $nodeMap = $this->normalizeNodeMap($index['nodes'], $diagnostics); + $components = $this->buildComponentDefinitions($nodeMap); + $componentDefinitionCount = $this->countComponentDefinitions($nodeMap); + $instanceReport = $this->resolveInstances($nodeMap, $components, $diagnostics); $topLevelIds = $index['top_level_node_ids']; $frameIds = $this->selectTopLevelFrameIds($topLevelIds, $nodeMap); @@ -74,6 +77,10 @@ public function normalize(array $source, array $options = array()): array 'text_node_count' => count($textInventory), 'asset_reference_count' => count($assetReferences), 'asset_references' => $assetReferences, + 'component_definition_count' => $componentDefinitionCount, + 'instance_node_count' => $instanceReport['instance_node_count'], + 'resolved_instance_count' => $instanceReport['resolved_instance_count'], + 'unresolved_component_references' => $instanceReport['unresolved_component_references'], 'diagnostic_count' => count($diagnostics), ), ); @@ -103,6 +110,11 @@ private function normalizeNode(array $node, array &$diagnostics): array $id = (string) ($node['id'] ?? ''); $type = strtoupper((string) ($node['type'] ?? '')); + $component = $this->normalizeComponentMetadata($node, $type); + if ( ! empty($component) ) { + $node['figma_component'] = $component; + } + if ( 'TEXT' === $type ) { $text = $this->normalizeText($node); if ( ! empty($text) ) { @@ -153,6 +165,280 @@ private function normalizeNode(array $node, array &$diagnostics): array return $node; } + /** + * @param array $node + * @return array + */ + private function normalizeComponentMetadata(array $node, string $type): array + { + $metadata = array(); + + if ( 'COMPONENT' === $type || 'COMPONENT_SET' === $type ) { + $metadata['role'] = 'definition'; + $metadata['definition_id'] = (string) ($node['id'] ?? ''); + } elseif ( 'INSTANCE' === $type ) { + $metadata['role'] = 'instance'; + $metadata['instance_id'] = (string) ($node['id'] ?? ''); + $reference = $this->readComponentReference($node); + if ( null !== $reference ) { + $metadata['component_id'] = $reference['id']; + $metadata['component_source_key'] = $reference['source_key']; + } + } + + if ( is_array($node['componentProperties'] ?? null) ) { + $metadata['component_properties'] = $node['componentProperties']; + } + + if ( is_array($node['overrides'] ?? null) ) { + $metadata['overrides'] = $node['overrides']; + } + + return $metadata; + } + + /** + * @param array> $nodeMap + * @return array> + */ + private function buildComponentDefinitions(array $nodeMap): array + { + $components = array(); + + foreach ( $nodeMap as $id => $node ) { + if ( ! in_array(strtoupper((string) ($node['type'] ?? '')), array('COMPONENT', 'COMPONENT_SET'), true) ) { + continue; + } + + foreach ( array_unique(array_filter(array($id, $this->readString($node, array('componentId', 'component_id', 'key'))))) as $componentId ) { + $components[(string) $componentId] = $node; + } + } + + return $components; + } + + /** + * @param array> $nodeMap + */ + private function countComponentDefinitions(array $nodeMap): int + { + $count = 0; + + foreach ( $nodeMap as $node ) { + if ( in_array(strtoupper((string) ($node['type'] ?? '')), array('COMPONENT', 'COMPONENT_SET'), true) ) { + $count++; + } + } + + return $count; + } + + /** + * @param array> $nodeMap + * @param array> $components + * @param array> $diagnostics + * @return array{instance_node_count: int, resolved_instance_count: int, unresolved_component_references: array>} + */ + private function resolveInstances(array &$nodeMap, array $components, array &$diagnostics): array + { + $instanceCount = 0; + $resolvedCount = 0; + $unresolved = array(); + + foreach ( $nodeMap as $id => $node ) { + if ( 'INSTANCE' !== strtoupper((string) ($node['type'] ?? '')) ) { + continue; + } + + $instanceCount++; + $reference = $this->readComponentReference($node); + if ( null === $reference || ! isset($components[$reference['id']]) ) { + $unresolved[] = array('instance_id' => $id, 'component_id' => $reference['id'] ?? ''); + $diagnostics[] = array( + 'severity' => 'warning', + 'code' => 'figma_instance_component_unresolved', + 'message' => 'Figma instance references a component definition that is not present in the same source graph.', + 'context' => array( + 'instance_id' => $id, + 'component_id' => $reference['id'] ?? null, + ), + ); + continue; + } + + if ( ! empty($node['children']) ) { + $unresolved[] = array('instance_id' => $id, 'component_id' => $reference['id']); + $diagnostics[] = array( + 'severity' => 'warning', + 'code' => 'figma_instance_resolution_skipped', + 'message' => 'Figma instance resolution was skipped because the source instance already contains children.', + 'context' => array('instance_id' => $id, 'component_id' => $reference['id']), + ); + continue; + } + + $overrides = $this->normalizeInstanceOverrides($node['overrides'] ?? array(), $id, $diagnostics); + if ( null === $overrides ) { + $unresolved[] = array('instance_id' => $id, 'component_id' => $reference['id']); + continue; + } + + $resolved = $this->cloneComponentForInstance($components[$reference['id']], $node, $reference['id'], $overrides); + $nodeMap[$id] = $resolved; + $resolvedCount++; + } + + return array( + 'instance_node_count' => $instanceCount, + 'resolved_instance_count' => $resolvedCount, + 'unresolved_component_references' => $unresolved, + ); + } + + /** + * @param array $node + * @return array{id: string, source_key: string}|null + */ + private function readComponentReference(array $node): ?array + { + foreach ( array('componentId', 'component_id', 'mainComponentId', 'main_component_id') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) && '' !== (string) $node[$key] ) { + return array('id' => (string) $node[$key], 'source_key' => $key); + } + } + + foreach ( array('mainComponent', 'component') as $key ) { + if ( is_array($node[$key] ?? null) ) { + $id = $this->readString($node[$key], array('id', 'key', 'componentId', 'node_id', 'nodeId')); + if ( null !== $id && '' !== $id ) { + return array('id' => $id, 'source_key' => $key); + } + } elseif ( isset($node[$key]) && is_scalar($node[$key]) && '' !== (string) $node[$key] ) { + return array('id' => (string) $node[$key], 'source_key' => $key); + } + } + + return null; + } + + /** + * @param array $node + * @param array $keys + */ + private function readString(array $node, array $keys): ?string + { + foreach ( $keys as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) && '' !== (string) $node[$key] ) { + return (string) $node[$key]; + } + } + + return null; + } + + /** + * @param mixed $rawOverrides + * @param array> $diagnostics + * @return array>|null + */ + private function normalizeInstanceOverrides(mixed $rawOverrides, string $instanceId, array &$diagnostics): ?array + { + if ( ! is_array($rawOverrides) || empty($rawOverrides) ) { + return array(); + } + + $overrides = array(); + foreach ( $rawOverrides as $key => $override ) { + if ( ! is_array($override) ) { + $diagnostics[] = array( + 'severity' => 'warning', + 'code' => 'figma_instance_override_unsupported', + 'message' => 'Figma instance override shape is unsupported and was not applied.', + 'context' => array('instance_id' => $instanceId), + ); + return null; + } + + $nodeId = $this->readString($override, array('nodeId', 'node_id', 'id')) ?? (is_string($key) ? $key : null); + if ( null === $nodeId || '' === $nodeId ) { + return null; + } + + foreach ( array('characters', 'text', 'name') as $field ) { + if ( isset($override[$field]) && is_scalar($override[$field]) ) { + $overrides[$nodeId][$field] = $override[$field]; + } + } + } + + return $overrides; + } + + /** + * @param array $component + * @param array $instance + * @param array> $overrides + * @return array + */ + private function cloneComponentForInstance(array $component, array $instance, string $componentId, array $overrides): array + { + $resolved = $component; + $resolved['id'] = (string) ($instance['id'] ?? $resolved['id'] ?? ''); + $resolved['type'] = 'INSTANCE'; + $resolved['name'] = (string) ($instance['name'] ?? $resolved['name'] ?? ''); + + foreach ( array('box', 'figma_box', 'layout', 'componentProperties') as $key ) { + if ( array_key_exists($key, $instance) ) { + $resolved[$key] = $instance[$key]; + } + } + + $resolved['figma_component'] = array_merge( + is_array($instance['figma_component'] ?? null) ? $instance['figma_component'] : array(), + array( + 'role' => 'instance', + 'instance_id' => (string) ($instance['id'] ?? ''), + 'component_id' => $componentId, + 'definition_node_id' => (string) ($component['id'] ?? ''), + 'resolved' => true, + ) + ); + $resolved['children'] = $this->applyInstanceOverridesToChildren(is_array($resolved['children'] ?? null) ? $resolved['children'] : array(), $overrides); + + return $resolved; + } + + /** + * @param array $children + * @param array> $overrides + * @return array + */ + private function applyInstanceOverridesToChildren(array $children, array $overrides): array + { + foreach ( $children as $index => $child ) { + if ( ! is_array($child) ) { + continue; + } + + $id = (string) ($child['id'] ?? ''); + foreach ( $overrides[$id] ?? array() as $field => $value ) { + $child[$field] = $value; + if ( in_array($field, array('characters', 'text'), true) && is_array($child['figma_text'] ?? null) ) { + $child['figma_text']['characters'] = (string) $value; + } + } + + if ( is_array($child['children'] ?? null) ) { + $child['children'] = $this->applyInstanceOverridesToChildren($child['children'], $overrides); + } + + $children[$index] = $child; + } + + return $children; + } + /** * @param array $node * @return array diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index b83faa1..66f54b4 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -514,6 +514,75 @@ $assert(str_contains($layoutFidelityCss, '.figma-node-5-6-matrix-transform{width:30px;height:30px;transform:matrix(0,1,-1,0,40,60)}'), 'layout-relative-transform-matrix'); $assert(! str_contains($layoutFidelityCss, 'font-family:Inter') && ! str_contains($layoutFidelityCss, 'body{margin:0;background') && ! str_contains($layoutFidelityCss, 'body{margin:0;color'), 'layout-css-avoids-theme-defaults'); +$resolvedInstanceResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Component Instance Fixture', + 'nodes' => array( + array( + 'id' => 'component:button', + 'type' => 'COMPONENT', + 'name' => 'Button component', + 'key' => 'button-key', + 'children' => array( + array( + 'id' => 'component:button-label', + 'type' => 'TEXT', + 'name' => 'Button label', + 'characters' => 'Default label', + ), + ), + ), + array( + 'id' => 'instance:button', + 'type' => 'INSTANCE', + 'name' => 'Primary CTA', + 'componentId' => 'button-key', + 'overrides' => array( + array( + 'nodeId' => 'component:button-label', + 'characters' => 'Buy now', + ), + ), + ), + ), +)); + +$resolvedInstanceHtml = $fileContent($resolvedInstanceResult, 'index.html'); +$resolvedInstanceReport = $resolvedInstanceResult['source_reports']['figma']['scenegraph'] ?? array(); + +$assert(1 === ($resolvedInstanceReport['component_definition_count'] ?? null), 'component-definition-count'); +$assert(1 === ($resolvedInstanceReport['instance_node_count'] ?? null), 'resolved-instance-counts-instance'); +$assert(1 === ($resolvedInstanceReport['resolved_instance_count'] ?? null), 'resolved-instance-counts-resolved'); +$assert(array() === ($resolvedInstanceReport['unresolved_component_references'] ?? null), 'resolved-instance-no-unresolved'); +$assert(str_contains($resolvedInstanceHtml, 'data-figma-node-id="instance:button"'), 'resolved-instance-preserves-instance-id'); +$assert(str_contains($resolvedInstanceHtml, 'Buy now'), 'resolved-instance-applies-text-override'); + +$unresolvedInstanceResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Unresolved Component Instance Fixture', + 'nodes' => array( + array( + 'id' => 'instance:missing', + 'type' => 'INSTANCE', + 'name' => 'Missing component instance', + 'mainComponent' => array('id' => 'missing-component'), + ), + ), +)); + +$unresolvedInstanceHtml = $fileContent($unresolvedInstanceResult, 'index.html'); +$unresolvedInstanceReport = $unresolvedInstanceResult['source_reports']['figma']['scenegraph'] ?? array(); +$unresolvedInstanceDiagnosticCodes = array_map( + static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), + $unresolvedInstanceResult['diagnostics'] ?? array() +); + +$assert(0 === ($unresolvedInstanceReport['component_definition_count'] ?? null), 'unresolved-instance-component-definition-count'); +$assert(1 === ($unresolvedInstanceReport['instance_node_count'] ?? null), 'unresolved-instance-counts-instance'); +$assert(0 === ($unresolvedInstanceReport['resolved_instance_count'] ?? null), 'unresolved-instance-counts-resolved'); +$assert('instance:missing' === ($unresolvedInstanceReport['unresolved_component_references'][0]['instance_id'] ?? null), 'unresolved-instance-report-instance-id'); +$assert('missing-component' === ($unresolvedInstanceReport['unresolved_component_references'][0]['component_id'] ?? null), 'unresolved-instance-report-component-id'); +$assert(str_contains($unresolvedInstanceHtml, 'data-figma-node-id="instance:missing"'), 'unresolved-instance-preserves-instance-id'); +$assert(in_array('figma_instance_component_unresolved', $unresolvedInstanceDiagnosticCodes, true), 'unresolved-instance-diagnostic'); + if ( ! empty($failures) ) { fwrite(STDERR, "Figma Transformer contract failures:\n- " . implode("\n- ", $failures) . "\n"); exit(1); From a27ef5a6e4dfd1fe5a36ab11bc473ffd0552abbf Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:38:41 -0400 Subject: [PATCH 16/31] Extend figma parity artifact reporting --- figma-transformer/README.md | 47 ++++++++++- figma-transformer/bin/figma-transformer | 69 ++++++++++++++- .../src/Parity/ParityReportBuilder.php | 84 +++++++++++++++++-- figma-transformer/tests/contract/run.php | 54 +++++++++--- 4 files changed, 233 insertions(+), 21 deletions(-) diff --git a/figma-transformer/README.md b/figma-transformer/README.md index 558d540..1f4815e 100644 --- a/figma-transformer/README.md +++ b/figma-transformer/README.md @@ -99,13 +99,58 @@ The result envelope includes: ## Parity Contract -Visual parity is measured outside the WordPress import path. The package records source and generated screenshot evidence, side-by-side comparisons, diff images, diff summaries, artifact paths, and metrics supplied by the parity runner. Homeboy is the expected runner for browser-heavy parity workflows; WordPress-only consumers can still read and display the parity report. +Visual parity is measured outside the WordPress import path. The package records source and generated screenshot evidence, side-by-side comparisons, diff images, diff summaries, artifact paths, and metrics supplied by the parity runner. Homeboy or another external browser-backed runner is expected to produce screenshots and image diffs, then pass artifact metadata into this package. WordPress-only consumers can read and display the parity report without running a browser. Parity report statuses: - `not_run`: no parity runner has executed for this transform. - `pending`: parity work is queued, external, or otherwise incomplete. - `compared`: caller-supplied source/generated evidence and diff data describe a completed comparison. +- `pass`: caller-supplied diff data is within the supplied threshold. +- `fail`: caller-supplied diff data exceeds the supplied threshold. + +Parity runners can attach evidence through the `parity` transform option or the CLI metadata flags. The contract accepts source screenshot URL/path metadata, generated screenshot artifact metadata, diff image artifact metadata, pixel mismatch count/ratio, threshold, viewport, and frame id. The transformer stores those references; it does not fetch, render, compare, or commit screenshot files. + +```php +$result = blocks_engine_figma_transformer_transform_scenegraph($scenegraph, array( + 'frame_id' => '1:1', + 'parity' => array( + 'status' => 'pass', + 'frame_id' => '1:1', + 'source_screenshot_url' => 'https://artifacts.example.test/source.png', + 'generated_screenshot_artifact' => 'homeboy://runs/123/generated.png', + 'diff_image_artifact' => 'homeboy://runs/123/diff.png', + 'pixel_mismatch_count' => 10, + 'pixel_mismatch_ratio' => 0.005, + 'threshold' => 0.01, + 'viewport' => array( + 'width' => 1200, + 'height' => 800, + ), + ), +)); +``` + +```sh +figma-transformer scenegraph.json \ + --frame-id=1:1 \ + --parity-status=pass \ + --parity-source-screenshot-url=https://artifacts.example.test/source.png \ + --parity-generated-screenshot-artifact=homeboy://runs/123/generated.png \ + --parity-diff-image-artifact=homeboy://runs/123/diff.png \ + --parity-pixel-mismatch-count=10 \ + --parity-pixel-mismatch-ratio=0.005 \ + --parity-threshold=0.01 \ + --parity-viewport=1200x800 +``` + +Homeboy/external runner workflow: + +1. Run the Figma transform and persist the static HTML/CSS/assets output. +2. Render the original design and generated artifact in an external browser-backed runner. +3. Upload screenshots, diff images, and any machine-readable diff report to a reviewable artifact surface attached to the issue, PR, or runner record. +4. Re-run or wrap the transform with the parity metadata above so `parity-report.json` contains stable artifact references and numeric comparison results. +5. Link the PR or issue to the external artifact record rather than local paths or localhost URLs. ## Fixture Strategy diff --git a/figma-transformer/bin/figma-transformer b/figma-transformer/bin/figma-transformer index ade4335..7ec91e2 100755 --- a/figma-transformer/bin/figma-transformer +++ b/figma-transformer/bin/figma-transformer @@ -12,16 +12,79 @@ if ( is_readable($autoload) ) { $path = $argv[1] ?? ''; if ( '' === $path ) { - fwrite(STDERR, "Usage: figma-transformer \n"); + fwrite(STDERR, "Usage: figma-transformer [--frame-id=] [--parity-status=] [--parity-source-screenshot-url=] [--parity-source-screenshot-path=] [--parity-generated-screenshot-artifact=] [--parity-diff-image-artifact=] [--parity-pixel-mismatch-count=] [--parity-pixel-mismatch-ratio=] [--parity-threshold=] [--parity-viewport=x]\n"); exit(1); } +$options = array(); +foreach ( array_slice($argv, 2) as $argument ) { + if ( ! str_starts_with($argument, '--') ) { + continue; + } + + $parts = explode('=', substr($argument, 2), 2); + $name = $parts[0]; + $value = $parts[1] ?? '1'; + + if ( 'frame-id' === $name ) { + $options['frame_id'] = $value; + $options['parity']['frame_id'] = $value; + continue; + } + + if ( 'parity-frame-id' === $name ) { + $options['parity']['frame_id'] = $value; + continue; + } + + if ( 'parity-viewport' === $name ) { + $options['parity']['viewport'] = blocks_engine_figma_transformer_parse_viewport($value); + continue; + } + + $parityMap = array( + 'parity-status' => 'status', + 'parity-reason' => 'reason', + 'parity-source-screenshot-url' => 'source_screenshot_url', + 'parity-source-screenshot-path' => 'source_screenshot_path', + 'parity-source-screenshot-artifact' => 'source_screenshot_artifact', + 'parity-generated-screenshot-url' => 'generated_screenshot_url', + 'parity-generated-screenshot-path' => 'generated_screenshot_path', + 'parity-generated-screenshot-artifact' => 'generated_screenshot_artifact', + 'parity-diff-image-url' => 'diff_image_url', + 'parity-diff-image-path' => 'diff_image_path', + 'parity-diff-image-artifact' => 'diff_image_artifact', + 'parity-pixel-mismatch-count' => 'pixel_mismatch_count', + 'parity-pixel-mismatch-ratio' => 'pixel_mismatch_ratio', + 'parity-threshold' => 'threshold', + ); + + if ( isset($parityMap[$name]) ) { + $options['parity'][$parityMap[$name]] = $value; + } +} + $transformer = new Automattic\BlocksEngine\FigmaTransformer\FigmaTransformer(); if ( str_ends_with(strtolower($path), '.json') ) { $decoded = json_decode((string) file_get_contents($path), true); - $result = $transformer->transformScenegraph(is_array($decoded) ? $decoded : array())->toArray(); + $result = $transformer->transformScenegraph(is_array($decoded) ? $decoded : array(), $options)->toArray(); } else { - $result = $transformer->transformFile($path)->toArray(); + $result = $transformer->transformFile($path, $options)->toArray(); } fwrite(STDOUT, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + +/** + * @return array + */ +function blocks_engine_figma_transformer_parse_viewport(string $value): array +{ + if ( preg_match('/^(\d+)x(\d+)$/', $value, $matches) ) { + return array( + 'width' => (int) $matches[1], + 'height' => (int) $matches[2], + ); + } + + return array('label' => $value); +} diff --git a/figma-transformer/src/Parity/ParityReportBuilder.php b/figma-transformer/src/Parity/ParityReportBuilder.php index bb3d197..defd7c0 100644 --- a/figma-transformer/src/Parity/ParityReportBuilder.php +++ b/figma-transformer/src/Parity/ParityReportBuilder.php @@ -11,7 +11,7 @@ final class ParityReportBuilder { public const SCHEMA = 'blocks-engine/figma-transformer/parity-report/v1'; - private const KNOWN_STATUSES = array('not_run', 'pending', 'compared'); + private const KNOWN_STATUSES = array('not_run', 'pending', 'compared', 'pass', 'fail'); /** * @param array $evidence @@ -25,17 +25,87 @@ public function build(array $evidence = array(), array $overrides = array()): ar $status = 'pending'; } + $artifacts = $this->arrayValue($evidence, 'artifacts'); + $source = $this->arrayValue($evidence, 'source'); + $generated = $this->arrayValue($evidence, 'generated'); + $diff = $this->nullableArrayValue($evidence, 'diff'); + $diffSummary = $this->arrayValue($evidence, 'diff_summary'); + $metrics = $this->arrayValue($evidence, 'metrics'); + $viewport = $this->arrayValue($evidence, 'viewport'); + + $this->copyScalar($evidence, 'source_screenshot_path', $source, 'screenshot_path'); + $this->copyScalar($evidence, 'source_screenshot_url', $source, 'screenshot_url'); + $this->copyScalar($evidence, 'source_screenshot_artifact', $source, 'screenshot_artifact'); + $this->copyScalar($evidence, 'generated_screenshot_path', $generated, 'screenshot_path'); + $this->copyScalar($evidence, 'generated_screenshot_url', $generated, 'screenshot_url'); + $this->copyScalar($evidence, 'generated_screenshot_artifact', $generated, 'screenshot_artifact'); + $this->copyScalar($evidence, 'diff_image_path', $diff, 'image_path'); + $this->copyScalar($evidence, 'diff_image_url', $diff, 'image_url'); + $this->copyScalar($evidence, 'diff_image_artifact', $diff, 'image_artifact'); + $this->copyScalar($evidence, 'frame_id', $source, 'frame_id'); + $this->copyScalar($evidence, 'frame_id', $generated, 'frame_id'); + $this->copyNumeric($evidence, 'pixel_mismatch_count', $diffSummary, 'pixel_mismatch_count'); + $this->copyNumeric($evidence, 'pixel_mismatch_count', $metrics, 'pixel_mismatch_count'); + $this->copyNumeric($evidence, 'pixel_mismatch_ratio', $diffSummary, 'pixel_mismatch_ratio'); + $this->copyNumeric($evidence, 'pixel_mismatch_ratio', $metrics, 'pixel_mismatch_ratio'); + $this->copyNumeric($evidence, 'threshold', $diffSummary, 'threshold'); + + if ( array_key_exists('threshold', $diffSummary) && array_key_exists('pixel_mismatch_ratio', $diffSummary) ) { + $diffSummary['passed'] = (float) $diffSummary['pixel_mismatch_ratio'] <= (float) $diffSummary['threshold']; + } + return array( 'schema' => self::SCHEMA, 'status' => $status, 'reason' => (string) ($overrides['reason'] ?? $evidence['reason'] ?? ''), - 'artifacts' => $evidence['artifacts'] ?? array(), - 'source' => $evidence['source'] ?? array(), - 'generated' => $evidence['generated'] ?? array(), + 'artifacts' => $artifacts, + 'source' => $source, + 'generated' => $generated, 'side_by_side' => $evidence['side_by_side'] ?? null, - 'diff' => $evidence['diff'] ?? null, - 'diff_summary' => $evidence['diff_summary'] ?? array(), - 'metrics' => $evidence['metrics'] ?? array(), + 'diff' => empty($diff) ? null : $diff, + 'diff_summary' => $diffSummary, + 'metrics' => $metrics, + 'viewport' => $viewport, ); } + + /** + * @param array $values + * @return array + */ + private function arrayValue(array $values, string $key): array + { + return isset($values[$key]) && is_array($values[$key]) ? $values[$key] : array(); + } + + /** + * @param array $values + * @return array + */ + private function nullableArrayValue(array $values, string $key): array + { + return isset($values[$key]) && is_array($values[$key]) ? $values[$key] : array(); + } + + /** + * @param array $source + * @param array $target + */ + private function copyScalar(array $source, string $sourceKey, array &$target, string $targetKey): void + { + if ( isset($source[$sourceKey]) && is_scalar($source[$sourceKey]) ) { + $target[$targetKey] = (string) $source[$sourceKey]; + } + } + + /** + * @param array $source + * @param array $target + */ + private function copyNumeric(array $source, string $sourceKey, array &$target, string $targetKey): void + { + if ( isset($source[$sourceKey]) && is_numeric($source[$sourceKey]) ) { + $target[$targetKey] = str_contains((string) $source[$sourceKey], '.') ? (float) $source[$sourceKey] : (int) $source[$sourceKey]; + } + } } diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 66f54b4..e66ae80 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -134,27 +134,61 @@ 'generated_screenshot_path' => 'artifacts/generated.png', 'diff_image_path' => 'artifacts/diff.png', ), - 'source' => array( - 'screenshot_path' => 'artifacts/source.png', - ), - 'generated' => array( - 'screenshot_path' => 'artifacts/generated.png', + 'source_screenshot_path' => 'artifacts/source.png', + 'generated_screenshot_path' => 'artifacts/generated.png', + 'diff_image_path' => 'artifacts/diff.png', + 'frame_id' => '1:1', + 'viewport' => array( + 'width' => 1200, + 'height' => 800, ), 'diff_summary' => array( 'changed_pixels' => 42, - 'threshold' => 0.02, - ), - 'metrics' => array( - 'pixel_diff_ratio' => 0.01, ), + 'pixel_mismatch_count' => 42, + 'pixel_mismatch_ratio' => 0.01, + 'threshold' => 0.02, +)); +$passingParity = $parityBuilder->build(array( + 'status' => 'pass', + 'source_screenshot_url' => 'https://example.com/artifacts/source.png', + 'generated_screenshot_artifact' => 'homeboy://runs/123/generated.png', + 'diff_image_artifact' => 'homeboy://runs/123/diff.png', + 'pixel_mismatch_count' => 10, + 'pixel_mismatch_ratio' => 0.005, + 'threshold' => 0.01, +)); +$failingParity = $parityBuilder->build(array( + 'status' => 'fail', + 'pixel_mismatch_count' => 500, + 'pixel_mismatch_ratio' => 0.05, + 'threshold' => 0.01, +)); +$notRunParity = $parityBuilder->build(); +$unknownParity = $parityBuilder->build(array( + 'status' => 'browser_timeout', )); $assert('pending' === ($pendingParity['status'] ?? null), 'parity-pending-status'); $assert('artifacts/parity-report.json' === ($pendingParity['artifacts']['report_path'] ?? null), 'parity-pending-artifact-path'); $assert('compared' === ($comparedParity['status'] ?? null), 'parity-compared-status'); $assert('artifacts/source.png' === ($comparedParity['source']['screenshot_path'] ?? null), 'parity-source-screenshot-path'); $assert('artifacts/generated.png' === ($comparedParity['generated']['screenshot_path'] ?? null), 'parity-generated-screenshot-path'); +$assert('artifacts/diff.png' === ($comparedParity['diff']['image_path'] ?? null), 'parity-diff-image-path'); +$assert('1:1' === ($comparedParity['source']['frame_id'] ?? null), 'parity-source-frame-id'); +$assert(1200 === ($comparedParity['viewport']['width'] ?? null), 'parity-viewport-width'); $assert(42 === ($comparedParity['diff_summary']['changed_pixels'] ?? null), 'parity-diff-summary'); -$assert(0.01 === ($comparedParity['metrics']['pixel_diff_ratio'] ?? null), 'parity-metric'); +$assert(42 === ($comparedParity['metrics']['pixel_mismatch_count'] ?? null), 'parity-pixel-mismatch-count'); +$assert(0.01 === ($comparedParity['metrics']['pixel_mismatch_ratio'] ?? null), 'parity-pixel-mismatch-ratio'); +$assert(true === ($comparedParity['diff_summary']['passed'] ?? null), 'parity-compared-passed-threshold'); +$assert('pass' === ($passingParity['status'] ?? null), 'parity-pass-status'); +$assert('https://example.com/artifacts/source.png' === ($passingParity['source']['screenshot_url'] ?? null), 'parity-pass-source-url'); +$assert('homeboy://runs/123/generated.png' === ($passingParity['generated']['screenshot_artifact'] ?? null), 'parity-pass-generated-artifact'); +$assert('homeboy://runs/123/diff.png' === ($passingParity['diff']['image_artifact'] ?? null), 'parity-pass-diff-artifact'); +$assert(true === ($passingParity['diff_summary']['passed'] ?? null), 'parity-pass-threshold'); +$assert('fail' === ($failingParity['status'] ?? null), 'parity-fail-status'); +$assert(false === ($failingParity['diff_summary']['passed'] ?? null), 'parity-fail-threshold'); +$assert('not_run' === ($notRunParity['status'] ?? null), 'parity-not-run-status'); +$assert('pending' === ($unknownParity['status'] ?? null), 'parity-unknown-status-falls-back-to-pending'); $fixture = blocks_engine_figma_transformer_create_fig_wrapper_fixture(); $fileResult = blocks_engine_figma_transformer_transform_file($fixture); From 8b91ef2db860e3e4570148a936a4be5a6a2d9fb2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 17:37:20 -0400 Subject: [PATCH 17/31] Document figma transformer local fixture workflow --- figma-transformer/README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/figma-transformer/README.md b/figma-transformer/README.md index 1f4815e..970d2c2 100644 --- a/figma-transformer/README.md +++ b/figma-transformer/README.md @@ -155,3 +155,33 @@ Homeboy/external runner workflow: ## Fixture Strategy Contract tests use small synthetic fixtures that exercise the public envelope, archive safety, deterministic HTML/CSS output, and parity report shape. Large real `.fig` exports are not committed to the repository. When real-file parity evidence is needed, generate it externally through Homeboy or another reviewable artifact surface and attach the resulting reports/screenshots to the relevant issue or PR. + +### Local Real-File Checks + +Use real `.fig` files only as operator-owned, non-committed inputs. Good manual sources are designs you own, Figma Community files or templates that allow duplication/export, and files accessible through the Figma REST API. + +Recommended local layout: + +```text +~/Downloads/figma-transformer-fixtures/ + source.fig + source-api.json + evidence/ +``` + +Example CLI checks: + +```sh +mkdir -p "$HOME/Downloads/figma-transformer-fixtures/evidence" +php figma-transformer/bin/figma-transformer "$HOME/Downloads/figma-transformer-fixtures/source.fig" > "$HOME/Downloads/figma-transformer-fixtures/evidence/source-result.json" +php figma-transformer/bin/figma-transformer "$HOME/Downloads/figma-transformer-fixtures/source-api.json" > "$HOME/Downloads/figma-transformer-fixtures/evidence/source-api-result.json" +``` + +Evidence to keep with the issue or PR: + +- The original Figma URL and file key, not the `.fig` file. +- Whether the input came from **File > Save local copy**, a Community duplicate, or `GET /v1/files/:key`. +- The result envelope JSON, diagnostics, generated artifact manifest, and parity screenshots/diffs when a parity runner was used. +- Runtime details that affect diagnostics, especially PHP version, `ZipArchive`, and Zstandard support. + +Do not copy real `.fig` exports, downloaded image fills, rendered screenshots, or proprietary customer designs into repository fixtures. If a real file exposes a decoder gap, reduce it to a synthetic fixture or attach non-sensitive evidence through the relevant issue/PR artifact workflow. From 3ad5b989ae425da7f4a5a4b2308141734ad3ff83 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 18:32:42 -0400 Subject: [PATCH 18/31] Import fig kiwi node changes end to end --- .../src/Compression/ZstdCapability.php | 60 ++++ .../src/FigFile/FigKiwiDecoder.php | 312 ++++++++++++++++++ .../src/FigFile/FigKiwiParser.php | 48 ++- figma-transformer/src/FigmaTransformer.php | 11 + .../src/Scenegraph/ScenegraphIndex.php | 30 +- .../src/Scenegraph/ScenegraphNormalizer.php | 70 +++- figma-transformer/tests/contract/run.php | 70 ++++ 7 files changed, 591 insertions(+), 10 deletions(-) create mode 100644 figma-transformer/src/FigFile/FigKiwiDecoder.php diff --git a/figma-transformer/src/Compression/ZstdCapability.php b/figma-transformer/src/Compression/ZstdCapability.php index 5d5b2cf..2d1ec90 100644 --- a/figma-transformer/src/Compression/ZstdCapability.php +++ b/figma-transformer/src/Compression/ZstdCapability.php @@ -9,6 +9,13 @@ */ final class ZstdCapability { + /** + * @param callable|null $decoder Optional test/dev decoder with signature fn(string $payload, array $context): string|null. + */ + public function __construct(private readonly mixed $decoder = null) + { + } + public function isAvailable(): bool { $status = $this->status(); @@ -38,6 +45,11 @@ public function status(): array */ public function uncompress(string $payload, string $source, int $chunkIndex): array { + $injected = $this->decodeWithInjectedDecoder($payload, array('source' => $source, 'chunk_index' => $chunkIndex)); + if ( null !== $injected['data'] || ! empty($injected['diagnostics']) ) { + return $injected; + } + if ( ! $this->isAvailable() ) { return array( 'data' => null, @@ -87,6 +99,54 @@ public function uncompress(string $payload, string $source, int $chunkIndex): ar ); } + /** + * @param array $context + * @return array{data: string|null, diagnostics: array>} + */ + private function decodeWithInjectedDecoder(string $payload, array $context): array + { + $decoder = is_callable($this->decoder) ? $this->decoder : null; + if ( null === $decoder && function_exists('apply_filters') ) { + $decoder = apply_filters('blocks_engine_figma_transformer_zstd_decoder', null, $context); + } + + if ( ! is_callable($decoder) ) { + return array('data' => null, 'diagnostics' => array()); + } + + try { + $decoded = $decoder($payload, $context); + } catch ( \Throwable $throwable ) { + return array( + 'data' => null, + 'diagnostics' => array( + array( + 'code' => 'figma_transformer_zstd_decoder_failed', + 'message' => 'Injected Zstandard decoder raised an error while decoding the payload.', + 'source' => (string) ($context['source'] ?? 'ZstdCapability'), + 'context' => array_merge($context, array('error' => $throwable->getMessage())), + ), + ), + ); + } + + if ( ! is_string($decoded) ) { + return array('data' => null, 'diagnostics' => array()); + } + + return array( + 'data' => $decoded, + 'diagnostics' => array( + array( + 'code' => 'figma_transformer_zstd_injected_decoder_used', + 'message' => 'Zstandard chunk decoded by an injected decoder callable.', + 'source' => (string) ($context['source'] ?? 'ZstdCapability'), + 'context' => $context, + ), + ), + ); + } + /** * @return array */ diff --git a/figma-transformer/src/FigFile/FigKiwiDecoder.php b/figma-transformer/src/FigFile/FigKiwiDecoder.php new file mode 100644 index 0000000..f95cb94 --- /dev/null +++ b/figma-transformer/src/FigFile/FigKiwiDecoder.php @@ -0,0 +1,312 @@ +|null, diagnostics: array>} + */ + public function decodeSchema(string $payload): array + { + try { + $reader = new FigKiwiByteReader($payload); + $definitions = array(); + $definitionCount = $reader->readVarUint(); + + for ( $i = 0; $i < $definitionCount; $i++ ) { + $kindIndex = null; + $definition = array( + 'name' => $reader->readString(), + 'kind' => null, + 'fields' => array(), + ); + $kindIndex = $reader->readByte(); + $definition['kind'] = self::KINDS[$kindIndex] ?? 'UNKNOWN'; + $fieldCount = $reader->readVarUint(); + + for ( $j = 0; $j < $fieldCount; $j++ ) { + $definition['fields'][] = array( + 'name' => $reader->readString(), + 'type' => $reader->readVarInt(), + 'is_array' => 1 === ($reader->readByte() & 1), + 'is_deprecated' => false, + 'value' => $reader->readVarUint(), + ); + } + + $definitions[] = $definition; + } + + foreach ( $definitions as $definitionIndex => $definition ) { + foreach ( $definition['fields'] as $fieldIndex => $field ) { + $type = $field['type']; + if ( null !== $type && $type < 0 ) { + $definitions[$definitionIndex]['fields'][$fieldIndex]['type'] = self::TYPES[~$type] ?? null; + } elseif ( null !== $type ) { + $definitions[$definitionIndex]['fields'][$fieldIndex]['type'] = $definitions[$type]['name'] ?? null; + } + } + } + + return array('schema' => array('definitions' => $definitions), 'diagnostics' => array()); + } catch ( \Throwable $throwable ) { + return array( + 'schema' => null, + 'diagnostics' => array($this->diagnostic('figma_transformer_kiwi_schema_decode_failed', 'Kiwi schema chunk could not be decoded.', $throwable->getMessage())), + ); + } + } + + /** + * @param array $schema + * @return array{message: array|null, diagnostics: array>} + */ + public function decodeMessage(string $payload, array $schema, string $rootType = 'Message'): array + { + try { + $definitions = $this->definitionsByName($schema); + if ( ! isset($definitions[$rootType]) ) { + return array('message' => null, 'diagnostics' => array($this->diagnostic('figma_transformer_kiwi_message_schema_missing', 'Kiwi schema does not define the expected root message.', $rootType))); + } + + $message = $this->decodeDefinition(new FigKiwiByteReader($payload), $definitions[$rootType], $definitions); + return array('message' => is_array($message) ? $message : null, 'diagnostics' => array()); + } catch ( \Throwable $throwable ) { + return array( + 'message' => null, + 'diagnostics' => array($this->diagnostic('figma_transformer_kiwi_message_decode_failed', 'Kiwi message chunk could not be decoded.', $throwable->getMessage())), + ); + } + } + + /** + * @param array $schema + * @return array> + */ + private function definitionsByName(array $schema): array + { + $definitions = array(); + foreach ( $schema['definitions'] ?? array() as $definition ) { + if ( is_array($definition) && isset($definition['name']) ) { + $definitions[(string) $definition['name']] = $definition; + } + } + + return $definitions; + } + + /** + * @param array $definition + * @param array> $definitions + */ + private function decodeDefinition(FigKiwiByteReader $reader, array $definition, array $definitions): array + { + $result = array(); + if ( 'MESSAGE' === ($definition['kind'] ?? null) ) { + $fieldsByValue = array(); + foreach ( $definition['fields'] ?? array() as $field ) { + if ( is_array($field) ) { + $fieldsByValue[(int) ($field['value'] ?? 0)] = $field; + } + } + + while ( true ) { + $fieldValue = $reader->readVarUint(); + if ( 0 === $fieldValue ) { + return $result; + } + if ( ! isset($fieldsByValue[$fieldValue]) ) { + throw new \RuntimeException('Attempted to parse invalid message field ' . $fieldValue . '.'); + } + $this->decodeField($reader, $fieldsByValue[$fieldValue], $definitions, $result); + } + } + + foreach ( $definition['fields'] ?? array() as $field ) { + if ( is_array($field) ) { + $this->decodeField($reader, $field, $definitions, $result); + } + } + + return $result; + } + + /** + * @param array $field + * @param array> $definitions + * @param array $result + */ + private function decodeField(FigKiwiByteReader $reader, array $field, array $definitions, array &$result): void + { + $type = (string) ($field['type'] ?? ''); + if ( true === ($field['is_array'] ?? false) ) { + if ( 'byte' === $type ) { + $value = $reader->readByteArray(); + } else { + $length = $reader->readVarUint(); + $value = array(); + for ( $i = 0; $i < $length; $i++ ) { + $value[] = $this->decodeValue($reader, $type, $definitions); + } + } + } else { + $value = $this->decodeValue($reader, $type, $definitions); + } + + if ( true !== ($field['is_deprecated'] ?? false) && isset($field['name']) ) { + $result[(string) $field['name']] = $value; + } + } + + /** + * @param array> $definitions + */ + private function decodeValue(FigKiwiByteReader $reader, string $type, array $definitions): mixed + { + return match ( $type ) { + 'bool' => 0 !== $reader->readByte(), + 'byte' => $reader->readByte(), + 'int' => $reader->readVarInt(), + 'uint' => $reader->readVarUint(), + 'float' => $reader->readVarFloat(), + 'string' => $reader->readString(), + 'int64' => $reader->readVarInt64(), + 'uint64' => $reader->readVarUint64(), + default => $this->decodeNamedValue($reader, $type, $definitions), + }; + } + + /** + * @param array> $definitions + */ + private function decodeNamedValue(FigKiwiByteReader $reader, string $type, array $definitions): mixed + { + $definition = $definitions[$type] ?? null; + if ( ! is_array($definition) ) { + throw new \RuntimeException('Invalid Kiwi type ' . $type . '.'); + } + + if ( 'ENUM' === ($definition['kind'] ?? null) ) { + $value = $reader->readVarUint(); + foreach ( $definition['fields'] ?? array() as $field ) { + if ( is_array($field) && (int) ($field['value'] ?? -1) === $value ) { + return (string) ($field['name'] ?? $value); + } + } + return $value; + } + + return $this->decodeDefinition($reader, $definition, $definitions); + } + + /** + * @return array + */ + private function diagnostic(string $code, string $message, string $error): array + { + return array('code' => $code, 'message' => $message, 'source' => 'FigKiwiDecoder', 'context' => array('error' => $error)); + } +} + +final class FigKiwiByteReader +{ + private int $offset = 0; + + public function __construct(private readonly string $data) + { + } + + public function readByte(): int + { + if ( $this->offset >= strlen($this->data) ) { + throw new \RuntimeException('Index out of bounds.'); + } + + return ord($this->data[$this->offset++]); + } + + public function readByteArray(): string + { + $length = $this->readVarUint(); + if ( $this->offset + $length > strlen($this->data) ) { + throw new \RuntimeException('Read array out of bounds.'); + } + $value = substr($this->data, $this->offset, $length); + $this->offset += $length; + return $value; + } + + public function readVarUint(): int + { + $value = 0; + $shift = 0; + do { + $byte = $this->readByte(); + $value |= ($byte & 0x7f) << $shift; + $shift += 7; + } while ( ($byte & 0x80) && $shift < 35 ); + + return $value; + } + + public function readVarInt(): int + { + $value = $this->readVarUint(); + return ($value & 1) ? ~($value >> 1) : ($value >> 1); + } + + public function readVarUint64(): int + { + $value = 0; + $shift = 0; + do { + $byte = $this->readByte(); + $value |= ($byte & 0x7f) << $shift; + $shift += 7; + } while ( ($byte & 0x80) && $shift < 63 ); + + return $value; + } + + public function readVarInt64(): int + { + $value = $this->readVarUint64(); + return ($value & 1) ? ~($value >> 1) : ($value >> 1); + } + + public function readVarFloat(): float + { + $first = $this->readByte(); + if ( 0 === $first ) { + return 0.0; + } + + $bits = unpack('V', chr($first) . chr($this->readByte()) . chr($this->readByte()) . chr($this->readByte())); + $value = is_array($bits) ? (int) $bits[1] : 0; + $rotated = (($value << 23) & 0xffffffff) | (($value >> 9) & 0x7fffff); + $float = unpack('f', pack('V', $rotated)); + + return is_array($float) ? (float) $float[1] : 0.0; + } + + public function readString(): string + { + $bytes = ''; + while ( true ) { + $byte = $this->readByte(); + if ( 0 === $byte ) { + return $bytes; + } + $bytes .= chr($byte); + } + } +} diff --git a/figma-transformer/src/FigFile/FigKiwiParser.php b/figma-transformer/src/FigFile/FigKiwiParser.php index d59277f..aed4f63 100644 --- a/figma-transformer/src/FigFile/FigKiwiParser.php +++ b/figma-transformer/src/FigFile/FigKiwiParser.php @@ -20,7 +20,8 @@ final class FigKiwiParser private const WIRE_RECORD_LIMIT = 64; public function __construct( - private readonly ZstdCapability $zstdCapability = new ZstdCapability() + private readonly ZstdCapability $zstdCapability = new ZstdCapability(), + private readonly FigKiwiDecoder $kiwiDecoder = new FigKiwiDecoder() ) { } @@ -54,6 +55,7 @@ public function parse(string $raw): array $chunks = array(); $diagnostics = array(); + $kiwiSchema = null; $offset = 12; $index = 0; $totalBytes = strlen($raw); @@ -97,7 +99,7 @@ public function parse(string $raw): array } else { $chunk['inflated_bytes'] = strlen($inflated); $chunk['inflated_preview_hex'] = bin2hex(substr($inflated, 0, 32)); - $chunk['payload'] = $this->classifyPayload($inflated); + $chunk['payload'] = $this->classifyPayload($inflated, $kiwiSchema, $diagnostics); } } elseif ( 'zstd' === $chunk['compression'] ) { $zstdResult = $this->zstdCapability->uncompress($payload, 'FigKiwiParser', $index); @@ -105,10 +107,10 @@ public function parse(string $raw): array if ( null !== $zstdResult['data'] ) { $chunk['inflated_bytes'] = strlen($zstdResult['data']); $chunk['inflated_preview_hex'] = bin2hex(substr($zstdResult['data'], 0, 32)); - $chunk['payload'] = $this->classifyPayload($zstdResult['data']); + $chunk['payload'] = $this->classifyPayload($zstdResult['data'], $kiwiSchema, $diagnostics); } } else { - $chunk['payload'] = $this->classifyPayload($payload); + $chunk['payload'] = $this->classifyPayload($payload, $kiwiSchema, $diagnostics); $diagnostics[] = $this->diagnostic('figma_transformer_kiwi_unknown_compression', 'fig-kiwi chunk compression could not be identified.', array('chunk_index' => $index)); } @@ -147,7 +149,7 @@ private function detectCompression(string $payload): string /** * @return array */ - private function classifyPayload(string $payload): array + private function classifyPayload(string $payload, ?array &$kiwiSchema, array &$diagnostics): array { $classification = $this->looksJsonLike($payload) ? 'json_invalid' : 'binary'; $metadata = array( @@ -157,6 +159,28 @@ private function classifyPayload(string $payload): array ); if ( 'json_invalid' !== $classification ) { + if ( null === $kiwiSchema ) { + $schemaResult = $this->kiwiDecoder->decodeSchema($payload); + if ( null !== $schemaResult['schema'] ) { + $diagnostics = array_merge($diagnostics, $schemaResult['diagnostics']); + $kiwiSchema = $schemaResult['schema']; + $metadata['classification'] = 'kiwi_schema'; + $metadata['kiwi_schema'] = array( + 'definition_count' => count($kiwiSchema['definitions'] ?? array()), + 'message_root' => $this->hasKiwiDefinition($kiwiSchema, 'Message'), + ); + return $metadata; + } + } else { + $messageResult = $this->kiwiDecoder->decodeMessage($payload, $kiwiSchema); + $diagnostics = array_merge($diagnostics, $messageResult['diagnostics']); + if ( null !== $messageResult['message'] ) { + $metadata['classification'] = 'kiwi_message'; + $metadata['kiwi_message'] = $messageResult['message']; + return $metadata; + } + } + $wire = $this->describeWirePayload($payload); if ( $wire['record_count'] > 0 ) { $metadata['wire'] = $wire; @@ -179,6 +203,20 @@ private function classifyPayload(string $payload): array return $metadata; } + /** + * @param array $schema + */ + private function hasKiwiDefinition(array $schema, string $name): bool + { + foreach ( $schema['definitions'] ?? array() as $definition ) { + if ( is_array($definition) && $name === ($definition['name'] ?? null) ) { + return true; + } + } + + return false; + } + /** * Describes protobuf-style wire records without claiming a schema-level decode. * diff --git a/figma-transformer/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php index 41cbbd9..392aff3 100644 --- a/figma-transformer/src/FigmaTransformer.php +++ b/figma-transformer/src/FigmaTransformer.php @@ -116,6 +116,17 @@ private function decodedScenegraphPayload(array $archive): ?array } } + foreach ( $chunks as $chunk ) { + if ( ! is_array($chunk) ) { + continue; + } + + $payload = $chunk['payload'] ?? array(); + if ( is_array($payload) && 'kiwi_message' === ($payload['classification'] ?? null) && is_array($payload['kiwi_message'] ?? null) ) { + return $payload['kiwi_message']; + } + } + return null; } diff --git a/figma-transformer/src/Scenegraph/ScenegraphIndex.php b/figma-transformer/src/Scenegraph/ScenegraphIndex.php index 3a0f017..cc6bccf 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphIndex.php +++ b/figma-transformer/src/Scenegraph/ScenegraphIndex.php @@ -140,7 +140,7 @@ private function collectNode(array $value, ?string $fallbackId, ?string $parentI return; } - $id = $this->readString($node, array('id', 'node_id', 'nodeId')) ?? $fallbackId; + $id = $this->readString($node, array('id', 'node_id', 'nodeId')) ?? $this->readGuid($node['guid'] ?? null) ?? $fallbackId; if ( null === $id || '' === $id ) { $diagnostics[] = array( 'code' => 'scenegraph_node_id_missing', @@ -149,7 +149,7 @@ private function collectNode(array $value, ?string $fallbackId, ?string $parentI return; } - $explicitParent = $this->readString($node, array('parent', 'parentId', 'parent_id')); + $explicitParent = $this->readString($node, array('parent', 'parentId', 'parent_id')) ?? $this->readParentGuid($node); $effectiveParent = $explicitParent ?? $parentId; $children = array(); @@ -199,7 +199,7 @@ private function unwrapNodeChange(array $value): ?array } } - if ( isset($value['type']) || isset($value['id']) || isset($value['children']) ) { + if ( isset($value['type']) || isset($value['id']) || isset($value['guid']) || isset($value['children']) ) { return $value; } @@ -221,6 +221,23 @@ private function readString(array $node, array $keys): ?string return null; } + private function readGuid(mixed $guid): ?string + { + if ( is_array($guid) && isset($guid['sessionID'], $guid['localID']) ) { + return (string) $guid['sessionID'] . ':' . (string) $guid['localID']; + } + + return null; + } + + /** + * @param array $node + */ + private function readParentGuid(array $node): ?string + { + return $this->readGuid($node['parentIndex']['guid'] ?? null); + } + /** * @param array $node * @param array $children @@ -269,6 +286,13 @@ private static function readBounds(array $node): array } } + if ( is_array($node['transform'] ?? null) ) { + return array( + 'x' => is_numeric($node['transform']['m02'] ?? null) ? (float) $node['transform']['m02'] : 0.0, + 'y' => is_numeric($node['transform']['m12'] ?? null) ? (float) $node['transform']['m12'] : 0.0, + ); + } + return array('x' => 0.0, 'y' => 0.0); } } diff --git a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php index c1de32d..2e65d32 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -40,6 +40,9 @@ public function normalize(array $source, array $options = array()): array } $renderIds = $topLevelIds; + if ( null !== $selectedFrameId && 1 === count($topLevelIds) && $selectedFrameId !== $topLevelIds[0] ) { + $renderIds = array($selectedFrameId); + } $renderNodes = array(); foreach ( $renderIds as $id ) { if ( isset($nodeMap[$id]) ) { @@ -580,7 +583,7 @@ private function normalizeStyledTextSegments(array $node): array private function normalizePaintCollections(array $node, string $nodeId, array &$diagnostics): array { $collections = array(); - foreach ( array('fills' => 'fills', 'strokes' => 'strokes', 'background' => 'background') as $sourceKey => $targetKey ) { + foreach ( array('fills' => 'fills', 'fillPaints' => 'fills', 'strokes' => 'strokes', 'strokePaints' => 'strokes', 'background' => 'background') as $sourceKey => $targetKey ) { if ( ! is_array($node[$sourceKey] ?? null) ) { continue; } @@ -679,7 +682,7 @@ private function normalizeVisualBox(array $node): array } } - foreach ( array('relativeTransform', 'absoluteTransform') as $sourceKey ) { + foreach ( array('relativeTransform', 'absoluteTransform', 'transform') as $sourceKey ) { if ( is_array($node[$sourceKey] ?? null) ) { $box['transform'] = $node[$sourceKey]; break; @@ -785,9 +788,56 @@ private function selectTopLevelFrameIds(array $topLevelIds, array $nodeMap): arr } } + if ( ! empty($frameIds) ) { + return $frameIds; + } + + foreach ( $topLevelIds as $id ) { + foreach ( $this->collectFrameDescendantIds($nodeMap[$id]['children'] ?? array()) as $frameId ) { + $frameIds[] = $frameId; + } + } + return $frameIds; } + /** + * @param mixed $children + * @return array + */ + private function collectFrameDescendantIds(mixed $children): array + { + if ( ! is_array($children) ) { + return array(); + } + + $frameIds = array(); + foreach ( $children as $child ) { + if ( ! is_array($child) ) { + continue; + } + + $type = strtoupper((string) ($child['type'] ?? '')); + if ( in_array($type, array('DOCUMENT', 'CANVAS'), true) ) { + if ( 'CANVAS' === $type && (false === ($child['visible'] ?? true) || true === ($child['internalOnly'] ?? false)) ) { + continue; + } + + foreach ( $this->collectFrameDescendantIds($child['children'] ?? array()) as $frameId ) { + $frameIds[] = $frameId; + } + continue; + } + + if ( in_array($type, array('FRAME', 'COMPONENT', 'INSTANCE'), true) ) { + $frameIds[] = (string) ($child['id'] ?? ''); + continue; + } + } + + return array_values(array_filter(array_unique($frameIds))); + } + /** * @param array> $nodeMap * @param array> $childrenIndex @@ -819,6 +869,22 @@ private function normalizeLayoutBox(array $node): array } } + if ( is_array($node['size'] ?? null) ) { + foreach ( array('x' => 'width', 'y' => 'height') as $source => $target ) { + if ( ! array_key_exists($target, $box) && isset($node['size'][$source]) && is_numeric($node['size'][$source]) ) { + $box[$target] = (float) $node['size'][$source]; + } + } + } + + if ( is_array($node['transform'] ?? null) ) { + foreach ( array('m02' => 'x', 'm12' => 'y') as $source => $target ) { + if ( ! array_key_exists($target, $box) && isset($node['transform'][$source]) && is_numeric($node['transform'][$source]) ) { + $box[$target] = (float) $node['transform'][$source]; + } + } + } + return $box; } diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index e66ae80..327d21a 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/../../figma-transformer.php'; use Automattic\BlocksEngine\FigmaTransformer\Compression\ZstdCapability; +use Automattic\BlocksEngine\FigmaTransformer\FigFile\FigKiwiDecoder; use Automattic\BlocksEngine\FigmaTransformer\FigFile\FigKiwiParser; use Automattic\BlocksEngine\FigmaTransformer\Parity\ParityReportBuilder; @@ -247,6 +248,32 @@ $assert('images/synthetic' === ($fileResult['source_reports']['figma']['assets'][0]['path'] ?? null), 'archive-asset-path'); $assert('asset' === ($fileResult['source_reports']['figma']['assets'][0]['content'] ?? null), 'archive-asset-content'); +$kiwiSchemaBytes = blocks_engine_figma_transformer_kiwi_schema_fixture(); +$kiwiMessageBytes = blocks_engine_figma_transformer_kiwi_message_fixture(); +$kiwiDecoder = new FigKiwiDecoder(); +$kiwiSchemaResult = $kiwiDecoder->decodeSchema($kiwiSchemaBytes); +$kiwiMessageResult = $kiwiDecoder->decodeMessage($kiwiMessageBytes, $kiwiSchemaResult['schema'] ?? array()); +$assert(null !== ($kiwiSchemaResult['schema'] ?? null), 'kiwi-schema-decodes'); +$assert('NODE_CHANGES' === ($kiwiMessageResult['message']['type'] ?? null), 'kiwi-message-enum-decodes'); +$assert(array('alpha', 'beta') === ($kiwiMessageResult['message']['nodeChanges'] ?? null), 'kiwi-message-array-decodes'); + +$injectedParser = new FigKiwiParser(new ZstdCapability(static fn (string $payload, array $context): string => $kiwiMessageBytes)); +$injectedCanvas = $injectedParser->parse( + 'fig-kiwi' + . pack('V', 106) + . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate($kiwiSchemaBytes)) + . blocks_engine_figma_transformer_kiwi_chunk("\x28\xb5\x2f\xfd" . 'synthetic-zstd-frame') +); +$injectedChunks = $injectedCanvas['canvas']['chunks'] ?? array(); +$injectedDiagnosticCodes = array_map( + static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), + $injectedCanvas['diagnostics'] ?? array() +); +$assert('kiwi_schema' === ($injectedChunks[0]['payload']['classification'] ?? null), 'kiwi-parser-classifies-schema'); +$assert('kiwi_message' === ($injectedChunks[1]['payload']['classification'] ?? null), 'kiwi-parser-classifies-message'); +$assert('NODE_CHANGES' === ($injectedChunks[1]['payload']['kiwi_message']['type'] ?? null), 'kiwi-parser-message-type'); +$assert(in_array('figma_transformer_zstd_injected_decoder_used', $injectedDiagnosticCodes, true), 'zstd-injected-decoder-diagnostic'); + $wirePayload = blocks_engine_figma_transformer_wire_varint(8) . blocks_engine_figma_transformer_wire_varint(150) . blocks_engine_figma_transformer_wire_varint(18) @@ -680,6 +707,49 @@ function blocks_engine_figma_transformer_wire_varint(int $value): string return $bytes; } +function blocks_engine_figma_transformer_wire_varint_signed(int $value): string +{ + return blocks_engine_figma_transformer_wire_varint($value < 0 ? ((~$value) << 1) | 1 : $value << 1); +} + +function blocks_engine_figma_transformer_kiwi_string(string $value): string +{ + return $value . "\0"; +} + +function blocks_engine_figma_transformer_kiwi_schema_field(string $name, int $type, bool $isArray, int $value): string +{ + return blocks_engine_figma_transformer_kiwi_string($name) + . blocks_engine_figma_transformer_wire_varint_signed($type) + . chr($isArray ? 1 : 0) + . blocks_engine_figma_transformer_wire_varint($value); +} + +function blocks_engine_figma_transformer_kiwi_schema_fixture(): string +{ + return blocks_engine_figma_transformer_wire_varint(2) + . blocks_engine_figma_transformer_kiwi_string('MessageType') + . chr(0) + . blocks_engine_figma_transformer_wire_varint(1) + . blocks_engine_figma_transformer_kiwi_schema_field('NODE_CHANGES', 0, false, 1) + . blocks_engine_figma_transformer_kiwi_string('Message') + . chr(2) + . blocks_engine_figma_transformer_wire_varint(2) + . blocks_engine_figma_transformer_kiwi_schema_field('type', 0, false, 1) + . blocks_engine_figma_transformer_kiwi_schema_field('nodeChanges', -6, true, 4); +} + +function blocks_engine_figma_transformer_kiwi_message_fixture(): string +{ + return blocks_engine_figma_transformer_wire_varint(1) + . blocks_engine_figma_transformer_wire_varint(1) + . blocks_engine_figma_transformer_wire_varint(4) + . blocks_engine_figma_transformer_wire_varint(2) + . blocks_engine_figma_transformer_kiwi_string('alpha') + . blocks_engine_figma_transformer_kiwi_string('beta') + . blocks_engine_figma_transformer_wire_varint(0); +} + /** * @return array */ From 95d603bc68ca0f947530173dd037a8128952bd8e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 18:15:07 -0400 Subject: [PATCH 19/31] Add optional zstd decoder adapters --- figma-transformer/README.md | 29 +++ figma-transformer/composer.json | 2 +- .../src/Compression/ZstdCapability.php | 172 ++++++++++++------ figma-transformer/tests/contract/run.php | 26 ++- 4 files changed, 173 insertions(+), 56 deletions(-) diff --git a/figma-transformer/README.md b/figma-transformer/README.md index 970d2c2..11bd905 100644 --- a/figma-transformer/README.md +++ b/figma-transformer/README.md @@ -74,6 +74,35 @@ Next decoder milestones: - Map decoded Kiwi messages into the normalized IR already accepted by `transformScenegraph()`. - Expand layout, paint, text, component, and asset coverage against external real-file evidence. +### Zstandard Support + +Zstandard decoding is optional and capability-driven so WordPress installs without zstd still produce deterministic diagnostics instead of hard failures. + +Supported decoder paths: + +- `ext-zstd` with `zstd_uncompress()` available. +- An explicit `Compression\ZstdCapability` adapter callable for hosts that provide a PHP-compatible zstd decoder through another package or service boundary. +- A WordPress filter adapter registered on `blocks_engine_figma_transformer_zstd_decoder`. + +Adapter callables receive the compressed payload and context array, and return decoded bytes or an array with `data` and optional `diagnostics`: + +```php +$zstd = new Automattic\BlocksEngine\FigmaTransformer\Compression\ZstdCapability( + static function (string $payload, array $context): string|false { + return my_zstd_decode($payload); + } +); +``` + +```php +add_filter( + 'blocks_engine_figma_transformer_zstd_decoder', + static fn () => static fn (string $payload, array $context): string|false => my_zstd_decode($payload) +); +``` + +No pure-PHP Zstandard decoder is bundled today. The practical blocker is a small, maintained, WordPress-compatible decoder that can handle production Figma zstd frames without a native extension or shell binary. Until that exists, unsupported runtimes report `figma_transformer_zstd_extension_missing` or adapter failure diagnostics and continue parsing the rest of the archive metadata. + ## Output Contract Successful transforms produce a static website artifact: diff --git a/figma-transformer/composer.json b/figma-transformer/composer.json index 1b0cd2b..39fbf43 100644 --- a/figma-transformer/composer.json +++ b/figma-transformer/composer.json @@ -33,7 +33,7 @@ "php": ">=8.1" }, "suggest": { - "ext-zstd": "Decode zstd-compressed canvas.fig chunks in native Figma .fig archives." + "ext-zstd": "Decode zstd-compressed canvas.fig chunks in native Figma .fig archives. Runtimes can alternatively register a decoder adapter callable." }, "bin": [ "bin/figma-transformer" diff --git a/figma-transformer/src/Compression/ZstdCapability.php b/figma-transformer/src/Compression/ZstdCapability.php index 2d1ec90..77b753e 100644 --- a/figma-transformer/src/Compression/ZstdCapability.php +++ b/figma-transformer/src/Compression/ZstdCapability.php @@ -5,15 +5,16 @@ namespace Automattic\BlocksEngine\FigmaTransformer\Compression; /** - * Reports whether native Zstandard decoding is available to PHP. + * Reports and executes available Zstandard decoding for PHP runtimes. */ final class ZstdCapability { /** - * @param callable|null $decoder Optional test/dev decoder with signature fn(string $payload, array $context): string|null. + * @param callable|null $decoder Optional adapter: fn (string $payload, array $context): string|array{data?: string|null, diagnostics?: array>}|false|null */ - public function __construct(private readonly mixed $decoder = null) - { + public function __construct( + private readonly mixed $decoder = null + ) { } public function isAvailable(): bool @@ -23,20 +24,35 @@ public function isAvailable(): bool } /** - * @return array{available: bool, extension_loaded: bool, extension_version: string|null, functions: array} + * @return array{available: bool, provider: string|null, extension_loaded: bool, extension_version: string|null, functions: array, adapter_registered: bool, wordpress_filter_registered: bool} */ public function status(): array { $extensionLoaded = extension_loaded('zstd'); + $nativeAvailable = $extensionLoaded && function_exists('zstd_uncompress'); + $adapterRegistered = is_callable($this->decoder); + $filterRegistered = $this->hasWordPressFilterDecoder(); + + $provider = null; + if ( $adapterRegistered ) { + $provider = 'adapter'; + } elseif ( $nativeAvailable ) { + $provider = 'ext-zstd'; + } elseif ( $filterRegistered ) { + $provider = 'wordpress_filter'; + } return array( - 'available' => $extensionLoaded && function_exists('zstd_uncompress'), + 'available' => null !== $provider, + 'provider' => $provider, 'extension_loaded' => $extensionLoaded, 'extension_version' => $extensionLoaded ? phpversion('zstd') ?: null : null, 'functions' => array( 'zstd_compress' => function_exists('zstd_compress'), 'zstd_uncompress' => function_exists('zstd_uncompress'), ), + 'adapter_registered' => $adapterRegistered, + 'wordpress_filter_registered' => $filterRegistered, ); } @@ -45,12 +61,14 @@ public function status(): array */ public function uncompress(string $payload, string $source, int $chunkIndex): array { - $injected = $this->decodeWithInjectedDecoder($payload, array('source' => $source, 'chunk_index' => $chunkIndex)); - if ( null !== $injected['data'] || ! empty($injected['diagnostics']) ) { - return $injected; + $status = $this->status(); + + if ( 'ext-zstd' === $status['provider'] ) { + return $this->uncompressWithNativeExtension($payload, $source, $chunkIndex); } - if ( ! $this->isAvailable() ) { + $decoder = $this->decoder(); + if ( null === $decoder ) { return array( 'data' => null, 'diagnostics' => array($this->diagnostic($source, $chunkIndex)), @@ -58,92 +76,97 @@ public function uncompress(string $payload, string $source, int $chunkIndex): ar } try { - $decoded = zstd_uncompress($payload); + $decoded = $decoder($payload, array('source' => $source, 'chunk_index' => $chunkIndex, 'status' => $status)); } catch ( \Throwable $throwable ) { return array( 'data' => null, 'diagnostics' => array( array( - 'code' => 'figma_transformer_zstd_uncompress_failed', - 'message' => 'Zstandard chunk detected but ext-zstd raised an error while decoding the payload.', + 'code' => 'figma_transformer_zstd_adapter_failed', + 'message' => 'Zstandard chunk detected but the configured decoder adapter raised an error.', 'source' => $source, 'context' => array_merge( array( 'chunk_index' => $chunkIndex, 'error' => $throwable->getMessage(), ), - $this->status() + $status ), ), ), ); } - if ( false === $decoded ) { + $diagnostics = array($this->diagnostic($source, $chunkIndex)); + if ( is_array($decoded) ) { + $diagnostics = array_merge($diagnostics, is_array($decoded['diagnostics'] ?? null) ? $decoded['diagnostics'] : array()); + $decoded = $decoded['data'] ?? null; + } + + if ( is_string($decoded) ) { return array( - 'data' => null, - 'diagnostics' => array( - array( - 'code' => 'figma_transformer_zstd_uncompress_failed', - 'message' => 'Zstandard chunk detected but ext-zstd could not decode the payload.', - 'source' => $source, - 'context' => array_merge(array('chunk_index' => $chunkIndex), $this->status()), - ), - ), + 'data' => $decoded, + 'diagnostics' => $diagnostics, ); } return array( - 'data' => $decoded, - 'diagnostics' => array($this->diagnostic($source, $chunkIndex)), + 'data' => null, + 'diagnostics' => array( + array( + 'code' => 'figma_transformer_zstd_adapter_failed', + 'message' => 'Zstandard chunk detected but the configured decoder adapter did not return decoded bytes.', + 'source' => $source, + 'context' => array_merge(array('chunk_index' => $chunkIndex), $status), + ), + ), ); } /** - * @param array $context * @return array{data: string|null, diagnostics: array>} */ - private function decodeWithInjectedDecoder(string $payload, array $context): array + private function uncompressWithNativeExtension(string $payload, string $source, int $chunkIndex): array { - $decoder = is_callable($this->decoder) ? $this->decoder : null; - if ( null === $decoder && function_exists('apply_filters') ) { - $decoder = apply_filters('blocks_engine_figma_transformer_zstd_decoder', null, $context); - } - - if ( ! is_callable($decoder) ) { - return array('data' => null, 'diagnostics' => array()); - } - try { - $decoded = $decoder($payload, $context); + $decoded = zstd_uncompress($payload); } catch ( \Throwable $throwable ) { return array( 'data' => null, 'diagnostics' => array( array( - 'code' => 'figma_transformer_zstd_decoder_failed', - 'message' => 'Injected Zstandard decoder raised an error while decoding the payload.', - 'source' => (string) ($context['source'] ?? 'ZstdCapability'), - 'context' => array_merge($context, array('error' => $throwable->getMessage())), + 'code' => 'figma_transformer_zstd_uncompress_failed', + 'message' => 'Zstandard chunk detected but ext-zstd raised an error while decoding the payload.', + 'source' => $source, + 'context' => array_merge( + array( + 'chunk_index' => $chunkIndex, + 'error' => $throwable->getMessage(), + ), + $this->status() + ), ), ), ); } - if ( ! is_string($decoded) ) { - return array('data' => null, 'diagnostics' => array()); + if ( false === $decoded ) { + return array( + 'data' => null, + 'diagnostics' => array( + array( + 'code' => 'figma_transformer_zstd_uncompress_failed', + 'message' => 'Zstandard chunk detected but ext-zstd could not decode the payload.', + 'source' => $source, + 'context' => array_merge(array('chunk_index' => $chunkIndex), $this->status()), + ), + ), + ); } return array( 'data' => $decoded, - 'diagnostics' => array( - array( - 'code' => 'figma_transformer_zstd_injected_decoder_used', - 'message' => 'Zstandard chunk decoded by an injected decoder callable.', - 'source' => (string) ($context['source'] ?? 'ZstdCapability'), - 'context' => $context, - ), - ), + 'diagnostics' => array($this->diagnostic($source, $chunkIndex)), ); } @@ -154,7 +177,7 @@ public function diagnostic(string $source, int $chunkIndex): array { $status = $this->status(); - if ( true === $status['available'] ) { + if ( 'ext-zstd' === $status['provider'] ) { return array( 'code' => 'figma_transformer_zstd_available', 'message' => 'Zstandard chunk detected and ext-zstd is available.', @@ -163,6 +186,15 @@ public function diagnostic(string $source, int $chunkIndex): array ); } + if ( null !== $status['provider'] ) { + return array( + 'code' => 'figma_transformer_zstd_adapter_available', + 'message' => 'Zstandard chunk detected and a configured decoder adapter is available.', + 'source' => $source, + 'context' => array_merge(array('chunk_index' => $chunkIndex), $status), + ); + } + if ( true === $status['extension_loaded'] ) { return array( 'code' => 'figma_transformer_zstd_function_missing', @@ -174,9 +206,41 @@ public function diagnostic(string $source, int $chunkIndex): array return array( 'code' => 'figma_transformer_zstd_extension_missing', - 'message' => 'Zstandard chunk detected; install ext-zstd to decode zstd-compressed fig-kiwi chunks.', + 'message' => 'Zstandard chunk detected; install ext-zstd or register a decoder adapter to decode zstd-compressed fig-kiwi chunks.', 'source' => $source, 'context' => array_merge(array('chunk_index' => $chunkIndex), $status), ); } + + /** + * @return callable|null + */ + private function decoder(): ?callable + { + if ( is_callable($this->decoder) ) { + return $this->decoder; + } + + if ( function_exists('apply_filters') ) { + $decoder = apply_filters('blocks_engine_figma_transformer_zstd_decoder', null, $this); + if ( is_callable($decoder) ) { + return $decoder; + } + } + + return null; + } + + private function hasWordPressFilterDecoder(): bool + { + if ( ! function_exists('has_filter') || ! function_exists('apply_filters') ) { + return false; + } + + if ( false === has_filter('blocks_engine_figma_transformer_zstd_decoder') ) { + return false; + } + + return is_callable(apply_filters('blocks_engine_figma_transformer_zstd_decoder', null, $this)); + } } diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 327d21a..b49fe3f 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -229,7 +229,9 @@ $assert(is_bool($zstdStatus['extension_loaded'] ?? null), 'zstd-status-extension-loaded-bool'); $assert(is_array($zstdStatus['functions'] ?? null), 'zstd-status-functions-array'); $assert(array_key_exists('zstd_uncompress', $zstdStatus['functions'] ?? array()), 'zstd-status-uncompress-function'); -$assert(($zstdStatus['available'] ?? null) === (($zstdStatus['extension_loaded'] ?? null) && ($zstdStatus['functions']['zstd_uncompress'] ?? null)), 'zstd-status-available-matches-runtime'); +$assert(array_key_exists('adapter_registered', $zstdStatus), 'zstd-status-adapter-registered-key'); +$assert(array_key_exists('wordpress_filter_registered', $zstdStatus), 'zstd-status-wordpress-filter-registered-key'); +$assert(($zstdStatus['available'] ?? null) === (null !== ($zstdStatus['provider'] ?? null)), 'zstd-status-available-matches-provider'); $assert(($zstdStatus['available'] ?? null) === ($zstdDiagnostic['context']['available'] ?? null), 'fig-kiwi-zstd-diagnostic-availability-context'); if ( true === ($zstdStatus['available'] ?? false) && function_exists('zstd_compress') ) { $zstdCompressed = zstd_compress('contract zstd round trip'); @@ -241,6 +243,28 @@ $assert(null === ($zstdUnavailable['data'] ?? null), 'zstd-unavailable-returns-null'); $assert(in_array((string) ($zstdUnavailable['diagnostics'][0]['code'] ?? ''), array('figma_transformer_zstd_extension_missing', 'figma_transformer_zstd_function_missing'), true), 'zstd-unavailable-diagnostic-code'); } + +$adapterCapability = new ZstdCapability(static function (string $payload): string|false { + if ( "\x28\xb5\x2f\xfd" !== substr($payload, 0, 4) ) { + return false; + } + + return json_encode(array('NODE_CHANGES' => array()), JSON_THROW_ON_ERROR); +}); +$adapterStatus = $adapterCapability->status(); +$adapterResult = $adapterCapability->uncompress("\x28\xb5\x2f\xfd" . 'adapter-frame', 'ContractTest', 2); +$adapterCanvasResult = ( new FigKiwiParser($adapterCapability) )->parse( + 'fig-kiwi' + . pack('V', 106) + . blocks_engine_figma_transformer_kiwi_chunk("\x28\xb5\x2f\xfd" . 'adapter-frame') +); +$failingAdapterResult = ( new ZstdCapability(static fn (): false => false) )->uncompress("\x28\xb5\x2f\xfd" . 'adapter-frame', 'ContractTest', 3); + +$assert(true === ($adapterStatus['available'] ?? null), 'zstd-adapter-status-available'); +$assert('adapter' === ($adapterStatus['provider'] ?? null) || 'ext-zstd' === ($adapterStatus['provider'] ?? null), 'zstd-adapter-status-provider'); +$assert('{"NODE_CHANGES":[]}' === ($adapterResult['data'] ?? null), 'zstd-adapter-decodes-payload'); +$assert('json' === ($adapterCanvasResult['canvas']['chunks'][0]['payload']['classification'] ?? null), 'fig-kiwi-zstd-adapter-classifies-json'); +$assert('figma_transformer_zstd_adapter_failed' === ($failingAdapterResult['diagnostics'][0]['code'] ?? null), 'zstd-adapter-failure-diagnostic'); $assert(! empty($fileResult['files']), 'file-transform-renders-decoded-scenegraph'); $assert(4 === ($fileResult['metrics']['node_count'] ?? null), 'file-transform-node-count'); $assert(isset($fileResult['source_reports']['figma']['html']), 'file-transform-html-source-report'); From 34af69ba8969ad93824a669cf301317e034611ad Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 18:15:07 -0400 Subject: [PATCH 20/31] Improve decoded fig import handoff --- figma-transformer/src/FigmaTransformer.php | 198 +++++++++++++++++++-- figma-transformer/tests/contract/run.php | 77 +++++++- 2 files changed, 259 insertions(+), 16 deletions(-) diff --git a/figma-transformer/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php index 392aff3..48941f0 100644 --- a/figma-transformer/src/FigmaTransformer.php +++ b/figma-transformer/src/FigmaTransformer.php @@ -54,8 +54,9 @@ public function transformFile(string $path, array $options = array()): FigmaTran 'reason' => 'parity_runner_not_invoked', )); - $scenegraph = $this->decodedScenegraphPayload($archive); - if ( null !== $scenegraph ) { + $scenegraphCandidate = $this->decodedScenegraphCandidate($archive); + if ( null !== $scenegraphCandidate ) { + $scenegraph = $this->withArchiveAssets($scenegraphCandidate['payload'], $archive['assets']); $scenegraphResult = $this->transformScenegraph($scenegraph, $options)->toArray(); $scenegraphStatus = (string) ($scenegraphResult['status'] ?? 'success_with_warnings'); if ( 'success' === $scenegraphStatus && ! empty($diagnostics) ) { @@ -70,17 +71,35 @@ public function transformFile(string $path, array $options = array()): FigmaTran $scenegraphResult['assets'] ?? array(), array( 'figma' => array_merge( - $sourceReports['figma'], + array_merge($sourceReports['figma'], array('decoded_scenegraph' => $scenegraphCandidate['report'])), is_array($scenegraphSourceReports) ? $scenegraphSourceReports : array() ), ), $scenegraphResult['parity'] ?? $parity, - array_merge($metrics, $scenegraphResult['metrics'] ?? array()) + array_merge( + $metrics, + array( + 'decoded_payload_candidate_count' => $scenegraphCandidate['candidate_count'], + 'selected_decoded_payload_index' => $scenegraphCandidate['report']['chunk_index'], + ), + $scenegraphResult['metrics'] ?? array() + ) ); } + $diagnostics[] = array( + 'severity' => 'warning', + 'code' => 'figma_transformer_decoded_scenegraph_missing', + 'message' => 'No decoded NODE_CHANGES, document, or nodes payload was available in canvas.fig.', + 'source' => 'FigmaTransformer', + 'context' => array( + 'decoded_payload_candidate_count' => 0, + 'canvas_chunk_count' => count($archive['archive']['canvas']['chunks'] ?? array()), + ), + ); + return FigmaTransformResult::create( - 'success_with_warnings', + $this->fallbackStatus($archive), $diagnostics, array(), $archive['assets'], @@ -94,13 +113,14 @@ public function transformFile(string $path, array $options = array()): FigmaTran * @param array $archive * @return array|null */ - private function decodedScenegraphPayload(array $archive): ?array + private function decodedScenegraphCandidate(array $archive): ?array { $chunks = $archive['archive']['canvas']['chunks'] ?? array(); if ( ! is_array($chunks) ) { return null; } + $candidates = array(); foreach ( $chunks as $chunk ) { if ( ! is_array($chunk) ) { continue; @@ -111,8 +131,17 @@ private function decodedScenegraphPayload(array $archive): ?array continue; } - if ( $this->isScenegraphPayload($payload['json']) ) { - return $payload['json']; + $json = $payload['json']; + if ( $this->isScenegraphPayload($json) ) { + $shape = $this->scenegraphShape($json); + $candidates[] = array( + 'payload' => $json, + 'score' => $this->scenegraphCandidateScore($json, $shape), + 'report' => array( + 'chunk_index' => (int) ($chunk['index'] ?? count($candidates)), + 'shape' => $shape, + ), + ); } } @@ -123,11 +152,41 @@ private function decodedScenegraphPayload(array $archive): ?array $payload = $chunk['payload'] ?? array(); if ( is_array($payload) && 'kiwi_message' === ($payload['classification'] ?? null) && is_array($payload['kiwi_message'] ?? null) ) { - return $payload['kiwi_message']; + $kiwiMessage = $payload['kiwi_message']; + if ( $this->isScenegraphPayload($kiwiMessage) ) { + $shape = $this->scenegraphShape($kiwiMessage); + $candidates[] = array( + 'payload' => $kiwiMessage, + 'score' => $this->scenegraphCandidateScore($kiwiMessage, $shape), + 'report' => array( + 'chunk_index' => (int) ($chunk['index'] ?? count($candidates)), + 'shape' => $shape, + 'classification' => 'kiwi_message', + ), + ); + } } } - return null; + if ( empty($candidates) ) { + return null; + } + + usort( + $candidates, + static function (array $a, array $b): int { + $scoreCompare = $b['score'] <=> $a['score']; + if ( 0 !== $scoreCompare ) { + return $scoreCompare; + } + + return ((int) $a['report']['chunk_index']) <=> ((int) $b['report']['chunk_index']); + } + ); + + $selected = $candidates[0]; + $selected['candidate_count'] = count($candidates); + return $selected; } /** @@ -144,6 +203,125 @@ private function isScenegraphPayload(array $payload): bool return false; } + /** + * @param array $payload + */ + private function scenegraphShape(array $payload): string + { + foreach ( array('NODE_CHANGES', 'node_changes', 'nodeChanges', 'document', 'nodes') as $key ) { + if ( array_key_exists($key, $payload) ) { + return $key; + } + } + + return 'unknown'; + } + + /** + * @param array $payload + */ + private function scenegraphCandidateScore(array $payload, string $shape): int + { + $shapeScore = match ( $shape ) { + 'NODE_CHANGES', 'node_changes', 'nodeChanges' => 300, + 'document' => 200, + 'nodes' => 100, + default => 0, + }; + + return $shapeScore + min(99, $this->scenegraphCandidateNodeCount($payload, $shape)); + } + + /** + * @param array $payload + */ + private function scenegraphCandidateNodeCount(array $payload, string $shape): int + { + $value = $payload[$shape] ?? null; + if ( ! is_array($value) ) { + return 0; + } + + if ( 'document' === $shape ) { + return $this->nestedNodeCount($value); + } + + $count = 0; + foreach ( $value as $item ) { + if ( is_array($item) ) { + $node = is_array($item['node'] ?? null) ? $item['node'] : $item; + $count += $this->nestedNodeCount($node); + } + } + + return $count; + } + + /** + * @param array $node + */ + private function nestedNodeCount(array $node): int + { + $count = 1; + foreach ( $node['children'] ?? array() as $child ) { + if ( is_array($child) ) { + $count += $this->nestedNodeCount($child); + } + } + + return $count; + } + + /** + * @param array $scenegraph + * @param array> $archiveAssets + * @return array + */ + private function withArchiveAssets(array $scenegraph, array $archiveAssets): array + { + if ( empty($archiveAssets) ) { + return $scenegraph; + } + + $assets = is_array($scenegraph['assets'] ?? null) ? $scenegraph['assets'] : array(); + foreach ( $archiveAssets as $asset ) { + if ( ! is_array($asset) ) { + continue; + } + + $id = (string) ($asset['id'] ?? $asset['hash'] ?? $asset['path'] ?? count($assets)); + $assets[$id] = $asset; + } + + $scenegraph['assets'] = $assets; + return $scenegraph; + } + + /** + * @param array $archive + */ + private function fallbackStatus(array $archive): string + { + foreach ( $archive['diagnostics'] ?? array() as $diagnostic ) { + $code = (string) ($diagnostic['code'] ?? ''); + if ( in_array($code, array( + 'figma_transformer_unreadable_file', + 'figma_transformer_invalid_zip', + 'figma_transformer_nested_fig_unreadable', + 'figma_transformer_tempfile_failed', + 'figma_transformer_missing_canvas', + 'figma_transformer_canvas_too_short', + 'figma_transformer_kiwi_truncated_chunk_table', + 'figma_transformer_kiwi_truncated_chunk', + 'figma_transformer_kiwi_zlib_inflate_failed', + ), true) ) { + return 'decode_failed'; + } + } + + return 'unsupported_decoder_pending'; + } + /** * Transform a decoded Figma scenegraph into static HTML artifact files. * diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index b49fe3f..c34ba93 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -217,13 +217,14 @@ $assert('fig-kiwi' === ($canvas['prelude'] ?? null), 'fig-kiwi-prelude'); $assert(106 === ($canvas['version'] ?? null), 'fig-kiwi-version'); $assert('inner.fig' === ($fileResult['source_reports']['figma']['input']['nested_fig'] ?? null), 'wrapper-nested-fig'); -$assert(4 === count($chunks), 'fig-kiwi-chunk-count'); +$assert(5 === count($chunks), 'fig-kiwi-chunk-count'); $assert('zlib' === ($chunks[0]['compression'] ?? null), 'fig-kiwi-first-chunk-zlib'); $assert('json' === ($chunks[0]['payload']['classification'] ?? null), 'fig-kiwi-first-chunk-json'); -$assert(isset($chunks[0]['payload']['json']['NODE_CHANGES']), 'fig-kiwi-first-chunk-node-changes'); +$assert(isset($chunks[0]['payload']['json']['nodes']), 'fig-kiwi-first-chunk-nodes-candidate'); $assert('json_invalid' === ($chunks[1]['payload']['classification'] ?? null), 'fig-kiwi-second-chunk-json-invalid'); -$assert('binary' === ($chunks[2]['payload']['classification'] ?? null), 'fig-kiwi-third-chunk-binary'); -$assert('zstd' === ($chunks[3]['compression'] ?? null), 'fig-kiwi-fourth-chunk-zstd'); +$assert(isset($chunks[2]['payload']['json']['NODE_CHANGES']), 'fig-kiwi-third-chunk-node-changes'); +$assert('binary' === ($chunks[3]['payload']['classification'] ?? null), 'fig-kiwi-fourth-chunk-binary'); +$assert('zstd' === ($chunks[4]['compression'] ?? null), 'fig-kiwi-fifth-chunk-zstd'); $assert(in_array($zstdCapabilityCode, $diagnosticCodes, true), 'fig-kiwi-zstd-capability-diagnostic'); $assert(is_bool($zstdStatus['available'] ?? null), 'zstd-status-available-bool'); $assert(is_bool($zstdStatus['extension_loaded'] ?? null), 'zstd-status-extension-loaded-bool'); @@ -237,7 +238,7 @@ $zstdCompressed = zstd_compress('contract zstd round trip'); $zstdRoundTrip = false !== $zstdCompressed ? $zstdCapability->uncompress($zstdCompressed, 'ContractTest', 1) : array('data' => null, 'diagnostics' => array()); $assert('contract zstd round trip' === ($zstdRoundTrip['data'] ?? null), 'zstd-real-round-trip'); - $assert(isset($chunks[3]['inflated_bytes']), 'fig-kiwi-zstd-real-fixture-inflated'); + $assert(isset($chunks[4]['inflated_bytes']), 'fig-kiwi-zstd-real-fixture-inflated'); } else { $zstdUnavailable = $zstdCapability->uncompress("\x28\xb5\x2f\xfd" . 'synthetic-zstd-frame', 'ContractTest', 1); $assert(null === ($zstdUnavailable['data'] ?? null), 'zstd-unavailable-returns-null'); @@ -267,10 +268,24 @@ $assert('figma_transformer_zstd_adapter_failed' === ($failingAdapterResult['diagnostics'][0]['code'] ?? null), 'zstd-adapter-failure-diagnostic'); $assert(! empty($fileResult['files']), 'file-transform-renders-decoded-scenegraph'); $assert(4 === ($fileResult['metrics']['node_count'] ?? null), 'file-transform-node-count'); +$assert(2 === ($fileResult['metrics']['decoded_payload_candidate_count'] ?? null), 'file-transform-decoded-candidate-count'); +$assert(2 === ($fileResult['metrics']['selected_decoded_payload_index'] ?? null), 'file-transform-selected-node-changes-index'); +$assert('NODE_CHANGES' === ($fileResult['source_reports']['figma']['decoded_scenegraph']['shape'] ?? null), 'file-transform-selected-node-changes-shape'); $assert(isset($fileResult['source_reports']['figma']['html']), 'file-transform-html-source-report'); $assert('synthetic' === ($fileResult['source_reports']['figma']['assets'][0]['id'] ?? null), 'archive-asset-id'); $assert('images/synthetic' === ($fileResult['source_reports']['figma']['assets'][0]['path'] ?? null), 'archive-asset-path'); $assert('asset' === ($fileResult['source_reports']['figma']['assets'][0]['content'] ?? null), 'archive-asset-content'); +$assert('assets/synthetic.bin' === ($fileResult['assets'][0]['path'] ?? null), 'archive-asset-emitted-from-decoded-scenegraph'); + +$pendingFixture = blocks_engine_figma_transformer_create_pending_decoder_fig_wrapper_fixture(); +$pendingResult = blocks_engine_figma_transformer_transform_file($pendingFixture); +@unlink($pendingFixture); +$pendingDiagnosticCodes = array_map( + static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), + $pendingResult['diagnostics'] ?? array() +); +$assert('unsupported_decoder_pending' === ($pendingResult['status'] ?? null), 'pending-decoder-status'); +$assert(in_array('figma_transformer_decoded_scenegraph_missing', $pendingDiagnosticCodes, true), 'pending-decoder-diagnostic'); $kiwiSchemaBytes = blocks_engine_figma_transformer_kiwi_schema_fixture(); $kiwiMessageBytes = blocks_engine_figma_transformer_kiwi_message_fixture(); @@ -685,8 +700,9 @@ function blocks_engine_figma_transformer_create_fig_wrapper_fixture(): string $canvas = 'fig-kiwi' . pack('V', 106) - . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate(json_encode(blocks_engine_figma_transformer_node_changes_fixture(), JSON_THROW_ON_ERROR))) + . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate(json_encode(blocks_engine_figma_transformer_nodes_candidate_fixture(), JSON_THROW_ON_ERROR))) . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate('{"NODE_CHANGES":')) + . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate(json_encode(blocks_engine_figma_transformer_node_changes_fixture(), JSON_THROW_ON_ERROR))) . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate('synthetic kiwi dictionary')) . blocks_engine_figma_transformer_kiwi_chunk(blocks_engine_figma_transformer_zstd_fixture_payload()); @@ -711,6 +727,27 @@ function blocks_engine_figma_transformer_create_fig_wrapper_fixture(): string return $outer; } +function blocks_engine_figma_transformer_create_pending_decoder_fig_wrapper_fixture(): string +{ + $path = tempnam(sys_get_temp_dir(), 'blocks-engine-pending-fig-'); + if ( false === $path ) { + throw new RuntimeException('Could not create temporary pending fig fixture path.'); + } + + $canvas = 'fig-kiwi' + . pack('V', 106) + . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate('synthetic undecoded canvas payload')); + + $zip = new ZipArchive(); + if ( true !== $zip->open($path, ZipArchive::OVERWRITE) ) { + throw new RuntimeException('Could not open pending fig ZIP.'); + } + $zip->addFromString('canvas.fig', $canvas); + $zip->close(); + + return $path; +} + function blocks_engine_figma_transformer_kiwi_chunk(string $payload): string { return pack('V', strlen($payload)) . $payload; @@ -804,6 +841,9 @@ function blocks_engine_figma_transformer_node_changes_fixture(): array 'id' => '4:4', 'type' => 'RECTANGLE', 'name' => 'Decoded Photo', + 'fills' => array( + array('type' => 'IMAGE', 'imageHash' => 'synthetic'), + ), ), ), ), @@ -812,6 +852,31 @@ function blocks_engine_figma_transformer_node_changes_fixture(): array ); } +/** + * @return array + */ +function blocks_engine_figma_transformer_nodes_candidate_fixture(): array +{ + return array( + 'name' => 'Lower Priority Nodes Candidate', + 'nodes' => array( + array( + 'id' => 'candidate:1', + 'type' => 'FRAME', + 'name' => 'Lower Priority Frame', + 'children' => array( + array( + 'id' => 'candidate:2', + 'type' => 'TEXT', + 'name' => 'Lower Priority Text', + 'characters' => 'This earlier payload must not be selected.', + ), + ), + ), + ), + ); +} + function blocks_engine_figma_transformer_zstd_fixture_payload(): string { if ( function_exists('zstd_compress') ) { From 4f56d2dc29bc50d5d03285e5eb0f7e94029e7f1d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 18:15:07 -0400 Subject: [PATCH 21/31] Add synthetic fig kiwi decoder harness --- .../src/FigFile/FigKiwiParser.php | 2 +- .../SyntheticFigKiwiFixtureBuilder.php | 158 ++++++++++++++++++ figma-transformer/tests/contract/run.php | 52 ++++-- 3 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 figma-transformer/tests/contract/SyntheticFigKiwiFixtureBuilder.php diff --git a/figma-transformer/src/FigFile/FigKiwiParser.php b/figma-transformer/src/FigFile/FigKiwiParser.php index aed4f63..3bb3837 100644 --- a/figma-transformer/src/FigFile/FigKiwiParser.php +++ b/figma-transformer/src/FigFile/FigKiwiParser.php @@ -182,7 +182,7 @@ private function classifyPayload(string $payload, ?array &$kiwiSchema, array &$d } $wire = $this->describeWirePayload($payload); - if ( $wire['record_count'] > 0 ) { + if ( $wire['record_count'] > 0 || true === $wire['truncated'] || null !== $wire['reason'] ) { $metadata['wire'] = $wire; } diff --git a/figma-transformer/tests/contract/SyntheticFigKiwiFixtureBuilder.php b/figma-transformer/tests/contract/SyntheticFigKiwiFixtureBuilder.php new file mode 100644 index 0000000..4744bd5 --- /dev/null +++ b/figma-transformer/tests/contract/SyntheticFigKiwiFixtureBuilder.php @@ -0,0 +1,158 @@ + $chunks + */ + public static function canvas(array $chunks, int $version = 106): string + { + return 'fig-kiwi' . pack('V', $version) . implode('', $chunks); + } + + public static function chunk(string $payload): string + { + return pack('V', strlen($payload)) . $payload; + } + + public static function zlibChunk(string $payload): string + { + return self::chunk(gzdeflate($payload)); + } + + public static function zstdMarkerChunk(string $payload = 'synthetic-zstd-frame'): string + { + if ( function_exists('zstd_compress') ) { + $compressed = zstd_compress($payload); + if ( false !== $compressed ) { + return self::chunk($compressed); + } + } + + return self::chunk("\x28\xb5\x2f\xfd" . $payload); + } + + /** + * @param array $payload + */ + public static function jsonZlibChunk(array $payload): string + { + return self::zlibChunk(json_encode($payload, JSON_THROW_ON_ERROR)); + } + + /** + * @param array $images + */ + public static function figArchive(string $canvas, array $images = array(), array $meta = array('name' => 'Synthetic Fixture')): string + { + $path = tempnam(sys_get_temp_dir(), 'blocks-engine-fig-'); + if ( false === $path ) { + throw new RuntimeException('Could not create temporary fig archive path.'); + } + + $zip = new ZipArchive(); + if ( true !== $zip->open($path, ZipArchive::OVERWRITE) ) { + throw new RuntimeException('Could not open fig archive ZIP.'); + } + + $zip->addFromString('canvas.fig', $canvas); + $zip->addFromString('meta.json', json_encode($meta, JSON_THROW_ON_ERROR)); + foreach ( $images as $pathInArchive => $content ) { + $zip->addFromString($pathInArchive, $content); + } + $zip->close(); + + return $path; + } + + /** + * @param array $images + */ + public static function wrapperArchive(string $canvas, array $images = array('images/synthetic' => 'asset')): string + { + $inner = self::figArchive($canvas, $images); + $outer = tempnam(sys_get_temp_dir(), 'blocks-engine-wrapper-fig-'); + if ( false === $outer ) { + @unlink($inner); + throw new RuntimeException('Could not create temporary fig wrapper path.'); + } + + $zip = new ZipArchive(); + if ( true !== $zip->open($outer, ZipArchive::OVERWRITE) ) { + @unlink($inner); + throw new RuntimeException('Could not open wrapper fig ZIP.'); + } + + $zip->addFromString('inner.fig', (string) file_get_contents($inner)); + $zip->close(); + @unlink($inner); + + return $outer; + } + + public static function wireVarint(int $value): string + { + $bytes = ''; + do { + $byte = $value & 0x7f; + $value = intdiv($value, 128); + if ( $value > 0 ) { + $byte |= 0x80; + } + $bytes .= chr($byte); + } while ( $value > 0 ); + + return $bytes; + } + + public static function sampleWirePayload(): string + { + return self::wireVarint(8) + . self::wireVarint(150) + . self::wireVarint(18) + . self::wireVarint(5) + . 'hello' + . self::wireVarint(29) + . "\x01\x02\x03\x04"; + } + + /** + * @return array + */ + public static function nodeChangesPayload(string $prefix = 'Decoded'): array + { + return array( + 'name' => $prefix . ' Node Changes Fixture', + 'NODE_CHANGES' => array( + '4:1' => array( + 'node' => array( + 'id' => '4:1', + 'type' => 'FRAME', + 'name' => $prefix . ' Landing', + 'children' => array( + array( + 'id' => '4:2', + 'type' => 'TEXT', + 'name' => 'Heading', + 'characters' => $prefix . ' First', + ), + array( + 'id' => '4:3', + 'type' => 'TEXT', + 'name' => 'Body', + 'characters' => $prefix . ' Second', + ), + array( + 'id' => '4:4', + 'type' => 'RECTANGLE', + 'name' => $prefix . ' Photo', + ), + ), + ), + ), + ), + ); + } +} diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index c34ba93..580616e 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/../../figma-transformer.php'; +require_once __DIR__ . '/SyntheticFigKiwiFixtureBuilder.php'; use Automattic\BlocksEngine\FigmaTransformer\Compression\ZstdCapability; use Automattic\BlocksEngine\FigmaTransformer\FigFile\FigKiwiDecoder; @@ -313,17 +314,9 @@ $assert('NODE_CHANGES' === ($injectedChunks[1]['payload']['kiwi_message']['type'] ?? null), 'kiwi-parser-message-type'); $assert(in_array('figma_transformer_zstd_injected_decoder_used', $injectedDiagnosticCodes, true), 'zstd-injected-decoder-diagnostic'); -$wirePayload = blocks_engine_figma_transformer_wire_varint(8) - . blocks_engine_figma_transformer_wire_varint(150) - . blocks_engine_figma_transformer_wire_varint(18) - . blocks_engine_figma_transformer_wire_varint(5) - . 'hello' - . blocks_engine_figma_transformer_wire_varint(29) - . "\x01\x02\x03\x04"; +$wirePayload = SyntheticFigKiwiFixtureBuilder::sampleWirePayload(); $wireCanvasResult = ( new FigKiwiParser() )->parse( - 'fig-kiwi' - . pack('V', 106) - . blocks_engine_figma_transformer_kiwi_chunk(gzdeflate($wirePayload)) + SyntheticFigKiwiFixtureBuilder::canvas(array(SyntheticFigKiwiFixtureBuilder::zlibChunk($wirePayload))) ); $wire = $wireCanvasResult['canvas']['chunks'][0]['payload']['wire'] ?? array(); $wireRecords = $wire['records'] ?? array(); @@ -338,6 +331,45 @@ $assert('hello' === ($wireRecords[1]['text_preview'] ?? null), 'fig-kiwi-wire-length-text-preview'); $assert('01020304' === ($wireRecords[2]['preview_hex'] ?? null), 'fig-kiwi-wire-fixed32-preview'); +$unknownBinaryResult = ( new FigKiwiParser() )->parse( + SyntheticFigKiwiFixtureBuilder::canvas(array(SyntheticFigKiwiFixtureBuilder::zlibChunk("\x00\x01\x02unknown"))) +); +$unknownBinaryPayload = $unknownBinaryResult['canvas']['chunks'][0]['payload'] ?? array(); +$assert('binary' === ($unknownBinaryPayload['classification'] ?? null), 'fig-kiwi-unknown-binary-classification'); +$assert(10 === ($unknownBinaryPayload['bytes'] ?? null), 'fig-kiwi-unknown-binary-byte-count'); +$assert('000102756e6b6e6f776e' === ($unknownBinaryPayload['preview_hex'] ?? null), 'fig-kiwi-unknown-binary-preview'); +$assert('zero_field_key' === ($unknownBinaryPayload['wire']['reason'] ?? null), 'fig-kiwi-unknown-binary-wire-stop-reason'); + +$truncatedVarintResult = ( new FigKiwiParser() )->parse( + SyntheticFigKiwiFixtureBuilder::canvas(array(SyntheticFigKiwiFixtureBuilder::zlibChunk(SyntheticFigKiwiFixtureBuilder::wireVarint(8) . "\x80"))) +); +$truncatedWire = $truncatedVarintResult['canvas']['chunks'][0]['payload']['wire'] ?? array(); +$assert(false === ($truncatedWire['complete'] ?? null), 'fig-kiwi-truncated-varint-incomplete'); +$assert(true === ($truncatedWire['truncated'] ?? null), 'fig-kiwi-truncated-varint-flag'); +$assert('truncated_varint_value' === ($truncatedWire['reason'] ?? null), 'fig-kiwi-truncated-varint-reason'); + +$unsupportedWireResult = ( new FigKiwiParser() )->parse( + SyntheticFigKiwiFixtureBuilder::canvas(array(SyntheticFigKiwiFixtureBuilder::zlibChunk(SyntheticFigKiwiFixtureBuilder::wireVarint(11) . 'tail'))) +); +$unsupportedWire = $unsupportedWireResult['canvas']['chunks'][0]['payload']['wire'] ?? array(); +$assert(false === ($unsupportedWire['complete'] ?? null), 'fig-kiwi-unsupported-wire-incomplete'); +$assert(false === ($unsupportedWire['truncated'] ?? null), 'fig-kiwi-unsupported-wire-not-truncated'); +$assert('unsupported_wire_type' === ($unsupportedWire['reason'] ?? null), 'fig-kiwi-unsupported-wire-reason'); + +$multiCandidateFixture = SyntheticFigKiwiFixtureBuilder::figArchive( + SyntheticFigKiwiFixtureBuilder::canvas(array( + SyntheticFigKiwiFixtureBuilder::jsonZlibChunk(array('metadata' => array('ignored' => true))), + SyntheticFigKiwiFixtureBuilder::jsonZlibChunk(SyntheticFigKiwiFixtureBuilder::nodeChangesPayload('First Candidate')), + SyntheticFigKiwiFixtureBuilder::jsonZlibChunk(SyntheticFigKiwiFixtureBuilder::nodeChangesPayload('Second Candidate')), + )) +); +$multiCandidateResult = blocks_engine_figma_transformer_transform_file($multiCandidateFixture); +@unlink($multiCandidateFixture); +$multiCandidateHtml = $fileContent($multiCandidateResult, 'index.html'); +$assert('success' === ($multiCandidateResult['status'] ?? null), 'fig-kiwi-multiple-candidates-transform-success'); +$assert(str_contains($multiCandidateHtml, 'First Candidate First'), 'fig-kiwi-multiple-candidates-renders-first-scenegraph'); +$assert(! str_contains($multiCandidateHtml, 'Second Candidate First'), 'fig-kiwi-multiple-candidates-stops-after-first-scenegraph'); + $nodeChangesResult = blocks_engine_figma_transformer_transform_scenegraph(array( 'name' => 'Node Changes Fixture', 'NODE_CHANGES' => array( From 824759c04249c7be826b23b90c4893d5b6ae2d9c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 18:37:40 -0400 Subject: [PATCH 22/31] Tighten fig kiwi schema detection --- figma-transformer/src/FigFile/FigKiwiParser.php | 2 +- figma-transformer/tests/contract/run.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/figma-transformer/src/FigFile/FigKiwiParser.php b/figma-transformer/src/FigFile/FigKiwiParser.php index 3bb3837..ef060ac 100644 --- a/figma-transformer/src/FigFile/FigKiwiParser.php +++ b/figma-transformer/src/FigFile/FigKiwiParser.php @@ -161,7 +161,7 @@ private function classifyPayload(string $payload, ?array &$kiwiSchema, array &$d if ( 'json_invalid' !== $classification ) { if ( null === $kiwiSchema ) { $schemaResult = $this->kiwiDecoder->decodeSchema($payload); - if ( null !== $schemaResult['schema'] ) { + if ( null !== $schemaResult['schema'] && ! empty($schemaResult['schema']['definitions']) ) { $diagnostics = array_merge($diagnostics, $schemaResult['diagnostics']); $kiwiSchema = $schemaResult['schema']; $metadata['classification'] = 'kiwi_schema'; diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 580616e..746a624 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -312,7 +312,7 @@ $assert('kiwi_schema' === ($injectedChunks[0]['payload']['classification'] ?? null), 'kiwi-parser-classifies-schema'); $assert('kiwi_message' === ($injectedChunks[1]['payload']['classification'] ?? null), 'kiwi-parser-classifies-message'); $assert('NODE_CHANGES' === ($injectedChunks[1]['payload']['kiwi_message']['type'] ?? null), 'kiwi-parser-message-type'); -$assert(in_array('figma_transformer_zstd_injected_decoder_used', $injectedDiagnosticCodes, true), 'zstd-injected-decoder-diagnostic'); +$assert(in_array('figma_transformer_zstd_adapter_available', $injectedDiagnosticCodes, true), 'zstd-injected-decoder-diagnostic'); $wirePayload = SyntheticFigKiwiFixtureBuilder::sampleWirePayload(); $wireCanvasResult = ( new FigKiwiParser() )->parse( From 2dd57117a24abb0f8a250ab2393408c61dcb5d65 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 18:44:18 -0400 Subject: [PATCH 23/31] Require zstd for fig imports --- figma-transformer/README.md | 9 +- figma-transformer/bin/figma-transformer | 59 ++++++++- figma-transformer/composer.json | 3 +- .../src/Compression/ZstdCommandDecoder.php | 112 ++++++++++++++++++ figma-transformer/tests/contract/run.php | 4 + 5 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 figma-transformer/src/Compression/ZstdCommandDecoder.php diff --git a/figma-transformer/README.md b/figma-transformer/README.md index 11bd905..2bab93b 100644 --- a/figma-transformer/README.md +++ b/figma-transformer/README.md @@ -76,13 +76,14 @@ Next decoder milestones: ### Zstandard Support -Zstandard decoding is optional and capability-driven so WordPress installs without zstd still produce deterministic diagnostics instead of hard failures. +Zstandard decoding is required for direct imports of modern `.fig` files because Figma stores the main Kiwi message chunk as zstd-compressed data. The Composer package requires `ext-zstd` for normal installs. Supported decoder paths: - `ext-zstd` with `zstd_uncompress()` available. -- An explicit `Compression\ZstdCapability` adapter callable for hosts that provide a PHP-compatible zstd decoder through another package or service boundary. -- A WordPress filter adapter registered on `blocks_engine_figma_transformer_zstd_decoder`. +- An explicit `Compression\ZstdCapability` adapter callable for operator/local verification when the host provides a trusted decoder through another boundary. +- A WordPress filter adapter registered on `blocks_engine_figma_transformer_zstd_decoder` for environments that intentionally provide decoding outside the extension. +- The CLI-only `--zstd-command=/path/to/zstd` option for local operator checks. This is not used implicitly by the library or plugin runtime. Adapter callables receive the compressed payload and context array, and return decoded bytes or an array with `data` and optional `diagnostics`: @@ -101,7 +102,7 @@ add_filter( ); ``` -No pure-PHP Zstandard decoder is bundled today. The practical blocker is a small, maintained, WordPress-compatible decoder that can handle production Figma zstd frames without a native extension or shell binary. Until that exists, unsupported runtimes report `figma_transformer_zstd_extension_missing` or adapter failure diagnostics and continue parsing the rest of the archive metadata. +No pure-PHP Zstandard decoder is bundled today. Unsupported runtimes report `figma_transformer_zstd_extension_missing` or adapter failure diagnostics and continue parsing the rest of the archive metadata. ## Output Contract diff --git a/figma-transformer/bin/figma-transformer b/figma-transformer/bin/figma-transformer index 7ec91e2..acbf7ed 100755 --- a/figma-transformer/bin/figma-transformer +++ b/figma-transformer/bin/figma-transformer @@ -11,12 +11,13 @@ if ( is_readable($autoload) ) { } $path = $argv[1] ?? ''; -if ( '' === $path ) { - fwrite(STDERR, "Usage: figma-transformer [--frame-id=] [--parity-status=] [--parity-source-screenshot-url=] [--parity-source-screenshot-path=] [--parity-generated-screenshot-artifact=] [--parity-diff-image-artifact=] [--parity-pixel-mismatch-count=] [--parity-pixel-mismatch-ratio=] [--parity-threshold=] [--parity-viewport=x]\n"); +if ( '' === $path || '--help' === $path || '-h' === $path ) { + fwrite(STDERR, "Usage: figma-transformer [--zstd-command=] [--frame-id=] [--parity-status=] [--parity-source-screenshot-url=] [--parity-source-screenshot-path=] [--parity-generated-screenshot-artifact=] [--parity-diff-image-artifact=] [--parity-pixel-mismatch-count=] [--parity-pixel-mismatch-ratio=] [--parity-threshold=] [--parity-viewport=x]\n"); exit(1); } $options = array(); +$zstdCommand = getenv('FIGMA_TRANSFORMER_ZSTD_COMMAND') ?: null; foreach ( array_slice($argv, 2) as $argument ) { if ( ! str_starts_with($argument, '--') ) { continue; @@ -26,6 +27,11 @@ foreach ( array_slice($argv, 2) as $argument ) { $name = $parts[0]; $value = $parts[1] ?? '1'; + if ( 'zstd-command' === $name ) { + $zstdCommand = $value; + continue; + } + if ( 'frame-id' === $name ) { $options['frame_id'] = $value; $options['parity']['frame_id'] = $value; @@ -64,7 +70,7 @@ foreach ( array_slice($argv, 2) as $argument ) { } } -$transformer = new Automattic\BlocksEngine\FigmaTransformer\FigmaTransformer(); +$transformer = blocks_engine_figma_transformer_cli_transformer(is_string($zstdCommand) && '' !== $zstdCommand ? $zstdCommand : null); if ( str_ends_with(strtolower($path), '.json') ) { $decoded = json_decode((string) file_get_contents($path), true); $result = $transformer->transformScenegraph(is_array($decoded) ? $decoded : array(), $options)->toArray(); @@ -72,7 +78,7 @@ if ( str_ends_with(strtolower($path), '.json') ) { $result = $transformer->transformFile($path, $options)->toArray(); } -fwrite(STDOUT, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); +fwrite(STDOUT, blocks_engine_figma_transformer_cli_json_encode($result) . "\n"); /** * @return array @@ -88,3 +94,48 @@ function blocks_engine_figma_transformer_parse_viewport(string $value): array return array('label' => $value); } + +function blocks_engine_figma_transformer_cli_transformer(?string $zstdCommand): Automattic\BlocksEngine\FigmaTransformer\FigmaTransformer +{ + if ( null === $zstdCommand ) { + return new Automattic\BlocksEngine\FigmaTransformer\FigmaTransformer(); + } + + $command = array($zstdCommand, '-dc'); + $zstd = new Automattic\BlocksEngine\FigmaTransformer\Compression\ZstdCapability( + new Automattic\BlocksEngine\FigmaTransformer\Compression\ZstdCommandDecoder($command) + ); + + return new Automattic\BlocksEngine\FigmaTransformer\FigmaTransformer( + new Automattic\BlocksEngine\FigmaTransformer\FigFile\FigArchiveReader( + new Automattic\BlocksEngine\FigmaTransformer\FigFile\FigKiwiParser($zstd) + ) + ); +} + +/** + * @param array $result + */ +function blocks_engine_figma_transformer_cli_json_encode(array $result): string +{ + $encoded = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR); + if ( is_string($encoded) ) { + return $encoded; + } + + return json_encode( + array( + 'schema' => 'blocks-engine/figma-transformer/result/v1', + 'status' => 'error', + 'diagnostics' => array( + array( + 'code' => 'figma_transformer_cli_json_encode_failed', + 'message' => 'Transform result could not be encoded as JSON.', + 'source' => 'figma-transformer-cli', + 'context' => array('json_error' => json_last_error_msg()), + ), + ), + ), + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ) ?: '{"status":"error"}'; +} diff --git a/figma-transformer/composer.json b/figma-transformer/composer.json index 39fbf43..a198f16 100644 --- a/figma-transformer/composer.json +++ b/figma-transformer/composer.json @@ -30,10 +30,11 @@ } }, "require": { + "ext-zstd": "*", "php": ">=8.1" }, "suggest": { - "ext-zstd": "Decode zstd-compressed canvas.fig chunks in native Figma .fig archives. Runtimes can alternatively register a decoder adapter callable." + "ext-zip": "Read zipped .fig archives when the extension is not provided by the host PHP build." }, "bin": [ "bin/figma-transformer" diff --git a/figma-transformer/src/Compression/ZstdCommandDecoder.php b/figma-transformer/src/Compression/ZstdCommandDecoder.php new file mode 100644 index 0000000..a94400e --- /dev/null +++ b/figma-transformer/src/Compression/ZstdCommandDecoder.php @@ -0,0 +1,112 @@ + $command Command argv, not a shell string. + */ + public function __construct(private readonly array $command) + { + } + + /** + * @param array $context + * @return array{data: string|null, diagnostics: array>} + */ + public function __invoke(string $payload, array $context): array + { + if ( empty($this->command) || '' === (string) $this->command[0] ) { + return array( + 'data' => null, + 'diagnostics' => array($this->diagnostic('figma_transformer_zstd_command_missing', 'Configured Zstandard command is empty.', $context)), + ); + } + + $descriptors = array( + 0 => array('pipe', 'r'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ); + + $process = @proc_open($this->command, $descriptors, $pipes); + if ( ! is_resource($process) ) { + return array( + 'data' => null, + 'diagnostics' => array($this->diagnostic('figma_transformer_zstd_command_open_failed', 'Configured Zstandard command could not be started.', $context)), + ); + } + + fwrite($pipes[0], $payload); + fclose($pipes[0]); + + $decoded = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + if ( 0 !== $exitCode || ! is_string($decoded) ) { + return array( + 'data' => null, + 'diagnostics' => array( + $this->diagnostic( + 'figma_transformer_zstd_command_failed', + 'Configured Zstandard command failed to decode the payload.', + array_merge( + $context, + array( + 'exit_code' => $exitCode, + 'stderr_preview' => is_string($stderr) ? substr($stderr, 0, 200) : '', + ) + ) + ), + ), + ); + } + + return array( + 'data' => $decoded, + 'diagnostics' => array( + $this->diagnostic('figma_transformer_zstd_command_used', 'Zstandard chunk decoded by a configured command adapter.', $context), + ), + ); + } + + /** + * @param array $context + * @return array + */ + private function diagnostic(string $code, string $message, array $context): array + { + return array( + 'code' => $code, + 'message' => $message, + 'source' => 'ZstdCommandDecoder', + 'context' => array_merge( + $context, + array( + 'command' => $this->redactedCommand(), + ) + ), + ); + } + + /** + * @return array + */ + private function redactedCommand(): array + { + return array_map( + static fn (string $part): string => preg_match('/(token|secret|password|key)=/i', $part) ? '[redacted]' : $part, + $this->command + ); + } +} diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 746a624..58ec247 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -6,6 +6,7 @@ require_once __DIR__ . '/SyntheticFigKiwiFixtureBuilder.php'; use Automattic\BlocksEngine\FigmaTransformer\Compression\ZstdCapability; +use Automattic\BlocksEngine\FigmaTransformer\Compression\ZstdCommandDecoder; use Automattic\BlocksEngine\FigmaTransformer\FigFile\FigKiwiDecoder; use Automattic\BlocksEngine\FigmaTransformer\FigFile\FigKiwiParser; use Automattic\BlocksEngine\FigmaTransformer\Parity\ParityReportBuilder; @@ -261,12 +262,15 @@ . blocks_engine_figma_transformer_kiwi_chunk("\x28\xb5\x2f\xfd" . 'adapter-frame') ); $failingAdapterResult = ( new ZstdCapability(static fn (): false => false) )->uncompress("\x28\xb5\x2f\xfd" . 'adapter-frame', 'ContractTest', 3); +$commandAdapterResult = ( new ZstdCapability(new ZstdCommandDecoder(array(PHP_BINARY, '-r', '$payload = stream_get_contents(STDIN); fwrite(STDOUT, $payload);'))) )->uncompress('command adapter bytes', 'ContractTest', 4); $assert(true === ($adapterStatus['available'] ?? null), 'zstd-adapter-status-available'); $assert('adapter' === ($adapterStatus['provider'] ?? null) || 'ext-zstd' === ($adapterStatus['provider'] ?? null), 'zstd-adapter-status-provider'); $assert('{"NODE_CHANGES":[]}' === ($adapterResult['data'] ?? null), 'zstd-adapter-decodes-payload'); $assert('json' === ($adapterCanvasResult['canvas']['chunks'][0]['payload']['classification'] ?? null), 'fig-kiwi-zstd-adapter-classifies-json'); $assert('figma_transformer_zstd_adapter_failed' === ($failingAdapterResult['diagnostics'][0]['code'] ?? null), 'zstd-adapter-failure-diagnostic'); +$assert('command adapter bytes' === ($commandAdapterResult['data'] ?? null), 'zstd-command-adapter-decodes-payload'); +$assert('figma_transformer_zstd_command_used' === ($commandAdapterResult['diagnostics'][1]['code'] ?? null), 'zstd-command-adapter-diagnostic'); $assert(! empty($fileResult['files']), 'file-transform-renders-decoded-scenegraph'); $assert(4 === ($fileResult['metrics']['node_count'] ?? null), 'file-transform-node-count'); $assert(2 === ($fileResult['metrics']['decoded_payload_candidate_count'] ?? null), 'file-transform-decoded-candidate-count'); From b4000938fa3d874872909a901a927bafc4b9927a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 23 Jun 2026 08:11:03 -0400 Subject: [PATCH 24/31] Improve Figma transform fidelity --- figma-transformer/bin/figma-transformer | 15 +- .../bin/figma-visual-attribution | 67 ++ figma-transformer/composer.json | 5 +- .../src/FigFile/FigArchiveReader.php | 28 +- .../src/Html/StaticHtmlEmitter.php | 869 ++++++++++++++++- .../Parity/VisualAttributionReportBuilder.php | 339 +++++++ .../src/Scenegraph/ScenegraphIndex.php | 52 +- .../src/Scenegraph/ScenegraphNormalizer.php | 872 ++++++++++++++++- figma-transformer/tests/contract/run.php | 877 +++++++++++++++++- .../src/ArtifactCompiler/ArtifactCompiler.php | 17 +- .../src/HtmlToBlocks/HtmlTransformer.php | 35 +- php-transformer/tests/contract/run.php | 14 +- 12 files changed, 3059 insertions(+), 131 deletions(-) create mode 100644 figma-transformer/bin/figma-visual-attribution create mode 100644 figma-transformer/src/Parity/VisualAttributionReportBuilder.php diff --git a/figma-transformer/bin/figma-transformer b/figma-transformer/bin/figma-transformer index acbf7ed..4a63b72 100755 --- a/figma-transformer/bin/figma-transformer +++ b/figma-transformer/bin/figma-transformer @@ -12,7 +12,7 @@ if ( is_readable($autoload) ) { $path = $argv[1] ?? ''; if ( '' === $path || '--help' === $path || '-h' === $path ) { - fwrite(STDERR, "Usage: figma-transformer [--zstd-command=] [--frame-id=] [--parity-status=] [--parity-source-screenshot-url=] [--parity-source-screenshot-path=] [--parity-generated-screenshot-artifact=] [--parity-diff-image-artifact=] [--parity-pixel-mismatch-count=] [--parity-pixel-mismatch-ratio=] [--parity-threshold=] [--parity-viewport=x]\n"); + fwrite(STDERR, "Usage: figma-transformer [--zstd-command=] [--frame-id=] [--font-css=] [--font-css-file=] [--parity-status=] [--parity-source-screenshot-url=] [--parity-source-screenshot-path=] [--parity-generated-screenshot-artifact=] [--parity-diff-image-artifact=] [--parity-pixel-mismatch-count=] [--parity-pixel-mismatch-ratio=] [--parity-threshold=] [--parity-viewport=x]\n"); exit(1); } @@ -38,6 +38,19 @@ foreach ( array_slice($argv, 2) as $argument ) { continue; } + if ( 'font-css' === $name ) { + $options['font_css'] = $value; + continue; + } + + if ( 'font-css-file' === $name ) { + $fontCss = is_readable($value) ? file_get_contents($value) : false; + if ( false !== $fontCss ) { + $options['font_css'] = $fontCss; + } + continue; + } + if ( 'parity-frame-id' === $name ) { $options['parity']['frame_id'] = $value; continue; diff --git a/figma-transformer/bin/figma-visual-attribution b/figma-transformer/bin/figma-visual-attribution new file mode 100644 index 0000000..4b2472f --- /dev/null +++ b/figma-transformer/bin/figma-visual-attribution @@ -0,0 +1,67 @@ +#!/usr/bin/env php + '', + 'source' => '', + 'generated' => '', + 'threshold' => 24, + 'limit' => 25, +); + +foreach ( array_slice($argv, 1) as $argument ) { + if ( '--help' === $argument || '-h' === $argument ) { + blocks_engine_figma_visual_attribution_usage(); + exit(0); + } + if ( ! str_starts_with($argument, '--') ) { + continue; + } + + $parts = explode('=', substr($argument, 2), 2); + $name = str_replace('-', '_', $parts[0]); + $value = $parts[1] ?? '1'; + if ( array_key_exists($name, $options) ) { + $options[$name] = $value; + } +} + +if ( '' === $options['transform_result'] || '' === $options['source'] || '' === $options['generated'] ) { + blocks_engine_figma_visual_attribution_usage(); + exit(1); +} + +$json = is_readable((string) $options['transform_result']) ? file_get_contents((string) $options['transform_result']) : false; +$transformResult = is_string($json) ? json_decode($json, true) : null; +if ( ! is_array($transformResult) ) { + fwrite(STDERR, "Could not read transform result JSON.\n"); + exit(1); +} + +$builder = new Automattic\BlocksEngine\FigmaTransformer\Parity\VisualAttributionReportBuilder(); +$report = $builder->build( + $transformResult, + (string) $options['source'], + (string) $options['generated'], + array( + 'threshold' => (int) $options['threshold'], + 'limit' => (int) $options['limit'], + ) +); + +fwrite(STDOUT, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR) . "\n"); +exit('error' === ($report['status'] ?? null) ? 1 : 0); + +function blocks_engine_figma_visual_attribution_usage(): void +{ + fwrite(STDERR, "Usage: figma-visual-attribution --transform-result= --source= --generated= [--threshold=24] [--limit=25]\n"); +} diff --git a/figma-transformer/composer.json b/figma-transformer/composer.json index a198f16..3f03fd9 100644 --- a/figma-transformer/composer.json +++ b/figma-transformer/composer.json @@ -30,14 +30,15 @@ } }, "require": { - "ext-zstd": "*", "php": ">=8.1" }, "suggest": { + "ext-zstd": "Decode zstd-compressed canvas.fig chunks when inspecting .fig archives.", "ext-zip": "Read zipped .fig archives when the extension is not provided by the host PHP build." }, "bin": [ - "bin/figma-transformer" + "bin/figma-transformer", + "bin/figma-visual-attribution" ], "scripts": { "test": [ diff --git a/figma-transformer/src/FigFile/FigArchiveReader.php b/figma-transformer/src/FigFile/FigArchiveReader.php index c6bdcab..2c2c2db 100644 --- a/figma-transformer/src/FigFile/FigArchiveReader.php +++ b/figma-transformer/src/FigFile/FigArchiveReader.php @@ -151,29 +151,49 @@ private function assetManifest(ZipArchive $zip): array $stat = $zip->statIndex($index); $content = $zip->getFromIndex($index); $hash = basename($name); + $contentString = false === $content ? '' : $content; $assets[] = array( 'id' => $hash, 'name' => $hash, 'path' => $name, 'hash' => $hash, 'bytes' => is_array($stat) ? (int) ($stat['size'] ?? 0) : 0, - 'mime_type' => $this->mimeTypeForPath($name), - 'content' => false === $content ? '' : $content, + 'mime_type' => $this->mimeTypeForPath($name, $contentString), + 'content' => $contentString, ); } return $assets; } - private function mimeTypeForPath(string $path): string + private function mimeTypeForPath(string $path, string $content = ''): string { - return match ( strtolower(pathinfo($path, PATHINFO_EXTENSION)) ) { + $extensionMimeType = match ( strtolower(pathinfo($path, PATHINFO_EXTENSION)) ) { 'jpg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'svg' => 'image/svg+xml', 'webp' => 'image/webp', default => 'application/octet-stream', }; + + if ( 'application/octet-stream' !== $extensionMimeType || '' === $content ) { + return $extensionMimeType; + } + + if ( str_starts_with($content, "\x89PNG\r\n\x1a\n") ) { + return 'image/png'; + } + if ( str_starts_with($content, "\xff\xd8\xff") ) { + return 'image/jpeg'; + } + if ( str_starts_with($content, 'RIFF') && 'WEBP' === substr($content, 8, 4) ) { + return 'image/webp'; + } + if ( str_starts_with(ltrim($content), 'sanitizeText((string) ($scenegraph['name'] ?? 'Figma Site')); $nodes = $this->nodeList($scenegraph); $diagnostics = array(); + $nodeStyleDiagnostics = array(); $assetFiles = $this->normalizeAssets($scenegraph['assets'] ?? array(), $diagnostics); $body = ''; @@ -31,14 +32,16 @@ public function emit(array $scenegraph, array $options = array()): array 'html{box-sizing:border-box}', '*,*::before,*::after{box-sizing:inherit}', 'body{margin:0}', + 'p,h1,h2,h3,h4,h5,h6{margin:0}', 'img{display:block;max-width:100%;height:auto}', ); + $fontCss = $this->fontCss($options); foreach ( $nodes as $node ) { if ( ! is_array($node) ) { continue; } - $body .= $this->emitNode($node, $cssRules, $diagnostics, 0, null); + $body .= $this->emitNode($node, $cssRules, $diagnostics, $nodeStyleDiagnostics, 0, null); } $files = array( @@ -52,7 +55,7 @@ public function emit(array $scenegraph, array $options = array()): array 'path' => 'style.css', 'role' => 'stylesheet', 'mime_type' => 'text/css', - 'content' => implode("\n", $cssRules) . "\n", + 'content' => ('' !== $fontCss ? $fontCss . "\n" : '') . implode("\n", $cssRules) . "\n", ), ); @@ -60,15 +63,38 @@ public function emit(array $scenegraph, array $options = array()): array $files[] = $assetFile; } + $visualNodeMap = $this->visualNodeMap($nodes); + $fontFamilies = $this->fontFamilies($nodeStyleDiagnostics); + if ( '' === $fontCss ) { + foreach ( $fontFamilies as $fontFamily ) { + if ( $this->isWebSafeFontFamily($fontFamily) ) { + continue; + } + $diagnostics[] = array( + 'severity' => 'info', + 'code' => 'font_css_missing_for_source_font', + 'message' => 'Source font family was emitted without supplied font CSS; browser font fallback may reduce visual parity.', + 'context' => array('font_family' => $fontFamily), + ); + } + } + return array( 'status' => 'success', 'diagnostics' => $diagnostics, 'files' => $files, 'assets' => $this->assetReport($assetFiles), 'source_report' => array( - 'name' => $title, - 'node_count' => $this->countNodes($nodes), - 'schema' => $scenegraph['schema'] ?? null, + 'name' => $title, + 'node_count' => $this->countNodes($nodes), + 'schema' => $scenegraph['schema'] ?? null, + 'node_style_diagnostic_count' => count($nodeStyleDiagnostics), + 'node_style_mismatch_count' => $this->countNodeStyleMismatches($nodeStyleDiagnostics), + 'node_style_diagnostics' => $nodeStyleDiagnostics, + 'visual_node_count' => count($visualNodeMap), + 'visual_node_map' => $visualNodeMap, + 'font_families' => $fontFamilies, + 'font_css_supplied' => '' !== $fontCss, ), 'metrics' => array( 'node_count' => $this->countNodes($nodes), @@ -82,7 +108,7 @@ public function emit(array $scenegraph, array $options = array()): array * @param array $cssRules * @param array> $diagnostics */ - private function emitNode(array $node, array &$cssRules, array &$diagnostics, int $depth, ?array $parentNode): string + private function emitNode(array $node, array &$cssRules, array &$diagnostics, array &$nodeStyleDiagnostics, int $depth, ?array $parentNode): string { $id = $this->sanitizeAttribute((string) ($node['id'] ?? '')); $name = (string) ($node['name'] ?? ''); @@ -93,14 +119,16 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, in $className = 'figma-node-' . $this->slug($id . '-' . $name); $children = $this->nodeList($node); $content = $text; + $vectorSvg = $this->supportedVectorSvg($node, $type); - foreach ( $children as $child ) { - if ( is_array($child) ) { - $content .= $this->emitNode($child, $cssRules, $diagnostics, $depth + 1, $node); + if ( ! ( 'BOOLEAN_OPERATION' === $type && null !== $vectorSvg ) ) { + foreach ( $children as $child ) { + if ( is_array($child) ) { + $content .= $this->emitNode($child, $cssRules, $diagnostics, $nodeStyleDiagnostics, $depth + 1, $node); + } } } - $vectorSvg = $this->supportedVectorSvg($node, $type); if ( null !== $vectorSvg ) { $content = $vectorSvg . $content; } @@ -123,6 +151,7 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, in if ( ! empty($styles) ) { $cssRules[] = '.' . $className . '{' . implode(';', $styles) . '}'; } + $nodeStyleDiagnostics[] = $this->nodeStyleDiagnostic($node, $type, $className, $tag, $styles, $parentNode); $attributes = sprintf(' class="%1$s" data-figma-node-id="%2$s" data-figma-node-name="%3$s"', $className, $id, $attributeName); if ( 'RECTANGLE' === $type && '' === $content ) { @@ -170,6 +199,294 @@ private function tagName(string $type, string $name, int $depth): string return 'div'; } + /** + * @param array $options + */ + private function fontCss(array $options): string + { + if ( isset($options['font_css']) && is_scalar($options['font_css']) ) { + return trim((string) $options['font_css']); + } + + return ''; + } + + /** + * @param array $node + * @param array $styles + * @return array + */ + private function nodeStyleDiagnostic(array $node, string $type, string $className, string $tag, array $styles, ?array $parentNode): array + { + $expected = $this->expectedNodeStyleData($node, $type, $parentNode); + $emitted = $this->emittedNodeStyleData($styles); + $matches = array(); + $mismatches = array(); + + foreach ( array_keys($expected + $emitted) as $key ) { + $left = $expected[$key] ?? null; + $right = $emitted[$key] ?? null; + $matches[$key] = $left === $right; + if ( ! $matches[$key] ) { + $mismatches[] = $key; + } + } + + return array( + 'node' => array( + 'id' => (string) ($node['id'] ?? ''), + 'name' => (string) ($node['name'] ?? ''), + 'type' => $type, + 'tag' => $tag, + 'class' => $className, + ), + 'expected' => $expected, + 'emitted' => $emitted, + 'matches' => $matches, + 'mismatches' => $mismatches, + ); + } + + /** + * @param array $node + * @return array + */ + private function expectedNodeStyleData(array $node, string $type, ?array $parentNode): array + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $data = array( + 'background' => 'TEXT' !== $type && ! in_array($type, array('VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE'), true) ? $this->backgroundColor($node) : null, + 'width' => $this->expectedCssLength($box['width'] ?? null), + 'height' => $this->expectedCssLength($box['height'] ?? null), + 'x' => null, + 'y' => null, + 'text_color' => null, + 'font_family' => null, + 'font_size' => null, + 'font_weight' => null, + 'line_height' => null, + ); + + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + if ( null !== $parentNode && $this->isFreeformContainer($parentNode) ) { + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + $data['x'] = $this->expectedCssLength($this->positionOffset($box, $parentBox, 'x')); + $data['y'] = $this->expectedCssLength($this->positionOffset($box, $parentBox, 'y')); + } elseif ( null !== $parentNode && 'absolute' === ($layout['positioning'] ?? null) ) { + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + $data['x'] = $this->expectedCssLength($this->relativeOffset($box, $parentBox, 'x')); + $data['y'] = $this->expectedCssLength($this->relativeOffset($box, $parentBox, 'y')); + } + + if ( 'TEXT' === $type ) { + foreach ( $this->expectedTextStyleData($node) as $key => $value ) { + $data[$key] = $value; + } + } + + return $data; + } + + /** + * @param array $node + * @return array + */ + private function expectedTextStyleData(array $node): array + { + $text = is_array($node['figma_text'] ?? null) ? $node['figma_text'] : array(); + $style = is_array($text['style'] ?? null) ? $text['style'] : array(); + if ( ! isset($style['color']) ) { + $paints = is_array($node['figma_paints']['fills'] ?? null) ? $node['figma_paints']['fills'] : array(); + $color = $this->firstSolidPaint($paints); + if ( null !== $color ) { + $style['css_color'] = $color; + } + } + + $declarations = $this->styleDeclarationMap($this->textStyleDeclarations($style)); + return array( + 'text_color' => $declarations['color'] ?? null, + 'font_family' => $declarations['font-family'] ?? null, + 'font_size' => $declarations['font-size'] ?? null, + 'font_weight' => $declarations['font-weight'] ?? null, + 'line_height' => $declarations['line-height'] ?? null, + ); + } + + /** + * @param array $styles + * @return array + */ + private function emittedNodeStyleData(array $styles): array + { + $map = $this->styleDeclarationMap($styles); + return array( + 'background' => $map['background'] ?? null, + 'width' => $map['width'] ?? null, + 'height' => $map['height'] ?? null, + 'x' => $map['left'] ?? null, + 'y' => $map['top'] ?? null, + 'text_color' => $map['color'] ?? null, + 'font_family' => $map['font-family'] ?? null, + 'font_size' => $map['font-size'] ?? null, + 'font_weight' => $map['font-weight'] ?? null, + 'line_height' => $map['line-height'] ?? null, + ); + } + + /** + * @param array $styles + * @return array + */ + private function styleDeclarationMap(array $styles): array + { + $map = array(); + foreach ( $styles as $style ) { + $parts = explode(':', $style, 2); + if ( 2 === count($parts) ) { + $map[trim($parts[0])] = trim($parts[1]); + } + } + + return $map; + } + + private function expectedCssLength(mixed $value): ?string + { + return is_numeric($value) ? $this->number((float) $value) . 'px' : null; + } + + /** + * @param array> $nodeStyleDiagnostics + */ + private function countNodeStyleMismatches(array $nodeStyleDiagnostics): int + { + $count = 0; + foreach ( $nodeStyleDiagnostics as $diagnostic ) { + $count += count(is_array($diagnostic['mismatches'] ?? null) ? $diagnostic['mismatches'] : array()); + } + + return $count; + } + + /** + * @param array> $nodeStyleDiagnostics + * @return array + */ + private function fontFamilies(array $nodeStyleDiagnostics): array + { + $families = array(); + foreach ( $nodeStyleDiagnostics as $diagnostic ) { + $family = $diagnostic['expected']['font_family'] ?? null; + if ( is_scalar($family) && '' !== (string) $family ) { + $families[] = trim((string) $family, '"'); + } + } + + sort($families); + return array_values(array_unique($families)); + } + + private function isWebSafeFontFamily(string $family): bool + { + return in_array(strtolower($family), array('arial', 'georgia', 'helvetica', 'serif', 'sans-serif', 'times new roman', 'verdana'), true); + } + + /** + * @param array> $nodes + * @return array> + */ + private function visualNodeMap(array $nodes): array + { + $map = array(); + foreach ( $nodes as $node ) { + if ( is_array($node) ) { + $this->appendVisualNodeMap($node, $map, 0.0, 0.0, null); + } + } + + return $map; + } + + /** + * @param array $node + * @param array> $map + */ + private function appendVisualNodeMap(array $node, array &$map, float $x, float $y, ?array $parentNode): void + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + + if ( null !== $parentNode && $this->isFreeformContainer($parentNode) ) { + $x += $this->positionOffset($box, $parentBox, 'x') ?? 0.0; + $y += $this->positionOffset($box, $parentBox, 'y') ?? 0.0; + } elseif ( null !== $parentNode && 'absolute' === ($layout['positioning'] ?? null) ) { + $x += $this->relativeOffset($box, $parentBox, 'x') ?? 0.0; + $y += $this->relativeOffset($box, $parentBox, 'y') ?? 0.0; + } + + $width = isset($box['width']) && is_numeric($box['width']) ? (float) $box['width'] : null; + $height = isset($box['height']) && is_numeric($box['height']) ? (float) $box['height'] : null; + if ( null !== $width && null !== $height ) { + $imagePaint = $this->firstImagePaint($node); + $text = is_array($node['figma_text'] ?? null) ? $node['figma_text'] : array(); + $map[] = array( + 'id' => (string) ($node['id'] ?? ''), + 'parent_id' => null !== $parentNode ? (string) ($parentNode['id'] ?? '') : '', + 'name' => (string) ($node['name'] ?? ''), + 'type' => strtoupper((string) ($node['type'] ?? '')), + 'rect' => array( + 'x' => $x, + 'y' => $y, + 'width' => $width, + 'height' => $height, + ), + 'layout' => array( + 'display' => $layout['display'] ?? null, + 'flex_direction' => $layout['flex_direction'] ?? null, + 'positioning' => $layout['positioning'] ?? null, + 'coordinate_space' => $box['coordinate_space'] ?? null, + ), + 'image' => null === $imagePaint ? null : $this->visualImageMetadata($imagePaint), + 'text' => empty($text) ? null : $this->visualTextMetadata($text), + ); + } + + $children = $this->nodeList($node); + if ( empty($children) ) { + return; + } + + $padding = is_array($layout['padding'] ?? null) ? $layout['padding'] : array(); + $childX = $x + ( isset($padding['left']) && is_numeric($padding['left']) ? (float) $padding['left'] : 0.0 ); + $childY = $y + ( isset($padding['top']) && is_numeric($padding['top']) ? (float) $padding['top'] : 0.0 ); + $gap = isset($layout['item_spacing']) && is_numeric($layout['item_spacing']) ? (float) $layout['item_spacing'] : 0.0; + $cursorX = $childX; + $cursorY = $childY; + $isRow = 'row' === ($layout['flex_direction'] ?? null); + + foreach ( $children as $child ) { + if ( ! is_array($child) ) { + continue; + } + + $childLayout = is_array($child['layout'] ?? null) ? $child['layout'] : array(); + $childBox = is_array($child['box'] ?? null) ? $child['box'] : array(); + if ( $this->isFreeformContainer($node) || 'absolute' === ($childLayout['positioning'] ?? null) ) { + $this->appendVisualNodeMap($child, $map, $x, $y, $node); + continue; + } + + $this->appendVisualNodeMap($child, $map, $cursorX, $cursorY, $node); + if ( $isRow ) { + $cursorX += ( isset($childBox['width']) && is_numeric($childBox['width']) ? (float) $childBox['width'] : 0.0 ) + $gap; + } else { + $cursorY += ( isset($childBox['height']) && is_numeric($childBox['height']) ? (float) $childBox['height'] : 0.0 ) + $gap; + } + } + } + /** * @param array $node * @return array @@ -196,18 +513,24 @@ private function styleDeclarations(array $node, string $type, ?array $parentNode $styles[] = 'overflow:hidden'; } - if ( $this->hasAbsoluteChild($node) ) { + $willPositionAbsolute = (null !== $parentNode && $this->isFreeformContainer($parentNode)) || 'absolute' === ($layout['positioning'] ?? null); + if ( ! $willPositionAbsolute && ($this->hasAbsoluteChild($node) || $this->isFreeformContainer($node)) ) { $styles[] = 'position:relative'; } - if ( 'absolute' === ($layout['positioning'] ?? null) ) { + if ( null !== $parentNode && $this->isFreeformContainer($parentNode) ) { + $styles[] = 'position:absolute'; + foreach ( $this->absolutePositionStyles($box, $layout, $parentNode) as $style ) { + $styles[] = $style; + } + } elseif ( 'absolute' === ($layout['positioning'] ?? null) ) { $styles[] = 'position:absolute'; foreach ( $this->absolutePositionStyles($box, $layout, $parentNode) as $style ) { $styles[] = $style; } } - if ( 'TEXT' !== $type && ! in_array($type, array('VECTOR', 'LINE', 'ELLIPSE'), true) ) { + if ( 'TEXT' !== $type && ! in_array($type, array('VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE'), true) ) { $background = $this->backgroundColor($node); if ( null !== $background ) { $styles[] = 'background:' . $background; @@ -235,8 +558,9 @@ private function styleDeclarations(array $node, string $type, ?array $parentNode $assetPath = $this->nodeAssetPath($node); if ( null !== $assetPath ) { $styles[] = 'background-image:url("' . $assetPath . '")'; - $styles[] = 'background-size:cover'; - $styles[] = 'background-position:center'; + foreach ( $this->imageBackgroundStyles($node) as $style ) { + $styles[] = $style; + } } if ( 'TEXT' === $type ) { @@ -245,6 +569,10 @@ private function styleDeclarations(array $node, string $type, ?array $parentNode } } + foreach ( $this->effectStyles($node, $type) as $style ) { + $styles[] = $style; + } + foreach ( array( 'display' => 'display', 'flex_direction' => 'flex-direction', @@ -269,7 +597,7 @@ private function styleDeclarations(array $node, string $type, ?array $parentNode $styles[] = 'gap:' . $this->number((float) $layout['item_spacing']) . 'px'; } - foreach ( $this->flexItemStyles($layout) as $style ) { + foreach ( $this->flexItemStyles($layout, $parentNode) as $style ) { $styles[] = $style; } @@ -285,8 +613,8 @@ private function absolutePositionStyles(array $box, array $layout, ?array $paren { $styles = array(); $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); - $left = $this->relativeOffset($box, $parentBox, 'x'); - $top = $this->relativeOffset($box, $parentBox, 'y'); + $left = $this->positionOffset($box, $parentBox, 'x'); + $top = $this->positionOffset($box, $parentBox, 'y'); $constraints = is_array($layout['constraints'] ?? null) ? $layout['constraints'] : array(); if ( null !== $left ) { @@ -305,6 +633,46 @@ private function absolutePositionStyles(array $box, array $layout, ?array $paren return $styles; } + /** + * @param array $box + * @return array + */ + private function localPositionStyles(array $box): array + { + $styles = array(); + if ( isset($box['x']) && is_numeric($box['x']) ) { + $styles[] = 'left:' . $this->number((float) $box['x']) . 'px'; + } + if ( isset($box['y']) && is_numeric($box['y']) ) { + $styles[] = 'top:' . $this->number((float) $box['y']) . 'px'; + } + + return $styles; + } + + /** + * @param array $node + */ + private function isFreeformContainer(array $node): bool + { + if ( true === ($node['layout']['freeform'] ?? false) ) { + return true; + } + + $children = $this->nodeList($node); + if ( 1 !== count($children) || ! is_array($children[0]) ) { + return false; + } + + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $childBox = is_array($children[0]['box'] ?? null) ? $children[0]['box'] : array(); + if ( ! isset($box['width'], $box['height'], $childBox['width'], $childBox['height']) || ! is_numeric($box['width']) || ! is_numeric($box['height']) || ! is_numeric($childBox['width']) || ! is_numeric($childBox['height']) ) { + return false; + } + + return (float) $childBox['width'] > (float) $box['width'] || (float) $childBox['height'] > (float) $box['height']; + } + /** * @param array $box * @param array $parentBox @@ -323,6 +691,23 @@ private function relativeOffset(array $box, array $parentBox, string $dimension) return $offset; } + /** + * @param array $box + * @param array $parentBox + */ + private function positionOffset(array $box, array $parentBox, string $dimension): ?float + { + if ( ! isset($box[$dimension]) || ! is_numeric($box[$dimension]) ) { + return null; + } + + if ( 'local' === ($box['coordinate_space'] ?? null) ) { + return (float) $box[$dimension]; + } + + return $this->relativeOffset($box, $parentBox, $dimension); + } + /** * @param array $node */ @@ -361,11 +746,17 @@ private function transformStyle(array $box): ?string */ private function cssMatrix(array $transform): ?string { - if ( 2 !== count($transform) || ! is_array($transform[0] ?? null) || ! is_array($transform[1] ?? null) ) { + if ( isset($transform['m00'], $transform['m01'], $transform['m02'], $transform['m10'], $transform['m11'], $transform['m12']) ) { + if ( 0.00001 > abs((float) $transform['m00'] - 1.0) && 0.00001 > abs((float) $transform['m01']) && 0.00001 > abs((float) $transform['m10']) && 0.00001 > abs((float) $transform['m11'] - 1.0) ) { + return null; + } + $values = array($transform['m00'], $transform['m10'], $transform['m01'], $transform['m11'], 0, 0); + } elseif ( 2 === count($transform) && is_array($transform[0] ?? null) && is_array($transform[1] ?? null) ) { + $values = array($transform[0][0] ?? null, $transform[1][0] ?? null, $transform[0][1] ?? null, $transform[1][1] ?? null, $transform[0][2] ?? null, $transform[1][2] ?? null); + } else { return null; } - $values = array($transform[0][0] ?? null, $transform[1][0] ?? null, $transform[0][1] ?? null, $transform[1][1] ?? null, $transform[0][2] ?? null, $transform[1][2] ?? null); foreach ( $values as $value ) { if ( ! is_numeric($value) ) { return null; @@ -379,33 +770,25 @@ private function cssMatrix(array $transform): ?string * @param array $layout * @return array */ - private function flexItemStyles(array $layout): array + private function flexItemStyles(array $layout, ?array $parentNode): array { $styles = array(); + $parentLayout = is_array($parentNode['layout'] ?? null) ? $parentNode['layout'] : array(); + $isFlexChild = in_array((string) ($parentLayout['display'] ?? ''), array('flex', 'inline-flex'), true); if ( 'FILL' === ($layout['sizing_horizontal'] ?? null) || 'FILL' === ($layout['sizing_vertical'] ?? null) ) { $styles[] = 'flex-grow:1'; $styles[] = 'flex-shrink:1'; } elseif ( isset($layout['grow']) && is_numeric($layout['grow']) ) { $styles[] = 'flex-grow:' . $this->number((float) $layout['grow']); + } elseif ( $isFlexChild ) { + $styles[] = 'flex-shrink:0'; } if ( isset($layout['align']) && 'STRETCH' === $layout['align'] ) { $styles[] = 'align-self:stretch'; } - $usesSourceOrder = 'absolute' === ($layout['positioning'] ?? null) - || 'FILL' === ($layout['sizing_horizontal'] ?? null) - || 'FILL' === ($layout['sizing_vertical'] ?? null) - || isset($layout['grow']) - || isset($layout['align']); - - if ( $usesSourceOrder && isset($layout['source_order']) && is_numeric($layout['source_order']) ) { - $order = (int) $layout['source_order']; - $styles[] = 'order:' . $order; - $styles[] = 'z-index:' . $order; - } - return $styles; } @@ -443,7 +826,7 @@ private function textContent(array $node): string } if ( isset($text['characters']) && is_scalar($text['characters']) ) { - return $this->sanitizeText((string) $text['characters']); + return $this->sanitizeText($this->derivedLineBreakText((string) $text['characters'], $text)); } return $this->sanitizeText((string) ($node['characters'] ?? $node['text'] ?? '')); @@ -465,7 +848,80 @@ private function textStyles(array $node): array } } - return $this->textStyleDeclarations($style); + $styles = $this->textStyleDeclarations($style); + if ( $this->textHasLineBreaks($node) || $this->textHasDerivedLineBreaks($node) ) { + $styles[] = 'white-space:pre-line'; + } + + return $styles; + } + + /** + * @param array $node + */ + private function textHasLineBreaks(array $node): bool + { + $text = is_array($node['figma_text'] ?? null) ? $node['figma_text'] : array(); + $segments = is_array($text['segments'] ?? null) ? $text['segments'] : array(); + foreach ( $segments as $segment ) { + if ( is_array($segment) && isset($segment['characters']) && is_scalar($segment['characters']) && str_contains((string) $segment['characters'], "\n") ) { + return true; + } + } + + foreach ( array($text['characters'] ?? null, $node['characters'] ?? null, $node['text'] ?? null) as $value ) { + if ( is_scalar($value) && str_contains((string) $value, "\n") ) { + return true; + } + } + + return false; + } + + /** + * @param array $text + */ + private function derivedLineBreakText(string $characters, array $text): string + { + if ( str_contains($characters, "\n") ) { + return $characters; + } + + $derivedLayout = is_array($text['derived_layout'] ?? null) ? $text['derived_layout'] : array(); + $baselines = is_array($derivedLayout['baselines'] ?? null) ? $derivedLayout['baselines'] : array(); + if ( 2 > count($baselines) ) { + return $characters; + } + + $chars = preg_split('//u', $characters, -1, PREG_SPLIT_NO_EMPTY); + if ( ! is_array($chars) || empty($chars) ) { + return $characters; + } + + $lines = array(); + foreach ( $baselines as $baseline ) { + if ( ! is_array($baseline) || ! isset($baseline['firstCharacter'], $baseline['endCharacter']) || ! is_numeric($baseline['firstCharacter']) || ! is_numeric($baseline['endCharacter']) ) { + return $characters; + } + $start = max(0, (int) $baseline['firstCharacter']); + $end = min(count($chars), (int) $baseline['endCharacter']); + if ( $end <= $start ) { + continue; + } + $lines[] = implode('', array_slice($chars, $start, $end - $start)); + } + + return empty($lines) ? $characters : implode("\n", $lines); + } + + /** + * @param array $node + */ + private function textHasDerivedLineBreaks(array $node): bool + { + $text = is_array($node['figma_text'] ?? null) ? $node['figma_text'] : array(); + $derivedLayout = is_array($text['derived_layout'] ?? null) ? $text['derived_layout'] : array(); + return isset($derivedLayout['baseline_count']) && is_numeric($derivedLayout['baseline_count']) && 1 < (int) $derivedLayout['baseline_count']; } /** @@ -490,6 +946,8 @@ private function textStyleDeclarations(array $style): array if ( isset($style['line_height_px']) && is_numeric($style['line_height_px']) ) { $styles[] = 'line-height:' . $this->number((float) $style['line_height_px']) . 'px'; + } elseif ( isset($style['line_height_raw']) && is_numeric($style['line_height_raw']) ) { + $styles[] = 'line-height:' . $this->number((float) $style['line_height_raw']); } elseif ( isset($style['line_height_percent']) && is_numeric($style['line_height_percent']) ) { $styles[] = 'line-height:' . $this->number((float) $style['line_height_percent']) . '%'; } @@ -582,9 +1040,86 @@ private function strokeStyles(array $node): array $width = (float) $node['strokeWeight']; } + if ( 'OUTSIDE' === strtoupper((string) ($node['strokeAlign'] ?? '')) ) { + return array('box-shadow:0 0 0 ' . $this->number((float) $width) . 'px ' . $stroke); + } + return array('border:' . $this->number((float) $width) . 'px solid ' . $stroke); } + /** + * @param array $node + * @return array + */ + private function effectStyles(array $node, string $type): array + { + $effects = is_array($node['figma_effects'] ?? null) ? $node['figma_effects'] : array(); + $boxShadows = array(); + $textShadows = array(); + $filters = array(); + $backdropFilters = array(); + + foreach ( $effects as $effect ) { + if ( ! is_array($effect) ) { + continue; + } + + $effectType = (string) ($effect['type'] ?? ''); + if ( in_array($effectType, array('drop_shadow', 'inner_shadow'), true) ) { + $shadow = $this->shadowValue($effect, 'inner_shadow' === $effectType); + if ( null === $shadow ) { + continue; + } + if ( 'TEXT' === $type && 'drop_shadow' === $effectType ) { + $textShadows[] = $shadow; + } else { + $boxShadows[] = $shadow; + } + continue; + } + + if ( 'layer_blur' === $effectType && isset($effect['radius']) && is_numeric($effect['radius']) ) { + $filters[] = 'blur(' . $this->number((float) $effect['radius']) . 'px)'; + } elseif ( 'background_blur' === $effectType && isset($effect['radius']) && is_numeric($effect['radius']) ) { + $backdropFilters[] = 'blur(' . $this->number((float) $effect['radius']) . 'px)'; + } + } + + $styles = array(); + if ( ! empty($boxShadows) ) { + $styles[] = 'box-shadow:' . implode(',', $boxShadows); + } + if ( ! empty($textShadows) ) { + $styles[] = 'text-shadow:' . implode(',', $textShadows); + } + if ( ! empty($filters) ) { + $styles[] = 'filter:' . implode(' ', $filters); + } + if ( ! empty($backdropFilters) ) { + $styles[] = 'backdrop-filter:' . implode(' ', $backdropFilters); + } + + return $styles; + } + + /** + * @param array $effect + */ + private function shadowValue(array $effect, bool $inset): ?string + { + $color = $this->color($effect['color'] ?? null); + if ( null === $color ) { + $color = 'rgba(0,0,0,0.25)'; + } + + return ( $inset ? 'inset ' : '' ) + . $this->number((float) ($effect['offset_x'] ?? 0)) . 'px ' + . $this->number((float) ($effect['offset_y'] ?? 0)) . 'px ' + . $this->number((float) ($effect['radius'] ?? 0)) . 'px ' + . $this->number((float) ($effect['spread'] ?? 0)) . 'px ' + . $color; + } + /** * @param mixed $assets * @param array> $diagnostics @@ -678,6 +1213,229 @@ private function nodeAssetPath(array $node): ?string return null; } + /** + * @param array $node + * @return array + */ + private function imageBackgroundStyles(array $node): array + { + $scaleMode = $this->nodeImageScaleMode($node); + $transformStyles = $this->imagePaintTransformStyles($node, $scaleMode); + if ( ! empty($transformStyles) ) { + return $transformStyles; + } + + if ( 'STRETCH' === $scaleMode ) { + return array('background-size:100% 100%', 'background-repeat:no-repeat', 'background-position:center'); + } + + if ( 'TILE' === $scaleMode ) { + return array('background-repeat:repeat', 'background-position:center'); + } + + return array('background-size:cover', 'background-position:center'); + } + + /** + * @param array $node + * @return array + */ + private function imagePaintTransformStyles(array $node, string $scaleMode): array + { + if ( 'STRETCH' !== $scaleMode ) { + return array(); + } + + $box = is_array($node['box'] ?? null) ? $node['box'] : (is_array($node['figma_box'] ?? null) ? $node['figma_box'] : array()); + $width = $box['width'] ?? $node['width'] ?? null; + $height = $box['height'] ?? $node['height'] ?? null; + if ( ! is_numeric($width) || ! is_numeric($height) || 0 >= (float) $width || 0 >= (float) $height ) { + return array(); + } + + foreach ( $this->nodeImagePaints($node) as $paint ) { + $matrix = $this->imagePaintTransformMatrix($paint); + if ( null === $matrix || $this->isIdentityImageTransform($matrix) ) { + continue; + } + + if ( 0.00001 < abs($matrix['m01']) || 0.00001 < abs($matrix['m10']) || 0 >= $matrix['m00'] || 0 >= $matrix['m11'] ) { + continue; + } + + $backgroundWidth = (float) $width / $matrix['m00']; + $backgroundHeight = (float) $height / $matrix['m11']; + $backgroundX = -1 * $matrix['m02'] * $backgroundWidth; + $backgroundY = -1 * $matrix['m12'] * $backgroundHeight; + + return array( + 'background-size:' . $this->number($backgroundWidth) . 'px ' . $this->number($backgroundHeight) . 'px', + 'background-repeat:no-repeat', + 'background-position:' . $this->number($backgroundX) . 'px ' . $this->number($backgroundY) . 'px', + ); + } + + return array(); + } + + /** + * @param array $node + * @return array|null + */ + private function firstImagePaint(array $node): ?array + { + foreach ( $this->nodeImagePaints($node) as $paint ) { + return $paint; + } + + return null; + } + + /** + * @param array $paint + * @return array + */ + private function visualImageMetadata(array $paint): array + { + $transform = $this->imagePaintTransformMatrix($paint); + $metadata = array( + 'scale_mode' => strtoupper((string) ($paint['imageScaleMode'] ?? $paint['scaleMode'] ?? 'FILL')), + 'has_transform' => null !== $transform && ! $this->isIdentityImageTransform($transform), + 'color_managed' => true === ($paint['imageShouldColorManage'] ?? false), + ); + + foreach ( array('ref', 'imageHash', 'imageName', 'originalImageWidth', 'originalImageHeight', 'scale', 'rotation') as $key ) { + if ( isset($paint[$key]) && is_scalar($paint[$key]) ) { + $metadata[$key] = $paint[$key]; + } + } + + return $metadata; + } + + /** + * @param array $text + * @return array + */ + private function visualTextMetadata(array $text): array + { + $metadata = array( + 'character_count' => isset($text['characters']) && is_scalar($text['characters']) ? strlen((string) $text['characters']) : 0, + 'segment_count' => is_array($text['segments'] ?? null) ? count($text['segments']) : 0, + ); + + $derivedLayout = is_array($text['derived_layout'] ?? null) ? $text['derived_layout'] : array(); + if ( ! empty($derivedLayout) ) { + $metadata['derived_layout'] = $derivedLayout; + $metadata['has_derived_layout'] = true; + $metadata['baseline_count'] = $derivedLayout['baseline_count'] ?? 0; + $metadata['glyph_count'] = $derivedLayout['glyph_count'] ?? 0; + } else { + $metadata['has_derived_layout'] = false; + } + + return $metadata; + } + + /** + * @param array $paint + * @return array{m00: float, m01: float, m02: float, m10: float, m11: float, m12: float}|null + */ + private function imagePaintTransformMatrix(array $paint): ?array + { + $transform = $paint['transform'] ?? null; + if ( ! is_array($transform) ) { + return null; + } + + if ( isset($transform['m00'], $transform['m01'], $transform['m02'], $transform['m10'], $transform['m11'], $transform['m12']) ) { + $values = array( + 'm00' => $transform['m00'], + 'm01' => $transform['m01'], + 'm02' => $transform['m02'], + 'm10' => $transform['m10'], + 'm11' => $transform['m11'], + 'm12' => $transform['m12'], + ); + } elseif ( is_array($transform[0] ?? null) && is_array($transform[1] ?? null) ) { + $values = array( + 'm00' => $transform[0][0] ?? null, + 'm01' => $transform[0][1] ?? null, + 'm02' => $transform[0][2] ?? null, + 'm10' => $transform[1][0] ?? null, + 'm11' => $transform[1][1] ?? null, + 'm12' => $transform[1][2] ?? null, + ); + } else { + return null; + } + + foreach ( $values as $value ) { + if ( ! is_numeric($value) ) { + return null; + } + } + + return array_map(static fn (mixed $value): float => (float) $value, $values); + } + + /** + * @param array{m00: float, m01: float, m02: float, m10: float, m11: float, m12: float} $matrix + */ + private function isIdentityImageTransform(array $matrix): bool + { + return 0.00001 > abs($matrix['m00'] - 1.0) + && 0.00001 > abs($matrix['m01']) + && 0.00001 > abs($matrix['m02']) + && 0.00001 > abs($matrix['m10']) + && 0.00001 > abs($matrix['m11'] - 1.0) + && 0.00001 > abs($matrix['m12']); + } + + /** + * @param array $node + */ + private function nodeImageScaleMode(array $node): string + { + foreach ( $this->nodeImagePaints($node) as $paint ) { + foreach ( array('imageScaleMode', 'scaleMode') as $key ) { + if ( isset($paint[$key]) && is_scalar($paint[$key]) && '' !== (string) $paint[$key] ) { + return strtoupper((string) $paint[$key]); + } + } + } + + return 'FILL'; + } + + /** + * @param array $node + * @return array> + */ + private function nodeImagePaints(array $node): array + { + $imagePaints = array(); + foreach ( array('fills', 'strokes', 'background') as $paintKey ) { + $paintCollections = array(); + if ( is_array($node[$paintKey] ?? null) ) { + $paintCollections[] = $node[$paintKey]; + } + if ( is_array($node['figma_paints'][$paintKey] ?? null) ) { + $paintCollections[] = $node['figma_paints'][$paintKey]; + } + + foreach ( $paintCollections as $paints ) { + foreach ( $paints as $paint ) { + if ( is_array($paint) && 'IMAGE' === strtoupper((string) ($paint['type'] ?? '')) ) { + $imagePaints[] = $paint; + } + } + } + } + + return $imagePaints; + } + /** * @param array $asset * @return array @@ -708,25 +1466,31 @@ private function assetAliases(array $asset, string $id): array private function nodeAssetReferences(array $node): array { $references = array(); - foreach ( array('asset_id', 'assetId', 'image_ref', 'imageRef', 'imageHash') as $key ) { + foreach ( array('asset_id', 'assetId', 'image_ref', 'imageRef', 'imageHash', 'ref') as $key ) { if ( isset($node[$key]) && is_scalar($node[$key]) ) { $references[] = (string) $node[$key]; } } foreach ( array('fills', 'strokes', 'background') as $paintKey ) { - if ( ! is_array($node[$paintKey] ?? null) ) { - continue; + $paintCollections = array(); + if ( is_array($node[$paintKey] ?? null) ) { + $paintCollections[] = $node[$paintKey]; + } + if ( is_array($node['figma_paints'][$paintKey] ?? null) ) { + $paintCollections[] = $node['figma_paints'][$paintKey]; } - foreach ( $node[$paintKey] as $paint ) { - if ( ! is_array($paint) || 'IMAGE' !== strtoupper((string) ($paint['type'] ?? '')) ) { - continue; - } + foreach ( $paintCollections as $paints ) { + foreach ( $paints as $paint ) { + if ( ! is_array($paint) || 'IMAGE' !== strtoupper((string) ($paint['type'] ?? '')) ) { + continue; + } - foreach ( array('imageRef', 'imageHash', 'asset_id', 'image_ref') as $key ) { - if ( isset($paint[$key]) && is_scalar($paint[$key]) && '' !== (string) $paint[$key] ) { - $references[] = (string) $paint[$key]; + foreach ( array('ref', 'imageRef', 'imageHash', 'asset_id', 'image_ref') as $key ) { + if ( isset($paint[$key]) && is_scalar($paint[$key]) && '' !== (string) $paint[$key] ) { + $references[] = (string) $paint[$key]; + } } } } @@ -745,7 +1509,7 @@ private function isUnsupportedVectorType(string $type): bool */ private function supportedVectorSvg(array $node, string $type): ?string { - if ( ! in_array($type, array('VECTOR', 'LINE', 'ELLIPSE', 'RECTANGLE'), true) ) { + if ( ! in_array($type, array('VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE', 'RECTANGLE'), true) ) { return null; } @@ -774,7 +1538,15 @@ private function supportedVectorSvg(array $node, string $type): ?string 'data-figma-vector="true"', ); - return '' . implode('', $elements) . ''; + $body = implode('', $elements); + $scale = is_array($node['figma_vector_scale'] ?? null) ? $node['figma_vector_scale'] : array(); + $scaleX = isset($scale['x']) && is_numeric($scale['x']) ? (float) $scale['x'] : 1.0; + $scaleY = isset($scale['y']) && is_numeric($scale['y']) ? (float) $scale['y'] : 1.0; + if ( abs($scaleX - 1.0) >= 0.0001 || abs($scaleY - 1.0) >= 0.0001 ) { + $body = '' . $body . ''; + } + + return '' . $body . ''; } /** @@ -784,6 +1556,9 @@ private function supportedVectorSvg(array $node, string $type): ?string private function vectorPathElements(array $node): array { $rawPaths = array(); + if ( is_array($node['figma_vector_paths'] ?? null) ) { + $rawPaths = array_merge($rawPaths, $node['figma_vector_paths']); + } foreach ( array('vectorPaths', 'paths') as $key ) { if ( is_array($node[$key] ?? null) ) { $rawPaths = array_merge($rawPaths, $node[$key]); diff --git a/figma-transformer/src/Parity/VisualAttributionReportBuilder.php b/figma-transformer/src/Parity/VisualAttributionReportBuilder.php new file mode 100644 index 0000000..a686142 --- /dev/null +++ b/figma-transformer/src/Parity/VisualAttributionReportBuilder.php @@ -0,0 +1,339 @@ + $transformResult + * @param array $options + * @return array + */ + public function build(array $transformResult, string $sourceImagePath, string $generatedImagePath, array $options = array()): array + { + if ( ! function_exists('imagecreatefrompng') ) { + return $this->errorReport('gd_png_unavailable', 'The GD PNG extension is required for visual attribution.'); + } + + $sourceImage = is_readable($sourceImagePath) ? imagecreatefrompng($sourceImagePath) : false; + if ( false === $sourceImage ) { + return $this->errorReport('source_image_unreadable', 'Source screenshot could not be read.', array('path' => $sourceImagePath)); + } + + $generatedImage = is_readable($generatedImagePath) ? imagecreatefrompng($generatedImagePath) : false; + if ( false === $generatedImage ) { + return $this->errorReport('generated_image_unreadable', 'Generated screenshot could not be read.', array('path' => $generatedImagePath)); + } + + $threshold = isset($options['threshold']) && is_numeric($options['threshold']) ? (int) $options['threshold'] : 24; + $limit = isset($options['limit']) && is_numeric($options['limit']) ? max(1, (int) $options['limit']) : 25; + $viewportWidth = min(imagesx($sourceImage), imagesx($generatedImage)); + $viewportHeight = min(imagesy($sourceImage), imagesy($generatedImage)); + $diagnostics = $this->nodeStyleDiagnostics($transformResult); + $visualNodesById = $this->visualNodesById($transformResult); + $nodes = array(); + $unattributed = 0; + + foreach ( $diagnostics as $diagnostic ) { + $node = is_array($diagnostic['node'] ?? null) ? $diagnostic['node'] : array(); + $emitted = is_array($diagnostic['emitted'] ?? null) ? $diagnostic['emitted'] : array(); + $expected = is_array($diagnostic['expected'] ?? null) ? $diagnostic['expected'] : array(); + $nodeId = (string) ($node['id'] ?? ''); + $visualNode = $visualNodesById[$nodeId] ?? null; + $rect = is_array($visualNode) ? $this->rectFromVisualNode($visualNode, $viewportWidth, $viewportHeight) : null; + if ( null === $rect ) { + $rect = $this->rectFromStyleData($emitted, $viewportWidth, $viewportHeight); + } + if ( null === $rect ) { + $unattributed++; + continue; + } + + $stats = $this->diffStatsForRect($sourceImage, $generatedImage, $rect, $threshold); + if ( 0 === $stats['area_pixels'] ) { + $unattributed++; + continue; + } + + $nodes[] = array( + 'node' => array( + 'id' => $nodeId, + 'name' => (string) ($node['name'] ?? ''), + 'type' => (string) ($node['type'] ?? ''), + 'class' => (string) ($node['class'] ?? ''), + ), + 'rect' => $rect, + 'features' => $this->features($expected, $emitted, is_array($visualNode) ? $visualNode : array()), + 'mismatches' => is_array($diagnostic['mismatches'] ?? null) ? array_values($diagnostic['mismatches']) : array(), + 'diff' => $stats, + ); + } + + usort( + $nodes, + static fn (array $left, array $right): int => ($right['diff']['mismatch_pixels'] ?? 0) <=> ($left['diff']['mismatch_pixels'] ?? 0) + ); + $leafNodes = $this->leafNodes($nodes, $visualNodesById); + + return array( + 'schema' => self::SCHEMA, + 'status' => 'success', + 'inputs' => array( + 'source_image_path' => $sourceImagePath, + 'generated_image_path' => $generatedImagePath, + 'threshold' => $threshold, + ), + 'viewport' => array( + 'width' => $viewportWidth, + 'height' => $viewportHeight, + ), + 'coverage' => array( + 'diagnostic_node_count' => count($diagnostics), + 'attributed_node_count' => count($nodes), + 'leaf_node_count' => count($leafNodes), + 'unattributed_node_count' => $unattributed, + 'coverage_ratio' => 0 === count($diagnostics) ? 0 : count($nodes) / count($diagnostics), + ), + 'top_nodes' => array_slice($nodes, 0, $limit), + 'top_leaf_nodes' => array_slice($leafNodes, 0, $limit), + ); + } + + /** + * @param array> $nodes + * @param array> $visualNodesById + * @return array> + */ + private function leafNodes(array $nodes, array $visualNodesById): array + { + $parentIds = array(); + foreach ( $visualNodesById as $visualNode ) { + if ( is_scalar($visualNode['parent_id'] ?? null) && '' !== (string) $visualNode['parent_id'] ) { + $parentIds[(string) $visualNode['parent_id']] = true; + } + } + + return array_values(array_filter( + $nodes, + static fn (array $node): bool => ! isset($parentIds[(string) ($node['node']['id'] ?? '')]) + )); + } + + /** + * @param array $transformResult + * @return array> + */ + private function nodeStyleDiagnostics(array $transformResult): array + { + $diagnostics = $transformResult['source_reports']['figma']['html']['node_style_diagnostics'] ?? array(); + return is_array($diagnostics) ? array_values(array_filter($diagnostics, 'is_array')) : array(); + } + + /** + * @param array $transformResult + * @return array> + */ + private function visualNodesById(array $transformResult): array + { + $visualNodes = $transformResult['source_reports']['figma']['html']['visual_node_map'] ?? array(); + $byId = array(); + if ( ! is_array($visualNodes) ) { + return $byId; + } + + foreach ( $visualNodes as $visualNode ) { + if ( ! is_array($visualNode) || ! is_scalar($visualNode['id'] ?? null) ) { + continue; + } + $byId[(string) $visualNode['id']] = $visualNode; + } + + return $byId; + } + + /** + * @param array $visualNode + * @return array{x:int,y:int,width:int,height:int}|null + */ + private function rectFromVisualNode(array $visualNode, int $viewportWidth, int $viewportHeight): ?array + { + $rect = is_array($visualNode['rect'] ?? null) ? $visualNode['rect'] : array(); + foreach ( array('x', 'y', 'width', 'height') as $key ) { + if ( ! isset($rect[$key]) || ! is_numeric($rect[$key]) ) { + return null; + } + } + + $left = max(0, (int) floor((float) $rect['x'])); + $top = max(0, (int) floor((float) $rect['y'])); + $right = min($viewportWidth, (int) ceil((float) $rect['x'] + (float) $rect['width'])); + $bottom = min($viewportHeight, (int) ceil((float) $rect['y'] + (float) $rect['height'])); + if ( $right <= $left || $bottom <= $top ) { + return null; + } + + return array( + 'x' => $left, + 'y' => $top, + 'width' => $right - $left, + 'height' => $bottom - $top, + ); + } + + /** + * @param array $styleData + * @return array{x:int,y:int,width:int,height:int}|null + */ + private function rectFromStyleData(array $styleData, int $viewportWidth, int $viewportHeight): ?array + { + $width = $this->cssPixels($styleData['width'] ?? null); + $height = $this->cssPixels($styleData['height'] ?? null); + if ( null === $width || null === $height || $width <= 0 || $height <= 0 ) { + return null; + } + + $x = $this->cssPixels($styleData['x'] ?? null) ?? 0.0; + $y = $this->cssPixels($styleData['y'] ?? null) ?? 0.0; + $left = max(0, (int) floor($x)); + $top = max(0, (int) floor($y)); + $right = min($viewportWidth, (int) ceil($x + $width)); + $bottom = min($viewportHeight, (int) ceil($y + $height)); + if ( $right <= $left || $bottom <= $top ) { + return null; + } + + return array( + 'x' => $left, + 'y' => $top, + 'width' => $right - $left, + 'height' => $bottom - $top, + ); + } + + private function cssPixels(mixed $value): ?float + { + if ( is_numeric($value) ) { + return (float) $value; + } + if ( ! is_scalar($value) ) { + return null; + } + $value = trim((string) $value); + if ( preg_match('/^-?\d+(?:\.\d+)?px$/', $value) ) { + return (float) substr($value, 0, -2); + } + + return null; + } + + /** + * @param resource|\GdImage $sourceImage + * @param resource|\GdImage $generatedImage + * @param array{x:int,y:int,width:int,height:int} $rect + * @return array + */ + private function diffStatsForRect(mixed $sourceImage, mixed $generatedImage, array $rect, int $threshold): array + { + $mismatch = 0; + $sum = 0; + $max = 0; + $area = $rect['width'] * $rect['height']; + + for ( $y = $rect['y']; $y < $rect['y'] + $rect['height']; $y++ ) { + for ( $x = $rect['x']; $x < $rect['x'] + $rect['width']; $x++ ) { + $sourceColor = imagecolorat($sourceImage, $x, $y); + $generatedColor = imagecolorat($generatedImage, $x, $y); + $delta = abs((($sourceColor >> 16) & 255) - (($generatedColor >> 16) & 255)) + + abs((($sourceColor >> 8) & 255) - (($generatedColor >> 8) & 255)) + + abs(($sourceColor & 255) - ($generatedColor & 255)); + $sum += $delta; + $max = max($max, $delta); + if ( $delta > $threshold ) { + $mismatch++; + } + } + } + + return array( + 'area_pixels' => $area, + 'mismatch_pixels' => $mismatch, + 'mismatch_ratio' => 0 === $area ? 0 : $mismatch / $area, + 'mean_rgb_sum_delta' => 0 === $area ? 0 : $sum / $area, + 'max_rgb_sum_delta' => $max, + ); + } + + /** + * @param array $expected + * @param array $emitted + * @return array + */ + private function features(array $expected, array $emitted, array $visualNode = array()): array + { + $features = array(); + $image = is_array($visualNode['image'] ?? null) ? $visualNode['image'] : array(); + if ( ! empty($image) ) { + $features[] = 'image'; + if ( isset($image['scale_mode']) && is_scalar($image['scale_mode']) ) { + $features[] = 'image-' . strtolower((string) $image['scale_mode']); + } + if ( true === ($image['has_transform'] ?? false) ) { + $features[] = 'image-transform'; + } + if ( true === ($image['color_managed'] ?? false) ) { + $features[] = 'image-color-managed'; + } + } + $text = is_array($visualNode['text'] ?? null) ? $visualNode['text'] : array(); + if ( isset($expected['font_family']) || isset($emitted['font_family']) ) { + $features[] = 'text'; + if ( true === ($text['has_derived_layout'] ?? false) ) { + $features[] = 'text-derived-layout'; + } + if ( isset($text['baseline_count']) && is_numeric($text['baseline_count']) && 1 < (int) $text['baseline_count'] ) { + $features[] = 'text-multiline-derived'; + } + if ( isset($text['glyph_count']) && is_numeric($text['glyph_count']) && 0 < (int) $text['glyph_count'] ) { + $features[] = 'text-glyph-metadata'; + } + } + if ( isset($expected['background']) || isset($emitted['background']) ) { + $features[] = 'background'; + } + if ( isset($expected['x']) || isset($expected['y']) || isset($emitted['x']) || isset($emitted['y']) ) { + $features[] = 'positioned'; + } + if ( empty($features) ) { + $features[] = 'layout'; + } + + return $features; + } + + /** + * @param array $context + * @return array + */ + private function errorReport(string $code, string $message, array $context = array()): array + { + return array( + 'schema' => self::SCHEMA, + 'status' => 'error', + 'diagnostics' => array( + array( + 'severity' => 'error', + 'code' => $code, + 'message' => $message, + 'context' => $context, + ), + ), + ); + } +} diff --git a/figma-transformer/src/Scenegraph/ScenegraphIndex.php b/figma-transformer/src/Scenegraph/ScenegraphIndex.php index cc6bccf..3691162 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphIndex.php +++ b/figma-transformer/src/Scenegraph/ScenegraphIndex.php @@ -52,7 +52,7 @@ public function build(array $source): array } foreach ( $childrenIndex as $parent => $children ) { - $childrenIndex[$parent] = $this->sortNodeIds($children, $nodeMap); + $childrenIndex[$parent] = $this->sortNodeIds($children, $nodeMap, $nodeMap[$parent] ?? array()); } $topLevelNodeIds = array(); @@ -161,6 +161,10 @@ private function collectNode(array $value, ?string $fallbackId, ?string $parentI if ( null !== $sourceOrder ) { $node['_source_order'] = $sourceOrder; } + $parentSortPosition = $this->readParentSortPosition($node); + if ( null !== $parentSortPosition ) { + $node['_parent_sort_position'] = $parentSortPosition; + } if ( isset($rawNodes[$id]) ) { $diagnostics[] = array( @@ -206,6 +210,24 @@ private function unwrapNodeChange(array $value): ?array return null; } + /** + * @param array $node + */ + private function readParentSortPosition(array $node): ?string + { + if ( isset($node['parentIndex']['position']) && is_scalar($node['parentIndex']['position']) ) { + $position = (string) $node['parentIndex']['position']; + return '' === $position ? null : $position; + } + + if ( isset($node['parent_index']['position']) && is_scalar($node['parent_index']['position']) ) { + $position = (string) $node['parent_index']['position']; + return '' === $position ? null : $position; + } + + return null; + } + /** * @param array $node * @param array $keys @@ -252,17 +274,28 @@ private function nodeRichness(array $node, array $children): int * @param array> $nodeMap * @return array */ - private function sortNodeIds(array $ids, array $nodeMap): array + private function sortNodeIds(array $ids, array $nodeMap, array $parentNode = array()): array { + $parentMode = strtoupper((string) ($parentNode['layoutMode'] ?? $parentNode['stackMode'] ?? '')); usort( $ids, - static function (string $left, string $right) use ($nodeMap): int { + static function (string $left, string $right) use ($nodeMap, $parentMode, $parentNode): int { $leftNode = $nodeMap[$left] ?? array(); $rightNode = $nodeMap[$right] ?? array(); $leftBox = self::readBounds($leftNode); $rightBox = self::readBounds($rightNode); + if ( 'HORIZONTAL' === $parentMode ) { + return array($leftBox['x'], $leftBox['y'], (string) ($leftNode['name'] ?? ''), $left) + <=> array($rightBox['x'], $rightBox['y'], (string) ($rightNode['name'] ?? ''), $right); + } + + $parentType = strtoupper((string) ($parentNode['type'] ?? '')); + if ( '' === $parentMode && ( 'GROUP' === $parentType || true === ($parentNode['resizeToFit'] ?? false) ) ) { + return self::layerOrderKey($leftNode, $left) <=> self::layerOrderKey($rightNode, $right); + } + return array($leftBox['y'], $leftBox['x'], (string) ($leftNode['name'] ?? ''), $left) <=> array($rightBox['y'], $rightBox['x'], (string) ($rightNode['name'] ?? ''), $right); } @@ -271,6 +304,19 @@ static function (string $left, string $right) use ($nodeMap): int { return $ids; } + /** + * @param array $node + * @return array{0: int, 1: string|int, 2: string} + */ + private static function layerOrderKey(array $node, string $id): array + { + if ( isset($node['_parent_sort_position']) && is_scalar($node['_parent_sort_position']) ) { + return array(0, (string) $node['_parent_sort_position'], $id); + } + + return array(1, (int) ($node['_source_order'] ?? 0), $id); + } + /** * @param array $node * @return array{x: float, y: float} diff --git a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php index 2e65d32..8f8154c 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -23,7 +23,9 @@ public function normalize(array $source, array $options = array()): array { $index = $this->index->build($source); $diagnostics = $index['diagnostics']; - $nodeMap = $this->normalizeNodeMap($index['nodes'], $diagnostics); + $blobs = is_array($source['blobs'] ?? null) ? $source['blobs'] : array(); + $paintStyles = $this->buildPaintStyleDefinitions($index['nodes'], $diagnostics); + $nodeMap = $this->normalizeNodeMap($index['nodes'], $diagnostics, $blobs, $paintStyles); $components = $this->buildComponentDefinitions($nodeMap); $componentDefinitionCount = $this->countComponentDefinitions($nodeMap); $instanceReport = $this->resolveInstances($nodeMap, $components, $diagnostics); @@ -46,7 +48,7 @@ public function normalize(array $source, array $options = array()): array $renderNodes = array(); foreach ( $renderIds as $id ) { if ( isset($nodeMap[$id]) ) { - $renderNodes[] = $nodeMap[$id]; + $renderNodes[] = $this->refreshResolvedTree($nodeMap[$id], $nodeMap); } } @@ -57,9 +59,9 @@ public function normalize(array $source, array $options = array()): array return array( 'schema' => 'blocks-engine/figma-transformer/scenegraph/v1', 'name' => $sourceName, - 'assets' => is_array($source['assets'] ?? null) ? $source['assets'] : array(), 'nodes' => $renderNodes, 'assets' => is_array($source['assets'] ?? null) ? $source['assets'] : array(), + 'figma_blobs' => $blobs, 'node_map' => $nodeMap, 'parent_index' => $index['parent_index'], 'children_index' => $index['children_index'], @@ -94,10 +96,10 @@ public function normalize(array $source, array $options = array()): array * @param array> $diagnostics * @return array> */ - private function normalizeNodeMap(array $nodeMap, array &$diagnostics): array + private function normalizeNodeMap(array $nodeMap, array &$diagnostics, array $blobs = array(), array $paintStyles = array()): array { foreach ( $nodeMap as $id => $node ) { - $nodeMap[$id] = $this->normalizeNode($node, $diagnostics); + $nodeMap[$id] = $this->normalizeNode($node, $diagnostics, $blobs, $paintStyles); } return $nodeMap; @@ -108,7 +110,7 @@ private function normalizeNodeMap(array $nodeMap, array &$diagnostics): array * @param array> $diagnostics * @return array */ - private function normalizeNode(array $node, array &$diagnostics): array + private function normalizeNode(array $node, array &$diagnostics, array $blobs = array(), array $paintStyles = array()): array { $id = (string) ($node['id'] ?? ''); $type = strtoupper((string) ($node['type'] ?? '')); @@ -125,11 +127,16 @@ private function normalizeNode(array $node, array &$diagnostics): array } } - $paints = $this->normalizePaintCollections($node, $id, $diagnostics); + $paints = $this->normalizePaintCollections($node, $id, $diagnostics, $paintStyles); if ( ! empty($paints) ) { $node['figma_paints'] = $paints; } + $vectorPaths = $this->normalizeVectorPaths($node, $blobs, $id, $diagnostics); + if ( ! empty($vectorPaths) ) { + $node['figma_vector_paths'] = $vectorPaths; + } + $box = $this->normalizeVisualBox($node); if ( ! empty($box) ) { $node['figma_box'] = $box; @@ -145,7 +152,10 @@ private function normalizeNode(array $node, array &$diagnostics): array $node['layout'] = $layout; } - $this->diagnoseEffects($node, $id, $diagnostics); + $effects = $this->normalizeEffects($node, $id, $diagnostics); + if ( ! empty($effects) ) { + $node['figma_effects'] = $effects; + } foreach ( array('children', 'nodes') as $childrenKey ) { if ( ! is_array($node[$childrenKey] ?? null) ) { @@ -154,7 +164,7 @@ private function normalizeNode(array $node, array &$diagnostics): array foreach ( $node[$childrenKey] as $index => $child ) { if ( is_array($child) ) { - $normalizedChild = $this->normalizeNode($child, $diagnostics); + $normalizedChild = $this->normalizeNode($child, $diagnostics, $blobs, $paintStyles); $childLayout = is_array($normalizedChild['layout'] ?? null) ? $normalizedChild['layout'] : array(); $childLayout['source_order'] = isset($normalizedChild['_source_order']) && is_numeric($normalizedChild['_source_order']) ? (int) $normalizedChild['_source_order'] @@ -176,7 +186,7 @@ private function normalizeComponentMetadata(array $node, string $type): array { $metadata = array(); - if ( 'COMPONENT' === $type || 'COMPONENT_SET' === $type ) { + if ( in_array($type, array('COMPONENT', 'COMPONENT_SET', 'SYMBOL'), true) ) { $metadata['role'] = 'definition'; $metadata['definition_id'] = (string) ($node['id'] ?? ''); } elseif ( 'INSTANCE' === $type ) { @@ -209,7 +219,7 @@ private function buildComponentDefinitions(array $nodeMap): array $components = array(); foreach ( $nodeMap as $id => $node ) { - if ( ! in_array(strtoupper((string) ($node['type'] ?? '')), array('COMPONENT', 'COMPONENT_SET'), true) ) { + if ( ! in_array(strtoupper((string) ($node['type'] ?? '')), array('COMPONENT', 'COMPONENT_SET', 'SYMBOL'), true) ) { continue; } @@ -229,7 +239,7 @@ private function countComponentDefinitions(array $nodeMap): int $count = 0; foreach ( $nodeMap as $node ) { - if ( in_array(strtoupper((string) ($node['type'] ?? '')), array('COMPONENT', 'COMPONENT_SET'), true) ) { + if ( in_array(strtoupper((string) ($node['type'] ?? '')), array('COMPONENT', 'COMPONENT_SET', 'SYMBOL'), true) ) { $count++; } } @@ -237,6 +247,28 @@ private function countComponentDefinitions(array $nodeMap): int return $count; } + /** + * @param array> $nodeMap + * @param array> $diagnostics + * @return array>>> + */ + private function buildPaintStyleDefinitions(array $nodeMap, array &$diagnostics): array + { + $styles = array(); + foreach ( $nodeMap as $id => $node ) { + if ( 'FILL' !== strtoupper((string) ($node['styleType'] ?? '')) ) { + continue; + } + + $paints = $this->normalizePaintList(is_array($node['fillPaints'] ?? null) ? $node['fillPaints'] : (is_array($node['fills'] ?? null) ? $node['fills'] : array()), $id, 'style.fillPaints', $diagnostics); + if ( ! empty($paints) ) { + $styles[$id]['fills'] = $paints; + } + } + + return $styles; + } + /** * @param array> $nodeMap * @param array> $components @@ -281,13 +313,13 @@ private function resolveInstances(array &$nodeMap, array $components, array &$di continue; } - $overrides = $this->normalizeInstanceOverrides($node['overrides'] ?? array(), $id, $diagnostics); + $overrides = $this->normalizeInstanceOverrides($node, $id, $diagnostics); if ( null === $overrides ) { $unresolved[] = array('instance_id' => $id, 'component_id' => $reference['id']); continue; } - $resolved = $this->cloneComponentForInstance($components[$reference['id']], $node, $reference['id'], $overrides); + $resolved = $this->cloneComponentForInstance($components[$reference['id']], $node, $reference['id'], $overrides, $nodeMap); $nodeMap[$id] = $resolved; $resolvedCount++; } @@ -299,6 +331,39 @@ private function resolveInstances(array &$nodeMap, array $components, array &$di ); } + /** + * @param array $node + * @param array> $nodeMap + * @param array $trail + * @return array + */ + private function refreshResolvedTree(array $node, array $nodeMap, array $trail = array()): array + { + $id = (string) ($node['id'] ?? ''); + if ( '' !== $id && isset($nodeMap[$id]) && ! in_array($id, $trail, true) ) { + $node = $nodeMap[$id]; + $trail[] = $id; + } + + if ( true === ($node['figma_component']['resolved'] ?? false) ) { + return $node; + } + + if ( ! is_array($node['children'] ?? null) ) { + return $node; + } + + foreach ( $node['children'] as $index => $child ) { + if ( ! is_array($child) ) { + continue; + } + + $node['children'][$index] = $this->refreshResolvedTree($child, $nodeMap, $trail); + } + + return $node; + } + /** * @param array $node * @return array{id: string, source_key: string}|null @@ -322,6 +387,24 @@ private function readComponentReference(array $node): ?array } } + $symbolId = $this->readGuidId($node['symbolData']['symbolID'] ?? null); + if ( null !== $symbolId ) { + return array('id' => $symbolId, 'source_key' => 'symbolData.symbolID'); + } + + return null; + } + + private function readGuidId(mixed $guid): ?string + { + if ( is_array($guid) && isset($guid['sessionID'], $guid['localID']) ) { + return (string) $guid['sessionID'] . ':' . (string) $guid['localID']; + } + + if ( is_scalar($guid) && '' !== (string) $guid ) { + return (string) $guid; + } + return null; } @@ -341,13 +424,21 @@ private function readString(array $node, array $keys): ?string } /** - * @param mixed $rawOverrides + * @param array $node * @param array> $diagnostics - * @return array>|null + * @return array>|null */ - private function normalizeInstanceOverrides(mixed $rawOverrides, string $instanceId, array &$diagnostics): ?array + private function normalizeInstanceOverrides(array $node, string $instanceId, array &$diagnostics): ?array { - if ( ! is_array($rawOverrides) || empty($rawOverrides) ) { + $rawOverrides = array(); + if ( is_array($node['overrides'] ?? null) ) { + $rawOverrides = array_merge($rawOverrides, $node['overrides']); + } + if ( is_array($node['symbolData']['symbolOverrides'] ?? null) ) { + $rawOverrides = array_merge($rawOverrides, $node['symbolData']['symbolOverrides']); + } + + if ( empty($rawOverrides) ) { return array(); } @@ -363,7 +454,7 @@ private function normalizeInstanceOverrides(mixed $rawOverrides, string $instanc return null; } - $nodeId = $this->readString($override, array('nodeId', 'node_id', 'id')) ?? (is_string($key) ? $key : null); + $nodeId = $this->readString($override, array('nodeId', 'node_id', 'id')) ?? $this->readOverrideGuidPathTarget($override) ?? (is_string($key) ? $key : null); if ( null === $nodeId || '' === $nodeId ) { return null; } @@ -373,25 +464,48 @@ private function normalizeInstanceOverrides(mixed $rawOverrides, string $instanc $overrides[$nodeId][$field] = $override[$field]; } } + if ( isset($override['textData']['characters']) && is_scalar($override['textData']['characters']) ) { + $overrides[$nodeId]['characters'] = (string) $override['textData']['characters']; + } } return $overrides; } + /** + * @param array $override + */ + private function readOverrideGuidPathTarget(array $override): ?string + { + $guidPath = $override['guidPath'] ?? null; + if ( ! is_array($guidPath) ) { + return null; + } + + $guids = is_array($guidPath['guids'] ?? null) ? $guidPath['guids'] : $guidPath; + $last = end($guids); + if ( false === $last ) { + return null; + } + + return $this->readGuidId($last); + } + /** * @param array $component * @param array $instance - * @param array> $overrides + * @param array> $overrides + * @param array> $nodeMap * @return array */ - private function cloneComponentForInstance(array $component, array $instance, string $componentId, array $overrides): array + private function cloneComponentForInstance(array $component, array $instance, string $componentId, array $overrides, array $nodeMap): array { $resolved = $component; $resolved['id'] = (string) ($instance['id'] ?? $resolved['id'] ?? ''); $resolved['type'] = 'INSTANCE'; $resolved['name'] = (string) ($instance['name'] ?? $resolved['name'] ?? ''); - foreach ( array('box', 'figma_box', 'layout', 'componentProperties') as $key ) { + foreach ( array('box', 'figma_box', 'layout', 'figma_paints', 'figma_effects', 'figma_vector_paths', 'componentProperties', 'fillPaints', 'effects', 'styleIdForFill', 'fillGeometry', 'strokeGeometry', 'vectorPaths', 'paths', 'pathData', 'path', 'd') as $key ) { if ( array_key_exists($key, $instance) ) { $resolved[$key] = $instance[$key]; } @@ -407,14 +521,190 @@ private function cloneComponentForInstance(array $component, array $instance, st 'resolved' => true, ) ); - $resolved['children'] = $this->applyInstanceOverridesToChildren(is_array($resolved['children'] ?? null) ? $resolved['children'] : array(), $overrides); + $resolvedChildren = is_array($resolved['children'] ?? null) ? $resolved['children'] : array(); + $resolvedChildren = $this->resolveClonedInstanceChildren($resolvedChildren, $nodeMap); + $resolvedChildren = $this->scaleVectorOnlyInstanceChildren($resolvedChildren, $component, $instance); + $resolved['children'] = $this->namespaceResolvedInstanceChildren( + $this->applyInstanceOverridesToChildren($resolvedChildren, $overrides), + (string) ($instance['id'] ?? '') + ); return $resolved; } /** * @param array $children - * @param array> $overrides + * @param array> $nodeMap + * @return array + */ + private function resolveClonedInstanceChildren(array $children, array $nodeMap): array + { + foreach ( $children as $index => $child ) { + if ( ! is_array($child) ) { + continue; + } + + $id = (string) ($child['id'] ?? ''); + if ( 'INSTANCE' === strtoupper((string) ($child['type'] ?? '')) && '' !== $id && isset($nodeMap[$id]) ) { + $child = $nodeMap[$id]; + } + + if ( is_array($child['children'] ?? null) ) { + $child['children'] = $this->resolveClonedInstanceChildren($child['children'], $nodeMap); + } + + $children[$index] = $child; + } + + return $children; + } + + /** + * @param array $children + * @return array + */ + private function scaleVectorOnlyInstanceChildren(array $children, array $component, array $instance): array + { + if ( ! $this->isVectorOnlyComponent($component) ) { + return $children; + } + + $componentBox = is_array($component['box'] ?? null) ? $component['box'] : array(); + $instanceBox = is_array($instance['box'] ?? null) ? $instance['box'] : array(); + $componentWidth = isset($componentBox['width']) && is_numeric($componentBox['width']) ? (float) $componentBox['width'] : 0.0; + $componentHeight = isset($componentBox['height']) && is_numeric($componentBox['height']) ? (float) $componentBox['height'] : 0.0; + $instanceWidth = isset($instanceBox['width']) && is_numeric($instanceBox['width']) ? (float) $instanceBox['width'] : 0.0; + $instanceHeight = isset($instanceBox['height']) && is_numeric($instanceBox['height']) ? (float) $instanceBox['height'] : 0.0; + if ( $componentWidth <= 0.0 || $componentHeight <= 0.0 || $instanceWidth <= 0.0 || $instanceHeight <= 0.0 ) { + return $children; + } + + $scaleX = $instanceWidth / $componentWidth; + $scaleY = $instanceHeight / $componentHeight; + if ( abs($scaleX - 1.0) < 0.0001 && abs($scaleY - 1.0) < 0.0001 ) { + return $children; + } + + return $this->scaleVectorChildren($children, $scaleX, $scaleY); + } + + /** + * @param array $children + * @return array + */ + private function scaleVectorChildren(array $children, float $scaleX, float $scaleY): array + { + foreach ( $children as $index => $child ) { + if ( ! is_array($child) ) { + continue; + } + + if ( is_array($child['box'] ?? null) ) { + foreach ( array('x' => $scaleX, 'width' => $scaleX, 'y' => $scaleY, 'height' => $scaleY) as $key => $scale ) { + if ( isset($child['box'][$key]) && is_numeric($child['box'][$key]) ) { + $child['box'][$key] = (float) $child['box'][$key] * $scale; + } + } + } + + if ( is_array($child['figma_box']['transform'] ?? null) ) { + foreach ( array('m00' => $scaleX, 'm02' => $scaleX, 'm11' => $scaleY, 'm12' => $scaleY) as $key => $scale ) { + if ( isset($child['figma_box']['transform'][$key]) && is_numeric($child['figma_box']['transform'][$key]) ) { + $child['figma_box']['transform'][$key] = (float) $child['figma_box']['transform'][$key] * $scale; + } + } + } + + if ( in_array(strtoupper((string) ($child['type'] ?? '')), array('VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE', 'RECTANGLE'), true) ) { + $child['figma_vector_scale'] = array('x' => $scaleX, 'y' => $scaleY); + } + + if ( is_array($child['children'] ?? null) ) { + $child['children'] = $this->scaleVectorChildren($child['children'], $scaleX, $scaleY); + } + + $children[$index] = $child; + } + + return $children; + } + + /** + * @param array $component + */ + private function isVectorOnlyComponent(array $component): bool + { + if ( ! empty($component['layout']) ) { + return false; + } + + $children = is_array($component['children'] ?? null) ? $component['children'] : array(); + if ( empty($children) ) { + return false; + } + + foreach ( $children as $child ) { + if ( ! is_array($child) || ! $this->isVectorOnlyNode($child) ) { + return false; + } + } + + return true; + } + + /** + * @param array $node + */ + private function isVectorOnlyNode(array $node): bool + { + $type = strtoupper((string) ($node['type'] ?? '')); + if ( ! in_array($type, array('VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE', 'RECTANGLE', 'INSTANCE'), true) ) { + return false; + } + + foreach ( is_array($node['children'] ?? null) ? $node['children'] : array() as $child ) { + if ( ! is_array($child) || ! $this->isVectorOnlyNode($child) ) { + return false; + } + } + + return true; + } + + /** + * @param array $children + * @return array + */ + private function namespaceResolvedInstanceChildren(array $children, string $instanceId): array + { + if ( '' === $instanceId ) { + return $children; + } + + foreach ( $children as $index => $child ) { + if ( ! is_array($child) ) { + continue; + } + + $sourceId = (string) ($child['id'] ?? ''); + if ( '' !== $sourceId && ! str_starts_with($sourceId, $instanceId . '/') ) { + $child['figma_component_source_id'] = $sourceId; + $child['id'] = $instanceId . '/' . $sourceId; + } + + if ( is_array($child['children'] ?? null) ) { + $child['children'] = $this->namespaceResolvedInstanceChildren($child['children'], $instanceId); + } + + $children[$index] = $child; + } + + return $children; + } + + /** + * @param array $children + * @param array> $overrides * @return array */ private function applyInstanceOverridesToChildren(array $children, array $overrides): array @@ -457,6 +747,10 @@ private function normalizeText(array $node): array } } + if ( ! isset($text['characters']) && isset($node['textData']['characters']) && is_scalar($node['textData']['characters']) ) { + $text['characters'] = (string) $node['textData']['characters']; + } + $style = array(); if ( is_array($node['style'] ?? null) ) { $style = $this->normalizeTextStyle($node['style']); @@ -473,6 +767,11 @@ private function normalizeText(array $node): array $text['style'] = $style; } + $derivedLayout = $this->normalizeDerivedTextLayout($node); + if ( ! empty($derivedLayout) ) { + $text['derived_layout'] = $derivedLayout; + } + $segments = $this->normalizeStyledTextSegments($node); if ( ! empty($segments) ) { $text['segments'] = $segments; @@ -481,6 +780,83 @@ private function normalizeText(array $node): array return $text; } + /** + * @param array $node + * @return array + */ + private function normalizeDerivedTextLayout(array $node): array + { + $source = is_array($node['derivedTextData'] ?? null) ? $node['derivedTextData'] : array(); + if ( empty($source) ) { + return array(); + } + + $layout = array(); + if ( is_array($source['layoutSize'] ?? null) ) { + $size = array(); + foreach ( array('x' => 'width', 'y' => 'height', 'width' => 'width', 'height' => 'height') as $sourceKey => $targetKey ) { + if ( ! isset($size[$targetKey]) && isset($source['layoutSize'][$sourceKey]) && is_numeric($source['layoutSize'][$sourceKey]) ) { + $size[$targetKey] = (float) $source['layoutSize'][$sourceKey]; + } + } + if ( ! empty($size) ) { + $layout['size'] = $size; + } + } + + if ( is_array($source['baselines'] ?? null) ) { + $layout['baseline_count'] = count($source['baselines']); + $baselines = array(); + foreach ( $source['baselines'] as $baseline ) { + if ( ! is_array($baseline) ) { + continue; + } + $normalized = array(); + foreach ( array('width', 'lineY', 'lineHeight', 'lineAscent', 'firstCharacter', 'endCharacter') as $key ) { + if ( isset($baseline[$key]) && is_numeric($baseline[$key]) ) { + $normalized[$key] = (float) $baseline[$key]; + } + } + if ( is_array($baseline['position'] ?? null) ) { + foreach ( array('x', 'y') as $axis ) { + if ( isset($baseline['position'][$axis]) && is_numeric($baseline['position'][$axis]) ) { + $normalized['position_' . $axis] = (float) $baseline['position'][$axis]; + } + } + } + if ( ! empty($normalized) ) { + $baselines[] = $normalized; + } + } + if ( ! empty($baselines) ) { + $layout['baselines'] = $baselines; + } + } + + if ( is_array($source['glyphs'] ?? null) ) { + $layout['glyph_count'] = count($source['glyphs']); + } + if ( is_array($source['fontMetaData'] ?? null) ) { + $fonts = array(); + foreach ( $source['fontMetaData'] as $font ) { + if ( ! is_array($font) ) { + continue; + } + $fonts[] = array( + 'family' => (string) ($font['key']['family'] ?? ''), + 'style' => (string) ($font['key']['style'] ?? ''), + 'font_weight' => isset($font['fontWeight']) && is_numeric($font['fontWeight']) ? (int) $font['fontWeight'] : null, + 'font_line_height' => isset($font['fontLineHeight']) && is_numeric($font['fontLineHeight']) ? (float) $font['fontLineHeight'] : null, + ); + } + if ( ! empty($fonts) ) { + $layout['fonts'] = $fonts; + } + } + + return $layout; + } + /** * @param array $source * @return array @@ -502,12 +878,42 @@ private function normalizeTextStyle(array $source): array } } + if ( isset($source['fontName']) && is_array($source['fontName']) ) { + if ( isset($source['fontName']['family']) && is_scalar($source['fontName']['family']) ) { + $style['font_family'] = (string) $source['fontName']['family']; + } + if ( isset($source['fontName']['postscript']) && is_scalar($source['fontName']['postscript']) ) { + $style['font_postscript_name'] = (string) $source['fontName']['postscript']; + } + if ( ! isset($style['font_weight']) && isset($source['fontName']['style']) && is_scalar($source['fontName']['style']) ) { + $fontWeight = $this->fontWeightFromStyle((string) $source['fontName']['style']); + if ( null !== $fontWeight ) { + $style['font_weight'] = $fontWeight; + } + } + } + foreach ( array('fontSize' => 'font_size', 'lineHeightPx' => 'line_height_px', 'lineHeightPercent' => 'line_height_percent', 'letterSpacing' => 'letter_spacing') as $sourceKey => $targetKey ) { if ( isset($source[$sourceKey]) && is_numeric($source[$sourceKey]) ) { $style[$targetKey] = (float) $source[$sourceKey]; } } + if ( isset($source['lineHeight']) && is_array($source['lineHeight']) && isset($source['lineHeight']['value']) && is_numeric($source['lineHeight']['value']) ) { + $lineHeightUnits = strtoupper((string) ($source['lineHeight']['units'] ?? '')); + if ( 'PIXELS' === $lineHeightUnits ) { + $style['line_height_px'] = (float) $source['lineHeight']['value']; + } elseif ( 'RAW' === $lineHeightUnits ) { + $style['line_height_raw'] = (float) $source['lineHeight']['value']; + } elseif ( str_contains($lineHeightUnits, 'PERCENT') ) { + $style['line_height_percent'] = (float) $source['lineHeight']['value']; + } + } + + if ( isset($source['letterSpacing']) && is_array($source['letterSpacing']) && isset($source['letterSpacing']['value']) && is_numeric($source['letterSpacing']['value']) ) { + $style['letter_spacing'] = (float) $source['letterSpacing']['value']; + } + foreach ( array('color', 'textColor') as $sourceKey ) { $color = $this->normalizeColor($source[$sourceKey] ?? null); if ( null !== $color ) { @@ -525,6 +931,40 @@ private function normalizeTextStyle(array $source): array return $style; } + private function fontWeightFromStyle(string $style): ?int + { + $style = strtolower(str_replace(array('-', '_'), ' ', $style)); + if ( str_contains($style, 'thin') ) { + return 100; + } + if ( str_contains($style, 'extra light') || str_contains($style, 'ultra light') ) { + return 200; + } + if ( str_contains($style, 'light') ) { + return 300; + } + if ( str_contains($style, 'regular') || str_contains($style, 'normal') ) { + return 400; + } + if ( str_contains($style, 'medium') ) { + return 500; + } + if ( str_contains($style, 'semi bold') || str_contains($style, 'semibold') || str_contains($style, 'demi bold') ) { + return 600; + } + if ( str_contains($style, 'extra bold') || str_contains($style, 'ultra bold') ) { + return 800; + } + if ( str_contains($style, 'bold') ) { + return 700; + } + if ( str_contains($style, 'black') || str_contains($style, 'heavy') ) { + return 900; + } + + return null; + } + /** * @param array $node * @return array> @@ -580,7 +1020,7 @@ private function normalizeStyledTextSegments(array $node): array * @param array> $diagnostics * @return array>> */ - private function normalizePaintCollections(array $node, string $nodeId, array &$diagnostics): array + private function normalizePaintCollections(array $node, string $nodeId, array &$diagnostics, array $paintStyles = array()): array { $collections = array(); foreach ( array('fills' => 'fills', 'fillPaints' => 'fills', 'strokes' => 'strokes', 'strokePaints' => 'strokes', 'background' => 'background') as $sourceKey => $targetKey ) { @@ -588,23 +1028,23 @@ private function normalizePaintCollections(array $node, string $nodeId, array &$ continue; } - $paints = array(); - foreach ( $node[$sourceKey] as $paint ) { - if ( ! is_array($paint) ) { - continue; - } - - $normalized = $this->normalizePaint($paint, $nodeId, $sourceKey, $diagnostics); - if ( ! empty($normalized) ) { - $paints[] = $normalized; - } - } + $paints = $this->normalizePaintList($node[$sourceKey], $nodeId, $sourceKey, $diagnostics); if ( ! empty($paints) ) { $collections[$targetKey] = $paints; } } + $styleFillId = $this->readStyleGuidId($node['styleIdForFill'] ?? null); + if ( null !== $styleFillId && ! empty($paintStyles[$styleFillId]['fills']) ) { + $collections['fills'] = $paintStyles[$styleFillId]['fills']; + } + + $styleStrokeId = $this->readStyleGuidId($node['styleIdForStrokeFill'] ?? $node['styleIdForStroke'] ?? null); + if ( null !== $styleStrokeId && ! empty($paintStyles[$styleStrokeId]['fills']) ) { + $collections['strokes'] = $paintStyles[$styleStrokeId]['fills']; + } + foreach ( array('fill' => 'fills', 'backgroundColor' => 'background') as $sourceKey => $targetKey ) { if ( ! isset($node[$sourceKey]) ) { continue; @@ -619,6 +1059,37 @@ private function normalizePaintCollections(array $node, string $nodeId, array &$ return $collections; } + /** + * @param array $paints + * @param array> $diagnostics + * @return array> + */ + private function normalizePaintList(array $paints, string $nodeId, string $paintKey, array &$diagnostics): array + { + $normalizedPaints = array(); + foreach ( $paints as $paint ) { + if ( ! is_array($paint) ) { + continue; + } + + $normalized = $this->normalizePaint($paint, $nodeId, $paintKey, $diagnostics); + if ( ! empty($normalized) ) { + $normalizedPaints[] = $normalized; + } + } + + return $normalizedPaints; + } + + private function readStyleGuidId(mixed $style): ?string + { + if ( is_array($style) && isset($style['guid']) ) { + return $this->readGuidId($style['guid']); + } + + return $this->readGuidId($style); + } + /** * @param array $paint * @param array> $diagnostics @@ -646,8 +1117,55 @@ private function normalizePaint(array $paint, string $nodeId, string $paintKey, } if ( 'IMAGE' === $type ) { - $ref = $paint['imageRef'] ?? $paint['imageHash'] ?? null; - return is_scalar($ref) && '' !== (string) $ref ? array('type' => 'IMAGE', 'ref' => (string) $ref) : array('type' => 'IMAGE'); + $normalized = array('type' => 'IMAGE'); + $ref = $paint['imageRef'] ?? $paint['imageHash'] ?? $paint['ref'] ?? null; + if ( is_scalar($ref) && '' !== (string) $ref ) { + $normalized['ref'] = $this->normalizeImageHash((string) $ref); + } + + if ( is_array($paint['image'] ?? null) ) { + $imageRef = $this->readNestedImageHash($paint['image']); + if ( null !== $imageRef ) { + $normalized['ref'] = $imageRef; + $normalized['imageHash'] = $imageRef; + } + if ( isset($paint['image']['name']) && is_scalar($paint['image']['name']) ) { + $normalized['imageName'] = (string) $paint['image']['name']; + } + } + + if ( is_array($paint['imageThumbnail'] ?? null) ) { + $thumbnailRef = $this->readNestedImageHash($paint['imageThumbnail']); + if ( null !== $thumbnailRef ) { + $normalized['thumbnailRef'] = $thumbnailRef; + $normalized['thumbnailHash'] = $thumbnailRef; + } + if ( isset($paint['imageThumbnail']['name']) && is_scalar($paint['imageThumbnail']['name']) ) { + $normalized['thumbnailName'] = (string) $paint['imageThumbnail']['name']; + } + } + + foreach ( array('imageScaleMode', 'scaleMode', 'altText') as $key ) { + if ( isset($paint[$key]) && is_scalar($paint[$key]) ) { + $normalized[$key] = (string) $paint[$key]; + } + } + foreach ( array('originalImageWidth', 'originalImageHeight', 'scale', 'rotation', 'opacity') as $key ) { + if ( isset($paint[$key]) && is_numeric($paint[$key]) ) { + $normalized[$key] = (float) $paint[$key]; + } + } + if ( isset($paint['imageShouldColorManage']) && is_bool($paint['imageShouldColorManage']) ) { + $normalized['imageShouldColorManage'] = $paint['imageShouldColorManage']; + } + foreach ( array('transform', 'imageTransform') as $transformKey ) { + if ( is_array($paint[$transformKey] ?? null) ) { + $normalized['transform'] = $paint[$transformKey]; + break; + } + } + + return $normalized; } $diagnostics[] = array( @@ -664,6 +1182,169 @@ private function normalizePaint(array $paint, string $nodeId, string $paintKey, return array(); } + private function readNestedImageHash(array $image): ?string + { + if ( ! isset($image['hash']) || ! is_scalar($image['hash']) || '' === (string) $image['hash'] ) { + return null; + } + + return $this->normalizeImageHash((string) $image['hash']); + } + + private function normalizeImageHash(string $hash): string + { + if ( 1 === preg_match('/^[a-f0-9]{40}$/i', $hash) ) { + return strtolower($hash); + } + + if ( 20 === strlen($hash) ) { + return bin2hex($hash); + } + + return $hash; + } + + /** + * @param array $node + * @param array $blobs + * @param array> $diagnostics + * @return array> + */ + private function normalizeVectorPaths(array $node, array $blobs, string $nodeId, array &$diagnostics): array + { + $paths = array(); + foreach ( array('fillGeometry', 'strokeGeometry') as $geometryKey ) { + if ( ! is_array($node[$geometryKey] ?? null) ) { + continue; + } + + foreach ( $node[$geometryKey] as $geometry ) { + if ( ! is_array($geometry) || ! isset($geometry['commandsBlob']) ) { + continue; + } + + $bytes = $this->readCommandBlobBytes($geometry['commandsBlob'], $blobs); + if ( null === $bytes ) { + continue; + } + + $path = $this->decodeVectorCommandBlob($bytes); + if ( null === $path ) { + $diagnostics[] = array( + 'severity' => 'warning', + 'code' => 'unsupported_vector_command_blob', + 'message' => 'Unsupported Figma vector command blob was omitted from SVG output.', + 'context' => array('node_id' => $nodeId, 'geometry' => $geometryKey), + ); + continue; + } + + $normalized = array('data' => $path, 'source' => $geometryKey); + if ( isset($geometry['windingRule']) && is_scalar($geometry['windingRule']) ) { + $normalized['windingRule'] = (string) $geometry['windingRule']; + } + $paths[] = $normalized; + } + } + + return $paths; + } + + /** + * @param array $blobs + */ + private function readCommandBlobBytes(mixed $commandsBlob, array $blobs): ?string + { + if ( is_array($commandsBlob) && isset($commandsBlob['bytes']) && is_scalar($commandsBlob['bytes']) ) { + return (string) $commandsBlob['bytes']; + } + + if ( is_numeric($commandsBlob) ) { + $blob = $blobs[(int) $commandsBlob] ?? null; + if ( is_array($blob) && isset($blob['bytes']) && is_scalar($blob['bytes']) ) { + return (string) $blob['bytes']; + } + if ( is_scalar($blob) ) { + return (string) $blob; + } + } + + if ( is_string($commandsBlob) ) { + return $commandsBlob; + } + + return null; + } + + private function decodeVectorCommandBlob(string $bytes): ?string + { + $offset = 0; + $length = strlen($bytes); + $parts = array(); + + while ( $offset < $length ) { + $opcode = ord($bytes[$offset]); + $offset++; + + if ( 0 === $opcode ) { + $parts[] = 'Z'; + continue; + } + + if ( 1 === $opcode || 2 === $opcode ) { + $point = $this->readFloatPair($bytes, $offset); + if ( null === $point ) { + return null; + } + $parts[] = ( 1 === $opcode ? 'M ' : 'L ' ) . $this->svgNumber($point[0]) . ' ' . $this->svgNumber($point[1]); + $offset += 8; + continue; + } + + if ( 4 === $opcode ) { + $points = array(); + for ( $i = 0; $i < 3; $i++ ) { + $point = $this->readFloatPair($bytes, $offset + ( $i * 8 )); + if ( null === $point ) { + return null; + } + $points[] = $point; + } + $parts[] = 'C ' . $this->svgNumber($points[0][0]) . ' ' . $this->svgNumber($points[0][1]) . ' ' . $this->svgNumber($points[1][0]) . ' ' . $this->svgNumber($points[1][1]) . ' ' . $this->svgNumber($points[2][0]) . ' ' . $this->svgNumber($points[2][1]); + $offset += 24; + continue; + } + + return null; + } + + return empty($parts) ? null : implode(' ', $parts); + } + + /** + * @return array{0: float, 1: float}|null + */ + private function readFloatPair(string $bytes, int $offset): ?array + { + if ( strlen($bytes) < $offset + 8 ) { + return null; + } + + $x = unpack('g', substr($bytes, $offset, 4)); + $y = unpack('g', substr($bytes, $offset + 4, 4)); + if ( false === $x || false === $y ) { + return null; + } + + return array((float) $x[1], (float) $y[1]); + } + + private function svgNumber(float $value): string + { + $number = rtrim(rtrim(sprintf('%.6F', $value), '0'), '.'); + return '' === $number || '-0' === $number ? '0' : $number; + } + /** * @param array $node * @return array @@ -711,27 +1392,58 @@ private function normalizeVisualBox(array $node): array * @param array $node * @param array> $diagnostics */ - private function diagnoseEffects(array $node, string $nodeId, array &$diagnostics): void + private function normalizeEffects(array $node, string $nodeId, array &$diagnostics): array { if ( ! is_array($node['effects'] ?? null) ) { - return; + return array(); } + $effects = array(); foreach ( $node['effects'] as $effect ) { if ( ! is_array($effect) || false === ($effect['visible'] ?? true) ) { continue; } + $type = strtoupper((string) ($effect['type'] ?? 'UNKNOWN')); + if ( in_array($type, array('DROP_SHADOW', 'INNER_SHADOW'), true) ) { + $normalized = array( + 'type' => 'DROP_SHADOW' === $type ? 'drop_shadow' : 'inner_shadow', + 'offset_x' => is_numeric($effect['offset']['x'] ?? null) ? (float) $effect['offset']['x'] : 0.0, + 'offset_y' => is_numeric($effect['offset']['y'] ?? null) ? (float) $effect['offset']['y'] : 0.0, + 'radius' => is_numeric($effect['radius'] ?? null) ? (float) $effect['radius'] : 0.0, + 'spread' => is_numeric($effect['spread'] ?? null) ? (float) $effect['spread'] : 0.0, + ); + $color = $this->normalizeColor($effect['color'] ?? null); + if ( null !== $color ) { + $normalized['color'] = $color; + } + if ( isset($effect['blendMode']) && is_scalar($effect['blendMode']) ) { + $normalized['blend_mode'] = (string) $effect['blendMode']; + } + $effects[] = $normalized; + continue; + } + + if ( in_array($type, array('LAYER_BLUR', 'BACKGROUND_BLUR'), true) ) { + $effects[] = array( + 'type' => 'LAYER_BLUR' === $type ? 'layer_blur' : 'background_blur', + 'radius' => is_numeric($effect['radius'] ?? null) ? (float) $effect['radius'] : 0.0, + ); + continue; + } + $diagnostics[] = array( 'severity' => 'warning', 'code' => 'unsupported_figma_effect_type', 'message' => 'Unsupported Figma effect was omitted from static CSS.', 'context' => array( 'node_id' => $nodeId, - 'type' => strtoupper((string) ($effect['type'] ?? 'UNKNOWN')), + 'type' => $type, ), ); } + + return $effects; } /** @@ -845,11 +1557,12 @@ private function collectFrameDescendantIds(mixed $children): array */ /** * @param array $node - * @return array + * @return array */ private function normalizeLayoutBox(array $node): array { $box = array(); + $coordinateSpace = null; foreach ( array('absoluteBoundingBox', 'absoluteRenderBounds') as $boundsKey ) { if ( ! is_array($node[$boundsKey] ?? null) ) { @@ -861,11 +1574,18 @@ private function normalizeLayoutBox(array $node): array $box[$dimension] = (float) $node[$boundsKey][$dimension]; } } + + if ( isset($node[$boundsKey]['x']) || isset($node[$boundsKey]['y']) ) { + $coordinateSpace = 'absolute'; + } } foreach ( array('x', 'y', 'width', 'height') as $dimension ) { if ( ! array_key_exists($dimension, $box) && isset($node[$dimension]) && is_numeric($node[$dimension]) ) { $box[$dimension] = (float) $node[$dimension]; + if ( 'x' === $dimension || 'y' === $dimension ) { + $coordinateSpace = 'local'; + } } } @@ -881,10 +1601,15 @@ private function normalizeLayoutBox(array $node): array foreach ( array('m02' => 'x', 'm12' => 'y') as $source => $target ) { if ( ! array_key_exists($target, $box) && isset($node['transform'][$source]) && is_numeric($node['transform'][$source]) ) { $box[$target] = (float) $node['transform'][$source]; + $coordinateSpace = 'local'; } } } + if ( null !== $coordinateSpace ) { + $box['coordinate_space'] = $coordinateSpace; + } + return $box; } @@ -900,6 +1625,17 @@ private function normalizeLayout(array $node): array $mode = strtoupper((string) $node['layoutMode']); $layout['mode'] = $mode; + if ( 'HORIZONTAL' === $mode ) { + $layout['display'] = 'flex'; + $layout['flex_direction'] = 'row'; + } elseif ( 'VERTICAL' === $mode ) { + $layout['display'] = 'flex'; + $layout['flex_direction'] = 'column'; + } + } elseif ( isset($node['stackMode']) && is_scalar($node['stackMode']) ) { + $mode = strtoupper((string) $node['stackMode']); + $layout['mode'] = $mode; + if ( 'HORIZONTAL' === $mode ) { $layout['display'] = 'flex'; $layout['flex_direction'] = 'row'; @@ -932,6 +1668,8 @@ private function normalizeLayout(array $node): array foreach ( array( 'primaryAxisAlignItems' => 'primary_axis_alignment', 'counterAxisAlignItems' => 'counter_axis_alignment', + 'stackPrimaryAlignItems' => 'primary_axis_alignment', + 'stackCounterAlignItems' => 'counter_axis_alignment', ) as $source => $target ) { if ( isset($node[$source]) && is_scalar($node[$source]) ) { $layout[$target] = strtoupper((string) $node[$source]); @@ -952,14 +1690,23 @@ private function normalizeLayout(array $node): array $padding[$edge] = (float) $node[$source]; } } + foreach ( array('left' => 'stackPaddingLeft', 'right' => 'stackPaddingRight', 'top' => 'stackPaddingTop', 'bottom' => 'stackPaddingBottom') as $edge => $source ) { + if ( ! array_key_exists($edge, $padding) && isset($node[$source]) && is_numeric($node[$source]) ) { + $padding[$edge] = (float) $node[$source]; + } + } foreach ( array('left', 'right') as $edge ) { if ( ! array_key_exists($edge, $padding) && isset($node['paddingHorizontal']) && is_numeric($node['paddingHorizontal']) ) { $padding[$edge] = (float) $node['paddingHorizontal']; + } elseif ( ! array_key_exists($edge, $padding) && isset($node['stackHorizontalPadding']) && is_numeric($node['stackHorizontalPadding']) ) { + $padding[$edge] = (float) $node['stackHorizontalPadding']; } } foreach ( array('top', 'bottom') as $edge ) { if ( ! array_key_exists($edge, $padding) && isset($node['paddingVertical']) && is_numeric($node['paddingVertical']) ) { $padding[$edge] = (float) $node['paddingVertical']; + } elseif ( ! array_key_exists($edge, $padding) && isset($node['stackVerticalPadding']) && is_numeric($node['stackVerticalPadding']) ) { + $padding[$edge] = (float) $node['stackVerticalPadding']; } } if ( ! empty($padding) ) { @@ -968,6 +1715,8 @@ private function normalizeLayout(array $node): array if ( isset($node['itemSpacing']) && is_numeric($node['itemSpacing']) ) { $layout['item_spacing'] = (float) $node['itemSpacing']; + } elseif ( isset($node['stackSpacing']) && is_numeric($node['stackSpacing']) ) { + $layout['item_spacing'] = (float) $node['stackSpacing']; } if ( isset($node['layoutWrap']) && is_scalar($node['layoutWrap']) ) { @@ -987,6 +1736,8 @@ private function normalizeLayout(array $node): array if ( isset($node['layoutAlign']) && is_scalar($node['layoutAlign']) ) { $layout['align'] = strtoupper((string) $node['layoutAlign']); + } elseif ( isset($node['stackChildAlignSelf']) && is_scalar($node['stackChildAlignSelf']) ) { + $layout['align'] = strtoupper((string) $node['stackChildAlignSelf']); } if ( true === ($node['clipsContent'] ?? false) ) { @@ -1005,6 +1756,13 @@ private function normalizeLayout(array $node): array } } + if ( ! isset($layout['display']) && is_array($node['children'] ?? null) && count($node['children']) > 1 ) { + $type = strtoupper((string) ($node['type'] ?? '')); + if ( true === ($node['resizeToFit'] ?? false) || in_array($type, array('FRAME', 'GROUP', 'COMPONENT', 'INSTANCE', 'SECTION'), true) ) { + $layout['freeform'] = true; + } + } + return $layout; } @@ -1035,12 +1793,18 @@ private function buildTextInventory(array $nodeMap): array } $text = null; - foreach ( array('characters', 'text', 'name') as $key ) { + foreach ( array('characters', 'text') as $key ) { if ( isset($node[$key]) && is_scalar($node[$key]) ) { $text = (string) $node[$key]; break; } } + if ( null === $text && isset($node['textData']['characters']) && is_scalar($node['textData']['characters']) ) { + $text = (string) $node['textData']['characters']; + } + if ( null === $text && isset($node['name']) && is_scalar($node['name']) ) { + $text = (string) $node['name']; + } $inventory[] = array( 'id' => $id, @@ -1073,11 +1837,16 @@ private function buildAssetReferences(array $nodeMap): array } foreach ( array('fills', 'strokes', 'background') as $paintKey ) { - if ( ! is_array($node[$paintKey] ?? null) ) { - continue; + $paintCollections = array(); + if ( is_array($node[$paintKey] ?? null) ) { + $paintCollections[] = $node[$paintKey]; + } + if ( is_array($node['figma_paints'][$paintKey] ?? null) ) { + $paintCollections[] = $node['figma_paints'][$paintKey]; } - foreach ( $node[$paintKey] as $paint ) { + foreach ( $paintCollections as $paints ) { + foreach ( $paints as $paint ) { if ( ! is_array($paint) || 'IMAGE' !== strtoupper((string) ($paint['type'] ?? '')) ) { continue; } @@ -1092,10 +1861,17 @@ private function buildAssetReferences(array $nodeMap): array ); } } + } } } - return $references; + $unique = array(); + foreach ( $references as $reference ) { + $key = (string) ($reference['node_id'] ?? '') . '|' . (string) ($reference['paint'] ?? '') . '|' . (string) ($reference['ref'] ?? ''); + $unique[$key] = $reference; + } + + return array_values($unique); } /** @@ -1104,7 +1880,7 @@ private function buildAssetReferences(array $nodeMap): array */ private function readImageReference(array $paint): ?array { - foreach ( array('imageRef', 'imageHash', 'asset_id', 'image_ref') as $key ) { + foreach ( array('ref', 'imageRef', 'imageHash', 'asset_id', 'image_ref') as $key ) { if ( isset($paint[$key]) && is_scalar($paint[$key]) && '' !== (string) $paint[$key] ) { return array( 'source_key' => $key, diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 58ec247..0ae32e8 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -10,6 +10,7 @@ use Automattic\BlocksEngine\FigmaTransformer\FigFile\FigKiwiDecoder; use Automattic\BlocksEngine\FigmaTransformer\FigFile\FigKiwiParser; use Automattic\BlocksEngine\FigmaTransformer\Parity\ParityReportBuilder; +use Automattic\BlocksEngine\FigmaTransformer\Parity\VisualAttributionReportBuilder; $failures = array(); @@ -19,8 +20,17 @@ } }; +$imageHash = '0123456789abcdef0123456789abcdef01234567'; +$vectorCommandBlob = chr(1) . pack('g', 0.0) . pack('g', 0.0) + . chr(2) . pack('g', 10.0) . pack('g', 0.0) + . chr(2) . pack('g', 10.0) . pack('g', 10.0) + . chr(0); + $scenegraph = array( 'name' => 'Fixture Site', + 'blobs' => array( + array('bytes' => $vectorCommandBlob), + ), 'assets' => array( 'hero-image' => array( 'name' => 'Hero Image', @@ -30,6 +40,11 @@ 'remote-image' => array( 'url' => 'https://cdn.example.com/remote.png', ), + $imageHash => array( + 'name' => 'Fixture Photo', + 'mime_type' => 'image/jpeg', + 'content' => 'fixture image bytes', + ), ), 'nodes' => array( array( @@ -73,6 +88,32 @@ 'fill' => array('r' => 1, 'g' => 0, 'b' => 0), 'asset_id' => 'hero-image', ), + array( + 'id' => '1:5', + 'type' => 'ROUNDED_RECTANGLE', + 'name' => 'Nested image paint', + 'width' => 160, + 'height' => 90, + 'fillPaints' => array( + array( + 'type' => 'IMAGE', + 'image' => array('hash' => hex2bin($imageHash), 'name' => 'fixture-photo-source'), + ), + ), + ), + array( + 'id' => '1:6', + 'type' => 'VECTOR', + 'name' => 'Blob vector', + 'width' => 10, + 'height' => 10, + 'fillPaints' => array( + array('type' => 'SOLID', 'color' => array('r' => 0, 'g' => 0, 'b' => 0, 'a' => 1)), + ), + 'fillGeometry' => array( + array('commandsBlob' => 0, 'windingRule' => 'NONZERO'), + ), + ), ), ), ), @@ -103,8 +144,8 @@ $assert('blocks-engine/figma-transformer/result/v1' === ($result['schema'] ?? null), 'result-schema'); $assert('success' === ($result['status'] ?? null), 'scenegraph-transform-success'); -$assert(4 === ($result['metrics']['node_count'] ?? null), 'node-count'); -$assert(1 === ($result['metrics']['asset_count'] ?? null), 'asset-count'); +$assert(6 === ($result['metrics']['node_count'] ?? null), 'node-count'); +$assert(2 === ($result['metrics']['asset_count'] ?? null), 'asset-count'); $assert(str_contains($html, 'Hello Figma'), 'html-contains-text'); $assert(str_contains($html, '
(string) ($asset['path'] ?? ''), $result['assets'] ?? array()); +$assert(in_array('assets/hero-image.svg', $assetPaths, true), 'asset-report-path'); $assert(in_array('external_asset_omitted', $diagnosticCodes, true), 'external-asset-diagnostic'); $assert(in_array('scenegraph_node_id_duplicate', $diagnosticCodes, true), 'duplicate-node-diagnostic'); $assert(($result['files'] ?? array()) === ($sameResult['files'] ?? array()), 'deterministic-files'); $assert('blocks-engine/figma-transformer/parity-report/v1' === ($result['parity']['schema'] ?? null), 'parity-schema'); $assert('not_run' === ($result['parity']['status'] ?? null), 'parity-default-not-run'); +$imageScaleResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Image Scale Fixture', + 'assets' => array( + 'fill-image' => array('mime_type' => 'image/png', 'content' => 'fill image'), + 'stretch-image' => array('mime_type' => 'image/png', 'content' => 'stretch image'), + ), + 'nodes' => array( + array( + 'id' => 'scale:fill', + 'type' => 'RECTANGLE', + 'name' => 'Fill image', + 'width' => 100, + 'height' => 80, + 'fillPaints' => array( + array('type' => 'IMAGE', 'imageRef' => 'fill-image', 'imageScaleMode' => 'FILL', 'imageShouldColorManage' => true, 'originalImageWidth' => 200, 'originalImageHeight' => 100), + ), + ), + array( + 'id' => 'scale:stretch', + 'type' => 'RECTANGLE', + 'name' => 'Stretch image', + 'width' => 100, + 'height' => 80, + 'fillPaints' => array( + array('type' => 'IMAGE', 'imageRef' => 'stretch-image', 'imageScaleMode' => 'STRETCH'), + ), + ), + ), +)); +$imageScaleCss = $fileContent($imageScaleResult, 'style.css'); +$assert(str_contains($imageScaleCss, '.figma-node-scale-fill-fill-image{width:100px;height:80px;background-image:url("assets/fill-image.png");background-size:cover;background-position:center}'), 'image-fill-emits-cover-background'); +$assert(str_contains($imageScaleCss, '.figma-node-scale-stretch-stretch-image{width:100px;height:80px;background-image:url("assets/stretch-image.png");background-size:100% 100%;background-repeat:no-repeat;background-position:center}'), 'image-stretch-emits-stretch-background'); +$imageScaleVisualNodes = $imageScaleResult['source_reports']['figma']['html']['visual_node_map'] ?? array(); +$imageScaleFillVisualNode = null; +foreach ( is_array($imageScaleVisualNodes) ? $imageScaleVisualNodes : array() as $visualNode ) { + if ( is_array($visualNode) && 'scale:fill' === ($visualNode['id'] ?? null) ) { + $imageScaleFillVisualNode = $visualNode; + break; + } +} +$assert('FILL' === ($imageScaleFillVisualNode['image']['scale_mode'] ?? null), 'visual-node-image-scale-mode'); +$assert(true === ($imageScaleFillVisualNode['image']['color_managed'] ?? null), 'visual-node-image-color-managed'); +$assert(200.0 === ($imageScaleFillVisualNode['image']['originalImageWidth'] ?? null), 'visual-node-image-original-width'); + +$imageTransformResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Image Transform Fixture', + 'assets' => array( + 'crop-image' => array('mime_type' => 'image/png', 'content' => 'crop image'), + 'fill-crop' => array('mime_type' => 'image/png', 'content' => 'fill image'), + ), + 'nodes' => array( + array( + 'id' => 'image:crop', + 'type' => 'RECTANGLE', + 'name' => 'Cropped image', + 'width' => 100, + 'height' => 80, + 'fillPaints' => array( + array( + 'type' => 'IMAGE', + 'imageRef' => 'crop-image', + 'imageScaleMode' => 'STRETCH', + 'transform' => array( + array(0.5, 0, 0.25), + array(0, 0.8, 0.1), + ), + ), + ), + ), + array( + 'id' => 'image:fill-crop', + 'type' => 'RECTANGLE', + 'name' => 'Fill crop image', + 'width' => 100, + 'height' => 80, + 'fillPaints' => array( + array( + 'type' => 'IMAGE', + 'imageRef' => 'fill-crop', + 'imageScaleMode' => 'FILL', + 'transform' => array( + array(0.5, 0, 0.25), + array(0, 0.8, 0.1), + ), + ), + ), + ), + ), +)); +$imageTransformCss = $fileContent($imageTransformResult, 'style.css'); +$assert(str_contains($imageTransformCss, '.figma-node-image-crop-cropped-image{width:100px;height:80px;background-image:url("assets/crop-image.png");background-size:200px 100px;background-repeat:no-repeat;background-position:-50px -10px}'), 'image-stretch-transform-emits-crop-background'); +$assert(str_contains($imageTransformCss, '.figma-node-image-fill-crop-fill-crop-image{width:100px;height:80px;background-image:url("assets/fill-crop.png");background-size:cover;background-position:center}'), 'image-fill-transform-keeps-cover-background'); + +$multilineTextResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Multiline Text Fixture', + 'nodes' => array( + array( + 'id' => 'text:multiline', + 'type' => 'TEXT', + 'name' => 'Checklist Text', + 'characters' => "One\nTwo\nThree", + 'fontSize' => 16, + ), + ), +)); +$multilineTextCss = $fileContent($multilineTextResult, 'style.css'); +$assert(str_contains($multilineTextCss, '.figma-node-text-multiline-checklist-text{font-size:16px;white-space:pre-line}'), 'multiline-text-preserves-line-breaks'); + +$derivedTextLayoutResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Derived Text Layout Fixture', + 'nodes' => array( + array( + 'id' => 'text:derived-layout', + 'type' => 'TEXT', + 'name' => 'Measured Text', + 'characters' => 'Measured by Figma', + 'width' => 146.5, + 'height' => 32.25, + 'fontName' => array('family' => 'Example Sans', 'style' => 'Regular'), + 'derivedTextData' => array( + 'layoutSize' => array('x' => 146.5, 'y' => 32.25), + 'baselines' => array( + array( + 'position' => array('x' => 0, 'y' => 20), + 'width' => 140, + 'lineY' => 0, + 'lineHeight' => 22, + 'lineAscent' => 17, + 'firstCharacter' => 0, + 'endCharacter' => 17, + ), + ), + 'glyphs' => array( + array('firstCharacter' => 0, 'advance' => 0.5), + array('firstCharacter' => 1, 'advance' => 0.5), + ), + 'fontMetaData' => array( + array( + 'key' => array('family' => 'Example Sans', 'style' => 'Regular'), + 'fontLineHeight' => 1.2, + 'fontWeight' => 400, + ), + ), + ), + ), + ), +)); +$derivedTextVisualNodes = $derivedTextLayoutResult['source_reports']['figma']['html']['visual_node_map'] ?? array(); +$derivedTextVisualNode = null; +foreach ( is_array($derivedTextVisualNodes) ? $derivedTextVisualNodes : array() as $visualNode ) { + if ( is_array($visualNode) && 'text:derived-layout' === ($visualNode['id'] ?? null) ) { + $derivedTextVisualNode = $visualNode; + break; + } +} +$assert(true === ($derivedTextVisualNode['text']['has_derived_layout'] ?? null), 'visual-node-derived-text-layout-present'); +$assert(1 === ($derivedTextVisualNode['text']['baseline_count'] ?? null), 'visual-node-derived-text-baseline-count'); +$assert(2 === ($derivedTextVisualNode['text']['glyph_count'] ?? null), 'visual-node-derived-text-glyph-count'); +$assert(146.5 === ($derivedTextVisualNode['text']['derived_layout']['size']['width'] ?? null), 'visual-node-derived-text-layout-width'); + +$derivedLineBreakResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Derived Line Break Fixture', + 'nodes' => array( + array( + 'id' => 'text:derived-lines', + 'type' => 'TEXT', + 'name' => 'Measured Lines', + 'characters' => 'First line Second line', + 'width' => 120, + 'height' => 44, + 'derivedTextData' => array( + 'layoutSize' => array('x' => 120, 'y' => 44), + 'baselines' => array( + array('firstCharacter' => 0, 'endCharacter' => 10, 'position' => array('x' => 0, 'y' => 16)), + array('firstCharacter' => 11, 'endCharacter' => 22, 'position' => array('x' => 0, 'y' => 38)), + ), + ), + ), + ), +)); +$derivedLineBreakHtml = $fileContent($derivedLineBreakResult, 'index.html'); +$derivedLineBreakCss = $fileContent($derivedLineBreakResult, 'style.css'); +$assert(str_contains($derivedLineBreakHtml, "First line\nSecond line"), 'derived-baselines-insert-line-breaks'); +$assert(str_contains($derivedLineBreakCss, '.figma-node-text-derived-lines-measured-lines{width:120px;height:44px;white-space:pre-line}'), 'derived-baselines-enable-pre-line'); + $parityBuilder = new ParityReportBuilder(); $pendingParity = $parityBuilder->build(array( 'status' => 'pending', @@ -193,6 +424,49 @@ $assert('not_run' === ($notRunParity['status'] ?? null), 'parity-not-run-status'); $assert('pending' === ($unknownParity['status'] ?? null), 'parity-unknown-status-falls-back-to-pending'); +if ( function_exists('imagecreatetruecolor') && function_exists('imagepng') ) { + $sourceImagePath = tempnam(sys_get_temp_dir(), 'figma-source-') . '.png'; + $generatedImagePath = tempnam(sys_get_temp_dir(), 'figma-generated-') . '.png'; + $sourceImage = imagecreatetruecolor(4, 4); + $generatedImage = imagecreatetruecolor(4, 4); + $whiteSource = imagecolorallocate($sourceImage, 255, 255, 255); + $whiteGenerated = imagecolorallocate($generatedImage, 255, 255, 255); + imagefilledrectangle($sourceImage, 0, 0, 3, 3, $whiteSource); + imagefilledrectangle($generatedImage, 0, 0, 3, 3, $whiteGenerated); + imagesetpixel($generatedImage, 1, 1, imagecolorallocate($generatedImage, 0, 0, 0)); + imagepng($sourceImage, $sourceImagePath); + imagepng($generatedImage, $generatedImagePath); + + $visualAttribution = ( new VisualAttributionReportBuilder() )->build( + array( + 'source_reports' => array( + 'figma' => array( + 'html' => array( + 'node_style_diagnostics' => array( + array( + 'node' => array('id' => 'node:1', 'name' => 'Node 1', 'type' => 'RECTANGLE', 'class' => 'figma-node-node-1'), + 'expected' => array('width' => '2px', 'height' => '2px', 'x' => '1px', 'y' => '1px', 'background' => '#ffffff'), + 'emitted' => array('width' => '2px', 'height' => '2px', 'x' => '1px', 'y' => '1px', 'background' => '#ffffff'), + 'mismatches' => array(), + ), + ), + ), + ), + ), + ), + $sourceImagePath, + $generatedImagePath, + array('threshold' => 24, 'limit' => 5) + ); + @unlink($sourceImagePath); + @unlink($generatedImagePath); + + $assert('blocks-engine/figma-transformer/visual-attribution/v1' === ($visualAttribution['schema'] ?? null), 'visual-attribution-schema'); + $assert('success' === ($visualAttribution['status'] ?? null), 'visual-attribution-success'); + $assert(1 === ($visualAttribution['top_nodes'][0]['diff']['mismatch_pixels'] ?? null), 'visual-attribution-node-mismatch-count'); + $assert(array('background', 'positioned') === ($visualAttribution['top_nodes'][0]['features'] ?? null), 'visual-attribution-node-features'); +} + $fixture = blocks_engine_figma_transformer_create_fig_wrapper_fixture(); $fileResult = blocks_engine_figma_transformer_transform_file($fixture); @unlink($fixture); @@ -423,6 +697,197 @@ $assert(false !== strpos($nodeChangesHtml, 'First') && false !== strpos($nodeChangesHtml, 'Second'), 'node-changes-html-text'); $assert(strpos($nodeChangesHtml, 'First') < strpos($nodeChangesHtml, 'Second'), 'node-changes-stable-child-sort'); +$layerOrderResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Layer Order Fixture', + 'NODE_CHANGES' => array( + 'layer:root' => array( + 'node' => array( + 'id' => 'layer:root', + 'type' => 'FRAME', + 'name' => 'Layer root', + 'width' => 300, + 'height' => 200, + 'resizeToFit' => true, + 'children' => array( + array( + 'id' => 'layer:top', + 'type' => 'RECTANGLE', + 'name' => 'Top bubble', + 'width' => 100, + 'height' => 40, + 'parentIndex' => array('position' => 'b'), + ), + array( + 'id' => 'layer:bottom', + 'type' => 'RECTANGLE', + 'name' => 'Bottom image', + 'width' => 200, + 'height' => 120, + 'parentIndex' => array('position' => 'a'), + ), + ), + ), + ), + ), +)); +$layerOrderHtml = $fileContent($layerOrderResult, 'index.html'); +$assert(strpos($layerOrderHtml, 'data-figma-node-id="layer:bottom"') < strpos($layerOrderHtml, 'data-figma-node-id="layer:top"'), 'freeform-layer-order-uses-parent-index-position'); + +$overflowWrapperResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Overflow Wrapper Fixture', + 'nodes' => array( + array( + 'id' => 'overflow:root', + 'type' => 'FRAME', + 'name' => 'Overflow Root', + 'width' => 100, + 'height' => 100, + 'children' => array( + array( + 'id' => 'overflow:wrapper', + 'type' => 'FRAME', + 'name' => 'Overflow Wrapper', + 'width' => 1, + 'height' => 10, + 'children' => array( + array( + 'id' => 'overflow:child', + 'type' => 'RECTANGLE', + 'name' => 'Overflow Child', + 'x' => 4, + 'y' => 2, + 'width' => 20, + 'height' => 12, + ), + ), + ), + ), + ), + ), +)); +$overflowWrapperCss = $fileContent($overflowWrapperResult, 'style.css'); +$assert(str_contains($overflowWrapperCss, '.figma-node-overflow-wrapper-overflow-wrapper{width:1px;height:10px;position:relative}'), 'overflow-wrapper-becomes-positioned-container'); +$assert(str_contains($overflowWrapperCss, '.figma-node-overflow-child-overflow-child{width:20px;height:12px;position:absolute;left:4px;top:2px}'), 'overflow-wrapper-child-keeps-local-position'); + +$objectTransformResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Object Transform Fixture', + 'nodes' => array( + array( + 'id' => 'transform:object', + 'type' => 'RECTANGLE', + 'name' => 'Object transform', + 'width' => 10, + 'height' => 10, + 'transform' => array('m00' => -1, 'm01' => 0, 'm02' => 11, 'm10' => 0, 'm11' => 1, 'm12' => 2), + ), + ), +)); +$objectTransformCss = $fileContent($objectTransformResult, 'style.css'); +$assert(str_contains($objectTransformCss, 'transform:matrix(-1,0,0,1,0,0)'), 'decoded-object-transform-emits-css-matrix'); + +$localTransformPositionResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Local Transform Position Fixture', + 'nodes' => array( + array( + 'id' => 'local:parent', + 'type' => 'FRAME', + 'name' => 'Local parent', + 'transform' => array('m00' => 1, 'm01' => 0, 'm02' => 855, 'm10' => 0, 'm11' => 1, 'm12' => 40), + 'width' => 500, + 'height' => 300, + 'children' => array( + array( + 'id' => 'local:child', + 'type' => 'RECTANGLE', + 'name' => 'Local child', + 'transform' => array('m00' => 1, 'm01' => 0, 'm02' => 115, 'm10' => 0, 'm11' => 1, 'm12' => 10), + 'width' => 100, + 'height' => 80, + ), + array( + 'id' => 'local:sibling', + 'type' => 'RECTANGLE', + 'name' => 'Local sibling', + 'transform' => array('m00' => 1, 'm01' => 0, 'm02' => 240, 'm10' => 0, 'm11' => 1, 'm12' => 30), + 'width' => 20, + 'height' => 20, + ), + ), + ), + ), +)); +$localTransformPositionCss = $fileContent($localTransformPositionResult, 'style.css'); +$assert(str_contains($localTransformPositionCss, '.figma-node-local-child-local-child{width:100px;height:80px;position:absolute;left:115px;top:10px}'), 'decoded-local-transform-position-is-not-parent-subtracted'); + +$fixedFlexResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Fixed Flex Fixture', + 'nodes' => array( + array( + 'id' => 'flex:row', + 'type' => 'FRAME', + 'name' => 'Flex row', + 'width' => 100, + 'height' => 40, + 'layoutMode' => 'HORIZONTAL', + 'children' => array( + array('id' => 'flex:child-a', 'type' => 'RECTANGLE', 'name' => 'Fixed child A', 'width' => 70, 'height' => 40), + array('id' => 'flex:child-b', 'type' => 'RECTANGLE', 'name' => 'Fixed child B', 'width' => 70, 'height' => 40), + ), + ), + ), +)); +$fixedFlexCss = $fileContent($fixedFlexResult, 'style.css'); +$assert(str_contains($fixedFlexCss, '.figma-node-flex-child-a-fixed-child-a{width:70px;height:40px;flex-shrink:0}'), 'fixed-flex-child-does-not-shrink'); + +$stylePaintResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Style Paint Fixture', + 'nodes' => array( + array( + 'guid' => array('sessionID' => 10, 'localID' => 1), + 'type' => 'ROUNDED_RECTANGLE', + 'name' => 'Primary-500', + 'styleType' => 'FILL', + 'fillPaints' => array( + array('type' => 'SOLID', 'color' => array('r' => 0.1, 'g' => 0.8, 'b' => 0.5, 'a' => 1)), + ), + ), + array( + 'id' => 'style:button', + 'type' => 'RECTANGLE', + 'name' => 'Styled button', + 'width' => 100, + 'height' => 40, + 'styleIdForFill' => array('guid' => array('sessionID' => 10, 'localID' => 1)), + 'fillPaints' => array( + array('type' => 'SOLID', 'color' => array('r' => 0.5, 'g' => 0.2, 'b' => 0.1, 'a' => 1)), + ), + ), + ), +)); +$stylePaintCss = $fileContent($stylePaintResult, 'style.css'); +$assert(str_contains($stylePaintCss, '.figma-node-style-button-styled-button{width:100px;height:40px;background:#1acc80}'), 'style-paint-overrides-stale-inline-fill'); + +$outsideStrokeResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Outside Stroke Fixture', + 'nodes' => array( + array( + 'id' => 'stroke:outside', + 'type' => 'RECTANGLE', + 'name' => 'Outside stroke image', + 'width' => 100, + 'height' => 80, + 'strokeAlign' => 'OUTSIDE', + 'strokeWeight'=> 8, + 'strokes' => array( + array('type' => 'SOLID', 'color' => array('r' => 1, 'g' => 1, 'b' => 1, 'a' => 1)), + ), + ), + ), +)); +$outsideStrokeCss = $fileContent($outsideStrokeResult, 'style.css'); +$assert(str_contains($outsideStrokeCss, '.figma-node-stroke-outside-outside-stroke-image{width:100px;height:80px;box-shadow:0 0 0 8px #ffffff}'), 'outside-stroke-emits-non-shrinking-shadow'); +$assert(! str_contains($outsideStrokeCss, 'border:8px solid #ffffff'), 'outside-stroke-does-not-shrink-border-box'); + $metadataResult = blocks_engine_figma_transformer_transform_scenegraph(array( 'name' => 'Text And Paint Metadata', 'nodes' => array( @@ -479,24 +944,73 @@ array('type' => 'GRADIENT_RADIAL'), ), ), + array( + 'id' => '4:4', + 'type' => 'TEXT', + 'name' => 'Raw line height text', + 'characters' => 'Raw line height', + 'fontName' => array('family' => 'Example Sans', 'style' => 'SemiBold'), + 'fontSize' => 18, + 'lineHeight' => array('units' => 'RAW', 'value' => 1.15), + ), ), ), ), )); +$metadataWithFontCssResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Font CSS Fixture', + 'nodes' => array( + array( + 'id' => 'font:1', + 'type' => 'TEXT', + 'name' => 'Font text', + 'characters' => 'Font CSS', + 'style' => array('fontFamily' => 'Example Sans', 'fontSize' => 20), + ), + ), +), array('font_css' => '@font-face{font-family:"Example Sans";src:url("assets/example-sans.woff2") format("woff2")}')); $metadataHtml = $fileContent($metadataResult, 'index.html'); $metadataCss = $fileContent($metadataResult, 'style.css'); +$metadataWithFontCss = $fileContent($metadataWithFontCssResult, 'style.css'); $metadataDiagnosticCodes = array_map( static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), $metadataResult['diagnostics'] ?? array() ); $assert(str_contains($metadataHtml, 'Hello World'), 'styled-text-segments-emit'); -$assert(str_contains($metadataCss, '.figma-node-4-1-metadata-frame{background:rgba(51,102,153,0.5);opacity:0.75;border-radius:12px;border:2px solid #000000}'), 'normalized-frame-paint-box-style'); -$assert(str_contains($metadataCss, '.figma-node-4-2-mixed-text{font-family:"Example Sans";font-size:20px;font-weight:600;line-height:125%;letter-spacing:0.5px;color:rgba(255,128,0,0.8);text-align:center;vertical-align:top;text-decoration:underline}'), 'normalized-text-style'); -$assert(str_contains($metadataCss, '.figma-node-4-3-uneven-radius{border-top-left-radius:4px;border-top-right-radius:8px;border-bottom-right-radius:12px;border-bottom-left-radius:16px}'), 'individual-radius-style'); +$assert(str_contains($metadataCss, 'p,h1,h2,h3,h4,h5,h6{margin:0}'), 'text-elements-reset-default-margins'); +$assert(str_contains($metadataCss, '.figma-node-4-1-metadata-frame{position:relative;background:rgba(51,102,153,0.5);opacity:0.75;border-radius:12px;border:2px solid #000000;box-shadow:0px 0px 0px 0px rgba(0,0,0,0.25)}'), 'normalized-frame-paint-box-style'); +$assert(str_contains($metadataCss, '.figma-node-4-2-mixed-text{position:absolute;font-family:"Example Sans";font-size:20px;font-weight:600;line-height:125%;letter-spacing:0.5px;color:rgba(255,128,0,0.8);text-align:center;vertical-align:top;text-decoration:underline}'), 'normalized-text-style'); +$assert(str_contains($metadataCss, '.figma-node-4-3-uneven-radius{position:absolute;border-top-left-radius:4px;border-top-right-radius:8px;border-bottom-right-radius:12px;border-bottom-left-radius:16px}'), 'individual-radius-style'); +$assert(str_contains($metadataCss, '.figma-node-4-4-raw-line-height-text{position:absolute;font-family:"Example Sans";font-size:18px;font-weight:600;line-height:1.15}'), 'font-style-weight-and-raw-line-height'); $assert(in_array('unsupported_figma_paint_type', $metadataDiagnosticCodes, true), 'unsupported-paint-diagnostic'); -$assert(in_array('unsupported_figma_effect_type', $metadataDiagnosticCodes, true), 'unsupported-effect-diagnostic'); +$assert(! in_array('unsupported_figma_effect_type', $metadataDiagnosticCodes, true), 'supported-effect-no-diagnostic'); +$assert(in_array('font_css_missing_for_source_font', $metadataDiagnosticCodes, true), 'missing-font-css-diagnostic'); +$assert(str_starts_with($metadataWithFontCss, '@font-face{font-family:"Example Sans";src:url("assets/example-sans.woff2") format("woff2")}'), 'font-css-prepended-when-supplied'); +$assert(array('Example Sans') === ($metadataWithFontCssResult['source_reports']['figma']['html']['font_families'] ?? null), 'font-family-inventory-reports-source-fonts'); +$assert(true === ($metadataWithFontCssResult['source_reports']['figma']['html']['font_css_supplied'] ?? null), 'font-css-supplied-report'); +$styleDiagnostics = $metadataResult['source_reports']['figma']['html']['node_style_diagnostics'] ?? array(); +$mixedTextStyleDiagnostic = null; +$frameStyleDiagnostic = null; +foreach ( $styleDiagnostics as $styleDiagnostic ) { + if ( '4:2' === ($styleDiagnostic['node']['id'] ?? null) ) { + $mixedTextStyleDiagnostic = $styleDiagnostic; + } + if ( '4:1' === ($styleDiagnostic['node']['id'] ?? null) ) { + $frameStyleDiagnostic = $styleDiagnostic; + } +} +$assert(null !== $mixedTextStyleDiagnostic, 'node-style-diagnostics-text-node-present'); +$assert('"Example Sans"' === ($mixedTextStyleDiagnostic['expected']['font_family'] ?? null), 'node-style-diagnostics-expected-font-family'); +$assert('"Example Sans"' === ($mixedTextStyleDiagnostic['emitted']['font_family'] ?? null), 'node-style-diagnostics-emitted-font-family'); +$assert('20px' === ($mixedTextStyleDiagnostic['expected']['font_size'] ?? null), 'node-style-diagnostics-expected-font-size'); +$assert('20px' === ($mixedTextStyleDiagnostic['emitted']['font_size'] ?? null), 'node-style-diagnostics-emitted-font-size'); +$assert('rgba(255,128,0,0.8)' === ($mixedTextStyleDiagnostic['expected']['text_color'] ?? null), 'node-style-diagnostics-expected-text-color'); +$assert('rgba(255,128,0,0.8)' === ($mixedTextStyleDiagnostic['emitted']['text_color'] ?? null), 'node-style-diagnostics-emitted-text-color'); +$assert(null !== $frameStyleDiagnostic, 'node-style-diagnostics-frame-node-present'); +$assert('rgba(51,102,153,0.5)' === ($frameStyleDiagnostic['expected']['background'] ?? null), 'node-style-diagnostics-expected-background'); +$assert('rgba(51,102,153,0.5)' === ($frameStyleDiagnostic['emitted']['background'] ?? null), 'node-style-diagnostics-emitted-background'); $assetReferenceResult = blocks_engine_figma_transformer_transform_scenegraph(array( 'name' => 'Asset Reference Fixture', @@ -562,7 +1076,7 @@ ); $assert(1 === ($assetReferenceResult['metrics']['asset_reference_count'] ?? null), 'normalized-image-reference-count'); -$assert('imageHash' === ($assetReferenceReport['asset_references'][0]['source_key'] ?? null), 'normalized-image-reference-source-key'); +$assert(in_array((string) ($assetReferenceReport['asset_references'][0]['source_key'] ?? null), array('imageHash', 'ref'), true), 'normalized-image-reference-source-key'); $assert('image-hash-1' === ($assetReferenceReport['asset_references'][0]['ref'] ?? null), 'normalized-image-reference-ref'); $assert(str_contains($assetReferenceCss, 'background-image:url("assets/archive-image.png")'), 'normalized-image-reference-css'); $assert(str_contains($assetReferenceHtml, 'data-figma-vector="true"'), 'supported-vector-svg-html'); @@ -643,13 +1157,44 @@ $layoutFidelityCss = $fileContent($layoutFidelityResult, 'style.css'); $assert(str_contains($layoutFidelityCss, '.figma-node-5-1-layout-frame{width:500px;height:300px;overflow:hidden;position:relative;display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch}'), 'layout-frame-clips-and-positions-absolute-children'); -$assert(str_contains($layoutFidelityCss, '.figma-node-5-2-fixed-card{width:100px;height:80px;opacity:0.6;transform:rotate(15deg)}'), 'layout-fixed-sizing-and-rotation'); -$assert(str_contains($layoutFidelityCss, '.figma-node-5-3-hug-label{width:fit-content;height:fit-content;font-size:12px}'), 'layout-hug-sizing'); -$assert(str_contains($layoutFidelityCss, '.figma-node-5-4-fill-panel{width:100%;height:100%;flex-grow:1;flex-shrink:1;align-self:stretch;order:2;z-index:2}'), 'layout-fill-sizing-and-order'); -$assert(str_contains($layoutFidelityCss, '.figma-node-5-5-absolute-badge{width:50px;height:20px;position:absolute;left:20px;right:430px;top:20px;bottom:260px;background:#000000;order:3;z-index:3}'), 'layout-absolute-constraints-and-z-index'); -$assert(str_contains($layoutFidelityCss, '.figma-node-5-6-matrix-transform{width:30px;height:30px;transform:matrix(0,1,-1,0,40,60)}'), 'layout-relative-transform-matrix'); +$assert(str_contains($layoutFidelityCss, '.figma-node-5-2-fixed-card{width:100px;height:80px;opacity:0.6;transform:rotate(15deg);flex-shrink:0}'), 'layout-fixed-sizing-and-rotation'); +$assert(str_contains($layoutFidelityCss, '.figma-node-5-3-hug-label{width:fit-content;height:fit-content;font-size:12px;flex-shrink:0}'), 'layout-hug-sizing'); +$assert(str_contains($layoutFidelityCss, '.figma-node-5-4-fill-panel{width:100%;height:100%;flex-grow:1;flex-shrink:1;align-self:stretch}'), 'layout-fill-sizing-without-source-order'); +$assert(str_contains($layoutFidelityCss, '.figma-node-5-5-absolute-badge{width:50px;height:20px;position:absolute;left:20px;right:430px;top:20px;bottom:260px;background:#000000;flex-shrink:0}'), 'layout-absolute-constraints-without-source-z-index'); +$assert(str_contains($layoutFidelityCss, '.figma-node-5-6-matrix-transform{width:30px;height:30px;transform:matrix(0,1,-1,0,40,60);flex-shrink:0}'), 'layout-relative-transform-matrix'); $assert(! str_contains($layoutFidelityCss, 'font-family:Inter') && ! str_contains($layoutFidelityCss, 'body{margin:0;background') && ! str_contains($layoutFidelityCss, 'body{margin:0;color'), 'layout-css-avoids-theme-defaults'); +$plainFrameLayoutResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Plain Frame Layout Fixture', + 'nodes' => array( + array( + 'id' => 'plain:frame', + 'type' => 'FRAME', + 'name' => 'Plain layout frame', + 'absoluteBoundingBox' => array('x' => 100, 'y' => 50, 'width' => 400, 'height' => 300), + 'children' => array( + array( + 'id' => 'plain:first', + 'type' => 'RECTANGLE', + 'name' => 'First positioned layer', + 'absoluteBoundingBox' => array('x' => 120, 'y' => 70, 'width' => 90, 'height' => 40), + ), + array( + 'id' => 'plain:second', + 'type' => 'TEXT', + 'name' => 'Second positioned text', + 'characters' => 'Positioned', + 'absoluteBoundingBox' => array('x' => 300, 'y' => 200, 'width' => 120, 'height' => 32), + ), + ), + ), + ), +)); +$plainFrameLayoutCss = $fileContent($plainFrameLayoutResult, 'style.css'); +$assert(str_contains($plainFrameLayoutCss, '.figma-node-plain-frame-plain-layout-frame{width:400px;height:300px;position:relative}'), 'plain-frame-becomes-freeform-positioned-canvas'); +$assert(str_contains($plainFrameLayoutCss, '.figma-node-plain-first-first-positioned-layer{width:90px;height:40px;position:absolute;left:20px;top:20px'), 'plain-frame-first-child-positioned-relative-to-parent'); +$assert(str_contains($plainFrameLayoutCss, '.figma-node-plain-second-second-positioned-text{width:120px;height:32px;position:absolute;left:200px;top:150px'), 'plain-frame-text-child-positioned-relative-to-parent'); + $resolvedInstanceResult = blocks_engine_figma_transformer_transform_scenegraph(array( 'name' => 'Component Instance Fixture', 'nodes' => array( @@ -719,6 +1264,310 @@ $assert(str_contains($unresolvedInstanceHtml, 'data-figma-node-id="instance:missing"'), 'unresolved-instance-preserves-instance-id'); $assert(in_array('figma_instance_component_unresolved', $unresolvedInstanceDiagnosticCodes, true), 'unresolved-instance-diagnostic'); +$booleanVectorResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Boolean Vector Fixture', + 'blobs' => array(array('bytes' => $vectorCommandBlob)), + 'nodes' => array( + array( + 'id' => 'boolean:1', + 'type' => 'BOOLEAN_OPERATION', + 'name' => 'Compound icon', + 'width' => 10, + 'height' => 10, + 'fillPaints' => array(array('type' => 'SOLID', 'color' => array('r' => 0, 'g' => 0, 'b' => 0))), + 'fillGeometry' => array(array('commandsBlob' => 0, 'windingRule' => 'NONZERO')), + 'children' => array( + array( + 'id' => 'boolean:2', + 'type' => 'VECTOR', + 'name' => 'Operand path', + 'width' => 10, + 'height' => 10, + 'vectorData' => array('vectorNetworkBlob' => 0), + ), + ), + ), + ), +)); +$booleanVectorHtml = $fileContent($booleanVectorResult, 'index.html'); +$booleanVectorDiagnosticCodes = array_map( + static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), + $booleanVectorResult['diagnostics'] ?? array() +); +$assert(str_contains($booleanVectorHtml, 'data-figma-vector="true"'), 'boolean-vector-parent-svg'); +$assert(! str_contains($booleanVectorHtml, 'data-figma-node-id="boolean:2"'), 'boolean-vector-suppresses-operand-child'); +$assert(! in_array('unsupported_vector_node_placeholder', $booleanVectorDiagnosticCodes, true), 'boolean-vector-no-placeholder-diagnostic'); + +$instanceVectorChildrenResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Instance Vector Children Fixture', + 'blobs' => array(array('bytes' => $vectorCommandBlob)), + 'nodes' => array( + array( + 'id' => 'icon:component', + 'type' => 'COMPONENT', + 'name' => 'Icon component', + 'key' => 'icon-key', + 'children' => array( + array( + 'id' => 'icon:vector', + 'type' => 'VECTOR', + 'name' => 'Vector', + 'width' => 10, + 'height' => 10, + 'fillGeometry' => array(array('commandsBlob' => 0, 'windingRule' => 'NONZERO')), + ), + ), + ), + array( + 'id' => 'icon:one', + 'type' => 'INSTANCE', + 'name' => 'Icon one', + 'componentId' => 'icon-key', + ), + array( + 'id' => 'icon:two', + 'type' => 'INSTANCE', + 'name' => 'Icon two', + 'componentId' => 'icon-key', + ), + ), +)); +$instanceVectorChildrenHtml = $fileContent($instanceVectorChildrenResult, 'index.html'); +$instanceVectorChildrenCss = $fileContent($instanceVectorChildrenResult, 'style.css'); +$assert(str_contains($instanceVectorChildrenHtml, 'data-figma-node-id="icon:one/icon:vector"'), 'instance-vector-child-id-namespaced-one'); +$assert(str_contains($instanceVectorChildrenHtml, 'data-figma-node-id="icon:two/icon:vector"'), 'instance-vector-child-id-namespaced-two'); +$assert(strpos($instanceVectorChildrenHtml, 'data-figma-node-id="icon:one/icon:vector"') !== strpos($instanceVectorChildrenHtml, 'data-figma-node-id="icon:vector"'), 'instance-vector-child-source-id-is-not-reused-by-instance-one'); +$assert(strpos($instanceVectorChildrenHtml, 'data-figma-node-id="icon:two/icon:vector"') !== strpos($instanceVectorChildrenHtml, 'data-figma-node-id="icon:vector"'), 'instance-vector-child-source-id-is-not-reused-by-instance-two'); +$assert(3 === substr_count($instanceVectorChildrenHtml, 'data-figma-vector="true"'), 'instance-vector-children-render-with-definition'); +$assert(str_contains($instanceVectorChildrenCss, '.figma-node-icon-one-icon-vector-vector{width:10px;height:10px'), 'instance-vector-css-one'); +$assert(str_contains($instanceVectorChildrenCss, '.figma-node-icon-two-icon-vector-vector{width:10px;height:10px'), 'instance-vector-css-two'); + +$nestedInstanceVectorResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Nested Instance Vector Fixture', + 'blobs' => array(array('bytes' => $vectorCommandBlob)), + 'nodes' => array( + array( + 'id' => 'nested-icon:component', + 'type' => 'COMPONENT', + 'name' => 'Icon component', + 'key' => 'nested-icon-key', + 'children' => array( + array( + 'id' => 'nested-icon:vector', + 'type' => 'VECTOR', + 'name' => 'Vector', + 'width' => 10, + 'height' => 10, + 'fillGeometry' => array(array('commandsBlob' => 0, 'windingRule' => 'NONZERO')), + ), + ), + ), + array( + 'id' => 'nested-wrapper:component', + 'type' => 'COMPONENT', + 'name' => 'Wrapper component', + 'key' => 'nested-wrapper-key', + 'children' => array( + array( + 'id' => 'nested-wrapper:icon', + 'type' => 'INSTANCE', + 'name' => 'Nested icon', + 'componentId' => 'nested-icon-key', + ), + ), + ), + array( + 'id' => 'nested-wrapper:instance', + 'type' => 'INSTANCE', + 'name' => 'Wrapper instance', + 'componentId' => 'nested-wrapper-key', + ), + ), +)); +$nestedInstanceVectorHtml = $fileContent($nestedInstanceVectorResult, 'index.html'); +$assert(str_contains($nestedInstanceVectorHtml, 'data-figma-node-id="nested-wrapper:instance/nested-wrapper:icon/nested-icon:vector"'), 'nested-instance-vector-child-id-namespaced'); +$assert(str_contains($nestedInstanceVectorHtml, 'data-figma-vector="true"'), 'nested-instance-vector-renders-svg'); + +$scaledVectorInstanceResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Scaled Vector Instance Fixture', + 'blobs' => array(array('bytes' => $vectorCommandBlob)), + 'nodes' => array( + array( + 'id' => 'scaled-icon:component', + 'type' => 'COMPONENT', + 'name' => 'Scaled icon component', + 'key' => 'scaled-icon-key', + 'width' => 10, + 'height' => 10, + 'children' => array( + array( + 'id' => 'scaled-icon:vector', + 'type' => 'VECTOR', + 'name' => 'Vector', + 'width' => 10, + 'height' => 10, + 'fillGeometry' => array(array('commandsBlob' => 0, 'windingRule' => 'NONZERO')), + ), + ), + ), + array( + 'id' => 'scaled-icon:instance', + 'type' => 'INSTANCE', + 'name' => 'Scaled icon instance', + 'componentId' => 'scaled-icon-key', + 'width' => 20, + 'height' => 20, + ), + ), +)); +$scaledVectorInstanceHtml = $fileContent($scaledVectorInstanceResult, 'index.html'); +$scaledVectorInstanceCss = $fileContent($scaledVectorInstanceResult, 'style.css'); +$assert(str_contains($scaledVectorInstanceCss, '.figma-node-scaled-icon-instance-scaled-icon-vector-vector{width:20px;height:20px'), 'scaled-vector-instance-child-css-scaled'); +$assert(str_contains($scaledVectorInstanceHtml, ''), 'scaled-vector-instance-svg-transform'); + +$effectsResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Effects Fixture', + 'nodes' => array( + array( + 'id' => 'effects:1', + 'type' => 'FRAME', + 'name' => 'Effects frame', + 'width' => 100, + 'height' => 100, + 'effects' => array( + array( + 'type' => 'DROP_SHADOW', + 'offset' => array('x' => 0, 'y' => 6), + 'radius' => 6, + 'spread' => 0, + 'color' => array('r' => 0, 'g' => 0, 'b' => 0, 'a' => 0.16), + ), + array( + 'type' => 'INNER_SHADOW', + 'offset' => array('x' => 1, 'y' => 2), + 'radius' => 3, + 'spread' => 4, + 'color' => array('r' => 1, 'g' => 0, 'b' => 0, 'a' => 0.5), + ), + array('type' => 'LAYER_BLUR', 'radius' => 2), + array('type' => 'BACKGROUND_BLUR', 'radius' => 5), + ), + 'children' => array( + array( + 'id' => 'effects:2', + 'type' => 'TEXT', + 'name' => 'Shadow text', + 'characters' => 'Shadow', + 'effects' => array( + array( + 'type' => 'DROP_SHADOW', + 'offset' => array('x' => 1, 'y' => 1), + 'radius' => 2, + 'color' => array('r' => 0, 'g' => 0, 'b' => 0, 'a' => 0.4), + ), + ), + ), + ), + ), + ), +)); +$effectsCss = $fileContent($effectsResult, 'style.css'); +$effectsDiagnosticCodes = array_map( + static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), + $effectsResult['diagnostics'] ?? array() +); +$assert(str_contains($effectsCss, 'box-shadow:0px 6px 6px 0px rgba(0,0,0,0.16),inset 1px 2px 3px 4px rgba(255,0,0,0.5)'), 'effects-box-shadow-css'); +$assert(str_contains($effectsCss, 'filter:blur(2px)'), 'effects-layer-blur-css'); +$assert(str_contains($effectsCss, 'backdrop-filter:blur(5px)'), 'effects-background-blur-css'); +$assert(str_contains($effectsCss, 'text-shadow:1px 1px 2px 0px rgba(0,0,0,0.4)'), 'effects-text-shadow-css'); +$assert(! in_array('unsupported_figma_effect_type', $effectsDiagnosticCodes, true), 'effects-supported-no-diagnostic'); + +$symbolInstanceResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Symbol Instance Fixture', + 'nodes' => array( + array( + 'guid' => array('sessionID' => 18, 'localID' => 96), + 'type' => 'SYMBOL', + 'name' => 'Legacy Symbol Button', + 'children' => array( + array( + 'guid' => array('sessionID' => 18, 'localID' => 97), + 'type' => 'TEXT', + 'name' => 'Symbol label', + 'characters' => 'Symbol label', + ), + ), + ), + array( + 'id' => 'instance:symbol-button', + 'type' => 'INSTANCE', + 'name' => 'Legacy Symbol Instance', + 'symbolData' => array('symbolID' => array('sessionID' => 18, 'localID' => 96)), + ), + ), +)); +$symbolInstanceHtml = $fileContent($symbolInstanceResult, 'index.html'); +$symbolInstanceReport = $symbolInstanceResult['source_reports']['figma']['scenegraph'] ?? array(); +$symbolInstanceDiagnosticCodes = array_map( + static fn (array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), + $symbolInstanceResult['diagnostics'] ?? array() +); +$assert(1 === ($symbolInstanceReport['component_definition_count'] ?? null), 'symbol-instance-definition-count'); +$assert(1 === ($symbolInstanceReport['instance_node_count'] ?? null), 'symbol-instance-instance-count'); +$assert(1 === ($symbolInstanceReport['resolved_instance_count'] ?? null), 'symbol-instance-resolved-count'); +$assert(str_contains($symbolInstanceHtml, 'data-figma-node-id="instance:symbol-button"'), 'symbol-instance-preserves-instance-id'); +$assert(str_contains($symbolInstanceHtml, 'Symbol label'), 'symbol-instance-renders-symbol-children'); +$assert(! in_array('figma_instance_component_unresolved', $symbolInstanceDiagnosticCodes, true), 'symbol-instance-no-unresolved-diagnostic'); + +$nestedSymbolInstanceResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Nested Symbol Instance Fixture', + 'nodes' => array( + array( + 'guid' => array('sessionID' => 30, 'localID' => 1), + 'type' => 'SYMBOL', + 'name' => 'Nested Button Symbol', + 'fillPaints' => array(array('type' => 'SOLID', 'color' => array('r' => 0, 'g' => 1, 'b' => 0))), + 'children' => array( + array( + 'guid' => array('sessionID' => 30, 'localID' => 2), + 'type' => 'TEXT', + 'name' => 'Nested Button Label', + 'characters' => 'Default nested label', + ), + ), + ), + array( + 'id' => 'nested:root', + 'type' => 'FRAME', + 'name' => 'Nested root', + 'children' => array( + array( + 'id' => 'nested:instance', + 'type' => 'INSTANCE', + 'name' => 'Nested Button Instance', + 'fillPaints' => array(array('type' => 'SOLID', 'color' => array('r' => 1, 'g' => 0, 'b' => 0))), + 'symbolData' => array( + 'symbolID' => array('sessionID' => 30, 'localID' => 1), + 'symbolOverrides' => array( + array( + 'guidPath' => array('guids' => array(array('sessionID' => 30, 'localID' => 2))), + 'textData' => array('characters' => 'Nested override label'), + ), + ), + ), + ), + ), + ), + ), +)); +$nestedSymbolInstanceHtml = $fileContent($nestedSymbolInstanceResult, 'index.html'); +$nestedSymbolInstanceCss = $fileContent($nestedSymbolInstanceResult, 'style.css'); +$nestedSymbolInstanceReport = $nestedSymbolInstanceResult['source_reports']['figma']['scenegraph'] ?? array(); +$assert(1 === ($nestedSymbolInstanceReport['resolved_instance_count'] ?? null), 'nested-symbol-instance-resolved-count'); +$assert(str_contains($nestedSymbolInstanceHtml, 'data-figma-node-id="nested:instance"'), 'nested-symbol-instance-preserves-instance-id'); +$assert(str_contains($nestedSymbolInstanceHtml, 'Nested override label'), 'nested-symbol-instance-applies-symbol-text-override'); +$assert(str_contains($nestedSymbolInstanceCss, '.figma-node-nested-instance-nested-button-instance{background:#ff0000}'), 'nested-symbol-instance-keeps-instance-fill'); + if ( ! empty($failures) ) { fwrite(STDERR, "Figma Transformer contract failures:\n- " . implode("\n- ", $failures) . "\n"); exit(1); diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 2b2fa2b..7136f33 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -37,9 +37,6 @@ public function compile(array $artifact): TransformerResult $entryBlocks = $this->compileEntryBlocks($html, $entryPath, $normalized['files']); $diagnostics = array_merge($diagnostics, $entryBlocks['diagnostics']); $serializedBlocks = $entryBlocks['serialized_blocks']; - if ( '' === $serializedBlocks && '' !== trim($html) ) { - $serializedBlocks = '' . "\n" . $html . "\n" . ''; - } if ( '' === $serializedBlocks && ! empty($documents['documents'][0]['block_markup']) ) { $serializedBlocks = (string) $documents['documents'][0]['block_markup']; } @@ -137,7 +134,7 @@ private function compileEntryBlocks(string $html, string $entryPath, array $file ); } - if ( '' === trim($html) || ! $this->entryHtmlReferencesImageAsset($html, $entryPath, $files) ) { + if ( '' === trim($html) ) { return array( 'blocks' => array(), 'serialized_blocks' => '', @@ -491,6 +488,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d $title = $this->titleFromHtml((string) ($file['content'] ?? ''), $path); $slug = $this->slugFromPath($path); $blockMarkup = $path === $entryPath ? $serializedBlocks : $this->htmlDocumentBlockMarkup((string) ($file['content'] ?? '')); + $bodyFormat = '' !== trim($blockMarkup) ? 'blocks' : 'html'; $pages[] = array_filter( array( 'source_path' => $path, @@ -499,9 +497,9 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d 'entrypoint' => $path === $entryPath || ! empty($file['entrypoint']), 'slug' => $slug, 'title' => $title, - 'metadata' => $this->documentMetadata($path, 'html', (string) ($file['role'] ?? 'document'), $slug, $title, 'html'), + 'metadata' => $this->documentMetadata($path, 'html', (string) ($file['role'] ?? 'document'), $slug, $title, $bodyFormat), 'html' => $file['content'] ?? '', - 'body_format' => 'html', + 'body_format' => $bodyFormat, 'block_markup' => $blockMarkup, 'bytes' => $file['bytes'] ?? 0, 'mime_type' => $file['mime_type'] ?? 'text/html', @@ -577,7 +575,12 @@ private function htmlDocumentBlockMarkup(string $html): string return $html; } - return '' . "\n" . $html . "\n" . ''; + $result = ( new HtmlTransformer() )->transform($html, array( + 'source' => 'html-document', + 'source_scope' => 'artifact-document', + ))->toArray(); + + return isset($result['serialized_blocks']) && is_scalar($result['serialized_blocks']) ? trim((string) $result['serialized_blocks']) : ''; } /** diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 5883b01..91de7c6 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -68,7 +68,7 @@ public function transform(string $html, array $options = array()): TransformerRe ), $this->fallbackProvenance), ); - $normalizedHtml = $this->normalizeHtml5VoidElements($html); + $normalizedHtml = $this->normalizeHtml5VoidElements($this->documentBodyHtml($html)); $document = new DOMDocument(); $previous = libxml_use_internal_errors(true); $loaded = $document->loadHTML('' . $normalizedHtml . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); @@ -219,6 +219,30 @@ private function normalizeHtml5VoidElements(string $html): string return preg_replace('/]*?)(?/i', '', $html) ?? $html; } + private function documentBodyHtml(string $html): string + { + if ( ! preg_match('/<(?:!doctype|html|head|body)\b/i', $html) ) { + return $html; + } + + $document = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $loaded = $document->loadHTML('' . $html); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + if ( ! $loaded ) { + return $html; + } + + $body = $document->getElementsByTagName('body')->item(0); + if ( ! $body instanceof DOMElement ) { + return $html; + } + + return $this->innerHtml($body); + } + /** * @param array> $fallbacks * @param array{strict: bool, allow_fallbacks: bool} $context @@ -424,7 +448,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca } if ( 'button' === $tagName ) { - return $this->createBlock('core/buttons', array(), array( $this->createBlock('core/button', array_merge($this->presentationAttributes($element), array( 'text' => $this->innerHtml($element) )), array(), $element) ), $element); + return $this->createBlock('core/buttons', array(), array( $this->createBlock('core/button', array_merge($this->presentationAttributes($element), array( 'text' => $this->buttonText($element) )), array(), $element) ), $element); } if ( 'svg' === $tagName ) { @@ -2056,11 +2080,16 @@ private function mergeClassNames(string ...$classNames): string private function buttonBlockFromAnchor(DOMElement $anchor): array { return $this->createBlock('core/button', array_filter(array_merge($this->presentationAttributes($anchor), array( - 'text' => $this->innerHtml($anchor), + 'text' => $this->buttonText($anchor), 'url' => $this->attr($anchor, 'href'), )), static fn ($value): bool => is_array($value) ? array() !== $value : '' !== $value), array(), $anchor); } + private function buttonText(DOMElement $element): string + { + return trim(preg_replace('/\s+/', ' ', (string) ($element->textContent ?? '')) ?? ''); + } + /** * @return array */ diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index ba9e98c..20df925 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -117,6 +117,15 @@ function serialize_blocks(array $blocks): string $assert('support' === ($formDiagnostic['controls'][1]['options'][0]['value'] ?? ''), 'conversion report exposes select option values'); $assert(is_int($formDiagnostic['html_bytes'] ?? null), 'conversion report exposes bounded fallback HTML byte size'); +$buttonResult = ( new HtmlTransformer() )->transform( + '

Reserve now

' +)->toArray(); +$buttonBlocks = $buttonResult['blocks'][0]['innerBlocks'] ?? array(); +$assert('core/buttons' === ($buttonBlocks[0]['blockName'] ?? ''), 'anchor converts to buttons block'); +$assert('Reserve now' === ($buttonBlocks[0]['innerBlocks'][0]['attrs']['text'] ?? ''), 'anchor button text strips nested markup'); +$assert('Call us' === ($buttonBlocks[1]['innerBlocks'][0]['attrs']['text'] ?? ''), 'button text strips nested markup'); +$assert(! str_contains((string) $buttonResult['serialized_blocks'], '\\u003c'), 'button serialization avoids escaped nested HTML attrs'); + $assetMetadataOptions = array( 'context' => array( 'asset_metadata' => array( @@ -154,12 +163,12 @@ function serialize_blocks(array $blocks): string $assert(ArtifactCompiler::INPUT_SCHEMA === ($simple['source_reports']['artifact']['schema'] ?? ''), 'artifact report exposes canonical site artifact schema'); $assert(ArtifactCompiler::INPUT_SCHEMA === ($simple['source_reports']['artifact']['original_schema'] ?? ''), 'canonical site artifact input schema is accepted and preserved'); $assert('index.html' === ($simple['source_reports']['artifact']['entry_path'] ?? ''), 'generated HTML becomes an index entry'); -$assert(str_contains((string) $simple['serialized_blocks'], ''), 'HTML is preserved as serialized block markup'); +$assert(str_contains((string) $simple['serialized_blocks'], ''), 'HTML is converted to serialized block markup'); $assert('hero' === ($simple['components'][0]['name'] ?? ''), 'component candidates are exposed'); $assert(! array_key_exists('legacy_mapping', $simple), 'artifact result omits compatibility-only legacy mapping'); $assert(strlen('

Hello artifact

') === ($simple['metrics']['input_bytes'] ?? null), 'artifact metrics expose input bytes'); $assert(strlen((string) $simple['serialized_blocks']) === ($simple['metrics']['output_bytes'] ?? null), 'artifact metrics expose output bytes'); -$assert(0 === ($simple['metrics']['block_count'] ?? null), 'artifact metrics expose block count'); +$assert(2 === ($simple['metrics']['block_count'] ?? null), 'artifact metrics expose block count'); $assert(0 === ($simple['metrics']['fallback_count'] ?? null), 'artifact metrics expose fallback count'); $assert(0 === ($simple['metrics']['diagnostic_count'] ?? null), 'artifact metrics expose diagnostic count'); $assert(is_float($simple['metrics']['transform_duration_ms'] ?? null), 'artifact metrics expose transform duration'); @@ -167,6 +176,7 @@ function serialize_blocks(array $blocks): string $assert('index.html' === ($simple['source_reports']['materialization_plan']['entry_path'] ?? ''), 'materialization plan exposes entry path'); $assert(1 === ($simple['source_reports']['materialization_plan']['totals']['pages'] ?? null), 'materialization plan counts pages'); $assert('index' === ($simple['source_reports']['materialization_plan']['pages'][0]['slug'] ?? ''), 'materialization plan exposes page slug'); +$assert('blocks' === ($simple['source_reports']['materialization_plan']['pages'][0]['body_format'] ?? ''), 'materialization plan exposes converted block body format'); $missingMaterializationPlan = $simple; unset($missingMaterializationPlan['source_reports']['materialization_plan']); From b34c9ea61bec5122105a64cc79cc26e68694206a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 23 Jun 2026 08:49:50 -0400 Subject: [PATCH 25/31] Allow larger website artifact images --- .../src/ArtifactCompiler/ArtifactNormalizer.php | 4 ++-- php-transformer/tests/contract/run.php | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php index edc5e2e..99c4e14 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php @@ -11,8 +11,8 @@ final class ArtifactNormalizer { public const DEFAULT_MAX_FILES = 500; - public const DEFAULT_MAX_FILE_BYTES = 1048576; - public const DEFAULT_MAX_TOTAL_BYTES = 10485760; + public const DEFAULT_MAX_FILE_BYTES = 5242880; + public const DEFAULT_MAX_TOTAL_BYTES = 52428800; /** * @param array $artifact diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 20df925..0a09325 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -5,6 +5,7 @@ use Automattic\BlocksEngine\PhpTransformer\Contract\TransformerResult; use Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\ArtifactCompiler; +use Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\ArtifactNormalizer; use Automattic\BlocksEngine\PhpTransformer\FormatBridge\FormatAdapterInterface; use Automattic\BlocksEngine\PhpTransformer\FormatBridge\FormatBridge; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer; @@ -462,11 +463,11 @@ function serialize_blocks(array $blocks): string $tooLarge = $compiler->compile( array( - 'files' => array( - 'index.html' => '
OK
', - 'huge.txt' => str_repeat('x', 1048577), - ), - ) + 'files' => array( + 'index.html' => '
OK
', + 'huge.txt' => str_repeat('x', ArtifactNormalizer::DEFAULT_MAX_FILE_BYTES + 1), + ), + ) )->toArray(); $assert('success_with_warnings' === $tooLarge['status'], 'oversized files are rejected with a warning status'); $assert(1 === ($tooLarge['source_reports']['artifact']['rejected_count'] ?? null), 'oversized file increments rejected count'); From 608c4c748a10fbfbf4a9041228224d62308c1167 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 23 Jun 2026 09:24:47 -0400 Subject: [PATCH 26/31] Preserve Figma graphic wrappers --- .../src/HtmlToBlocks/HtmlTransformer.php | 14 +++++++++++--- php-transformer/tests/contract/run.php | 8 ++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 91de7c6..29cb055 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -522,7 +522,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca $children = $this->convertChildren($element, $fallbacks, true); if ( 1 === count($children) ) { - if ( $this->shouldPreserveWrapper($element) && 'core/group' !== ($children[0]['blockName'] ?? '') ) { + if ( $this->shouldPreserveWrapper($element) ) { return $this->createBlock('core/group', $this->presentationAttributes($element), $children, $element); } return $children[0]; @@ -764,11 +764,19 @@ private function shouldPreserveWrapper(DOMElement $element): bool private function shouldPreserveEmptyVisualElement(DOMElement $element): bool { - if ( '' !== trim($element->textContent ?? '') || 0 !== $this->childElementCount($element) ) { + if ( '' !== trim($element->textContent ?? '') ) { + return false; + } + + if ( $this->shouldPreserveWrapper($element) ) { + return true; + } + + if ( 0 !== $this->childElementCount($element) ) { return false; } - return $this->shouldPreserveWrapper($element) || in_array(strtolower($this->attr($element, 'role')), array( 'presentation', 'none' ), true) || 'true' === strtolower($this->attr($element, 'aria-hidden')); + return in_array(strtolower($this->attr($element, 'role')), array( 'presentation', 'none' ), true) || 'true' === strtolower($this->attr($element, 'aria-hidden')); } private function isInlineContentElement(string $tagName): bool diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 0a09325..eee47b5 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -127,6 +127,14 @@ function serialize_blocks(array $blocks): string $assert('Call us' === ($buttonBlocks[1]['innerBlocks'][0]['attrs']['text'] ?? ''), 'button text strips nested markup'); $assert(! str_contains((string) $buttonResult['serialized_blocks'], '\\u003c'), 'button serialization avoids escaped nested HTML attrs'); +$figmaGraphicGroup = ( new HtmlTransformer() )->transform( + '
' +)->toArray(); +$serializedFigmaGraphicGroup = (string) ($figmaGraphicGroup['serialized_blocks'] ?? ''); +$assert(str_contains($serializedFigmaGraphicGroup, 'figma-node-21-448-group-32'), 'HTML transform preserves Figma graphic group wrapper classes'); +$assert(str_contains($serializedFigmaGraphicGroup, 'figma-node-21-449-map'), 'HTML transform preserves nested Figma graphic group classes'); +$assert(str_contains($serializedFigmaGraphicGroup, 'figma-node-21-450-map-image'), 'HTML transform preserves background-image Figma leaf classes'); + $assetMetadataOptions = array( 'context' => array( 'asset_metadata' => array( From 065d3b8f81c2f5d38e5021d6d97650b996146b9c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 23 Jun 2026 10:17:29 -0400 Subject: [PATCH 27/31] Keep HTML wrapper contract generic --- php-transformer/tests/contract/run.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index eee47b5..5f15139 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -127,13 +127,13 @@ function serialize_blocks(array $blocks): string $assert('Call us' === ($buttonBlocks[1]['innerBlocks'][0]['attrs']['text'] ?? ''), 'button text strips nested markup'); $assert(! str_contains((string) $buttonResult['serialized_blocks'], '\\u003c'), 'button serialization avoids escaped nested HTML attrs'); -$figmaGraphicGroup = ( new HtmlTransformer() )->transform( - '
' +$inlineSvgVisualWrapper = ( new HtmlTransformer() )->transform( + '
' )->toArray(); -$serializedFigmaGraphicGroup = (string) ($figmaGraphicGroup['serialized_blocks'] ?? ''); -$assert(str_contains($serializedFigmaGraphicGroup, 'figma-node-21-448-group-32'), 'HTML transform preserves Figma graphic group wrapper classes'); -$assert(str_contains($serializedFigmaGraphicGroup, 'figma-node-21-449-map'), 'HTML transform preserves nested Figma graphic group classes'); -$assert(str_contains($serializedFigmaGraphicGroup, 'figma-node-21-450-map-image'), 'HTML transform preserves background-image Figma leaf classes'); +$serializedInlineSvgVisualWrapper = (string) ($inlineSvgVisualWrapper['serialized_blocks'] ?? ''); +$assert(str_contains($serializedInlineSvgVisualWrapper, 'visual-region'), 'HTML transform preserves CSS-addressable visual wrapper classes'); +$assert(str_contains($serializedInlineSvgVisualWrapper, 'map-layer'), 'HTML transform preserves nested visual wrapper classes'); +$assert(str_contains($serializedInlineSvgVisualWrapper, 'map-image'), 'HTML transform preserves background-image visual leaf classes when inline SVG children are unsupported'); $assetMetadataOptions = array( 'context' => array( From cd902b49e07ee7f56c2462618a877c6bfa0174d1 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 23 Jun 2026 10:38:03 -0400 Subject: [PATCH 28/31] Preserve safe inline SVG icons --- .../src/HtmlToBlocks/HtmlTransformer.php | 68 ++++++++++++++++++- php-transformer/tests/contract/run.php | 20 +++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 29cb055..3f10e59 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -130,7 +130,7 @@ public function transform(string $html, array $options = array()): TransformerRe $diagnostics = array( array( 'code' => 'html_to_blocks_core_slice', - 'message' => 'Converted supported core text, layout, media, gallery, embed, file, table, button, shortcode, spacer, definition-list, details, navigation, and wrapper elements; unsupported elements are reported as fallbacks.', + 'message' => 'Converted supported core text, layout, media, gallery, embed, file, table, button, shortcode, spacer, definition-list, details, navigation, safe inline SVG images, and wrapper elements; unsupported elements are reported as fallbacks.', 'source' => self::class, ), ); @@ -319,6 +319,17 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( $this->isInlineContentElement($tagName) ) { $content = $this->outerHtml($element); if ( '' === trim($this->runtime->stripAllTags($content)) ) { + $children = $this->convertChildren($element, $fallbacks, true); + if ( 1 === count($children) ) { + if ( array() !== $this->presentationAttributes($element) ) { + return $this->createBlock('core/group', $this->presentationAttributes($element), $children, $element); + } + return $children[0]; + } + if ( array() !== $children ) { + return $this->createBlock('core/group', $this->presentationAttributes($element), $children, $element); + } + return null; } @@ -452,6 +463,11 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca } if ( 'svg' === $tagName ) { + $svgBlock = $this->inlineSvgBlockFromElement($element); + if ( null !== $svgBlock ) { + return $svgBlock; + } + $this->captureInlineSvgFallback($element, $fallbacks); return null; } @@ -1217,6 +1233,56 @@ private function safeFallbackHtml(DOMElement $element): string return trim($html); } + /** + * @return array|null + */ + private function inlineSvgBlockFromElement(DOMElement $element): ?array + { + if ( ! $this->isSafeSvgContent($this->outerHtml($element)) ) { + return null; + } + + $html = $this->safeFallbackHtml($element); + if ( ! $this->isSafeSvgContent($html) ) { + return null; + } + + $attrs = array_filter(array_merge($this->presentationAttributes($element), array( + 'url' => 'data:image/svg+xml,' . rawurlencode($html), + 'alt' => $this->inlineSvgAltText($element), + 'title' => $this->inlineSvgTitleText($element), + 'width' => $this->attr($element, 'width'), + 'height' => $this->attr($element, 'height'), + )), static fn ($value): bool => '' !== $value); + + return $this->createBlock('core/image', $attrs, array(), $element); + } + + private function inlineSvgAltText(DOMElement $element): string + { + if ( 'true' === strtolower($this->attr($element, 'aria-hidden')) || 'presentation' === strtolower($this->attr($element, 'role')) ) { + return ''; + } + + $ariaLabel = trim($this->attr($element, 'aria-label')); + if ( '' !== $ariaLabel ) { + return $ariaLabel; + } + + return $this->inlineSvgTitleText($element); + } + + private function inlineSvgTitleText(DOMElement $element): string + { + foreach ( $element->childNodes as $child ) { + if ( $child instanceof DOMElement && 'title' === strtolower($child->tagName) ) { + return trim($child->textContent ?? ''); + } + } + + return ''; + } + /** * @param array> $fallbacks */ diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 5f15139..c5aa328 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -133,7 +133,25 @@ function serialize_blocks(array $blocks): string $serializedInlineSvgVisualWrapper = (string) ($inlineSvgVisualWrapper['serialized_blocks'] ?? ''); $assert(str_contains($serializedInlineSvgVisualWrapper, 'visual-region'), 'HTML transform preserves CSS-addressable visual wrapper classes'); $assert(str_contains($serializedInlineSvgVisualWrapper, 'map-layer'), 'HTML transform preserves nested visual wrapper classes'); -$assert(str_contains($serializedInlineSvgVisualWrapper, 'map-image'), 'HTML transform preserves background-image visual leaf classes when inline SVG children are unsupported'); +$assert(str_contains($serializedInlineSvgVisualWrapper, 'map-image'), 'HTML transform preserves background-image visual leaf classes when inline SVG children are present'); + +$safeInlineSvg = ( new HtmlTransformer() )->transform( + '
', + array( + 'strict' => true, + 'allow_fallbacks' => false, + ) +)->toArray(); +$safeInlineSvgSerialized = (string) ($safeInlineSvg['serialized_blocks'] ?? ''); +$assert('success' === ($safeInlineSvg['status'] ?? ''), 'safe inline SVG does not trip strict fallback gates', (string) ($safeInlineSvg['status'] ?? '')); +$assert(array() === ($safeInlineSvg['fallbacks'] ?? array()), 'safe inline SVG is converted instead of recorded as fallback metadata'); +$assert('core/image' === ($safeInlineSvg['blocks'][0]['innerBlocks'][0]['innerBlocks'][0]['blockName'] ?? ''), 'safe inline SVG converts to a Gutenberg-renderable image block'); +$assert(! str_contains($safeInlineSvgSerialized, '" }, @@ -82,7 +82,7 @@ { "path": "source_reports.compiled_site.theme.images.0", "assert": "equals", "value": "public/assets/hero.svg" }, { "path": "source_reports.compiled_site.theme.template_parts.0", "assert": "equals", "value": "public/parts/header.html" }, { "path": "source_reports.compiled_site.template_parts.0.area", "assert": "equals", "value": "header" }, - { "path": "source_reports.compiled_site.template_parts.0.block_markup", "assert": "contains", "value": "
" }, + { "path": "source_reports.compiled_site.template_parts.0.block_markup", "assert": "contains", "value": "