Jelajahi Sumber

fix(pdf): refine answer-subquestion layout and mark-box counting

Improve answer-stem subquestion line breaking, unify exam-paper core stem styles, and align scan-sheet box counts to use stem-derived minimums with explicit-step support while keeping question-check response and file naming concise.

Made-with: Cursor
yemeishu 1 bulan lalu
induk
melakukan
9fffb44c6b

+ 2 - 2
app/Http/Controllers/Api/QuestionPdfController.php

@@ -57,7 +57,7 @@ class QuestionPdfController extends Controller
         }
 
         $questionIds = $request->input('question_ids');
-        $studentId = (string) $request->input('student_id', 'question-check');
+        $studentId = (string) $request->input('student_id', 'question_check');
         $studentName = $request->input('student_name', '________');
         $studentGrade = $request->input('student_grade', '________');
         $teacherName = $request->input('teacher_name', '________');
@@ -238,7 +238,7 @@ class QuestionPdfController extends Controller
         }
 
         // Generate unique paper ID
-        $paperId = 'custom_' . $studentId . '_' . time() . '_' . uniqid();
+        $paperId = $studentId . '_' . time() . '_' . uniqid();
 
         return (object) [
             'paper_id' => $paperId,

+ 5 - 2
app/Services/ExamPdfExportService.php

@@ -3150,7 +3150,7 @@ class ExamPdfExportService
                 return [];
             }
 
