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..86cb173 --- /dev/null +++ b/docs/contracts/figma-transformer-result.md @@ -0,0 +1,58 @@ +# 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. + +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` +- `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 + +## 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 new file mode 100644 index 0000000..2bab93b --- /dev/null +++ b/figma-transformer/README.md @@ -0,0 +1,217 @@ +# 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. + +## 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: + +- `.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. + +### 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. + +### Zstandard Support + +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 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`: + +```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. 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: + +```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, 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 + +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. diff --git a/figma-transformer/bin/figma-transformer b/figma-transformer/bin/figma-transformer new file mode 100755 index 0000000..0ffedae --- /dev/null +++ b/figma-transformer/bin/figma-transformer @@ -0,0 +1,159 @@ +#!/usr/bin/env php + [--zstd-command=] [--frame-id=] [--font-css=] [--font-css-file=] [--render-text-glyph-paths] [--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; + } + + $parts = explode('=', substr($argument, 2), 2); + $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; + 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 ( 'render-text-glyph-paths' === $name ) { + $options['render_text_glyph_paths'] = true; + 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 = 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(); +} else { + $result = $transformer->transformFile($path, $options)->toArray(); +} + +fwrite(STDOUT, blocks_engine_figma_transformer_cli_json_encode($result) . "\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); +} + +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/bin/figma-visual-attribution b/figma-transformer/bin/figma-visual-attribution new file mode 100644 index 0000000..9ab8f4a --- /dev/null +++ b/figma-transformer/bin/figma-visual-attribution @@ -0,0 +1,73 @@ +#!/usr/bin/env php + '', + 'source' => '', + 'generated' => '', + 'threshold' => 24, + 'material_threshold' => 96, + 'severe_threshold' => 192, + 'rank_by' => 'material_delta_score', + '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'], + 'material_threshold' => (int) $options['material_threshold'], + 'severe_threshold' => (int) $options['severe_threshold'], + 'rank_by' => (string) $options['rank_by'], + '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] [--material-threshold=96] [--severe-threshold=192] [--rank-by=material_delta_score] [--limit=25]\n"); +} diff --git a/figma-transformer/composer.json b/figma-transformer/composer.json new file mode 100644 index 0000000..3f03fd9 --- /dev/null +++ b/figma-transformer/composer.json @@ -0,0 +1,60 @@ +{ + "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" + }, + "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-visual-attribution" + ], + "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/Compression/ZstdCapability.php b/figma-transformer/src/Compression/ZstdCapability.php new file mode 100644 index 0000000..77b753e --- /dev/null +++ b/figma-transformer/src/Compression/ZstdCapability.php @@ -0,0 +1,246 @@ +>}|false|null + */ + public function __construct( + private readonly mixed $decoder = null + ) { + } + + public function isAvailable(): bool + { + $status = $this->status(); + return true === $status['available']; + } + + /** + * @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' => 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, + ); + } + + /** + * @return array{data: string|null, diagnostics: array>} + */ + public function uncompress(string $payload, string $source, int $chunkIndex): array + { + $status = $this->status(); + + if ( 'ext-zstd' === $status['provider'] ) { + return $this->uncompressWithNativeExtension($payload, $source, $chunkIndex); + } + + $decoder = $this->decoder(); + if ( null === $decoder ) { + return array( + 'data' => null, + 'diagnostics' => array($this->diagnostic($source, $chunkIndex)), + ); + } + + try { + $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_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(), + ), + $status + ), + ), + ), + ); + } + + $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' => $decoded, + 'diagnostics' => $diagnostics, + ); + } + + return array( + '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), + ), + ), + ); + } + + /** + * @return array{data: string|null, diagnostics: array>} + */ + private function uncompressWithNativeExtension(string $payload, string $source, int $chunkIndex): array + { + 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, + '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($this->diagnostic($source, $chunkIndex)), + ); + } + + /** + * @return array + */ + public function diagnostic(string $source, int $chunkIndex): array + { + $status = $this->status(); + + if ( 'ext-zstd' === $status['provider'] ) { + return array( + 'code' => 'figma_transformer_zstd_available', + 'message' => 'Zstandard chunk detected and ext-zstd is available.', + 'source' => $source, + 'context' => array_merge(array('chunk_index' => $chunkIndex), $status), + ); + } + + 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', + 'message' => 'Zstandard chunk detected; ext-zstd is loaded but zstd_uncompress is unavailable.', + 'source' => $source, + 'context' => array_merge(array('chunk_index' => $chunkIndex), $status), + ); + } + + return array( + 'code' => 'figma_transformer_zstd_extension_missing', + '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/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/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..2c2c2db --- /dev/null +++ b/figma-transformer/src/FigFile/FigArchiveReader.php @@ -0,0 +1,242 @@ + + */ + 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); + $canvasResult = $this->readCanvas($zip); + $canvas = $canvasResult['canvas']; + $zip->close(); + + $diagnostics = $canvasResult['diagnostics']; + if ( null === $canvas ) { + $diagnostics[] = $this->diagnostic('figma_transformer_missing_canvas', 'Archive does not contain canvas.fig.', 'FigArchiveReader'); + } + + 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); + $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, $contentString), + 'content' => $contentString, + ); + } + + return $assets; + } + + private function mimeTypeForPath(string $path, string $content = ''): string + { + $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), '|null, diagnostics: array>} + */ + private function readCanvas(ZipArchive $zip): array + { + $raw = $zip->getFromName('canvas.fig'); + if ( false === $raw ) { + return array( + 'canvas' => null, + 'diagnostics' => array(), + ); + } + + return $this->figKiwiParser->parse($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/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 new file mode 100644 index 0000000..ef060ac --- /dev/null +++ b/figma-transformer/src/FigFile/FigKiwiParser.php @@ -0,0 +1,371 @@ +|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(); + $kiwiSchema = null; + $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)); + $chunk['payload'] = $this->classifyPayload($inflated, $kiwiSchema, $diagnostics); + } + } elseif ( 'zstd' === $chunk['compression'] ) { + $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'], $kiwiSchema, $diagnostics); + } + } else { + $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)); + } + + $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'; + } + + /** + * @return array + */ + private function classifyPayload(string $payload, ?array &$kiwiSchema, array &$diagnostics): 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 ) { + if ( null === $kiwiSchema ) { + $schemaResult = $this->kiwiDecoder->decodeSchema($payload); + if ( null !== $schemaResult['schema'] && ! empty($schemaResult['schema']['definitions']) ) { + $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 || true === $wire['truncated'] || null !== $wire['reason'] ) { + $metadata['wire'] = $wire; + } + + 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; + } + + /** + * @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. + * + * @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); + return '' !== $trimmed && ('{' === $trimmed[0] || '[' === $trimmed[0]); + } + + /** + * @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/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php new file mode 100644 index 0000000..48941f0 --- /dev/null +++ b/figma-transformer/src/FigmaTransformer.php @@ -0,0 +1,361 @@ + $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'], + 'assets' => $archive['assets'], + ), + ); + + $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', + )); + + $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) ) { + $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( + array_merge($sourceReports['figma'], array('decoded_scenegraph' => $scenegraphCandidate['report'])), + is_array($scenegraphSourceReports) ? $scenegraphSourceReports : array() + ), + ), + $scenegraphResult['parity'] ?? $parity, + 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( + $this->fallbackStatus($archive), + $diagnostics, + array(), + $archive['assets'], + $sourceReports, + $parity, + $metrics + ); + } + + /** + * @param array $archive + * @return array|null + */ + 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; + } + + $payload = $chunk['payload'] ?? array(); + if ( ! is_array($payload) || 'json' !== ($payload['classification'] ?? null) || ! is_array($payload['json'] ?? null) ) { + continue; + } + + $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, + ), + ); + } + } + + 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) ) { + $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', + ), + ); + } + } + } + + 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; + } + + /** + * @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; + } + + /** + * @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. + * + * @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); + $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'], + $diagnostics, + $artifact['files'], + $artifact['assets'], + array( + 'figma' => array( + 'scenegraph' => $normalized['source_report'], + 'html' => $artifact['source_report'], + ), + ), + $parity, + array( + '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 new file mode 100644 index 0000000..432fdff --- /dev/null +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -0,0 +1,2121 @@ +> + */ + private array $assetsById = array(); + + private bool $renderTextGlyphPaths = false; + + /** + * @param array $scenegraph Normalized Figma scenegraph. + * @param array $options Transformation options. + * @return array + */ + public function emit(array $scenegraph, array $options = array()): array + { + $this->renderTextGlyphPaths = true === ($options['render_text_glyph_paths'] ?? false); + $title = $this->sanitizeText((string) ($scenegraph['name'] ?? 'Figma Site')); + $nodes = $this->nodeList($scenegraph); + $diagnostics = array(); + $nodeStyleDiagnostics = array(); + $assetFiles = $this->normalizeAssets($scenegraph['assets'] ?? array(), $diagnostics); + + $body = ''; + $cssRules = 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}', + ); + if ( $this->renderTextGlyphPaths ) { + $cssRules[] = '.figma-text-glyphs{display:block;width:100%;height:100%;overflow:visible}'; + } + $fontCss = $this->fontCss($options); + + foreach ( $nodes as $node ) { + if ( ! is_array($node) ) { + continue; + } + $body .= $this->emitNode($node, $cssRules, $diagnostics, $nodeStyleDiagnostics, 0, null); + } + + $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' => ('' !== $fontCss ? $fontCss . "\n" : '') . implode("\n", $cssRules) . "\n", + ), + ); + + foreach ( $assetFiles as $assetFile ) { + $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, + '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, + 'render_text_glyph_paths' => $this->renderTextGlyphPaths, + ), + 'metrics' => array( + 'node_count' => $this->countNodes($nodes), + 'asset_count' => count($assetFiles), + ), + ); + } + + /** + * @param array $node + * @param array $cssRules + * @param array> $diagnostics + */ + 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'] ?? ''); + $attributeName = $this->sanitizeAttribute($name); + $type = strtoupper((string) ($node['type'] ?? 'FRAME')); + $text = 'TEXT' === $type ? ( $this->textGlyphSvg($node) ?? $this->textContent($node) ) : $this->textContent($node); + $tag = $this->tagName($type, $name, $depth); + $className = 'figma-node-' . $this->slug($id . '-' . $name); + $children = $this->nodeList($node); + $content = $text; + $vectorSvg = $this->supportedVectorSvg($node, $type); + + if ( ! ( 'BOOLEAN_OPERATION' === $type && null !== $vectorSvg ) ) { + foreach ( $children as $child ) { + if ( is_array($child) ) { + $content .= $this->emitNode($child, $cssRules, $diagnostics, $nodeStyleDiagnostics, $depth + 1, $node); + } + } + } + + if ( null !== $vectorSvg ) { + $content = $vectorSvg . $content; + } + + if ( $this->isUnsupportedVectorType($type) && null === $vectorSvg ) { + $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) . ''; + } + } + + $styles = $this->styleDeclarations($node, $type, $parentNode); + 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 ) { + $attributes .= ' aria-hidden="true"'; + } + if ( $this->isUnsupportedVectorType($type) && null === $vectorSvg ) { + $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); + } + + 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 $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 + */ + 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 ) { + $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'; + } + } + + if ( true === ($layout['clips_content'] ?? false) ) { + $styles[] = 'overflow:hidden'; + } + + $willPositionAbsolute = (null !== $parentNode && $this->isFreeformContainer($parentNode)) || 'absolute' === ($layout['positioning'] ?? null); + if ( ! $willPositionAbsolute && ($this->hasAbsoluteChild($node) || $this->isFreeformContainer($node)) ) { + $styles[] = 'position:relative'; + } + + 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', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE'), true) ) { + $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']); + } + + $transform = $this->transformStyle($box); + if ( null !== $transform ) { + $styles[] = 'transform:' . $transform; + if ( $this->hasExplicitTransformMatrix($box) ) { + $styles[] = 'transform-origin:0 0'; + } + } + + foreach ( $this->radiusStyles($box) as $style ) { + $styles[] = $style; + } + + foreach ( $this->strokeStyles($node) as $style ) { + $styles[] = $style; + } + + $assetPath = $this->nodeAssetPath($node); + if ( null !== $assetPath ) { + $styles[] = 'background-image:url("' . $assetPath . '")'; + foreach ( $this->imageBackgroundStyles($node) as $style ) { + $styles[] = $style; + } + } + + if ( 'TEXT' === $type ) { + foreach ( $this->textStyles($node) as $style ) { + $styles[] = $style; + } + } + + foreach ( $this->effectStyles($node, $type) as $style ) { + $styles[] = $style; + } + + 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'; + } + + foreach ( $this->flexItemStyles($layout, $parentNode) 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->positionOffset($box, $parentBox, 'x'); + $top = $this->positionOffset($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 + * @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 ( true === ($node['figma_component']['resolved'] ?? false) && ! empty($children) && empty($node['layout']['display'] ?? null) ) { + return true; + } + + 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 + */ + 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 $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 + */ + 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 $box + */ + private function hasExplicitTransformMatrix(array $box): bool + { + return isset($box['transform']) && is_array($box['transform']); + } + + /** + * @param array $transform + */ + private function cssMatrix(array $transform): ?string + { + 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; + } + + 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 $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'; + } + + 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($this->derivedLineBreakText((string) $text['characters'], $text)); + } + + return $this->sanitizeText((string) ($node['characters'] ?? $node['text'] ?? '')); + } + + /** + * @param array $node + */ + private function textGlyphSvg(array $node): ?string + { + if ( ! $this->renderTextGlyphPaths ) { + return null; + } + + $text = is_array($node['figma_text'] ?? null) ? $node['figma_text'] : array(); + $derivedLayout = is_array($text['derived_layout'] ?? null) ? $text['derived_layout'] : array(); + $glyphPaths = is_array($derivedLayout['glyph_paths'] ?? null) ? $derivedLayout['glyph_paths'] : array(); + if ( empty($glyphPaths) ) { + return null; + } + + $label = isset($text['characters']) && is_scalar($text['characters']) ? (string) $text['characters'] : (string) ($node['characters'] ?? $node['text'] ?? ''); + if ( ! $this->textAllowsGlyphRendering($label, $text) ) { + return null; + } + + $size = is_array($derivedLayout['size'] ?? null) ? $derivedLayout['size'] : array(); + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $width = isset($size['width']) && is_numeric($size['width']) ? (float) $size['width'] : ( isset($box['width']) && is_numeric($box['width']) ? (float) $box['width'] : 0.0 ); + $height = isset($size['height']) && is_numeric($size['height']) ? (float) $size['height'] : ( isset($box['height']) && is_numeric($box['height']) ? (float) $box['height'] : 0.0 ); + if ( 0.0 >= $width || 0.0 >= $height ) { + return null; + } + + $paths = ''; + $cursors = array(); + foreach ( $glyphPaths as $glyphPath ) { + if ( ! is_array($glyphPath) ) { + continue; + } + + $fontSize = isset($glyphPath['fontSize']) && is_numeric($glyphPath['fontSize']) ? (float) $glyphPath['fontSize'] : $this->textGlyphFallbackFontSize($text); + $baseline = $this->textGlyphBaseline($glyphPath, $derivedLayout); + $baselineKey = (string) $baseline['index']; + if ( ! isset($cursors[$baselineKey]) ) { + $cursors[$baselineKey] = $baseline['x']; + } + $x = isset($glyphPath['position_x']) && is_numeric($glyphPath['position_x']) ? (float) $glyphPath['position_x'] : ( isset($glyphPath['x']) && is_numeric($glyphPath['x']) ? (float) $glyphPath['x'] : (float) $cursors[$baselineKey] ); + $y = isset($glyphPath['position_y']) && is_numeric($glyphPath['position_y']) ? (float) $glyphPath['position_y'] : ( isset($glyphPath['y']) && is_numeric($glyphPath['y']) ? (float) $glyphPath['y'] : $baseline['y'] ); + $transform = 'translate(' . $this->number($x) . ' ' . $this->number($y) . ')'; + if ( 0.0 < $fontSize ) { + $transform .= ' scale(' . $this->number($fontSize) . ' -' . $this->number($fontSize) . ')'; + } + if ( isset($glyphPath['advance']) && is_numeric($glyphPath['advance']) ) { + $cursors[$baselineKey] += (float) $glyphPath['advance'] * ( 0.0 < $fontSize ? $fontSize : 1.0 ); + } + if ( ! isset($glyphPath['data']) || ! is_scalar($glyphPath['data']) ) { + continue; + } + if ( isset($glyphPath['character']) && is_scalar($glyphPath['character']) && '' !== (string) $glyphPath['character'] && ctype_space((string) $glyphPath['character']) ) { + continue; + } + + $attributes = ' d="' . $this->sanitizeAttribute((string) $glyphPath['data']) . '" fill="currentColor" transform="' . $transform . '"'; + $paths .= '
'; + } + + if ( '' === $paths ) { + return null; + } + + return '' . $paths . ''; + } + + /** + * @param array $text + */ + private function textAllowsGlyphRendering(string $characters, array $text): bool + { + if ( $this->textNeedsDomSymbolFallback($characters) ) { + return false; + } + + if ( mb_strlen($characters) > 80 ) { + return false; + } + + if ( mb_strlen($characters) > 45 && 1 === preg_match('/[.!?。!?]$/u', trim($characters)) ) { + return false; + } + + if ( str_contains($characters, "\n") && ! $this->textLooksLikeDisplayText($text) ) { + return false; + } + + if ( ! empty($text['segments'] ?? array()) ) { + return false; + } + + return true; + } + + /** + * @param array $text + */ + private function textLooksLikeDisplayText(array $text): bool + { + $style = is_array($text['style'] ?? null) ? $text['style'] : array(); + if ( isset($style['font_weight']) && is_numeric($style['font_weight']) && 700 <= (float) $style['font_weight'] ) { + return true; + } + + if ( isset($style['font_size']) && is_numeric($style['font_size']) && 30 <= (float) $style['font_size'] ) { + return true; + } + + $derivedLineHeight = $this->textDerivedBaselineLineHeight($text); + return null !== $derivedLineHeight && 36 <= $derivedLineHeight; + } + + private function textNeedsDomSymbolFallback(string $characters): bool + { + return 1 === preg_match('/[✔✖✕✓✗•▪■□☑]/u', $characters); + } + + /** + * @param array $text + */ + private function textGlyphFallbackFontSize(array $text): float + { + $style = is_array($text['style'] ?? null) ? $text['style'] : array(); + return isset($style['font_size']) && is_numeric($style['font_size']) ? (float) $style['font_size'] : 0.0; + } + + /** + * @param array $glyphPath + * @param array $derivedLayout + * @return array{index: int, x: float, y: float} + */ + private function textGlyphBaseline(array $glyphPath, array $derivedLayout): array + { + $baselines = is_array($derivedLayout['baselines'] ?? null) ? $derivedLayout['baselines'] : array(); + $character = isset($glyphPath['firstCharacter']) && is_numeric($glyphPath['firstCharacter']) ? (float) $glyphPath['firstCharacter'] : null; + foreach ( $baselines as $index => $baseline ) { + if ( ! is_array($baseline) ) { + continue; + } + $x = isset($baseline['position_x']) && is_numeric($baseline['position_x']) ? (float) $baseline['position_x'] : 0.0; + $y = isset($baseline['position_y']) && is_numeric($baseline['position_y']) ? (float) $baseline['position_y'] : ( isset($baseline['lineAscent']) && is_numeric($baseline['lineAscent']) ? (float) $baseline['lineAscent'] : 0.0 ); + if ( null === $character || ! isset($baseline['firstCharacter'], $baseline['endCharacter']) || ! is_numeric($baseline['firstCharacter']) || ! is_numeric($baseline['endCharacter']) ) { + return array('index' => (int) $index, 'x' => $x, 'y' => $y); + } + if ( $character >= (float) $baseline['firstCharacter'] && $character < (float) $baseline['endCharacter'] ) { + return array('index' => (int) $index, 'x' => $x, 'y' => $y); + } + } + + return array('index' => 0, 'x' => 0.0, 'y' => 0.0); + } + + /** + * @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; + } + } + + $styles = $this->textStyleDeclarations($style); + $derivedLineHeight = $this->textDerivedBaselineLineHeight($text); + if ( null !== $derivedLineHeight ) { + $styles = array_values(array_filter( + $styles, + static fn (string $style): bool => ! str_starts_with($style, 'line-height:') + )); + $styles[] = 'line-height:' . $this->number($derivedLineHeight) . 'px'; + } + 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']; + } + + /** + * @param array $text + */ + private function textDerivedBaselineLineHeight(array $text): ?float + { + $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 null; + } + + $positions = array(); + foreach ( $baselines as $baseline ) { + if ( is_array($baseline) && isset($baseline['position_y']) && is_numeric($baseline['position_y']) ) { + $positions[] = (float) $baseline['position_y']; + } + } + sort($positions); + + $deltas = array(); + for ( $i = 1; $i < count($positions); $i++ ) { + $delta = $positions[$i] - $positions[$i - 1]; + if ( 0.0 < $delta ) { + $deltas[] = $delta; + } + } + if ( empty($deltas) ) { + return null; + } + + sort($deltas); + return $deltas[(int) floor(( count($deltas) - 1 ) / 2)]; + } + + /** + * @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_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']) . '%'; + } + + 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']; + } + + 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 + * @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; + foreach ( $this->assetAliases($asset, $id) as $alias ) { + $this->assetsById[$alias] = $file; + } + } + + 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 + { + foreach ( $this->nodeAssetReferences($node) as $assetId ) { + if ( isset($this->assetsById[$assetId]) ) { + return (string) $this->assetsById[$assetId]['path']; + } + } + + 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; + $metadata['glyph_path_count'] = is_array($derivedLayout['glyph_paths'] ?? null) ? count($derivedLayout['glyph_paths']) : 0; + $characters = isset($text['characters']) && is_scalar($text['characters']) ? (string) $text['characters'] : ''; + $metadata['glyph_rendering'] = $this->renderTextGlyphPaths && ! empty($derivedLayout['glyph_paths']) && $this->textAllowsGlyphRendering($characters, $text) ? 'svg_paths' : 'dom_text'; + } else { + $metadata['has_derived_layout'] = false; + $metadata['glyph_rendering'] = 'dom_text'; + } + + 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 + */ + 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', 'ref') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) ) { + $references[] = (string) $node[$key]; + } + } + + 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'] ?? '')) ) { + continue; + } + + 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]; + } + } + } + } + } + + 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); + } + + /** + * @param array $node + */ + private function supportedVectorSvg(array $node, string $type): ?string + { + if ( ! in_array($type, array('VECTOR', 'BOOLEAN_OPERATION', '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; + } + + $viewBox = array('x' => 0.0, 'y' => 0.0, 'width' => $width, 'height' => $height); + $pathBounds = $this->vectorPathBounds($node); + if ( null !== $pathBounds && ( $pathBounds['width'] > $width + 0.001 || $pathBounds['height'] > $height + 0.001 || $pathBounds['x'] < -0.001 || $pathBounds['y'] < -0.001 ) ) { + $viewBox = $pathBounds; + } elseif ( null !== $pathBounds && $this->vectorPathTouchesViewBoxEdge($pathBounds, $viewBox) ) { + $padding = 0.5; + $viewBox = array( + 'x' => $viewBox['x'] - $padding, + 'y' => $viewBox['y'] - $padding, + 'width' => $viewBox['width'] + ( $padding * 2 ), + 'height' => $viewBox['height'] + ( $padding * 2 ), + ); + } + + $attributes = array( + 'xmlns="http://www.w3.org/2000/svg"', + 'viewBox="' . $this->number($viewBox['x']) . ' ' . $this->number($viewBox['y']) . ' ' . $this->number($viewBox['width']) . ' ' . $this->number($viewBox['height']) . '"', + 'width="100%"', + 'height="100%"', + 'role="img"', + 'aria-label="' . $this->sanitizeAttribute((string) ($node['name'] ?? $type)) . '"', + 'data-figma-vector="true"', + ); + + $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 . ''; + } + + /** + * @param array{x: float, y: float, width: float, height: float} $pathBounds + * @param array{x: float, y: float, width: float, height: float} $viewBox + */ + private function vectorPathTouchesViewBoxEdge(array $pathBounds, array $viewBox): bool + { + $epsilon = 0.001; + return abs($pathBounds['x'] - $viewBox['x']) <= $epsilon + || abs($pathBounds['y'] - $viewBox['y']) <= $epsilon + || abs(($pathBounds['x'] + $pathBounds['width']) - ($viewBox['x'] + $viewBox['width'])) <= $epsilon + || abs(($pathBounds['y'] + $pathBounds['height']) - ($viewBox['y'] + $viewBox['height'])) <= $epsilon; + } + + /** + * @param array $node + * @return array{x: float, y: float, width: float, height: float}|null + */ + private function vectorPathBounds(array $node): ?array + { + $paths = array(); + if ( is_array($node['figma_vector_paths'] ?? null) ) { + $paths = array_merge($paths, $node['figma_vector_paths']); + } + foreach ( array('vectorPaths', 'paths') as $key ) { + if ( is_array($node[$key] ?? null) ) { + $paths = array_merge($paths, $node[$key]); + } + } + foreach ( array('pathData', 'path', 'd') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) ) { + $paths[] = array('data' => (string) $node[$key]); + } + } + + $minX = null; + $minY = null; + $maxX = null; + $maxY = null; + foreach ( $paths as $rawPath ) { + $path = is_array($rawPath) ? (string) ($rawPath['data'] ?? $rawPath['pathData'] ?? $rawPath['path'] ?? $rawPath['d'] ?? '') : (string) $rawPath; + $path = $this->safeSvgPathData($path); + if ( null === $path || ! preg_match_all('/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/i', $path, $matches) ) { + continue; + } + $numbers = array_map('floatval', $matches[0]); + for ( $i = 0; $i + 1 < count($numbers); $i += 2 ) { + $x = $numbers[$i]; + $y = $numbers[$i + 1]; + $minX = null === $minX ? $x : min($minX, $x); + $minY = null === $minY ? $y : min($minY, $y); + $maxX = null === $maxX ? $x : max($maxX, $x); + $maxY = null === $maxY ? $y : max($maxY, $y); + } + } + + if ( null === $minX || null === $minY || null === $maxX || null === $maxY || $maxX <= $minX || $maxY <= $minY ) { + return null; + } + + return array('x' => $minX, 'y' => $minY, 'width' => $maxX - $minX, 'height' => $maxY - $minY); + } + + /** + * @param array $node + * @return array + */ + 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]); + } + } + 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 + */ + 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); + } + + /** + * @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); + } + + 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; + } + + $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); + } + + 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 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'); + } + + 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..defd7c0 --- /dev/null +++ b/figma-transformer/src/Parity/ParityReportBuilder.php @@ -0,0 +1,111 @@ + $evidence + * @param array $overrides + * @return array + */ + 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'; + } + + $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' => $artifacts, + 'source' => $source, + 'generated' => $generated, + 'side_by_side' => $evidence['side_by_side'] ?? null, + '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/src/Parity/VisualAttributionReportBuilder.php b/figma-transformer/src/Parity/VisualAttributionReportBuilder.php new file mode 100644 index 0000000..e9bd334 --- /dev/null +++ b/figma-transformer/src/Parity/VisualAttributionReportBuilder.php @@ -0,0 +1,426 @@ + $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; + $materialThreshold = isset($options['material_threshold']) && is_numeric($options['material_threshold']) ? (int) $options['material_threshold'] : 96; + $severeThreshold = isset($options['severe_threshold']) && is_numeric($options['severe_threshold']) ? (int) $options['severe_threshold'] : 192; + $rankBy = isset($options['rank_by']) && is_scalar($options['rank_by']) ? (string) $options['rank_by'] : 'material_delta_score'; + $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, $materialThreshold, $severeThreshold); + 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'][$rankBy] ?? 0) <=> ($left['diff'][$rankBy] ?? 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, + 'material_threshold' => $materialThreshold, + 'severe_threshold' => $severeThreshold, + 'rank_by' => $rankBy, + ), + '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), + ), + 'totals' => $this->diffTotals($nodes), + '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, int $materialThreshold, int $severeThreshold): array + { + $mismatch = 0; + $materialMismatch = 0; + $severeMismatch = 0; + $materialDeltaScore = 0; + $bucketGt24 = 0; + $bucketGt48 = 0; + $bucketGt96 = 0; + $bucketGt192 = 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++; + } + if ( $delta > $materialThreshold ) { + $materialMismatch++; + $materialDeltaScore += $delta - $threshold; + } + if ( $delta > $severeThreshold ) { + $severeMismatch++; + } + if ( $delta > 24 ) { + $bucketGt24++; + } + if ( $delta > 48 ) { + $bucketGt48++; + } + if ( $delta > 96 ) { + $bucketGt96++; + } + if ( $delta > 192 ) { + $bucketGt192++; + } + } + } + + return array( + 'area_pixels' => $area, + 'mismatch_pixels' => $mismatch, + 'mismatch_ratio' => 0 === $area ? 0 : $mismatch / $area, + 'material_mismatch_pixels' => $materialMismatch, + 'material_mismatch_ratio' => 0 === $area ? 0 : $materialMismatch / $area, + 'severe_mismatch_pixels' => $severeMismatch, + 'severe_mismatch_ratio' => 0 === $area ? 0 : $severeMismatch / $area, + 'mean_rgb_sum_delta' => 0 === $area ? 0 : $sum / $area, + 'material_delta_score' => $materialDeltaScore, + 'mean_material_delta' => 0 === $area ? 0 : $materialDeltaScore / $area, + 'max_rgb_sum_delta' => $max, + 'severity_buckets' => array( + 'gt24' => $bucketGt24, + 'gt48' => $bucketGt48, + 'gt96' => $bucketGt96, + 'gt192' => $bucketGt192, + ), + 'thresholds' => array( + 'noise' => $threshold, + 'material' => $materialThreshold, + 'severe' => $severeThreshold, + ), + ); + } + + /** + * @param array> $nodes + * @return array + */ + private function diffTotals(array $nodes): array + { + $totals = array( + 'mismatch_pixels' => 0, + 'material_mismatch_pixels' => 0, + 'severe_mismatch_pixels' => 0, + 'material_delta_score' => 0, + 'severity_buckets' => array( + 'gt24' => 0, + 'gt48' => 0, + 'gt96' => 0, + 'gt192' => 0, + ), + ); + + foreach ( $nodes as $node ) { + $diff = is_array($node['diff'] ?? null) ? $node['diff'] : array(); + foreach ( array('mismatch_pixels', 'material_mismatch_pixels', 'severe_mismatch_pixels', 'material_delta_score') as $key ) { + if ( isset($diff[$key]) && is_numeric($diff[$key]) ) { + $totals[$key] += (int) $diff[$key]; + } + } + $buckets = is_array($diff['severity_buckets'] ?? null) ? $diff['severity_buckets'] : array(); + foreach ( array('gt24', 'gt48', 'gt96', 'gt192') as $key ) { + if ( isset($buckets[$key]) && is_numeric($buckets[$key]) ) { + $totals['severity_buckets'][$key] += (int) $buckets[$key]; + } + } + } + + return $totals; + } + + /** + * @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 new file mode 100644 index 0000000..3691162 --- /dev/null +++ b/figma-transformer/src/Scenegraph/ScenegraphIndex.php @@ -0,0 +1,344 @@ + $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, is_int($key) ? $key : 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, $nodeMap[$parent] ?? array()); + } + + $topLevelNodeIds = array(); + foreach ( $parentIndex as $id => $parent ) { + if ( null === $parent || ! isset($nodeMap[$parent]) ) { + $topLevelNodeIds[] = $id; + } + } + $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, + 'children_index' => $childrenIndex, + 'top_level_node_ids' => $topLevelNodeIds, + 'diagnostics' => $diagnostics, + ); + } + + /** + * @param array> $nodeMap + * @param array> $childrenIndex + * @param array $trail + * @return array + */ + private function hydrateNode(string $id, array $nodeMap, array $childrenIndex, array $trail = array()): 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]) ) { + $node['children'][] = $this->hydrateNode($childId, $nodeMap, $childrenIndex, $trail); + } + } + + return $node; + } + + /** + * @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, ?int $sourceOrder, 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')) ?? $this->readGuid($node['guid'] ?? null) ?? $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')) ?? $this->readParentGuid($node); + $effectiveParent = $explicitParent ?? $parentId; + + $children = array(); + if ( is_array($node['children'] ?? null) ) { + $children = $node['children']; + } + + unset($node['children']); + 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( + '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, + 'children' => $children, + ); + + foreach ( $children as $key => $child ) { + if ( is_array($child) ) { + $this->collectNode($child, is_string($key) ? $key : null, $id, is_int($key) ? $key : null, $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['guid']) || isset($value['children']) ) { + return $value; + } + + 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 + */ + 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; + } + + 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 + */ + private function nodeRichness(array $node, array $children): int + { + return count($node, COUNT_RECURSIVE) + count($children, COUNT_RECURSIVE); + } + + /** + * @param array $ids + * @param array> $nodeMap + * @return 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, $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); + } + ); + + 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} + */ + 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, + ); + } + } + + 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 new file mode 100644 index 0000000..bbb2b36 --- /dev/null +++ b/figma-transformer/src/Scenegraph/ScenegraphNormalizer.php @@ -0,0 +1,2107 @@ + $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); + $diagnostics = $index['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, $blobs, $paintStyles); + $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; + if ( null !== $selectedFrameId && 1 === count($topLevelIds) && $selectedFrameId !== $topLevelIds[0] ) { + $renderIds = array($selectedFrameId); + } + $renderNodes = array(); + foreach ( $renderIds as $id ) { + if ( isset($nodeMap[$id]) ) { + $renderNodes[] = $this->refreshResolvedTree($nodeMap[$id], $nodeMap); + } + } + + $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, + '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'], + 'top_level_node_ids' => $topLevelIds, + 'top_level_frame_ids' => $frameIds, + 'selected_frame_id' => $selectedFrameId, + 'text_inventory' => $textInventory, + 'asset_references' => $assetReferences, + 'diagnostics' => $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), + '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), + ), + ); + } + + /** + * @param array> $nodeMap + * @param array> $diagnostics + * @return 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, $blobs, $paintStyles); + } + + return $nodeMap; + } + + /** + * @param array $node + * @param array> $diagnostics + * @return array + */ + private function normalizeNode(array $node, array &$diagnostics, array $blobs = array(), array $paintStyles = array()): 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, $blobs, $id, $diagnostics); + if ( ! empty($text) ) { + $node['figma_text'] = $text; + } + } + + $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; + } + + $layoutBox = $this->normalizeLayoutBox($node); + if ( ! empty($layoutBox) ) { + $node['box'] = $layoutBox; + } + + $layout = $this->normalizeLayout($node); + if ( ! empty($layout) ) { + $node['layout'] = $layout; + } + + $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) ) { + continue; + } + + foreach ( $node[$childrenKey] as $index => $child ) { + if ( is_array($child) ) { + $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'] + : (int) $index; + $normalizedChild['layout'] = $childLayout; + $node[$childrenKey][$index] = $normalizedChild; + } + } + } + + return $node; + } + + /** + * @param array $node + * @return array + */ + private function normalizeComponentMetadata(array $node, string $type): array + { + $metadata = array(); + + if ( in_array($type, array('COMPONENT', 'COMPONENT_SET', 'SYMBOL'), true) ) { + $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', 'SYMBOL'), 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', 'SYMBOL'), true) ) { + $count++; + } + } + + 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 + * @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 $blobs = array(), array $paintStyles = array()): 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, $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, $diagnostics, $blobs, $paintStyles); + $nodeMap[$id] = $resolved; + $resolvedCount++; + } + + return array( + 'instance_node_count' => $instanceCount, + 'resolved_instance_count' => $resolvedCount, + 'unresolved_component_references' => $unresolved, + ); + } + + /** + * @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 + */ + 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); + } + } + + $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; + } + + /** + * @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 array $node + * @param array> $diagnostics + * @return array>|null + */ + private function normalizeInstanceOverrides(array $node, string $instanceId, array &$diagnostics): ?array + { + $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 ( is_array($node['derivedSymbolData'] ?? null) ) { + $rawOverrides = array_merge($rawOverrides, $node['derivedSymbolData']); + } + + if ( 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')) ?? $this->readOverrideGuidPathTarget($override) ?? (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]; + } + } + if ( isset($override['textData']['characters']) && is_scalar($override['textData']['characters']) ) { + $overrides[$nodeId]['characters'] = (string) $override['textData']['characters']; + } + foreach ( array('derivedTextData', 'size', 'transform', 'fillPaints', 'fills', 'strokes', 'effects', 'styleIdForFill', 'fillGeometry', 'strokeGeometry', 'vectorPaths', 'paths', 'pathData', 'path', 'd', 'cornerRadius', 'rectangleTopLeftCornerRadius', 'rectangleTopRightCornerRadius', 'rectangleBottomLeftCornerRadius', 'rectangleBottomRightCornerRadius') as $field ) { + if ( array_key_exists($field, $override) ) { + $overrides[$nodeId][$field] = $override[$field]; + } + } + } + + 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; + $ids = array(); + foreach ( $guids as $guid ) { + $id = $this->readGuidId($guid); + if ( null !== $id ) { + $ids[] = $id; + } + } + + return empty($ids) ? null : implode('/', $ids); + } + + /** + * @param array $component + * @param array $instance + * @param array> $overrides + * @param array> $nodeMap + * @return array + */ + private function cloneComponentForInstance(array $component, array $instance, string $componentId, array $overrides, array $nodeMap, array &$diagnostics, array $blobs = array(), array $paintStyles = array()): 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', '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]; + } + } + + $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, + ) + ); + $resolvedChildren = is_array($resolved['children'] ?? null) ? $resolved['children'] : array(); + $resolvedChildren = $this->resolveClonedInstanceChildren($resolvedChildren, $nodeMap); + $resolvedChildren = $this->scaleVectorOnlyInstanceChildren($resolvedChildren, $component, $instance); + if ( $this->instanceOverridesUseTransforms($overrides) ) { + $resolved['layout'] = array('freeform' => true); + } + $resolved['children'] = $this->namespaceResolvedInstanceChildren( + $this->applyInstanceOverridesToChildren($resolvedChildren, $overrides, $diagnostics, $blobs, $paintStyles), + (string) ($instance['id'] ?? '') + ); + + return $resolved; + } + + /** + * @param array> $overrides + */ + private function instanceOverridesUseTransforms(array $overrides): bool + { + foreach ( $overrides as $override ) { + if ( is_array($override) && is_array($override['transform'] ?? null) ) { + return true; + } + } + + return false; + } + + /** + * @param array $children + * @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 &$diagnostics, array $blobs = array(), array $paintStyles = array()): array + { + foreach ( $children as $index => $child ) { + if ( ! is_array($child) ) { + continue; + } + + $id = (string) ($child['id'] ?? ''); + $hasOverride = false; + $overrideFields = $overrides[$id] ?? array(); + foreach ( $overrideFields as $field => $value ) { + $hasOverride = true; + $child[$field] = $value; + if ( in_array($field, array('characters', 'text'), true) && is_array($child['figma_text'] ?? null) ) { + $child['figma_text']['characters'] = (string) $value; + } + } + if ( $hasOverride ) { + $child = $this->normalizeOverriddenInstanceChild($child, $id, $overrideFields, $diagnostics, $blobs, $paintStyles); + } + + if ( is_array($child['children'] ?? null) ) { + $child['children'] = $this->applyInstanceOverridesToChildren($child['children'], $overrides, $diagnostics, $blobs, $paintStyles); + } + + $children[$index] = $child; + } + + return $children; + } + + /** + * @param array $child + * @param array> $diagnostics + * @return array + */ + private function normalizeOverriddenInstanceChild(array $child, string $id, array $overrideFields, array &$diagnostics, array $blobs = array(), array $paintStyles = array()): array + { + $hasVectorGeometryOverride = array_key_exists('fillGeometry', $overrideFields) || array_key_exists('strokeGeometry', $overrideFields); + $hasExplicitSizeOverride = array_key_exists('size', $overrideFields); + if ( is_array($child['size'] ?? null) ) { + foreach ( array('x' => 'width', 'y' => 'height') as $source => $target ) { + if ( isset($child['size'][$source]) && is_numeric($child['size'][$source]) ) { + $child[$target] = (float) $child['size'][$source]; + } + } + } + if ( is_array($child['transform'] ?? null) ) { + foreach ( array('m02' => 'x', 'm12' => 'y') as $source => $target ) { + if ( isset($child['transform'][$source]) && is_numeric($child['transform'][$source]) ) { + $child[$target] = (float) $child['transform'][$source]; + } + } + } + + foreach ( array('figma_text', 'figma_paints', 'figma_vector_paths', 'figma_box', 'box', 'layout', 'figma_effects') as $key ) { + unset($child[$key]); + } + unset($child['figma_vector_scale']); + + $child = $this->normalizeNode($child, $diagnostics, $blobs, $paintStyles); + if ( $hasVectorGeometryOverride && ! $hasExplicitSizeOverride ) { + $bounds = $this->normalizedVectorPathBounds(is_array($child['figma_vector_paths'] ?? null) ? $child['figma_vector_paths'] : array()); + if ( null !== $bounds ) { + $box = is_array($child['box'] ?? null) ? $child['box'] : array(); + foreach ( array('width', 'height') as $dimension ) { + if ( ! isset($box[$dimension]) || ! is_numeric($box[$dimension]) || $bounds[$dimension] > (float) $box[$dimension] + 0.001 ) { + $child[$dimension] = $bounds[$dimension]; + $child['box'][$dimension] = $bounds[$dimension]; + } + } + } + } + + return $child; + } + + /** + * @param array> $paths + * @return array{width: float, height: float}|null + */ + private function normalizedVectorPathBounds(array $paths): ?array + { + $minX = null; + $minY = null; + $maxX = null; + $maxY = null; + foreach ( $paths as $path ) { + if ( ! is_array($path) || ! isset($path['data']) || ! is_scalar($path['data']) || ! preg_match_all('/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/i', (string) $path['data'], $matches) ) { + continue; + } + $numbers = array_map('floatval', $matches[0]); + for ( $i = 0; $i + 1 < count($numbers); $i += 2 ) { + $x = $numbers[$i]; + $y = $numbers[$i + 1]; + $minX = null === $minX ? $x : min($minX, $x); + $minY = null === $minY ? $y : min($minY, $y); + $maxX = null === $maxX ? $x : max($maxX, $x); + $maxY = null === $maxY ? $y : max($maxY, $y); + } + } + + if ( null === $minX || null === $minY || null === $maxX || null === $maxY || $maxX <= $minX || $maxY <= $minY ) { + return null; + } + + return array('width' => $maxX - $minX, 'height' => $maxY - $minY); + } + + /** + * @param array $node + * @return array + */ + private function normalizeText(array $node, array $blobs = array(), string $nodeId = '', array &$diagnostics = array()): array + { + $text = array(); + + foreach ( array('characters', 'text') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) ) { + $text['characters'] = (string) $node[$key]; + break; + } + } + + 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']); + } + + $rootStyle = $this->normalizeTextStyle($node); + foreach ( $rootStyle as $key => $value ) { + if ( ! array_key_exists($key, $style) ) { + $style[$key] = $value; + } + } + + if ( ! empty($style) ) { + $text['style'] = $style; + } + + $derivedLayout = $this->normalizeDerivedTextLayout($node, $blobs, $nodeId, $diagnostics); + if ( ! empty($derivedLayout) ) { + $text['derived_layout'] = $derivedLayout; + } + + $segments = $this->normalizeStyledTextSegments($node); + if ( ! empty($segments) ) { + $text['segments'] = $segments; + } + + return $text; + } + + /** + * @param array $node + * @return array + */ + private function normalizeDerivedTextLayout(array $node, array $blobs = array(), string $nodeId = '', array &$diagnostics = array()): 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']); + $glyphPaths = array(); + $characters = isset($node['textData']['characters']) && is_scalar($node['textData']['characters']) ? (string) $node['textData']['characters'] : ( isset($node['characters']) && is_scalar($node['characters']) ? (string) $node['characters'] : '' ); + $characterList = '' !== $characters ? preg_split('//u', $characters, -1, PREG_SPLIT_NO_EMPTY) : array(); + if ( ! is_array($characterList) ) { + $characterList = array(); + } + foreach ( $source['glyphs'] as $index => $glyph ) { + if ( ! is_array($glyph) ) { + continue; + } + + $glyphPath = array(); + foreach ( array('x', 'y', 'advance', 'fontSize', 'fontIndex', 'firstCharacter', 'endCharacter') as $key ) { + if ( isset($glyph[$key]) && is_numeric($glyph[$key]) ) { + $glyphPath[$key] = (float) $glyph[$key]; + } + } + if ( isset($glyph['firstCharacter']) && is_numeric($glyph['firstCharacter']) && isset($characterList[(int) $glyph['firstCharacter']]) ) { + $glyphPath['character'] = $characterList[(int) $glyph['firstCharacter']]; + } + if ( is_array($glyph['position'] ?? null) ) { + foreach ( array('x', 'y') as $axis ) { + if ( isset($glyph['position'][$axis]) && is_numeric($glyph['position'][$axis]) ) { + $glyphPath['position_' . $axis] = (float) $glyph['position'][$axis]; + } + } + } + + if ( isset($glyph['commandsBlob']) ) { + $bytes = $this->readCommandBlobBytes($glyph['commandsBlob'], $blobs); + if ( null !== $bytes ) { + $path = $this->decodeVectorCommandBlob($bytes); + if ( null === $path ) { + $diagnostics[] = array( + 'severity' => 'warning', + 'code' => 'unsupported_text_glyph_command_blob', + 'message' => 'Unsupported Figma text glyph command blob was omitted from derived glyph metadata.', + 'context' => array('node_id' => $nodeId, 'glyph_index' => $index), + ); + } else { + $glyphPath['data'] = $path; + } + } + } + + if ( empty($glyphPath) ) { + continue; + } + $glyphPaths[] = $glyphPath; + } + if ( ! empty($glyphPaths) ) { + $layout['glyph_paths'] = $glyphPaths; + } + } + 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 + */ + 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]; + } + } + + 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 ) { + $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; + } + + 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> + */ + 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 $paintStyles = array()): array + { + $collections = array(); + foreach ( array('fills' => 'fills', 'fillPaints' => 'fills', 'strokes' => 'strokes', 'strokePaints' => 'strokes', 'background' => 'background') as $sourceKey => $targetKey ) { + if ( ! is_array($node[$sourceKey] ?? null) ) { + continue; + } + + $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; + } + + $color = $this->normalizeColor($node[$sourceKey]); + if ( null !== $color ) { + $collections[$targetKey][] = array('type' => 'SOLID', 'color' => $color); + } + } + + 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 + * @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 ) { + $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( + '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(); + } + + 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 ) { + if ( empty($parts) ) { + continue; + } + $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 ( 3 === $opcode ) { + $points = array(); + for ( $i = 0; $i < 2; $i++ ) { + $point = $this->readFloatPair($bytes, $offset + ( $i * 8 )); + if ( null === $point ) { + return null; + } + $points[] = $point; + } + $parts[] = 'Q ' . $this->svgNumber($points[0][0]) . ' ' . $this->svgNumber($points[0][1]) . ' ' . $this->svgNumber($points[1][0]) . ' ' . $this->svgNumber($points[1][1]); + $offset += 16; + 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 + */ + private function normalizeVisualBox(array $node): array + { + $box = array(); + + if ( isset($node['opacity']) && is_numeric($node['opacity']) ) { + $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', 'transform') 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']; + } + + 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 normalizeEffects(array $node, string $nodeId, array &$diagnostics): array + { + if ( ! is_array($node['effects'] ?? null) ) { + 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' => $type, + ), + ); + } + + return $effects; + } + + /** + * @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)); + } + + /** + * @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; + } + } + + 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 + * @return array> + */ + /** + * @param array $node + * @return array + */ + private function normalizeLayoutBox(array $node): array + { + $box = array(); + $coordinateSpace = null; + + 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]; + } + } + + 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'; + } + } + } + + 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]; + $coordinateSpace = 'local'; + } + } + } + + if ( null !== $coordinateSpace ) { + $box['coordinate_space'] = $coordinateSpace; + } + + 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'; + } + } 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'; + } elseif ( 'VERTICAL' === $mode ) { + $layout['display'] = 'flex'; + $layout['flex_direction'] = 'column'; + } + } + + 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', + '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]); + } + } + + 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' => '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) ) { + $layout['padding'] = $padding; + } + + 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']) ) { + $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'; + } + + 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']); + } elseif ( isset($node['stackChildAlignSelf']) && is_scalar($node['stackChildAlignSelf']) ) { + $layout['align'] = strtoupper((string) $node['stackChildAlignSelf']); + } + + 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; + } + } + + 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; + } + + private function cssAxisAlignment(string $alignment): ?string + { + return match ( strtoupper($alignment) ) { + 'MIN' => 'flex-start', + 'CENTER' => 'center', + 'MAX' => 'flex-end', + 'SPACE_BETWEEN' => 'space-between', + 'BASELINE' => 'baseline', + 'STRETCH' => 'stretch', + default => null, + }; + } + + /** + * @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') 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, + '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('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 ) { + $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'] ?? '')) ) { + continue; + } + + $reference = $this->readImageReference($paint); + if ( null !== $reference ) { + $references[] = array( + 'node_id' => $id, + 'paint' => $paintKey, + 'source_key' => $reference['source_key'], + 'ref' => $reference['ref'], + ); + } + } + } + } + } + + $unique = array(); + foreach ( $references as $reference ) { + $key = (string) ($reference['node_id'] ?? '') . '|' . (string) ($reference['paint'] ?? '') . '|' . (string) ($reference['ref'] ?? ''); + $unique[$key] = $reference; + } + + return array_values($unique); + } + + /** + * @param array $paint + * @return array{source_key: string, ref: string}|null + */ + private function readImageReference(array $paint): ?array + { + 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, + 'ref' => (string) $paint[$key], + ); + } + } + + return null; + } + + /** + * @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/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 new file mode 100644 index 0000000..0504feb --- /dev/null +++ b/figma-transformer/tests/contract/run.php @@ -0,0 +1,2084 @@ + 'Fixture Site', + 'blobs' => array( + array('bytes' => $vectorCommandBlob), + ), + 'assets' => array( + 'hero-image' => array( + 'name' => 'Hero Image', + 'mime_type' => 'image/svg+xml', + 'content' => '', + ), + '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( + 'id' => '1:1', + 'type' => 'FRAME', + 'name' => 'Hero section', + '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', + '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', + 'absoluteRenderBounds' => array('width' => 320, 'height' => 180), + 'x' => 10, + 'y' => 20, + 'layoutPositioning' => 'ABSOLUTE', + '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'), + ), + ), + ), + ), + ), + ), + array('id' => '1:2', 'type' => 'TEXT', 'name' => 'Duplicate title', 'text' => 'Duplicate'), + ), +); + +$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'); +$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'); +$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, '
'Oversized Vector Bounds Fixture', + 'blobs' => array(array('bytes' => $vectorCommandBlob)), + 'nodes' => array( + array( + 'id' => 'vector:oversized-bounds', + 'type' => 'VECTOR', + 'name' => 'Oversized Bounds', + 'width' => 5, + 'height' => 5, + 'fillGeometry' => array(array('commandsBlob' => 0)), + ), + ), +)); +$oversizedVectorHtml = $fileContent($oversizedVectorResult, 'index.html'); +$oversizedVectorCss = $fileContent($oversizedVectorResult, 'style.css'); +$assert(str_contains($oversizedVectorHtml, 'viewBox="0 0 10 10"'), 'oversized-vector-viewbox-uses-path-bounds'); +$assert(str_contains($oversizedVectorCss, '.figma-node-vector-oversized-bounds-oversized-bounds{width:5px;height:5px'), 'oversized-vector-css-keeps-node-size'); + +$matrixTransformResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Matrix Transform Fixture', + 'nodes' => array( + array( + 'id' => 'matrix:flip', + 'type' => 'FRAME', + 'name' => 'Matrix Flip', + 'width' => 20, + 'height' => 20, + 'transform' => array('m00' => -1, 'm01' => 0, 'm02' => 0, 'm10' => 0, 'm11' => 1, 'm12' => 0), + ), + ), +)); +$matrixTransformCss = $fileContent($matrixTransformResult, 'style.css'); +$assert(str_contains($matrixTransformCss, 'transform:matrix(-1,0,0,1,0,0);transform-origin:0 0'), 'matrix-transform-uses-figma-origin'); +$assetPaths = array_map(static fn (array $asset): string => (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'); + +$derivedTextLayoutScenegraph = array( + 'name' => 'Derived Text Layout Fixture', + 'blobs' => array( + array('bytes' => $quadraticCommandBlob), + ), + 'nodes' => array( + array( + 'id' => 'text:derived-layout', + 'type' => 'TEXT', + 'name' => 'Measured Text', + 'characters' => 'A B', + 'width' => 146.5, + 'height' => 32.25, + 'fontSize' => 10, + '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, 'fontSize' => 10, 'x' => 2, 'y' => 3, 'commandsBlob' => 0), + array('firstCharacter' => 1, 'advance' => 0.5, 'fontSize' => 10, 'commandsBlob' => 0), + array('firstCharacter' => 2, 'advance' => 0.5, 'fontSize' => 10, 'commandsBlob' => 0), + ), + 'fontMetaData' => array( + array( + 'key' => array('family' => 'Example Sans', 'style' => 'Regular'), + 'fontLineHeight' => 1.2, + 'fontWeight' => 400, + ), + ), + ), + ), + ), +); +$derivedTextLayoutResult = blocks_engine_figma_transformer_transform_scenegraph($derivedTextLayoutScenegraph); +$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(3 === ($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'); +$assert('M 0 0 Q 4 8 8 0 Z' === ($derivedTextVisualNode['text']['derived_layout']['glyph_paths'][0]['data'] ?? null), 'visual-node-derived-text-glyph-quadratic-path'); +$assert(2.0 === ($derivedTextVisualNode['text']['derived_layout']['glyph_paths'][0]['x'] ?? null), 'visual-node-derived-text-glyph-position'); +$assert('dom_text' === ($derivedTextVisualNode['text']['glyph_rendering'] ?? null), 'visual-node-derived-text-default-dom-rendering'); +$assert(false === ($derivedTextLayoutResult['source_reports']['figma']['html']['render_text_glyph_paths'] ?? null), 'derived-text-glyph-rendering-default-disabled'); +$assert(! str_contains($fileContent($derivedTextLayoutResult, 'index.html'), 'data-figma-text-glyphs="true"'), 'derived-text-default-avoids-glyph-svg'); + +$derivedTextGlyphResult = blocks_engine_figma_transformer_transform_scenegraph($derivedTextLayoutScenegraph, array('render_text_glyph_paths' => true)); +$derivedTextGlyphHtml = $fileContent($derivedTextGlyphResult, 'index.html'); +$derivedTextGlyphCss = $fileContent($derivedTextGlyphResult, 'style.css'); +$derivedTextGlyphVisualNodes = $derivedTextGlyphResult['source_reports']['figma']['html']['visual_node_map'] ?? array(); +$derivedTextGlyphVisualNode = null; +foreach ( is_array($derivedTextGlyphVisualNodes) ? $derivedTextGlyphVisualNodes : array() as $visualNode ) { + if ( is_array($visualNode) && 'text:derived-layout' === ($visualNode['id'] ?? null) ) { + $derivedTextGlyphVisualNode = $visualNode; + break; + } +} +$assert(str_contains($derivedTextGlyphHtml, 'data-figma-text-glyphs="true"'), 'derived-text-glyph-svg-emitted'); +$assert(str_contains($derivedTextGlyphHtml, 'aria-label="A B"'), 'derived-text-glyph-svg-label'); +$assert(str_contains($derivedTextGlyphHtml, 'd="M 0 0 Q 4 8 8 0 Z"'), 'derived-text-glyph-svg-path'); +$assert(str_contains($derivedTextGlyphHtml, 'transform="translate(2 3) scale(10 -10)"'), 'derived-text-glyph-svg-position'); +$assert(str_contains($derivedTextGlyphHtml, 'transform="translate(10 20) scale(10 -10)"'), 'derived-text-glyph-svg-advance-through-space'); +$assert(! str_contains($derivedTextGlyphHtml, 'transform="translate(5 20) scale(10 -10)"'), 'derived-text-glyph-svg-skips-space-path'); +$assert(str_contains($derivedTextGlyphCss, '.figma-text-glyphs{display:block;width:100%;height:100%;overflow:visible}'), 'derived-text-glyph-svg-css'); +$assert('svg_paths' === ($derivedTextGlyphVisualNode['text']['glyph_rendering'] ?? null), 'visual-node-derived-text-glyph-rendering-mode'); + +$symbolicTextGlyphResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Symbolic Text Glyph Fallback Fixture', + 'blobs' => array(array('bytes' => $quadraticCommandBlob)), + 'nodes' => array( + array( + 'id' => 'text:symbolic-glyph', + 'type' => 'TEXT', + 'name' => 'Checklist Text', + 'characters' => "✔ Included\n✖ Excluded", + 'width' => 120, + 'height' => 48, + 'fontSize' => 16, + 'derivedTextData' => array( + 'layoutSize' => array('x' => 120, 'y' => 48), + 'glyphs' => array(array('firstCharacter' => 0, 'advance' => 1, 'fontSize' => 16, 'commandsBlob' => 0)), + ), + ), + ), +), array('render_text_glyph_paths' => true)); +$symbolicTextGlyphHtml = $fileContent($symbolicTextGlyphResult, 'index.html'); +$symbolicTextGlyphVisualNode = null; +foreach ( $symbolicTextGlyphResult['source_reports']['figma']['html']['visual_node_map'] ?? array() as $visualNode ) { + if ( is_array($visualNode) && 'text:symbolic-glyph' === ($visualNode['id'] ?? null) ) { + $symbolicTextGlyphVisualNode = $visualNode; + break; + } +} +$assert(str_contains($symbolicTextGlyphHtml, "✔ Included\n✖ Excluded"), 'symbolic-text-glyph-fallback-renders-dom-text'); +$assert(! str_contains($symbolicTextGlyphHtml, 'data-figma-text-glyphs="true"'), 'symbolic-text-glyph-fallback-avoids-svg-paths'); +$assert('dom_text' === ($symbolicTextGlyphVisualNode['text']['glyph_rendering'] ?? null), 'symbolic-text-glyph-fallback-visual-metadata-dom'); + +$paragraphTextGlyphResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Paragraph Text Glyph Fallback Fixture', + 'blobs' => array(array('bytes' => $quadraticCommandBlob)), + 'nodes' => array( + array( + 'id' => 'text:paragraph-glyph', + 'type' => 'TEXT', + 'name' => 'Paragraph copy', + 'characters' => 'This longer paragraph copy should remain real DOM text instead of SVG glyph paths because it needs browser text flow and selection.', + 'width' => 240, + 'height' => 80, + 'fontSize' => 16, + 'derivedTextData' => array( + 'layoutSize' => array('x' => 240, 'y' => 80), + 'glyphs' => array(array('firstCharacter' => 0, 'advance' => 1, 'fontSize' => 16, 'commandsBlob' => 0)), + ), + ), + ), +), array('render_text_glyph_paths' => true)); +$paragraphTextGlyphHtml = $fileContent($paragraphTextGlyphResult, 'index.html'); +$assert(str_contains($paragraphTextGlyphHtml, 'This longer paragraph copy should remain real DOM text'), 'paragraph-text-glyph-fallback-renders-dom-text'); +$assert(! str_contains($paragraphTextGlyphHtml, 'data-figma-text-glyphs="true"'), 'paragraph-text-glyph-fallback-avoids-svg-paths'); + +$sentenceTextGlyphResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Sentence Text Glyph Fallback Fixture', + 'blobs' => array(array('bytes' => $quadraticCommandBlob)), + 'nodes' => array( + array( + 'id' => 'text:sentence-glyph', + 'type' => 'TEXT', + 'name' => 'Sentence copy', + 'characters' => 'Sentence-style body copy should remain DOM text.', + 'width' => 240, + 'height' => 32, + 'fontSize' => 16, + 'derivedTextData' => array( + 'layoutSize' => array('x' => 240, 'y' => 32), + 'glyphs' => array(array('firstCharacter' => 0, 'advance' => 1, 'fontSize' => 16, 'commandsBlob' => 0)), + ), + ), + ), +), array('render_text_glyph_paths' => true)); +$sentenceTextGlyphHtml = $fileContent($sentenceTextGlyphResult, 'index.html'); +$assert(str_contains($sentenceTextGlyphHtml, 'Sentence-style body copy should remain DOM text.'), 'sentence-text-glyph-fallback-renders-dom-text'); +$assert(! str_contains($sentenceTextGlyphHtml, 'data-figma-text-glyphs="true"'), 'sentence-text-glyph-fallback-avoids-svg-paths'); + +$multilineHeadingGlyphResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Multiline Heading Glyph Fixture', + 'blobs' => array(array('bytes' => $quadraticCommandBlob)), + 'nodes' => array( + array( + 'id' => 'text:multiline-heading-glyph', + 'type' => 'TEXT', + 'name' => 'Short Wrapped Heading', + 'characters' => "Short\nwrapped\nheading text", + 'width' => 120, + 'height' => 90, + 'style' => array('fontWeight' => 700), + 'derivedTextData' => array( + 'baselines' => array( + array('firstCharacter' => 0, 'endCharacter' => 5, 'position' => array('x' => 0, 'y' => 20)), + array('firstCharacter' => 6, 'endCharacter' => 13, 'position' => array('x' => 0, 'y' => 45)), + array('firstCharacter' => 14, 'endCharacter' => 26, 'position' => array('x' => 0, 'y' => 70)), + ), + 'glyphs' => array( + array('firstCharacter' => 0, 'advance' => 1, 'fontSize' => 20, 'commandsBlob' => 0), + array('firstCharacter' => 6, 'advance' => 1, 'fontSize' => 20, 'commandsBlob' => 0), + array('firstCharacter' => 14, 'advance' => 1, 'fontSize' => 20, 'commandsBlob' => 0), + ), + ), + ), + ), +), array('render_text_glyph_paths' => true)); +$multilineHeadingGlyphHtml = $fileContent($multilineHeadingGlyphResult, 'index.html'); +$assert(str_contains($multilineHeadingGlyphHtml, "aria-label=\"Short\nwrapped\nheading text\""), 'multiline-heading-glyph-renders-svg-label'); +$assert(str_contains($multilineHeadingGlyphHtml, 'data-figma-text-glyphs="true"'), 'multiline-heading-glyph-renders-svg'); + +$multilineLargeDisplayGlyphResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Multiline Large Display Glyph Fixture', + 'blobs' => array(array('bytes' => $quadraticCommandBlob)), + 'nodes' => array( + array( + 'id' => 'text:multiline-large-display-glyph', + 'type' => 'TEXT', + 'name' => 'Large Wrapped Display', + 'characters' => "Large\nwrapped", + 'width' => 160, + 'height' => 80, + 'fontSize' => 34, + 'style' => array('fontWeight' => 400), + 'derivedTextData' => array( + 'baselines' => array( + array('firstCharacter' => 0, 'endCharacter' => 5, 'position' => array('x' => 0, 'y' => 34)), + array('firstCharacter' => 6, 'endCharacter' => 13, 'position' => array('x' => 0, 'y' => 72)), + ), + 'glyphs' => array( + array('firstCharacter' => 0, 'advance' => 1, 'fontSize' => 34, 'commandsBlob' => 0), + array('firstCharacter' => 6, 'advance' => 1, 'fontSize' => 34, 'commandsBlob' => 0), + ), + ), + ), + ), +), array('render_text_glyph_paths' => true)); +$multilineLargeDisplayGlyphHtml = $fileContent($multilineLargeDisplayGlyphResult, 'index.html'); +$assert(str_contains($multilineLargeDisplayGlyphHtml, "aria-label=\"Large\nwrapped\""), 'multiline-large-display-glyph-renders-svg-label'); +$assert(str_contains($multilineLargeDisplayGlyphHtml, 'data-figma-text-glyphs="true"'), 'multiline-large-display-glyph-renders-svg'); + +$multilineCopyGlyphResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Multiline Copy Glyph Fixture', + 'blobs' => array(array('bytes' => $quadraticCommandBlob)), + 'nodes' => array( + array( + 'id' => 'text:multiline-copy-glyph', + 'type' => 'TEXT', + 'name' => 'Wrapped Copy', + 'characters' => "Short\nwrapped\ncopy", + 'width' => 120, + 'height' => 60, + 'style' => array('fontWeight' => 400), + 'derivedTextData' => array( + 'baselines' => array( + array('firstCharacter' => 0, 'endCharacter' => 5, 'position' => array('x' => 0, 'y' => 20)), + array('firstCharacter' => 6, 'endCharacter' => 13, 'position' => array('x' => 0, 'y' => 45)), + ), + 'glyphs' => array( + array('firstCharacter' => 0, 'advance' => 1, 'fontSize' => 20, 'commandsBlob' => 0), + array('firstCharacter' => 6, 'advance' => 1, 'fontSize' => 20, 'commandsBlob' => 0), + ), + ), + ), + ), +), array('render_text_glyph_paths' => true)); +$multilineCopyGlyphHtml = $fileContent($multilineCopyGlyphResult, 'index.html'); +$assert(str_contains($multilineCopyGlyphHtml, "Short\nwrapped\ncopy"), 'multiline-copy-glyph-fallback-renders-dom-text'); +$assert(! str_contains($multilineCopyGlyphHtml, 'data-figma-text-glyphs="true"'), 'multiline-copy-glyph-fallback-avoids-svg'); + +$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, + 'lineHeightPx' => 40, + '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;line-height:22px;white-space:pre-line}'), 'derived-baselines-enable-pre-line'); +$assert(! str_contains($derivedLineBreakCss, 'line-height:40px;line-height:22px'), 'derived-baselines-replace-source-line-height'); + +$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_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, + ), + '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(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'); + +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, 'material_threshold' => 96, 'severe_threshold' => 192, '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(1 === ($visualAttribution['top_nodes'][0]['diff']['material_mismatch_pixels'] ?? null), 'visual-attribution-node-material-mismatch-count'); + $assert(1 === ($visualAttribution['top_nodes'][0]['diff']['severe_mismatch_pixels'] ?? null), 'visual-attribution-node-severe-mismatch-count'); + $assert(741 === ($visualAttribution['top_nodes'][0]['diff']['material_delta_score'] ?? null), 'visual-attribution-node-material-delta-score'); + $assert(array('gt24' => 1, 'gt48' => 1, 'gt96' => 1, 'gt192' => 1) === ($visualAttribution['top_nodes'][0]['diff']['severity_buckets'] ?? null), 'visual-attribution-node-severity-buckets'); + $assert(1 === ($visualAttribution['totals']['material_mismatch_pixels'] ?? null), 'visual-attribution-totals-material-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); + +$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() +); +$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'); +$assert(106 === ($canvas['version'] ?? null), 'fig-kiwi-version'); +$assert('inner.fig' === ($fileResult['source_reports']['figma']['input']['nested_fig'] ?? null), 'wrapper-nested-fig'); +$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']['nodes']), 'fig-kiwi-first-chunk-nodes-candidate'); +$assert('json_invalid' === ($chunks[1]['payload']['classification'] ?? null), 'fig-kiwi-second-chunk-json-invalid'); +$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'); +$assert(is_array($zstdStatus['functions'] ?? null), 'zstd-status-functions-array'); +$assert(array_key_exists('zstd_uncompress', $zstdStatus['functions'] ?? array()), 'zstd-status-uncompress-function'); +$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'); + $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[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'); + $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); +$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'); +$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(); +$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_adapter_available', $injectedDiagnosticCodes, true), 'zstd-injected-decoder-diagnostic'); + +$wirePayload = SyntheticFigKiwiFixtureBuilder::sampleWirePayload(); +$wireCanvasResult = ( new FigKiwiParser() )->parse( + SyntheticFigKiwiFixtureBuilder::canvas(array(SyntheticFigKiwiFixtureBuilder::zlibChunk($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'); + +$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( + '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'); + +$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( + 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'), + ), + ), + 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, '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), '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', + '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, + ), + 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', + ), + ), + ), + ), + ), + ), +)); + +$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(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'); +$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'); + +$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);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);transform-origin:0 0;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( + 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'); + +$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{') && str_contains($nestedSymbolInstanceCss, 'background:#ff0000'), 'nested-symbol-instance-keeps-instance-fill'); + +$derivedSymbolInstanceResult = blocks_engine_figma_transformer_transform_scenegraph(array( + 'name' => 'Derived Symbol Overrides Fixture', + 'blobs' => array(array('bytes' => $vectorCommandBlob)), + 'nodes' => array( + array( + 'guid' => array('sessionID' => 40, 'localID' => 1), + 'type' => 'SYMBOL', + 'name' => 'Derived Symbol', + 'children' => array( + array( + 'guid' => array('sessionID' => 40, 'localID' => 2), + 'type' => 'TEXT', + 'name' => 'Derived Label', + 'characters' => 'Default', + 'width' => 40, + 'height' => 12, + ), + array( + 'guid' => array('sessionID' => 40, 'localID' => 3), + 'type' => 'VECTOR', + 'name' => 'Derived Icon', + 'width' => 5, + 'height' => 5, + 'fillGeometry' => array(array('commandsBlob' => 0)), + ), + ), + ), + array( + 'id' => 'derived:instance', + 'type' => 'INSTANCE', + 'name' => 'Derived Instance', + 'symbolData' => array( + 'symbolID' => array('sessionID' => 40, 'localID' => 1), + 'symbolOverrides' => array( + array( + 'guidPath' => array('guids' => array(array('sessionID' => 40, 'localID' => 2))), + 'textData' => array('characters' => 'Override'), + ), + ), + ), + 'derivedSymbolData' => array( + array( + 'guidPath' => array('guids' => array(array('sessionID' => 40, 'localID' => 2))), + 'size' => array('x' => 90, 'y' => 24), + 'transform' => array('m00' => 1, 'm01' => 0, 'm02' => 12, 'm10' => 0, 'm11' => 1, 'm12' => 6), + 'derivedTextData' => array('layoutSize' => array('x' => 90, 'y' => 24)), + ), + array( + 'guidPath' => array('guids' => array(array('sessionID' => 40, 'localID' => 3))), + 'transform' => array('m00' => 1, 'm01' => 0, 'm02' => 110, 'm10' => 0, 'm11' => 1, 'm12' => 10), + 'fillGeometry' => array(array('commandsBlob' => 0)), + ), + ), + ), + ), +)); +$derivedSymbolInstanceHtml = $fileContent($derivedSymbolInstanceResult, 'index.html'); +$derivedSymbolInstanceCss = $fileContent($derivedSymbolInstanceResult, 'style.css'); +$assert(str_contains($derivedSymbolInstanceHtml, 'data-figma-node-id="derived:instance/40:2"'), 'derived-symbol-instance-label-namespaced'); +$assert(str_contains($derivedSymbolInstanceHtml, 'Override'), 'derived-symbol-instance-text-override'); +$assert(str_contains($derivedSymbolInstanceHtml, 'data-figma-node-id="derived:instance/40:3"'), 'derived-symbol-instance-icon-namespaced'); +$assert(str_contains($derivedSymbolInstanceHtml, 'd="M 0 0 L 10 0 L 10 10 Z"'), 'derived-symbol-instance-icon-geometry'); +$assert(! str_contains($derivedSymbolInstanceHtml, '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_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; +} + +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; +} + +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 + */ +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', + 'fills' => array( + array('type' => 'IMAGE', 'imageHash' => 'synthetic'), + ), + ), + ), + ), + ), + ), + ); +} + +/** + * @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') ) { + $compressed = zstd_compress('synthetic zstd payload'); + if ( false !== $compressed ) { + return $compressed; + } + } + + return "\x28\xb5\x2f\xfd" . 'synthetic-zstd-frame'; +} diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index b403365..a0683a1 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -39,9 +39,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']; } @@ -241,6 +238,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d if ( '' === $blockMarkup && '' !== trim($content) ) { $blockMarkup = $this->htmlDocumentBlockMarkup($content); } + $bodyFormat = '' !== trim($blockMarkup) ? 'blocks' : 'html'; $pages[] = array_filter( array( 'source_path' => $path, @@ -249,9 +247,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', @@ -327,7 +325,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/ArtifactCompiler/ArtifactNormalizer.php b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php index 426a65a..ff5b8a0 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php @@ -13,8 +13,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/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 863c0c7..6a4019c 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -86,7 +86,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); @@ -149,7 +149,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, ), ); @@ -239,6 +239,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 @@ -315,6 +339,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; } @@ -470,8 +505,9 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca } if ( 'svg' === $tagName ) { - if ( $this->isSafeDecorativeSvgElement($element) ) { - return $this->createBlock('core/html', array( 'content' => $this->safeFallbackHtml($element) ), array(), $element); + $svgBlock = $this->inlineSvgBlockFromElement($element); + if ( null !== $svgBlock ) { + return $svgBlock; } $this->captureInlineSvgFallback($element, $fallbacks); @@ -581,7 +617,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]; @@ -823,11 +859,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 @@ -1441,6 +1485,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/plugin-bootstrap.php b/php-transformer/tests/contract/plugin-bootstrap.php index 17ba996..15c72d5 100644 --- a/php-transformer/tests/contract/plugin-bootstrap.php +++ b/php-transformer/tests/contract/plugin-bootstrap.php @@ -3,7 +3,7 @@ require dirname(__DIR__, 2) . '/php-transformer.php'; -assertSame('0.1.1', blocks_engine_php_transformer_version(), 'Plugin helper exposes version.'); +assertSame('0.1.2', blocks_engine_php_transformer_version(), 'Plugin helper exposes version.'); assertSame(dirname(__DIR__, 2), blocks_engine_php_transformer_path(), 'Plugin helper exposes package path.'); $htmlInput = '

Plugin bootstrap

Ready.

'; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 8f31ceb..67d5797 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\AssetAnalysis\ReferenceAnalyzer; use Automattic\BlocksEngine\PhpTransformer\FormatBridge\FormatAdapterInterface; use Automattic\BlocksEngine\PhpTransformer\FormatBridge\FormatBridge; @@ -172,6 +173,41 @@ 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(str_contains((string) ($buttonBlocks[0]['innerBlocks'][0]['attrs']['text'] ?? ''), 'Reserve now'), 'anchor button text preserves visible label'); +$assert(str_contains((string) ($buttonBlocks[1]['innerBlocks'][0]['attrs']['text'] ?? ''), 'Call us'), 'button text preserves visible label'); +$assert(! str_contains((string) $buttonResult['serialized_blocks'], '\\u003c'), 'button serialization avoids escaped nested HTML attrs'); + +$inlineSvgVisualWrapper = ( new HtmlTransformer() )->transform( + '
' +)->toArray(); +$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 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": "