Forráskód Böngészése

fix(pdf): 题干插图与常规组卷一致并收敛质检 PDF

- 抽取 paper-exam-shared-image-styles,exam-paper 与 question-check 共用 220px/60mm 等规则。\n- MathFormulaProcessor:<image> 转 img 同步上限;质检/自定义组卷 buildPdf 启用扁图自适应。\n- Filament 题干 img 限高;新增 compare_question_pdf_two_paths 脚本本地对比两条 PDF 管线。

Made-with: Cursor
yemeishu 3 hete
szülő
commit
6e381c843e

+ 5 - 3
app/Services/ExamPdfExportService.php

@@ -3281,7 +3281,8 @@ class ExamPdfExportService
                 if ($this->katexRenderer) {
                     $examHtml = $this->katexRenderer->renderHtml($examHtml);
                 }
-                $examPdf = $this->buildPdf($examHtml);
+                // 与学生卷 PDF 一致:扁长图走自适应宽度(与 renderAndStoreExamPdf 第二参语义对齐)
+                $examPdf = $this->buildPdf($examHtml, true);
                 if ($examPdf) {
                     $examPath = "custom_exams/{$paper->paper_id}_exam.pdf";
                     $examUrl = $this->pdfStorageService->put($examPath, $examPdf);
@@ -3298,7 +3299,7 @@ class ExamPdfExportService
                     if ($this->katexRenderer) {
                         $gradingHtml = $this->katexRenderer->renderHtml($gradingHtml);
                     }
-                    $gradingPdf = $this->buildPdf($gradingHtml);
+                    $gradingPdf = $this->buildPdf($gradingHtml, true);
                     if ($gradingPdf) {
                         $gradingPath = "custom_exams/{$paper->paper_id}_grading.pdf";
                         $gradingUrl = $this->pdfStorageService->put($gradingPath, $gradingPdf);
@@ -3376,7 +3377,8 @@ class ExamPdfExportService
                 $html = $this->katexRenderer->renderHtml($html);
             }
 
-            $pdfBinary = $this->buildPdf($this->ensureUtf8Html($html));
+            // 与 renderAndStoreExamPdf 学生卷一致:启用扁长图自适应(buildPdf 第二参 true)
+            $pdfBinary = $this->buildPdf($this->ensureUtf8Html($html), true);
             if (empty($pdfBinary)) {
                 Log::error('generateQuestionCheckPdf: buildPdf 失败', [
                     'paper_id' => $paper->paper_id ?? null,

+ 8 - 4
app/Services/MathFormulaProcessor.php

@@ -74,16 +74,20 @@ class MathFormulaProcessor
 
     /**
      * 将自定义 <image> 标签转换为标准 <img> 标签
-     * 例如:<image src="https://example.com/1.png"/> => <img src="https://example.com/1.png" />
+     * 例如:<image src="https://example.com/1.png"/> => <img ... />(宽度不超过容器,避免填空题大图撑破试卷/PDF)
      */
     private static function convertImageTags(string $content): string
     {
         // 匹配 <image src="..." /> 或 <image src="..."></image> 格式
-        return preg_replace(
+        return preg_replace_callback(
             '/<image\s+src=["\']([^"\']+)["\'](?:\s*\/>|><\/image>)/i',
-            '<img src="$1" />',
+            static function (array $m): string {
+                $src = htmlspecialchars($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+                return '<img src="'.$src.'" alt="" style="max-width:220px;max-height:60mm;width:auto;height:auto;object-fit:contain;display:block;" />';
+            },
             $content
-        );
+        ) ?? $content;
     }
 
     /**

+ 10 - 0
resources/views/filament/pages/question-detail.blade.php

@@ -270,6 +270,16 @@
         height: auto;
         display: block;
     }
+    .question-stem img,
+    .solution-content img {
+        max-width: 100%;
+        max-height: 240px;
+        height: auto;
+        object-fit: contain;
+        display: block;
+        margin-left: auto;
+        margin-right: auto;
+    }
     .solution-content {
         white-space: pre-line;
     }

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

@@ -332,64 +332,7 @@
         .wavy-underline.short {
             min-width: 60px;
         }
-        /* PDF图片容器:防止图片跨页分割 - 增强版 */
-        .pdf-figure {
-            break-inside: avoid;
-            page-break-inside: avoid;
-            -webkit-column-break-inside: avoid;
-            break-before: avoid;
-            break-after: avoid;
-            page-break-before: avoid;
-            page-break-after: avoid;
-            margin: 8px 0;
-            display: block;
-            /* 确保图片不会在页面底部被截断 */
-            min-height: 30px;
-            /* 限制独立图块尺寸,避免图片压过题干 */
-            max-height: 82mm;
-        }
-        .pdf-figure img {
-            max-width: min(84%, 420px);
-            max-height: 82mm;
-            width: auto;
-            height: auto;
-            display: block;
-            margin: 0 auto;
-            object-fit: contain;
-            box-sizing: border-box;
-            -webkit-print-color-adjust: exact;
-            print-color-adjust: exact;
-            image-rendering: -webkit-optimize-contrast;
-        }
-        /* 题干中的图片样式(向后兼容) */
-        .question-stem img,
-        .question-main img,
-        .question-content img,
-        .answer-meta img,
-        .answer-line img,
-        .solution-content img,
-        .solution-section img,
-        .solution-parsed img {
-            display: block;
-            max-width: 220px;
-            max-height: 60mm;
-            width: auto;
-            height: auto;
-            margin: 6px auto;
-            box-sizing: border-box;
-            object-fit: contain;
-            -webkit-print-color-adjust: exact;
-            print-color-adjust: exact;
-            image-rendering: -webkit-optimize-contrast;
-        }
-        /* 选项中的图片样式 - 防止超出容器 */
-        .option img {
-            max-width: 92%;
-            max-height: 42mm;
-            height: auto;
-            object-fit: contain;
-            vertical-align: middle;
-        }
+        @include('pdf.partials.paper-exam-shared-image-styles')
         @media print {
             .no-print {
                 display: none;

+ 1 - 0
resources/views/pdf/partials/paper-body-core-styles.blade.php

@@ -44,6 +44,7 @@
     orphans: 3;
     widows: 3;
 }
+/* 题干插图尺寸见 pdf/partials/paper-exam-shared-image-styles(与 exam-paper / question-check 共用) */
 .question-content {
     font-size: 14px;
     margin-bottom: 8px;

+ 56 - 0
resources/views/pdf/partials/paper-exam-shared-image-styles.blade.php

@@ -0,0 +1,56 @@
+{{-- 与 pdf/exam-paper 一致:题干/选项插图上限(质检 PDF 原先缺少此段导致图过大) --}}
+/* PDF图片容器:防止图片跨页分割 - 增强版 */
+.pdf-figure {
+    break-inside: avoid;
+    page-break-inside: avoid;
+    -webkit-column-break-inside: avoid;
+    break-before: avoid;
+    break-after: avoid;
+    page-break-before: avoid;
+    page-break-after: avoid;
+    margin: 8px 0;
+    display: block;
+    min-height: 30px;
+    max-height: 82mm;
+}
+.pdf-figure img {
+    max-width: min(84%, 420px);
+    max-height: 82mm;
+    width: auto;
+    height: auto;
+    display: block;
+    margin: 0 auto;
+    object-fit: contain;
+    box-sizing: border-box;
+    -webkit-print-color-adjust: exact;
+    print-color-adjust: exact;
+    image-rendering: -webkit-optimize-contrast;
+}
+/* 题干中的图片样式(与常规组卷学生卷 PDF 一致) */
+.question-stem img,
+.question-main img,
+.question-content img,
+.answer-meta img,
+.answer-line img,
+.solution-content img,
+.solution-section img,
+.solution-parsed img {
+    display: block;
+    max-width: 220px;
+    max-height: 60mm;
+    width: auto;
+    height: auto;
+    margin: 6px auto;
+    box-sizing: border-box;
+    object-fit: contain;
+    -webkit-print-color-adjust: exact;
+    print-color-adjust: exact;
+    image-rendering: -webkit-optimize-contrast;
+}
+.option img {
+    max-width: 92%;
+    max-height: 42mm;
+    height: auto;
+    object-fit: contain;
+    vertical-align: middle;
+}

+ 1 - 0
resources/views/pdf/question-check.blade.php

@@ -38,6 +38,7 @@
         @include('pdf.partials.answer-detail-styles')
         @include('pdf.partials.grading-scan-sheet-styles')
         @include('pdf.partials.paper-body-core-styles')
+        @include('pdf.partials.paper-exam-shared-image-styles')
     </style>
 </head>
 <body style="page-break-before: always;">

+ 203 - 0
scripts/compare_question_pdf_two_paths.php

@@ -0,0 +1,203 @@
+<?php
+
+/**
+ * 同一 questions.id:输出两份本地 PDF 供对比
+ *   A) 题目质检(pdf.question-check → generateQuestionCheckPdf)
+ *   B) 常规学生卷页面(pdf.exam-paper,含装订线与卷头,与组卷导出同源样式)
+ *
+ * 用法:
+ *   php scripts/compare_question_pdf_two_paths.php --id=34728
+ *       [--out-dir storage/app/audit_placeholder/pdf_compare]
+ */
+
+declare(strict_types=1);
+
+require __DIR__.'/../vendor/autoload.php';
+$app = require __DIR__.'/../bootstrap/app.php';
+$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
+$kernel->bootstrap();
+
+use App\Services\ExamPdfExportService;
+use App\Services\KatexRenderer;
+use Illuminate\Support\Facades\DB;
+
+$options = getopt('', ['id::', 'out-dir::', 'connection::', 'table::']);
+
+$questionId = isset($options['id']) ? max(1, (int) $options['id']) : 34728;
+$connection = isset($options['connection']) ? trim((string) $options['connection']) : config('database.default');
+$table = isset($options['table']) ? trim((string) $options['table']) : 'questions';
+
+$outRoot = isset($options['out-dir']) ? rtrim((string) $options['out-dir'], '/') : dirname(__DIR__).'/storage/app/audit_placeholder/pdf_compare';
+if ($outRoot === '' || ($outRoot[0] !== '/' && ! preg_match('#^[A-Za-z]:[\\\\/]#', $outRoot))) {
+    $outRoot = dirname(__DIR__).'/'.ltrim($outRoot, '/');
+}
+$stamp = date('Ymd_His');
+$runDir = $outRoot.'/'.$stamp.'_q'.$questionId;
+if (! @mkdir($runDir, 0775, true) && ! is_dir($runDir)) {
+    fwrite(STDERR, "Cannot mkdir {$runDir}\n");
+    exit(1);
+}
+
+$row = DB::connection($connection)->table($table)->where('id', $questionId)->first();
+if ($row === null) {
+    fwrite(STDERR, "Question id {$questionId} not found.\n");
+    exit(1);
+}
+
+$questionMap = [(int) $row->id => $row];
+$grouped = groupQuestionsByType($questionMap, [$questionId]);
+$paper = buildVirtualPaper('PDF对比_'.$questionId, 'pdf_compare_'.$stamp, $grouped);
+
+/** @var ExamPdfExportService $pdfService */
+$pdfService = $app->make(ExamPdfExportService::class);
+
+// --- A 题目质检 ---
+$pdfMetaCheck = [
+    'student_name' => '对比',
+    'exam_code' => 'QC_'.$questionId,
+    'assemble_type_label' => '题目质检',
+    'header_title' => '对比|QC_'.$questionId.'|题目质检',
+    'exam_pdf_title' => '对比_QC_'.$questionId,
+    'grading_pdf_title' => '对比_QC_'.$questionId,
+    'knowledge_pdf_title' => '对比_QC_'.$questionId,
+];
+
+$htmlCheck = view('pdf.question-check', [
+    'paper' => $paper,
+    'questions' => $grouped,
+    'student' => ['name' => '对比', 'grade' => '________'],
+    'teacher' => ['name' => '________'],
+    'pdfMeta' => $pdfMetaCheck,
+])->render();
+
+$resultA = $pdfService->generateQuestionCheckPdf(
+    $paper,
+    $grouped,
+    ['name' => '对比', 'grade' => '________'],
+    ['name' => '________'],
+    $runDir.'/A_question_check.pdf'
+);
+
+if (($resultA['local_path'] ?? '') === '') {
+    fwrite(STDERR, "Path A failed: ".json_encode($resultA, JSON_UNESCAPED_UNICODE)."\n");
+    exit(1);
+}
+
+// --- B 常规试卷(学生卷,无答案)---
+$pdfMetaExam = [
+    'exam_pdf_title' => '对比_常规卷_'.$questionId,
+    'exam_code' => 'EX_'.$questionId,
+    'student_name' => '对比',
+    'header_title' => '对比|EX_'.$questionId.'|常规卷',
+    'assemble_type_label' => '样式对比',
+];
+
+$htmlExam = view('pdf.exam-paper', [
+    'paper' => $paper,
+    'questions' => $grouped,
+    'student' => ['name' => '对比', 'grade' => '________'],
+    'teacher' => ['name' => '________'],
+    'includeAnswer' => false,
+    'pdfMeta' => $pdfMetaExam,
+])->render();
+
+$katex = new KatexRenderer();
+$htmlExam = $katex->renderHtml($htmlExam);
+
+$ref = new ReflectionClass($pdfService);
+$mEnsure = $ref->getMethod('ensureUtf8Html');
+$mEnsure->setAccessible(true);
+$mBuild = $ref->getMethod('buildPdf');
+$mBuild->setAccessible(true);
+
+$htmlExamUtf8 = $mEnsure->invoke($pdfService, $htmlExam);
+$binaryExam = $mBuild->invoke($pdfService, $htmlExamUtf8, true);
+if ($binaryExam === null || $binaryExam === '') {
+    fwrite(STDERR, "Path B buildPdf returned empty.\n");
+    exit(1);
+}
+
+$pathB = $runDir.'/B_exam_paper_student.pdf';
+file_put_contents($pathB, $binaryExam);
+
+// 可选:保存 HTML 便于 diff
+file_put_contents($runDir.'/A_question_check.html', $htmlCheck);
+file_put_contents($runDir.'/B_exam_paper.html', $htmlExam);
+
+echo json_encode([
+    'question_id' => $questionId,
+    'output_directory' => $runDir,
+    'A_question_check_pdf' => $resultA['local_path'],
+    'B_exam_paper_pdf' => $pathB,
+    'notes' => 'A=题目质检模板;B=常规组卷学生卷模板(exam-paper)。题干区均用 components.exam.paper-body。',
+], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)."\n";
+
+/**
+ * @param  array<int, object>  $questionMap
+ */
+function groupQuestionsByType(array $questionMap, array $originalOrder): array
+{
+    $grouped = ['choice' => [], 'fill' => [], 'answer' => []];
+    $n = 1;
+    foreach ($originalOrder as $id) {
+        if (! isset($questionMap[$id])) {
+            continue;
+        }
+        $q = $questionMap[$id];
+        $type = normalizeQuestionType($q->question_type ?? null);
+        $grouped[$type][] = (object) [
+            'id' => $q->id,
+            'question_number' => $n++,
+            'content' => $q->stem,
+            'options' => is_string($q->options) ? json_decode($q->options, true) : ($q->options ?? []),
+            'answer' => $q->answer,
+            'solution' => $q->solution,
+            'score' => match ($type) {
+                'choice', 'fill' => 5,
+                default => 10,
+            },
+            'difficulty' => $q->difficulty,
+            'kp_code' => $q->kp_code,
+        ];
+    }
+
+    return $grouped;
+}
+
+function normalizeQuestionType(?string $type): string
+{
+    if (! $type) {
+        return 'answer';
+    }
+    $type = strtolower(trim($type));
+    $map = [
+        'choice' => 'choice', '选择题' => 'choice', 'single_choice' => 'choice', 'multiple_choice' => 'choice',
+        'fill' => 'fill', '填空题' => 'fill', 'blank' => 'fill',
+        'answer' => 'answer', '解答题' => 'answer', 'subjective' => 'answer',
+    ];
+
+    return $map[$type] ?? 'answer';
+}
+
+function buildVirtualPaper(string $paperName, string $studentId, array $groupedQuestions): object
+{
+    $totalScore = 0;
+    $totalQuestions = 0;
+    foreach ($groupedQuestions as $questions) {
+        foreach ($questions as $q) {
+            $totalScore += $q->score;
+            $totalQuestions++;
+        }
+    }
+
+    // PaperNaming::extractExamCode 要求可解析为合法 15 位考试编码
+    $paperId = 'paper_100000000000001';
+
+    return (object) [
+        'paper_id' => $paperId,
+        'paper_name' => $paperName,
+        'total_score' => $totalScore,
+        'total_questions' => $totalQuestions,
+        'created_at' => now()->toDateTimeString(),
+    ];
+}