Bladeren bron

merge: ye/fix-judge-card-template-geometry

yemeishu 2 weken geleden
bovenliggende
commit
e25f85710d

+ 39 - 0
app/Console/Commands/GenerateJudgeCardTemplateCommand.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Support\JudgeCardTemplateBuilder;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\File;
+
+class GenerateJudgeCardTemplateCommand extends Command
+{
+    protected $signature = 'exam:generate-judge-card-template {--path= : 输出文件路径,默认项目根目录 judge_card.template.json}';
+
+    protected $description = '根据判卷卡排版参数生成 Python 识别模板 JSON';
+
+    public function handle(JudgeCardTemplateBuilder $builder): int
+    {
+        $outputPath = (string) ($this->option('path') ?: base_path('judge_card.template.json'));
+        $directory = dirname($outputPath);
+
+        if (! is_dir($directory)) {
+            File::ensureDirectoryExists($directory);
+        }
+
+        $template = $builder->build();
+        $json = json_encode($template, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+        if ($json === false) {
+            $this->error('生成 judge_card.template.json 失败:JSON 编码错误');
+
+            return self::FAILURE;
+        }
+
+        File::put($outputPath, $json.PHP_EOL);
+        $this->info("判卷卡模板已生成:{$outputPath}");
+
+        return self::SUCCESS;
+    }
+}
+

+ 36 - 0
app/Support/GradingMarkBoxCounter.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Support;
+
+class GradingMarkBoxCounter
+{
+    public function countFillBlanks(?string $text): int
+    {
+        $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);
+    }
+
+    public function countAnswerSteps(?string $text): int
+    {
+        $text = (string) $text;
+        if (!preg_match('/(步骤\s*\d+|第\s*\d+\s*步)/u', $text)) {
+            return 1;
+        }
+
+        $parts = preg_split('/(?=步骤\s*\d+|第\s*\d+\s*步)/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+        $count = 0;
+        foreach ($parts as $part) {
+            if (trim((string) $part) !== '') {
+                $count++;
+            }
+        }
+
+        return max(1, $count);
+    }
+}
+

+ 106 - 0
app/Support/JudgeCardTemplateBuilder.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Support;
+
+class JudgeCardTemplateBuilder
+{
+    private const TOTAL_QUESTIONS = 20;
+
+    public function build(): array
+    {
+        $template = config('exam.judge_card_template', []);
+
+        $page = [
+            'width' => (int) data_get($template, 'page.width', 2480),
+            'height' => (int) data_get($template, 'page.height', 3508),
+            'dpi' => (int) data_get($template, 'page.dpi', 300),
+            'margin_top' => (int) data_get($template, 'page.margin_top', 260),
+            'margin_right' => (int) data_get($template, 'page.margin_right', 236),
+            'margin_bottom' => (int) data_get($template, 'page.margin_bottom', 272),
+            'margin_left' => (int) data_get($template, 'page.margin_left', 236),
+        ];
+        $box = [
+            'width' => (int) data_get($template, 'box.width', 30),
+            'height' => (int) data_get($template, 'box.height', 30),
+        ];
+        $layout = [
+            'start_x' => (int) data_get($template, 'layout.start_x', 522),
+            'start_y' => (int) data_get($template, 'layout.start_y', 709),
+            'row_height' => (int) data_get($template, 'layout.row_height', 100),
+            'col_spacing' => (int) data_get($template, 'layout.col_spacing', 72),
+            'header_top_offset' => (int) data_get($template, 'layout.header_top_offset', 0),
+            'row_left_x' => (int) data_get($template, 'layout.row_left_x', 244),
+            'row_width' => (int) data_get($template, 'layout.row_width', 1992),
+            'row_padding_top' => (int) data_get($template, 'layout.row_padding_top', 8),
+            'row_padding_bottom' => (int) data_get($template, 'layout.row_padding_bottom', 8),
+            'label_x' => (int) data_get($template, 'layout.label_x', 260),
+            'label_width' => (int) data_get($template, 'layout.label_width', 180),
+            'label_to_box_gap' => (int) data_get($template, 'layout.label_to_box_gap', 16),
+        ];
+        $questions = self::TOTAL_QUESTIONS;
+        $questionBoxCounts = $this->buildQuestionBoxCountsByRule($questions);
+
+        return [
+            'page' => $page,
+            'box' => $box,
+            'layout' => $layout,
+            'questions' => $questions,
+            'question_box_counts' => $questionBoxCounts,
+            'mark_rules' => [
+                'correct' => array_values((array) data_get($template, 'mark_rules.correct', ['/', '\\'])),
+                'wrong' => array_values((array) data_get($template, 'mark_rules.wrong', ['X'])),
+                'blank_is_wrong' => (bool) data_get($template, 'mark_rules.blank_is_wrong', true),
+            ],
+            'question_boxes' => $this->buildQuestionBoxes($questions, $questionBoxCounts, $layout, $box),
+        ];
+    }
+
+    private function buildQuestionBoxCountsByRule(int $questions): array
+    {
+        $result = ['default' => 1];
+        for ($q = 1; $q <= $questions; $q++) {
+            $count = $this->resolveBoxCountByRule($q);
+            if ($count !== 1) {
+                $result[(string) $q] = $count;
+            }
+        }
+
+        return $result;
+    }
+
+    private function buildQuestionBoxes(int $questions, array $questionBoxCounts, array $layout, array $box): array
+    {
+        $result = [];
+        $defaultCount = (int) ($questionBoxCounts['default'] ?? 1);
+
+        for ($q = 1; $q <= $questions; $q++) {
+            $count = (int) ($questionBoxCounts[(string) $q] ?? $defaultCount);
+            $boxes = [];
+            $y = (int) $layout['start_y'] + (($q - 1) * (int) $layout['row_height']);
+
+            for ($i = 0; $i < $count; $i++) {
+                $boxes[] = [
+                    'box_index' => $i,
+                    'x' => (int) $layout['start_x'] + ($i * (int) $layout['col_spacing']),
+                    'y' => $y,
+                    'width' => (int) $box['width'],
+                    'height' => (int) $box['height'],
+                ];
+            }
+
+            $result[] = [
+                'question' => $q,
+                'box_count' => $count,
+                'boxes' => $boxes,
+            ];
+        }
+
+        return $result;
+    }
+
+    private function resolveBoxCountByRule(int $questionNo): int
+    {
+        // 固定规则:17、18题双框,其余单框
+        return in_array($questionNo, [17, 18], true) ? 2 : 1;
+    }
+}

+ 1 - 0
bootstrap/app.php

@@ -18,6 +18,7 @@ return Application::configure(basePath: dirname(__DIR__))
         \App\Console\Commands\BackfillQuestionMetaCommand::class,
         \App\Console\Commands\SyncQuestionAssetsCommand::class,
         \App\Console\Commands\SyncQuestionsFromQuestionBank::class,
+        \App\Console\Commands\GenerateJudgeCardTemplateCommand::class,
     ])
     ->withMiddleware(function (Middleware $middleware): void {
         // 信任所有代理,允许读取 X-Forwarded-* 头

+ 48 - 0
config/exam.php

@@ -42,4 +42,52 @@ return [
     |
     */
     'pdf_grading_append_scan_sheet' => env('EXAM_PDF_GRADING_APPEND_SCAN_SHEET', false),
+
+    /*
+    |--------------------------------------------------------------------------
+    | 判卷卡识别模板参数(供 Python/OpenCV 使用)
+    |--------------------------------------------------------------------------
+    |
+    | Laravel 侧仅负责输出几何模板参数,不负责识别逻辑。
+    | 使用 artisan 命令 `exam:generate-judge-card-template` 生成 JSON 文件。
+    |
+    */
+    'judge_card_template' => [
+        'page' => [
+            'width' => 2480,
+            'height' => 3508,
+            'dpi' => 300,
+            // 与 @page 一致的页边距(单位:px@300DPI)
+            'margin_top' => 260,      // 2.2cm
+            'margin_right' => 236,    // 2.0cm
+            'margin_bottom' => 272,   // 2.3cm
+            'margin_left' => 236,     // 2.0cm
+        ],
+        'box' => [
+            'width' => 66,
+            'height' => 66,
+        ],
+        'layout' => [
+            // 第1题第1个方框左上角(300DPI像素坐标)
+            'start_x' => 286,
+            'start_y' => 650,
+            // 相邻题目的纵向步进、同题多框横向步进(300DPI像素)
+            'row_height' => 126,
+            'col_spacing' => 90,
+            // 判题卡视觉排版参数(同样基于300DPI像素,供页面渲染与JSON对齐)
+            'header_top_offset' => -30,
+            'row_left_x' => 8,
+            'row_width' => 2464,
+            'row_padding_top' => 10,
+            'row_padding_bottom' => 10,
+            'label_x' => 24,
+            'label_width' => 210,
+            'label_to_box_gap' => 20,
+        ],
+        'mark_rules' => [
+            'correct' => ['/', '\\'],
+            'wrong' => ['X'],
+            'blank_is_wrong' => true,
+        ],
+    ],
 ];

+ 321 - 0
judge_card.template.json

@@ -0,0 +1,321 @@
+{
+    "page": {
+        "width": 2480,
+        "height": 3508,
+        "dpi": 300,
+        "margin_top": 260,
+        "margin_right": 236,
+        "margin_bottom": 272,
+        "margin_left": 236
+    },
+    "box": {
+        "width": 66,
+        "height": 66
+    },
+    "layout": {
+        "start_x": 286,
+        "start_y": 650,
+        "row_height": 126,
+        "col_spacing": 90,
+        "header_top_offset": -30,
+        "row_left_x": 8,
+        "row_width": 2464,
+        "row_padding_top": 10,
+        "row_padding_bottom": 10,
+        "label_x": 24,
+        "label_width": 210,
+        "label_to_box_gap": 20
+    },
+    "questions": 20,
+    "question_box_counts": {
+        "default": 1,
+        "17": 2,
+        "18": 2
+    },
+    "mark_rules": {
+        "correct": [
+            "/",
+            "\\"
+        ],
+        "wrong": [
+            "X"
+        ],
+        "blank_is_wrong": true
+    },
+    "question_boxes": [
+        {
+            "question": 1,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 650,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 2,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 776,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 3,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 902,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 4,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 1028,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 5,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 1154,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 6,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 1280,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 7,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 1406,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 8,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 1532,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 9,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 1658,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 10,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 1784,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 11,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 1910,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 12,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 2036,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 13,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 2162,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 14,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 2288,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 15,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 2414,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 16,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 2540,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 17,
+            "box_count": 2,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 2666,
+                    "width": 66,
+                    "height": 66
+                },
+                {
+                    "box_index": 1,
+                    "x": 376,
+                    "y": 2666,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 18,
+            "box_count": 2,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 2792,
+                    "width": 66,
+                    "height": 66
+                },
+                {
+                    "box_index": 1,
+                    "x": 376,
+                    "y": 2792,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 19,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 2918,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        },
+        {
+            "question": 20,
+            "box_count": 1,
+            "boxes": [
+                {
+                    "box_index": 0,
+                    "x": 286,
+                    "y": 3044,
+                    "width": 66,
+                    "height": 66
+                }
+            ]
+        }
+    ]
+}

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

@@ -67,21 +67,9 @@
         }
     }
 
