isProduction = app()->environment('production'); $this->mergeTool = $this->detectMergeTool(); } /** * 检测系统中可用的PDF合并工具 */ private function detectMergeTool(): string { // 生产环境优先使用pdfunite if ($this->isProduction) { if ($this->commandExists('pdfunite')) { return 'pdfunite'; } } // 本地开发环境使用qpdf if ($this->commandExists('qpdf')) { return 'qpdf'; } // 备选:尝试pdfunite if ($this->commandExists('pdfunite')) { return 'pdfunite'; } throw new \Exception('未找到可用的PDF合并工具(pdfunite或qpdf)'); } /** * 检查命令是否存在 */ private function commandExists(string $command): bool { // 【修复】异步环境中PATH可能不完整,尝试绝对路径 $fullPaths = [ // 本地开发路径 "/opt/homebrew/bin/{$command}", "/usr/local/bin/{$command}", // 系统路径 "/usr/bin/{$command}", "/usr/sbin/{$command}", // Docker/Laravel 容器常见路径 "/bin/{$command}", "/sbin/{$command}", "/usr/local/sbin/{$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 { switch ($this->mergeTool) { case 'pdfunite': return $this->mergeWithPdfunite($pdfPaths, $outputPath, null); case 'qpdf': return $this->mergeWithQpdf($pdfPaths, $outputPath, null); default: throw new \Exception("不支持的合并工具: {$this->mergeTool}"); } } 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 ]); try { // 进度回调:开始合并 if ($progressCallback) { $progressCallback(20, "使用 {$this->mergeTool} 开始合并PDF..."); } switch ($this->mergeTool) { case 'pdfunite': $result = $this->mergeWithPdfunite($pdfPaths, $outputPath, $progressCallback); break; case 'qpdf': $result = $this->mergeWithQpdf($pdfPaths, $outputPath, $progressCallback); break; default: throw new \Exception("不支持的合并工具: {$this->mergeTool}"); } // 进度回调:完成 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(带进度回调) * 【优化】添加进度反馈 */ private function mergeWithPdfunite(array $pdfPaths, string $outputPath, ?callable $progressCallback = null): bool { $command = 'pdfunite ' . implode(' ', array_map('escapeshellarg', $pdfPaths)) . ' ' . escapeshellarg($outputPath); Log::debug('执行pdfunite命令', ['command' => $command]); // 【优化】设置超时时间为60秒,避免无限等待 $timeout = 60; if ($progressCallback) { $progressCallback(30, '执行pdfunite命令...'); } $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('pdfunite合并成功', [ 'output_path' => $outputPath, 'duration_ms' => $duration, 'file_count' => count($pdfPaths) ]); if ($progressCallback) { $progressCallback(95, "合并完成!耗时 {$duration}ms"); } return true; } Log::error('pdfunite合并失败', [ 'exit_code' => $output->exitCode(), 'output' => $output->output(), 'error' => $output->errorOutput(), 'duration_ms' => $duration, 'timeout_seconds' => $timeout ]); return false; } /** * 使用qpdf合并PDF(带进度回调) * 【修复】qpdf命令格式和文件验证问题 * 正确格式:qpdf --empty --pages file1.pdf,file2.pdf -- output.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 ]); if ($progressCallback) { $progressCallback(-1, "PDF文件不存在: " . basename($path)); } return false; } Log::debug('qpdf验证文件存在', ['path' => $path, 'size' => filesize($path)]); } // 【修复】构建正确的qpdf命令 // qpdf --empty --pages file1.pdf,file2.pdf -- output.pdf // 注意:qpdf不需要手动指定页面范围,会自动合并所有页面 // 直接使用文件路径,不使用escapeshellarg(避免单引号问题) $pagesArg = implode(',', $pdfPaths); $command = "qpdf --empty --pages {$pagesArg} -- -- {$outputPath}"; Log::debug('执行qpdf命令', ['command' => $command]); // 【优化】设置超时时间为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) ]); 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, 'command' => $command, 'input_files' => $pdfPaths, 'output_file' => $outputPath, 'note' => 'qpdf会自动合并所有页面,不需要指定页面范围' ]); if ($progressCallback) { $progressCallback(-1, 'qpdf合并失败: ' . $output->errorOutput()); } return false; } }