1024, 'mobile' => 767]; /** * Dieselben Grenzen, als Medienabfragen fuer einen Verweis im Seitenkopf. * * Genau eine trifft zu, und sie trifft dieselbe Variante wie die Regel weiter unten - sonst * holte ein Vorabverweis ein zweites Bild. */ public const PRELOAD_DEVICES = [ 'desktop' => '(min-width:1025px)', 'tablet' => '(min-width:768px) and (max-width:1024px)', 'mobile' => '(max-width:767px)', ]; public function __construct(private ?MediaLibrary $media = null) { } /** @throws InvalidDocument */ public function validate(mixed $style): void { $this->require(is_array($style), 'Style must be an object.'); $properties = StyleSchema::properties(); $hoverable = StyleSchema::hoverable(); foreach ($style as $device => $values) { $this->require(isset(StyleSchema::DEVICES[$device]) && is_array($values), 'Unknown style device.'); foreach ($values as $key => $value) { if ('hover' === $key) { $this->require(is_array($value), 'Hover styles must be an object.'); foreach ($value as $hoverKey => $hoverValue) { $this->require(in_array($hoverKey, $hoverable, true), sprintf('The property "%s" has no hover value.', (string) $hoverKey)); $this->value($properties[$hoverKey], $hoverValue, (string) $hoverKey); } continue; } if ('scrolled' === $key) { $this->require(is_array($value), 'Scrolled styles must be an object.'); foreach ($value as $scrolledKey => $scrolledValue) { $this->require(in_array($scrolledKey, StyleSchema::scrollable(), true), sprintf('The property "%s" has no scrolled value.', (string) $scrolledKey)); $this->value($properties[$scrolledKey], $scrolledValue, (string) $scrolledKey); } continue; } $this->require(is_string($key) && isset($properties[$key]), sprintf('Unknown style property "%s".', (string) $key)); $this->value($properties[$key], $value, $key); } } } private function value(array $property, mixed $value, string $key): void { if (($property['sides'] ?? false) || ($property['corners'] ?? false)) { $parts = ($property['corners'] ?? false) ? StyleSchema::corners() : StyleSchema::sides(); $this->require(is_array($value), sprintf('"%s" is given per part.', $key)); foreach ($value as $part => $single) { $this->require(isset($parts[$part]), sprintf('Unknown part for "%s".', $key)); $this->scalar($property, $single, $key); } return; } $this->scalar($property, $value, $key); } private function scalar(array $property, mixed $value, string $key): void { $this->require(is_string($value) || is_int($value) || is_float($value), sprintf('"%s" needs a simple value.', $key)); $text = (string) $value; match ($property['type']) { 'color' => $this->require(1 === preg_match(StyleSchema::COLOR, $text), sprintf('"%s" is not a colour.', $key)), 'length' => $this->require(1 === preg_match(StyleSchema::LENGTH, $text), sprintf('"%s" is not a length.', $key)), 'length-auto' => $this->require(1 === preg_match(StyleSchema::LENGTH_AUTO, $text), sprintf('"%s" is not a length.', $key)), 'choice' => $this->require(isset($property['values'][$text]), sprintf('"%s" is not an allowed value for %s.', $text, $key)), 'number' => $this->require( is_numeric($value) && (float) $value >= $property['min'] && (float) $value <= $property['max'], sprintf('"%s" is outside its range.', $key), ), 'font' => $this->require(1 === preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $text), sprintf('"%s" is not a font key.', $key)), // A share of nothing is a column that cannot be seen, so zero is refused with the rest. 'fractions' => $this->require( 1 === preg_match(StyleSchema::FRACTIONS, $text) && !in_array(0.0, array_map(floatval(...), explode(' ', $text)), true), sprintf('"%s" is not a list of column shares.', $key), ), 'media' => $this->require('none' === $text || 1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)), default => $this->require(false, 'Unknown style type.'), }; } /** * Compiles every styled block of a document into one stylesheet. * * @return string CSS, already escaped by construction; empty when nothing is styled */ public function compileDocument(array $document): string { $css = ''; $walk = function (array $blocks, array $parent = []) use (&$walk, &$css): void { foreach ($blocks as $block) { if (is_array($block['style'] ?? null) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', (string) ($block['id'] ?? ''))) { $css .= $this->compileBlock((string) $block['id'], $block['style']); if ('image' === ($block['type'] ?? null)) { $css .= $this->imageAlignment((string) $block['id'], $block['style'], $parent); } if ('row' === ($block['type'] ?? null)) { $css .= $this->columnTracks((string) $block['id'], $block['style'], is_array($block['layout'] ?? null) ? $block['layout'] : [], count($block['children'] ?? [])); } } $walk($block['children'] ?? [], $block); } }; $walk($document['blocks'] ?? []); return $css; } /** * In a normal section/column, align-self cannot position a figure. Align its box * and the possibly smaller image inside it, including linked images. A horizontal * flex group must keep align-self's vertical axis without consuming its main-axis * space with automatic margins. Resolve both styles at each breakpoint so a parent * layout change or "auto" on a phone cannot leave desktop alignment behind. */ private function imageAlignment(string $id, array $style, array $parent): string { $css = $previous = ''; $values = $group = []; // These block types render children in an unstyled inner wrapper. $wrapped = in_array($parent['type'] ?? '', ['section', 'accordionitem', 'slider'], true); foreach (['desktop', 'tablet', 'mobile'] as $device) { $values = array_replace_recursive($values, $style[$device] ?? []); $group = array_replace_recursive($group, $parent['style'][$device] ?? []); if (!isset($values['alignSelf'])) { continue; } $alignment = match ($values['alignSelf']) { 'center' => ['auto', 'auto', 'center'], 'flex-start' => ['0', 'auto', 'start'], 'flex-end' => ['auto', '0', 'end'], default => null, }; $horizontalFlex = !$wrapped && in_array($group['display'] ?? '', ['flex', 'inline-flex'], true) && in_array($group['flexDirection'] ?? 'row', ['row', 'row-reverse'], true); [$left, $right, $justify] = $horizontalFlex || null === $alignment ? [$values['margin']['left'] ?? '0', $values['margin']['right'] ?? '0', 'auto'] : $alignment; $rules = sprintf('#%s{margin-left:%s;margin-right:%s;justify-self:%s}#%s img{margin-left:%s;margin-right:%s}', $id, $left, $right, $justify, $id, $alignment[0] ?? '0', $alignment[1] ?? '0'); if ($rules !== $previous) { $css .= isset(self::BREAKPOINTS[$device]) ? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rules) : $rules; $previous = $rules; } } return $css; } private function compileBlock(string $id, array $style): string { $css = ''; $style = $this->scaleBackground($style); foreach (['desktop', 'tablet', 'mobile'] as $device) { $values = $style[$device] ?? null; if (!is_array($values)) { continue; } $rules = ''; $base = $this->declarations($values, 'self', $device); if ('' !== $base) { $rules .= sprintf('#%s{%s}', $id, $base); } // Some properties belong on the images inside the block, not on the block: object-fit // on a figure does nothing, and a radius on the figure leaves the image square. $images = $this->declarations($values, 'img', $device); if ('' !== $images) { $rules .= sprintf('#%s img{%s}', $id, $images); } // Slider colours: on the slider for the script-free markers, and on the control bar // the script places right after it. $controls = $this->declarations($values, 'controls', $device); if ('' !== $controls) { $rules .= sprintf('#%s,#%s + .slider-controls{%s}', $id, $id, $controls); } // Der Menü-Knopf: die Farben liegen am Block, gelten aber dem aufklappbaren Knopf. $burger = $this->declarations($values, 'burger', $device); if ('' !== $burger) { $rules .= sprintf('#%s .menu-burger>summary{%s}', $id, $burger); } $hover = is_array($values['hover'] ?? null) ? $this->declarations($this->inheritShadow($values['hover'], $values), 'self', $device) : ''; if ('' !== $hover) { $rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover); } // Das Symbol im Button (ADR 109): Farbe, Fläche, Größe und Drehung liegen am Button, // gelten aber seiner Symbolhülle - auch beim Überfahren und bei Tastaturfokus. $icon = $this->declarations($values, 'icon', $device); if ('' !== $icon) { $rules .= sprintf('#%s .button-icon{%s}', $id, $icon); } $iconHover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'icon', $device) : ''; if ('' !== $iconHover) { $rules .= sprintf('#%s:hover .button-icon,#%s:focus-visible .button-icon{%s}', $id, $id, $iconHover); } // The scrolled state: scrolled.js puts `is-scrolled` on once the page has moved. // The change eases in, so a header does not snap from glass to solid. $scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($this->inheritShadow($values['scrolled'], $values), 'self', $device) : ''; if ('' !== $scrolled) { $rules .= sprintf('#%s{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s,opacity .3s}html.is-scrolled #%s{%s}', $id, $id, $scrolled); } if ('' === $rules) { continue; } // Desktop first, narrower screens override it. Mobile last so it wins on small screens. $css .= isset(self::BREAKPOINTS[$device]) ? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rules) : $rules; } return $css; } /** * Turns one validated value set into CSS declarations. * * @param string $target 'self' for the block, 'img' for the images inside it */ private function declarations(array $values, string $target = 'self', string $device = 'desktop'): string { $properties = StyleSchema::properties(); $out = []; foreach ($values as $key => $value) { if ('hover' === $key || !isset($properties[$key])) { continue; } $property = $properties[$key]; if ($target !== ($property['target'] ?? 'self')) { continue; } if (null === $property['css']) { continue; // Composed below out of several values. } if ($property['corners'] ?? false) { // border-radius becomes border-top-left-radius and friends. foreach ($value as $corner => $single) { $out[] = sprintf('border-%s-radius:%s', StyleSchema::cornerCss((string) $corner), $this->css($property, (string) $single)); } continue; } if ($property['sides'] ?? false) { // The spelling comes from the schema: "border-top-width", not "border-width-top". $pattern = $property['sideCss'] ?? $property['css'].'-%s'; foreach ($value as $side => $single) { $out[] = sprintf('%s:%s', sprintf($pattern, $side), $this->css($property, (string) $single)); } continue; } $out[] = sprintf('%s:%s', $property['css'], $this->css($property, (string) $value)); } // The composed declarations describe the block itself; none of them has an image variant. if ('self' === $target) { foreach ([ $this->background($values, $device), $this->shadow($values), $this->textShadow($values), $this->filter($values), $this->transform($values), $this->grid($values), $this->transition($values), ] as $composed) { if ('' !== $composed) { $out[] = $composed; } } } return implode(';', $out); } private function css(array $property, string $value): string { if ('color' === $property['type']) { return str_starts_with($value, '@') ? sprintf('var(--k-%s)', substr($value, 1)) : $value; } if ('font' === $property['type']) { return sprintf('var(--kf-%s)', $value); } return $value; } /** Overlay, gradient and image share one background-image declaration, layered in that order. */ private function background(array $values, string $device = 'desktop'): string { $layers = []; $overlay = $values['overlayColor'] ?? null; if (null !== $overlay) { $colour = $this->css(['type' => 'color'], (string) $overlay); $alpha = isset($values['overlayOpacity']) ? (float) $values['overlayOpacity'] : 0.5; $layers[] = sprintf('linear-gradient(color-mix(in srgb,%s %d%%,transparent),color-mix(in srgb,%s %d%%,transparent))', $colour, (int) round($alpha * 100), $colour, (int) round($alpha * 100)); } if (isset($values['gradientFrom'], $values['gradientTo'])) { $layers[] = sprintf( 'linear-gradient(%ddeg,%s,%s)', (int) ($values['gradientAngle'] ?? 180), $this->css(['type' => 'color'], (string) $values['gradientFrom']), $this->css(['type' => 'color'], (string) $values['gradientTo']), ); } if (isset($values['backgroundImage']) && 'none' !== $values['backgroundImage'] && null !== $this->media) { $item = $this->media->find((string) $values['backgroundImage']); if (null !== $item && [] !== $item['variants']) { // A phone should not download the 2400 pixel variant of a background. CSS has no // srcset, but the rules are already written per device, so the device decides. $largest = self::variantFor($item['variants'], $device); // The URL comes from the media library, never from the document. $layers[] = sprintf('url("%s")', $largest['src']); } } return [] === $layers ? ('none' === ($values['backgroundImage'] ?? null) ? 'background-image:none' : '') : 'background-image:'.implode(',', $layers); } /** * Ein Zustand, der nur die Schattenfarbe aendert, erbt Versatz, Weichzeichnung und Ausdehnung * vom Grundzustand - sonst hiesse er `box-shadow:0 0 0 0 ` und waere unsichtbar. */ private function inheritShadow(array $state, array $base): array { foreach (['shadow' => ['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowInset'], 'textShadow' => ['textShadowX', 'textShadowY', 'textShadowBlur']] as $colour => $parts) { if (!isset($state[$colour.'Color']) && [] === array_intersect_key($state, array_flip($parts))) { continue; } foreach ($parts as $part) { if (!isset($state[$part]) && isset($base[$part])) { $state[$part] = $base[$part]; } } } return $state; } private function shadow(array $values): string { $parts = array_intersect_key($values, array_flip(['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowColor'])); if ([] === $parts) { return ''; } return sprintf( 'box-shadow:%s%s %s %s %s %s', 'innen' === ($values['shadowInset'] ?? 'aussen') ? 'inset ' : '', $values['shadowX'] ?? '0', $values['shadowY'] ?? '0', $values['shadowBlur'] ?? '0', $values['shadowSpread'] ?? '0', $this->css(['type' => 'color'], (string) ($values['shadowColor'] ?? '#00000026')), ); } /** A text shadow, from the same kind of parts as the box shadow. */ private function textShadow(array $values): string { $parts = array_intersect_key($values, array_flip(['textShadowX', 'textShadowY', 'textShadowBlur', 'textShadowColor'])); if ([] === $parts) { return ''; } return sprintf( 'text-shadow:%s %s %s %s', $values['textShadowX'] ?? '0', $values['textShadowY'] ?? '0', $values['textShadowBlur'] ?? '0', $this->css(['type' => 'color'], (string) ($values['textShadowColor'] ?? '#00000040')), ); } /** * The image filters, as one declaration in a fixed order. * * The order matters for the result, so it is written here rather than taken from the order in * which someone happened to set the values. */ private function filter(array $values): string { $parts = []; foreach ([ 'blur' => 'blur(%spx)', 'brightness' => 'brightness(%s)', 'contrast' => 'contrast(%s)', 'saturate' => 'saturate(%s)', 'grayscale' => 'grayscale(%s)', 'sepia' => 'sepia(%s)', 'invert' => 'invert(%s)', ] as $key => $format) { if (isset($values[$key])) { $parts[] = sprintf($format, (float) $values[$key]); } } if (isset($values['hueRotate'])) { $parts[] = sprintf('hue-rotate(%ddeg)', (int) $values['hueRotate']); } return [] === $parts ? '' : 'filter:'.implode(' ', $parts); } private function transform(array $values): string { $parts = []; foreach (['translateX' => 'translateX(%s)', 'translateY' => 'translateY(%s)'] as $key => $format) { if (isset($values[$key])) { $parts[] = sprintf($format, $values[$key]); } } if (isset($values['scale'])) { $parts[] = sprintf('scale(%s)', (float) $values['scale']); } if (isset($values['rotate'])) { $parts[] = sprintf('rotate(%ddeg)', (int) $values['rotate']); } return [] === $parts ? '' : 'transform:'.implode(' ', $parts); } /** * The variant a device should load for a background. * * Mobile takes the smallest that is at least 960 wide, tablet the one around 1600, desktop the * largest. A picture that only has small variants simply gives what it has. */ /** * Ein Hintergrundbild gilt auch dann in Gerätegrößen, wenn es nur einmal gesetzt wurde. * * Wer im Editor ein Hintergrundbild wählt, setzt es üblicherweise am Schreibtisch - also unter * „Desktop“. Ohne eigene Regel für die kleineren Geräte erbt ein Telefon diese Regel und lädt * die größte Variante: gemessen 189 KB statt 40 KB für ein Bild, das nie in dieser Auflösung * zu sehen ist. CSS kennt kein srcset, aber die Regeln stehen ohnehin je Gerät da - also * bekommt jedes Gerät dieselbe Bildwahl, die es bekäme, wenn jemand sie einzeln gesetzt hätte. * * Ein ausdrücklich gesetztes Bild wird nie ersetzt; ergänzt wird nur, wo nichts steht. */ private function scaleBackground(array $style): array { $inherited = $style['desktop'] ?? []; foreach (['tablet', 'mobile'] as $device) { if (isset($inherited['backgroundImage']) || isset($style[$device]['backgroundImage'])) { $style[$device]['backgroundImage'] ??= $inherited['backgroundImage']; // Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem // Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text. foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) { if (isset($inherited[$begleiter]) && !isset($style[$device][$begleiter])) { $style[$device][$begleiter] = $inherited[$begleiter]; } } } $inherited = array_replace($inherited, $style[$device] ?? []); } return $style; } /** * Die Variante, die ein Hintergrundbild auf diesem Geraet bekommt. * * Oeffentlich und statisch, weil der Seitenkopf dieselbe Wahl treffen muss, wenn er das Bild * vorab holt. Zwei Stellen mit derselben Regel waeren zwei Stellen, die auseinanderlaufen. * * @param list $variants * * @return array{src: string, width: int} */ public static function variantFor(array $variants, string $device): array { $wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000; $chosen = $variants[0]; foreach ($variants as $variant) { if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) { $chosen = $variant; } } return $chosen; } /** * The column shares of a row: "2 1" becomes two tracks of 2fr and 1fr. Shares inherit from the * wider device like every other style. A device whose layout stacks the columns - "one" from * the classic tokens or a single grid column - gets one track instead: the desktop rule carries * an id and would otherwise outrank the class that stacks the columns on a phone. */ private function columnTracks(string $id, array $style, array $layout, int $count): string { $css = $previous = ''; $widths = null; $stacked = false; foreach (['desktop', 'tablet', 'mobile'] as $device) { $values = is_array($style[$device] ?? null) ? $style[$device] : []; $widths = $values['columnWidths'] ?? $widths; if (isset($values['gridColumns'])) { $stacked = 1 === (int) $values['gridColumns']; } elseif (isset($layout['design'][$device]['columns'])) { $stacked = 'one' === $layout['design'][$device]['columns']; } if (null === $widths || $count < 2) { continue; } $rule = sprintf('#%s{grid-template-columns:%s}', $id, $stacked ? 'minmax(0,1fr)' : $this->tracks((string) $widths, $count)); if ($rule === $previous) { continue; } $css .= isset(self::BREAKPOINTS[$device]) ? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rule) : $rule; $previous = $rule; } return $css; } /** One track per column: a column added later gets the average share, surplus shares are dropped. */ private function tracks(string $widths, int $count): string { $shares = array_values(array_filter(array_map(floatval(...), explode(' ', $widths)), static fn (float $share): bool => $share > 0)); $shares = array_slice($shares, 0, $count); $average = [] === $shares ? 1.0 : array_sum($shares) / count($shares); while (count($shares) < $count) { $shares[] = $average; } $format = static fn (float $share): string => rtrim(rtrim(number_format($share, 1, '.', ''), '0'), '.'); return implode(' ', array_map(static fn (float $share): string => sprintf('minmax(0,%sfr)', $format($share)), $shares)); } private function grid(array $values): string { return isset($values['gridColumns']) ? sprintf('grid-template-columns:repeat(%d,minmax(0,1fr))', max(1, min(12, (int) $values['gridColumns']))) : ''; } private function transition(array $values): string { return isset($values['transition']) ? sprintf('transition:all %dms ease', max(0, min(2000, (int) $values['transition']))) : ''; } private function require(bool $condition, string $message): void { if (!$condition) { throw new InvalidDocument($message); } } }