Toolvana: production-ready media tools platform (CodeIgniter 4)
- 134-tool registry with programmatic SEO (unique titles/H1/descriptions, JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter) - DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD), Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG) - Security: SSRF guard, MIME validation, rate limits, API-key auth, bcrypt admin login, security headers - Admin panel: dashboard, tools/categories/guides CRUD, SEO audit, analytics, job inspector with retry, system health, feature flags - Docker deployment (nginx + web/api FPM pools + scalable workers), PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
use App\Libraries\Process;
|
||||
|
||||
/**
|
||||
* PDF driver: qpdf (merge/split/pages/metadata), Ghostscript (compress),
|
||||
* Poppler (rasterize + text extraction). Images->PDF uses GD to build
|
||||
* pages and Ghostscript to assemble.
|
||||
*/
|
||||
final class Pdf implements DriverInterface
|
||||
{
|
||||
public function handle(array $job, array $tool, callable $update): array
|
||||
{
|
||||
return match ($job['operation']) {
|
||||
'pdf_merge' => $this->merge($job, $update),
|
||||
'pdf_split' => $this->split($job, $update, singlePage: false),
|
||||
'pdf_extract_pages' => $this->extractPages($job, $update),
|
||||
'pdf_compress' => $this->compress($job, $update),
|
||||
'pdf_remove_metadata'=> $this->removeMetadata($job, $update),
|
||||
'pdf_to_jpg' => $this->rasterize($job, $update, 'jpg'),
|
||||
'pdf_to_png' => $this->rasterize($job, $update, 'png'),
|
||||
'pdf_to_text' => $this->toText($job, $update),
|
||||
'images_to_pdf' => $this->imagesToPdf($job, $update),
|
||||
default => throw new \RuntimeException('Unsupported operation.'),
|
||||
};
|
||||
}
|
||||
|
||||
private function merge(array $job, callable $update): array
|
||||
{
|
||||
$extra = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
|
||||
$files = [Ffmpeg::resolveInput($job)];
|
||||
foreach ((array) $extra as $f) {
|
||||
$files[] = Ffmpeg::storagePath((string) $f, \App\Libraries\Pipeline::incomingDir());
|
||||
}
|
||||
if (count($files) < 2) {
|
||||
throw new \RuntimeException('Select at least two PDF files.');
|
||||
}
|
||||
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$update(20, 'merging');
|
||||
$proc = Process::binary('qpdf', ['--empty', '--pages', ...$files, '--', $out], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Merge failed — are all files valid PDFs?');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'merged.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function split(array $job, callable $update, bool $singlePage): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[$zipFile, $zipPath] = self::output($job, 'zip');
|
||||
$tmpDir = sys_get_temp_dir() . '/tv_pdf_' . $job['id'];
|
||||
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
|
||||
|
||||
// one PDF per page via qpdf page ranges
|
||||
$info = Process::binary('pdfinfo', [$input], 30);
|
||||
$pagesN = 0;
|
||||
if ($info->run() && preg_match('/Pages:\s+(\d+)/', $info->out(), $m)) {
|
||||
$pagesN = (int) $m[1];
|
||||
}
|
||||
if ($pagesN < 1) {
|
||||
throw new \RuntimeException('Could not read the PDF.');
|
||||
}
|
||||
$update(10, 'splitting');
|
||||
for ($p = 1; $p <= $pagesN; ++$p) {
|
||||
$proc = Process::binary('qpdf', [$input, '--pages', '.', (string) $p, '--', rtrim($tmpDir, '/') . "/page_{$p}.pdf"], 120);
|
||||
if (! $proc->run()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->zipDirectory($zipPath, $tmpDir);
|
||||
|
||||
return ['file' => basename($zipPath), 'name' => 'split_pages.zip', 'ext' => 'zip'];
|
||||
}
|
||||
|
||||
private function extractPages(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
$range = (string) (json_decode($job['params'] ?? '{}', true)['pages'] ?? '');
|
||||
if (! preg_match('/^[0-9,\- ]{1,60}$/', $range)) {
|
||||
throw new \RuntimeException('Enter pages like 1-3 or 2,5,7.');
|
||||
}
|
||||
$normalized = str_replace(' ', '', $range);
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$update(25, 'extracting');
|
||||
|
||||
// expand "1-3" into individual page numbers for qpdf
|
||||
$parts = [];
|
||||
foreach (explode(',', $normalized) as $chunk) {
|
||||
if (preg_match('/^(\d+)-(\d+)$/', $chunk, $m)) {
|
||||
foreach (range((int) $m[1], (int) $m[2]) as $p) {
|
||||
$parts[] = (string) $p;
|
||||
}
|
||||
} elseif (ctype_digit($chunk)) {
|
||||
$parts[] = $chunk;
|
||||
}
|
||||
}
|
||||
$proc = Process::binary('qpdf', ['--empty', '--pages', $input, ...$parts, '--', $out], 300);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Extraction failed — check the page range.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'extracted.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function compress(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$level = (string) (json_decode($job['params'] ?? '{}', true)['preset'] ?? 'ebook');
|
||||
$valid = ['screen', 'ebook', 'printer', 'prepress'];
|
||||
in_array($level, $valid, true) || $level = 'ebook';
|
||||
|
||||
$update(15, 'compressing');
|
||||
$proc = Process::binary('gs', [
|
||||
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.5', '-dPDFSETTINGS=/' . $level,
|
||||
'-dNOPAUSE', '-dQUIET', '-dBATCH',
|
||||
"-sOutputFile={$out}", $input,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run() || ! is_file($out) || filesize($out) === 0) {
|
||||
throw new \RuntimeException('Compression failed.');
|
||||
}
|
||||
if (filesize($out) >= filesize($input)) {
|
||||
// already optimized — deliver a byte-identical copy rather than bigger file
|
||||
copy($input, $out);
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'compressed.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function removeMetadata(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$update(30, 'removing metadata');
|
||||
$proc = Process::binary('qpdf', ['--empty', '--pages', $input, '1-z', '--', $out], 300);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Metadata removal failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'cleaned.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function rasterize(array $job, callable $update, string $format): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
$dpi = min(200, max(72, (int) (json_decode($job['params'] ?? '{}', true)['dpi'] ?? 150)));
|
||||
$tmpDir = sys_get_temp_dir() . '/tv_pdf_' . $job['id'];
|
||||
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
|
||||
$update(10, 'rendering pages');
|
||||
|
||||
$proc = Process::binary('pdftoppm', [
|
||||
"-{$format}", '-r', (string) $dpi, $input, rtrim($tmpDir, '/') . '/page',
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Rendering failed — is this a valid PDF?');
|
||||
}
|
||||
|
||||
[$zipFile, $zipPath] = self::output($job, 'zip');
|
||||
$this->zipDirectory($zipPath, $tmpDir);
|
||||
$update(90, 'packaging');
|
||||
|
||||
return ['file' => basename($zipPath), 'name' => "pdf_as_{$format}.zip", 'ext' => 'zip'];
|
||||
}
|
||||
|
||||
private function toText(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[, $txtPath] = self::output($job, 'txt');
|
||||
$update(30, 'extracting text');
|
||||
$proc = Process::binary('pdftotext', ['-layout', $input, $txtPath], 300);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Text extraction failed. Scanned PDFs contain images, not text.');
|
||||
}
|
||||
|
||||
return ['file' => basename($txtPath), 'name' => 'document.txt', 'ext' => 'txt'];
|
||||
}
|
||||
|
||||
private function imagesToPdf(array $job, callable $update): array
|
||||
{
|
||||
$extra = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
|
||||
$files = [Ffmpeg::resolveInput($job)];
|
||||
foreach ((array) $extra as $f) {
|
||||
$files[] = Ffmpeg::storagePath((string) $f);
|
||||
}
|
||||
if (count(array_filter($files)) === 0) {
|
||||
throw new \RuntimeException('Add at least one image.');
|
||||
}
|
||||
|
||||
// normalize every image to an intermediate PDF page via ImageMagick
|
||||
$update(15, 'building pages');
|
||||
$pagePdfs = [];
|
||||
$i = 0;
|
||||
foreach ($files as $file) {
|
||||
if (! is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
$pagePdf = sys_get_temp_dir() . "/tv_img2pdf_{$job['id']}_{$i}.pdf";
|
||||
$proc = Process::binary('convert', [
|
||||
$file, '-auto-orient', '-background', 'white', '-flatten', '-resize', '2480x3508>', $pagePdf,
|
||||
], 120);
|
||||
if ($proc->run()) {
|
||||
$pagePdfs[] = $pagePdf;
|
||||
}
|
||||
++$i;
|
||||
}
|
||||
if ($pagePdfs === []) {
|
||||
throw new \RuntimeException('The images could not be converted to PDF pages.');
|
||||
}
|
||||
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$proc = Process::binary('gs', [
|
||||
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.5', '-dNOPAUSE', '-dQUIET', '-dBATCH',
|
||||
"-sOutputFile={$out}", ...$pagePdfs,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
array_map('unlink', $pagePdfs);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('PDF assembly failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'images.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private static function output(array $job, string $ext): array
|
||||
{
|
||||
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
|
||||
|
||||
return [$file, \App\Libraries\Pipeline::storageDir() . '/' . $file];
|
||||
}
|
||||
|
||||
private function zipDirectory(string $zipPath, string $dir): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
foreach (glob(rtrim($dir, '/') . '/*') ?: [] as $f) {
|
||||
if (is_file($f) && filesize($f) > 0) {
|
||||
$zip->addFile($f, basename($f));
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
array_map('unlink', glob(rtrim($dir, '/') . '/*') ?: []);
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user