From fb46078d429e5417b7c5ca15dfff975bed0a4f4d Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 13:56:34 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ColorValue=20pr=C3=BCft=20Farbwerte=20(?= =?UTF-8?q?Hex,=20rgb/hsl,=20Schl=C3=BCsselwort)=20und=20die=20relativen?= =?UTF-8?q?=20Einstellungen=20von=20Farbverweisen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/Integrations/Divi/Design/ColorValue.php | 97 ++++++++++++++++ .../Divi/Design/ColorValueTest.php | 105 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 src/Integrations/Divi/Design/ColorValue.php create mode 100644 tests/Unit/Integrations/Divi/Design/ColorValueTest.php diff --git a/src/Integrations/Divi/Design/ColorValue.php b/src/Integrations/Divi/Design/ColorValue.php new file mode 100644 index 0000000..d0b602c --- /dev/null +++ b/src/Integrations/Divi/Design/ColorValue.php @@ -0,0 +1,97 @@ +}} zusammen mit "relative".'; + + /** + * shortcut: these ranges are the sliders of the Visual Builder, not derived from Divi's PHP code; widen them if a + * Divi release accepts more. + */ + private const RANGES = [ + 'hue' => [-360, 360], + 'saturation' => [-100, 100], + 'lightness' => [-100, 100], + 'opacity' => [-100, 100], + ]; + + private const MAX_LENGTH = 100; + + private const HEX = '/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iD'; + + /** Only characters a color function needs; no "(", ")", ";" or quotes inside – nothing to break out of. */ + private const FUNCTION = '/^(?:rgb|rgba|hsl|hsla)\(\s*[0-9a-z.%+\-\/,\s]*\)$/iD'; + + private const KEYWORD = '/^[a-z]{3,20}$/iD'; + + /** @return string|null Null when $color is a valid plain color value, else a German message. */ + public static function check(string $color): ?string + { + if ($color === '') { + return 'Farbwert fehlt. ' . self::TYPES_NOTE; + } + if (str_contains($color, '$variable(') || str_contains($color, '{{')) { + return 'Verweise ($variable(…)$) und Platzhalter sind hier kein Farbwert; Platzhalter {{color:}} ' + . 'gehören allein in "color" und brauchen "relative".'; + } + + $valid = strlen($color) <= self::MAX_LENGTH && ( + preg_match(self::HEX, $color) === 1 + || preg_match(self::KEYWORD, $color) === 1 + || (preg_match(self::FUNCTION, $color) === 1 && preg_match('/\d/', $color) === 1) + ); + + return $valid ? null : sprintf('Ungültiger Farbwert "%s". %s', mb_substr($color, 0, 60), self::TYPES_NOTE); + } + + /** + * The settings for VariableRef::serialize('color', …): only hue, saturation, lightness, opacity, numbers within + * their ranges, in that order; whole floats become ints. + * + * @param array $relative + * @return array|string The settings, or a German message. + */ + public static function relative(array $relative): array|string + { + if ($relative === []) { + return 'relative: mindestens eine Einstellung nötig (' . implode(', ', array_keys(self::RANGES)) . ').'; + } + + $unknown = array_diff(array_map('strval', array_keys($relative)), array_keys(self::RANGES)); + if ($unknown !== []) { + return sprintf( + 'relative: unbekannte Einstellung "%s"; erlaubt: %s.', + implode('", "', $unknown), + implode(', ', array_keys(self::RANGES)) + ); + } + + $settings = []; + foreach (self::RANGES as $key => [$min, $max]) { + if (! array_key_exists($key, $relative)) { + continue; + } + $value = $relative[$key]; + $number = is_int($value) || is_float($value); + if (! $number || ! is_finite((float) $value) || $value < $min || $value > $max) { + return sprintf('relative.%s: Zahl zwischen %d und %d erwartet.', $key, $min, $max); + } + $settings[$key] = is_float($value) && floor($value) === $value ? (int) $value : $value; + } + + return $settings; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/ColorValueTest.php b/tests/Unit/Integrations/Divi/Design/ColorValueTest.php new file mode 100644 index 0000000..b3181cc --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/ColorValueTest.php @@ -0,0 +1,105 @@ + */ + public static function validColors(): array + { + return [ + 'hex3' => ['#abc'], 'hex4' => ['#abcd'], 'hex6' => ['#5B3A29'], 'hex8' => ['#5b3a29cc'], + 'rgb' => ['rgb(10, 20, 30)'], 'rgba' => ['rgba(10,20,30,0.5)'], 'rgb modern' => ['rgb(10 20 30 / 50%)'], + 'hsl' => ['hsl(210, 50%, 40%)'], 'hsla' => ['hsla(210deg, 50%, 40%, .5)'], + 'keyword' => ['red'], 'keyword upper' => ['Transparent'], 'keyword long' => ['lightgoldenrodyellow'], + ]; + } + + /** @dataProvider validColors */ + public function testValidColors(string $color): void + { + self::assertNull(ColorValue::check($color)); + } + + /** @return array */ + public static function invalidColors(): array + { + return [ + 'empty' => [''], 'hex5' => ['#abcde'], 'hex7' => ['#abcdef1'], 'hex without hash' => ['abcdef12'], + 'hex digits' => ['#ggg'], 'surrounding space' => [' #fff'], 'function without number' => ['rgb()'], + 'function with var' => ['rgb(var(--x))'], 'function with semicolon' => ['rgb(1;2;3)'], + 'unknown function' => ['url(1)'], 'keyword too short' => ['re'], 'keyword with digit' => ['red1'], + 'keyword too long' => ['abcdefghijklmnopqrstu'], 'css injection' => ['red;}body{x:y'], + 'too long' => ['rgb(' . str_repeat('1,', 60) . '1)'], + 'reference' => ['$variable({"type":"color","value":{"name":"gcid-a","settings":{}}})$'], + 'placeholder' => ['{{color:primary}}'], + ]; + } + + /** @dataProvider invalidColors */ + public function testInvalidColorsGetAGermanMessage(string $color): void + { + $message = ColorValue::check($color); + + self::assertIsString($message); + self::assertNotSame('', $message); + } + + public function testMessageNamesTheAllowedForms(): void + { + self::assertStringContainsString(ColorValue::TYPES_NOTE, (string) ColorValue::check('#abcde')); + self::assertStringContainsString('Farbschlüsselwort', ColorValue::TYPES_NOTE); + self::assertStringContainsString('Platzhalter', (string) ColorValue::check('{{color:primary}}')); + } + + public function testRelativeKeepsTheOrderAndNormalizesWholeFloats(): void + { + self::assertSame( + ['hue' => 10, 'lightness' => -12.5, 'opacity' => 100], + ColorValue::relative(['opacity' => 100.0, 'lightness' => -12.5, 'hue' => 10]) + ); + self::assertSame(['saturation' => 0], ColorValue::relative(['saturation' => 0])); + } + + public function testRelativeBoundsAreInclusive(): void + { + self::assertSame(['hue' => -360], ColorValue::relative(['hue' => -360])); + self::assertSame(['hue' => 360], ColorValue::relative(['hue' => 360])); + self::assertSame(['opacity' => 100], ColorValue::relative(['opacity' => 100])); + self::assertSame(['opacity' => -100], ColorValue::relative(['opacity' => -100])); + } + + public function testRelativeRejectsRangesTypesAndUnknownKeys(): void + { + self::assertSame('relative.hue: Zahl zwischen -360 und 360 erwartet.', ColorValue::relative(['hue' => 361])); + self::assertSame( + 'relative.lightness: Zahl zwischen -100 und 100 erwartet.', + ColorValue::relative(['lightness' => -101]) + ); + self::assertSame( + 'relative.saturation: Zahl zwischen -100 und 100 erwartet.', + ColorValue::relative(['saturation' => '10']) + ); + self::assertSame( + 'relative.opacity: Zahl zwischen -100 und 100 erwartet.', + ColorValue::relative(['opacity' => true]) + ); + self::assertSame( + 'relative.hue: Zahl zwischen -360 und 360 erwartet.', + ColorValue::relative(['hue' => NAN]) + ); + self::assertSame( + 'relative: unbekannte Einstellung "alpha"; erlaubt: hue, saturation, lightness, opacity.', + ColorValue::relative(['alpha' => 1]) + ); + self::assertSame( + 'relative: mindestens eine Einstellung nötig (hue, saturation, lightness, opacity).', + ColorValue::relative([]) + ); + } +}