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
53 changes: 53 additions & 0 deletions lib/Horde/Image.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,59 @@ public static function brightness($color)
return round((($r * 299) + ($g * 587) + ($b * 114)) / 1000);
}

/**
* Returns the WCAG relative luminance of a color (0.0 - 1.0).
*
* @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
*
* @param string $color An HTML color, e.g.: #ffffcc.
*
* @return float The relative luminance.
*/
public static function relativeLuminance($color)
{
[$r, $g, $b] = self::getColor($color);
$f = function ($c) {
$c /= 255;
return ($c <= 0.03928)
? $c / 12.92
: pow(($c + 0.055) / 1.055, 2.4);
};

return (0.2126 * $f($r)) + (0.7152 * $f($g)) + (0.0722 * $f($b));
}

/**
* Picks the foreground color that contrasts best with a background,
* using the WCAG contrast-ratio formula.
*
* This is more accurate than thresholding brightness(): for some
* saturated mid-luminance colors a naive brightness threshold picks the
* lower-contrast option (e.g. white on bright magenta). Comparing the
* actual WCAG contrast ratio against both candidates always picks the
* more readable one.
*
* @param string $bg The background color, e.g.: #ff00ee.
* @param string $light Candidate light foreground (default white).
* @param string $dark Candidate dark foreground (default black).
*
* @return string Either $light or $dark, whichever contrasts best.
*/
public static function contrastColor($bg, $light = '#fff', $dark = '#000')
{
$lbg = self::relativeLuminance($bg);
$ratio = function ($l1, $l2) {
$hi = max($l1, $l2);
$lo = min($l1, $l2);
return ($hi + 0.05) / ($lo + 0.05);
};

$cLight = $ratio($lbg, self::relativeLuminance($light));
$cDark = $ratio($lbg, self::relativeLuminance($dark));

return ($cLight >= $cDark) ? $light : $dark;
}

/**
* Calculates the grayscale value of a color.
*
Expand Down
94 changes: 94 additions & 0 deletions test/unit/Color/WcagContrastTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

namespace Horde\Image\Test\Unit\Color;

use Horde_Image;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

/**
* @author Torben Dannhauer <torben@dannhauer.de>
*/
#[CoversClass(Horde_Image::class)]
final class WcagContrastTest extends TestCase
{
public function testRelativeLuminanceBlack(): void
{
self::assertEqualsWithDelta(0.0, Horde_Image::relativeLuminance('#000'), 0.0001);
}

public function testRelativeLuminanceWhite(): void
{
self::assertEqualsWithDelta(1.0, Horde_Image::relativeLuminance('#fff'), 0.0001);
}

public function testRelativeLuminanceMidGray(): void
{
// #808080 → linearized sRGB ≈ 0.2159 per channel → WCAG L ≈ 0.2159
self::assertEqualsWithDelta(0.2159, Horde_Image::relativeLuminance('#808080'), 0.0001);
}

public function testRelativeLuminanceShorthandHex(): void
{
self::assertEqualsWithDelta(
Horde_Image::relativeLuminance('#ffffff'),
Horde_Image::relativeLuminance('#fff'),
0.0001
);
}

/**
* @dataProvider contrastColorProvider
*/
public function testContrastColor(string $background, string $expected): void
{
self::assertSame($expected, Horde_Image::contrastColor($background));
}

/**
* @return array<string, array{string, string}>
*/
public static function contrastColorProvider(): array
{
return [
'bright magenta prefers black' => ['#fb00ec', '#000'],
'shorthand magenta prefers black' => ['#f0e', '#000'],
'blue prefers white' => ['#0000ff', '#fff'],
'dark green prefers white' => ['#008000', '#fff'],
'white background prefers black' => ['#ffffff', '#000'],
'black background prefers white' => ['#000000', '#fff'],
'mid gray prefers black' => ['#808080', '#000'],
];
}

public function testContrastColorPreservesCandidateFormat(): void
{
self::assertSame(
'#000000',
Horde_Image::contrastColor('#fb00ec', '#ffffff', '#000000')
);
}

public function testContrastColorCustomCandidates(): void
{
self::assertSame(
'#111111',
Horde_Image::contrastColor('#ffffff', '#eeeeee', '#111111')
);
self::assertSame(
'#eeeeee',
Horde_Image::contrastColor('#000000', '#eeeeee', '#111111')
);
}

public function testContrastColorDiffersFromBrightnessThreshold(): void
{
$background = '#fb00ec';
$legacy = Horde_Image::brightness($background) < 128 ? '#fff' : '#000';

self::assertSame('#fff', $legacy);
self::assertSame('#000', Horde_Image::contrastColor($background));
}
}