-    // 计算填空空格数量
-    $countBlanks = function($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) {
-        $matches = [];
-        $cnt = preg_match_all('/第\s*\d+\s*步/u', $text ?? '', $matches);
-        return max(1, $cnt);
-    };
+    $boxCounter = app(\App\Support\GradingMarkBoxCounter::class);
+    // 与判题卡共用同一计数规则,避免方框数量不一致
+    $countBlanks = fn($text) => $boxCounter->countFillBlanks($text);
 
     $renderBoxes = function($num) {
         // 判卷方框放大 1.2 倍,保持单行布局

+ 3 - 2
resources/views/pdf/exam-grading.blade.php

@@ -30,12 +30,12 @@
             }
             @top-right {
                 content: "{{ $gradingCode }}";
-                font-size: 17px;
+                font-size: 19px;
                 font-weight: 600;
                 font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
                 letter-spacing: 0;
                 padding-right: 3mm;
-                padding-top: 1.2mm;
+                padding-top: 1.8mm;
                 color: #222;
             }
             @bottom-left {
@@ -376,6 +376,7 @@
             'gradingCode' => $gradingCode,
             'teacher' => $teacher,
             'student' => $student,
+            'pdfMeta' => $pdfMeta ?? [],
         ])
     @endif
 

+ 2 - 2
resources/views/pdf/exam-paper.blade.php

