Add Sheet upload function

This commit is contained in:
2025-04-28 08:01:27 +02:00
parent 9bae324447
commit 49b7c0f5d5
29 changed files with 1106 additions and 135 deletions

View File

@@ -4,19 +4,28 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Candidate;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Entity\User;
use App\Enum\FlashType;
use App\Form\AddCandidatesFormType;
use App\Form\CreateSeasonFormType;
use App\Form\UploadQuizFormType;
use App\Repository\CandidateRepository;
use App\Repository\SeasonRepository;
use App\Security\Voter\SeasonVoter;
use App\Service\QuizSpreadsheetService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsController]
#[IsGranted('ROLE_USER')]
@@ -26,6 +35,7 @@ final class BackofficeController extends AbstractController
private readonly SeasonRepository $seasonRepository,
private readonly CandidateRepository $candidateRepository,
private readonly Security $security,
private readonly TranslatorInterface $translator,
) {}
#[Route('/backoffice/', name: 'app_backoffice_index')]
@@ -43,6 +53,30 @@ final class BackofficeController extends AbstractController
]);
}
#[Route('/backoffice/add', name: 'app_backoffice_season_add', priority: 10)]
public function seasonAdd(Request $request, EntityManagerInterface $em): Response
{
$season = new Season();
$form = $this->createForm(CreateSeasonFormType::class, $season);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser();
\assert($user instanceof User);
$season->addOwner($user);
$season->generateSeasonCode();
$em->persist($season);
$em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('backoffice/season_add.html.twig', ['form' => $form]);
}
#[Route('/backoffice/{seasonCode}', name: 'app_backoffice_season')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function season(Season $season): Response
@@ -72,9 +106,58 @@ final class BackofficeController extends AbstractController
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
#[Route('/backoffice/{seasonCode}/{quiz}/yaml')]
public function testRoute(Season $season, Quiz $quiz, SerializerInterface $serializer): Response
#[Route('/backoffice/{seasonCode}/add_candidate', name: 'app_backoffice_add_candidates', priority: 10)]
public function addCandidates(Season $season, Request $request, EntityManagerInterface $em): Response
{
return new Response($serializer->serialize(\App\Resource\Quiz::fromEntity($quiz)->questions, 'yaml', ['yaml_inline' => 100, 'yaml_flags' => 0]), headers: ['Content-Type' => 'text/yaml']);
$form = $this->createForm(AddCandidatesFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$candidates = $form->get('candidates')->getData();
foreach (explode("\r\n", $candidates) as $candidate) {
$season->addCandidate(new Candidate($candidate));
}
$em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form]);
}
#[Route('/backoffice/{seasonCode}/add', name: 'app_backoffice_quiz_add', priority: 10)]
public function addQuiz(Request $request, Season $season, QuizSpreadsheetService $quizSpreadsheet, EntityManagerInterface $em): Response
{
$quiz = new Quiz();
$form = $this->createForm(UploadQuizFormType::class, $quiz);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/* @var UploadedFile $sheet */
$sheet = $form->get('sheet')->getData();
$quizSpreadsheet->xlsxToQuiz($quiz, $sheet);
$quiz->setSeason($season);
$em->persist($quiz);
$em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz Added!'));
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]);
}
#[Route('/backoffice/template', name: 'app_backoffice_template', priority: 10)]
public function getTemplate(QuizSpreadsheetService $excel): Response
{
$response = new StreamedResponse($excel->generateTemplate());
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', 'attachment; filename="template.xlsx"');
return $response;
}
}

View File

