| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413 |
- <?php
- namespace App\Services;
- use Illuminate\Support\Facades\Log;
- use Illuminate\Support\Facades\Process;
- use Illuminate\Support\Str;
- /**
- * PDF合并工具类
- * 支持pdfunite(生产环境)和qpdf(本地开发)
- */
- class PdfMerger
- {
- private string $mergeTool;
- private bool $isProduction;
- public function __construct()
- {
- $this->isProduction = app()->environment('production');
- $this->mergeTool = $this->detectMergeTool();
- }
- /**
- * 检测系统中可用的PDF合并工具
- * 【修复】优先使用pdfunite,更简单可靠
- */
- private function detectMergeTool(): string
- {
- // 优先检测pdfunite(更简单可靠)
- if ($this->commandExists('pdfunite')) {
- Log::info('检测到pdfunite,将使用pdfunite进行PDF合并');
- return 'pdfunite';
- }
- // 备选qpdf
- if ($this->commandExists('qpdf')) {
- Log::info('未检测到pdfunite,使用qpdf作为备选');
- return 'qpdf';
- }
- throw new \Exception('未找到PDF合并工具(pdfunite或qpdf)');
- }
- /**
- * 检查命令是否存在
- */
- private function commandExists(string $command): bool
- {
- // 【修复】异步环境中PATH可能不完整,尝试绝对路径
- $fullPaths = [
- // 本地开发路径
- "/opt/homebrew/bin/{$command}",
- "/usr/local/bin/{$command}",
- // 服务器常见路径(Ubuntu/Debian/CentOS/RHEL)
- "/usr/bin/{$command}",
- "/usr/sbin/{$command}",
- "/usr/local/sbin/{$command}",
- // Docker/Laravel 容器常见路径
- "/bin/{$command}",
- "/sbin/{$command}",
- // Alpine Linux 容器
- "/usr/gnu/bin/{$command}",
- // 自定义安装路径
- "/opt/{$command}/bin/{$command}",
- "/usr/share/{$command}/{$command}",
- ];
- Log::debug("检查命令是否存在: {$command}", [
- 'checking_absolute_paths' => $fullPaths
- ]);
- // 首先尝试绝对路径
- foreach ($fullPaths as $path) {
- if (file_exists($path) && is_executable($path)) {
- Log::debug("找到命令(绝对路径): {$command} -> {$path}");
- return true;
- }
- }
- // 如果绝对路径都不行,再尝试which命令
- Log::debug("绝对路径未找到,尝试which命令: {$command}");
- $output = Process::run("which {$command}");
- $result = $output->successful();
- Log::debug("which命令结果", [
- 'command' => $command,
- 'successful' => $result,
- 'output' => $output->output(),
- 'error' => $output->errorOutput()
- ]);
- return $result;
- }
- /**
- * 合并多个PDF文件
- *
- * @param array $pdfPaths PDF文件路径数组
- * @param string $outputPath 输出文件路径
- * @return bool 合并是否成功
- * @throws \Exception
- */
- public function merge(array $pdfPaths, string $outputPath): bool
- {
- // 验证输入文件
- foreach ($pdfPaths as $path) {
- if (!file_exists($path)) {
- throw new \Exception("PDF文件不存在: {$path}");
- }
- }
- // 确保输出目录存在
- $outputDir = dirname($outputPath);
- if (!is_dir($outputDir)) {
- mkdir($outputDir, 0755, true);
- }
- Log::info('开始合并PDF', [
- 'tool' => $this->mergeTool,
- 'input_count' => count($pdfPaths),
- 'output_path' => $outputPath
- ]);
- try {
- // 根据工具类型执行合并
- if ($this->mergeTool === 'pdfunite') {
- return $this->mergeWithPdfunite($pdfPaths, $outputPath, null);
- } else {
- return $this->mergeWithQpdf($pdfPaths, $outputPath, null);
- }
- } catch (\Exception $e) {
- Log::error('PDF合并失败', [
- 'tool' => $this->mergeTool,
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString()
- ]);
- throw $e;
- }
- }
- /**
- * 获取当前使用的合并工具
- */
- public function getMergeTool(): string
- {
- return $this->mergeTool;
- }
- /**
- * 检查环境是否支持PDF合并
- */
- public function isSupported(): bool
- {
- return in_array($this->mergeTool, ['pdfunite', 'qpdf']);
- }
- /**
- * 【新增】快速合并模式(带进度回调)
- *
- * @param array $pdfPaths PDF文件路径数组
- * @param string $outputPath 输出文件路径
- * @param callable|null $progressCallback 进度回调函数 (percentage, message) => void
- * @return bool 合并是否成功
- * @throws \Exception
- */
- public function mergeWithProgress(array $pdfPaths, string $outputPath, ?callable $progressCallback = null): bool
- {
- // 进度回调:开始
- if ($progressCallback) {
- $progressCallback(0, '开始合并PDF...');
- }
- // 验证输入文件
- foreach ($pdfPaths as $index => $path) {
- if (!file_exists($path)) {
- throw new \Exception("PDF文件不存在: {$path}");
- }
- // 进度回调:验证文件
- if ($progressCallback) {
- $progress = round(($index + 1) / (count($pdfPaths) + 1) * 20, 0); // 前20%用于验证
- $progressCallback($progress, "验证PDF文件: " . basename($path));
- }
- }
- // 确保输出目录存在
- $outputDir = dirname($outputPath);
- if (!is_dir($outputDir)) {
- mkdir($outputDir, 0755, true);
- }
- Log::info('开始快速合并PDF', [
- 'tool' => $this->mergeTool,
- 'input_count' => count($pdfPaths),
- 'output_path' => $outputPath,
- 'tool_selection' => $this->mergeTool === 'pdfunite' ? '优先使用pdfunite(更可靠)' : '使用qpdf作为备选'
- ]);
- try {
- // 进度回调:开始合并
- if ($progressCallback) {
- $toolName = $this->mergeTool === 'pdfunite' ? 'pdfunite' : 'qpdf';
- $progressCallback(20, "使用{$toolName}开始合并PDF...");
- }
- // 根据工具类型执行合并
- if ($this->mergeTool === 'pdfunite') {
- $result = $this->mergeWithPdfunite($pdfPaths, $outputPath, $progressCallback);
- } else {
- $result = $this->mergeWithQpdf($pdfPaths, $outputPath, $progressCallback);
- }
- // 进度回调:完成
- if ($progressCallback) {
- $progressCallback(100, 'PDF合并完成!');
- }
- return $result;
- } catch (\Exception $e) {
- Log::error('PDF合并失败', [
- 'tool' => $this->mergeTool,
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString()
- ]);
- if ($progressCallback) {
- $progressCallback(-1, 'PDF合并失败: ' . $e->getMessage());
- }
- throw $e;
- }
- }
- /**
- * 使用pdfunite合并PDF(带进度回调)
- * 【新增】pdfunite更简单可靠
- */
- private function mergeWithPdfunite(array $pdfPaths, string $outputPath, ?callable $progressCallback = null): bool
- {
- // 验证输入文件
- foreach ($pdfPaths as $index => $path) {
- if (!file_exists($path)) {
- Log::error('pdfunite合并失败:PDF文件不存在', [
- 'file_path' => $path,
- 'file_index' => $index,
- 'all_files' => $pdfPaths
- ]);
- if ($progressCallback) {
- $progressCallback(-1, "PDF文件不存在: " . basename($path));
- }
- return false;
- }
- Log::debug('pdfunite验证文件存在', ['path' => $path, 'size' => filesize($path)]);
- }
- // 构建pdfunite命令:pdfunite file1.pdf file2.pdf output.pdf
- $filesArg = '';
- foreach ($pdfPaths as $path) {
- $filesArg .= escapeshellarg($path) . ' ';
- }
- $command = 'pdfunite ' . $filesArg . escapeshellarg($outputPath);
- Log::info('使用pdfunite合并PDF', [
- 'command' => $command,
- 'input_count' => count($pdfPaths),
- 'output_path' => $outputPath
- ]);
- if ($progressCallback) {
- $progressCallback(30, '执行pdfunite命令...');
- }
- $startTime = microtime(true);
- $output = Process::timeout(60)->run($command);
- $duration = round((microtime(true) - $startTime) * 1000, 2);
- if ($progressCallback) {
- $progressCallback(80, '处理PDF合并结果...');
- }
- if ($output->successful()) {
- Log::info('pdfunite合并成功', [
- 'output_path' => $outputPath,
- 'duration_ms' => $duration,
- 'file_count' => count($pdfPaths),
- 'command' => $command
- ]);
- if ($progressCallback) {
- $progressCallback(95, "合并完成!耗时 {$duration}ms");
- }
- return true;
- }
- Log::error('pdfunite合并失败', [
- 'exit_code' => $output->exitCode(),
- 'output' => $output->output(),
- 'error' => $output->errorOutput(),
- 'duration_ms' => $duration,
- 'command' => $command
- ]);
- if ($progressCallback) {
- $progressCallback(-1, 'pdfunite合并失败: ' . $output->errorOutput());
- }
- return false;
- }
- /**
- * 使用qpdf合并PDF(带进度回调)
- * 【修复】优化qpdf命令格式
- */
- private function mergeWithQpdf(array $pdfPaths, string $outputPath, ?callable $progressCallback = null): bool
- {
- // 【重要】验证输入文件是否存在
- foreach ($pdfPaths as $index => $path) {
- if (!file_exists($path)) {
- Log::error('qpdf合并失败:PDF文件不存在', [
- 'file_path' => $path,
- 'file_index' => $index,
- 'all_files' => $pdfPaths,
- 'file_exists_check' => file_exists($path),
- 'directory' => dirname($path),
- 'directory_exists' => is_dir(dirname($path)),
- 'directory_contents' => is_dir(dirname($path)) ? scandir(dirname($path)) : 'N/A'
- ]);
- if ($progressCallback) {
- $progressCallback(-1, "PDF文件不存在: " . basename($path));
- }
- return false;
- }
- Log::debug('qpdf验证文件存在', ['path' => $path, 'size' => filesize($path)]);
- }
- // 【最终修复】qpdf命令格式问题 - 查阅qpdf手册
- // 正确格式应该是:qpdf --empty --pages file1.pdf,file2.pdf z -- output.pdf
- // 注意:qpdf需要在最后添加页面范围(如z表示最后一页)
- // 【最终修复】qpdf命令格式问题
- // 正确格式:qpdf --empty --pages file1.pdf,file2.pdf -- output.pdf
- // 注意:qpdf需要为每个PDF指定页面范围,如1-z表示所有页面
- // 使用逗号分隔文件,并为每个文件指定页面范围1-z(所有页面)
- $pagesArg = '';
- foreach ($pdfPaths as $path) {
- $pagesArg .= escapeshellarg($path) . '1-z,';
- }
- // 移除末尾的逗号
- $pagesArg = rtrim($pagesArg, ',');
- $command = "qpdf --empty --pages {$pagesArg} -- -- " . escapeshellarg($outputPath);
- Log::info('【最终修复】qpdf命令格式 - 为每个PDF指定页面范围', [
- 'command' => $command,
- 'pages_arg' => $pagesArg,
- 'note' => 'qpdf需要为每个PDF指定页面范围(如1-z),使用逗号分隔文件'
- ]);
- // 【优化】设置超时时间为60秒,避免无限等待
- $timeout = 60;
- if ($progressCallback) {
- $progressCallback(30, '执行qpdf命令(修复版)...');
- }
- $startTime = microtime(true);
- $output = Process::timeout($timeout)->run($command);
- $duration = round((microtime(true) - $startTime) * 1000, 2);
- if ($progressCallback) {
- $progressCallback(80, '处理PDF合并结果...');
- }
- if ($output->successful()) {
- Log::info('qpdf合并成功', [
- 'output_path' => $outputPath,
- 'duration_ms' => $duration,
- 'file_count' => count($pdfPaths),
- 'final_command' => $command
- ]);
- if ($progressCallback) {
- $progressCallback(95, "合并完成!耗时 {$duration}ms");
- }
- return true;
- }
- Log::error('qpdf合并失败', [
- 'exit_code' => $output->exitCode(),
- 'output' => $output->output(),
- 'error' => $output->errorOutput(),
- 'duration_ms' => $duration,
- 'timeout_seconds' => $timeout,
- 'final_command' => $command,
- 'input_files' => $pdfPaths,
- 'output_file' => $outputPath,
- 'note' => 'qpdf需要为每个PDF指定页面范围(如1-z),用逗号分隔文件'
- ]);
- if ($progressCallback) {
- $progressCallback(-1, 'qpdf合并失败: ' . $output->errorOutput());
- }
- return false;
- }
- }
|