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