@@ -27,12 +27,12 @@
             }
             @top-right {
                 content: "{{ $examCode }}";
-                font-size: 17px;
+                font-size: 19px;
                 font-weight: 600;
                 font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
                 letter-spacing: 0;
                 padding-right: 3mm;
-                padding-top: 1.2mm;
+                padding-top: 1.8mm;
                 color: #222;
             }
             @bottom-left {

+ 52 - 27
resources/views/pdf/partials/grading-scan-sheet-styles.blade.php

@@ -1,55 +1,80 @@
-/* 扫描判题卡页 */
+/* 扫描判题卡页(两列紧凑布局) */
 .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-paper-code {
+    margin-top: 6px;
+    font-size: 18px;
+    font-weight: 700;
+    letter-spacing: 0.4px;
+}
+
 .scan-sheet-hint {
-    font-size: 13px;
+    font-size: 14px;
     color: #444;
-    margin-bottom: 10px;
+    margin-bottom: 14px;
     line-height: 1.5;
 }
-.scan-sheet-list {
+
+.scan-sheet-two-cols {
     display: grid;
-    grid-template-columns: 1fr;
-    gap: 6px;
+    grid-template-columns: 1fr 1fr;
+    column-gap: 22px;
+    align-items: start;
 }
-.scan-sheet-item {
-    border: 1px solid #b5b5b5;
-    border-radius: 4px;
-    padding: 6px 8px;
+
+.scan-sheet-col {
     display: grid;
-    grid-template-columns: auto auto 1fr;
+    row-gap: 8px;
+}
+
+.scan-sheet-item {
+    display: flex;
     align-items: center;
-    column-gap: 8px;
-    font-size: 13px;
+    min-height: 24px;
+}
+
+.scan-sheet-no {
+    font-weight: 700;
+    font-size: 14px;
     line-height: 1.2;
-    page-break-inside: avoid;
-    break-inside: avoid;
+    white-space: nowrap;
+    margin-right: 2px;
 }
+
+.scan-sheet-marks {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+}
+
 .scan-grade-box {
+    border: 1px solid #333;
+    box-sizing: border-box;
+    background: #fff;
     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-empty {
+    color: #888;
+    font-size: 13px;
+    margin-top: 6px;
 }
-.scan-sheet-marks {
-    display: flex;
-    align-items: center;
-    gap: 4px;
-    justify-content: flex-start;
-    white-space: nowrap;
+
+@media print {
+    .scan-sheet-two-cols {
+        column-gap: 18px;
+    }
 }

+ 75 - 42
resources/views/pdf/partials/grading-scan-sheet.blade.php

@@ -1,62 +1,95 @@
 @php
-    // 判题卡项目:保留题号与题目对应方框数(用于OCR锚点+判卷标记)
+    $boxCounter = app(\App\Support\GradingMarkBoxCounter::class);
+    // 按当前试卷真实题目动态生成判题卡条目(题量与方框数)
     $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);
-    };
+    $countBlanks = fn($text): int => $boxCounter->countFillBlanks($text);
+    $countSteps = fn($text): int => $boxCounter->countAnswerSteps($text);
 
     foreach (($questions['choice'] ?? []) as $q) {
-        $scanSheetItems[] = ['no' => (int)($q->question_number ?? 0), 'box_count' => 1];
+        $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 ?? '')];
+        $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' => $countSteps($q->solution ?? '')];
     }
 
-    usort($scanSheetItems, function ($a, $b) {
+    usort($scanSheetItems, static function ($a, $b) {
         return ($a['no'] <=> $b['no']);
     });
-    // 每页容量不是上限20,而是保证至少可容纳20题,并尽量提高单页承载
-    $scanSheetPerPage = 24;
-    $scanSheetPages = array_chunk($scanSheetItems, $scanSheetPerPage);
+    $scanSheetItems = array_values(array_filter($scanSheetItems, static function ($item) {
+        return (int) ($item['no'] ?? 0) > 0;
+    }));
+
+    $assembleTypeLabel = $pdfMeta['assemble_type_label'] ?? null;
+    $showAssembleType = !empty($assembleTypeLabel) && $assembleTypeLabel !== '未知类型';
+    $scanPaperCode = (string) ($pdfMeta['paper_id_num'] ?? $pdfMeta['exam_code'] ?? '');
+    if ($scanPaperCode === '' && !empty($paper->paper_id)) {
+        $scanPaperCode = preg_replace('/^paper_/', '', (string) $paper->paper_id) ?: (string) $paper->paper_id;
+    }
+
+    $totalItems = count($scanSheetItems);
+    $leftCount = (int) ceil($totalItems / 2);
+    $leftItems = array_slice($scanSheetItems, 0, $leftCount);
+    $rightItems = array_slice($scanSheetItems, $leftCount);
 @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 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>
+        @if($scanPaperCode !== '')
+            <div class="scan-sheet-paper-code" style="margin-top:6px;font-size:18px;font-weight:700;letter-spacing:0.4px;">{{ $scanPaperCode }}</div>
+        @endif
+        <div style="display:flex;justify-content:space-between;font-size:14px;margin-top:8px;">
+            <span>老师:{{ $teacher['name'] ?? '________' }}</span>
+            <span>年级:@formatGrade($student['grade'] ?? '________')</span>
+            @if($showAssembleType)
+                <span>类型:{{ $assembleTypeLabel }}</span>
+            @endif
+            <span>姓名:{{ $student['name'] ?? '________' }}</span>
+            <span>得分:________</span>
         </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>
+    </div>
+    <div class="scan-sheet-hint" style="font-size:14px;color:#444;margin-bottom:14px;line-height:1.5;">提示:请根据答案和解析进行批改,在回答正确的 □ 前划 / ,在回答错误的 □ 前打 X 或置空</div>
+
+    <div class="scan-sheet-two-cols" style="display:flex;align-items:flex-start;gap:18px;">
+        <div class="scan-sheet-col" style="flex:1 1 0;display:grid;row-gap:8px;">
+            @foreach($leftItems as $item)
+                @php
+                    $questionNo = (int) ($item['no'] ?? 0);
+                    $boxCount = max(1, (int) ($item['box_count'] ?? 1));
+                @endphp
+                <div class="scan-sheet-item" style="display:flex;align-items:center;min-height:24px;">
+                    <span class="scan-sheet-no" style="font-weight:700;font-size:14px;line-height:1.2;white-space:nowrap;margin-right:2px;">题目 {{ $questionNo }}.</span>
+                    <span class="scan-sheet-marks" style="display:inline-flex;align-items:center;gap:4px;">
+                        @for($i = 0; $i < $boxCount; $i++)
+                            <span class="scan-grade-box" style="display:inline-block;width:17px;height:17px;border:1px solid #333;box-sizing:border-box;background:#fff;vertical-align:middle;"></span>
+                        @endfor
+                    </span>
+                </div>
+            @endforeach
+        </div>
+
+        <div class="scan-sheet-col" style="flex:1 1 0;display:grid;row-gap:8px;">
+            @foreach($rightItems as $item)
+                @php
+                    $questionNo = (int) ($item['no'] ?? 0);
+                    $boxCount = max(1, (int) ($item['box_count'] ?? 1));
+                @endphp
+                <div class="scan-sheet-item" style="display:flex;align-items:center;min-height:24px;">
+                    <span class="scan-sheet-no" style="font-weight:700;font-size:14px;line-height:1.2;white-space:nowrap;margin-right:2px;">题目 {{ $questionNo }}.</span>
+                    <span class="scan-sheet-marks" style="display:inline-flex;align-items:center;gap:4px;">
+                        @for($i = 0; $i < $boxCount; $i++)
+                            <span class="scan-grade-box" style="display:inline-block;width:17px;height:17px;border:1px solid #333;box-sizing:border-box;background:#fff;vertical-align:middle;"></span>
                         @endfor
                     </span>
                 </div>
             @endforeach
         </div>
     </div>
-@endforeach
+
+    @if($totalItems === 0)
+        <div class="scan-sheet-empty" style="color:#888;font-size:13px;margin-top:6px;">暂无可渲染题目</div>
+    @endif
+</div>

+ 2 - 2
resources/views/pdf/partials/kp-explain-styles.blade.php

@@ -16,12 +16,12 @@
         }
         @top-right {
             content: "{{ $pdfMeta['header_title'] ?? ($studentName . '|' . $examCode) }}";
-            font-size: 12px;
+            font-size: 19px;
             font-weight: 600;
             font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
             letter-spacing: 0;
             padding-right: 3mm;
-            padding-top: 1.2mm;
+            padding-top: 1.8mm;
             color: #222;
         }
         @bottom-left {