'int', 'default' => 14, 'min' => 10, 'max' => 32, 'step' => 1]; private const HEADER_SIZE = ['type' => 'int', 'default' => 30, 'min' => 22, 'max' => 72, 'step' => 1]; private const LINE_HEIGHT = ['type' => 'float', 'min' => 0.8, 'max' => 3, 'step' => 0.1]; private const WEIGHT = ['type' => 'weight', 'default' => '500', 'choices' => self::WEIGHTS]; private const FONT = ['type' => 'font', 'default' => null]; /** * Tablet/phone sizes inherit the next larger one while unset (ruling W3); their default is what Divi's CSS * readers assume (14 / 30) when nothing is stored at all. */ private const DEFINITIONS = [ 'heading_font' => ['label' => 'Überschrift-Schrift', ...self::FONT], 'body_font' => ['label' => 'Fließtext-Schrift', ...self::FONT], 'primary_nav_font' => ['label' => 'Hauptmenü-Schrift', ...self::FONT], 'secondary_nav_font' => ['label' => 'Nebenmenü-Schrift', ...self::FONT], 'heading_font_weight' => ['label' => 'Überschrift-Schriftstärke', ...self::WEIGHT], 'body_font_weight' => ['label' => 'Fließtext-Schriftstärke', ...self::WEIGHT], 'body_font_size' => ['label' => 'Fließtext-Schriftgröße', ...self::BODY_SIZE], 'tablet_body_font_size' => [ 'label' => 'Fließtext-Schriftgröße (Tablet)', 'inherits' => 'body_font_size', ...self::BODY_SIZE, ], 'phone_body_font_size' => [ 'label' => 'Fließtext-Schriftgröße (Smartphone)', 'inherits' => 'tablet_body_font_size', ...self::BODY_SIZE, ], 'body_header_size' => ['label' => 'Überschrift-Schriftgröße', ...self::HEADER_SIZE], 'tablet_header_font_size' => [ 'label' => 'Überschrift-Schriftgröße (Tablet)', 'inherits' => 'body_header_size', ...self::HEADER_SIZE, ], 'phone_header_font_size' => [ 'label' => 'Überschrift-Schriftgröße (Smartphone)', 'inherits' => 'tablet_header_font_size', ...self::HEADER_SIZE, ], 'body_font_height' => ['label' => 'Fließtext-Zeilenhöhe', 'default' => 1.7, ...self::LINE_HEIGHT], 'body_header_height' => ['label' => 'Überschrift-Zeilenhöhe', 'default' => 1.0, ...self::LINE_HEIGHT], 'body_header_spacing' => [ 'type' => 'int', 'label' => 'Überschrift-Buchstabenabstand', 'default' => 0, 'min' => -2, 'max' => 10, 'step' => 1, ], 'body_header_style' => [ 'type' => 'style', 'label' => 'Überschrift-Schriftstil', 'default' => null, 'choices' => self::STYLES, ], 'content_width' => [ 'type' => 'int', 'label' => 'Inhaltsbreite', 'default' => 1080, 'min' => 960, 'max' => 1920, 'step' => 1, ], 'gutter_width' => [ 'type' => 'int', 'label' => 'Spaltenabstand', 'default' => 3, 'min' => 1, 'max' => 4, 'step' => 1, ], 'section_padding' => [ 'type' => 'int', 'label' => 'Abschnitt-Innenabstand', 'default' => 4, 'min' => 0, 'max' => 10, 'step' => 1, ], 'row_padding' => [ 'type' => 'int', 'label' => 'Zeile-Innenabstand', 'default' => 2, 'min' => 0, 'max' => 10, 'step' => 1, ], ]; /** * @return array{type: 'int'|'float'|'font'|'weight'|'style', label: string, default: int|float|string|null, * min?: int|float, max?: int|float, step?: int|float, choices?: list, inherits?: string} * @throws InvalidArgumentException For a key outside the whitelist. */ public static function definition(string $key): array { return self::DEFINITIONS[$key] ?? throw new InvalidArgumentException( sprintf('Unbekannte Theme-Option "%s".', $key) ); } public static function isKey(string $key): bool { return isset(self::DEFINITIONS[$key]); } /** * Type, range, step and choices of $value; null = valid, else a German message. null as a value is "unset" – * the caller handles it before check() – and counts as invalid here. "none" and false are no font (ruling W3). */ public static function check(string $key, mixed $value): ?string { $definition = self::definition($key); $label = $definition['label']; return match ($definition['type']) { 'int', 'float' => self::checkNumber($label, $definition, $value), 'font' => self::checkFont($label, $value), default => self::checkChoice($label, $definition['choices'] ?? [], $value, $definition['type']), }; } /** * The value in the type Divi stores: int, float, weight as string, font and style as trimmed string. * Only meaningful for a value check() accepted. */ public static function normalize(string $key, mixed $value): int|float|string { return match (self::definition($key)['type']) { 'int' => (int) self::number($value), 'float' => (float) self::number($value), 'weight' => (string) (int) self::number($value), default => trim((string) (is_scalar($value) ? $value : '')), }; } /** * What Divi uses for $key: the stored value, else (while unset) the effective value of the key it inherits from * (ruling W3), else Divi's default; null when there is neither (a font or style left at Divi's default). * A stored value that is false, null or no usable value counts as unset. * * @param array $stored */ public static function effective(string $key, array $stored): int|float|string|null { $definition = self::definition($key); $value = self::usable($definition['type'], $stored[$key] ?? null); if ($value !== null) { return $value; } return isset($definition['inherits']) ? self::effective($definition['inherits'], $stored) : $definition['default']; } /** @param array $definition */ private static function checkNumber(string $label, array $definition, mixed $value): ?string { $number = self::number($value); if ($number === null || ! is_finite((float) $number)) { return sprintf('%s: erwartet eine Zahl, erhalten %s.', $label, self::describe($value)); } if ($definition['type'] === 'int' && floor((float) $number) !== (float) $number) { return sprintf('%s: erwartet eine ganze Zahl, erhalten %s.', $label, self::describe($value)); } $min = $definition['min']; $max = $definition['max']; if ($number < $min || $number > $max) { return sprintf( '%s: %s liegt außerhalb von %s bis %s.', $label, self::decimal($number), self::decimal($min), self::decimal($max) ); } $steps = ($number - $min) / $definition['step']; if (abs($steps - round($steps)) > 1e-6) { return sprintf( '%s: %s passt nicht zur Schrittweite %s (ab %s).', $label, self::decimal($number), self::decimal($definition['step']), self::decimal($min) ); } return null; } private static function checkFont(string $label, mixed $value): ?string { if (! is_string($value)) { return sprintf('%s: erwartet einen Schriftnamen (Text), erhalten %s.', $label, self::describe($value)); } $name = trim($value); if ($name === '' || mb_strlen($name) > self::MAX_FONT_LENGTH) { return sprintf('%s: der Schriftname muss 1 bis %d Zeichen lang sein.', $label, self::MAX_FONT_LENGTH); } if (preg_match('/[\x00-\x1f\x7f<>"\'`]/u', $name) === 1 || ! mb_check_encoding($name, 'UTF-8')) { return sprintf('%s: der Schriftname enthält unzulässige Zeichen.', $label); } if (strtolower($name) === 'none') { return sprintf('%s: "none" ist kein Schriftname; null setzt auf Divis Standardschrift zurück.', $label); } return null; } /** @param list $choices */ private static function checkChoice(string $label, array $choices, mixed $value, string $type): ?string { $text = match (true) { is_string($value) => $value, is_int($value) && $type === 'weight' => (string) $value, default => null, }; if ($text === null || ! in_array($text, $choices, true)) { return sprintf( '%s: erlaubt ist genau ein Wert aus %s, erhalten %s.', $label, implode(', ', $choices), self::describe($value) ); } return null; } /** The number in $value (int, float or a plain decimal string), else null. */ private static function number(mixed $value): int|float|null { return match (true) { is_int($value), is_float($value) => $value, is_string($value) && preg_match('/^-?\d+(\.\d+)?$/D', $value) === 1 => $value + 0, default => null, }; } /** A stored value in the type of $type, or null when it is not usable (false, empty, garbage). */ private static function usable(string $type, mixed $value): int|float|string|null { if ($type === 'font' || $type === 'style') { return is_string($value) && trim($value) !== '' && strtolower(trim($value)) !== 'none' ? trim($value) : null; } $number = self::number($value); if ($number === null) { return null; } return match ($type) { 'int' => (int) $number, 'float' => (float) $number, default => (string) (int) $number, }; } private static function decimal(int|float $number): string { return str_replace('.', ',', (string) $number); } private static function describe(mixed $value): string { return match (true) { is_string($value) => '"' . mb_substr($value, 0, 40) . '"', is_bool($value) => $value ? 'true' : 'false', $value === null => 'null', is_int($value), is_float($value) => var_export($value, true), default => 'einen ' . get_debug_type($value), }; } }