From 9dbbb93a980387353109f5989e312541df8d37df Mon Sep 17 00:00:00 2001 From: MCJ Vasseur <14887731+vmcj@users.noreply.github.com> Date: Thu, 7 Aug 2025 21:23:13 +0200 Subject: [PATCH 1/2] Fix spelling No functional change intended. --- webapp/src/Entity/ContestProblem.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/Entity/ContestProblem.php b/webapp/src/Entity/ContestProblem.php index 1fadd43507..21d02e6bfe 100644 --- a/webapp/src/Entity/ContestProblem.php +++ b/webapp/src/Entity/ContestProblem.php @@ -261,7 +261,7 @@ public function validate(ExecutionContextInterface $context): void { if ($this->getColor() && Utils::convertToHex($this->getColor()) === null) { $context - ->buildViolation('This is not a valid color') + ->buildViolation('This is not a valid color.') ->atPath('color') ->addViolation(); } From f4e085cf4ebf4c5bd78f07006f4b21ef30d7b4da Mon Sep 17 00:00:00 2001 From: MCJ Vasseur <14887731+vmcj@users.noreply.github.com> Date: Sat, 9 Aug 2025 08:54:24 +0200 Subject: [PATCH 2/2] Allow Alpha in RGBA strings The Alpha is allowed by the CLICS spec but we don't really use those in the UI normally. The only place where we have to fully ignore the alpha is for the luminance. In that case we now by default merge it with a white background most likely this will be the same for either background but this was not tested. As bonus we now also test for the shorthand (#ABC) versuse (#AABBCC). The functions using the strings have moved from the TwigExtension to utils as the they don't really depend on Twig itself. This makes testing easier and can use this in the future for other elements. --- webapp/src/Twig/TwigExtension.php | 56 +--------------- webapp/src/Utils/Utils.php | 95 +++++++++++++++++++++++++-- webapp/tests/Unit/Utils/UtilsTest.php | 53 ++++++++++++--- 3 files changed, 137 insertions(+), 67 deletions(-) diff --git a/webapp/src/Twig/TwigExtension.php b/webapp/src/Twig/TwigExtension.php index aed9adf2ae..9267a1e28c 100644 --- a/webapp/src/Twig/TwigExtension.php +++ b/webapp/src/Twig/TwigExtension.php @@ -1155,64 +1155,14 @@ public function fileTypeIcon(string $type): string return 'fas fa-file-' . $iconName; } - private function relativeLuminance(string $rgb): float - { - // See https://en.wikipedia.org/wiki/Relative_luminance - [$r, $g, $b] = Utils::parseHexColor($rgb); - - [$lr, $lg, $lb] = [ - pow($r / 255, 2.4), - pow($g / 255, 2.4), - pow($b / 255, 2.4), - ]; - - return 0.2126 * $lr + 0.7152 * $lg + 0.0722 * $lb; - } - - private function apcaContrast(string $fgColor, string $bgColor): float - { - // Based on WCAG 3.x (https://www.w3.org/TR/wcag-3.0/) - $luminanceForeground = $this->relativeLuminance($fgColor); - $luminanceBackground = $this->relativeLuminance($bgColor); - - $contrast = ($luminanceBackground > $luminanceForeground) - ? (pow($luminanceBackground, 0.56) - pow($luminanceForeground, 0.57)) * 1.14 - : (pow($luminanceBackground, 0.65) - pow($luminanceForeground, 0.62)) * 1.14; - - return round($contrast * 100, 2); - } - - /** - * @return array{string, string} - */ - private function hexToForegroundAndBorder(string $rgb): array - { - $background = Utils::parseHexColor($rgb); - - // Pick a border that's a bit darker. - $darker = $background; - $darker[0] = max($darker[0] - 64, 0); - $darker[1] = max($darker[1] - 64, 0); - $darker[2] = max($darker[2] - 64, 0); - $border = Utils::rgbToHex($darker); - - // Pick the text color with the biggest absolute contrast. - $contrastWithWhite = $this->apcaContrast('#ffffff', $rgb); - $contrastWithBlack = $this->apcaContrast('#000000', $rgb); - - $foreground = (abs($contrastWithBlack) > abs($contrastWithWhite)) ? '#000000' : '#ffffff'; - - return [$foreground, $border]; - } - public function problemBadge(ContestProblem $problem, bool $grayedOut = false): string { - $rgb = Utils::convertToHex($problem->getColor() ?? '#ffffff'); + $rgb = Utils::convertToHex($problem->getColor() ?? '#ffffff'); if ($grayedOut || empty($rgb)) { $rgb = Utils::convertToHex('whitesmoke'); } - [$foreground, $border] = $this->hexToForegroundAndBorder($rgb); + [$foreground, $border] = Utils::hexToForegroundAndBorder($rgb); if ($grayedOut) { $foreground = 'silver'; @@ -1238,7 +1188,7 @@ public function problemBadgeMaybe( $rgb = Utils::convertToHex('whitesmoke'); } - [$foreground, $border] = $this->hexToForegroundAndBorder($rgb); + [$foreground, $border] = Utils::hexToForegroundAndBorder($rgb); if (!$matrixItem->isCorrect) { $foreground = 'silver'; diff --git a/webapp/src/Utils/Utils.php b/webapp/src/Utils/Utils.php index 7d0a191a02..ae2815f3d4 100644 --- a/webapp/src/Utils/Utils.php +++ b/webapp/src/Utils/Utils.php @@ -343,19 +343,25 @@ public static function convertToColor(string $hex): ?string } /** - * Parse a hex color into it's three RGB values. + * Parse a hex color into it's four RGBA values. * - * @return array{int, int, int} + * @return array{int, int, int, int} */ public static function parseHexColor(string $hex): array { + $tmp = substr($hex, 1); + if (strlen($tmp) % 3 == 0) { + $mult = strlen($tmp) / 3; + $hex .= str_repeat("f", $mult); + } // Source: https://stackoverflow.com/a/21966100 - $length = (strlen($hex) - 1) / 3; + $length = (strlen($hex) - 1) / 4; $fact = [17, 1, 0.062272][$length - 1]; return [ (int)round(hexdec(substr($hex, 1, $length)) * $fact), (int)round(hexdec(substr($hex, 1 + $length, $length)) * $fact), - (int)round(hexdec(substr($hex, 1 + 2 * $length, $length)) * $fact) + (int)round(hexdec(substr($hex, 1 + 2 * $length, $length)) * $fact), + (int)round(hexdec(substr($hex, 1 + 3 * $length, $length)) * $fact) ]; } @@ -371,11 +377,88 @@ public static function componentToHex(int $component): string /** * Convert an RGB triple into a CSS hex color. * - * @param array{int, int, int} $color + * @param array{int, int, int}|array{int, int, int, int} $color */ public static function rgbToHex(array $color): string { - return "#" . static::componentToHex($color[0]) . static::componentToHex($color[1]) . static::componentToHex($color[2]); + $result = "#"; + if (count($color) === 3) { + $color[] = 255; + } + for ($i=0; $i $luminanceForeground) + ? (pow($luminanceBackground, 0.56) - pow($luminanceForeground, 0.57)) * 1.14 + : (pow($luminanceBackground, 0.65) - pow($luminanceForeground, 0.62)) * 1.14; + + return round($contrast * 100, 2); + } + + /** + * @return array{string, string} + */ + public static function hexToForegroundAndBorder(string $rgb): array + { + $background = Utils::parseHexColor($rgb); + + // Pick a border that's a bit darker. + // We explicit keep the alpha channel as-is. + $darker = $background; + $darker[0] = max($darker[0] - 64, 0); + $darker[1] = max($darker[1] - 64, 0); + $darker[2] = max($darker[2] - 64, 0); + $border = Utils::rgbToHex($darker); + + // Pick the text color with the biggest absolute contrast. + $contrastWithWhite = static::apcaContrast('#ffffff', $rgb); + $contrastWithBlack = static::apcaContrast('#000000', $rgb); + + $foreground = (abs($contrastWithBlack) > abs($contrastWithWhite)) ? '#000000' : '#ffffff'; + + return [$foreground, $border]; } /** diff --git a/webapp/tests/Unit/Utils/UtilsTest.php b/webapp/tests/Unit/Utils/UtilsTest.php index 5f35f6b4ea..360c35b55f 100644 --- a/webapp/tests/Unit/Utils/UtilsTest.php +++ b/webapp/tests/Unit/Utils/UtilsTest.php @@ -321,10 +321,13 @@ public function testConvertToHexConvert(): void public function testParseHexColor(): void { - self::assertEquals([255, 255, 255], Utils::parseHexColor('#ffffff')); - self::assertEquals([0, 0, 0], Utils::parseHexColor('#000000')); - self::assertEquals([171, 205, 239], Utils::parseHexColor('#abcdef')); - self::assertEquals([254, 220, 186], Utils::parseHexColor('#FEDCBA')); + self::assertEquals([255, 255, 255, 255], Utils::parseHexColor('#ffffff')); + self::assertEquals([0, 0, 0, 255], Utils::parseHexColor('#000000')); + self::assertEquals([0, 0, 0, 255], Utils::parseHexColor('#000')); + self::assertEquals([0, 0, 0, 0], Utils::parseHexColor('#00000000')); + self::assertEquals([0, 0, 0, 0], Utils::parseHexColor('#0000')); + self::assertEquals([171, 205, 239, 255], Utils::parseHexColor('#abcdef')); + self::assertEquals([254, 220, 186, 255], Utils::parseHexColor('#FEDCBA')); } public function testComponentToHex(): void @@ -337,10 +340,44 @@ public function testComponentToHex(): void public function testRgbToHex(): void { - self::assertEquals('#ffffff', Utils::rgbToHex([255, 255, 255])); - self::assertEquals('#000000', Utils::rgbToHex([0, 0, 0])); - self::assertEquals('#abcdef', Utils::rgbToHex([171, 205, 239])); - self::assertEquals('#fedcba', Utils::rgbToHex([254, 220, 186])); + self::assertEquals('#ffffffff', Utils::rgbToHex([255, 255, 255, 255])); + self::assertEquals('#ffffff00', Utils::rgbToHex([255, 255, 255, 0])); + self::assertEquals('#000000ff', Utils::rgbToHex([0, 0, 0, 255])); + self::assertEquals('#00000000', Utils::rgbToHex([0, 0, 0, 0])); + self::assertEquals('#abcdefff', Utils::rgbToHex([171, 205, 239, 255])); + self::assertEquals('#fedcbaff', Utils::rgbToHex([254, 220, 186, 255])); + } + + public function testRelativeLuminance(): void + { + self::assertEquals(0.0, Utils::relativeLuminance("#000000")); + self::assertEquals(1.0, Utils::relativeLuminance("#FFFfff")); + self::assertEquals(1.0, Utils::relativeLuminance("#FFFfffFF")); + self::assertEquals(0.00751604342389449, Utils::relativeLuminance("#123")); + self::assertEquals(0.528186803960141, Utils::relativeLuminance("#1234")); + } + + /** + * Test that the APCA contrast function returns the correct data + */ + public function testApcaContrast(): void + { + self::assertEquals(-114.0, Utils::apcaContrast("#ffffff", "#000000")); + self::assertEquals(114.0, Utils::apcaContrast("#000000", "#ffffff")); + self::assertEquals(0.0, Utils::apcaContrast("#fffFFF", "#FFFfff")); + self::assertEquals(-0.36, Utils::apcaContrast("#111", "#111")); + self::assertEquals(58.09, Utils::apcaContrast("#123f", "#975A")); + self::assertEquals(25.15, Utils::apcaContrast("#11223344", "#00110011")); + self::assertEquals(-35.06, Utils::apcaContrast("#11223344", "#FF0011")); + } + + public function testHexToForegroundAndBorder(): void + { + self::assertEquals(["#000000", "#bfbd9dff"], Utils::hexToForegroundAndBorder("#fffDDD")); + self::assertEquals(["#ffffff", "#000000ff"], Utils::hexToForegroundAndBorder("#000000")); + self::assertEquals(["#000000", "#6a7b8cff"], Utils::hexToForegroundAndBorder("#ABC")); + self::assertEquals(["#ffffff", "#00000099"], Utils::hexToForegroundAndBorder("#1239")); + self::assertEquals(["#000000", "#00000040"], Utils::hexToForegroundAndBorder("#10203040")); } /**