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(); } 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")); } /**