@@ -52,6 +52,7 @@ class KrtekFixtures extends Fixture
->setQuestion('Is de Krtek een man of een vrouw?')
->addAnswer(new Answer('Vrouw', true))
->addAnswer(new Answer('Man'))
->setOrdering(1)
)
->addQuestion((new Question())
@@ -59,6 +60,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Geen', true))
->addAnswer(new Answer('1'))
->addAnswer(new Answer('2'))
->setOrdering(2)
)
->addQuestion((new Question())
@@ -68,12 +70,14 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Koningsdag'))
->addAnswer(new Answer('Kerst', true))
->addAnswer(new Answer('Oud en Nieuw'))
->setOrdering(3)
)
->addQuestion((new Question())
->setQuestion('Hoe kwam de Krtek naar Kersteren vandaag?')
->addAnswer(new Answer('Met het OV', true))
->addAnswer(new Answer('Met de auto'))
->setOrdering(4)
)
->addQuestion((new Question())
->setQuestion('Met wie keek de Krtek video bij binnenkomst?')
@@ -90,6 +94,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Remy'))
->addAnswer(new Answer('Robbert'))
->addAnswer(new Answer('Tom', true))
->setOrdering(5)
)
->addQuestion((new Question())
@@ -102,6 +107,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Probeer ook eens buiten de lijntjes te kleuren', true))
->addAnswer(new Answer('Ga als je groot bent op groepsreis! '))
->addAnswer(new Answer('Trek minder aan van de mening van anderen, het is oké om anders te zijn.'))
->setOrdering(6)
)
->addQuestion((new Question())
@@ -112,6 +118,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Pantoffels'))
->addAnswer(new Answer('Hakken'))
->addAnswer(new Answer('Geen schoenen, alleen sokken'))
->setOrdering(7)
)
->addQuestion((new Question())
@@ -119,12 +126,14 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Fiets', true))
->addAnswer(new Answer('Auto'))
->addAnswer(new Answer('Trein'))
->setOrdering(8)
)
->addQuestion((new Question())
->setQuestion('Heeft de Krtek een eigen auto?')
->addAnswer(new Answer('Ja'))
->addAnswer(new Answer('Nee', true))
->setOrdering(9)
)
->addQuestion((new Question())
@@ -144,12 +153,14 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Pieter'))
->addAnswer(new Answer('Renée Fokker'))
->addAnswer(new Answer('Sam, Davy', true))
->setOrdering(10)
)
->addQuestion((new Question())
->setQuestion('Zou de Krtek molboekjes, jokers, vrijstellingen of topitos uit iemands rugzak stelen om te kunnen winnen?')
->addAnswer(new Answer('Ja'))
->addAnswer(new Answer('Nee', true))
->setOrdering(11)
)
->addQuestion((new Question())
@@ -157,6 +168,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Éénpersoons, losstaand bed'))
->addAnswer(new Answer('Éénpersoonsbed, tegen een ander bed aan', true))
->addAnswer(new Answer('Tweepersoons bed'))
->setOrdering(12)
)
->addQuestion((new Question())
@@ -165,12 +177,14 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('6', true))
->addAnswer(new Answer('7'))
->addAnswer(new Answer('8'))
->setOrdering(13)
)
->addQuestion((new Question())
->setQuestion('Waar zat de Krtek aan tafel bij het diner?')
->addAnswer(new Answer('Met de rug naar de accommodatie'))
->addAnswer(new Answer('Met de rug naar de buitenmuur', true))
->setOrdering(14)
)
->addQuestion((new Question())
@@ -188,6 +202,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Remy'))
->addAnswer(new Answer('Robbert'))
->addAnswer(new Answer('Tom'))
->setOrdering(15)
)
;
}
@@ -202,6 +217,7 @@ class KrtekFixtures extends Fixture
->setQuestion('Is de Krtek een man of een vrouw?')
->addAnswer(new Answer('Man'))
->addAnswer(new Answer('Vrouw', true))
->setOrdering(1)
)
->addQuestion((new Question())
@@ -213,6 +229,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('De Krtek heeft een intolerantie'))
->addAnswer(new Answer('De Krtek eet geen rundvlees'))
->addAnswer(new Answer('De Krtek eet geen waterdieren'))
->setOrdering(2)
)
->addQuestion((new Question())
@@ -224,6 +241,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Tom'))
->addAnswer(new Answer('De huisdieren van de Krtek hebben geen naam'))
->addAnswer(new Answer('De Krtek heeft geen huisdieren', true))
->setOrdering(3)
)
->addQuestion((new Question())
@@ -234,6 +252,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Melk'))
->addAnswer(new Answer('Sap'))
->addAnswer(new Answer('Niks'))
->setOrdering(4)
)
->addQuestion((new Question())
@@ -245,6 +264,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Oostenrijk'))
->addAnswer(new Answer('Turkije'))
->addAnswer(new Answer('Zweden', true))
->setOrdering(5)
)
->addQuestion((new Question())
@@ -254,6 +274,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Het derde groepje'))
->addAnswer(new Answer('Het vierde groepje'))
->addAnswer(new Answer('Het vijfde groepje'))
->setOrdering(6)
)
->addQuestion((new Question())
@@ -262,12 +283,14 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Het universum', true))
->addAnswer(new Answer('Toeval'))
->addAnswer(new Answer('De Krtek is hindoeïstisch'))
->setOrdering(7)
)
->addQuestion((new Question())
->setQuestion('At de Krtek op vrijdagavond heksenkaas tijdens het diner?')
->addAnswer(new Answer('Ja', true))
->addAnswer(new Answer('Nee'))
->setOrdering(8)
)
->addQuestion((new Question())
@@ -276,6 +299,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Tussen 1:00 en 1:59 uur', true))
->addAnswer(new Answer('Tussen 2:00 en 2:59 uur'))
->addAnswer(new Answer('Na 3:00'))
->setOrdering(9)
)
->addQuestion((new Question())
@@ -284,6 +308,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('2'))
->addAnswer(new Answer('3'))
->addAnswer(new Answer('geen', true))
->setOrdering(10)
)
->addQuestion((new Question())
@@ -294,6 +319,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Sesamstraat'))
->addAnswer(new Answer('Spongebob Squarepants'))
->addAnswer(new Answer('Teletubbies'))
->setOrdering(11)
)
->addQuestion((new Question())
@@ -301,6 +327,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('In koffer(s)', true))
->addAnswer(new Answer('In losse tas(sen)'))
->addAnswer(new Answer('In een rugzak'))
->setOrdering(12)
)
->addQuestion((new Question())
@@ -313,12 +340,14 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Servies dat tegen elkaar klettert'))
->addAnswer(new Answer('Het geroekoe van een duif', true))
->addAnswer(new Answer('Piepschuim'))
->setOrdering(13)
)
->addQuestion((new Question())
->setQuestion('Wilde de Krtek penningmeester worden?')
->addAnswer(new Answer('Ja'))
->addAnswer(new Answer('Nee', true))
->setOrdering(14)
)
->addQuestion((new Question())
@@ -336,6 +365,7 @@ class KrtekFixtures extends Fixture
->addAnswer(new Answer('Remy'))
->addAnswer(new Answer('Robbert'))
->addAnswer(new Answer('Tom'))
->setOrdering(15)
)
;
}

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use App\Repository\AnswerRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Bridge\Doctrine\Types\UuidType;
@@ -21,6 +22,9 @@ class Answer
#[ORM\CustomIdGenerator(class: UuidGenerator::class)]
private Uuid $id;
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
private int $ordering;
#[ORM\ManyToOne(inversedBy: 'answers')]
#[ORM\JoinColumn(nullable: false)]
private Question $question;
@@ -121,4 +125,16 @@ class Answer
return $this;
}
public function getOrdering(): int
{
return $this->ordering;
}
public function setOrdering(int $ordering): self
{
$this->ordering = $ordering;
return $this;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use App\Repository\QuestionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Bridge\Doctrine\Types\UuidType;
@@ -21,7 +22,10 @@ class Question
#[ORM\CustomIdGenerator(class: UuidGenerator::class)]
private ?Uuid $id = null;
#[ORM\Column(length: 255, nullable: false)]
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
private int $ordering;
#[ORM\Column(type: Types::STRING, length: 255)]
private string $question;
#[ORM\ManyToOne(inversedBy: 'questions')]
@@ -33,6 +37,7 @@ class Question
/** @var Collection<int, Answer> */
#[ORM\OneToMany(targetEntity: Answer::class, mappedBy: 'question', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['ordering' => 'ASC'])]
private Collection $answers;
public function __construct()
@@ -115,4 +120,16 @@ class Question
return null;
}
public function getOrdering(): int
{
return $this->ordering;
}
public function setOrdering(int $ordering): static
{
$this->ordering = $ordering;
return $this;
}
}

