ensureBelongsToEvent($event, $joinToken); $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) { return route('api.v1.tenant.events.join-tokens.layouts.download', [ 'event' => $event, 'joinToken' => $joinToken, 'layout' => $layoutId, 'format' => $format, ]); }); return response()->json([ 'data' => $layouts, ]); } public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format) { $this->ensureBelongsToEvent($event, $joinToken); $layoutConfig = JoinTokenLayoutRegistry::find($layout); if (! $layoutConfig) { abort(404, 'Layout nicht gefunden.'); } if (! in_array($format, ['pdf', 'png'], true)) { abort(404, 'Unbekanntes Exportformat.'); } $layoutConfig = $this->applyCustomization($layoutConfig, $joinToken); $tokenUrl = url('/e/'.$joinToken->token); $qrPngDataUri = 'data:image/png;base64,'.base64_encode( QrCode::format('png') ->margin(0) ->size($layoutConfig['qr']['size_px']) ->generate($tokenUrl) ); $backgroundStyle = $this->buildBackgroundStyle($layoutConfig); $eventName = $this->resolveEventName($event); $viewData = [ 'layout' => $layoutConfig, 'event' => $event, 'eventName' => $eventName, 'token' => $joinToken, 'tokenUrl' => $tokenUrl, 'qrPngDataUri' => $qrPngDataUri, 'backgroundStyle' => $backgroundStyle, 'customization' => $joinToken->metadata['layout_customization'] ?? null, 'advancedLayout' => $this->buildAdvancedLayout( $layoutConfig, $joinToken->metadata['layout_customization'] ?? null, $qrPngDataUri, $tokenUrl, $eventName ), ]; $html = view('layouts.join-token.pdf', $viewData)->render(); $options = new Options; $options->set('isHtml5ParserEnabled', true); $options->set('isRemoteEnabled', true); $options->set('defaultFont', 'Helvetica'); $dompdf = new Dompdf($options); $dompdf->setPaper(strtoupper($layoutConfig['paper']), $layoutConfig['orientation'] === 'landscape' ? 'landscape' : 'portrait'); $dompdf->loadHtml($html, 'UTF-8'); $dompdf->render(); $pdfBinary = $dompdf->output(); $filenameStem = sprintf('%s-%s', Str::slug($eventName ?: 'event'), $layoutConfig['id']); if ($format === 'png') { if (! class_exists(\Imagick::class)) { abort(500, 'PNG-Export erfordert Imagick.'); } try { $imagick = new \Imagick; $imagick->setResolution(300, 300); $imagick->setBackgroundColor(new \ImagickPixel('white')); $imagick->setOption('pdf:use-cropbox', 'true'); $imagick->readImageBlob($pdfBinary); $imagick->setIteratorIndex(0); $flattened = $imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); $flattened->setImageFormat('png'); $flattened->setImageUnits(\Imagick::RESOLUTION_PIXELSPERINCH); $flattened->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); $pngBinary = $flattened->getImagesBlob(); $flattened->clear(); $flattened->destroy(); $imagick->clear(); $imagick->destroy(); } catch (\Throwable $exception) { report($exception); abort(500, 'PNG-Export konnte nicht erzeugt werden.'); } return response($pngBinary) ->header('Content-Type', 'image/png') ->header('Content-Disposition', 'attachment; filename="'.$filenameStem.'.png"'); } return response($pdfBinary) ->header('Content-Type', 'application/pdf') ->header('Content-Disposition', 'attachment; filename="'.$filenameStem.'.pdf"'); } private function ensureBelongsToEvent(Event $event, EventJoinToken $joinToken): void { if ($joinToken->event_id !== $event->id) { abort(404); } } private function resolveEventName(Event $event): string { $name = $event->name; if (is_array($name)) { $locale = $event->default_locale ?? 'de'; return $name[$locale] ?? $name['de'] ?? reset($name) ?: 'Event'; } return is_string($name) && $name !== '' ? $name : 'Event'; } private function applyCustomization(array $layout, EventJoinToken $joinToken): array { $customization = data_get($joinToken->metadata, 'layout_customization'); if (! is_array($customization)) { return $layout; } $layoutId = $customization['layout_id'] ?? null; if (is_string($layoutId) && isset($layout['id']) && $layoutId !== $layout['id']) { // Allow customization to target a specific layout; if mismatch, skip style overrides. // General text overrides are still applied below. } $colorKeys = [ 'accent' => 'accent_color', 'text' => 'text_color', 'background' => 'background_color', 'secondary' => 'secondary_color', 'badge' => 'badge_color', ]; foreach ($colorKeys as $layoutKey => $customKey) { if (isset($customization[$customKey]) && is_string($customization[$customKey])) { $layout[$layoutKey] = $customization[$customKey]; } } if (isset($customization['background_gradient']) && is_array($customization['background_gradient'])) { $layout['background_gradient'] = $customization['background_gradient']; } foreach (['headline' => 'name', 'subtitle', 'description', 'badge_label', 'instructions_heading', 'link_heading', 'cta_label', 'cta_caption', 'link_label'] as $customKey => $layoutKey) { if (isset($customization[$customKey]) && is_string($customization[$customKey])) { $layout[$layoutKey] = $customization[$customKey]; } } if (array_key_exists('instructions', $customization) && is_array($customization['instructions'])) { $layout['instructions'] = array_values(array_filter($customization['instructions'], fn ($value) => is_string($value) && trim($value) !== '')); } if (! empty($customization['logo_data_url']) && is_string($customization['logo_data_url'])) { $layout['logo_url'] = $customization['logo_data_url']; } elseif (! empty($customization['logo_url']) && is_string($customization['logo_url'])) { $layout['logo_url'] = $customization['logo_url']; } return $layout; } private function buildBackgroundStyle(array $layout): string { $gradient = $layout['background_gradient'] ?? null; if (is_array($gradient) && ! empty($gradient['stops'])) { $angle = $gradient['angle'] ?? 180; $stops = implode(',', $gradient['stops']); return sprintf('linear-gradient(%ddeg,%s)', $angle, $stops); } return $layout['background'] ?? '#FFFFFF'; } private function buildAdvancedLayout(array $layout, $customization, string $qrPngDataUri, string $tokenUrl, string $eventName): ?array { if (! is_array($customization)) { return null; } if (($customization['mode'] ?? null) !== 'advanced') { return null; } $elements = $customization['elements'] ?? null; if (! is_array($elements) || empty($elements)) { return null; } $width = (int) ($layout['svg']['width'] ?? 1080); $height = (int) ($layout['svg']['height'] ?? 1520); $accent = $layout['accent'] ?? '#6366F1'; $text = $layout['text'] ?? '#0F172A'; $secondary = $layout['secondary'] ?? '#1F2937'; $badge = $layout['badge'] ?? $accent; $resolved = []; foreach ($elements as $element) { if (! is_array($element) || empty($element['id']) || empty($element['type'])) { continue; } $type = (string) $element['type']; $dimensions = $this->normalizeElementDimensions($type, $element, $width, $height); $content = $this->resolveElementContent($type, $customization, $layout, $eventName, $tokenUrl, $element['content'] ?? null); $style = [ 'position' => 'absolute', 'left' => sprintf('%dpx', $dimensions['x']), 'top' => sprintf('%dpx', $dimensions['y']), 'width' => sprintf('%dpx', $dimensions['width']), 'height' => sprintf('%dpx', $dimensions['height']), ]; if (isset($element['rotation']) && is_numeric($element['rotation']) && (float) $element['rotation'] !== 0.0) { $style['transform'] = sprintf('rotate(%sdeg)', (float) $element['rotation']); $style['transform-origin'] = 'center center'; } $fontSize = isset($element['font_size']) && is_numeric($element['font_size']) ? max(8, min(160, (float) $element['font_size'])) : null; $lineHeight = isset($element['line_height']) && is_numeric($element['line_height']) ? max(0.5, min(3.0, (float) $element['line_height'])) : null; $letterSpacing = isset($element['letter_spacing']) && is_numeric($element['letter_spacing']) ? (float) $element['letter_spacing'] : null; $resolved[] = [ 'id' => (string) $element['id'], 'type' => $type, 'content' => $content, 'align' => $element['align'] ?? null, 'font_size' => $fontSize, 'line_height' => $lineHeight, 'letter_spacing' => $letterSpacing, 'font_family' => $element['font_family'] ?? null, 'fill' => $element['fill'] ?? null, 'style' => $style, 'style_string' => $this->styleToString($style), 'width' => $dimensions['width'], 'height' => $dimensions['height'], 'asset' => match ($type) { 'qr' => $qrPngDataUri, 'logo' => $customization['logo_data_url'] ?? $customization['logo_url'] ?? $layout['logo_url'] ?? null, default => null, }, ]; } if (empty($resolved)) { return null; } return [ 'width' => $width, 'height' => $height, 'background' => $layout['background'] ?? '#FFFFFF', 'background_gradient' => $layout['background_gradient'] ?? null, 'accent' => $accent, 'text' => $text, 'secondary' => $secondary, 'badge' => $badge, 'qr_src' => $qrPngDataUri, 'logo_src' => $customization['logo_data_url'] ?? $customization['logo_url'] ?? $layout['logo_url'] ?? null, 'elements' => $resolved, ]; } private function resolveElementContent(string $type, array $customization, array $layout, string $eventName, string $tokenUrl, $fallback = null): ?string { return match ($type) { 'headline' => $customization['headline'] ?? $eventName, 'subtitle' => $customization['subtitle'] ?? ($layout['subtitle'] ?? ''), 'description' => $customization['description'] ?? ($layout['description'] ?? ''), 'link' => (isset($customization['link_label']) && trim($customization['link_label']) !== '') ? $customization['link_label'] : $tokenUrl, 'badge' => $customization['badge_label'] ?? ($layout['badge_label'] ?? 'Digitale Gästebox'), 'cta' => $customization['cta_label'] ?? ($layout['cta_label'] ?? 'Scan mich & starte direkt'), default => $fallback !== null ? (string) $fallback : null, }; } private function styleToString(array $style): string { return implode(';', array_map( static fn ($key, $value) => sprintf('%s:%s', $key, $value), array_keys($style), $style )); } private function normalizeElementDimensions(string $type, array $element, int $canvasWidth, int $canvasHeight): array { $minDimensions = match ($type) { 'qr' => ['width' => 240, 'height' => 240], 'logo' => ['width' => 140, 'height' => 100], 'badge' => ['width' => 160, 'height' => 60], 'cta' => ['width' => 200, 'height' => 80], 'text' => ['width' => 200, 'height' => 120], default => ['width' => 160, 'height' => 80], }; $width = (int) round((float) ($element['width'] ?? $minDimensions['width'])); $height = (int) round((float) ($element['height'] ?? $minDimensions['height'])); $width = max($minDimensions['width'], min($width, $canvasWidth)); $height = max($minDimensions['height'], min($height, $canvasHeight)); if ($type === 'qr') { $size = (int) round(max($width, $height)); $size = max(240, min($size, min($canvasWidth, $canvasHeight))); $width = $height = $size; } $maxX = max($canvasWidth - $width, 0); $maxY = max($canvasHeight - $height, 0); $x = (int) round((float) ($element['x'] ?? 0)); $y = (int) round((float) ($element['y'] ?? 0)); $x = max(0, min($x, $maxX)); $y = max(0, min($y, $maxY)); return [ 'x' => $x, 'y' => $y, 'width' => $width, 'height' => $height, ]; } }