Files
toolvana/app/Libraries/Pipeline/Pdf.php
T
deepseek beaf0e1f37 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
2026-08-23 07:10:30 +00:00

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);
}
}