Explorar o código

fix(pdf): unify scan-sheet stem source and keep standard fractions

Unify mark-box stem field priority across both scan-sheet templates and keep formula display in standard fraction format while preserving adaptive PDF image-metrics caching flow.

Made-with: Cursor
yemeishu hai 4 semanas
pai
achega
b132170d0f

+ 117 - 4
app/Services/ExamPdfExportService.php

@@ -35,6 +35,7 @@ class ExamPdfExportService
      * @var array<string, array{w:int,h:int}|null>
      * @var array<string, array{w:int,h:int}|null>
      */
      */
     private array $pdfImageDimensionCache = [];
     private array $pdfImageDimensionCache = [];
+    private ?bool $hasPdfImageMetricsTable = null;
 
 
     public function __construct(
     public function __construct(
         private readonly LearningAnalyticsService $learningAnalyticsService,
         private readonly LearningAnalyticsService $learningAnalyticsService,
@@ -95,7 +96,7 @@ class ExamPdfExportService
             return null;
             return null;
         }
         }
 
 
-        $pdfBinary = $this->buildPdf($html);
+        $pdfBinary = $this->buildPdf($html, ! $includeAnswer && ! $useGradingView);
         if ($pdfBinary === null || $pdfBinary === '') {
         if ($pdfBinary === null || $pdfBinary === '') {
             Log::error('renderAndStoreExamPdf: buildPdf 失败', [
             Log::error('renderAndStoreExamPdf: buildPdf 失败', [
                 'paper_id' => $paperId,
                 'paper_id' => $paperId,
@@ -206,7 +207,7 @@ class ExamPdfExportService
 
 
             // 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
             // 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
             Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
             Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
-            $pdfBinary = $this->buildPdf($unifiedHtml);
+            $pdfBinary = $this->buildPdf($unifiedHtml, true, true);
             if (! $pdfBinary) {
             if (! $pdfBinary) {
                 Log::error('ExamPdfExportService: 生成统一PDF失败', ['paper_id' => $paperId]);
                 Log::error('ExamPdfExportService: 生成统一PDF失败', ['paper_id' => $paperId]);
 
 
@@ -1394,11 +1395,15 @@ class ExamPdfExportService
     /**
     /**
      * 构建PDF
      * 构建PDF
      */
      */
-    private function buildPdf(string $html): ?string
+    private function buildPdf(string $html, bool $applyWideImageSizing = false, bool $scopeToExamPart = false): ?string
     {
     {
         $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_').'.html';
         $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_').'.html';
         $utf8Html = $this->ensureUtf8Html($html);
         $utf8Html = $this->ensureUtf8Html($html);
-        $utf8Html = $this->applyAdaptiveWideImageSizing($utf8Html);
+        if ($applyWideImageSizing) {
+            $utf8Html = $scopeToExamPart
+                ? $this->applyAdaptiveWideImageSizingToExamPart($utf8Html)
+                : $this->applyAdaptiveWideImageSizing($utf8Html);
+        }
         $written = file_put_contents($tmpHtml, $utf8Html);
         $written = file_put_contents($tmpHtml, $utf8Html);
 
 
         Log::debug('ExamPdfExportService: HTML文件已创建', [
         Log::debug('ExamPdfExportService: HTML文件已创建', [
@@ -1479,6 +1484,30 @@ class ExamPdfExportService
         );
         );
     }
     }
 
 
+    /**
+     * 仅在 unified HTML 的试卷容器中应用扁图策略,避免影响判卷/知识点讲解部分。
+     */
+    private function applyAdaptiveWideImageSizingToExamPart(string $html): string
+    {
+        $startMarker = '<!-- EXAM_PART_START -->';
+        $endMarker = '<!-- EXAM_PART_END -->';
+        $startPos = strpos($html, $startMarker);
+        $endPos = strpos($html, $endMarker);
+        if ($startPos === false || $endPos === false || $endPos <= $startPos) {
+            return $html;
+        }
+
+        $contentStart = $startPos + strlen($startMarker);
+        $examContent = substr($html, $contentStart, $endPos - $contentStart);
+        if ($examContent === false || $examContent === '') {
+            return $html;
+        }
+
+        $processedExamContent = $this->applyAdaptiveWideImageSizing($examContent);
+
+        return substr($html, 0, $contentStart).$processedExamContent.substr($html, $endPos);
+    }
+
     /**
     /**
      * @return array{w:int,h:int}|null
      * @return array{w:int,h:int}|null
      */
      */
@@ -1489,6 +1518,13 @@ class ExamPdfExportService
         }
         }
 
 
         try {
         try {
+            $persisted = $this->getPersistedPdfImageMetrics($src);
+            if ($persisted !== null) {
+                $this->pdfImageDimensionCache[$src] = $persisted;
+
+                return $persisted;
+            }
+
             if (! str_starts_with($src, 'http://') && ! str_starts_with($src, 'https://')) {
             if (! str_starts_with($src, 'http://') && ! str_starts_with($src, 'https://')) {
                 $this->pdfImageDimensionCache[$src] = null;
                 $this->pdfImageDimensionCache[$src] = null;
 
 
@@ -1498,6 +1534,7 @@ class ExamPdfExportService
             $size = @getimagesize($src);
             $size = @getimagesize($src);
             if (is_array($size) && count($size) >= 2) {
             if (is_array($size) && count($size) >= 2) {
                 $data = ['w' => (int) $size[0], 'h' => (int) $size[1]];
                 $data = ['w' => (int) $size[0], 'h' => (int) $size[1]];
+                $this->persistPdfImageMetrics($src, $data);
                 $this->pdfImageDimensionCache[$src] = $data;
                 $this->pdfImageDimensionCache[$src] = $data;
 
 
                 return $data;
                 return $data;
@@ -1514,6 +1551,70 @@ class ExamPdfExportService
         }
         }
     }
     }
 
 
+    /**
+     * @return array{w:int,h:int}|null
+     */
+    private function getPersistedPdfImageMetrics(string $src): ?array
+    {
+        if (! $this->isPdfImageMetricsTableReady()) {
+            return null;
+        }
+
+        $row = DB::table('pdf_image_metrics')
+            ->where('src', $src)
+            ->first(['width', 'height']);
+
+        if (! $row) {
+            return null;
+        }
+
+        $w = (int) ($row->width ?? 0);
+        $h = (int) ($row->height ?? 0);
+        if ($w <= 0 || $h <= 0) {
+            return null;
+        }
+
+        return ['w' => $w, 'h' => $h];
+    }
+
+    /**
+     * @param  array{w:int,h:int}  $data
+     */
+    private function persistPdfImageMetrics(string $src, array $data): void
+    {
+        if (! $this->isPdfImageMetricsTableReady()) {
+            return;
+        }
+
+        $w = (int) ($data['w'] ?? 0);
+        $h = (int) ($data['h'] ?? 0);
+        if ($w <= 0 || $h <= 0) {
+            return;
+        }
+
+        DB::table('pdf_image_metrics')->upsert([
+            [
+                'src' => $src,
+                'width' => $w,
+                'height' => $h,
+                'ratio' => round($w / max(1, $h), 4),
+                'updated_at' => now(),
+                'created_at' => now(),
+            ],
+        ], ['src'], ['width', 'height', 'ratio', 'updated_at']);
+    }
+
+    private function isPdfImageMetricsTableReady(): bool
+    {
+        if ($this->hasPdfImageMetricsTable !== null) {
+            return $this->hasPdfImageMetricsTable;
+        }
+
+        $this->hasPdfImageMetricsTable = Schema::hasTable('pdf_image_metrics');
+
+        return $this->hasPdfImageMetricsTable;
+    }
+
     private function shouldApplyAdaptiveSizingToSrc(string $src): bool
     private function shouldApplyAdaptiveSizingToSrc(string $src): bool
     {
     {
         $parts = parse_url($src);
         $parts = parse_url($src);
@@ -2282,10 +2383,12 @@ class ExamPdfExportService
 
 
         // 添加试卷部分
         // 添加试卷部分
         $bodyContent .= '
         $bodyContent .= '
+    <!-- EXAM_PART_START -->
     <!-- 试卷部分 - 连续显示 -->
     <!-- 试卷部分 - 连续显示 -->
     <div class="exam-part">
     <div class="exam-part">
 '.$examBody.'
 '.$examBody.'
     </div>
     </div>
+    <!-- EXAM_PART_END -->
 ';
 ';
 
 
         // 添加判卷部分
         // 添加判卷部分
@@ -3353,6 +3456,16 @@ class ExamPdfExportService
     private function normalizeAnswerFieldForPdf(object $question): object
     private function normalizeAnswerFieldForPdf(object $question): object
     {
     {
         $normalizedQuestion = clone $question;
         $normalizedQuestion = clone $question;
+        // 以 paper_questions.question_text 为标准题干字段,兼容旧链路 content。
+        $questionText = trim((string) ($normalizedQuestion->question_text ?? ''));
+        if ($questionText === '') {
+            $questionText = trim((string) ($normalizedQuestion->content ?? ''));
+        }
+        if ($questionText !== '') {
+            $normalizedQuestion->question_text = $questionText;
+            $normalizedQuestion->content = $questionText;
+        }
+
         $answerText = trim((string) ($normalizedQuestion->answer ?? ''));
         $answerText = trim((string) ($normalizedQuestion->answer ?? ''));
         if ($answerText !== '') {
         if ($answerText !== '') {
             return $normalizedQuestion;
             return $normalizedQuestion;

+ 9 - 29
app/Support/OptionLayoutDecider.php

@@ -6,30 +6,8 @@ class OptionLayoutDecider
 {
 {
     public function normalizeCompactMathForDisplay(string $option): string
     public function normalizeCompactMathForDisplay(string $option): string
     {
     {
-        $trimmed = trim($option);
-        if ($trimmed === '') {
-            return $option;
-        }
-
-        $text = preg_replace('/^\$(.*)\$$/u', '$1', $trimmed) ?? $trimmed;
-        $parts = $this->extractSingleFractionParts($text);
-        if ($parts === null) {
-            return $option;
-        }
-
-        [$num, $den] = $parts;
-        $compactPart = '/^[\-+0-9a-zA-Z\x{221A}\\\\{}]+$/u';
-        if (
-            preg_match($compactPart, $num) !== 1
-            || preg_match($compactPart, $den) !== 1
-            || preg_match('/[=<>]/u', $num.$den) === 1
-            || $this->hasBinaryOperator($num)
-            || $this->hasBinaryOperator($den)
-        ) {
-            return $option;
-        }
-
-        return str_replace($text, $num.'/'.$den, $trimmed);
+        // 展示层保持数学标准分式(分子/分母上下结构),不做 \frac -> a/b 的文本替换
+        return $option;
     }
     }
 
 
     /**
     /**
@@ -179,7 +157,7 @@ class OptionLayoutDecider
         $optionLength = $rawLength;
         $optionLength = $rawLength;
         $isSimpleCompactMath = preg_match('/^-?[0-9a-zA-Z\x{221A}]+(?:\/[0-9a-zA-Z\x{221A}]+)?$/u', $optionTextNoDollar) === 1;
         $isSimpleCompactMath = preg_match('/^-?[0-9a-zA-Z\x{221A}]+(?:\/[0-9a-zA-Z\x{221A}]+)?$/u', $optionTextNoDollar) === 1;
         $isCompactLatexFraction = preg_match(
         $isCompactLatexFraction = preg_match(
-            '/^\\\\d?frac\{[-+0-9a-zA-Z\\\\\x{221A}\^\(\)]+\}\{[-+0-9a-zA-Z\\\\\x{221A}\^\(\)]+\}$/u',
+            '/^[+\-]?\\\\d?frac\{[-+0-9a-zA-Z\\\\\x{221A}\^\(\)]+\}\{[-+0-9a-zA-Z\\\\\x{221A}\^\(\)]+\}$/u',
             $optionTextNoDollar
             $optionTextNoDollar
         ) === 1;
         ) === 1;
         $isCompactLatexDegree = preg_match(
         $isCompactLatexDegree = preg_match(
@@ -351,14 +329,16 @@ class OptionLayoutDecider
     }
     }
 
 
     /**
     /**
-     * @return array{0:string,1:string}|null
+     * @return array{0:string,1:string,2:string}|null
      */
      */
     private function extractSingleFractionParts(string $text): ?array
     private function extractSingleFractionParts(string $text): ?array
     {
     {
-        if (! preg_match('/^\\\\d?frac/u', $text)) {
+        if (! preg_match('/^([+\-]?)(\\\\d?frac)/u', $text, $m)) {
             return null;
             return null;
         }
         }
-        $offset = preg_match('/^\\\\dfrac/u', $text) ? 6 : 5; // \dfrac or \frac
+        $prefix = (string) ($m[1] ?? '');
+        $fracCmd = (string) ($m[2] ?? '\\frac');
+        $offset = mb_strlen($prefix.$fracCmd, 'UTF-8');
         $len = mb_strlen($text, 'UTF-8');
         $len = mb_strlen($text, 'UTF-8');
 
 
         if ($offset >= $len || mb_substr($text, $offset, 1, 'UTF-8') !== '{') {
         if ($offset >= $len || mb_substr($text, $offset, 1, 'UTF-8') !== '{') {
@@ -380,7 +360,7 @@ class OptionLayoutDecider
             return null;
             return null;
         }
         }
 
 
-        return [$num, $den];
+        return [$prefix, $num, $den];
     }
     }
 
 
     private function hasBinaryOperator(string $expr): bool
     private function hasBinaryOperator(string $expr): bool

+ 26 - 0
database/migrations/2026_04_15_223000_create_pdf_image_metrics_table.php

@@ -0,0 +1,26 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('pdf_image_metrics', function (Blueprint $table) {
+            $table->id();
+            $table->string('src', 512)->unique();
+            $table->unsignedInteger('width');
+            $table->unsignedInteger('height');
+            $table->decimal('ratio', 8, 4);
+            $table->timestamps();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('pdf_image_metrics');
+    }
+};
+

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

@@ -9,12 +9,25 @@
         $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) {
     foreach (($questions['fill'] ?? []) as $q) {
-        $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => $countBlanks($q->content ?? '')];
+        $stemText = (string) ($q->question_text ?? '');
+        if ($stemText === '') {
+            // 兼容旧链路:部分历史对象仅有 content 字段
+            $stemText = (string) ($q->content ?? '');
+        }
+        $scanSheetItems[] = [
+            'no' => (int) ($q->question_number ?? 0),
+            'box_count' => $countBlanks($stemText),
+        ];
     }
     }
     foreach (($questions['answer'] ?? []) as $q) {
     foreach (($questions['answer'] ?? []) as $q) {
+        $stemText = (string) ($q->question_text ?? '');
+        if ($stemText === '') {
+            // 兼容旧链路:部分历史对象仅有 content 字段
+            $stemText = (string) ($q->content ?? '');
+        }
         $scanSheetItems[] = [
         $scanSheetItems[] = [
             'no' => (int) ($q->question_number ?? 0),
             'no' => (int) ($q->question_number ?? 0),
-            'box_count' => $countAnswerBoxes($q->content ?? '', $q->solution ?? ''),
+            'box_count' => $countAnswerBoxes($stemText, $q->solution ?? ''),
         ];
         ];
     }
     }
 
 

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

@@ -8,12 +8,25 @@
         $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) {
     foreach (($questions['fill'] ?? []) as $q) {
-        $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => $countBlanks($q->content ?? '')];
+        $stemText = (string) ($q->question_text ?? '');
+        if ($stemText === '') {
+            // 兼容旧链路:部分历史对象仅有 content 字段
+            $stemText = (string) ($q->content ?? '');
+        }
+        $scanSheetItems[] = [
+            'no' => (int) ($q->question_number ?? 0),
+            'box_count' => $countBlanks($stemText),
+        ];
     }
     }
     foreach (($questions['answer'] ?? []) as $q) {
     foreach (($questions['answer'] ?? []) as $q) {
+        $stemText = (string) ($q->question_text ?? '');
+        if ($stemText === '') {
+            // 兼容旧链路:部分历史对象仅有 content 字段
+            $stemText = (string) ($q->content ?? '');
+        }
         $scanSheetItems[] = [
         $scanSheetItems[] = [
             'no' => (int) ($q->question_number ?? 0),
             'no' => (int) ($q->question_number ?? 0),
-            'box_count' => $countAnswerBoxes($q->content ?? '', $q->solution ?? ''),
+            'box_count' => $countAnswerBoxes($stemText, $q->solution ?? ''),
         ];
         ];
     }
     }
 
 

+ 3 - 2
tests/Unit/OptionLayoutDeciderTest.php

@@ -89,10 +89,11 @@ class OptionLayoutDeciderTest extends TestCase
         $this->assertSame('options-grid-2', $result['class']);
         $this->assertSame('options-grid-2', $result['class']);
     }
     }
 
 
-    public function test_normalize_compact_fraction_for_display(): void
+    public function test_keep_standard_fraction_display_format(): void
     {
     {
         $decider = new OptionLayoutDecider();
         $decider = new OptionLayoutDecider();
-        $this->assertSame('\\sqrt{3}/2', $decider->normalizeCompactMathForDisplay('\\frac{\\sqrt{3}}{2}'));
+        $this->assertSame('\\frac{\\sqrt{3}}{2}', $decider->normalizeCompactMathForDisplay('\\frac{\\sqrt{3}}{2}'));
+        $this->assertSame('-\\frac{\\sqrt{3}}{2}', $decider->normalizeCompactMathForDisplay('-\\frac{\\sqrt{3}}{2}'));
         $this->assertSame('\\frac{x+1}{2}', $decider->normalizeCompactMathForDisplay('\\frac{x+1}{2}'));
         $this->assertSame('\\frac{x+1}{2}', $decider->normalizeCompactMathForDisplay('\\frac{x+1}{2}'));
     }
     }
 }
 }