Initial survey bundle import
This commit is contained in:
@@ -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, '_-'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user