- Add DocumentStorageService for file-based storage (replaces Base64 in DB) - Add DocumentServeController with /file and /download endpoints - Add DocumentUploadProcessor using FormData + filesystem storage - Add DocumentNormalizer exposing fileUrl/downloadUrl on all responses - Add DocumentFileCleanupListener for automatic file deletion - Add MigrateDocumentsToFilesystemCommand (Base64 → files, memory-safe) - Add ApiFilter (SearchFilter, ExistsFilter, OrderFilter) on Document entity - Add PdfCompressorService + refactor CompressPdfCommand for batch processing - Fix TypeMachine PUT: deserialize=false + validate=false to prevent UniqueEntity false positive and writableLink collection interference - Update CHANGELOG for v1.8.0 - Update frontend submodule Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
219 lines
6.5 KiB
PHP
219 lines
6.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Command;
|
|
|
|
use App\Entity\Document;
|
|
use App\Repository\DocumentRepository;
|
|
use App\Service\DocumentStorageService;
|
|
use App\Service\PdfCompressorService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
|
use Symfony\Component\Console\Command\Command;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
|
|
use function count;
|
|
use function strlen;
|
|
|
|
#[AsCommand(
|
|
name: 'app:compress-pdf',
|
|
description: 'Compress all PDF documents without quality loss',
|
|
)]
|
|
class CompressPdfCommand extends Command
|
|
{
|
|
public function __construct(
|
|
private readonly DocumentRepository $documentRepository,
|
|
private readonly EntityManagerInterface $em,
|
|
private readonly PdfCompressorService $pdfCompressor,
|
|
private readonly DocumentStorageService $storageService,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this
|
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be compressed without actually doing it')
|
|
;
|
|
}
|
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
{
|
|
$io = new SymfonyStyle($input, $output);
|
|
$dryRun = $input->getOption('dry-run');
|
|
|
|
// Check if qpdf is installed
|
|
exec('which qpdf', $qpdfPath, $returnCode);
|
|
if (0 !== $returnCode) {
|
|
$io->error('qpdf is not installed. Run: sudo apt install qpdf');
|
|
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$documents = $this->documentRepository->findBy(['mimeType' => 'application/pdf']);
|
|
|
|
if (empty($documents)) {
|
|
$io->info('No PDF documents found.');
|
|
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
$io->title('PDF Compression');
|
|
$io->text(sprintf('Found %d PDF documents', count($documents)));
|
|
|
|
$totalSaved = 0;
|
|
$compressed = 0;
|
|
|
|
foreach ($documents as $document) {
|
|
$path = $document->getPath();
|
|
|
|
if ($this->storageService->isBase64DataUri($path)) {
|
|
$this->compressBase64Document($document, $path, $dryRun, $io, $totalSaved, $compressed);
|
|
} else {
|
|
$this->compressFileDocument($document, $path, $dryRun, $io, $totalSaved, $compressed);
|
|
}
|
|
}
|
|
|
|
if (!$dryRun && $compressed > 0) {
|
|
$this->em->flush();
|
|
$io->success(sprintf(
|
|
'Compressed %d/%d PDFs. Total space saved: %s',
|
|
$compressed,
|
|
count($documents),
|
|
$this->formatBytes($totalSaved)
|
|
));
|
|
} elseif ($dryRun) {
|
|
$io->info('Dry run completed. No changes made.');
|
|
} else {
|
|
$io->info('No PDFs needed compression.');
|
|
}
|
|
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
private function compressBase64Document(
|
|
Document $document,
|
|
string $path,
|
|
bool $dryRun,
|
|
SymfonyStyle $io,
|
|
int &$totalSaved,
|
|
int &$compressed,
|
|
): void {
|
|
$base64Data = $path;
|
|
if (str_contains($base64Data, ',')) {
|
|
$base64Data = explode(',', $base64Data, 2)[1];
|
|
}
|
|
|
|
$pdfContent = base64_decode($base64Data, true);
|
|
if (false === $pdfContent) {
|
|
$io->warning(sprintf('Failed to decode document: %s', $document->getName()));
|
|
|
|
return;
|
|
}
|
|
|
|
$originalSize = strlen($pdfContent);
|
|
|
|
if ($dryRun) {
|
|
$io->text(sprintf(
|
|
' [DRY-RUN] Would compress (base64): %s (%s)',
|
|
$document->getName(),
|
|
$this->formatBytes($originalSize)
|
|
));
|
|
|
|
return;
|
|
}
|
|
|
|
$result = $this->pdfCompressor->compressBase64Pdf($path);
|
|
if (null !== $result) {
|
|
$document->setPath($result['path']);
|
|
$document->setSize($result['size']);
|
|
$totalSaved += $result['saved'];
|
|
++$compressed;
|
|
|
|
$io->text(sprintf(
|
|
' OK %s: %s → %s (-%s, -%.1f%%)',
|
|
$document->getName(),
|
|
$this->formatBytes($result['originalSize']),
|
|
$this->formatBytes($result['size']),
|
|
$this->formatBytes($result['saved']),
|
|
($result['saved'] / $result['originalSize']) * 100
|
|
));
|
|
} else {
|
|
$io->text(sprintf(
|
|
' - %s: Already optimal (%s)',
|
|
$document->getName(),
|
|
$this->formatBytes($originalSize)
|
|
));
|
|
}
|
|
}
|
|
|
|
private function compressFileDocument(
|
|
Document $document,
|
|
string $path,
|
|
bool $dryRun,
|
|
SymfonyStyle $io,
|
|
int &$totalSaved,
|
|
int &$compressed,
|
|
): void {
|
|
$absolutePath = $this->storageService->getAbsolutePath($path);
|
|
if (!file_exists($absolutePath)) {
|
|
$io->warning(sprintf('File not found: %s (%s)', $document->getName(), $path));
|
|
|
|
return;
|
|
}
|
|
|
|
$originalSize = filesize($absolutePath);
|
|
if (false === $originalSize) {
|
|
return;
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$io->text(sprintf(
|
|
' [DRY-RUN] Would compress (file): %s (%s)',
|
|
$document->getName(),
|
|
$this->formatBytes($originalSize)
|
|
));
|
|
|
|
return;
|
|
}
|
|
|
|
$result = $this->pdfCompressor->compressFile($absolutePath);
|
|
if (null !== $result) {
|
|
$document->setSize($result['size']);
|
|
$totalSaved += $result['saved'];
|
|
++$compressed;
|
|
|
|
$io->text(sprintf(
|
|
' OK %s: %s → %s (-%s, -%.1f%%)',
|
|
$document->getName(),
|
|
$this->formatBytes($result['originalSize']),
|
|
$this->formatBytes($result['size']),
|
|
$this->formatBytes($result['saved']),
|
|
($result['saved'] / $result['originalSize']) * 100
|
|
));
|
|
} else {
|
|
$io->text(sprintf(
|
|
' - %s: Already optimal (%s)',
|
|
$document->getName(),
|
|
$this->formatBytes($originalSize)
|
|
));
|
|
}
|
|
}
|
|
|
|
private function formatBytes(int $bytes): string
|
|
{
|
|
$units = ['B', 'KB', 'MB', 'GB'];
|
|
$i = 0;
|
|
while ($bytes >= 1024 && $i < count($units) - 1) {
|
|
$bytes /= 1024;
|
|
++$i;
|
|
}
|
|
|
|
return round($bytes, 2).' '.$units[$i];
|
|
}
|
|
}
|