Quellcode durchsuchen

Optimize exam PDF HTML rendering

yemeishu vor 2 Wochen
Ursprung
Commit
5838280704

+ 3 - 26
app/Http/Controllers/ExamPdfController.php

@@ -1166,26 +1166,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 +1354,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 +1386,7 @@ class ExamPdfController extends Controller
 
                     continue;
                 }
-                RegeneratePdfJob::dispatch($paper->paper_id, $includeKpExplain);
+                RegeneratePdfJob::dispatch($paper->paper_id);
                 $queued[] = $paper->paper_id;
             }
 
@@ -1462,10 +1443,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 +1460,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 {

+ 109 - 80
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;
@@ -141,25 +143,25 @@ 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);
-        }
+        // 与组卷规则保持一致:仅知识点组卷类型(paper_type=2)包含知识点讲解。
+        $paperType = Paper::query()
+            ->where('paper_id', $paperId)
+            ->value('paper_type');
+        $shouldIncludeKpExplain = ((int) $paperType) === 2;
 
         Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', [
             'paper_id' => $paperId,
-            'include_kp_explain' => $includeKpExplain,
+            'has_kp_explain' => $shouldIncludeKpExplain,
         ]);
 
         try {
             // 步骤0:获取知识点讲解HTML(如需要)
             $kpExplainHtml = null;
-            if ($includeKpExplain) {
+            if ($shouldIncludeKpExplain) {
                 Log::info('generateUnifiedPdf: 开始获取知识点讲解HTML', ['paper_id' => $paperId]);
                 $kpExplainHtml = $this->fetchKnowledgeExplanationHtml($paperId);
                 if ($kpExplainHtml) {
@@ -1795,10 +1797,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);
+            $response = Http::timeout(2)->get($url);
             if ($response->successful()) {
                 $html = $response->body();
                 if (! empty(trim($html))) {
@@ -1831,6 +1838,34 @@ class ExamPdfExportService
         }
     }
 
+    private function renderKnowledgeExplanationHtmlFromController(string $paperId): ?string
+    {
+        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;
+            }
+
+            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
     {
         if (! class_exists(\Michelf\MarkdownExtra::class)) {
@@ -1843,7 +1878,9 @@ class ExamPdfExportService
             '/<div class="kp-markdown-source"[^>]*>([\s\S]*?)<\/div>\s*<div class="kp-markdown-container[^"]*"[^>]*><\/div>/i',
             function ($matches) use ($parser) {
                 $markdown = html_entity_decode(trim($matches[1]), ENT_QUOTES, 'UTF-8');
-                $rendered = $parser->transform($markdown);
+                [$protectedMarkdown, $mathPlaceholders] = $this->protectLatexBlocksForMarkdown($markdown);
+                $rendered = $parser->transform($protectedMarkdown);
+                $rendered = strtr($rendered, $mathPlaceholders);
 
                 return '<div class="kp-markdown-container kp-markdown-content">'.$rendered.'</div>';
             },
@@ -1852,17 +1889,17 @@ class ExamPdfExportService
     }
 
     /**
-     * 【新增】渲染试卷HTML(通过HTTP调用路由
+     * 渲染试卷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);
+        // 阶段A:优先本地直渲,降低 HTTP 自调用开销。
+        $html = $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
+        if (! empty($html)) {
+            return $html;
         }
 
         try {
-            // 通过HTTP客户端获取渲染后的HTML(与知识点讲解相同的逻辑)
             $routeName = $useGradingView
                 ? 'filament.admin.auth.intelligent-exam.grading'
                 : 'filament.admin.auth.intelligent-exam.pdf';
@@ -1877,7 +1914,7 @@ class ExamPdfExportService
                 }
             }
 
-            Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败,使用备用方案', [
+            Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败', [
                 'paper_id' => $paperId,
                 'url' => $url,
             ]);
@@ -1889,8 +1926,7 @@ class ExamPdfExportService
             ]);
         }
 
-        // 备用方案:直接渲染视图
-        return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
+        return null;
     }
 
     /**
@@ -1899,70 +1935,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 +1970,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依赖
@@ -5052,11 +5058,34 @@ MARKDOWN;
 
         $parser = new \Michelf\MarkdownExtra;
         $markdown = html_entity_decode($content, ENT_QUOTES, 'UTF-8');
-        $rendered = $parser->transform($markdown);
+        [$protectedMarkdown, $mathPlaceholders] = $this->protectLatexBlocksForMarkdown($markdown);
+        $rendered = $parser->transform($protectedMarkdown);
+        $rendered = strtr($rendered, $mathPlaceholders);
 
         return '<div class="kp-markdown-container kp-markdown-content">'.$rendered.'</div>';
     }
 
+    /**
+     * 保护 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 looksLikeHtml(string $content): bool
     {
         if (stripos($content, 'kp-markdown-container') !== false ||

+ 23 - 1
app/Services/KatexRenderer.php

@@ -213,7 +213,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 +254,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)) {

+ 0 - 12
config/pdf.php

@@ -12,18 +12,6 @@ return [
     */
     'debug_save_html' => env('PDF_DEBUG_SAVE_HTML', false),
 
-    /*
-    |--------------------------------------------------------------------------
-    | 统一PDF:是否包含“知识点讲解”章节(默认值)
-    |--------------------------------------------------------------------------
-    |
-    | 当生成统一PDF(卷子+判卷)时,可在最前面插入“知识点讲解”章节。
-    | - 该默认值可被请求参数覆盖(例如 include_kp_explain=true/false)
-    | - 关闭时保持现有“卷子+判卷”二合一行为不变
-    |
-    */
-    'include_kp_explain_default' => env('PDF_INCLUDE_KP_EXPLAIN', false),
-
     /*
     |--------------------------------------------------------------------------
     | Chrome 轮询超时(秒)

+ 33 - 0
docs/pdf-generation.md

@@ -33,16 +33,19 @@ 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
@@ -51,6 +54,7 @@ buildPdf() → renderWithChrome()  Chrome Headless 转 PDF
   - `renderWithChrome()` - Chrome Headless渲染
 
 ### 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 +64,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 +87,7 @@ Route::get('/admin/intelligent-exam/grading/{paper_id}', [ExamPdfController::cla
 ## 数据流
 
 ### 数据库表
+
 - `papers` - 试卷基本信息
 - `paper_questions` - 试卷题目关联
   - `question_number` - 题目序号
@@ -88,6 +96,7 @@ Route::get('/admin/intelligent-exam/grading/{paper_id}', [ExamPdfController::cla
   - `question_text` - 题目内容
 
 ### 题目分类流程
+
 ```
 paper_questions 表数据
@@ -103,66 +112,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渲染问题
+
 **症状**:PDF生成失败或内容不完整
 
 **排查点**:
+
 1. 检查Chrome是否安装:`which google-chrome` 或 `which chromium`
 2. 检查超时设置(当前90秒)
 3. 检查外部资源加载(KaTeX CDN等)
 
 ### 问题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 +198,7 @@ ORDER BY question_number;
 ## 关键正则表达式
 
 ### 选项提取(ExamPdfController + paper-body.blade.php)
+
 ```php
 // 正确的正则(要求选项标记在行首或空白后)
 $pattern = '/(?:^|\s)([A-D])[\.、:.:]\s*(.+?)(?=(?:^|\s)[A-D][\.、:.:]|$)/su';
@@ -182,6 +208,7 @@ $pattern = '/([A-D])[\.、:.:]\s*(.+?)(?=\s*[A-D][\.、:.:]|$)/su';
 ```
 
 ### 题干分离
+
 ```php
 // 正确的正则
 preg_match('/^(.+?)(?=(?:^|\s)[A-D][\.、:.:])/su', $content, $match);
@@ -204,3 +231,9 @@ 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` 与组卷队列保持一致:仅知识点组卷类型关联“知识点讲解”,不再提供外部覆盖参数。