- 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
251 lines
9.8 KiB
PHP
251 lines
9.8 KiB
PHP
<?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);
|
|
}
|
|
}
|