-            $path = 'custom_exams/'.($paper->paper_id ?? ('custom_'.time())).'_question_check.pdf';
+            $path = 'custom_exams/'.($paper->paper_id ?? ('custom_'.time())).'.pdf';
             $url = $this->pdfStorageService->put($path, $pdfBinary);
             if (! $url) {
                 Log::error('generateQuestionCheckPdf: 上传失败', [
@@ -3161,7 +3161,10 @@ class ExamPdfExportService
                 return [];
             }
 
-            return ['pdf_url' => $url];
+            return [
+                'pdf_url' => $url,
+                'grading_pdf_url' => $url,
+            ];
         } catch (\Throwable $e) {
             Log::error('generateQuestionCheckPdf 失败', [
                 'paper_id' => $paper->paper_id ?? null,

+ 66 - 12
app/Support/GradingMarkBoxCounter.php

@@ -15,24 +15,78 @@ class GradingMarkBoxCounter
         return max(1, $count);
     }
 
-    public function countAnswerSteps(?string $text): int
+    public function countAnswerSteps(?string $text, ?string $stem = null): int
     {
-        $text = (string) $text;
-        $stepPattern = '(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?|第\s*[0-9一二三四五六七八九十百零两]+\s*步\s*[::]?)';
+        $stepCount = $this->countExplicitAnswerSteps($text);
+        if ($stepCount > 0) {
+            return $stepCount;
+        }
+
+        return $this->inferSubQuestionCount($stem);
+    }
+
+    /**
+     * 判题卡解答题方框数:
+     * - 优先按题干小题编号系列计数((1)(2)...)
+     * - 若不存在系列小题,则固定 1 框(避免被解析步骤数放大)
+     */
+    public function countAnswerMarkBoxes(?string $stem, ?string $solution = null): int
+    {
+        $minFromStem = $this->inferSubQuestionCount($stem);
+        $stepCount = $this->countExplicitAnswerSteps($solution);
+
+        return max($minFromStem, $stepCount > 0 ? $stepCount : 1);
+    }
+
+    /**
+     * 仅识别“显式步骤标记”并计数:
+     * - 步骤1: / 步骤一:
+     * - 第1步: / 第一步:
+     * 要求位于段首(行首或标点后),降低误判。
+     */
+    private function countExplicitAnswerSteps(?string $text): int
+    {
+        $text = trim((string) $text);
+        if ($text === '') {
+            return 0;
+        }
+
+        $text = preg_replace('/<br\s*\/?>/iu', "\n", $text) ?? $text;
+        $stepLabelPattern = '(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?)';
+        $anchorPattern = '/(?:^|[\r\n。;;!?!?])\s*' . $stepLabelPattern . '/u';
+        preg_match_all($anchorPattern, $text, $matches);
+        $count = count($matches[0] ?? []);
 
-        if (!preg_match('/' . $stepPattern . '/u', $text)) {
+        return max(0, $count);
+    }
+
+    /**
+     * 当解析里没有步骤时,按题干中的小题“系列编号”估算判题卡方框数。
+     * 仅在至少出现 2 个编号时生效,避免误把 f(1) 这类数学表达当作小题。
+     */
+    private function inferSubQuestionCount(?string $stem): int
+    {
+        $stem = (string) $stem;
+        if ($stem === '') {
             return 1;
         }
 
-        $parts = preg_split('/(?=' . $stepPattern . ')/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
-        $count = 0;
-        foreach ($parts as $part) {
-            $stepText = trim((string) $part);
-            if ($stepText !== '' && preg_match('/^' . $stepPattern . '/u', $stepText)) {
-                $count++;
-            }
+        preg_match_all('/[((][1-9][0-9]*[))]/u', $stem, $allMarkers);
+        $markerCount = count($allMarkers[0] ?? []);
+        if ($markerCount < 2) {
+            return 1;
         }
 
-        return max(1, $count);
+        // 与题干显示层保持一致:把小题起始语境标准化到 <br>(n)
+        $normalized = preg_replace('/^\s*([((][1-9][0-9]*[))])\s*/u', '$1 ', $stem) ?? $stem;
+        $normalized = preg_replace('/([。;;!?!?::.])\s*([((][1-9][0-9]*[))])\s*/u', '$1<br>$2 ', $normalized) ?? $normalized;
+        $normalized = preg_replace('/(?:(?:\\\\r\\\\n|\\\\n)|(?:\r?\n)|(?:<br\s*\/?>)|\s)+\s*([((][1-9][0-9]*[))])\s*/u', '<br>$1 ', $normalized) ?? $normalized;
+        $normalized = preg_replace('/(求出|求解|求|写出|计算|证明|判断|化简)\s*([((][1-9][0-9]*[))])\s*/u', '$1<br>$2 ', $normalized) ?? $normalized;
+
+        // 统计“行首或换行后”的编号数量,作为子题数量
+        preg_match_all('/(?:^|<br>\s*)([((][1-9][0-9]*[))])/u', $normalized, $series);
+        $seriesCount = count($series[1] ?? []);
+
+        return max(1, $seriesCount);
     }
 }

+ 19 - 1
resources/views/components/exam/paper-body.blade.php

@@ -407,6 +407,24 @@
         @php
             // 【修复】使用question_number字段作为显示序号,确保全局序号一致性
             $questionNumber = $q->question_number ?? (count($choiceQuestions) + count($fillQuestions) + $index + 1);
+            // 解答题小题排版优化(仅在小题编号语境下换行,避免误伤 f(1) 这类函数表达)
+            $answerStem = (string) ($q->content ?? '');
+            preg_match_all('/[((][1-9][0-9]*[))]/u', $answerStem, $subQuestionMatches);
+            $subQuestionCount = count($subQuestionMatches[0] ?? []);
+            // 前提:只有出现“至少两个小题编号(形成系列)”才做自动换行
+            if ($subQuestionCount >= 2) {
+                // 开头的 (1)/(2) 不额外插入换行,只做标准化空格
+                $answerStem = preg_replace('/^\s*([((][1-9][0-9]*[))])\s*/u', '$1 ', $answerStem) ?? $answerStem;
+                // 句读后接小题编号:断行
+                $answerStem = preg_replace('/([。;;!?!?::.])\s*([((][1-9][0-9]*[))])\s*/u', '$1<br>$2 ', $answerStem) ?? $answerStem;
+                // 换行簇(实际换行、字面量\\n、已有<br>)后接小题编号:统一压成单个 <br>
+                $answerStem = preg_replace('/(?:(?:\\\\r\\\\n|\\\\n)|(?:\r?\n)|(?:<br\s*\/?>)|\s)+\s*([((][1-9][0-9]*[))])\s*/u', '<br>$1 ', $answerStem) ?? $answerStem;
+                // 关键词引导的小题也换行,如 “求(1)…(2)… / 写出(1)…”
+                $answerStem = preg_replace('/(求出|求解|求|写出|计算|证明|判断|化简)\s*([((][1-9][0-9]*[))])\s*/u', '$1<br>$2 ', $answerStem) ?? $answerStem;
+            }
+            $answerStemRendered = $mathProcessed
+                ? $answerStem
+                : \App\Services\MathFormulaProcessor::processFormulas($answerStem);
         @endphp
         <div class="question">
             <div class="question-grid">
@@ -425,7 +443,7 @@
                                 @endif
                             </span>
                         @endunless
-                        <span class="question-stem">{!! $mathProcessed ? $q->content : \App\Services\MathFormulaProcessor::processFormulas($q->content) !!}</span>
+                        <span class="question-stem">{!! $answerStemRendered !!}</span>
                     </div>
                 @endif
                 @unless($gradingMode)

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

@@ -98,43 +98,7 @@
             align-items: center;
             justify-content: center;
         }
-        /* 大题标题:不与后面内容分开 */
-        .section-title {
-            font-size: 16px;
-            font-weight: bold;
-            margin-top: 20px;
-            margin-bottom: 10px;
-            page-break-after: avoid;
-            break-after: avoid;
-        }
-        /* 题目整体:不分页 */
-        .question {
-            margin-bottom: 15px;
-            page-break-inside: avoid;
-            break-inside: avoid;
-            -webkit-column-break-inside: avoid;
-        }
-        /* 题目网格:不分页 */
-        .question-grid {
-            display: grid;
-            grid-template-columns: auto 1fr;
-            column-gap: 4px;
-            row-gap: 6px;
-            align-items: flex-start;
-            page-break-inside: avoid;
-            break-inside: avoid;
-        }
-        .question-lead {
-            display: flex;
-            gap: 4px;
-            align-items: flex-start;
-            font-weight: 600;
-            font-size: 14px;
-            line-height: 1.65;
-            margin-top: 1px;
-        }
-        .question-lead.spacer { visibility: hidden; }
-        .question-number { white-space: nowrap; margin-right: 2px; }
+        @include('pdf.partials.paper-body-core-styles')
         .grading-boxes { gap: 4px; flex-wrap: wrap; align-items: center; }
         .grading-boxes span { vertical-align: middle; }
         .question-main { font-size: 14px; line-height: 1.65; font-family: inherit; display: block; }

+ 5 - 2
resources/views/pdf/partials/grading-scan-sheet.blade.php

@@ -3,7 +3,7 @@
     // 按当前试卷真实题目动态生成判题卡条目(题量与方框数)
     $scanSheetItems = [];
     $countBlanks = fn($text): int => $boxCounter->countFillBlanks($text);
-    $countSteps = fn($text): int => $boxCounter->countAnswerSteps($text);
+    $countAnswerBoxes = fn($stem = '', $solution = ''): int => $boxCounter->countAnswerMarkBoxes($stem, $solution);
 
     foreach (($questions['choice'] ?? []) as $q) {
         $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => 1];
@@ -12,7 +12,10 @@
         $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => $countBlanks($q->content ?? '')];
     }
     foreach (($questions['answer'] ?? []) as $q) {
-        $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => $countSteps($q->solution ?? '')];
+        $scanSheetItems[] = [
+            'no' => (int) ($q->question_number ?? 0),
+            'box_count' => $countAnswerBoxes($q->content ?? '', $q->solution ?? ''),
+        ];
     }
 
     usort($scanSheetItems, static function ($a, $b) {

+ 5 - 2
resources/views/pdf/partials/question-check-scan-sheet.blade.php

@@ -2,7 +2,7 @@
     $boxCounter = app(\App\Support\GradingMarkBoxCounter::class);
     $scanSheetItems = [];
     $countBlanks = fn($text): int => $boxCounter->countFillBlanks($text);
-    $countSteps = fn($text): int => $boxCounter->countAnswerSteps($text);
+    $countAnswerBoxes = fn($stem = '', $solution = ''): int => $boxCounter->countAnswerMarkBoxes($stem, $solution);
 
     foreach (($questions['choice'] ?? []) as $q) {
         $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => 1];
@@ -11,7 +11,10 @@
         $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => $countBlanks($q->content ?? '')];
     }
     foreach (($questions['answer'] ?? []) as $q) {
-        $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => $countSteps($q->solution ?? '')];
+        $scanSheetItems[] = [
+            'no' => (int) ($q->question_number ?? 0),
+            'box_count' => $countAnswerBoxes($q->content ?? '', $q->solution ?? ''),
+        ];
     }
 
     usort($scanSheetItems, static function ($a, $b) {