From 141d1e90260ca63f415d65060752f4c9d3e8eb28 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 14 Dec 2023 17:53:18 +0100 Subject: [PATCH] enh(theming): Adjust color utils to work as specified by WCAG (color contrast and luma calculation) Signed-off-by: Ferdinand Thiessen --- apps/theming/lib/Themes/CommonThemeTrait.php | 2 +- apps/theming/lib/Util.php | 55 ++++++++++++++++++-- apps/theming/tests/UtilTest.php | 29 +++++++++-- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/apps/theming/lib/Themes/CommonThemeTrait.php b/apps/theming/lib/Themes/CommonThemeTrait.php index 5a389c8533f..0b033b3fac9 100644 --- a/apps/theming/lib/Themes/CommonThemeTrait.php +++ b/apps/theming/lib/Themes/CommonThemeTrait.php @@ -40,7 +40,7 @@ trait CommonThemeTrait { */ protected function generatePrimaryVariables(string $colorMainBackground, string $colorMainText): array { $isBrightColor = $this->util->isBrightColor($colorMainBackground); - $colorPrimaryElement = $this->util->elementColor($this->primaryColor, $isBrightColor); + $colorPrimaryElement = $this->util->elementColor($this->primaryColor, $isBrightColor, $colorMainBackground); $colorPrimaryLight = $this->util->mix($colorPrimaryElement, $colorMainBackground, -80); $colorPrimaryElementLight = $this->util->mix($colorPrimaryElement, $colorMainBackground, -80); diff --git a/apps/theming/lib/Util.php b/apps/theming/lib/Util.php index 951b07bfa2a..c4f0123460e 100644 --- a/apps/theming/lib/Util.php +++ b/apps/theming/lib/Util.php @@ -57,7 +57,7 @@ class Util { * @return bool */ public function invertTextColor(string $color): bool { - return $this->isBrightColor($color); + return $this->colorContrast($color, '#ffffff') < 4.5; } /** @@ -81,7 +81,28 @@ class Util { * @param ?bool $brightBackground * @return string */ - public function elementColor($color, ?bool $brightBackground = null) { + public function elementColor($color, ?bool $brightBackground = null, ?string $backgroundColor = null) { + if ($backgroundColor !== null) { + $brightBackground = $brightBackground ?? $this->isBrightColor($backgroundColor); + // Minimal amount that is possible to change the luminance + $epsilon = 1.0 / 255.0; + // Current iteration to prevent infinite loops + $iteration = 0; + // We need to keep blurred backgrounds in mind which might be mixed with the background + $blurredBackground = $this->mix($backgroundColor, $brightBackground ? $color : '#ffffff', 66); + $contrast = $this->colorContrast($color, $blurredBackground); + + // Min. element contrast is 3:1 but we need to keep hover states in mind -> min 3.2:1 + while ($contrast < 3.2 && $iteration++ < 100) { + $hsl = Color::hexToHsl($color); + $hsl['L'] = max(0, min(1, $hsl['L'] + ($brightBackground ? -$epsilon : $epsilon))); + $color = '#' . Color::hslToHex($hsl); + $contrast = $this->colorContrast($color, $blurredBackground); + } + return $color; + } + + // Fallback for legacy calling $luminance = $this->calculateLuminance($color); if ($brightBackground !== false && $luminance > 0.8) { @@ -139,12 +160,38 @@ class Util { } /** + * Calculate the Luma according to WCAG 2 + * http://www.w3.org/TR/WCAG20/#relativeluminancedef * @param string $color rgb color value * @return float */ public function calculateLuma(string $color): float { - [$red, $green, $blue] = $this->hexToRGB($color); - return (0.2126 * $red + 0.7152 * $green + 0.0722 * $blue) / 255; + $rgb = $this->hexToRGB($color); + + // Normalize the values by converting to float and applying the rules from WCAG2.0 + $rgb = array_map(function (int $color) { + $color = $color / 255.0; + if ($color <= 0.03928) { + return $color / 12.92; + } else { + return pow((($color + 0.055) / 1.055), 2.4); + } + }, $rgb); + + [$red, $green, $blue] = $rgb; + return (0.2126 * $red + 0.7152 * $green + 0.0722 * $blue); + } + + /** + * Calculat the color contrast according to WCAG 2 + * http://www.w3.org/TR/WCAG20/#contrast-ratiodef + * @param string $color1 The first color + * @param string $color2 The second color + */ + public function colorContrast(string $color1, string $color2): float { + $luminance1 = $this->calculateLuma($color1) + 0.05; + $luminance2 = $this->calculateLuma($color2) + 0.05; + return max($luminance1, $luminance2) / min($luminance1, $luminance2); } /** diff --git a/apps/theming/tests/UtilTest.php b/apps/theming/tests/UtilTest.php index 0d986a2b112..857e9fff6a5 100644 --- a/apps/theming/tests/UtilTest.php +++ b/apps/theming/tests/UtilTest.php @@ -35,19 +35,20 @@ use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; use OCP\IConfig; +use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; class UtilTest extends TestCase { /** @var Util */ protected $util; - /** @var IConfig */ + /** @var IConfig|MockObject */ protected $config; - /** @var IAppData */ + /** @var IAppData|MockObject */ protected $appData; - /** @var IAppManager */ + /** @var IAppManager|MockObject */ protected $appManager; - /** @var ImageManager */ + /** @var ImageManager|MockObject */ protected $imageManager; protected function setUp(): void { @@ -59,11 +60,29 @@ class UtilTest extends TestCase { $this->util = new Util($this->config, $this->appManager, $this->appData, $this->imageManager); } + public function dataColorContrast() { + return [ + ['#ffffff', '#FFFFFF', 1], + ['#000000', '#000000', 1], + ['#ffffff', '#000000', 21], + ['#000000', '#FFFFFF', 21], + ['#9E9E9E', '#353535', 4.578], + ['#353535', '#9E9E9E', 4.578], + ]; + } + + /** + * @dataProvider dataColorContrast + */ + public function testColorContrast(string $color1, string $color2, $contrast) { + $this->assertEqualsWithDelta($contrast, $this->util->colorContrast($color1, $color2), .001); + } + public function dataInvertTextColor() { return [ ['#ffffff', true], ['#000000', false], - ['#0082C9', false], + ['#00679e', false], ['#ffff00', true], ]; } -- 2.39.5