ExamPdfExportService.php 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  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. // 使用无头 Chrome 渲染 HTML,保留前端样式并彻底解决大量空白页问题
  76. $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_') . '.html';
  77. $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_') . '.pdf';
  78. file_put_contents($tmpHtml, $this->ensureUtf8Html($html));
  79. $chromeBinary = env('PDF_CHROME_BINARY');
  80. if (!$chromeBinary) {
  81. // 默认优先 Mac,本地开发;不存在则尝试常见 Linux 路径
  82. $candidates = [
  83. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  84. '/usr/bin/chromium-browser',
  85. '/usr/bin/chromium',
  86. '/usr/bin/google-chrome',
  87. ];
  88. foreach ($candidates as $path) {
  89. if (is_file($path) && is_executable($path)) {
  90. $chromeBinary = $path;
  91. break;
  92. }
  93. }
  94. }
  95. if (!$chromeBinary) {
  96. Log::error('ExamPdfExportService: 未找到可用的 Chrome/Chromium 可执行文件');
  97. return null;
  98. }
  99. $process = new Process([
  100. $chromeBinary,
  101. '--headless',
  102. '--disable-gpu',
  103. '--no-sandbox',
  104. '--disable-setuid-sandbox',
  105. '--disable-dev-shm-usage', // 避免容器内 /dev/shm 过小导致崩溃
  106. '--no-zygote',
  107. '--single-process',
  108. '--print-to-pdf=' . $tmpPdf,
  109. '--print-to-pdf-no-header',
  110. '--allow-file-access-from-files',
  111. 'file://' . $tmpHtml,
  112. ]);
  113. $process->setTimeout(30);
  114. $process->run();
  115. if (!$process->isSuccessful() || !file_exists($tmpPdf)) {
  116. Log::error('ExamPdfExportService: Chrome 渲染失败', [
  117. 'cmd' => implode(' ', (array) $process->getCommandLine()),
  118. 'exit_code' => $process->getExitCode(),
  119. 'error' => $process->getErrorOutput(),
  120. 'output' => $process->getOutput(),
  121. ]);
  122. @unlink($tmpHtml);
  123. @unlink($tmpPdf);
  124. return null;
  125. }
  126. $pdfBinary = file_get_contents($tmpPdf);
  127. @unlink($tmpHtml);
  128. @unlink($tmpPdf);
  129. return $pdfBinary ?: null;
  130. }
  131. private function ensureUtf8Html(string $html): string
  132. {
  133. $meta = '<meta charset="UTF-8">';
  134. if (stripos($html, '<head>') !== false) {
  135. return preg_replace('/<head>/i', "<head>{$meta}", $html, 1);
  136. }
  137. return $meta . $html;
  138. }
  139. }