diff --git a/lib/Service/DesignTokensMapper.php b/lib/Service/DesignTokensMapper.php index 0c651531..a9421bff 100644 --- a/lib/Service/DesignTokensMapper.php +++ b/lib/Service/DesignTokensMapper.php @@ -613,12 +613,19 @@ private function serializeColorObject(array $value): array { return ['ok' => false, 'reason' => 'unsupported-color-space', 'detail' => ($value['colorSpace'] ?? 'unknown')]; } + $alphaHex = $this->alphaToHex(alpha: $value['alpha'] ?? null); + if (isset($value['hex']) === true && is_string($value['hex']) === true) { $hex = $value['hex']; if (str_starts_with($hex, '#') === false) { $hex = '#' . $hex; } + // The hex member is a 6-digit fallback with no alpha of its own. + if (strlen($hex) === 7) { + $hex .= $alphaHex; + } + return ['ok' => true, 'value' => $hex]; } @@ -626,31 +633,79 @@ private function serializeColorObject(array $value): array { && is_array($value['components']) === true && count($value['components']) >= 3 ) { - return ['ok' => true, 'value' => $this->componentsToHex(components: array_values($value['components']))]; + $hex = $this->componentsToHex( + components: array_values($value['components']), + linear: $colorSpace === 'srgb-linear' + ); + + return ['ok' => true, 'value' => $hex . $alphaHex]; } return ['ok' => false, 'reason' => 'unsupported-value-shape']; }//end serializeColorObject() /** - * Serialize three 0–1 sRGB float components to a `#rrggbb` hex literal. + * Serialize three 0–1 sRGB-family float components to a `#rrggbb` hex literal. + * + * Linear-light (`srgb-linear`) components go through the sRGB transfer + * function first; scaling them straight to 0–255 renders them too dark. * * @param array $components The `[r, g, b]` components (0–1 range, clamped). + * @param bool $linear Whether the components are linear light. * * @return string The `#rrggbb` hex literal. * - * @spec openspec/specs/custom-token-sets/spec.md + * @spec openspec/changes/authoring-dtcg-export/tasks.md#task-2.2 */ - private function componentsToHex(array $components): string { + private function componentsToHex(array $components, bool $linear): string { $hex = '#'; foreach ([0, 1, 2] as $index) { $channel = max(0.0, min(1.0, (float)$components[$index])); + if ($linear === true) { + $channel = $this->linearToSrgb(channel: $channel); + } + $hex .= str_pad(dechex((int)round($channel * 255)), 2, '0', STR_PAD_LEFT); } return $hex; }//end componentsToHex() + /** + * Apply the sRGB transfer function (CSS Color 4) to one linear-light channel. + * + * @param float $channel The linear channel, 0–1. + * + * @return float The gamma-encoded sRGB channel, 0–1. + */ + private function linearToSrgb(float $channel): float { + if ($channel <= 0.0031308) { + return 12.92 * $channel; + } + + return (1.055 * ($channel ** (1 / 2.4))) - 0.055; + }//end linearToSrgb() + + /** + * Turn a colour object's `alpha` into the fourth hex pair, or '' when opaque. + * + * @param mixed $alpha The `alpha` member (0–1), absent for an opaque colour. + * + * @return string Two hex digits, or an empty string for an opaque or absent alpha. + */ + private function alphaToHex(mixed $alpha): string { + if (is_int($alpha) === false && is_float($alpha) === false) { + return ''; + } + + $alpha = max(0.0, min(1.0, (float)$alpha)); + if ($alpha >= 1.0) { + return ''; + } + + return str_pad(dechex((int)round($alpha * 255)), 2, '0', STR_PAD_LEFT); + }//end alphaToHex() + /** * Serialize a `dimension` value: legacy string passthrough, or the * `{value, unit}` object form concatenated as ``. diff --git a/tests/Unit/Service/DesignTokensMapperTest.php b/tests/Unit/Service/DesignTokensMapperTest.php index 987f9a79..f488fefd 100644 --- a/tests/Unit/Service/DesignTokensMapperTest.php +++ b/tests/Unit/Service/DesignTokensMapperTest.php @@ -660,6 +660,76 @@ public function testAccountingInvariantHoldsAcrossFullCorpus(string $fixture): v $this->assertAccountingInvariant(document: $document, result: $result, message: $fixture); }//end testAccountingInvariantHoldsAcrossFullCorpus() + // -- Colour components: srgb-linear and alpha (thematiq#695) ------------- + + /** + * Map one object-form colour `$value` onto --nldesign-color-primary. + * + * @param array $value The DTCG colour object. + * + * @return array The mapper result. + */ + private function mapPrimaryColour(array $value): array { + return $this->mapper->map(document: ['color' => ['primary' => ['$type' => 'color', '$value' => $value]]]); + }//end mapPrimaryColour() + + /** + * Linear-light components go through the sRGB transfer function before + * scaling: a linear 0.5 grey is about #bcbcbc, not #808080. + */ + public function testSrgbLinearAppliesTransferFunction(): void { + $result = $this->mapPrimaryColour(value: ['colorSpace' => 'srgb-linear', 'components' => [0.5, 0.5, 0.5]]); + + $this->assertSame('#bcbcbc', $result['declarations']['--nldesign-color-primary']); + $this->assertSame([], $result['errors']); + }//end testSrgbLinearAppliesTransferFunction() + + /** + * The transfer function's linear segment near black is honoured too. + */ + public function testSrgbLinearNearBlackUsesTheLinearSegment(): void { + $result = $this->mapPrimaryColour(value: ['colorSpace' => 'srgb-linear', 'components' => [0.002, 0, 1]]); + + // 0.002 * 12.92 = 0.02584 -> 7 -> 07. + $this->assertSame('#0700ff', $result['declarations']['--nldesign-color-primary']); + }//end testSrgbLinearNearBlackUsesTheLinearSegment() + + /** + * A colour's alpha is kept as the fourth pair of an 8-digit hex. + */ + public function testAlphaKeptAsEightDigitHex(): void { + $result = $this->mapPrimaryColour(value: ['colorSpace' => 'srgb', 'components' => [0, 0, 0], 'alpha' => 0.5]); + + $this->assertSame('#00000080', $result['declarations']['--nldesign-color-primary']); + }//end testAlphaKeptAsEightDigitHex() + + /** + * Alpha and srgb-linear together, the issue's own example. + */ + public function testSrgbLinearWithAlpha(): void { + $result = $this->mapPrimaryColour(value: ['colorSpace' => 'srgb-linear', 'components' => [0.5, 0.5, 0.5], 'alpha' => 0.5]); + + $this->assertSame('#bcbcbc80', $result['declarations']['--nldesign-color-primary']); + }//end testSrgbLinearWithAlpha() + + /** + * An alpha on the hex fallback is kept too, since the hex member carries no alpha. + */ + public function testAlphaIsAddedToTheHexFallback(): void { + $result = $this->mapPrimaryColour(value: ['colorSpace' => 'srgb', 'components' => [0, 0, 0], 'hex' => '#000000', 'alpha' => 0.25]); + + $this->assertSame('#00000040', $result['declarations']['--nldesign-color-primary']); + }//end testAlphaIsAddedToTheHexFallback() + + /** + * A fully opaque colour stays a 6-digit hex, so existing sets import unchanged. + */ + public function testOpaqueColourStaysSixDigitHex(): void { + $result = $this->mapPrimaryColour(value: ['colorSpace' => 'srgb', 'components' => [0.0823, 0.2588, 0.451], 'alpha' => 1]); + + $this->assertSame('#154273', $result['declarations']['--nldesign-color-primary']); + }//end testOpaqueColourStaysSixDigitHex() + /** * Every JSON fixture in the corpus (malformed JSON is a controller-level * concern and is excluded here).