954a5102ac
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>
457 lines
18 KiB
PHP
457 lines
18 KiB
PHP
<?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 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('<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, '_-'));
|
||
}
|
||
} |