PdfMerger.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\Log;
  4. use Illuminate\Support\Facades\Process;
  5. use Illuminate\Support\Str;
  6. /**
  7. * PDF合并工具类
  8. * 支持pdfunite(生产环境)和qpdf(本地开发)
  9. */
  10. class PdfMerger
  11. {
  12. private string $mergeTool;
  13. private bool $isProduction;
  14. public function __construct()
  15. {
  16. $this->isProduction = app()->environment('production');
  17. $this->mergeTool = $this->detectMergeTool();
  18. }
  19. /**
  20. * 检测系统中可用的PDF合并工具
  21. * 【修复】优先使用pdfunite,更简单可靠
  22. */
  23. private function detectMergeTool(): string
  24. {
  25. // 优先检测pdfunite(更简单可靠)
  26. if ($this->commandExists('pdfunite')) {
  27. Log::info('检测到pdfunite,将使用pdfunite进行PDF合并');
  28. return 'pdfunite';
  29. }
  30. // 备选qpdf
  31. if ($this->commandExists('qpdf')) {
  32. Log::info('未检测到pdfunite,使用qpdf作为备选');
  33. return 'qpdf';
  34. }
  35. throw new \Exception('未找到PDF合并工具(pdfunite或qpdf)');
  36. }
  37. /**
  38. * 检查命令是否存在
  39. */
  40. private function commandExists(string $command): bool
  41. {
  42. // 【修复】异步环境中PATH可能不完整,尝试绝对路径
  43. $fullPaths = [
  44. // 本地开发路径
  45. "/opt/homebrew/bin/{$command}",
  46. "/usr/local/bin/{$command}",
  47. // 服务器常见路径(Ubuntu/Debian/CentOS/RHEL)
  48. "/usr/bin/{$command}",
  49. "/usr/sbin/{$command}",
  50. "/usr/local/sbin/{$command}",
  51. // Docker/Laravel 容器常见路径
  52. "/bin/{$command}",
  53. "/sbin/{$command}",
  54. // Alpine Linux 容器
  55. "/usr/gnu/bin/{$command}",
  56. // 自定义安装路径
  57. "/opt/{$command}/bin/{$command}",
  58. "/usr/share/{$command}/{$command}",
  59. ];
  60. Log::debug("检查命令是否存在: {$command}", [
  61. 'checking_absolute_paths' => $fullPaths
  62. ]);
  63. // 首先尝试绝对路径
  64. foreach ($fullPaths as $path) {
  65. if (file_exists($path) && is_executable($path)) {
  66. Log::debug("找到命令(绝对路径): {$command} -> {$path}");
  67. return true;
  68. }
  69. }
  70. // 如果绝对路径都不行,再尝试which命令
  71. Log::debug("绝对路径未找到,尝试which命令: {$command}");
  72. $output = Process::run("which {$command}");
  73. $result = $output->successful();
  74. Log::debug("which命令结果", [
  75. 'command' => $command,
  76. 'successful' => $result,
  77. 'output' => $output->output(),
  78. 'error' => $output->errorOutput()
  79. ]);
  80. return $result;
  81. }
  82. /**
  83. * 合并多个PDF文件
  84. *
  85. * @param array $pdfPaths PDF文件路径数组
  86. * @param string $outputPath 输出文件路径
  87. * @return bool 合并是否成功
  88. * @throws \Exception
  89. */
  90. public function merge(array $pdfPaths, string $outputPath): bool
  91. {
  92. // 验证输入文件
  93. foreach ($pdfPaths as $path) {
  94. if (!file_exists($path)) {
  95. throw new \Exception("PDF文件不存在: {$path}");
  96. }
  97. }
  98. // 确保输出目录存在
  99. $outputDir = dirname($outputPath);
  100. if (!is_dir($outputDir)) {
  101. mkdir($outputDir, 0755, true);
  102. }
  103. Log::info('开始合并PDF', [
  104. 'tool' => $this->mergeTool,
  105. 'input_count' => count($pdfPaths),
  106. 'output_path' => $outputPath
  107. ]);
  108. try {
  109. // 根据工具类型执行合并
  110. if ($this->mergeTool === 'pdfunite') {
  111. return $this->mergeWithPdfunite($pdfPaths, $outputPath, null);
  112. } else {
  113. return $this->mergeWithQpdf($pdfPaths, $outputPath, null);
  114. }
  115. } catch (\Exception $e) {
  116. Log::error('PDF合并失败', [
  117. 'tool' => $this->mergeTool,
  118. 'error' => $e->getMessage(),
  119. 'trace' => $e->getTraceAsString()
  120. ]);
  121. throw $e;
  122. }
  123. }
  124. /**
  125. * 获取当前使用的合并工具
  126. */
  127. public function getMergeTool(): string
  128. {
  129. return $this->mergeTool;
  130. }
  131. /**
  132. * 检查环境是否支持PDF合并
  133. */
  134. public function isSupported(): bool
  135. {
  136. return in_array($this->mergeTool, ['pdfunite', 'qpdf']);
  137. }
  138. /**
  139. * 【新增】快速合并模式(带进度回调)
  140. *
  141. * @param array $pdfPaths PDF文件路径数组
  142. * @param string $outputPath 输出文件路径
  143. * @param callable|null $progressCallback 进度回调函数 (percentage, message) => void
  144. * @return bool 合并是否成功
  145. * @throws \Exception
  146. */
  147. public function mergeWithProgress(array $pdfPaths, string $outputPath, ?callable $progressCallback = null): bool
  148. {
  149. // 进度回调:开始
  150. if ($progressCallback) {
  151. $progressCallback(0, '开始合并PDF...');
  152. }
  153. // 验证输入文件
  154. foreach ($pdfPaths as $index => $path) {
  155. if (!file_exists($path)) {
  156. throw new \Exception("PDF文件不存在: {$path}");
  157. }
  158. // 进度回调:验证文件
  159. if ($progressCallback) {
  160. $progress = round(($index + 1) / (count($pdfPaths) + 1) * 20, 0); // 前20%用于验证
  161. $progressCallback($progress, "验证PDF文件: " . basename($path));
  162. }
  163. }
  164. // 确保输出目录存在
  165. $outputDir = dirname($outputPath);
  166. if (!is_dir($outputDir)) {
  167. mkdir($outputDir, 0755, true);
  168. }
  169. Log::info('开始快速合并PDF', [
  170. 'tool' => $this->mergeTool,
  171. 'input_count' => count($pdfPaths),
  172. 'output_path' => $outputPath,
  173. 'tool_selection' => $this->mergeTool === 'pdfunite' ? '优先使用pdfunite(更可靠)' : '使用qpdf作为备选'
  174. ]);
  175. try {
  176. // 进度回调:开始合并
  177. if ($progressCallback) {
  178. $toolName = $this->mergeTool === 'pdfunite' ? 'pdfunite' : 'qpdf';
  179. $progressCallback(20, "使用{$toolName}开始合并PDF...");
  180. }
  181. // 根据工具类型执行合并
  182. if ($this->mergeTool === 'pdfunite') {
  183. $result = $this->mergeWithPdfunite($pdfPaths, $outputPath, $progressCallback);
  184. } else {
  185. $result = $this->mergeWithQpdf($pdfPaths, $outputPath, $progressCallback);
  186. }
  187. // 进度回调:完成
  188. if ($progressCallback) {
  189. $progressCallback(100, 'PDF合并完成!');
  190. }
  191. return $result;
  192. } catch (\Exception $e) {
  193. Log::error('PDF合并失败', [
  194. 'tool' => $this->mergeTool,
  195. 'error' => $e->getMessage(),
  196. 'trace' => $e->getTraceAsString()
  197. ]);
  198. if ($progressCallback) {
  199. $progressCallback(-1, 'PDF合并失败: ' . $e->getMessage());
  200. }
  201. throw $e;
  202. }
  203. }
  204. /**
  205. * 使用pdfunite合并PDF(带进度回调)
  206. * 【新增】pdfunite更简单可靠
  207. */
  208. private function mergeWithPdfunite(array $pdfPaths, string $outputPath, ?callable $progressCallback = null): bool
  209. {
  210. // 验证输入文件
  211. foreach ($pdfPaths as $index => $path) {
  212. if (!file_exists($path)) {
  213. Log::error('pdfunite合并失败:PDF文件不存在', [
  214. 'file_path' => $path,
  215. 'file_index' => $index,
  216. 'all_files' => $pdfPaths
  217. ]);
  218. if ($progressCallback) {
  219. $progressCallback(-1, "PDF文件不存在: " . basename($path));
  220. }
  221. return false;
  222. }
  223. Log::debug('pdfunite验证文件存在', ['path' => $path, 'size' => filesize($path)]);
  224. }
  225. // 构建pdfunite命令:pdfunite file1.pdf file2.pdf output.pdf
  226. $filesArg = '';
  227. foreach ($pdfPaths as $path) {
  228. $filesArg .= escapeshellarg($path) . ' ';
  229. }
  230. $command = 'pdfunite ' . $filesArg . escapeshellarg($outputPath);
  231. Log::info('使用pdfunite合并PDF', [
  232. 'command' => $command,
  233. 'input_count' => count($pdfPaths),
  234. 'output_path' => $outputPath
  235. ]);
  236. if ($progressCallback) {
  237. $progressCallback(30, '执行pdfunite命令...');
  238. }
  239. $startTime = microtime(true);
  240. $output = Process::timeout(60)->run($command);
  241. $duration = round((microtime(true) - $startTime) * 1000, 2);
  242. if ($progressCallback) {
  243. $progressCallback(80, '处理PDF合并结果...');
  244. }
  245. if ($output->successful()) {
  246. Log::info('pdfunite合并成功', [
  247. 'output_path' => $outputPath,
  248. 'duration_ms' => $duration,
  249. 'file_count' => count($pdfPaths),
  250. 'command' => $command
  251. ]);
  252. if ($progressCallback) {
  253. $progressCallback(95, "合并完成!耗时 {$duration}ms");
  254. }
  255. return true;
  256. }
  257. Log::error('pdfunite合并失败', [
  258. 'exit_code' => $output->exitCode(),
  259. 'output' => $output->output(),
  260. 'error' => $output->errorOutput(),
  261. 'duration_ms' => $duration,
  262. 'command' => $command
  263. ]);
  264. if ($progressCallback) {
  265. $progressCallback(-1, 'pdfunite合并失败: ' . $output->errorOutput());
  266. }
  267. return false;
  268. }
  269. /**
  270. * 使用qpdf合并PDF(带进度回调)
  271. * 【修复】优化qpdf命令格式
  272. */
  273. private function mergeWithQpdf(array $pdfPaths, string $outputPath, ?callable $progressCallback = null): bool
  274. {
  275. // 【重要】验证输入文件是否存在
  276. foreach ($pdfPaths as $index => $path) {
  277. if (!file_exists($path)) {
  278. Log::error('qpdf合并失败:PDF文件不存在', [
  279. 'file_path' => $path,
  280. 'file_index' => $index,
  281. 'all_files' => $pdfPaths,
  282. 'file_exists_check' => file_exists($path),
  283. 'directory' => dirname($path),
  284. 'directory_exists' => is_dir(dirname($path)),
  285. 'directory_contents' => is_dir(dirname($path)) ? scandir(dirname($path)) : 'N/A'
  286. ]);
  287. if ($progressCallback) {
  288. $progressCallback(-1, "PDF文件不存在: " . basename($path));
  289. }
  290. return false;
  291. }
  292. Log::debug('qpdf验证文件存在', ['path' => $path, 'size' => filesize($path)]);
  293. }
  294. // 【最终修复】qpdf命令格式问题 - 查阅qpdf手册
  295. // 正确格式应该是:qpdf --empty --pages file1.pdf,file2.pdf z -- output.pdf
  296. // 注意:qpdf需要在最后添加页面范围(如z表示最后一页)
  297. // 【最终修复】qpdf命令格式问题
  298. // 正确格式:qpdf --empty --pages file1.pdf,file2.pdf -- output.pdf
  299. // 注意:qpdf需要为每个PDF指定页面范围,如1-z表示所有页面
  300. // 使用逗号分隔文件,并为每个文件指定页面范围1-z(所有页面)
  301. $pagesArg = '';
  302. foreach ($pdfPaths as $path) {
  303. $pagesArg .= escapeshellarg($path) . '1-z,';
  304. }
  305. // 移除末尾的逗号
  306. $pagesArg = rtrim($pagesArg, ',');
  307. $command = "qpdf --empty --pages {$pagesArg} -- -- " . escapeshellarg($outputPath);
  308. Log::info('【最终修复】qpdf命令格式 - 为每个PDF指定页面范围', [
  309. 'command' => $command,
  310. 'pages_arg' => $pagesArg,
  311. 'note' => 'qpdf需要为每个PDF指定页面范围(如1-z),使用逗号分隔文件'
  312. ]);
  313. // 【优化】设置超时时间为60秒,避免无限等待
  314. $timeout = 60;
  315. if ($progressCallback) {
  316. $progressCallback(30, '执行qpdf命令(修复版)...');
  317. }
  318. $startTime = microtime(true);
  319. $output = Process::timeout($timeout)->run($command);
  320. $duration = round((microtime(true) - $startTime) * 1000, 2);
  321. if ($progressCallback) {
  322. $progressCallback(80, '处理PDF合并结果...');
  323. }
  324. if ($output->successful()) {
  325. Log::info('qpdf合并成功', [
  326. 'output_path' => $outputPath,
  327. 'duration_ms' => $duration,
  328. 'file_count' => count($pdfPaths),
  329. 'final_command' => $command
  330. ]);
  331. if ($progressCallback) {
  332. $progressCallback(95, "合并完成!耗时 {$duration}ms");
  333. }
  334. return true;
  335. }
  336. Log::error('qpdf合并失败', [
  337. 'exit_code' => $output->exitCode(),
  338. 'output' => $output->output(),
  339. 'error' => $output->errorOutput(),
  340. 'duration_ms' => $duration,
  341. 'timeout_seconds' => $timeout,
  342. 'final_command' => $command,
  343. 'input_files' => $pdfPaths,
  344. 'output_file' => $outputPath,
  345. 'note' => 'qpdf需要为每个PDF指定页面范围(如1-z),用逗号分隔文件'
  346. ]);
  347. if ($progressCallback) {
  348. $progressCallback(-1, 'qpdf合并失败: ' . $output->errorOutput());
  349. }
  350. return false;
  351. }
  352. }