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