Parcourir la source

merge: fill punctuation and blank rendering safety

yemeishu il y a 3 semaines
Parent
commit
1357b2579e

+ 188 - 0
app/Support/BlankPlaceholderRenderer.php

@@ -0,0 +1,188 @@
+<?php
+
+namespace App\Support;
+
+class BlankPlaceholderRenderer
+{
+    private const DEFAULT_BLANK_SPAN = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
+    // 仅匹配“空白占位”型 underline,不匹配 \underline{\frac{...}} 这类有内容公式下划线
+    private const BLANK_UNDERLINE_PATTERN = '/\\\\+underline\{\s*(?:(?:\\\\+qquad+|\\\\+quad+|\\\\+hspace\{[^{}]*\}|\\\\+hphantom\{\s*(?:(?:\\\\+qquad+|\\\\+quad+|\\\\+hspace\{[^{}]*\}|_{2,}|&nbsp;|&#160;|\s| |\\\\+\s+)*)\s*\}|_{2,}|&nbsp;|&#160;|\s| |\\\\+\s+)*)\s*\}/u';
+
+    /**
+     * 将题干中的空括号/下划线/部分异常占位符统一替换为标准空位样式。
+     *
+     * @return array{0:string,1:bool} [renderedContent, replacedAnyPlaceholder]
+     */
+    public static function replaceToBlankSpan(
+        string $content,
+        ?string $blankSpan = null,
+        bool $collapseAdjacentBlanks = false,
+        bool $normalizeChineseTerminalPeriod = true
+    ): array
+    {
+        $blankSpan = $blankSpan ?: self::DEFAULT_BLANK_SPAN;
+        $renderedContent = $content;
+
+        $latexPlaceholders = [];
+        $counter = 0;
+        $renderedContent = preg_replace_callback('/\$(?:[^\$]|\\\\.)*\$/u', function ($matches) use (&$latexPlaceholders, &$counter, $blankSpan) {
+            $latexContent = $matches[0];
+            $inner = mb_substr($latexContent, 1, mb_strlen($latexContent) - 2);
+
+            // 数学环境内也可能包含填空占位符(如 $\\underline{\\qquad}$ / $\\angle A=\\underline{\\quad}$)
+            $blankToken = '<<<BLANK_IN_MATH_'.$counter.'>>>';
+            $innerWithBlanks = preg_replace(
+                [
+                    self::BLANK_UNDERLINE_PATTERN,
+                    '/\\\\+qquad+/u',
+                    '/\\\\+quad+/u',
+                    '/[((](?:\s|&nbsp;|&#160;| )*[))]/u',
+                    '/_{2,}/u',
+                ],
+                $blankToken,
+                $inner,
+                -1,
+                $blankCount
+            );
+            if ($blankCount > 0) {
+                $parts = explode($blankToken, $innerWithBlanks);
+                $rebuilt = '';
+                $lastIndex = count($parts) - 1;
+                foreach ($parts as $index => $part) {
+                    if ($part !== '') {
+                        // 纯标点不再包进数学环境,避免生成 "$.$" 这类尾部格式。
+                        if (preg_match('/^[\..。]$/u', $part)) {
+                            $rebuilt .= $part;
+                        } else {
+                            $rebuilt .= htmlspecialchars('$'.$part.'$', ENT_QUOTES | ENT_HTML5, 'UTF-8');
+                        }
+                    }
+                    if ($index < $lastIndex) {
+                        $rebuilt .= $blankSpan;
+                    }
+                }
+
+                return $rebuilt === '' ? $blankSpan : $rebuilt;
+            }
+
+            $placeholder = '<<<LATEX_BLANK_'.$counter.'>>>';
+            $latexPlaceholders[$placeholder] = $latexContent;
+            $counter++;
+
+            return $placeholder;
+        }, $renderedContent);
+
+        // 兼容常见空位写法:\underline{...}、\qquad、空括号(含 nbsp 等空白)、连续下划线、尾部 \\$
+        $patterns = [
+            self::BLANK_UNDERLINE_PATTERN,
+            '/\\\\+qquad+/u',
+            '/[((](?:\s|&nbsp;|&#160;| )*[))]/u',
+            '/_{2,}/u',
+            '/\\\\+\$(?=\s*$)/u',
+        ];
+        $renderedContent = preg_replace($patterns, $blankSpan, $renderedContent);
+        if ($collapseAdjacentBlanks) {
+            $quotedBlankSpan = preg_quote($blankSpan, '/');
+            $renderedContent = preg_replace('/(?:'.$quotedBlankSpan.'(?:\s|&nbsp;|&#160;| )*){2,}/u', $blankSpan, $renderedContent);
+        }
+        // 兼容脏数据:空位后紧跟孤立 "$" 且位于句尾(如 "...=____$."),移除该孤立 "$"。
+        // 仅作用在“标准空位 + 句尾”场景,不影响正常数学公式分隔符。
+        $quotedBlankSpan = preg_quote($blankSpan, '/');
+        $renderedContent = preg_replace(
+            '/('.$quotedBlankSpan.')\s*\$(?=\s*[\..。]?(?:\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$)/u',
+            '$1',
+            $renderedContent
+        ) ?? $renderedContent;
+
+        foreach ($latexPlaceholders as $placeholder => $latexContent) {
+            if (preg_match('/^\$(.*?)(\\\\+)\$$/u', $latexContent, $match)) {
+                $inner = rtrim($match[1]);
+                if ($inner === '' || preg_match('/[=::]\s*$/u', $inner)) {
+                    if ($inner === '') {
+                        $replacement = $blankSpan;
+                    } else {
+                        $replacement = htmlspecialchars('$'.$inner.'$', ENT_QUOTES | ENT_HTML5, 'UTF-8').' '.$blankSpan;
+                    }
+                    $renderedContent = str_replace($placeholder, $replacement, $renderedContent);
+                    continue;
+                }
+            }
+
+            $encodedLatex = htmlspecialchars($latexContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+            $renderedContent = str_replace($placeholder, $encodedLatex, $renderedContent);
+        }
+
+        if ($normalizeChineseTerminalPeriod) {
+            $renderedContent = self::normalizeChineseTerminalPeriod($renderedContent);
+        }
+
+        return [$renderedContent, $renderedContent !== $content];
+    }
+
+    public static function defaultBlankSpan(): string
+    {
+        return self::DEFAULT_BLANK_SPAN;
+    }
+
+    /**
+     * 统一句尾标点(仅处理句尾,不影响中间小数/表达式)
+     *
+     * $mode:
+     * - remove: 去掉句尾句号
+     * - dot:    句尾统一为英文实心点 "."
+     * - cn:     句尾统一为中文句号 "。"
+     */
+    public static function normalizeTerminalPunctuation(string $content, string $mode): string
+    {
+        $replacement = match ($mode) {
+            'remove' => '',
+            'dot' => '.',
+            'cn' => '。',
+            default => null,
+        };
+        if ($replacement === null) {
+            return $content;
+        }
+
+        // 仅处理句尾最后一个标点(允许句尾带 HTML 标签,如 <image .../>)。
+        // 1) 先处理数学片段尾点(如 "$.$" / "$。$" / "$.$")。
+        if (preg_match('/^(.*)\$\s*[\..。]\s*\$(\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$/us', $content, $m)) {
+            return $m[1].$replacement.$m[2];
+        }
+
+        // 2) 再处理普通句尾点(只替换最后一个,不影响中间文本)。
+        if (preg_match('/^(.*?)([\..。])(\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$/us', $content, $m)) {
+            return $m[1].$replacement.$m[3];
+        }
+
+        return $content;
+    }
+
+    /**
+     * 仅当句尾不存在句号类标点时,追加目标标点。
+     * 不会覆盖已存在的句尾标点,也不处理正文中间内容。
+     */
+    public static function appendTerminalPunctuationIfMissing(string $content, string $punctuation): string
+    {
+        if ($punctuation === '') {
+            return $content;
+        }
+
+        // 句尾若已有终止符号(中英文句号/问号/叹号/分号/冒号),则不再追加
+        if (preg_match('/[\..。!!\??;;::](\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$/us', $content)) {
+            return $content;
+        }
+
+        return rtrim($content).$punctuation;
+    }
+
+    private static function normalizeChineseTerminalPeriod(string $content): string
+    {
+        // 仅在存在中文语境时,把句末英文句号统一为中文句号。
+        if (! preg_match('/\p{Han}/u', $content)) {
+            return $content;
+        }
+
+        return self::normalizeTerminalPunctuation($content, 'cn');
+    }
+}

+ 2 - 25
app/Support/GradingStyleQuestionStem.php

@@ -122,31 +122,8 @@ class GradingStyleQuestionStem
      */
     private static function applyBlankPlaceholdersLikeGrading(string $stemLine): string
     {
-        $blankSpan = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
-        $renderedStem = $stemLine;
-        $renderedStem = preg_replace('/\\\underline\{[^}]*\}/', $blankSpan, $renderedStem);
-        $renderedStem = preg_replace('/\\\qquad+/', $blankSpan, $renderedStem);
-
-        $latexPlaceholders = [];
-        $counter = 0;
-        $renderedStem = preg_replace_callback('/\$[^$]+\$/u', function ($matches) use (&$latexPlaceholders, &$counter) {
-            $placeholder = '<<<LATEX_'.$counter.'>>>';
-            $latexPlaceholders[$placeholder] = $matches[0];
-            $counter++;
-
-            return $placeholder;
-        }, $renderedStem);
-
-        $renderedStem = preg_replace(['/(\s*)/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $renderedStem);
-
-        foreach ($latexPlaceholders as $placeholder => $latexContent) {
-            $encodedLatex = htmlspecialchars($latexContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
-            $renderedStem = str_replace($placeholder, $encodedLatex, $renderedStem);
-        }
-
-        if ($renderedStem === $stemLine) {
-            $renderedStem .= ' '.$blankSpan;
-        }
+        [$renderedStem] = BlankPlaceholderRenderer::replaceToBlankSpan($stemLine, null, true, false);
+        $renderedStem = BlankPlaceholderRenderer::normalizeTerminalPunctuation($renderedStem, 'remove');
 
         return $renderedStem;
     }

+ 11 - 57
resources/views/components/exam/paper-body.blade.php

@@ -149,37 +149,10 @@
                     $stemLine = trim($stemMatch[1]);
                 }
             }
-            // 将题干中的空括号/下划线替换为短波浪线;如无占位符,则在末尾追加短波浪线
-            $blankSpan = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
-            // 【修复】扩展下划线转换规则,支持LaTeX格式和多种占位符
-            $renderedStem = $stemLine;
-            // 先处理LaTeX格式的underline命令
-            $renderedStem = preg_replace('/\\\underline\{[^}]*\}/', $blankSpan, $renderedStem);
-            $renderedStem = preg_replace('/\\\qquad+/', $blankSpan, $renderedStem);
-            // 【修复】在处理填空占位符时,保护LaTeX公式不被破坏
-            // 先标记LaTeX公式区域
-            $latexPlaceholders = [];
-            $counter = 0;
-            $renderedStem = preg_replace_callback('/\$[^$]+\$/u', function($matches) use (&$latexPlaceholders, &$counter, $blankSpan) {
-                $placeholder = '<<<LATEX_' . $counter . '>>>';
-                $latexPlaceholders[$placeholder] = $matches[0];
-                $counter++;
-                return $placeholder;
-            }, $renderedStem);
-
-            // 现在处理普通占位符(不会破坏LaTeX公式)
-            $renderedStem = preg_replace(['/(\s*)/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $renderedStem);
-
-            // 恢复LaTeX公式(并进行HTML实体编码防止被浏览器解析)
-            foreach ($latexPlaceholders as $placeholder => $latexContent) {
-                $encodedLatex = htmlspecialchars($latexContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
-                $renderedStem = str_replace($placeholder, $encodedLatex, $renderedStem);
-            }
-
-            // 如果没有占位符,在末尾添加
-            if ($renderedStem === $stemLine) {
-                $renderedStem .= ' ' . $blankSpan;
-            }
+            // 选择题只做占位符归一,不再兜底追加下划线,避免出现“( )+下划线”重复。
+            [$renderedStem] = \App\Support\BlankPlaceholderRenderer::replaceToBlankSpan($stemLine, null, true, false);
+            // 选择题:句尾不保留句号。
+            $renderedStem = \App\Support\BlankPlaceholderRenderer::normalizeTerminalPunctuation($renderedStem, 'remove');
             $renderedStem = $mathProcessed ? $renderedStem : \App\Services\MathFormulaProcessor::processFormulas($renderedStem);
         @endphp
         <div class="question">
@@ -312,34 +285,15 @@
         @php
             // 【修复】使用question_number字段作为显示序号,确保全局序号一致性
             $questionNumber = $q->question_number ?? (count($choiceQuestions) + $index + 1);
-            $blankSpan = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
-            // 【修复】扩展下划线转换规则,支持LaTeX格式和多种占位符
-            $renderedContent = $q->content;
-            // 【修复】在处理填空占位符时,保护LaTeX公式不被破坏
-            // 先标记LaTeX公式区域(支持包含反斜杠和花括号的LaTeX命令)
-            $latexPlaceholders = [];
-            $counter = 0;
-            $renderedContent = preg_replace_callback('/\$(?:[^\$]|\\.)*\$/u', function($matches) use (&$latexPlaceholders, &$counter, $blankSpan) {
-                $placeholder = '<<<LATEX_FILL_' . $counter . '>>>';
-                $latexPlaceholders[$placeholder] = $matches[0];
-                $counter++;
-                return $placeholder;
-            }, $renderedContent);
-
-            // 现在处理普通占位符(不会破坏LaTeX公式)
-            $renderedContent = preg_replace(['/(\s*)/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $renderedContent);
-
-            // 恢复LaTeX公式(并进行HTML实体编码防止被浏览器解析)
-            foreach ($latexPlaceholders as $placeholder => $latexContent) {
-                $encodedLatex = htmlspecialchars($latexContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
-                $renderedContent = str_replace($placeholder, $encodedLatex, $renderedContent);
-            }
-
-            // 如果没有占位符且内容没有变化,在末尾添加
-            // 但要检查是否已经有填空占位符(如\underline{\qquad})
-            if ($renderedContent === $q->content && !preg_match('/\\\\underline|\\\\qquad|(\s*)|\(\s*\)/', $renderedContent)) {
+            $blankSpan = \App\Support\BlankPlaceholderRenderer::defaultBlankSpan();
+            [$renderedContent, $hasPlaceholders] = \App\Support\BlankPlaceholderRenderer::replaceToBlankSpan((string) $q->content, $blankSpan, false, false);
+            // 填空题保留兜底:题干无任何占位时,在末尾补一个标准空位。
+            if (!$hasPlaceholders) {
                 $renderedContent .= ' ' . $blankSpan;
             }
+            // 填空题:句尾统一为实心小圆点(英文句点)。
+            $renderedContent = \App\Support\BlankPlaceholderRenderer::normalizeTerminalPunctuation($renderedContent, 'dot');
+            $renderedContent = \App\Support\BlankPlaceholderRenderer::appendTerminalPunctuationIfMissing($renderedContent, '.');
             $renderedContent = $mathProcessed ? $renderedContent : \App\Services\MathFormulaProcessor::processFormulas($renderedContent);
         @endphp
         <div class="question">