- 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)
301 lines
9.6 KiB
PHP
301 lines
9.6 KiB
PHP
<?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);
|
||
}
|
||
}
|