Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions lib/Service/DesignTokensMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -613,44 +613,99 @@ 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];
}

if (isset($value['components']) === true
&& 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<int, mixed> $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 `<value><unit>`.
Expand Down
70 changes: 70 additions & 0 deletions tests/Unit/Service/DesignTokensMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $value The DTCG colour object.
*
* @return array<string, mixed> 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).
Expand Down
Loading