View File

@@ -30,6 +30,7 @@ class Quiz
/** @var Collection<int, Question> */
#[ORM\OneToMany(targetEntity: Question::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['ordering' => 'ASC'])]
private Collection $questions;
/** @var Collection<int, Correction> */

View File

@@ -15,6 +15,8 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: SeasonRepository::class)]
class Season
{
private const string SEASON_CODE_CHARACTERS = 'bcdfghjklmnpqrstvwxz';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
@@ -148,4 +150,18 @@ class Season
{
return $this->owners->contains($user);
}
public function generateSeasonCode(): self
{
$code = '';
$len = mb_strlen(self::SEASON_CODE_CHARACTERS) - 1;
for ($i = 0; $i < 5; ++$i) {
$code .= self::SEASON_CODE_CHARACTERS[random_int(0, $len)];
}
$this->seasonCode = $code;
return $this;
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Exception;
class SpreadsheetDataException extends SpreadsheetException
{
/** @param list<string> $errors */
public function __construct(
public readonly array $errors,
string $message = '',
int $code = 0,
?\Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exception;
class SpreadsheetException extends \Exception {}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Season;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/** @extends AbstractType<Season> */
class CreateSeasonFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => $this->translator->trans('Season Name'),
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Season::class,
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Quiz;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Contracts\Translation\TranslatorInterface;
/** @extends AbstractType<Quiz> */
class UploadQuizFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => $this->translator->trans('Quiz name'),
])
->add('sheet', FileType::class, [
'label' => $this->translator->trans('Quiz (xlsx)'),
'mapped' => false,
'required' => true,
'constraints' => [
new File([
'maxSize' => '1024k',
'mimeTypes' => [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
'mimeTypesMessage' => $this->translator->trans('Please upload a valid XLSX file'),
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Quiz::class,
]);
}
}

View File

@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Resource;
use App\Entity\Answer;
use App\Entity\Quiz as QuizEntity;
use Doctrine\Common\Collections\Collection;
final class Quiz
{
/** @param array<string, array<string, bool>> $questions*/
public function __construct(
public array $questions,
) {}
public static function fromEntity(QuizEntity $quiz): self
{
$questions = [];
foreach ($quiz->getQuestions() as $question) {
$questions[$question->getQuestion()] = self::answerArray($question->getAnswers());
}
return new self($questions);
}
/** @param Collection<int, Answer> $answers
* @return array<string, bool>
**/
private static function answerArray(Collection $answers): array
{
$result = [];
foreach ($answers as $answer) {
$result[$answer->getText()] = $answer->isRightAnswer();
}
return $result;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Answer;
use App\Entity\Question;
use App\Entity\Quiz;
use App\Exception\SpreadsheetDataException;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\File\File;
class QuizSpreadsheetService
{
public function generateTemplate(bool $fillExample = true): \Closure
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->getStyle('1:1')->getFont()->setBold(true);
$sheet->setCellValue('A1', 'Question');
$sheet->getColumnDimension('A')->setWidth(30);
$sheet->getStyle('A:A')->getAlignment()->setWrapText(true);
$counter = 1;
foreach (range('B', 'L', 2) as $column) {
$sheet->setCellValue($column.'1', 'Answer '.$counter++);
$sheet->getColumnDimension($column)->setWidth(30);
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
}
foreach (range('C', 'M', 2) as $column) {
$sheet->setCellValue($column.'1', 'Correct');
$sheet->getColumnDimension($column)->setAutoSize(true);
}
if ($fillExample) {
$sheet->setCellValue('B2', 'Man');
$sheet->setCellValue('C2', true);
$sheet->setCellValue('D2', 'Vrouw');
$sheet->setCellValue('E2', false);
$sheet->setCellValue('A2', 'Is de mol een man of een vrouw?');
}
return $this->toXlsx($spreadsheet);
}
/** @throws SpreadsheetDataException */
public function xlsxToQuiz(Quiz $quiz, File $file): Quiz
{
$spreadsheet = $this->readSheet($file);
$sheet = $spreadsheet->getSheet($spreadsheet->getFirstSheetIndex());
$answerLines = \array_slice($sheet->toArray(formatData: false), 1);
return $this->fillQuizFromArray($quiz, $answerLines);
}
private function readSheet(File $file): Spreadsheet
{
return (new \PhpOffice\PhpSpreadsheet\Reader\Xlsx())->setReadDataOnly(true)->load($file->getRealPath());
}
/**
* @param array<int, array<int, string|bool|null>> $sheet
*
* @throws SpreadsheetDataException
*/
private function fillQuizFromArray(Quiz $quiz, array $sheet): Quiz
{
$errors = [];
$questionCounter = 1;
foreach ($sheet as $questionArr) {
if (null === $questionArr[0]) {
break;
}
$question = new Question();
$question->setQuestion((string) $questionArr[0]);
$question->setOrdering($questionCounter++);
$answerCounter = 1;
$arrCounter = 1;
while (true) {
if (null === $questionArr[$arrCounter]) {
if (1 === $answerCounter) {
$errors[] = \sprintf('Question %d has no answers', $answerCounter);
}
break;
}
$answer = new Answer((string) $questionArr[$arrCounter++], (bool) $questionArr[$arrCounter++]);
$answer->setOrdering($answerCounter++);
$question->addAnswer($answer);
}
$quiz->addQuestion($question);
}
if ([] !== $errors) {
throw new SpreadsheetDataException($errors);
}
return $quiz;
}
public function quizToXlsx(Quiz $quiz): void {}
private function toXlsx(Spreadsheet $spreadsheet): \Closure
{
$writer = new Xlsx($spreadsheet);
return static fn () => $writer->save('php://output');
}
}