Quellcode durchsuchen

merge: pdf option and formula alignment fixes

yemeishu vor 3 Wochen
Ursprung
Commit
2571842af0

+ 2 - 0
.env.example

@@ -86,3 +86,5 @@ MATHRECSYS_TIMEOUT=30
 EXAM_PDF_SHOW_QUESTION_ID=false
 EXAM_PDF_SHOW_QUESTION_DIFFICULTY=false
 EXAM_PDF_GRADING_SHOW_STEM=true
+# 服务端 KaTeX 渲染使用的 Node 可执行文件(默认 node)
+KATEX_NODE_BINARY=node

+ 3 - 3
app/Http/Controllers/Api/IntelligentExamController.php

@@ -465,6 +465,8 @@ class IntelligentExamController extends Controller
             Log::info('PDF生成任务已加入队列', [
                 'task_id' => $taskId,
                 'paper_id' => $paperId,
+                'queue_connection' => config('queue.default'),
+                'queue_name' => 'pdf',
             ]);
         } catch (\Exception $e) {
             Log::error('PDF生成任务队列失败,不回退到同步处理', [
@@ -473,9 +475,7 @@ class IntelligentExamController extends Controller
                 'error' => $e->getMessage(),
                 'note' => '依赖队列重试机制,不进行同步处理以避免并发冲突',
             ]);
-            // 【优化】不回退到同步处理,避免与队列任务并发冲突
-            // 队列系统有重试机制,会自动处理失败情况
-            // $this->processPdfGeneration($taskId, $paperId);
+            $this->taskManager->markTaskFailed($taskId, 'PDF任务入队失败: ' . $e->getMessage());
         }
     }
 

+ 33 - 5
app/Jobs/GenerateExamPdfJob.php

@@ -13,6 +13,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
 use Illuminate\Support\Facades\Log;
+use Throwable;
 
 class GenerateExamPdfJob implements ShouldQueue
 {
@@ -22,7 +23,9 @@ class GenerateExamPdfJob implements ShouldQueue
 
     public string $paperId;
 
-    public int $maxAttempts = 3;
+    public int $tries = 3;
+
+    public int $timeout = 300;
 
     public function __construct(string $taskId, string $paperId)
     {
@@ -31,6 +34,8 @@ class GenerateExamPdfJob implements ShouldQueue
 
         // 指定使用 pdf 队列,由独立的 pdf-worker 容器处理
         $this->onQueue('pdf');
+        // 避免事务未提交时 worker 提前消费导致“试卷不存在”
+        $this->afterCommit();
     }
 
     public function handle(
@@ -47,7 +52,11 @@ class GenerateExamPdfJob implements ShouldQueue
             ]);
 
             // 【修复】首先检查试卷是否存在
