PdfMerger.php 14 KB

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