Explorar o código

feat(pdf): add grading scan sheet and fix unified style merge

yemeishu hai 3 semanas
pai
achega
40454c5ce4

+ 1 - 0
.env.example

@@ -85,3 +85,4 @@ MATHRECSYS_TIMEOUT=30
 # PDF 配置
 EXAM_PDF_SHOW_QUESTION_ID=false
 EXAM_PDF_GRADING_SHOW_STEM=true
+EXAM_PDF_GRADING_APPEND_SCAN_SHEET=false

+ 24 - 8
app/Services/ExamPdfExportService.php

@@ -1506,10 +1506,18 @@ class ExamPdfExportService
 
             // 提取head内容(保留原始样式和meta信息)
             $examHead = $this->extractHeadContent($examHtml);
+            $gradingHead = $this->extractHeadContent($gradingHtml);
             $kpExplainHead = $kpExplainHtml ? $this->extractHeadContent($kpExplainHtml) : null;
 
             // 构建统一HTML文档(保留原始结构)
-            $unifiedHtml = $this->buildUnifiedHtmlWithOriginalStructure($examHead, $examBody, $gradingBody, $kpExplainBody, $kpExplainHead);
+            $unifiedHtml = $this->buildUnifiedHtmlWithOriginalStructure(
+                $examHead,
+                $examBody,
+                $gradingBody,
+                $gradingHead,
+                $kpExplainBody,
+                $kpExplainHead
+            );
 
             Log::info('HTML合并成功(保留原始样式)', [
                 'exam_length' => strlen($examBody),
@@ -1596,7 +1604,14 @@ class ExamPdfExportService
      * 【优化重构】构建统一的HTML文档(容器结构 + 消除空白页)
      * 使用容器结构替代空的 page-break div,避免中间出现空白页
      */
-    private function buildUnifiedHtmlWithOriginalStructure(string $examHead, string $examBody, string $gradingBody, ?string $kpExplainBody = null, ?string $kpExplainHead = null): string
+    private function buildUnifiedHtmlWithOriginalStructure(
+        string $examHead,
+        string $examBody,
+        string $gradingBody,
+        string $gradingHead,
+        ?string $kpExplainBody = null,
+        ?string $kpExplainHead = null
+    ): string
     {
         // 清洗内容:移除可能存在的分页符,避免双重分页
         $examBody = $this->stripPageBreakElements($examBody);
@@ -1605,15 +1620,16 @@ class ExamPdfExportService
             $kpExplainBody = $this->stripPageBreakElements($kpExplainBody);
         }
 
-        // 合并 head 内容:试卷 head + 知识点讲解 head(去重)+ 分页控制样式
+        // 合并 head 内容:试卷 head + 判卷 head + 知识点讲解 head(去重)+ 分页控制样式
         $mergedHead = $examHead;
 
-        // 如果有知识点讲解 head,合并样式(避免重复)
-        if ($kpExplainHead) {
-            // 提取知识点讲解中的 <style> 内容并追加
-            if (preg_match_all('/<style[^>]*>(.*?)<\/style>/is', $kpExplainHead, $styleMatches)) {
+        // 按顺序合并其他 head 里的 <style>(避免重复)
+        foreach ([$gradingHead, $kpExplainHead] as $extraHead) {
+            if (! $extraHead) {
+                continue;
+            }
+            if (preg_match_all('/<style[^>]*>(.*?)<\/style>/is', $extraHead, $styleMatches)) {
                 foreach ($styleMatches[0] as $idx => $styleTag) {
-                    // 避免重复添加相同的样式
                     if (! str_contains($mergedHead, $styleMatches[1][$idx])) {
                         $mergedHead .= "\n    ".$styleTag;
                     }

+ 10 - 0
config/exam.php

@@ -22,4 +22,14 @@ return [
     |
     */
     'pdf_grading_show_stem' => env('EXAM_PDF_GRADING_SHOW_STEM', true),
+
+    /*
+    |--------------------------------------------------------------------------
+    | 判卷PDF追加扫描答题卡页
+    |--------------------------------------------------------------------------
+    |
+    | 控制是否在判卷PDF末尾追加一页用于扫描识别的答题卡。
+    |
+    */
+    'pdf_grading_append_scan_sheet' => env('EXAM_PDF_GRADING_APPEND_SCAN_SHEET', false),
 ];

+ 13 - 0
resources/views/pdf/exam-grading.blade.php

@@ -7,6 +7,8 @@
     $studentName = $student['name'] ?? ($paper->student_id ?? '________');
     // 生成时间(格式:2026年01月30日 15:04:05)
     $generateDateTime = now()->format('Y年m月d日 H:i:s');
+    // 是否在判卷PDF末尾追加扫描判题卡
+    $appendScanSheet = config('exam.pdf_grading_append_scan_sheet', false);
 @endphp
 <!DOCTYPE html>
 <html lang="zh-CN">
@@ -316,6 +318,8 @@
         .question-content .katex-display {
             margin: 0.35em 0 !important;
         }
+
+        @include('pdf.partials.grading-scan-sheet-styles')
     </style>
 </head>
 <body style="page-break-before: always;">
@@ -335,6 +339,15 @@
     @include('components.exam.paper-body', ['questions' => $questions, 'grading' => true])
     </div>
 
+    @if($appendScanSheet)
+        @include('pdf.partials.grading-scan-sheet', [
+            'questions' => $questions,
+            'gradingCode' => $gradingCode,
+            'teacher' => $teacher,
+            'student' => $student,
+        ])
+    @endif
+
 
     <!-- KaTeX -->
     <script src="/js/katex.min.js"></script>

+ 56 - 0
resources/views/pdf/partials/common-styles.blade.php

@@ -365,4 +365,60 @@
     .katex-display {
         margin: 0.35em 0 !important;
     }
+
+    /* 扫描判题卡页(统一PDF时样式需在公共样式中存在) */
+    .scan-sheet-page {
+        page-break-before: always;
+        break-before: page;
+    }
+    .scan-sheet-header {
+        text-align: center;
+        margin-bottom: 1.5rem;
+        border-bottom: 2px solid #000;
+        padding-bottom: 1rem;
+    }
+    .scan-sheet-hint {
+        font-size: 13px;
+        color: #444;
+        margin-bottom: 10px;
+        line-height: 1.5;
+    }
+    .scan-sheet-list {
+        display: grid;
+        grid-template-columns: 1fr;
+        gap: 6px;
+    }
+    .scan-sheet-item {
+        border: 1px solid #b5b5b5;
+        border-radius: 4px;
+        padding: 6px 8px;
+        display: grid;
+        grid-template-columns: auto auto 1fr;
+        align-items: center;
+        column-gap: 8px;
+        font-size: 13px;
+        line-height: 1.2;
+        page-break-inside: avoid;
+        break-inside: avoid;
+    }
+    .scan-grade-box {
+        width: 17px;
+        height: 17px;
+        border: 1px solid #333;
+        display: inline-block;
+        vertical-align: middle;
+        box-sizing: border-box;
+    }
+    .scan-sheet-no {
+        font-weight: 700;
+        text-align: center;
+        min-width: 44px;
+    }
+    .scan-sheet-marks {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+        justify-content: flex-start;
+        white-space: nowrap;
+    }
 </style>

+ 55 - 0
resources/views/pdf/partials/grading-scan-sheet-styles.blade.php

@@ -0,0 +1,55 @@
+/* 扫描判题卡页 */
+.scan-sheet-page {
+    page-break-before: always;
+    break-before: page;
+}
+.scan-sheet-header {
+    text-align: center;
+    margin-bottom: 1.5rem;
+    border-bottom: 2px solid #000;
+    padding-bottom: 1rem;
+}
+.scan-sheet-hint {
+    font-size: 13px;
+    color: #444;
+    margin-bottom: 10px;
+    line-height: 1.5;
+}
+.scan-sheet-list {
+    display: grid;
+    grid-template-columns: 1fr;
+    gap: 6px;
+}
+.scan-sheet-item {
+    border: 1px solid #b5b5b5;
+    border-radius: 4px;
+    padding: 6px 8px;
+    display: grid;
+    grid-template-columns: auto auto 1fr;
+    align-items: center;
+    column-gap: 8px;
+    font-size: 13px;
+    line-height: 1.2;
+    page-break-inside: avoid;
+    break-inside: avoid;
+}
+.scan-grade-box {
+    width: 17px;
+    height: 17px;
+    border: 1px solid #333;
+    display: inline-block;
+    vertical-align: middle;
+    box-sizing: border-box;
+}
+.scan-sheet-no {
+    font-weight: 700;
+    text-align: center;
+    min-width: 44px;
+}
+.scan-sheet-marks {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    justify-content: flex-start;
+    white-space: nowrap;
+}

+ 62 - 0
resources/views/pdf/partials/grading-scan-sheet.blade.php

@@ -0,0 +1,62 @@
+@php
+    // 判题卡项目:保留题号与题目对应方框数(用于OCR锚点+判卷标记)
+    $scanSheetItems = [];
+    $countBlanks = function ($text) {
+        $text = (string)$text;
+        // 与判卷正文保持一致:下划线、中文空括号、英文空括号
+        $count = 0;
+        $count += preg_match_all('/_{2,}/u', $text, $m);
+        $count += preg_match_all('/(\s*)/u', $text, $m);
+        $count += preg_match_all('/\(\s*\)/', $text, $m);
+        return max(1, $count);
+    };
+    $countSteps = function ($text) {
+        $text = (string)$text;
+        // 与判卷正文保持一致:支持“步骤1”与“第1步”两种写法
+        $count = preg_match_all('/(步骤\s*\d+|第\s*\d+\s*步)/u', $text, $m);
+        return max(1, $count);
+    };
+
+    foreach (($questions['choice'] ?? []) as $q) {
+        $scanSheetItems[] = ['no' => (int)($q->question_number ?? 0), 'box_count' => 1];
+    }
+    foreach (($questions['fill'] ?? []) as $q) {
+        $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 ?? '')];
+    }
+
+    usort($scanSheetItems, function ($a, $b) {
+        return ($a['no'] <=> $b['no']);
+    });
+    // 每页容量不是上限20,而是保证至少可容纳20题,并尽量提高单页承载
+    $scanSheetPerPage = 24;
+    $scanSheetPages = array_chunk($scanSheetItems, $scanSheetPerPage);
+@endphp
+@foreach($scanSheetPages as $pageIndex => $scanSheetPageItems)
+    <div class="page scan-sheet-page" style="page-break-before: always; break-before: page; width:100%; max-width:100%; margin:0 auto; padding:0 8px; box-sizing:border-box;">
+        <div class="scan-sheet-header" style="text-align:center;margin-bottom:1.5rem;border-bottom:2px solid #000;padding-bottom:1rem;">
+            <div style="font-size:22px;font-weight:bold;">判题卡</div>
+            <div style="display:flex;justify-content:space-between;font-size:14px;margin-top:8px;">
+                <span>老师:{{ $teacher['name'] ?? '________' }}</span>
+                <span>年级:@formatGrade($student['grade'] ?? '________')</span>
+                <span>姓名:{{ $student['name'] ?? '________' }}</span>
+                <span>得分:________</span>
+            </div>
+        </div>
+        <div class="scan-sheet-hint" style="font-size:13px;color:#444;margin-bottom:10px;line-height:1.5;">提示:请根据答案和解析进行批改,在回答正确的 □ 前划 / ,在回答错误的 □ 前打 X 或置空</div>
+        <div class="scan-sheet-list" style="display:grid;grid-template-columns:minmax(0,1fr);gap:4px;width:100%;box-sizing:border-box;">
+            @foreach($scanSheetPageItems as $scanItem)
+                <div class="scan-sheet-item" style="border:1px solid #b5b5b5;border-radius:4px;padding:4px 8px;min-height:28px;display:grid;grid-template-columns:auto 1fr;align-items:center;column-gap:8px;font-size:13px;line-height:1.2;page-break-inside:avoid;break-inside:avoid;width:100%;box-sizing:border-box;">
+                    <span class="scan-sheet-no" style="font-weight:700;text-align:center;min-width:58px;">题目 {{ $scanItem['no'] > 0 ? $scanItem['no'] : ($pageIndex * $scanSheetPerPage + $loop->iteration) }}.</span>
+                    <span class="scan-sheet-marks" style="display:flex;align-items:center;gap:4px 6px;justify-content:flex-start;flex-wrap:wrap;max-width:100%;">
+                        @for($i = 0; $i < max(1, (int)($scanItem['box_count'] ?? 1)); $i++)
+                            <span class="scan-grade-box" style="width:17px;height:17px;border:1px solid #333;display:inline-block;vertical-align:middle;box-sizing:border-box;"></span>
+                        @endfor
+                    </span>
+                </div>
+            @endforeach
+        </div>
+    </div>
+@endforeach