Explorar o código

Merge branch 'main' into feat/question-bank-qc-from-main

yemeishu hai 1 mes
pai
achega
f783bec376

+ 33 - 19
Dockerfile

@@ -28,7 +28,7 @@ RUN bun run build
 # ========================================
 # 第二阶段:PHP 运行时(使用 php-fpm)
 # ========================================
-FROM php:8.3-fpm-alpine AS runtime
+FROM php:8.3-fpm-alpine AS base-runtime
 
 # 安装系统依赖 - 使用阿里云镜像
 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
@@ -44,8 +44,6 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
         icu-dev \
         libzip-dev \
         sqlite-dev \
-        # Nginx(高性能 Web 服务器)
-        nginx \
         # Chrome/Chromium 依赖(PDF 生成必须)
         chromium \
         nss \
@@ -66,7 +64,7 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
         npm \
         && rm -rf /var/cache/apk/* \
         && fc-cache -fv \
-        && mkdir -p /run/dbus /run/nginx /var/log/nginx
+        && mkdir -p /run/dbus
 
 # 设置 Chrome 环境变量
 ENV CHROME_BIN=/usr/bin/chromium-browser \
@@ -95,20 +93,12 @@ WORKDIR /app
 COPY composer.json composer.lock ./
 RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
 
-# 复制应用代码(排除 node_modules, vendor 等)
-COPY . .
-
-# 从第一阶段复制构建好的前端资源
-COPY --from=frontend-builder /app/public/build ./public/build
-
 # 安装 KaTeX(服务端公式渲染)
 RUN npm config set registry https://registry.npmmirror.com && \
     npm install -g katex@0.16.9
 
-# 缓存路由和视图(不缓存配置,配置在运行时从 .env 读取)
-RUN php artisan route:cache && \
-    php artisan view:cache && \
-    php artisan filament:upgrade || true
+# 复制应用代码(排除 node_modules, vendor 等)
+COPY . .
 
 # 创建必要目录
 RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache
@@ -118,6 +108,28 @@ RUN chown -R www-data:www-data /app && \
     chmod -R 755 /app && \
     chmod -R 777 /app/storage /app/bootstrap/cache
 
+# 通用入口脚本(queue worker 模式仅执行命令,不启动 nginx)
+COPY docker-entrypoint.sh /usr/local/bin/
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+
+# ========================================
+# 第三阶段:App Runtime(Nginx + PHP-FPM)
+# ========================================
+FROM base-runtime AS app-runtime
+
+# 安装 Nginx(仅 Web API 容器需要)
+RUN apk add --no-cache nginx && \
+    mkdir -p /run/nginx /var/log/nginx
+
+# 从第一阶段复制构建好的前端资源
+COPY --from=frontend-builder /app/public/build ./public/build
+
+# 缓存路由和视图(不缓存配置,配置在运行时从 .env 读取)
+RUN php artisan route:cache && \
+    php artisan view:cache && \
+    php artisan filament:upgrade || true
+
 # 复制 Nginx 和 PHP-FPM 配置
 COPY docker/nginx.conf /etc/nginx/nginx.conf
 COPY docker/www.conf /usr/local/etc/php-fpm.d/www.conf
@@ -128,9 +140,11 @@ EXPOSE 8000
 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
     CMD curl -f http://127.0.0.1:8000/health || exit 1
 
-# 启动脚本:启动 dbus、php-fpm、nginx
-COPY docker-entrypoint.sh /usr/local/bin/
-RUN chmod +x /usr/local/bin/docker-entrypoint.sh
-
-ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
 CMD ["nginx", "-g", "daemon off;"]
+
+# ========================================
+# 第四阶段:PDF Worker Runtime(无 Nginx/无前端构建)
+# ========================================
+FROM base-runtime AS pdfworker
+
+CMD ["php", "artisan", "queue:work", "--queue=pdf", "--sleep=3", "--tries=2", "--max-time=300", "--max-jobs=10"]

+ 64 - 38
app/Http/Controllers/ExamPdfController.php

@@ -216,9 +216,14 @@ class ExamPdfController extends Controller
         // 【修复】先移除SVG内容,避免误匹配SVG注释中的 BD:DC、A:B 等内容
         $contentWithoutSvg = preg_replace('/<svg[^>]*>.*?<\/svg>/is', '[SVG_PLACEHOLDER]', $content);
 
+        // 方案B:必须先检测到至少4个连续选项字母(如 A/B/C/D)才允许拆分选项。
+        if (! $this->hasConsecutiveOptionMarkers($contentWithoutSvg, 4)) {
+            return [];
+        }
+
         // 1. 尝试匹配多种格式的选项:A. / A、/ A: / A.(中文句点)/ A.(无空格)
         // 【修复】选项标记必须在行首或空白后,避免误匹配 SVG 注释中的 BD:DC 等内容
-        $pattern = '/(?:^|\s)([A-D])[\.、:.:]\s*(.+?)(?=(?:^|\s)[A-D][\.、:.:]|$)/su';
+        $pattern = '/(?:^|\s)([A-D])[\.、.]\s*(.+?)(?=(?:^|\s)[A-D][\.、.]|$)/su';
 
         if (preg_match_all($pattern, $contentWithoutSvg, $matches, PREG_SET_ORDER)) {
             foreach ($matches as $match) {
@@ -240,7 +245,7 @@ class ExamPdfController extends Controller
             foreach ($lines as $line) {
                 $line = trim($line);
                 // 【修复】行首匹配选项标记
-                if (preg_match('/^([A-D])[\.、:]\s*(.+)$/u', $line, $match)) {
+                if (preg_match('/^([A-D])[\.、.]\s*(.+)$/u', $line, $match)) {
                     $optionText = trim($match[2]);
                     if (! empty($optionText)) {
                         $options[] = $optionText;
@@ -261,13 +266,18 @@ class ExamPdfController extends Controller
     /**
      * 分离题干内容和选项
      */
