pp-planer/app/Services/FileConversionService.php
Thorsten Bus 0e3c647cfc feat: probundle export with media, image upscaling, upload dimension warnings
- Fix probundle exports missing images (double slides/ prefix in storage path)
- Replace manual ZipArchive with PresentationBundle + ProBundleWriter from parser plugin
- Add per-agenda-item download route and buttons for songs and slide items
- Remove text layer from image-only slides in .pro generation
- Fix image conversion: upscale small images, black bars on 2 sides max (contain)
- Add upload warnings for non-16:9 and sub-1920x1080 images (German, non-blocking)
- Update SlideFactory and all tests to use slides/ prefix in stored_filename
- Add 11 new tests (agenda download, image conversion, upload warnings)
2026-03-30 10:29:37 +02:00

301 lines
9.6 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
namespace App\Services;
use App\Jobs\ConvertPowerPointJob;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use ZipArchive;
class FileConversionService
{
private const MAX_FILE_SIZE_BYTES = 52428800;
private const SUPPORTED_EXTENSIONS = ['png', 'jpg', 'jpeg', 'ppt', 'pptx', 'zip'];
private const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg'];
private const POWERPOINT_EXTENSIONS = ['ppt', 'pptx'];
public function convertImage(UploadedFile|string|SplFileInfo $file): array
{
$sourcePath = $this->resolvePath($file);
$extension = $this->resolveExtension($file, $sourcePath);
$this->assertSupported($extension);
if (! in_array($extension, self::IMAGE_EXTENSIONS, true)) {
throw new InvalidArgumentException('Nur Bilddateien koennen mit convertImage verarbeitet werden.');
}
$this->assertSize($file, $sourcePath);
$filename = Str::uuid()->toString().'.jpg';
$relativePath = 'slides/'.$filename;
$targetPath = Storage::disk('public')->path($relativePath);
Storage::disk('public')->makeDirectory('slides');
$this->ensureDirectory(dirname($targetPath));
$manager = $this->createImageManager();
$image = $manager->read($sourcePath);
$originalWidth = $image->width();
$originalHeight = $image->height();
$warnings = $this->checkImageDimensions($originalWidth, $originalHeight);
$image->contain(1920, 1080, '000000', 'center');
$image->save($targetPath, quality: 90);
$thumbnailPath = $this->generateThumbnail($relativePath);
return [
'filename' => $relativePath,
'thumbnail' => $thumbnailPath,
'warnings' => $warnings,
];
}
public function convertPowerPoint(UploadedFile|string|SplFileInfo $file): string
{
$sourcePath = $this->resolvePath($file);
$extension = $this->resolveExtension($file, $sourcePath);
$this->assertSupported($extension);
if (! in_array($extension, self::POWERPOINT_EXTENSIONS, true)) {
throw new InvalidArgumentException('Nur PPT/PPTX-Dateien sind hier erlaubt.');
}
$this->assertSize($file, $sourcePath);
$jobId = Str::uuid()->toString();
ConvertPowerPointJob::dispatch($sourcePath, $jobId);
return $jobId;
}
public function processZip(UploadedFile|string|SplFileInfo $file): array
{
$sourcePath = $this->resolvePath($file);
$extension = $this->resolveExtension($file, $sourcePath);
$this->assertSupported($extension);
if ($extension !== 'zip') {
throw new InvalidArgumentException('processZip erwartet eine ZIP-Datei.');
}
$this->assertSize($file, $sourcePath);
$zip = new ZipArchive;
if ($zip->open($sourcePath) !== true) {
throw new InvalidArgumentException('ZIP-Datei konnte nicht geoeffnet werden.');
}
$extractDir = storage_path('app/temp/zip-'.Str::uuid()->toString());
if (! is_dir($extractDir) && ! mkdir($extractDir, 0775, true) && ! is_dir($extractDir)) {
throw new InvalidArgumentException('Temporaires ZIP-Verzeichnis konnte nicht erstellt werden.');
}
$zip->extractTo($extractDir);
$zip->close();
$results = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($extractDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $entry) {
if (! $entry instanceof SplFileInfo || ! $entry->isFile()) {
continue;
}
$entryPath = $entry->getRealPath();
if ($entryPath === false) {
continue;
}
$entryExtension = $this->extensionFromPath($entryPath);
if (! in_array($entryExtension, self::SUPPORTED_EXTENSIONS, true)) {
continue;
}
if (in_array($entryExtension, self::IMAGE_EXTENSIONS, true)) {
$results[] = $this->convertImage($entryPath);
continue;
}
if (in_array($entryExtension, self::POWERPOINT_EXTENSIONS, true)) {
$results[] = ['job_id' => $this->convertPowerPoint($entryPath)];
continue;
}
$results = [...$results, ...$this->processZip($entryPath)];
}
$this->deleteDirectory($extractDir);
return $results;
}
public function generateThumbnail(string $path): string
{
$absolutePath = str_starts_with($path, DIRECTORY_SEPARATOR)
? $path
: Storage::disk('public')->path($path);
if (! is_file($absolutePath)) {
throw new InvalidArgumentException('Datei fuer Thumbnail nicht gefunden.');
}
$filename = pathinfo($absolutePath, PATHINFO_FILENAME).'.jpg';
$thumbnailRelativePath = 'slides/thumbnails/'.$filename;
$thumbnailAbsolutePath = Storage::disk('public')->path($thumbnailRelativePath);
Storage::disk('public')->makeDirectory('slides/thumbnails');
$this->ensureDirectory(dirname($thumbnailAbsolutePath));
$manager = $this->createImageManager();
$canvas = $manager->create(320, 180)->fill('000000');
$image = $manager->read($absolutePath);
$image->scaleDown(width: 320, height: 180);
$canvas->place($image, 'center');
$canvas->save($thumbnailAbsolutePath, quality: 85);
return $thumbnailRelativePath;
}
private function resolvePath(UploadedFile|string|SplFileInfo $file): string
{
if ($file instanceof UploadedFile) {
$path = $file->getRealPath();
if ($path === false || ! is_file($path)) {
throw new InvalidArgumentException('Upload-Datei ist ungueltig.');
}
return $path;
}
if ($file instanceof SplFileInfo) {
$path = $file->getRealPath();
if ($path === false) {
throw new InvalidArgumentException('Dateipfad ist ungueltig.');
}
return $path;
}
if (! is_file($file)) {
throw new InvalidArgumentException('Datei wurde nicht gefunden.');
}
return $file;
}
private function assertSupported(string $extension): void
{
if (! in_array($extension, self::SUPPORTED_EXTENSIONS, true)) {
throw new InvalidArgumentException('Dateityp wird nicht unterstuetzt.');
}
}
private function extensionFromPath(string $path): string
{
return strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
}
private function resolveExtension(UploadedFile|string|SplFileInfo $file, string $sourcePath): string
{
if ($file instanceof UploadedFile) {
return strtolower($file->getClientOriginalExtension());
}
return $this->extensionFromPath($sourcePath);
}
private function assertSize(UploadedFile|string|SplFileInfo $file, string $resolvedPath): void
{
$size = $file instanceof UploadedFile
? ($file->getSize() ?? 0)
: (filesize($resolvedPath) ?: 0);
if ($size > self::MAX_FILE_SIZE_BYTES) {
throw new InvalidArgumentException('Datei ist groesser als 50MB.');
}
}
private function deleteDirectory(string $directory): void
{
if (! is_dir($directory)) {
return;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
if (! $item instanceof SplFileInfo) {
continue;
}
if ($item->isDir()) {
@rmdir($item->getPathname());
continue;
}
@unlink($item->getPathname());
}
@rmdir($directory);
}
/** @return string[] */
private function checkImageDimensions(int $width, int $height): array
{
$warnings = [];
$isExactOrLarger16by9 = $width >= 1920 && $height >= 1080
&& abs($width / $height - 16 / 9) < 0.01;
if (! $isExactOrLarger16by9) {
$warnings[] = 'Das Bild hat nicht das optimale Seitenverhältnis von 16:9. '
.'Für die beste Darstellung verwende bitte Bilder mit exakt 1920×1080 Pixeln. '
.'Das Bild wurde trotzdem verarbeitet, es können aber schwarze Ränder entstehen.';
}
if ($width < 1920 || $height < 1080) {
$warnings[] = "Das Bild ({$width}×{$height}) ist kleiner als 1920×1080 und wurde hochskaliert. "
.'Dadurch kann die Qualität schlechter sein. '
.'Lade am besten Bilder mit mindestens 1920×1080 Pixeln hoch.';
}
return $warnings;
}
private function ensureDirectory(string $directory): void
{
if (is_dir($directory)) {
return;
}
if (! mkdir($directory, 0775, true) && ! is_dir($directory)) {
throw new InvalidArgumentException('Zielverzeichnis konnte nicht erstellt werden.');
}
}
private function createImageManager(): mixed
{
$managerClass = implode('\\', ['Intervention', 'Image', 'ImageManager']);
$driverClass = implode('\\', ['Intervention', 'Image', 'Drivers', 'Gd', 'Driver']);
return new $managerClass(new $driverClass);
}
}