Initial survey bundle import

This commit is contained in:
Jürgen Mummert
2026-05-17 17:31:28 +02:00
commit b8475e3a57
84 changed files with 7157 additions and 0 deletions
+409
View File
@@ -0,0 +1,409 @@
<?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;
if (!is_array($chart)) {
return [];
}
$chartType = (string) ($chart['type'] ?? '');
$sourceRows = match ($chartType) {
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
'line' => $questionResult['distribution'] ?? [],
default => [],
};
if (!is_array($sourceRows) || [] === $sourceRows) {
return [];
}
$dataset = $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'),
]);
}
/**
* @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 = [];
for ($tick = 0; $tick <= $maxValue; ++$tick) {
$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);
$pointCircles[] = sprintf('<circle cx="%.2f" cy="%.2f" r="5.5" fill="%s" stroke="#ffffff" stroke-width="2" />', $x, $y, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32')));
$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, '_-'));
}
}