ExamPdfExportService.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. <?php
  2. namespace App\Services;
  3. use App\Http\Controllers\ExamPdfController;
  4. use Illuminate\Http\Request;
  5. use Illuminate\Support\Facades\Log;
  6. use Illuminate\Support\Facades\Storage;
  7. use Illuminate\Support\Facades\URL;
  8. use Symfony\Component\Process\Process;
  9. class ExamPdfExportService
  10. {
  11. private ExamPdfController $controller;
  12. public function __construct(ExamPdfController $controller)
  13. {
  14. $this->controller = $controller;
  15. }
  16. /**
  17. 生成试卷 PDF(不含答案)
  18. */
  19. public function generateExamPdf(string $paperId): ?string
  20. {
  21. return $this->renderAndStore($paperId, includeAnswer: false, suffix: 'exam');
  22. }
  23. /**
  24. 生成判卷 PDF(含答案与解析)
  25. */
  26. public function generateGradingPdf(string $paperId): ?string
  27. {
  28. return $this->renderAndStore($paperId, includeAnswer: true, suffix: 'grading', useGradingView: true);
  29. }
  30. private function renderAndStore(
  31. string $paperId,
  32. bool $includeAnswer,
  33. string $suffix,
  34. bool $useGradingView = false
  35. ): ?string {
  36. try {
  37. $html = $this->renderHtml($paperId, $includeAnswer, $useGradingView);
  38. if (!$html) {
  39. return null;
  40. }
  41. $pdfBinary = $this->buildPdf($html);
  42. if (!$pdfBinary) {
  43. return null;
  44. }
  45. $path = "exams/{$paperId}_{$suffix}.pdf";
  46. Storage::disk('public')->put($path, $pdfBinary);
  47. return URL::to(Storage::url($path));
  48. } catch (\Throwable $e) {
  49. Log::error('ExamPdfExportService: 生成 PDF 失败', [
  50. 'paper_id' => $paperId,
  51. 'suffix' => $suffix,
  52. 'error' => $e->getMessage()
  53. ]);
  54. return null;
  55. }
  56. }
  57. private function renderHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
  58. {
  59. // 复用已有控制器的渲染逻辑,保证版式一致
  60. $request = Request::create(
  61. '/admin/intelligent-exam/' . ($useGradingView ? 'grading' : 'pdf') . '/' . $paperId,
  62. 'GET',
  63. ['answer' => $includeAnswer ? 'true' : 'false']
  64. );
  65. $view = $useGradingView
  66. ? $this->controller->showGrading($request, $paperId)
  67. : $this->controller->show($request, $paperId);
  68. if (is_object($view) && method_exists($view, 'render')) {
  69. return $this->ensureUtf8Html($view->render());
  70. }
  71. return null;
  72. }
  73. private function buildPdf(string $html): ?string
  74. {
  75. $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_') . '.html';
  76. file_put_contents($tmpHtml, $this->ensureUtf8Html($html));
  77. // 先尝试 Chrome
  78. $chromePdf = $this->renderWithChrome($tmpHtml);
  79. if ($chromePdf !== null) {
  80. @unlink($tmpHtml);
  81. return $chromePdf;
  82. }
  83. // Chrome 失败则降级 wkhtmltopdf,尽量保证有输出
  84. $wkPdf = $this->renderWithWkhtml($tmpHtml);
  85. @unlink($tmpHtml);
  86. return $wkPdf;
  87. }
  88. private function renderWithChrome(string $htmlPath): ?string
  89. {
  90. $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_') . '.pdf';
  91. $chromeBinary = env('PDF_CHROME_BINARY');
  92. if (!$chromeBinary) {
  93. $candidates = [
  94. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  95. '/usr/bin/google-chrome-stable',
  96. '/usr/bin/google-chrome',
  97. '/usr/bin/chromium-browser',
  98. '/usr/bin/chromium',
  99. ];
  100. foreach ($candidates as $path) {
  101. if (is_file($path) && is_executable($path)) {
  102. $chromeBinary = $path;
  103. break;
  104. }
  105. }
  106. }
  107. if (!$chromeBinary) {
  108. Log::warning('ExamPdfExportService: 未找到可用的 Chrome/Chromium,可尝试 wkhtmltopdf');
  109. return null;
  110. }
  111. $process = new Process([
  112. $chromeBinary,
  113. '--headless',
  114. '--disable-gpu',
  115. '--no-sandbox',
  116. '--disable-setuid-sandbox',
  117. '--disable-dev-shm-usage',
  118. '--no-zygote',
  119. '--single-process',
  120. '--disable-features=VizDisplayCompositor',
  121. '--user-data-dir=' . sys_get_temp_dir() . '/chrome-user-data',
  122. '--print-to-pdf=' . $tmpPdf,
  123. '--print-to-pdf-no-header',
  124. '--allow-file-access-from-files',
  125. 'file://' . $htmlPath,
  126. ]);
  127. $process->setTimeout(40);
  128. $process->run();
  129. if (!$process->isSuccessful() || !file_exists($tmpPdf)) {
  130. Log::error('ExamPdfExportService: Chrome 渲染失败', [
  131. 'cmd' => implode(' ', (array) $process->getCommandLine()),
  132. 'exit_code' => $process->getExitCode(),
  133. 'error' => $process->getErrorOutput(),
  134. 'output' => $process->getOutput(),
  135. ]);
  136. @unlink($tmpPdf);
  137. return null;
  138. }
  139. $pdfBinary = file_get_contents($tmpPdf);
  140. @unlink($tmpPdf);
  141. return $pdfBinary ?: null;
  142. }
  143. private function renderWithWkhtml(string $htmlPath): ?string
  144. {
  145. $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_wk_') . '.pdf';
  146. $wkBinary = env('PDF_WKHTML_BINARY');
  147. if (!$wkBinary) {
  148. $candidates = [
  149. '/usr/bin/wkhtmltopdf',
  150. '/usr/local/bin/wkhtmltopdf',
  151. ];
  152. foreach ($candidates as $path) {
  153. if (is_file($path) && is_executable($path)) {
  154. $wkBinary = $path;
  155. break;
  156. }
  157. }
  158. }
  159. if (!$wkBinary) {
  160. Log::error('ExamPdfExportService: 未找到可用的 Chrome,且 wkhtmltopdf 未安装,无法导出');
  161. return null;
  162. }
  163. $process = new Process([
  164. $wkBinary,
  165. '--disable-smart-shrinking',
  166. '--encoding', 'utf-8',
  167. $htmlPath,
  168. $tmpPdf,
  169. ]);
  170. $process->setTimeout(40);
  171. $process->run();
  172. if (!$process->isSuccessful() || !file_exists($tmpPdf)) {
  173. Log::error('ExamPdfExportService: wkhtmltopdf 渲染失败', [
  174. 'cmd' => implode(' ', (array) $process->getCommandLine()),
  175. 'exit_code' => $process->getExitCode(),
  176. 'error' => $process->getErrorOutput(),
  177. 'output' => $process->getOutput(),
  178. ]);
  179. @unlink($tmpPdf);
  180. return null;
  181. }
  182. $pdfBinary = file_get_contents($tmpPdf);
  183. @unlink($tmpPdf);
  184. return $pdfBinary ?: null;
  185. }
  186. private function ensureUtf8Html(string $html): string
  187. {
  188. $meta = '<meta charset="UTF-8">';
  189. if (stripos($html, '<head>') !== false) {
  190. return preg_replace('/<head>/i', "<head>{$meta}", $html, 1);
  191. }
  192. return $meta . $html;
  193. }
  194. }