getGotenbergBaseUrl(); } /** * @param list> $questionResults */ public function createPdfExportResponse(SurveyModel $survey, int $completedSubmissionCount, array $questionResults): Response { $gotenbergBaseUrl = $this->getGotenbergBaseUrl(); if (null === $gotenbergBaseUrl) { throw new BadRequestHttpException('Es wurde keine Gotenberg-URL in den Einstellungen hinterlegt.'); } $html = $this->renderTemplate( $survey, $completedSubmissionCount, $this->prepareQuestionResults($questionResults), ); $filename = $this->buildFilename((string) $survey->title); $pdfContent = $this->requestPdfFromGotenberg($gotenbergBaseUrl, $html, $filename); return new Response( $pdfContent, Response::HTTP_OK, [ 'Content-Type' => 'application/pdf', 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), ], ); } private function getGotenbergBaseUrl(): ?string { $url = trim((string) Config::get('surveyGotenbergUrl')); return '' !== $url ? rtrim($url, '/') : null; } private function requestPdfFromGotenberg(string $gotenbergBaseUrl, string $html, string $filename): string { $formData = new FormDataPart([ 'files' => new DataPart($html, 'index.html', 'text/html'), 'paperWidth' => '8.27', 'paperHeight' => '11.7', 'marginTop' => '0.5', 'marginBottom' => '0.5', 'marginLeft' => '0.5', 'marginRight' => '0.5', 'emulatedMediaType' => 'screen', 'printBackground' => 'true', 'failOnConsoleExceptions' => 'true', 'failOnResourceLoadingFailed' => 'true', ]); $headers = $formData->getPreparedHeaders()->toArray(); $headers['Gotenberg-Output-Filename'] = pathinfo($filename, PATHINFO_FILENAME); try { $response = $this->httpClient->request('POST', $gotenbergBaseUrl.'/forms/chromium/convert/html', [ 'headers' => $headers, 'body' => $formData->bodyToIterable(), 'timeout' => 60, ]); } catch (TransportExceptionInterface $exception) { throw new BadGatewayHttpException('Die Gotenberg-Instanz konnte nicht erreicht werden.', $exception); } $statusCode = $response->getStatusCode(); $content = $response->getContent(false); if (Response::HTTP_OK !== $statusCode) { $message = trim(strip_tags(substr($content, 0, 500))); throw new BadGatewayHttpException(sprintf( 'Die Gotenberg-Instanz hat den PDF-Export mit HTTP %d abgelehnt%s', $statusCode, '' !== $message ? ': '.$message : '.' )); } return $content; } /** * @param list> $questionResults * * @return list> */ private function prepareQuestionResults(array $questionResults): array { $preparedResults = []; foreach ($questionResults as $questionResult) { $questionResult['pdfChartType'] = (string) (($questionResult['chart']['type'] ?? '') ?: ''); $questionResult['pdfChartDataUri'] = $this->buildChartDataUri($questionResult); $questionResult['pdfChartLegend'] = $this->buildChartLegend($questionResult); $preparedResults[] = $questionResult; } return $preparedResults; } /** * @param array $questionResult * * @return list */ private function buildChartLegend(array $questionResult): array { $chart = $questionResult['chart'] ?? null; $chartType = is_array($chart) ? (string) ($chart['type'] ?? '') : ''; $sourceRows = match ($chartType) { 'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [], 'line' => $questionResult['distribution'] ?? [], // Freitext hat kein Diagramm: Antwort-Häufigkeiten wie in der Online-Ansicht zeigen. default => $questionResult['responseSummary'] ?? [], }; if (!is_array($sourceRows) || [] === $sourceRows) { return []; } $dataset = is_array($chart) ? ($chart['data']['datasets'][0] ?? []) : []; $fillColors = match ($chartType) { 'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [], 'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [], default => [], }; $borderColors = match ($chartType) { 'pie' => is_array($dataset['borderColor'] ?? null) ? $dataset['borderColor'] : [], 'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [], default => [], }; $legend = []; foreach (array_values($sourceRows) as $index => $row) { if (!is_array($row)) { continue; } $legend[] = [ 'label' => (string) ($row['label'] ?? ''), 'fillColor' => (string) ($fillColors[$index] ?? '#D3E4F1'), 'borderColor' => (string) ($borderColors[$index] ?? '#0064AD'), 'count' => (int) ($row['count'] ?? 0), 'percentage' => (int) ($row['percentage'] ?? 0), ]; } return $legend; } private function renderTemplate(SurveyModel $survey, int $completedSubmissionCount, array $questionResults): string { $templatePath = __DIR__.'/../Resources/views/pdf/survey_results.html.twig'; $templateContent = file_get_contents($templatePath); if (false === $templateContent) { throw new \RuntimeException('Die PDF-Template-Datei konnte nicht gelesen werden: '.$templatePath); } return $this->twig->createTemplate($templateContent)->render([ 'survey' => $survey, 'completedSubmissionCount' => $completedSubmissionCount, 'questionResults' => $questionResults, 'exportedAt' => (new \DateTimeImmutable())->format('d.m.Y H:i'), 'documentTitle' => $this->buildDocumentTitle((string) $survey->title), 'fontFaceCss' => $this->buildEmbeddedFontFaceCss(), ]); } private function buildDocumentTitle(string $surveyTitle): string { $title = trim(strip_tags($surveyTitle)); return '' !== $title ? $title.' Ergebnisse' : 'Umfrage Ergebnisse'; } private function buildEmbeddedFontFaceCss(): string { $fontDefinitions = [ ['family' => 'PT Sans Narrow', 'weight' => 400, 'path' => __DIR__.'/../../public/fonts/PTSansNarrow-Regular.woff2'], ['family' => 'PT Sans Narrow', 'weight' => 700, 'path' => __DIR__.'/../../public/fonts/PTSansNarrow-Bold.woff2'], ['family' => 'Blogger Sans', 'weight' => 500, 'path' => __DIR__.'/../../public/fonts/Blogger Sans-Medium.woff2'], ]; $rules = []; foreach ($fontDefinitions as $fontDefinition) { $fontContent = @file_get_contents($fontDefinition['path']); if (false === $fontContent) { continue; } $rules[] = sprintf( '@font-face { font-family: "%s"; src: url("data:font/woff2;base64,%s") format("woff2"); font-weight: %d; font-style: normal; font-display: swap; }', $fontDefinition['family'], base64_encode($fontContent), $fontDefinition['weight'], ); } return implode("\n", $rules); } /** * @param array $questionResult */ private function buildChartDataUri(array $questionResult): ?string { $chart = $questionResult['chart'] ?? null; if (!is_array($chart)) { return null; } $svg = match ((string) ($chart['type'] ?? '')) { 'pie' => $this->buildPieChartSvg($questionResult, $chart), 'line' => $this->buildLineChartSvg($questionResult, $chart), default => null, }; if (null === $svg) { return null; } return 'data:image/svg+xml;base64,'.base64_encode($svg); } /** * @param array $questionResult * @param array $chart */ private function buildPieChartSvg(array $questionResult, array $chart): ?string { $options = $questionResult['options'] ?? $questionResult['responseSummary'] ?? []; if (!is_array($options) || [] === $options) { return $this->buildEmptyChartSvg('Noch keine Daten'); } $dataset = $chart['data']['datasets'][0] ?? null; $fillColors = is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : []; $borderColors = is_array($dataset['borderColor'] ?? null) ? $dataset['borderColor'] : []; $counts = array_map(static fn (array $option): int => (int) ($option['count'] ?? 0), $options); $total = array_sum($counts); if ($total <= 0) { return $this->buildEmptyChartSvg('Noch keine Daten'); } $centerX = 140.0; $centerY = 140.0; $radius = 88.0; $startAngle = -90.0; $segments = []; foreach ($options as $index => $option) { $count = (int) ($option['count'] ?? 0); if ($count <= 0) { continue; } $sweepAngle = ($count / $total) * 360.0; $endAngle = $startAngle + $sweepAngle; $largeArcFlag = $sweepAngle > 180.0 ? 1 : 0; $startPoint = $this->polarToCartesian($centerX, $centerY, $radius, $startAngle); $endPoint = $this->polarToCartesian($centerX, $centerY, $radius, $endAngle); $fill = (string) ($fillColors[$index] ?? '#D3E4F1'); $stroke = (string) ($borderColors[$index] ?? '#0064AD'); $segments[] = sprintf( '', $centerX, $centerY, $startPoint['x'], $startPoint['y'], $radius, $radius, $largeArcFlag, $endPoint['x'], $endPoint['y'], $this->escapeXml($fill), $this->escapeXml($stroke), ); $startAngle = $endAngle; } return sprintf( '%s%d', implode('', $segments), $total, ); } /** * @param array $questionResult * @param array $chart */ private function buildLineChartSvg(array $questionResult, array $chart): ?string { $distribution = $questionResult['distribution'] ?? []; if (!is_array($distribution) || [] === $distribution) { return $this->buildEmptyChartSvg('Noch keine Bewertungen'); } $labels = array_map(static fn (array $row): string => (string) ($row['label'] ?? ''), $distribution); $values = array_map(static fn (array $row): int => (int) ($row['count'] ?? 0), $distribution); $maxValue = max(1, max($values)); $width = 560.0; $height = 280.0; $paddingLeft = 58.0; $paddingRight = 22.0; $paddingTop = 24.0; $paddingBottom = 44.0; $plotWidth = $width - $paddingLeft - $paddingRight; $plotHeight = $height - $paddingTop - $paddingBottom; $steps = max(1, count($values) - 1); $dataset = is_array($chart['data']['datasets'][0] ?? null) ? $chart['data']['datasets'][0] : []; $lineColor = (string) ($dataset['borderColor'] ?? '#0064AD'); $areaFill = (string) ($dataset['backgroundColor'] ?? 'rgba(236, 124, 50, 0.12)'); $pointColors = is_array($chart['data']['datasets'][0]['pointBackgroundColor'] ?? null) ? $chart['data']['datasets'][0]['pointBackgroundColor'] : []; $polylinePoints = []; $areaPolylinePoints = []; $pointCircles = []; $xLabels = []; $yGrid = []; // Große Skalen (z. B. Alter 1–100): X-Beschriftung ausdünnen und die // Punkt-Marker verkleinern bzw. weglassen, damit das Diagramm lesbar bleibt. $pointCount = count($values); $labelStep = max(1, (int) ceil($pointCount / 12)); $lastIndex = $pointCount - 1; $pointRadius = $pointCount > 40 ? 0.0 : ($pointCount > 20 ? 3.0 : 5.5); $yStep = max(1, (int) ceil($maxValue / 6)); for ($tick = 0; $tick <= $maxValue; $tick += $yStep) { $ratio = $maxValue > 0 ? $tick / $maxValue : 0; $y = $paddingTop + $plotHeight - ($ratio * $plotHeight); $yGrid[] = sprintf('', $paddingLeft, $y, $width - $paddingRight, $y); $yGrid[] = sprintf('%d', $paddingLeft - 8, $y + 4, $tick); } foreach ($values as $index => $value) { $x = $paddingLeft + ($plotWidth * ($index / $steps)); $y = $paddingTop + $plotHeight - (($value / $maxValue) * $plotHeight); $polylinePoints[] = sprintf('%.2f,%.2f', $x, $y); $areaPolylinePoints[] = sprintf('%.2f,%.2f', $x, $y); if ($pointRadius > 0.0) { $pointCircles[] = sprintf('', $x, $y, $pointRadius, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32'))); } if (0 === $index % $labelStep || $index === $lastIndex) { $xLabels[] = sprintf('%s', $x, $height - 16, $this->escapeXml($labels[$index])); } } $areaPoints = sprintf( '%.2f,%.2f %s %.2f,%.2f', $paddingLeft, $paddingTop + $plotHeight, implode(' ', $areaPolylinePoints), $width - $paddingRight, $paddingTop + $plotHeight, ); return sprintf( '%s%s%s', implode('', $yGrid), $paddingLeft, $paddingTop + $plotHeight, $width - $paddingRight, $paddingTop + $plotHeight, $paddingLeft, $paddingTop, $paddingLeft, $paddingTop + $plotHeight, $this->escapeXml($areaFill), $areaPoints, $this->escapeXml($lineColor), implode(' ', $polylinePoints), implode('', $pointCircles), implode('', $xLabels), ); } private function buildEmptyChartSvg(string $label): string { return sprintf( '%s', $this->escapeXml($label), ); } /** * @return array{x:float,y:float} */ private function polarToCartesian(float $centerX, float $centerY, float $radius, float $angleInDegrees): array { $angleInRadians = deg2rad($angleInDegrees); return [ 'x' => $centerX + ($radius * cos($angleInRadians)), 'y' => $centerY + ($radius * sin($angleInRadians)), ]; } private function escapeXml(string $value): string { return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8'); } private function buildFilename(string $title): string { $normalizedTitle = preg_replace('/\s+/', '_', trim($title)) ?: 'Umfrage'; $sanitizedTitle = preg_replace('/[^\pL\pN_-]+/u', '', $normalizedTitle) ?: 'Umfrage'; return sprintf('%s_Ergebnisse.pdf', trim($sanitizedTitle, '_-')); } }