controller = $controller; } /** 生成试卷 PDF(不含答案) */ public function generateExamPdf(string $paperId): ?string { return $this->renderAndStore($paperId, includeAnswer: false, suffix: 'exam'); } /** 生成判卷 PDF(含答案与解析) */ public function generateGradingPdf(string $paperId): ?string { return $this->renderAndStore($paperId, includeAnswer: true, suffix: 'grading', useGradingView: true); } private function renderAndStore( string $paperId, bool $includeAnswer, string $suffix, bool $useGradingView = false ): ?string { try { $html = $this->renderHtml($paperId, $includeAnswer, $useGradingView); if (!$html) { return null; } $pdfBinary = $this->buildPdf($html); if (!$pdfBinary) { return null; } $path = "exams/{$paperId}_{$suffix}.pdf"; Storage::disk('public')->put($path, $pdfBinary); return URL::to(Storage::url($path)); } catch (\Throwable $e) { Log::error('ExamPdfExportService: 生成 PDF 失败', [ 'paper_id' => $paperId, 'suffix' => $suffix, 'error' => $e->getMessage() ]); return null; } } private function renderHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string { // 复用已有控制器的渲染逻辑,保证版式一致 $request = Request::create( '/admin/intelligent-exam/' . ($useGradingView ? 'grading' : 'pdf') . '/' . $paperId, 'GET', ['answer' => $includeAnswer ? 'true' : 'false'] ); $view = $useGradingView ? $this->controller->showGrading($request, $paperId) : $this->controller->show($request, $paperId); if (is_object($view) && method_exists($view, 'render')) { return $this->ensureUtf8Html($view->render()); } return null; } private function buildPdf(string $html): ?string { // 使用无头 Chrome 渲染 HTML,保留前端样式并彻底解决大量空白页问题 $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_') . '.html'; $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_') . '.pdf'; file_put_contents($tmpHtml, $this->ensureUtf8Html($html)); $chromeBinary = env('PDF_CHROME_BINARY'); if (!$chromeBinary) { // 默认优先 Mac,本地开发;不存在则尝试常见 Linux 路径 $candidates = [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome', ]; foreach ($candidates as $path) { if (is_file($path) && is_executable($path)) { $chromeBinary = $path; break; } } } if (!$chromeBinary) { Log::error('ExamPdfExportService: 未找到可用的 Chrome/Chromium 可执行文件'); return null; } $process = new Process([ $chromeBinary, '--headless', '--disable-gpu', '--no-sandbox', '--print-to-pdf=' . $tmpPdf, '--print-to-pdf-no-header', '--allow-file-access-from-files', 'file://' . $tmpHtml, ]); $process->setTimeout(30); $process->run(); if (!$process->isSuccessful() || !file_exists($tmpPdf)) { Log::error('ExamPdfExportService: Chrome 渲染失败', [ 'error' => $process->getErrorOutput(), 'output' => $process->getOutput(), ]); @unlink($tmpHtml); @unlink($tmpPdf); return null; } $pdfBinary = file_get_contents($tmpPdf); @unlink($tmpHtml); @unlink($tmpPdf); return $pdfBinary ?: null; } private function ensureUtf8Html(string $html): string { $meta = ''; if (stripos($html, '') !== false) { return preg_replace('//i', "{$meta}", $html, 1); } return $meta . $html; } }