]*><\/div>/i',
function ($matches) use ($parser) {
$markdown = html_entity_decode(trim($matches[1]), ENT_QUOTES, 'UTF-8');
$rendered = $parser->transform($markdown);
return '
'.$rendered.'
';
},
$html
);
}
/**
* 【新增】渲染试卷HTML(通过HTTP调用路由)
*/
private function renderExamHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
{
try {
// 通过HTTP客户端获取渲染后的HTML(与知识点讲解相同的逻辑)
$routeName = $useGradingView
? 'filament.admin.auth.intelligent-exam.grading'
: 'filament.admin.auth.intelligent-exam.pdf';
$url = route($routeName, ['paper_id' => $paperId, 'answer' => $includeAnswer ? 'true' : 'false']);
$response = Http::get($url);
if ($response->successful()) {
$html = $response->body();
if (! empty(trim($html))) {
return $this->ensureUtf8Html($html);
}
}
Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败,使用备用方案', [
'paper_id' => $paperId,
'url' => $url,
]);
} catch (\Exception $e) {
Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML异常', [
'paper_id' => $paperId,
'error' => $e->getMessage(),
]);
}
// 备用方案:直接渲染视图
return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
}
/**
* 备用方案:直接渲染视图生成试卷HTML
*/
private function renderExamHtmlFromView(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
{
try {
$paper = Paper::with('questions')->find($paperId);
if (! $paper) {
Log::error('ExamPdfExportService: 试卷不存在', ['paper_id' => $paperId]);
return null;
}
if ($paper->questions->isEmpty()) {
Log::error('ExamPdfExportService: 试卷没有题目数据', [
'paper_id' => $paperId,
'question_count' => 0,
]);
return null;
}
$viewName = $useGradingView ? 'pdf.exam-grading' : 'pdf.exam-paper';
// 构造视图需要的变量
$questions = ['choice' => [], 'fill' => [], 'answer' => []];
foreach ($paper->questions as $pq) {
$qType = $this->normalizeQuestionType($pq->question_type ?? 'answer');
$questions[$qType][] = $pq;
}
$studentModel = \App\Models\Student::find($paper->student_id);
$teacherModel = \App\Models\Teacher::find($paper->teacher_id);
$student = ['name' => $studentModel->name ?? ($paper->student_id ?? '________'), 'grade' => $studentModel->grade ?? '________'];
$teacher = ['name' => $teacherModel->name ?? ($paper->teacher_id ?? '________')];
$html = view($viewName, [
'paper' => $paper,
'questions' => $questions,
'includeAnswer' => $includeAnswer,
'student' => $student,
'teacher' => $teacher,
])->render();
if (empty(trim($html))) {
Log::error('ExamPdfExportService: 视图渲染结果为空', [
'paper_id' => $paperId,
'view_name' => $viewName,
'question_count' => $paper->questions->count(),
]);
return null;
}
return $this->ensureUtf8Html($html);
} catch (\Exception $e) {
Log::error('ExamPdfExportService: 备用方案渲染失败', [
'paper_id' => $paperId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return null;
}
}
/**
* 构建分析数据(重构版)
* 优先使用本地MySQL数据,减少API依赖
*/
private function buildAnalysisData(string $paperId, string $studentId): ?array
{
// 【关键调试】确认方法被调用
Log::warning('ExamPdfExportService: buildAnalysisData方法被调用了!', [
'paper_id' => $paperId,
'student_id' => $studentId,
'timestamp' => now()->toISOString(),
]);
$paper = Paper::with(['questions' => function ($query) {
$query->orderBy('question_number')->orderBy('id');
}])->find($paperId);
if (! $paper) {
Log::warning('ExamPdfExportService: 未找到试卷,将尝试仅基于分析数据生成PDF', [
'paper_id' => $paperId,
'student_id' => $studentId,
]);
// 【修复】即使试卷不存在,也尝试基于分析数据生成PDF
$paper = new \stdClass;
$paper->paper_id = $paperId;
$paper->paper_name = "学情分析报告_{$studentId}_{$paperId}";
$paper->question_count = 0;
$paper->total_score = 0;
$paper->created_at = now();
$paper->questions = collect();
}
$student = Student::find($studentId);
$studentInfo = [
'id' => $student?->student_id ?? $studentId,
'name' => $student?->name ?? $studentId,
'grade' => $student?->grade ?? '未知年级',
'class' => $student?->class_name ?? '未知班级',
];
// 【修改】直接从本地数据库获取分析数据(不再调用API)
$analysisData = [];
// 首先尝试从paper->analysis_id获取
if (! empty($paper->analysis_id)) {
Log::info('ExamPdfExportService: 从本地数据库获取试卷分析数据', [
'paper_id' => $paperId,
'student_id' => $studentId,
'analysis_id' => $paper->analysis_id,
]);
$analysisRecord = \DB::table('exam_analysis_results')
->where('id', $paper->analysis_id)
->where('student_id', $studentId)
->first();
if ($analysisRecord && ! empty($analysisRecord->analysis_data)) {
$analysisData = json_decode($analysisRecord->analysis_data, true);
Log::info('ExamPdfExportService: 成功获取本地分析数据(通过analysis_id)', [
'data_size' => strlen($analysisRecord->analysis_data),
]);
} else {
Log::warning('ExamPdfExportService: 未找到本地分析数据,将尝试其他方式', [
'paper_id' => $paperId,
'student_id' => $studentId,
'analysis_id' => $paper->analysis_id,
]);
}
}
// 如果没有analysis_id或未找到数据,直接从exam_analysis_results表查询
if (empty($analysisData)) {
Log::info('ExamPdfExportService: 直接从exam_analysis_results表查询分析数据', [
'paper_id' => $paperId,
'student_id' => $studentId,
]);
$analysisRecord = \DB::table('exam_analysis_results')
->where('paper_id', $paperId)
->where('student_id', $studentId)
->first();
if ($analysisRecord && ! empty($analysisRecord->analysis_data)) {
$analysisData = json_decode($analysisRecord->analysis_data, true);
Log::info('ExamPdfExportService: 成功获取本地分析数据(直接查询)', [
'data_size' => strlen($analysisRecord->analysis_data),
'question_count' => count($analysisData['question_analysis'] ?? []),
]);
} else {
Log::warning('ExamPdfExportService: 未找到任何分析数据,将使用空数据', [
'paper_id' => $paperId,
'student_id' => $studentId,
]);
}
}
// 【修复】优先使用analysisData中的knowledge_point_analysis数据
$masteryData = [];
$parentMasteryLevels = []; // 新增:父节点掌握度数据
Log::info('ExamPdfExportService: 开始处理掌握度数据', [
'student_id' => $studentId,
'analysisData_keys' => array_keys($analysisData),
'has_knowledge_point_analysis' => ! empty($analysisData['knowledge_point_analysis']),
]);
if (! empty($analysisData['knowledge_point_analysis'])) {
// 将knowledge_point_analysis转换为buildMasterySummary期望的格式
foreach ($analysisData['knowledge_point_analysis'] as $kp) {
$masteryData[] = [
'kp_code' => $kp['kp_id'] ?? null,
'kp_name' => $kp['kp_id'] ?? '未知知识点',
'mastery_level' => $kp['mastery_level'] ?? 0,
'mastery_change' => $kp['change'] ?? null,
];
}
// 【修复】基于所有兄弟节点历史数据计算父节点掌握度,并获取掌握度变化
try {
// 获取本次考试涉及的知识点代码列表
$examKpCodes = array_column($masteryData, 'kp_code');
Log::info('ExamPdfExportService: 本次考试涉及的知识点', [
'count' => count($examKpCodes),
'kp_codes' => $examKpCodes,
]);
// 获取上一个快照的数据(用于计算变化)
// 如果没有其他试卷的记录,使用同一试卷的上一次快照
$lastSnapshot = DB::connection('mysql')
->table('knowledge_point_mastery_snapshots')
->where('student_id', $studentId)
->where('paper_id', $paper->paper_id)
->where('snapshot_id', '!=', "snap_{$paper->paper_id}_".date('YmdHis'))
->latest('snapshot_time')
->first();
$previousMasteryData = [];
if ($lastSnapshot) {
$previousMasteryJson = json_decode($lastSnapshot->mastery_data, true);
foreach ($previousMasteryJson as $kpCode => $data) {
$previousMasteryData[$kpCode] = [
'current_mastery' => $data['current_mastery'] ?? 0,
'previous_mastery' => $data['previous_mastery'] ?? null,
];
}
Log::info('ExamPdfExportService: 获取到上一次快照数据', [
'snapshot_time' => $lastSnapshot->snapshot_time,
'kp_count' => count($previousMasteryData),
]);
}
// 为当前知识点添加变化数据
foreach ($masteryData as &$item) {
$kpCode = $item['kp_code'];
if (isset($previousMasteryData[$kpCode])) {
$previous = floatval($previousMasteryData[$kpCode]['previous_mastery'] ?? 0);
$current = floatval($item['mastery_level']);
$item['mastery_change'] = $current - $previous;
}
}
unset($item); // 解除引用
// 获取所有父节点掌握度
$masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
$allParentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? [];
// 计算与本次考试相关的父节点掌握度(基于所有兄弟节点)
$parentMasteryLevels = [];
// 【修复】使用数据库查询正确匹配父子关系,而不是字符串前缀
foreach ($allParentMasteryLevels as $parentKpCode => $parentMastery) {
// 查询这个父节点的所有子节点
$childNodes = DB::connection('mysql')
->table('knowledge_points')
->where('parent_kp_code', $parentKpCode)
->pluck('kp_code')
->toArray();
// 检查是否有子节点在本次考试中出现
$relevantChildren = array_intersect($examKpCodes, $childNodes);
if (! empty($relevantChildren)) {
// 【修复】计算父节点变化:基于所有子节点的平均变化
$childChanges = [];
foreach ($relevantChildren as $childKpCode) {
$previousChild = $previousMasteryData[$childKpCode]['previous_mastery'] ?? null;
$currentChild = null;
foreach ($masteryData as $item) {
if ($item['kp_code'] === $childKpCode) {
$currentChild = $item['mastery_level'];
break;
}
}
if ($previousChild !== null && $currentChild !== null) {
$childChanges[] = floatval($currentChild) - floatval($previousChild);
}
}
$avgChange = ! empty($childChanges) ? array_sum($childChanges) / count($childChanges) : null;
// 获取父节点中文名称
$parentKpInfo = DB::connection('mysql')
->table('knowledge_points')
->where('kp_code', $parentKpCode)
->first();
$parentMasteryLevels[$parentKpCode] = [
'kp_code' => $parentKpCode,
'kp_name' => $parentKpInfo->name ?? $parentKpCode,
'mastery_level' => $parentMastery,
'mastery_percentage' => round($parentMastery * 100, 1),
'mastery_change' => $avgChange,
'children' => $relevantChildren,
];
}
}
Log::info('ExamPdfExportService: 过滤后的父节点掌握度', [
'all_parent_count' => count($allParentMasteryLevels),
'filtered_parent_count' => count($parentMasteryLevels),
'filtered_codes' => array_keys($parentMasteryLevels),
]);
} catch (\Exception $e) {
Log::warning('ExamPdfExportService: 获取父节点掌握度失败', [
'error' => $e->getMessage(),
]);
}
Log::info('ExamPdfExportService: 使用analysisData中的掌握度数据', [
'count' => count($masteryData),
'masteryData_sample' => ! empty($masteryData) ? array_slice($masteryData, 0, 2) : [],
]);
} else {
// 如果没有knowledge_point_analysis,使用MasteryCalculator获取多层级掌握度概览
try {
Log::info('ExamPdfExportService: 获取学生多层级掌握度概览', [
'student_id' => $studentId,
]);
$masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
$masteryData = $masteryOverview['details'] ?? [];
$parentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? []; // 获取父节点掌握度
// 【修复】将对象数组转换为关联数组(避免 stdClass 对象访问错误)
if (! empty($masteryData) && is_array($masteryData)) {
$masteryData = array_map(function ($item) {
if (is_object($item)) {
return [
'kp_code' => $item->kp_code ?? null,
'kp_name' => $item->kp_name ?? null,
'mastery_level' => floatval($item->mastery_level ?? 0),
'mastery_change' => $item->mastery_change !== null ? floatval($item->mastery_change) : null,
];
}
return $item;
}, $masteryData);
}
// 【修复】获取快照数据以计算掌握度变化
$lastSnapshot = DB::connection('mysql')
->table('knowledge_point_mastery_snapshots')
->where('student_id', $studentId)
->latest('snapshot_time')
->first();
if ($lastSnapshot) {
$previousMasteryJson = json_decode($lastSnapshot->mastery_data, true);
foreach ($masteryData as &$item) {
$kpCode = $item['kp_code'];
if (isset($previousMasteryJson[$kpCode])) {
$previous = floatval($previousMasteryJson[$kpCode]['previous_mastery'] ?? 0);
$current = floatval($item['mastery_level']);
$item['mastery_change'] = $current - $previous;
}
}
unset($item);
}
Log::info('ExamPdfExportService: 成功获取多层级掌握度数据', [
'count' => count($masteryData),
'parent_count' => count($parentMasteryLevels),
]);
} catch (\Exception $e) {
Log::error('ExamPdfExportService: 获取掌握度数据失败', [
'student_id' => $studentId,
'error' => $e->getMessage(),
]);
}
}
// 【修改】使用本地方法获取学习路径推荐(替代API调用)
$recommendations = [];
try {
Log::info('ExamPdfExportService: 获取学习路径推荐', [
'student_id' => $studentId,
]);
$learningPaths = $this->learningAnalyticsService->recommendLearningPaths($studentId, 3);
$recommendations = $learningPaths['recommendations'] ?? [];
Log::info('ExamPdfExportService: 成功获取学习路径推荐', [
'count' => count($recommendations),
]);
} catch (\Exception $e) {
Log::error('ExamPdfExportService: 获取学习路径推荐失败', [
'student_id' => $studentId,
'error' => $e->getMessage(),
]);
}
// 获取知识点名称映射
$kpNameMap = $this->buildKnowledgePointNameMap();
Log::info('ExamPdfExportService: 获取知识点名称映射', [
'kpNameMap_count' => count($kpNameMap),
'kpNameMap_keys_sample' => ! empty($kpNameMap) ? array_slice(array_keys($kpNameMap), 0, 5) : [],
]);
// 【修复】直接从MySQL数据库获取题目详情(不通过API)
// 只有当 $paper 是 Paper 模型时才查询题目详情
$questionDetails = ($paper instanceof \App\Models\Paper)
? $this->getQuestionDetailsFromMySQL($paper)
: [];
// 处理题目数据
$questions = $this->processQuestionsForReport($paper, $questionDetails, $kpNameMap);
// 【关键调试】查看buildMasterySummary的返回结果
$masterySummary = $this->buildMasterySummary($masteryData, $kpNameMap);
Log::info('ExamPdfExportService: buildMasterySummary返回结果', [
'masteryData_count' => count($masteryData),
'kpNameMap_count' => count($kpNameMap),
'masterySummary_keys' => array_keys($masterySummary),
'masterySummary_items_count' => count($masterySummary['items'] ?? []),
'masterySummary_items_sample' => ! empty($masterySummary['items']) ? array_slice($masterySummary['items'], 0, 2) : [],
]);
// 【修复】处理父节点掌握度数据:过滤零值、转换名称、构建层级关系
$examKpCodes = array_column($masteryData, 'kp_code'); // 本次考试涉及的知识点
$processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examKpCodes);
Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
'raw_count' => count($parentMasteryLevels),
'processed_count' => count($processedParentMastery),
'processed_sample' => ! empty($processedParentMastery) ? array_slice($processedParentMastery, 0, 3) : [],
]);
return [
'paper' => [
'id' => $paper->paper_id,
'name' => $paper->paper_name,
'total_questions' => $paper->question_count,
'total_score' => $paper->total_score,
'created_at' => $paper->created_at,
],
'student' => $studentInfo,
'questions' => $questions,
'mastery' => $masterySummary,
'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
'recommendations' => $recommendations,
'analysis_data' => $analysisData,
];
}
/**
* 【修复】直接从PaperQuestion表获取题目详情(不通过API)
*/
private function getQuestionDetailsFromMySQL(Paper $paper): array
{
$details = [];
Log::info('ExamPdfExportService: 从PaperQuestion表查询题目详情', [
'paper_id' => $paper->paper_id,
'question_count' => $paper->questions->count(),
]);
foreach ($paper->questions as $pq) {
try {
// 【关键修复】直接从PaperQuestion对象获取solution和correct_answer
$detail = [
'id' => $pq->question_id,
'content' => $pq->question_text,
'question_type' => $pq->question_type,
'answer' => $pq->correct_answer ?? null, // 【修复】从PaperQuestion获取正确答案
'solution' => $pq->solution ?? null, // 【修复】从PaperQuestion获取解题思路
];
$details[(string) ($pq->question_id ?? $pq->id)] = $detail;
Log::debug('ExamPdfExportService: 成功获取题目详情', [
'paper_question_id' => $pq->id,
'question_id' => $pq->question_id,
'has_answer' => ! empty($pq->correct_answer),
'has_solution' => ! empty($pq->solution),
'answer_preview' => $pq->correct_answer ? substr($pq->correct_answer, 0, 50) : null,
]);
} catch (\Throwable $e) {
Log::error('ExamPdfExportService: 获取题目详情失败', [
'paper_question_id' => $pq->id,
'error' => $e->getMessage(),
]);
}
}
return $details;
}
/**
* 处理题目数据(用于报告)
*/
private function processQuestionsForReport($paper, array $questionDetails, array $kpNameMap): array
{
$grouped = [
'choice' => [],
'fill' => [],
'answer' => [],
];
// 【修复】处理空的试卷(questions可能不存在)
$questions = $paper->questions ?? collect();
if ($questions->isEmpty()) {
Log::info('ExamPdfExportService: 试卷没有题目,返回空数组');
return $grouped;
}
$sortedQuestions = $questions
->sortBy(function ($q, int $idx) {
$number = $q->question_number ?? $idx + 1;
return is_numeric($number) ? (float) $number : ($q->id ?? $idx);
});
foreach ($sortedQuestions as $idx => $question) {
$kpCode = $question->knowledge_point ?? '';
$kpName = $kpNameMap[$kpCode] ?? $kpCode ?: '未标注';
// 【修复】直接从PaperQuestion对象获取solution和correct_answer
$answer = $question->correct_answer ?? null; // 直接从PaperQuestion获取
$solution = $question->solution ?? null; // 直接从PaperQuestion获取
$detail = $questionDetails[(string) ($question->question_id ?? $question->id)] ?? [];
$typeRaw = $question->question_type ?? ($detail['question_type'] ?? $detail['type'] ?? '');
$normalizedType = $this->normalizeQuestionType($typeRaw);
$number = $question->question_number ?? ($idx + 1);
// 处理题干文本
$questionText = is_array($question->question_text)
? json_encode($question->question_text, JSON_UNESCAPED_UNICODE)
: ($question->question_text ?? '');
$payload = [
'question_number' => $number,
'question_text' => $this->formatNewlines($questionText), // 格式化换行
'question_type' => $normalizedType,
'knowledge_point' => $kpCode,
'knowledge_point_name' => $kpName,
'score' => $question->score,
'answer' => $this->formatNewlines($answer), // 格式化换行
'solution' => $this->formatNewlines($solution), // 格式化换行
'student_answer' => $this->formatNewlines($question->student_answer ?? null), // 格式化换行
'correct_answer' => $this->formatNewlines($answer), // 格式化换行
'is_correct' => $question->is_correct ?? null,
'score_obtained' => $question->score_obtained ?? null,
];
$grouped[$normalizedType][] = $payload;
// 【调试】记录题目数据
Log::debug('ExamPdfExportService: 处理题目数据', [
'paper_question_id' => $question->id,
'question_id' => $question->question_id,
'has_answer' => ! empty($answer),
'has_solution' => ! empty($solution),
'answer_preview' => $answer ? substr($answer, 0, 50) : null,
]);
}
$ordered = array_merge($grouped['choice'], $grouped['fill'], $grouped['answer']);
// 按卷面顺序重新编号
foreach ($ordered as $i => &$q) {
$q['display_number'] = $i + 1;
}
unset($q);
return $ordered;
}
/**
* 构建PDF
*/
private function buildPdf(string $html): ?string
{
$tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_').'.html';
$utf8Html = $this->ensureUtf8Html($html);
$written = file_put_contents($tmpHtml, $utf8Html);
Log::debug('ExamPdfExportService: HTML文件已创建', [
'path' => $tmpHtml,
'html_length' => strlen($utf8Html),
'written_bytes' => $written,
]);
// 【调试】如果启用了HTML保存调试,复制HTML到storage用于分析
if (config('pdf.debug_save_html', false)) {
$debugPath = storage_path('app/debug_pdf_'.date('YmdHis').'.html');
@copy($tmpHtml, $debugPath);
Log::warning('ExamPdfExportService: [DEBUG] HTML副本已保存', ['path' => $debugPath]);
}
// 仅使用Chrome渲染
$chromePdf = $this->renderWithChrome($tmpHtml);
@unlink($tmpHtml);
return $chromePdf;
}
/**
* 使用Chrome渲染PDF
*/
private function renderWithChrome(string $htmlPath): ?string
{
$tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_').'.pdf';
$userDataDir = sys_get_temp_dir().'/chrome-profile-'.uniqid();
$chromeBinary = $this->findChromeBinary();
if (! $chromeBinary) {
Log::error('ExamPdfExportService: 未找到可用的Chrome/Chromium');
return null;
}
// 设置运行时目录
$runtimeHome = sys_get_temp_dir().'/chrome-home';
$runtimeXdg = sys_get_temp_dir().'/chrome-xdg';
if (! File::exists($runtimeHome)) {
@File::makeDirectory($runtimeHome, 0755, true);
}
if (! File::exists($runtimeXdg)) {
@File::makeDirectory($runtimeXdg, 0755, true);
}
$process = new Process([
$chromeBinary,
'--headless=new', // 【优化】使用新渲染引擎
'--disable-gpu',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-features=VizDisplayCompositor',
'--disable-extensions',
// '--disable-background-networking', // 注释掉,可能阻止必要的网络请求
'--disable-component-update',
'--disable-client-side-phishing-detection',
'--disable-default-apps',
'--disable-domain-reliability',
'--disable-sync',
'--no-first-run',
'--no-default-browser-check',
'--disable-crash-reporter',
'--disable-print-preview',
'--disable-features=TranslateUI',
'--disable-features=OptimizationHints',
'--disable-ipc-flooding-protection',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-features=AudioServiceOutOfProcess',
'--disable-gpu-sandbox',
'--disable-software-rasterizer',
'--disable-background-mode',
'--disable-extensions-http-throttling',
'--disable-ipc-flooding-protection',
'--disable-features=Dbus', // 禁用 dbus
// 【关键修复】添加虚拟时间预算,让Chrome有足够时间加载CDN资源和执行JS
'--virtual-time-budget=30000', // 30秒虚拟时间用于加载外部资源
'--run-all-compositor-stages-before-draw', // 确保所有渲染完成后再生成PDF
'--user-data-dir='.$userDataDir,
'--print-to-pdf='.$tmpPdf,
'--print-to-pdf-no-header',
'--allow-file-access-from-files',
'--font-render-hinting=none', // 【优化】禁用字体渲染提示
'--disable-font-antialiasing',
'file://'.$htmlPath,
], null, [
'HOME' => $runtimeHome,
'XDG_RUNTIME_DIR' => $runtimeXdg,
]);
$process->setTimeout(90); // 【修复】增加超时时间到90秒
$killSignal = \defined('SIGKILL') ? \SIGKILL : 9;
Log::warning('ExamPdfExportService: [调试] Chrome命令准备执行', [
'chrome_binary' => $chromeBinary,
'html_path' => $htmlPath,
'html_exists' => file_exists($htmlPath),
'html_size' => file_exists($htmlPath) ? filesize($htmlPath) : 0,
'pdf_path' => $tmpPdf,
'user_data_dir' => $userDataDir,
]);
try {
$startedAt = microtime(true);
$process->start();
$pdfGenerated = false;
// 轮询检测PDF是否生成
$pollStart = microtime(true);
$maxPollSeconds = 80; // 【修复】增加轮询超时到80秒
while ($process->isRunning() && (microtime(true) - $pollStart) < $maxPollSeconds) {
if (file_exists($tmpPdf) && filesize($tmpPdf) > 0) {
$pdfGenerated = true;
Log::info('ExamPdfExportService: PDF生成成功,提前终止Chrome进程', [
'elapsed' => round(microtime(true) - $startedAt, 2),
'pdf_size' => filesize($tmpPdf),
]);
$process->stop(3, $killSignal); // 【优化】减少停止等待时间
break;
}
usleep(100_000); // 【优化】从200ms减少到100ms
}
if ($process->isRunning()) {
$process->stop(3, $killSignal); // 【优化】减少停止等待时间
}
// 【优化】删除不必要的wait()调用,避免重复等待
// $process->wait();
} catch (ProcessTimedOutException|ProcessSignaledException $e) {
if ($process->isRunning()) {
$process->stop(3, $killSignal); // 【优化】减少停止等待时间
}
Log::warning('ExamPdfExportService: Chrome进程超时或被信号中断', [
'elapsed' => round((microtime(true) - $startedAt), 2),
'exception' => get_class($e),
]);
return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, $startedAt);
} catch (\Throwable $e) {
if ($process->isRunning()) {
$process->stop(3, $killSignal); // 【优化】减少停止等待时间
}
Log::error('ExamPdfExportService: Chrome进程异常', [
'elapsed' => round((microtime(true) - $startedAt), 2),
'error' => $e->getMessage(),
]);
return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
}
return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
}
/**
* 处理Chrome进程结果
*/
private function handleChromeProcessResult(string $tmpPdf, string $userDataDir, Process $process, ?float $startedAt): ?string
{
$pdfExists = file_exists($tmpPdf);
$pdfSize = $pdfExists ? filesize($tmpPdf) : null;
$elapsed = $startedAt ? round((microtime(true) - $startedAt), 2) : null;
// 【优化】即使进程未成功,只要PDF存在且大小合理就返回
if ($pdfExists && $pdfSize > 1000) { // 至少1KB
Log::info('ExamPdfExportService: PDF生成成功', [
'elapsed' => $elapsed,
'pdf_size' => $pdfSize,
'exit_code' => $process->getExitCode(),
'is_successful' => $process->isSuccessful(),
]);
$pdfBinary = file_get_contents($tmpPdf);
@unlink($tmpPdf);
File::deleteDirectory($userDataDir);
return $pdfBinary;
}
// 如果PDF不存在或太小,记录错误
Log::error('ExamPdfExportService: Chrome渲染失败', [
'elapsed' => $elapsed,
'pdf_exists' => $pdfExists,
'pdf_size' => $pdfSize,
'exit_code' => $process->getExitCode(),
'error' => $process->getErrorOutput(),
'output' => $process->getOutput(),
]);
@unlink($tmpPdf);
File::deleteDirectory($userDataDir);
return null;
}
/**
* 查找Chrome二进制文件
*/
private function findChromeBinary(): ?string
{
$candidates = [
env('PDF_CHROME_BINARY'),
env('CHROME_BIN'), // Docker Alpine 环境变量
'/usr/bin/chromium-browser', // Alpine Linux
'/usr/bin/chromium',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', // macOS
];
foreach ($candidates as $path) {
if ($path && is_file($path) && is_executable($path)) {
return $path;
}
}
return null;
}
/**
* 确保HTML为UTF-8编码,并内联外部资源
*/
private function ensureUtf8Html(string $html): string
{
$meta = '
';
if (stripos($html, '') !== false) {
$html = preg_replace('//i', "{$meta}", $html, 1);
} else {
$html = $meta.$html;
}
// 【关键修复】内联KaTeX CSS,避免Chrome在容器中加载CDN资源超时
$html = $this->inlineExternalResources($html);
return $html;
}
/**
* 将CDN资源替换为内联资源
* 【关键修复】避免Chrome在容器中加载CDN资源超时,同时支持本地路径
*/
private function inlineExternalResources(string $html): string
{
// 检查是否包含 KaTeX 资源(CDN 或本地)
$hasKatexCdn = strpos($html, 'cdn.jsdelivr.net/npm/katex') !== false;
$hasKatexLocal = strpos($html, '/js/katex.min.js') !== false || strpos($html, '/css/katex/katex.min.css') !== false;
// 【调试】记录HTML内容信息
Log::warning('ExamPdfExportService: inlineExternalResources', [
'html_length' => strlen($html),
'has_katex_cdn' => $hasKatexCdn,
'has_katex_local' => $hasKatexLocal,
]);
// 如果既没有 CDN 也没有本地链接,跳过
if (! $hasKatexCdn && ! $hasKatexLocal) {
Log::warning('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
return $html;
}
try {
// 读取并内联 KaTeX CSS(无论 CDN 还是本地)
$katexCssPath = public_path('css/katex/katex.min.css');
if (file_exists($katexCssPath)) {
$katexCss = file_get_contents($katexCssPath);
// 修复字体路径:将相对路径改为 data URI
$fontsDir = public_path('css/katex/fonts');
$katexCss = preg_replace_callback(
'/url\(["\']?fonts\/([^"\')\s]+)["\']?\)/i',
function ($matches) use ($fontsDir) {
$fontFile = $fontsDir.'/'.$matches[1];
if (file_exists($fontFile)) {
$fontData = base64_encode(file_get_contents($fontFile));
$mimeType = str_ends_with($matches[1], '.woff2') ? 'font/woff2' : 'font/woff';
return 'url(data:'.$mimeType.';base64,'.$fontData.')';
}
return $matches[0];
},
$katexCss
);
// 替换 CDN CSS 链接
if ($hasKatexCdn) {
$html = preg_replace(
'/
]*href=["\']https:\/\/cdn\.jsdelivr\.net\/npm\/katex[^"\']*katex\.min\.css["\'][^>]*>/i',
'',
$html
);
}
// 替换本地 CSS 链接
if ($hasKatexLocal) {
$html = preg_replace(
'/
]*href=["\']\/css\/katex\/katex\.min\.css["\'][^>]*>/i',
'',
$html
);
}
Log::info('ExamPdfExportService: KaTeX CSS 已内联(含字体 data URI)');
}
// 读取本地 KaTeX JS(用于移除)
$katexJsPath = public_path('js/katex.min.js');
$autoRenderJsPath = public_path('js/auto-render.min.js');
if (file_exists($katexJsPath)) {
$katexJs = file_get_contents($katexJsPath);
$html = preg_replace(
'/',
$html
);
}
if (file_exists($autoRenderJsPath)) {
$autoRenderJs = file_get_contents($autoRenderJsPath);
$html = preg_replace(
'/',
$html
);
}
// 【关键修复】使用服务端预渲染,而不是依赖客户端 JavaScript
// Chrome headless 的 --print-to-pdf 不会等待 JS 执行完成
// 所以我们使用 Node.js KaTeX 在服务端预渲染所有公式
// 1. 移除所有 KaTeX JavaScript(不再需要,因为使用服务端渲染)
// 移除内联的 katex.min.js
$html = preg_replace(
'/