Files
survey-bundle/src/Service/SurveyResultsPdfService.php
T
admin.juergen 954a5102ac Refine range results and restore new-tab view links
Zweite Runde Kundenrückmeldung:

- Range-Ergebnisse: Das Diagramm behält die komplette lineare Skala
  (rangeMin..rangeMax), aber die Werte-Liste/Tabelle zeigt nur noch die
  tatsächlich beantworteten Werte. Sonst würde eine große Skala (z. B. Alter
  1-100, Schrittweite 1) die Ergebnisseite mit hunderten Nullzeilen fluten.
  Neues Feld distributionRows für die Liste; distribution bleibt fürs Diagramm.
- Große Skalen: X-Achsenbeschriftung wird ausgedünnt (Chart.js autoSkip/
  maxTicksLimit, SVG jede n-te Beschriftung), Punkt-Marker verkleinert bzw.
  ab >40 Punkten weggelassen. Auch die Y-Ticks im PDF-SVG werden gedeckelt.
- PDF-Range-Tabelle ohne Farb-Swatch-Spalte (nur Wert/Anzahl/Anteil), da die
  Kopplung an ein Vollskalen-Diagramm nicht mehr sinnvoll ist.
- Aktionslinks in der Frontend-Liste: Öffentliche Ansicht, Entwurfs-Ansicht
  und Ergebnisse anzeigen öffnen wieder im neuen Fenster (target=_blank);
  nur Bearbeiten bleibt im selben Tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:15:05 +02:00

457 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\Config;
use Mummert\SurveyBundle\Model\SurveyModel;
use Symfony\Component\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadGatewayHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Twig\Environment;
final class SurveyResultsPdfService
{
public function __construct(
private readonly Environment $twig,
private readonly HttpClientInterface $httpClient,
) {
}
public function isConfigured(): bool
{
return null !== $this->getGotenbergBaseUrl();
}
/**
* @param list<array<string, mixed>> $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<array<string, mixed>> $questionResults
*
* @return list<array<string, mixed>>
*/
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<string, mixed> $questionResult
*
* @return list<array{label:string,fillColor:string,borderColor:string,count:int,percentage:int}>
*/
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<string, mixed> $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<string, mixed> $questionResult
* @param array<string, mixed> $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(
'<path d="M %.2f %.2f L %.2f %.2f A %.2f %.2f 0 %d 1 %.2f %.2f Z" fill="%s" stroke="%s" stroke-width="2" />',
$centerX,
$centerY,
$startPoint['x'],
$startPoint['y'],
$radius,
$radius,
$largeArcFlag,
$endPoint['x'],
$endPoint['y'],
$this->escapeXml($fill),
$this->escapeXml($stroke),
);
$startAngle = $endAngle;
}
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="280" height="280" viewBox="0 0 280 280" role="img" aria-label="Kreisdiagramm"><rect width="280" height="280" rx="24" fill="#ffffff"/><circle cx="140" cy="140" r="108" fill="#f6f8fb"/><g>%s</g><circle cx="140" cy="140" r="26" fill="#ffffff"/><text x="140" y="146" text-anchor="middle" font-size="18" font-family="DejaVu Sans" fill="#222222">%d</text></svg>',
implode('', $segments),
$total,
);
}
/**
* @param array<string, mixed> $questionResult
* @param array<string, mixed> $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 1100): 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('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d7dee8" stroke-width="1" />', $paddingLeft, $y, $width - $paddingRight, $y);
$yGrid[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="end" font-size="11" font-family="DejaVu Sans" fill="#555e68">%d</text>', $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('<circle cx="%.2f" cy="%.2f" r="%.1f" fill="%s" stroke="#ffffff" stroke-width="2" />', $x, $y, $pointRadius, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32')));
}
if (0 === $index % $labelStep || $index === $lastIndex) {
$xLabels[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11" font-family="DejaVu Sans" fill="#555e68">%s</text>', $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(
'<svg xmlns="http://www.w3.org/2000/svg" width="560" height="280" viewBox="0 0 560 280" role="img" aria-label="Liniendiagramm"><rect width="560" height="280" rx="22" fill="#ffffff"/><rect x="1" y="1" width="558" height="278" rx="22" fill="none" stroke="#e3e7ee"/><g>%s</g><line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#8a95a3" stroke-width="1.2"/><line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#8a95a3" stroke-width="1.2"/><polygon fill="%s" stroke="none" points="%s"/><polyline fill="none" stroke="%s" stroke-width="3.5" stroke-linejoin="round" stroke-linecap="round" points="%s"/><g>%s</g><g>%s</g></svg>',
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(
'<svg xmlns="http://www.w3.org/2000/svg" width="560" height="220" viewBox="0 0 560 220" role="img" aria-label="Leeres Diagramm"><rect width="560" height="220" rx="22" fill="#f6f8fb"/><rect x="1" y="1" width="558" height="218" rx="22" fill="none" stroke="#e3e7ee"/><text x="280" y="114" text-anchor="middle" font-size="18" font-family="DejaVu Sans" fill="#555e68">%s</text></svg>',
$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, '_-'));
}
}