-    private function separateStemAndOptions(string $content): array
+    private function separateStemAndOptions(string $content, ?string $questionType = null): array
     {
+        // 仅选择题需要做“题干/选项拆分”,其他题型直接返回原始题干。
+        if ($questionType === null || $this->normalizeQuestionTypeValue($questionType) !== 'choice') {
+            return [$content, []];
+        }
+
         // 【修复】先移除SVG内容,避免误匹配SVG注释中的 BD:DC、A:B 等内容
         $contentWithoutSvg = preg_replace('/<svg[^>]*>.*?<\/svg>/is', '[SVG_PLACEHOLDER]', $content);
 
-        // 【修复】检测是否有选项时,要求选项标记在行首或空白后
-        $hasOptions = preg_match('/(?:^|\s)[A-D][\.、:.:]/u', $contentWithoutSvg);
+        // 方案B:必须至少命中4个连续选项字母(A→D)才判定为选择题选项区。
+        $hasOptions = $this->hasConsecutiveOptionMarkers($contentWithoutSvg, 4);
 
         if (! $hasOptions) {
             return [$content, []];
@@ -279,7 +289,7 @@ class ExamPdfController extends Controller
         // 如果提取到选项,分离题干
         if (! empty($options)) {
             // 【修复】找到第一个选项的位置,要求选项标记在行首或空白后
-            if (preg_match('/^(.+?)(?=(?:^|\s)[A-D][\.、:])/su', $contentWithoutSvg, $match)) {
+            if (preg_match('/^(.+?)(?=(?:^|\s)[A-D][\.、.])/su', $contentWithoutSvg, $match)) {
                 $stem = trim($match[1]);
                 // 如果题干中有SVG占位符,从原始内容中提取对应部分
                 if (strpos($stem, '[SVG_PLACEHOLDER]') !== false) {
@@ -322,6 +332,45 @@ class ExamPdfController extends Controller
         return [$content, []];
     }
 
+    /**
+     * 检测题干中是否存在至少 N 个连续选项字母(如 A/B/C/D)。
+     */
+    private function hasConsecutiveOptionMarkers(string $content, int $minRun = 4): bool
+    {
+        if ($minRun <= 1) {
+            return preg_match('/(?:^|\s)[A-H][\.、.]/u', $content) === 1;
+        }
+
+        if (! preg_match_all('/(?:^|\s)([A-H])[\.、.]/u', $content, $matches)) {
+            return false;
+        }
+
+        $letters = array_map(static fn ($ch) => strtoupper((string) $ch), $matches[1] ?? []);
+        if (empty($letters)) {
+            return false;
+        }
+
+        $longestRun = 1;
+        $currentRun = 1;
+        for ($i = 1, $count = count($letters); $i < $count; $i++) {
+            $prev = ord($letters[$i - 1]);
+            $curr = ord($letters[$i]);
+            if ($curr === $prev + 1) {
+                $currentRun++;
+            } else {
+                $currentRun = 1;
+            }
+            if ($currentRun > $longestRun) {
+                $longestRun = $currentRun;
+            }
+            if ($longestRun >= $minRun) {
+                return true;
+            }
+        }
+
+        return $longestRun >= $minRun;
+    }
+
     /**
      * 根据题型获取默认分数
      */
@@ -555,7 +604,7 @@ class ExamPdfController extends Controller
                                 $rawContent = $apiData['stem'] ?? $q['stem'] ?? $q['content'] ?? '';
 
                                 // 分离题干和选项
-                                [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
+                                [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent, $q['question_type'] ?? null);
 
                                 $q['stem'] = $stem;
                                 $q['content'] = $stem; // 同时设置content字段
@@ -664,7 +713,7 @@ class ExamPdfController extends Controller
                             $rawContent = $apiData['stem'] ?? $q['stem'] ?? '题目内容缺失';
 
                             // 分离题干和选项
-                            [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
+                            [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent, $q['question_type'] ?? null);
 
                             // 合并数据,优先使用题库API的 stem、answer、solution、options
                             $q['stem'] = $stem;
@@ -715,7 +764,7 @@ class ExamPdfController extends Controller
             $rawContent = $q['stem'] ?? $q['content'] ?? '题目内容缺失';
 
             // 分离题干和选项
-            [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent);
+            [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent, $q['question_type'] ?? null);
 
             // 如果从题库API获取了选项,优先使用
             $options = $q['options'] ?? $extractedOptions;
@@ -853,7 +902,7 @@ class ExamPdfController extends Controller
                         if (isset($responseDataMap[$q['id']])) {
                             $apiData = $responseDataMap[$q['id']];
                             $rawContent = $apiData['stem'] ?? $q['stem'] ?? $q['content'] ?? '';
-                            [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
+                            [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent, $q['question_type'] ?? null);
                             $q['stem'] = $stem;
                             $q['content'] = $stem; // 同时设置content字段
                             $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
@@ -922,7 +971,7 @@ class ExamPdfController extends Controller
                         if (isset($responseDataMap[$q['id']])) {
                             $apiData = $responseDataMap[$q['id']];
                             $rawContent = $apiData['stem'] ?? $q['stem'] ?? '题目内容缺失';
-                            [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
+                            [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent, $q['question_type'] ?? null);
                             $q['stem'] = $stem;
                             $q['content'] = $stem; // 同时设置content字段
                             $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
@@ -965,7 +1014,7 @@ class ExamPdfController extends Controller
         $questions = ['choice' => [], 'fill' => [], 'answer' => []];
         foreach ($questionsData as $q) {
             $rawContent = $q['stem'] ?? $q['content'] ?? '题目内容缺失';
-            [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent);
+            [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent, $q['question_type'] ?? null);
             $options = $q['options'] ?? $extractedOptions;
             $answer = $q['answer'] ?? '';
             $solution = $q['solution'] ?? '';
@@ -1166,26 +1215,11 @@ class ExamPdfController extends Controller
                 ], 400);
             }
 
-            // 根据 config 或 env 配置决定是否包含知识点讲解
-            // 还需要判断如果摸底(paper_type =0)的时候也是不需要插入知识点讲解内容
-            $includeKpExplain = null;
-
-            if ($request->has('include_kp_explain')) {
-                $includeKpExplain = filter_var(
-                    $request->input('include_kp_explain'),
-                    FILTER_VALIDATE_BOOLEAN,
-                    FILTER_NULL_ON_FAILURE
-                );
-            } elseif ($paperModel->paper_type === 0) {
-                $includeKpExplain = false;
-            }
-
-            info("includekpexplain", [$includeKpExplain]);
             // 调用 PDF 生成服务
             $pdfService = app(\App\Services\ExamPdfExportService::class);
 
             // 生成统一 PDF(卷子 + 判卷)
-            $pdfUrl = $pdfService->generateUnifiedPdf($paper_id, $includeKpExplain);
+            $pdfUrl = $pdfService->generateUnifiedPdf($paper_id);
 
             if ($pdfUrl) {
                 Log::info('RegeneratePdf: PDF重新生成成功', [
@@ -1369,10 +1403,6 @@ class ExamPdfController extends Controller
         $startTime = $startDate.' 00:00:00';
         $endTime = $endDate.' 23:59:59';
 
-        $includeKpExplain = $request->has('include_kp_explain')
-            ? filter_var($request->input('include_kp_explain'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)
-            : null;
-
         Log::info('RegeneratePdfBatch: 投递队列', [
             'start_date' => $startDate,
             'end_date' => $endDate,
@@ -1405,7 +1435,7 @@ class ExamPdfController extends Controller
 
                     continue;
                 }
-                RegeneratePdfJob::dispatch($paper->paper_id, $includeKpExplain);
+                RegeneratePdfJob::dispatch($paper->paper_id);
                 $queued[] = $paper->paper_id;
             }
 
@@ -1462,10 +1492,6 @@ class ExamPdfController extends Controller
             ], 400);
         }
 
-        $includeKpExplain = $request->has('include_kp_explain')
-            ? filter_var($request->input('include_kp_explain'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)
-            : null;
-
         Log::info('RegeneratePdfBatchByIds: 投递队列', ['paper_ids' => $paperIds, 'count' => count($paperIds)]);
 
         try {
@@ -1483,7 +1509,7 @@ class ExamPdfController extends Controller
 
                     continue;
                 }
-                RegeneratePdfJob::dispatch($paperId, $includeKpExplain);
+                RegeneratePdfJob::dispatch($paperId);
                 $queued[] = $paperId;
             }
 

+ 1 - 9
app/Jobs/GenerateExamPdfJob.php

@@ -136,15 +136,7 @@ class GenerateExamPdfJob implements ShouldQueue
 
             $taskManager->updateTaskProgress($this->taskId, 10, '开始生成统一PDF(直接合并两个页面,效率最高)...');
 
-            // 根据 config 或 env 配置决定是否包含知识点讲解
-            // 还需要判断如果摸底(paper_type =0)的时候也是不需要插入知识点讲解内容
-            $includeKpExplain = null;
-
-            if($paperModel->paper_type === 0) {
-                $includeKpExplain = false;
-            }
-            info("includekpexplain", [$includeKpExplain, $paperModel->paper_type]);
-            $unifiedPdfUrl = $pdfExportService->generateUnifiedPdf($this->paperId, $includeKpExplain);
+            $unifiedPdfUrl = $pdfExportService->generateUnifiedPdf($this->paperId);
 
             $taskManager->updateTaskProgress($this->taskId, 90, 'PDF生成完成,准备返回结果...');
             $examContent = $paperPayloadService->buildExamContent($paperModel);

+ 2 - 7
app/Jobs/RegeneratePdfJob.php

@@ -20,16 +20,13 @@ class RegeneratePdfJob implements ShouldQueue
 
     public string $paperId;
 
-    public ?bool $includeKpExplain;
-
     public int $tries = 2;
 
     public int $timeout = 300;
 
-    public function __construct(string $paperId, ?bool $includeKpExplain = null)
+    public function __construct(string $paperId)
     {
         $this->paperId = $paperId;
-        $this->includeKpExplain = $includeKpExplain;
 
         $this->onQueue('pdf');
     }
@@ -43,11 +40,9 @@ class RegeneratePdfJob implements ShouldQueue
             return;
         }
 
-        $useKpExplain = $this->includeKpExplain ?? ($paper->paper_type !== 0);
-
         try {
             Log::info('RegeneratePdfJob: 开始', ['paper_id' => $this->paperId]);
-            $pdfUrl = $pdfExportService->generateUnifiedPdf($this->paperId, $useKpExplain);
+            $pdfUrl = $pdfExportService->generateUnifiedPdf($this->paperId);
             if ($pdfUrl) {
                 Log::info('RegeneratePdfJob: 成功', ['paper_id' => $this->paperId, 'pdf_url' => $pdfUrl]);
             } else {

+ 441 - 108
app/Services/ExamPdfExportService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\DTO\ExamAnalysisDataDto;
 use App\DTO\ReportPayloadDto;
+use App\Http\Controllers\ExamPdfController;
 use App\Models\Paper;
 use App\Models\Question;
 use App\Models\Student;
@@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\URL;
+use Illuminate\Http\Request;
 use Illuminate\Support\Str;
 use Symfony\Component\Process\Exception\ProcessSignaledException;
 use Symfony\Component\Process\Exception\ProcessTimedOutException;
@@ -38,6 +40,7 @@ class ExamPdfExportService
     private array $pdfImageDimensionCache = [];
     private ?bool $hasPdfImageMetricsTable = null;
     private ?array $knowledgePointMetaCache = null;
+    private ?string $lastDebugHtmlPath = null;
 
     public function __construct(
         private readonly LearningAnalyticsService $learningAnalyticsService,
@@ -141,61 +144,91 @@ class ExamPdfExportService
      * 效率提升40-50%,只需生成一次PDF
      *
      * @param  string  $paperId  试卷ID
-     * @param  bool|null  $includeKpExplain  是否包含知识点讲解,null则使用配置文件默认值
      * @return string|null PDF URL
      */
-    public function generateUnifiedPdf(string $paperId, ?bool $includeKpExplain = null): ?string
+    public function generateUnifiedPdf(string $paperId): ?string
     {
-        // 决定是否包含知识点讲解
-        if ($includeKpExplain === null) {
-            $includeKpExplain = config('pdf.include_kp_explain_default', false);
-        }
+        $flowStart = microtime(true);
+        $lastMark = $flowStart;
+        $timings = [];
+        $mark = static function (string $label) use (&$lastMark, &$timings): void {
+            $now = microtime(true);
+            $timings[$label] = round(($now - $lastMark) * 1000, 1);
+            $lastMark = $now;
+        };
+
+        // 与组卷规则保持一致:仅知识点组卷类型(paper_type=2)包含知识点讲解。
+        $paperType = Paper::query()
+            ->where('paper_id', $paperId)
+            ->value('paper_type');
+        $shouldIncludeKpExplain = ((int) $paperType) === 2;
+        $mark('load_paper_type_ms');
 
         Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', [
             'paper_id' => $paperId,
-            'include_kp_explain' => $includeKpExplain,
+            'has_kp_explain' => $shouldIncludeKpExplain,
         ]);
 
         try {
+            $totalStartedAt = microtime(true);
             // 步骤0:获取知识点讲解HTML(如需要)
             $kpExplainHtml = null;
-            if ($includeKpExplain) {
+            if ($shouldIncludeKpExplain) {
+                $kpStartedAt = microtime(true);
                 Log::info('generateUnifiedPdf: 开始获取知识点讲解HTML', ['paper_id' => $paperId]);
                 $kpExplainHtml = $this->fetchKnowledgeExplanationHtml($paperId);
+                $mark('kp_explain_html_ms');
                 if ($kpExplainHtml) {
                     // 统一在 mergeHtmlWithPageBreak()->ensureUtf8Html() 阶段处理内联与公式预渲染,
                     // 避免在此处重复处理导致额外耗时。
                     Log::info('generateUnifiedPdf: 知识点讲解HTML获取并处理成功', [
                         'paper_id' => $paperId,
                         'length' => strlen($kpExplainHtml),
+                        'elapsed_ms' => (int) round((microtime(true) - $kpStartedAt) * 1000),
                     ]);
                 } else {
                     Log::warning('generateUnifiedPdf: 知识点讲解HTML获取失败,将跳过', ['paper_id' => $paperId]);
                 }
+            } else {
+                $timings['kp_explain_html_ms'] = 0.0;
             }
 
             // 步骤1:同时渲染两个页面的HTML
+            $examRenderStartedAt = microtime(true);
             Log::info('generateUnifiedPdf: 开始渲染试卷HTML', ['paper_id' => $paperId]);
             $examHtml = $this->renderExamHtml($paperId, includeAnswer: false, useGradingView: false);
+            $mark('exam_html_ms');
             if (! $examHtml) {
                 Log::error('ExamPdfExportService: 渲染卷子HTML失败', ['paper_id' => $paperId]);
 
                 return null;
             }
-            Log::info('generateUnifiedPdf: 试卷HTML渲染完成', ['paper_id' => $paperId, 'length' => strlen($examHtml)]);
+            Log::info('generateUnifiedPdf: 试卷HTML渲染完成', [
+                'paper_id' => $paperId,
+                'length' => strlen($examHtml),
+                'elapsed_ms' => (int) round((microtime(true) - $examRenderStartedAt) * 1000),
+            ]);
 
+            $gradingRenderStartedAt = microtime(true);
             Log::info('generateUnifiedPdf: 开始渲染判卷HTML', ['paper_id' => $paperId]);
             $gradingHtml = $this->renderExamHtml($paperId, includeAnswer: true, useGradingView: true);
+            $mark('grading_html_ms');
             if (! $gradingHtml) {
                 Log::error('ExamPdfExportService: 渲染判卷HTML失败', ['paper_id' => $paperId]);
 
                 return null;
             }
-            Log::info('generateUnifiedPdf: 判卷HTML渲染完成', ['paper_id' => $paperId, 'length' => strlen($gradingHtml)]);
+            Log::info('generateUnifiedPdf: 判卷HTML渲染完成', [
+                'paper_id' => $paperId,
+                'length' => strlen($gradingHtml),
+                'elapsed_ms' => (int) round((microtime(true) - $gradingRenderStartedAt) * 1000),
+            ]);
 
             // 步骤2:插入分页符,合并HTML
+            $mergeStartedAt = microtime(true);
             Log::info('generateUnifiedPdf: 开始合并HTML(保留原始样式)', ['paper_id' => $paperId]);
             $unifiedHtml = $this->mergeHtmlWithPageBreak($examHtml, $gradingHtml, $kpExplainHtml);
+            $mark('merge_html_ms');
             if (! $unifiedHtml) {
                 Log::error('ExamPdfExportService: HTML合并失败', ['paper_id' => $paperId]);
 
@@ -205,17 +238,25 @@ class ExamPdfExportService
                 'paper_id' => $paperId,
                 'length' => strlen($unifiedHtml),
                 'has_kp_explain' => ! empty($kpExplainHtml),
+                'elapsed_ms' => (int) round((microtime(true) - $mergeStartedAt) * 1000),
             ]);
 
             // 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
+            $pdfRenderStartedAt = microtime(true);
             Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
+            $this->lastDebugHtmlPath = null;
             $pdfBinary = $this->buildPdf($unifiedHtml, true, true);
+            $mark('build_pdf_ms');
             if (! $pdfBinary) {
                 Log::error('ExamPdfExportService: 生成统一PDF失败', ['paper_id' => $paperId]);
 
                 return null;
             }
-            Log::info('generateUnifiedPdf: PDF生成完成', ['paper_id' => $paperId, 'pdf_size' => strlen($pdfBinary)]);
+            Log::info('generateUnifiedPdf: PDF生成完成', [
+                'paper_id' => $paperId,
+                'pdf_size' => strlen($pdfBinary),
+                'elapsed_ms' => (int) round((microtime(true) - $pdfRenderStartedAt) * 1000),
+            ]);
 
             // 步骤4:保存PDF
             $paper = Paper::where('paper_id', $paperId)->first();
@@ -226,25 +267,47 @@ class ExamPdfExportService
             }
             $allPdfName = $this->buildPdfFileName($paper);
             $path = "exams/{$allPdfName}";
+            $storageStartedAt = microtime(true);
             Log::info('generateUnifiedPdf: 开始保存PDF到云存储', ['paper_id' => $paperId, 'path' => $path]);
             $url = $this->pdfStorageService->put($path, $pdfBinary);
+            $mark('upload_pdf_ms');
             if (! $url) {
                 Log::error('ExamPdfExportService: 保存统一PDF失败', ['path' => $path]);
 
                 return null;
             }
-            Log::info('generateUnifiedPdf: PDF保存完成', ['paper_id' => $paperId, 'url' => $url]);
+            Log::info('generateUnifiedPdf: PDF保存完成', [
+                'paper_id' => $paperId,
+                'url' => $url,
+                'elapsed_ms' => (int) round((microtime(true) - $storageStartedAt) * 1000),
+            ]);
 
             // 步骤5:保存URL到数据库(存储到all_pdf_url字段)
             Log::info('generateUnifiedPdf: 开始保存URL到数据库', ['paper_id' => $paperId, 'field' => 'all_pdf_url']);
             $this->savePdfUrlToDatabase($paperId, 'all_pdf_url', $url);
+            $mark('save_url_ms');
             Log::info('generateUnifiedPdf: URL保存完成', ['paper_id' => $paperId]);
 
+            Log::warning('PDF_GENERATION_TIMING', [
+                'paper_id' => $paperId,
+                'paper_type' => (int) $paperType,
+                'has_kp_explain' => ! empty($kpExplainHtml),
+                'exam_html_bytes' => strlen($examHtml),
+                'grading_html_bytes' => strlen($gradingHtml),
+                'kp_explain_html_bytes' => $kpExplainHtml ? strlen($kpExplainHtml) : 0,
+                'unified_html_bytes' => strlen($unifiedHtml),
+                'pdf_bytes' => strlen($pdfBinary),
+                'timings_ms' => $timings,
+                'total_ms' => round((microtime(true) - $flowStart) * 1000, 1),
+                'debug_html_path' => $this->lastDebugHtmlPath,
+            ]);
+
             Log::info('generateUnifiedPdf 全部完成(终极优化:直接HTML合并生成一份PDF)', [
                 'paper_id' => $paperId,
                 'url' => $url,
                 'pdf_size' => strlen($pdfBinary),
                 'method' => 'direct HTML merge to PDF (no pdfunite)',
+                'elapsed_ms' => (int) round((microtime(true) - $totalStartedAt) * 1000),
             ]);
 
             return $url;
@@ -701,6 +764,7 @@ class ExamPdfExportService
                 if ($ca === $cb) {
                     return strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''));
                 }
+
                 return $cb <=> $ca;
             });
             $radarChildrenByModule[$moduleCode] = $items;
@@ -774,6 +838,7 @@ class ExamPdfExportService
         $keep = array_values(array_filter($highToLow, fn ($i) => ($this->toPcMasteryPercent($i['mastery_level']) ?? -1) >= 85));
         $boost = array_values(array_filter($lowToHigh, function ($i) {
             $percent = $this->toPcMasteryPercent($i['mastery_level']) ?? -1;
+
             return $percent >= 60 && $percent < 85;
         }));
         $key = array_values(array_filter($lowToHigh, fn ($i) => ($this->toPcMasteryPercent($i['mastery_level']) ?? 101) < 60));
@@ -1480,6 +1545,7 @@ class ExamPdfExportService
                 if ($code !== $rootCode) {
                     $leaves[] = $code;
                 }
+
                 continue;
             }
 
@@ -1795,10 +1861,15 @@ class ExamPdfExportService
      */
     private function fetchKnowledgeExplanationHtml(string $paperId): ?string
     {
+        $html = $this->renderKnowledgeExplanationHtmlFromController($paperId);
+        if ($html !== null) {
+            return $html;
+        }
+
         try {
             $url = route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $paperId]);
-
-            $response = Http::get($url);
+            $timeout = max(1, (int) config('pdf.kp_explain_fetch_timeout_seconds', 2));
+            $response = Http::timeout($timeout)->get($url);
             if ($response->successful()) {
                 $html = $response->body();
                 if (! empty(trim($html))) {
@@ -1817,6 +1888,7 @@ class ExamPdfExportService
             Log::warning('ExamPdfExportService: 获取知识点讲解HTML失败', [
                 'paper_id' => $paperId,
                 'url' => $url,
+                'timeout_seconds' => $timeout,
             ]);
 
             return null;
@@ -1825,25 +1897,48 @@ class ExamPdfExportService
             Log::warning('ExamPdfExportService: 获取知识点讲解HTML异常', [
                 'paper_id' => $paperId,
                 'error' => $e->getMessage(),
+                'timeout_seconds' => config('pdf.kp_explain_fetch_timeout_seconds', 2),
             ]);
 
             return null;
         }
     }
 
-    private function renderKpExplainMarkdown(string $html): string
+    private function renderKnowledgeExplanationHtmlFromController(string $paperId): ?string
     {
-        if (! class_exists(\Michelf\MarkdownExtra::class)) {
-            return $html;
-        }
+        try {
+            $request = Request::create("/admin/intelligent-exam/knowledge-explanation/{$paperId}", 'GET');
+            $result = app(ExamPdfController::class)->showKnowledgeExplanation($request, $paperId);
+            $html = $this->renderControllerResultToHtml($result);
+            if ($html === null || trim($html) === '') {
+                return null;
+            }
 
-        $parser = new \Michelf\MarkdownExtra;
+            Log::info('ExamPdfExportService: 本地渲染知识点讲解HTML成功', [
+                'paper_id' => $paperId,
+                'length' => strlen($html),
+            ]);
 
+            $html = $this->ensureUtf8Html($html);
+
+            return $this->renderKpExplainMarkdown($html);
+        } catch (\Throwable $e) {
+            Log::warning('ExamPdfExportService: 本地渲染知识点讲解HTML异常,将回退HTTP', [
+                'paper_id' => $paperId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return null;
+        }
+    }
+
+    private function renderKpExplainMarkdown(string $html): string
+    {
         return preg_replace_callback(
             '/<div class="kp-markdown-source"[^>]*>([\s\S]*?)<\/div>\s*<div class="kp-markdown-container[^"]*"[^>]*><\/div>/i',
-            function ($matches) use ($parser) {
+            function ($matches) {
                 $markdown = html_entity_decode(trim($matches[1]), ENT_QUOTES, 'UTF-8');
-                $rendered = $parser->transform($markdown);
+                $rendered = $this->renderKpMarkdownContent($markdown);
 
                 return '<div class="kp-markdown-container kp-markdown-content">'.$rendered.'</div>';
             },
@@ -1851,18 +1946,16 @@ class ExamPdfExportService
         );
     }
 
-    /**
-     * 【新增】渲染试卷HTML(通过HTTP调用路由)
-     */
     private function renderExamHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
     {
-        // 判卷部分启用答案详情页时,优先本地渲染,避免跨进程配置不一致。
-        if ($useGradingView && config('exam.pdf_grading_append_scan_sheet', false)) {
-            return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
+        // PDF worker 已经运行在 Laravel 进程内,优先直接渲染 Blade,避免 HTTP 自调用开销。
+        $html = $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
+        if ($html !== null) {
+            return $html;
         }
 
         try {
-            // 通过HTTP客户端获取渲染后的HTML(与知识点讲解相同的逻辑)
+            // 兜底:保留原 HTTP 路由渲染路径,避免特殊页面上下文下直接视图失败。
             $routeName = $useGradingView
                 ? 'filament.admin.auth.intelligent-exam.grading'
                 : 'filament.admin.auth.intelligent-exam.pdf';
@@ -1877,7 +1970,7 @@ class ExamPdfExportService
                 }
             }
 
-            Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败,使用备用方案', [
+            Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败', [
                 'paper_id' => $paperId,
                 'url' => $url,
             ]);
@@ -1889,8 +1982,7 @@ class ExamPdfExportService
             ]);
         }
 
-        // 备用方案:直接渲染视图
-        return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
+        return null;
     }
 
     /**
@@ -1899,70 +1991,23 @@ class ExamPdfExportService
     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 = $this->resolveExamViewName($useGradingView);
-
-            // 构造视图需要的变量
-            $questions = ['choice' => [], 'fill' => [], 'answer' => []];
-            foreach ($paper->questions as $pq) {
-                $qType = $this->normalizeQuestionType($pq->question_type ?? 'answer');
-                $questions[$qType][] = $this->normalizeAnswerFieldForPdf($pq);
-            }
-
-            $studentModel = \App\Models\Student::find($paper->student_id);
-            $teacherModel = \App\Models\Teacher::find($paper->teacher_id);
-            if (! $teacherModel && ! empty($paper->teacher_id)) {
-                $teacherModel = \App\Models\Teacher::query()
-                    ->where('teacher_id', $paper->teacher_id)
-                    ->first();
-            }
-            $student = ['name' => $studentModel->name ?? ($paper->student_id ?? '________'), 'grade' => $studentModel->grade ?? '________'];
-            $teacher = ['name' => $teacherModel->name ?? ($paper->teacher_id ?? '________')];
-            $examCode = PaperNaming::extractExamCode((string) $paper->paper_id);
-            try {
-                $assembleTypeLabel = PaperNaming::assembleTypeLabel((int) $paper->paper_type);
-            } catch (\Throwable $e) {
-                $assembleTypeLabel = '未知类型';
-            }
-            $pdfMeta = [
-                'student_name' => $student['name'],
-                'exam_code' => $examCode,
-                'assemble_type_label' => $assembleTypeLabel,
-                'header_title' => $examCode,
-                'exam_pdf_title' => '试卷_'.$examCode,
-                'grading_pdf_title' => '判卷_'.$examCode,
-                'knowledge_pdf_title' => '知识点梳理_'.$examCode,
-            ];
-
-            $html = view($viewName, [
-                'paper' => $paper,
-                'questions' => $questions,
-                'includeAnswer' => $includeAnswer,
-                'student' => $student,
-                'teacher' => $teacher,
-                'pdfMeta' => $pdfMeta,
-            ])->render();
+            $request = Request::create(
+                $useGradingView
+                    ? "/admin/intelligent-exam/grading/{$paperId}"
+                    : "/admin/intelligent-exam/pdf/{$paperId}",
+                'GET',
+                ['answer' => $includeAnswer ? 'true' : 'false']
+            );
+            $controller = app(ExamPdfController::class);
+            $result = $useGradingView
+                ? $controller->showGrading($request, $paperId)
+                : $controller->show($request, $paperId);
+            $html = $this->renderControllerResultToHtml($result);
 
-            if (empty(trim($html))) {
+            if ($html === null || trim($html) === '') {
                 Log::error('ExamPdfExportService: 视图渲染结果为空', [
                     'paper_id' => $paperId,
-                    'view_name' => $viewName,
-                    'question_count' => $paper->questions->count(),
+                    'use_grading_view' => $useGradingView,
                 ]);
 
                 return null;
@@ -1981,6 +2026,23 @@ class ExamPdfExportService
         }
     }
 
+    private function renderControllerResultToHtml(mixed $result): ?string
+    {
+        if (is_string($result)) {
+            return $result;
+        }
+
+        if (is_object($result) && method_exists($result, 'render')) {
+            return $result->render();
+        }
+
+        if (is_object($result) && method_exists($result, 'getContent')) {
+            return $result->getContent();
+        }
+
+        return null;
+    }
+
     /**
      * 构建分析数据(重构版)
      * 优先使用本地MySQL数据,减少API依赖
@@ -2289,6 +2351,7 @@ class ExamPdfExportService
                     $masteryData = array_map(function ($item) {
                         if (is_object($item)) {
                             $kpCode = $item->kp_code ?? null;
+
                             return [
                                 'kp_code' => $kpCode,
                                 'kp_name' => $item->kp_name ?? null,
@@ -2719,7 +2782,9 @@ class ExamPdfExportService
      */
     private function buildPdf(string $html, bool $applyWideImageSizing = false, bool $scopeToExamPart = false): ?string
     {
+        $startedAt = microtime(true);
         $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_').'.html';
+        $prepareStartedAt = microtime(true);
         $utf8Html = $this->ensureUtf8Html($html);
         if ($applyWideImageSizing) {
             $utf8Html = $scopeToExamPart
@@ -2728,24 +2793,167 @@ class ExamPdfExportService
         }
         $written = file_put_contents($tmpHtml, $utf8Html);
 
-        Log::debug('ExamPdfExportService: HTML文件已创建', [
+        Log::info('ExamPdfExportService: PDF HTML准备完成', [
             'path' => $tmpHtml,
             'html_length' => strlen($utf8Html),
             'written_bytes' => $written,
+            'elapsed_ms' => (int) round((microtime(true) - $prepareStartedAt) * 1000),
         ]);
 
         // 【调试】如果启用了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]);
+            $this->lastDebugHtmlPath = $debugPath;
+            Log::debug('ExamPdfExportService: [DEBUG] HTML副本已保存', ['path' => $debugPath]);
         }
 
-        // 仅使用Chrome渲染
-        $chromePdf = $this->renderWithChrome($tmpHtml);
+        $pdf = $this->renderWithConfiguredBackend($tmpHtml);
         @unlink($tmpHtml);
 
-        return $chromePdf;
+        Log::info('ExamPdfExportService: buildPdf完成', [
+            'success' => $pdf !== null,
+            'pdf_size' => $pdf !== null ? strlen($pdf) : null,
+            'elapsed_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+        ]);
+
+        return $pdf;
+    }
+
+    /**
+     * 根据配置选择 PDF 渲染后端。
+     */
+    private function renderWithConfiguredBackend(string $htmlPath): ?string
+    {
+        $renderer = strtolower(trim((string) config('pdf.renderer', 'gotenberg')));
+
+        if ($renderer === 'gotenberg') {
+            $pdf = $this->renderWithGotenberg($htmlPath);
+            if ($pdf !== null) {
+                return $pdf;
+            }
+
+            if ($this->shouldFallbackToChrome()) {
+                Log::warning('ExamPdfExportService: Gotenberg 渲染失败,回退 Chrome CLI', [
+                    'html_path' => $htmlPath,
+                ]);
+
+                return $this->renderWithChrome($htmlPath);
+            }
+
+            Log::error('ExamPdfExportService: Gotenberg 渲染失败,配置禁止回退 Chrome', [
+                'html_path' => $htmlPath,
+            ]);
+
+            return null;
+        }
+
+        if ($renderer === 'chrome') {
+            return $this->renderWithChrome($htmlPath);
+        }
+
+        Log::error('ExamPdfExportService: 未识别的 PDF_RENDERER,拒绝渲染', [
+            'renderer' => $renderer,
+            'html_path' => $htmlPath,
+            'supported_renderers' => ['gotenberg', 'chrome'],
+        ]);
+
+        return null;
+    }
+
+    /**
+     * 通过 Gotenberg 常驻服务生成 PDF。
+     */
+    private function renderWithGotenberg(string $htmlPath): ?string
+    {
+        $startedAt = microtime(true);
+        $baseUrl = rtrim((string) config('pdf.gotenberg_url', 'http://gotenberg:3000'), '/');
+        $connectTimeout = max(1, (int) config('pdf.gotenberg_connect_timeout_seconds', 3));
+        $timeout = max(5, (int) config('pdf.gotenberg_timeout_seconds', 60));
+        $trace = 'exam-pdf-'.basename($htmlPath).'-'.str_replace('.', '', uniqid('', true));
+
+        if ($baseUrl === '') {
+            Log::error('ExamPdfExportService: Gotenberg URL 为空', [
+                'html_path' => $htmlPath,
+            ]);
+
+            return null;
+        }
+
+        if (! is_file($htmlPath)) {
+            Log::error('ExamPdfExportService: Gotenberg 渲染失败,HTML文件不存在', [
+                'html_path' => $htmlPath,
+            ]);
+
+            return null;
+        }
+
+        try {
+            $htmlContent = file_get_contents($htmlPath);
+            if ($htmlContent === false) {
+                Log::error('ExamPdfExportService: Gotenberg 渲染失败,HTML文件读取失败', [
+                    'trace' => $trace,
+                    'html_path' => $htmlPath,
+                ]);
+
+                return null;
+            }
+
+            $response = Http::timeout($timeout)
+                ->connectTimeout($connectTimeout)
+                ->withHeaders([
+                    'Gotenberg-Trace' => $trace,
+                    'Gotenberg-Output-Filename' => 'exam.pdf',
+                ])
+                ->attach('files', $htmlContent, 'index.html')
+                ->post($baseUrl.'/forms/chromium/convert/html', [
+                    'preferCssPageSize' => 'true',
+                    'printBackground' => 'true',
+                ]);
+
+            $body = $response->body();
+            $contentType = strtolower((string) $response->header('Content-Type', ''));
+            $isPdfContent = str_contains($contentType, 'application/pdf')
+                || str_starts_with($body, '%PDF-');
+
+            if ($response->successful() && $isPdfContent && $body !== '') {
+                Log::info('ExamPdfExportService: Gotenberg 渲染成功', [
+                    'trace' => $trace,
+                    'status' => $response->status(),
+                    'pdf_size' => strlen($body),
+                    'content_type' => $contentType,
+                    'elapsed_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+                ]);
+
+                return $body;
+            }
+
+            Log::error('ExamPdfExportService: Gotenberg 渲染失败', [
+                'trace' => $trace,
+                'status' => $response->status(),
+                'body_size' => strlen($body),
+                'content_type' => $contentType,
+                'is_pdf_content' => $isPdfContent,
+                'body_preview' => mb_substr($body, 0, 500),
+                'elapsed_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('ExamPdfExportService: Gotenberg 请求异常', [
+                'trace' => $trace,
+                'url' => $baseUrl,
+                'error' => $e->getMessage(),
+                'connect_timeout_seconds' => $connectTimeout,
+                'timeout_seconds' => $timeout,
+                'elapsed_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+            ]);
+        }
+
+        return null;
+    }
+
+    private function shouldFallbackToChrome(): bool
+    {
+        return (bool) config('pdf.fallback_to_chrome', true);
     }
 
     /**
@@ -3047,7 +3255,7 @@ class ExamPdfExportService
         $process->setTimeout(180); // 复杂学情报告页允许更长渲染时间,降低超时失败率
         $killSignal = \defined('SIGKILL') ? \SIGKILL : 9;
 
-        Log::warning('ExamPdfExportService: [调试] Chrome命令准备执行', [
+        Log::debug('ExamPdfExportService: [调试] Chrome命令准备执行', [
             'chrome_binary' => $chromeBinary,
             'html_path' => $htmlPath,
             'html_exists' => file_exists($htmlPath),
@@ -3097,6 +3305,7 @@ class ExamPdfExportService
             if ($result !== null) {
                 return $result;
             }
+
             return $this->renderWithChromeMinimal($chromeBinary, $htmlPath);
         } catch (\Throwable $e) {
             if ($process->isRunning()) {
@@ -3255,8 +3464,7 @@ class ExamPdfExportService
         $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', [
+        Log::debug('ExamPdfExportService: inlineExternalResources', [
             'html_length' => strlen($html),
             'has_katex_cdn' => $hasKatexCdn,
             'has_katex_local' => $hasKatexLocal,
@@ -3264,7 +3472,7 @@ class ExamPdfExportService
 
         // 如果既没有 CDN 也没有本地链接,仍尝试注入 KaTeX 关系符通用修复
         if (! $hasKatexCdn && ! $hasKatexLocal) {
-            Log::warning('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
+            Log::debug('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
 
             return $this->applyKatexRelationGlyphFixes($html);
         }
@@ -5041,32 +5249,157 @@ MARKDOWN;
             return '';
         }
 
-        if ($this->looksLikeHtml($content)) {
+        if ($this->looksLikeRenderedKpHtml($content)) {
             return $content;
         }
 
-        if (! class_exists(\Michelf\MarkdownExtra::class)) {
-            $safe = htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
-            return '<div class="kp-markdown-container kp-markdown-content">'.nl2br($safe).'</div>';
+        if ($this->looksLikeHtml($content) && ! $this->looksLikeMarkdown($content)) {
+            return $content;
         }
 
-        $parser = new \Michelf\MarkdownExtra;
         $markdown = html_entity_decode($content, ENT_QUOTES, 'UTF-8');
-        $rendered = $parser->transform($markdown);
+        $rendered = $this->renderKpMarkdownContent($markdown);
 
         return '<div class="kp-markdown-container kp-markdown-content">'.$rendered.'</div>';
     }
 
-    private function looksLikeHtml(string $content): bool
+    private function renderKpMarkdownContent(string $markdown): string
+    {
+        if (class_exists(\Michelf\MarkdownExtra::class)) {
+            $parser = new \Michelf\MarkdownExtra;
+            [$protectedMarkdown, $mathPlaceholders] = $this->protectLatexBlocksForMarkdown($markdown);
+            $rendered = $parser->transform($protectedMarkdown);
+
+            return strtr($rendered, $mathPlaceholders);
+        }
+
+        return $this->renderBasicKpMarkdown($markdown);
+    }
+
+    private function renderBasicKpMarkdown(string $markdown): string
+    {
+        $lines = preg_split('/\R/u', trim($markdown));
+        if ($lines === false) {
+            $safe = htmlspecialchars($markdown, ENT_QUOTES, 'UTF-8');
+
+            return nl2br($safe);
+        }
+
+        $html = [];
+        $paragraph = [];
+        $listType = null;
+
+        $flushParagraph = function () use (&$html, &$paragraph): void {
+            if ($paragraph === []) {
+                return;
+            }
+
+            $html[] = '<p>'.implode('<br>', $paragraph).'</p>';
+            $paragraph = [];
+        };
+        $closeList = function () use (&$html, &$listType): void {
+            if ($listType === null) {
+                return;
+            }
+
+            $html[] = "</{$listType}>";
+            $listType = null;
+        };
+        $openList = function (string $type) use (&$html, &$listType, $closeList): void {
+            if ($listType === $type) {
+                return;
+            }
+
+            $closeList();
+            $html[] = "<{$type}>";
+            $listType = $type;
+        };
+
+        foreach ($lines as $line) {
+            $line = rtrim($line);
+            if ($line === '') {
+                $flushParagraph();
+                $closeList();
+                continue;
+            }
+
+            if (preg_match('/^(#{1,6})\s+(.+)$/u', $line, $match)) {
+                $flushParagraph();
+                $closeList();
+                $level = min(6, strlen($match[1]));
+                $html[] = sprintf('<h%d>%s</h%d>', $level, $this->escapeMarkdownLine($match[2]), $level);
+                continue;
+            }
+
+            if (preg_match('/^\s*[-*]\s+(.+)$/u', $line, $match)) {
+                $flushParagraph();
+                $openList('ul');
+                $html[] = '<li>'.$this->escapeMarkdownLine($match[1]).'</li>';
+                continue;
+            }
+
+            if (preg_match('/^\s*\d+\.\s+(.+)$/u', $line, $match)) {
+                $flushParagraph();
+                $openList('ol');
+                $html[] = '<li>'.$this->escapeMarkdownLine($match[1]).'</li>';
+                continue;
+            }
+
+            $paragraph[] = $this->escapeMarkdownLine($line);
+        }
+
+        $flushParagraph();
+        $closeList();
+
+        return implode("\n", $html);
+    }
+
+    private function escapeMarkdownLine(string $line): string
+    {
+        return htmlspecialchars($line, ENT_QUOTES, 'UTF-8');
+    }
+
+    /**
+     * 保护 Markdown 中的数学块,避免 MarkdownExtra 吃掉 LaTeX 反斜杠
+     */
+    private function protectLatexBlocksForMarkdown(string $markdown): array
+    {
+        $placeholders = [];
+        $index = 0;
+        $pattern = '/\$\$[\s\S]*?\$\$|\\\\\[[\s\S]*?\\\\\]|\\\\\([\s\S]*?\\\\\)|(?<!\$)\$[^$\n]+\$(?!\$)/';
+
+        $protected = preg_replace_callback($pattern, function (array $matches) use (&$placeholders, &$index) {
+            // 使用 markdown 安全占位符,避免被 __ 粗体语法吞掉
+            $token = "@@KPMATHBLOCK{$index}@@";
+            $placeholders[$token] = $matches[0];
+            $index++;
+
+            return $token;
+        }, $markdown);
+
+        return [$protected ?? $markdown, $placeholders];
+    }
+
+    private function looksLikeRenderedKpHtml(string $content): bool
     {
         if (stripos($content, 'kp-markdown-container') !== false ||
             stripos($content, 'kp-markdown-content') !== false) {
             return true;
         }
 
+        return false;
+    }
+
+    private function looksLikeHtml(string $content): bool
+    {
         return (bool) preg_match('/<\s*(p|div|h[1-6]|ul|ol|li|table|span|blockquote|pre|code|br)\b/i', $content);
     }
 
+    private function looksLikeMarkdown(string $content): bool
+    {
+        return (bool) preg_match('/(^|\R)\s{0,3}#{1,6}\s+\S|(^|\R)\s{0,3}[-*]\s+\S|(^|\R)\s{0,3}\d+\.\s+\S/u', $content);
+    }
+
     private function shouldUseDefaultExplanations(): bool
     {
         if (!Schema::hasTable('knowledge_points')) {

+ 25 - 2
app/Services/KatexRenderer.php

@@ -204,7 +204,8 @@ class KatexRenderer
                 $decoded = html_entity_decode($tex, ENT_QUOTES, 'UTF-8');
             }
 
-            // 清理公式内部的换行与 <br>,避免 \frac{M}\n{N} 破坏解析
+            // 清理公式内部的软换行标签,避免断行优化污染 LaTeX 内容
+            $tex = preg_replace('/<wbr\\s*\\/?>/i', '', $tex);
             $tex = preg_replace('/<br\\s*\\/?>/i', '', $tex);
             $tex = preg_replace('/\\r\\n|\\r|\\n/', '', $tex);
 
@@ -213,7 +214,9 @@ class KatexRenderer
             // 修复漏空格的 \quad/\qquad(如 \quadz、\quadx)
             $tex = preg_replace('/\\\\q(u)?ad(?=[A-Za-z0-9])/', '\\\\q$1ad ', $tex);
 
-            return $this->fixCasesLineBreaks($tex);
+            $tex = $this->fixCasesLineBreaks($tex);
+
+            return $this->fixMatrixLineBreaks($tex);
         };
 
         // $$...$$
@@ -252,6 +255,26 @@ class KatexRenderer
         }, $tex);
     }
 
+    /**
+     * 修复 matrix/vmatrix 等环境中被压成单反斜杠的换行
+     * 例如:\mathbf k\x_1&y_1... -> \mathbf k\\x_1&y_1...
+     */
+    private function fixMatrixLineBreaks(string $tex): string
+    {
+        $envPattern = '/\\\\begin\{(matrix|pmatrix|bmatrix|Bmatrix|vmatrix|Vmatrix|array)\}([\s\S]*?)\\\\end\{\1\}/';
+
+        return preg_replace_callback($envPattern, function ($m) {
+            $env = $m[1];
+            $content = $m[2];
+
+            // 仅修复“行分隔被压成单反斜杠”的已知场景:
+            // 例如 ...\mathbf k\x_1&y_1... 里的 \x_1 需要恢复为 \\x_1。
+            $content = preg_replace('/(?<=[0-9A-Za-z}])(?<!\\\\)\\\\(?=[xy]\s*_[0-9]\s*&)/', '\\\\\\\\', $content);
+
+            return "\\begin{{$env}}{$content}\\end{{$env}}";
+        }, $tex);
+    }
+
     private function extractKatexErrorSnippet(string $html): array
     {
         if (!preg_match('/<span class="katex-error"[^>]*>(.*?)<\/span>/is', $html, $match)) {

+ 18 - 5
config/pdf.php

@@ -14,15 +14,28 @@ return [
 
     /*
     |--------------------------------------------------------------------------
-    | 统一PDF:是否包含“知识点讲解”章节(默认值)
+    | PDF渲染后端
     |--------------------------------------------------------------------------
     |
-    | 当生成统一PDF(卷子+判卷)时,可在最前面插入“知识点讲解”章节。
-    | - 该默认值可被请求参数覆盖(例如 include_kp_explain=true/false)
-    | - 关闭时保持现有“卷子+判卷”二合一行为不变
+    | gotenberg:通过常驻 Gotenberg 服务生成 PDF,减少每次启动 Chrome 的长尾耗时。
+    | chrome:保留原有本机 Chrome CLI 渲染路径,作为兼容/兜底方案。
     |
     */
-    'include_kp_explain_default' => env('PDF_INCLUDE_KP_EXPLAIN', false),
+    'renderer' => env('PDF_RENDERER', 'gotenberg'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Gotenberg 服务配置
+    |--------------------------------------------------------------------------
+    |
+    | PDF worker 通过内网访问 Gotenberg 服务;超时控制单次转换最长等待时间。
+    |
+    */
+    'gotenberg_url' => env('GOTENBERG_URL', 'http://gotenberg:3000'),
+    'gotenberg_connect_timeout_seconds' => (int) env('PDF_GOTENBERG_CONNECT_TIMEOUT_SECONDS', 3),
+    'gotenberg_timeout_seconds' => (int) env('PDF_GOTENBERG_TIMEOUT_SECONDS', 60),
+    'fallback_to_chrome' => env('PDF_FALLBACK_TO_CHROME', true),
+    'kp_explain_fetch_timeout_seconds' => (int) env('PDF_KP_EXPLAIN_FETCH_TIMEOUT_SECONDS', 2),
 
     /*
     |--------------------------------------------------------------------------

+ 40 - 5
docker-compose.pdf.yml

@@ -1,7 +1,9 @@
 services:
   # 主应用(Web API 服务)- 用于渲染 HTML
   app:
-    build: .
+    build:
+      context: .
+      target: app-runtime
     container_name: math_cms_app
     ports:
       - "8000:8000"
@@ -18,9 +20,28 @@ services:
       retries: 3
       start_period: 40s
 
+  # Gotenberg PDF 转换服务(内网访问,不暴露公网端口)
+  gotenberg:
+    image: gotenberg/gotenberg:8
+    container_name: math_cms_gotenberg
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 20s
+    deploy:
+      resources:
+        limits:
+          cpus: '2'
+          memory: 1536M
+
   # PDF Worker 1
   pdf-worker-1:
-    build: .
+    build:
+      context: .
+      target: pdfworker
     container_name: pdf_worker_1
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     env_file:
@@ -33,6 +54,8 @@ services:
     depends_on:
       app:
         condition: service_healthy
+      gotenberg:
+        condition: service_healthy
     healthcheck:
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       interval: 30s
@@ -47,7 +70,9 @@ services:
 
   # PDF Worker 2
   pdf-worker-2:
-    build: .
+    build:
+      context: .
+      target: pdfworker
     container_name: pdf_worker_2
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     env_file:
@@ -60,6 +85,8 @@ services:
     depends_on:
       app:
         condition: service_healthy
+      gotenberg:
+        condition: service_healthy
     healthcheck:
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       interval: 30s
@@ -74,7 +101,9 @@ services:
 
   # PDF Worker 3
   pdf-worker-3:
-    build: .
+    build:
+      context: .
+      target: pdfworker
     container_name: pdf_worker_3
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     env_file:
@@ -87,6 +116,8 @@ services:
     depends_on:
       app:
         condition: service_healthy
+      gotenberg:
+        condition: service_healthy
     healthcheck:
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       interval: 30s
@@ -101,7 +132,9 @@ services:
 
   # PDF Worker 4
   pdf-worker-4:
-    build: .
+    build:
+      context: .
+      target: pdfworker
     container_name: pdf_worker_4
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     env_file:
@@ -114,6 +147,8 @@ services:
     depends_on:
       app:
         condition: service_healthy
+      gotenberg:
+        condition: service_healthy
     healthcheck:
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       interval: 30s

+ 29 - 3
docker-compose.yml

@@ -1,7 +1,9 @@
 services:
   # 主应用(Web API 服务)- 使用 Nginx + PHP-FPM
   app:
-    build: .
+    build:
+      context: .
+      target: app-runtime
     container_name: math_cms_app
     # 使用 Dockerfile 中的默认 CMD(nginx + php-fpm)
     ports:
@@ -21,7 +23,9 @@ services:
 
   # 队列 Worker(通用任务队列)
   queue:
-    build: .
+    build:
+      context: .
+      target: app-runtime
     container_name: math_cms_queue
     command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
     env_file:
@@ -44,9 +48,28 @@ services:
           cpus: '1'
           memory: 512M
 
+  # Gotenberg PDF 转换服务(内网访问,不暴露公网端口)
+  gotenberg:
+    image: gotenberg/gotenberg:8
+    container_name: math_cms_gotenberg
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 20s
+    deploy:
+      resources:
+        limits:
+          cpus: '2'
+          memory: 1536M
+
   # PDF 生成专用 Worker(资源隔离,防止 Chrome 吃满 CPU)
   pdf-worker:
-    build: .
+    build:
+      context: .
+      target: pdfworker
     container_name: math_cms_pdf
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=2 --max-time=300 --max-jobs=10
     env_file:
@@ -56,6 +79,9 @@ services:
       - ./.env:/app/.env
     restart: unless-stopped
     stop_grace_period: 120s
+    depends_on:
+      gotenberg:
+        condition: service_healthy
     # PDF worker 健康检查:检查进程是否存在
     healthcheck:
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]

+ 57 - 7
docs/pdf-generation.md

@@ -25,7 +25,7 @@ Blade模板渲染 (exam-paper.blade.php + paper-body.blade.php)
 mergeHtmlWithPageBreak()  合并两个HTML
-buildPdf() → renderWithChrome()  Chrome Headless 转 PDF
+buildPdf() → Gotenberg(默认)/ Chrome CLI(兜底)转 PDF
 上传到OSS,返回URL
 ```
@@ -33,24 +33,29 @@ buildPdf() → renderWithChrome()  Chrome Headless 转 PDF
 ## 涉及的关键文件
 
 ### 1. API入口
+
 - `app/Http/Controllers/Api/IntelligentExamController.php`
   - `store()` - 创建试卷的API入口
   - `triggerPdfGeneration()` - 触发PDF生成队列任务
 
 ### 2. 队列任务
+
 - `app/Jobs/GenerateExamPdfJob.php`
   - 在 `pdf` 队列中执行
   - 调用 `ExamPdfExportService::generateUnifiedPdf()`
 
 ### 3. PDF生成服务
+
 - `app/Services/ExamPdfExportService.php`
   - `generateUnifiedPdf()` - 生成统一PDF(试卷+判卷)
   - `renderExamHtml()` - 通过HTTP请求获取渲染后的HTML
   - `mergeHtmlWithPageBreak()` - 合并两个HTML页面
-  - `buildPdf()` - 调用Chrome生成PDF
-  - `renderWithChrome()` - Chrome Headless渲染
+  - `buildPdf()` - 按配置选择 PDF 渲染后端
+  - `renderWithGotenberg()` - Gotenberg 常驻 Chromium 服务渲染
+  - `renderWithChrome()` - Chrome CLI 兜底渲染
 
 ### 4. HTML渲染控制器
+
 - `app/Http/Controllers/ExamPdfController.php`
   - `show()` - 渲染试卷HTML(路由:`/admin/intelligent-exam/pdf/{paper_id}`)
   - `showGrading()` - 渲染判卷HTML(路由:`/admin/intelligent-exam/grading/{paper_id}`)
@@ -60,14 +65,17 @@ buildPdf() → renderWithChrome()  Chrome Headless 转 PDF
   - `determineQuestionType()` - 根据内容推断题目类型
 
 ### 5. Blade模板
+
 - `resources/views/pdf/exam-paper.blade.php` - 试卷主模板
 - `resources/views/pdf/exam-grading.blade.php` - 判卷主模板
 - `resources/views/components/exam/paper-body.blade.php` - 题目渲染组件(核心)
 
 ### 6. 数学公式处理
+
 - `app/Services/MathFormulaProcessor.php` - LaTeX公式处理
 
 ### 7. 路由配置
+
 - `routes/web.php` 第15-16行
 
 ```php
@@ -80,6 +88,7 @@ Route::get('/admin/intelligent-exam/grading/{paper_id}', [ExamPdfController::cla
 ## 数据流
 
 ### 数据库表
+
 - `papers` - 试卷基本信息
 - `paper_questions` - 试卷题目关联
   - `question_number` - 题目序号
@@ -88,6 +97,7 @@ Route::get('/admin/intelligent-exam/grading/{paper_id}', [ExamPdfController::cla
   - `question_text` - 题目内容
 
 ### 题目分类流程
+
 ```
 paper_questions 表数据
@@ -103,66 +113,82 @@ paper-body.blade.php 按类型渲染
 ## 常见问题排查
 
 ### 问题1:题目丢失
+
 **症状**:网页能正常显示所有题目,但PDF中缺少部分题目
 
 **排查点**:
+
 1. 检查 `ExamPdfController` 中的正则表达式是否误匹配SVG内容
 2. 检查 `paper-body.blade.php` 中的正则表达式
 3. 开启调试:设置环境变量 `PDF_DEBUG_SAVE_HTML=true`,查看生成的HTML
 
 **已修复的问题**(2026-01-05):
+
 - SVG中的坐标标注(如`BD:DC`)被误识别为选项标记(如`D:`)
 - 修复方法:正则表达式添加 `(?:^|\s)` 前缀,要求选项标记在行首或空白后
 
 ### 问题2:题目类型分类错误
+
 **症状**:选择题被分到填空题,或反之
 
 **排查点**:
+
 1. 检查 `paper_questions.question_type` 字段是否有值
 2. 检查 `normalizeQuestionTypeValue()` 方法
 3. 检查 `determineQuestionType()` 方法的推断逻辑
 
 **已修复的问题**(2026-01-05):
+
 - 选择题因含有`()`被误判为填空题
 - 修复方法:优先使用 `question_type` 字段,而非根据内容推断
 
-### 问题3:Chrome渲染问题
+### 问题3:PDF渲染问题
 **症状**:PDF生成失败或内容不完整
 
 **排查点**:
-1. 检查Chrome是否安装:`which google-chrome` 或 `which chromium`
-2. 检查超时设置(当前90秒)
-3. 检查外部资源加载(KaTeX CDN等)
+1. 默认检查 Gotenberg 是否健康:`curl http://gotenberg:3000/health`(容器内)或 `docker compose ps gotenberg`
+2. 检查渲染后端配置:`PDF_RENDERER=gotenberg|chrome`
+3. 检查 Gotenberg 超时:`PDF_GOTENBERG_TIMEOUT_SECONDS`
+4. 如果启用 Chrome 兜底,检查 Chrome 是否安装:`which google-chrome` 或 `which chromium`
+5. 检查外部资源加载(KaTeX 已内联;远程图片仍需网络可达)
 
 ### 问题4:HTML结构问题
+
 **症状**:页面样式异常或部分内容不显示
 
 **排查点**:
+
 1. 检查div标签是否正确闭合
 2. 使用 `PDF_DEBUG_SAVE_HTML=true` 保存HTML后在浏览器中检查
 
 ## 调试方法
 
 ### 1. 保存HTML副本
+
 ```bash
 # 在 .env 中添加
 PDF_DEBUG_SAVE_HTML=true
 ```
+
 HTML会保存到 `storage/app/debug_pdf_*.html`
 
 ### 2. 查看队列日志
+
 ```bash
 docker exec math_cms_app tail -100 storage/logs/laravel.log | grep -E "(PDF|ExamPdf|题目)"
 ```
 
 ### 3. 手动测试HTML渲染
+
 直接访问URL查看HTML渲染结果:
+
 ```
 /admin/intelligent-exam/pdf/{paper_id}?answer=false  # 试卷
 /admin/intelligent-exam/grading/{paper_id}           # 判卷
 ```
 
 ### 4. 检查题目数据
+
 ```sql
 SELECT question_number, question_type, question_bank_id
 FROM paper_questions
@@ -173,6 +199,7 @@ ORDER BY question_number;
 ## 关键正则表达式
 
 ### 选项提取(ExamPdfController + paper-body.blade.php)
+
 ```php
 // 正确的正则(要求选项标记在行首或空白后)
 $pattern = '/(?:^|\s)([A-D])[\.、:.:]\s*(.+?)(?=(?:^|\s)[A-D][\.、:.:]|$)/su';
@@ -182,6 +209,7 @@ $pattern = '/([A-D])[\.、:.:]\s*(.+?)(?=\s*[A-D][\.、:.:]|$)/su';
 ```
 
 ### 题干分离
+
 ```php
 // 正确的正则
 preg_match('/^(.+?)(?=(?:^|\s)[A-D][\.、:.:])/su', $content, $match);
@@ -192,11 +220,15 @@ preg_match('/^(.+?)(?=(?:^|\s)[A-D][\.、:.:])/su', $content, $match);
 - `math_cms_app` - 主应用 (Nginx + PHP-FPM)
 - `math_cms_queue` - 默认队列 worker
 - `math_cms_pdf` - PDF队列 worker(带Chrome)
+- `math_cms_gotenberg` - 默认 PDF 转换服务(常驻 Chromium)
 
 ```bash
 # 重启PDF队列
 docker compose restart pdf-worker
 
+# 重启Gotenberg
+docker compose restart gotenberg
+
 # 查看队列状态
 docker exec math_cms_app php artisan queue:failed
 ```
@@ -204,3 +236,21 @@ docker exec math_cms_app php artisan queue:failed
 ## 配置文件
 
 - `config/pdf.php` - PDF调试设置
+
+## 当前知识点讲解开关规则(2026-04)
+
+- 默认行为统一为:仅当 `papers.paper_type = 2`(知识点组题)时,统一 PDF 包含“知识点讲解”。
+- `paper_type = 3`(教材组题)等其他类型,统一 PDF 不包含“知识点讲解”。
+- `/api/papers/{paper_id}/regenerate` 与组卷队列保持一致:仅知识点组卷类型关联“知识点讲解”,不再提供外部覆盖参数。
+
+关键环境变量:
+
+```env
+PDF_RENDERER=gotenberg
+GOTENBERG_URL=http://gotenberg:3000
+PDF_GOTENBERG_CONNECT_TIMEOUT_SECONDS=3
+PDF_GOTENBERG_TIMEOUT_SECONDS=60
+PDF_FALLBACK_TO_CHROME=true
+PDF_CHROME_POLL_TIMEOUT_SECONDS=40
+PDF_KP_EXPLAIN_FETCH_TIMEOUT_SECONDS=2
+```

+ 9 - 3
docs/pdf-worker-deployment.md

@@ -65,7 +65,7 @@ docker compose -f docker-compose.pdf.yml up -d
 docker compose -f docker-compose.pdf.yml ps
 ```
 
-预期输出:5 个容器都是 `Up` 状态
+预期输出:6 个容器都是 `Up` 状态(包含 `math_cms_gotenberg`)
 
 ### 3.2 检查数据库连接
 
@@ -79,7 +79,13 @@ docker exec pdf_worker_1 php artisan db:show
 docker exec pdf_worker_1 php artisan tinker --execute="dump(Redis::ping());"
 ```
 
-### 3.4 检查队列消费
+### 3.4 检查 Gotenberg 连接
+
+```bash
+docker exec pdf_worker_1 php artisan tinker --execute="dump(\Illuminate\Support\Facades\Http::get(config('pdf.gotenberg_url').'/health')->json());"
+```
+
+### 3.5 检查队列消费
 
 ```bash
 # 查看队列长度
@@ -89,7 +95,7 @@ docker exec pdf_worker_1 php artisan tinker --execute="echo Redis::llen('queues:
 docker compose -f docker-compose.pdf.yml logs -f pdf-worker-1
 ```
 
-### 3.5 端到端测试
+### 3.6 端到端测试
 
 在主服务器触发一个 PDF 生成,观察 PDF 机器日志是否有消费记录。
 

+ 1 - 7
resources/views/pdf/exam-paper.blade.php

@@ -72,11 +72,6 @@
             font-weight: bold;
             margin-bottom: 10px;
         }
-        .paper-title {
-            font-size: 20px;
-            font-weight: bold;
-            margin-bottom: 15px;
-        }
         .info-row {
             display: flex;
             justify-content: space-between;
@@ -317,8 +312,7 @@
 
     <div class="page">
     <div class="header">
-        <div class="school-name">数学智能练习题</div>
-        <div class="paper-title">{{ $examCode }}</div>
+        <div class="school-name">数学智能学案</div>
         <div class="info-row">
             <span>老师:{{ $teacher['name'] ?? '________' }}</span>
             <span>年级:@formatGrade($student['grade'] ?? '________')</span>

+ 1 - 0
scripts/katex-render.mjs

@@ -146,6 +146,7 @@ function processDelimiter(html, left, right, displayMode) {
         try {
             // 清理 LaTeX 内容 - 先解码 HTML 实体
             let cleanLatex = decodeHtmlEntities(latex.trim());
+            cleanLatex = cleanLatex.replace(/<wbr\s*\/?>/gi, '');
 
             // 跳过空内容
             if (!cleanLatex) {