-            $paperModel = Paper::with('questions')->find($this->paperId);
+            // 强制走主库读取,避免读写分离下新建试卷短时不可见导致“试卷不存在”
+            $paperModel = Paper::query()
+                ->useWritePdo()
+                ->with('questions')
+                ->find($this->paperId);
             if (! $paperModel) {
                 Log::error('PDF生成队列任务失败:试卷不存在', [
                     'task_id' => $this->taskId,
@@ -56,7 +65,7 @@ class GenerateExamPdfJob implements ShouldQueue
                 ]);
 
                 // 如果试卷不存在,判断是否需要重试
-                if ($this->attempts() < $this->maxAttempts) {
+                if ($this->attempts() < $this->tries) {
                     Log::info('试卷不存在,将在2秒后重试', [
                         'task_id' => $this->taskId,
                         'paper_id' => $this->paperId,
@@ -87,7 +96,7 @@ class GenerateExamPdfJob implements ShouldQueue
                     'question_count' => 0,
                 ]);
 
-                if ($this->attempts() < $this->maxAttempts) {
+                if ($this->attempts() < $this->tries) {
                     Log::info('试卷没有题目,将在1秒后重试', [
                         'task_id' => $this->taskId,
                         'paper_id' => $this->paperId,
@@ -147,7 +156,7 @@ class GenerateExamPdfJob implements ShouldQueue
             ]);
 
             // 如果是第一次失败且试卷可能还在创建中,等待后重试
-            if ($this->attempts() < $this->maxAttempts && strpos($e->getMessage(), '不存在') !== false) {
+            if ($this->attempts() < $this->tries && strpos($e->getMessage(), '不存在') !== false) {
                 Log::info('检测到试卷不存在错误,将在2秒后重试', [
                     'task_id' => $this->taskId,
                     'paper_id' => $this->paperId,
@@ -161,4 +170,23 @@ class GenerateExamPdfJob implements ShouldQueue
             $taskManager->markTaskFailed($this->taskId, $e->getMessage());
         }
     }
+
+    public function failed(Throwable $exception): void
+    {
+        try {
+            app(TaskManager::class)->markTaskFailed($this->taskId, $exception->getMessage());
+        } catch (Throwable $innerException) {
+            Log::error('PDF生成队列任务失败回调异常', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->paperId,
+                'error' => $innerException->getMessage(),
+            ]);
+        }
+
+        Log::error('PDF生成队列任务最终失败', [
+            'task_id' => $this->taskId,
+            'paper_id' => $this->paperId,
+            'error' => $exception->getMessage(),
+        ]);
+    }
 }

+ 53 - 34
app/Services/KatexRenderer.php

@@ -100,45 +100,64 @@ class KatexRenderer
             return $html;
         }
 
-        try {
-            // 创建进程
-            $process = new Process(['node', $this->scriptPath]);
-            $process->setInput($html);
-            $process->setTimeout(30); // 30秒超时
-
-            // 执行
-            $process->run();
-
-            // 检查是否成功
-            if (!$process->isSuccessful()) {
-                Log::warning('KatexRenderer: Node.js 脚本执行失败', [
-                    'exit_code' => $process->getExitCode(),
-                    'error' => $process->getErrorOutput(),
+        $configuredBinary = trim((string) config('math-render.katex.node_binary', 'node'));
+        $candidates = array_values(array_unique(array_filter([
+            $configuredBinary ?: 'node',
+            'node', // 兜底,避免误配时直接失败
+        ])));
+
+        $lastError = null;
+        $lastExitCode = null;
+
+        foreach ($candidates as $nodeBinary) {
+            try {
+                $process = new Process([$nodeBinary, $this->scriptPath]);
+                $process->setInput($html);
+                $process->setTimeout(30); // 30秒超时
+                $process->run();
+
+                if (!$process->isSuccessful()) {
+                    $lastExitCode = $process->getExitCode();
+                    $lastError = trim($process->getErrorOutput()) ?: trim($process->getOutput());
+                    Log::warning('KatexRenderer: Node.js 脚本执行失败,尝试下一个候选', [
+                        'node_binary' => $nodeBinary,
+                        'exit_code' => $lastExitCode,
+                        'error' => $lastError,
+                    ]);
+                    continue;
+                }
+
+                $output = $process->getOutput();
+                if (empty($output)) {
+                    Log::warning('KatexRenderer: Node.js 脚本输出为空', [
+                        'node_binary' => $nodeBinary,
+                    ]);
+                    return $html;
+                }
+
+                Log::info('KatexRenderer: LaTeX 公式渲染成功', [
+                    'node_binary' => $nodeBinary,
+                    'input_length' => strlen($html),
+                    'output_length' => strlen($output),
                 ]);
-                return $html;
-            }
-
-            $output = $process->getOutput();
 
-            // 验证输出
-            if (empty($output)) {
-                Log::warning('KatexRenderer: Node.js 脚本输出为空');
-                return $html;
+                return $output;
+            } catch (\Exception $e) {
+                $lastError = $e->getMessage();
+                Log::warning('KatexRenderer: 渲染异常,尝试下一个候选', [
+                    'node_binary' => $nodeBinary,
+                    'error' => $lastError,
+                ]);
             }
+        }
 
-            Log::info('KatexRenderer: LaTeX 公式渲染成功', [
-                'input_length' => strlen($html),
-                'output_length' => strlen($output),
-            ]);
-
-            return $output;
+        Log::error('KatexRenderer: 所有Node候选均执行失败', [
+            'node_candidates' => $candidates,
+            'last_exit_code' => $lastExitCode,
+            'last_error' => $lastError,
+        ]);
 
-        } catch (\Exception $e) {
-            Log::error('KatexRenderer: 渲染异常', [
-                'error' => $e->getMessage(),
-            ]);
-            return $html;
-        }
+        return $html;
     }
 
     /**

+ 2 - 0
config/math-render.php

@@ -5,6 +5,8 @@ return [
         'js_path' => env('KATEX_JS_PATH', '/js/katex.min.js'),
         'css_path' => env('KATEX_CSS_PATH', '/css/katex/katex.min.css'),
         'auto_init' => env('KATEX_AUTO_INIT', true),
+        // 服务端KaTeX渲染使用的Node可执行文件,默认使用PATH中的node
+        'node_binary' => env('KATEX_NODE_BINARY', 'node'),
     ],
 
     'render' => [

+ 15 - 3
resources/views/components/exam/paper-body.blade.php

@@ -218,7 +218,10 @@
                         $optCount = count($options);
                         $maxOptionLength = 0;
                         foreach ($options as $opt) {
-                            $optText = strip_tags(\App\Services\MathFormulaProcessor::processFormulas($opt));
+                            // 用原始选项文本估算长度,避免公式渲染后的冗余DOM文本干扰列数判断
+                            $optText = strip_tags((string) $opt);
+                            $optText = preg_replace('/\\\\[a-zA-Z]+|[\\{\\}\\$\\^_]/u', '', $optText);
+                            $optText = preg_replace('/\s+/u', '', (string) $optText);
                             $maxOptionLength = max($maxOptionLength, mb_strlen($optText, 'UTF-8'));
                         }
 
@@ -256,12 +259,21 @@
                                     $label = strtoupper($optIndex);
                                 }
                                 // 【修复】根据是否已预处理决定处理方式
+                                $normalizedOpt = (string) $opt;
+                                // 选项内优先使用行内分式,避免 \dfrac 导致单个选项视觉突兀
+                                $normalizedOpt = str_replace('\\dfrac', '\\frac', $normalizedOpt);
+                                $normalizedOpt = str_replace('\\displaystyle', '', $normalizedOpt);
+                                // 清理来源HTML里可能携带的超大字号,避免单题选项异常放大
+                                $normalizedOpt = preg_replace('/font-size\s*:[^;"]+;?/iu', '', $normalizedOpt);
+                                $normalizedOpt = preg_replace('/line-height\s*:[^;"]+;?/iu', '', $normalizedOpt);
+                                $normalizedOpt = preg_replace('/style\s*=\s*([\'"])\s*\1/iu', '', $normalizedOpt);
+
                                 if ($mathProcessed) {
                                     // 已预处理:数据已包含处理好的 <img> 和公式,直接使用
-                                    $renderedOpt = $opt;
+                                    $renderedOpt = $normalizedOpt;
                                 } else {
                                     // 未预处理:先转义保护,processFormulas() 内部会解码并处理
-                                    $encodedOpt = htmlspecialchars($opt, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+                                    $encodedOpt = htmlspecialchars($normalizedOpt, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                                     $renderedOpt = \App\Services\MathFormulaProcessor::processFormulas($encodedOpt);
                                 }
                             @endphp

+ 35 - 5
resources/views/pdf/exam-grading.blade.php

@@ -153,14 +153,43 @@
         /* 单个选项:不分页 */
         .option {
             display: flex;
-            align-items: flex-start;
-            font-size: 14px;
+            align-items: baseline;
+            font-size: 13.2px;
             line-height: 1.6;
             page-break-inside: avoid;
             break-inside: avoid;
         }
-        .option strong { margin-right: 4px; }
-        .option-compact { font-size: 14px; line-height: 1.6; }
+        .option strong { margin-right: 4px; flex: 0 0 auto; line-height: 1.6; }
+        .option-compact { line-height: inherit; }
+        .option p, .option div { margin: 0; display: inline; }
+        .option .katex {
+            font-size: 1em !important;
+            vertical-align: 0;
+        }
+        /* 仅提升分式可读性(不放大整行选项) */
+        .option .katex .mfrac {
+            font-size: 1em !important;
+        }
+        /* 选项里的分子分母保持可读且不挤压横线 */
+        .option .katex .mfrac .mtight {
+            font-size: 1em !important;
+        }
+        /* 分数线稍加粗 */
+        .option .katex .frac-line {
+            border-bottom-width: 0.055em !important;
+        }
+        /* 自动化实测后的分式微调:分母下移、分子上移,避免贴线 */
+        .option .katex .mfrac .vlist > span:nth-child(1) {
+            transform: translateY(0.24em) !important;
+        }
+        .option .katex .mfrac .vlist > span:nth-child(3) {
+            transform: translateY(-0.16em) !important;
+        }
+        .option .katex-display {
+            display: inline;
+            margin: 0 !important;
+            vertical-align: baseline;
+        }
         /* 答案区域:不分页 */
         .answer-area {
             position: relative;
@@ -309,7 +338,8 @@
         .question-main .katex,
         .question-content .katex {
             font-size: 1em !important;
-            vertical-align: -0.04em;
+            /* 避免题干/解析中的行内公式整体下沉 */
+            vertical-align: 0;
         }
         .question-stem .katex-display,
         .question-main .katex-display,

+ 35 - 5
resources/views/pdf/exam-paper.blade.php

@@ -199,17 +199,46 @@
         /* 单个选项:不分页 */
         .option {
             width: 100%;
-            font-size: 14px;
+            font-size: 13.2px;
             line-height: 1.6;
             word-wrap: break-word;
             display: flex;
-            align-items: flex-start;
+            align-items: baseline;
             page-break-inside: avoid;
             break-inside: avoid;
         }
-        .option strong { margin-right: 4px; }
+        .option strong { margin-right: 4px; flex: 0 0 auto; line-height: 1.6; }
         .option-inline { display: inline-flex; align-items: baseline; margin-right: 20px; }
-        .option-compact { font-size: 14px; line-height: 1.6; }
+        .option-compact { line-height: inherit; }
+        .option p, .option div { margin: 0; display: inline; }
+        .option .katex {
+            font-size: 1em !important;
+            vertical-align: 0;
+        }
+        /* 仅提升分式可读性(不放大整行选项) */
+        .option .katex .mfrac {
+            font-size: 1em !important;
+        }
+        /* 选项里的分子分母保持可读且不挤压横线 */
+        .option .katex .mfrac .mtight {
+            font-size: 1em !important;
+        }
+        /* 分数线稍加粗 */
+        .option .katex .frac-line {
+            border-bottom-width: 0.055em !important;
+        }
+        /* 自动化实测后的分式微调:分母下移、分子上移,避免贴线 */
+        .option .katex .mfrac .vlist > span:nth-child(1) {
+            transform: translateY(0.24em) !important;
+        }
+        .option .katex .mfrac .vlist > span:nth-child(3) {
+            transform: translateY(-0.16em) !important;
+        }
+        .option .katex-display {
+            display: inline;
+            margin: 0 !important;
+            vertical-align: baseline;
+        }
         /* 答案元信息:不分页 */
         .answer-meta {
             font-size: 12px;
@@ -402,7 +431,8 @@
         .question-main .katex,
         .question-content .katex {
             font-size: 1em !important;
-            vertical-align: -0.04em;
+            /* 避免题干/解析中的行内公式整体下沉 */
+            vertical-align: 0;
         }
         .question-stem .katex-display,
         .question-main .katex-display,

+ 17 - 2
scripts/katex-render.mjs

@@ -85,6 +85,17 @@ function encodeHtmlEntities(text) {
  * 渲染 HTML 中的所有数学公式
  */
 function renderMathInHtml(html) {
+    // 保护 script/style/pre/textarea,避免把JS/CSS代码里的 $...$ 误当成公式
+    const protectedBlocks = [];
+    const protectedHtml = html.replace(
+        /<(script|style|textarea|pre)\b[\s\S]*?<\/\1>/gi,
+        (block) => {
+            const marker = `__KATEX_PROTECTED_BLOCK_${protectedBlocks.length}__`;
+            protectedBlocks.push(block);
+            return marker;
+        }
+    );
+
     // 定界符配置(按优先级排序)
     const delimiters = [
         { left: '$$', right: '$$', display: true },
@@ -93,14 +104,18 @@ function renderMathInHtml(html) {
         { left: '$', right: '$', display: false },
     ];
 
-    let result = html;
+    let result = protectedHtml;
 
     // 按顺序处理每种定界符
     for (const delimiter of delimiters) {
         result = processDelimiter(result, delimiter.left, delimiter.right, delimiter.display);
     }
 
-    return result;
+    // 恢复被保护的代码块
+    return result.replace(/__KATEX_PROTECTED_BLOCK_(\d+)__/g, (_, idx) => {
+        const i = Number(idx);
+        return Number.isInteger(i) && protectedBlocks[i] ? protectedBlocks[i] : '';
+    });
 }
 
 /**