| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- <?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;"> </span>';
- // 仅匹配“空白占位”型 underline,不匹配 \underline{\frac{...}} 这类有内容公式下划线
- private const BLANK_UNDERLINE_PATTERN = '/\\\\+underline\{\s*(?:(?:\\\\+qquad+|\\\\+quad+|\\\\+hspace\{[^{}]*\}|\\\\+hphantom\{\s*(?:(?:\\\\+qquad+|\\\\+quad+|\\\\+hspace\{[^{}]*\}|_{2,}| | |\s| |\\\\+\s+)*)\s*\}|_{2,}| | |\s| |\\\\+\s+)*)\s*\}/u';
- /**
- * \left(\quad\right) / \left(\qquad\right) 中的 \quad 是合法间距,不是填空占位;替换前临时保护以免误伤。
- *
- * @return array{0:string,1:array<string,string>}
- */
- private static function protectLeftRightQuadPairs(string $inner): array
- {
- $map = [];
- $idx = 0;
- // 使用 \x5C 避免 PCRE 将 \left / \quad 中的 \l、\q 解析为无效转义
- $protected = preg_replace_callback(
- '/\x5Cleft\s*\(\s*(?:\x5Cquad|\x5Cqquad)\s*\x5Cright\s*\)/u',
- static function (array $m) use (&$map, &$idx): string {
- $key = '<<<LR_PAIR_'.$idx.'>>>';
- $map[$key] = $m[0];
- $idx++;
- return $key;
- },
- $inner
- );
- return [$protected ?? $inner, $map];
- }
- /**
- * @param array<string,string> $restoreMap
- */
- private static function restoreProtectedLeftRightQuadPairs(string $inner, array $restoreMap): string
- {
- if ($restoreMap === []) {
- return $inner;
- }
- return str_replace(array_keys($restoreMap), array_values($restoreMap), $inner);
- }
- /**
- * 数学片段内因占位拆分后的分段,按顺序交替输出「小段 $...$」与 HTML 空位;避免首尾空分段导致半截 `$` 或与 HTML 错位。
- *
- * @param array<int,string> $parts
- */
- private static function rebuildMathSegmentsWithBlankSpans(array $parts, string $blankSpan): string
- {
- $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;
- }
- /**
- * 脏数据常见:$a=______ 后紧跟中文但漏写闭合 $,导致下一个 $...$ 整段被吞并。
- * 在连续下划线占位后、若紧接着汉字/全角逗号且中间仍无第二个 $,则补一个闭合 $。
- *
- * 规范写法「$a$=__________时」在横线前的 $ 是 $a$ 的闭合符,紧跟 =,不得在此插 $(否则会得到 ...=____$时)。
- * 因此仅当「该 $ 后面第一个非 $ 片段不是以 = 开头接上横线」时…… 更简:(?! =) 在匹配起点 $ 之后:若紧跟 = 则本规则不锚定在此 $(见下 (?!=))。
- */
- private static function closeMissingDollarAfterUnderscoreBlank(string $content): string
- {
- // 不能在「已成对的 $…$」的闭合 $ 上锚定:否则会把 $40^{\circ}$ 的收尾 $ 当成新段开头,
- // 一路吞到后面 ______度,错误插入「______$度」(见 questions.id=332)。
- if (! preg_match_all('/\$(?!=)([^$]*_{2,})(?=[\p{Han},。;])/u', $content, $matches, PREG_OFFSET_CAPTURE)) {
- return $content;
- }
- $out = $content;
- foreach (array_reverse($matches[0]) as [$text, $byteOffset]) {
- $before = substr($out, 0, $byteOffset);
- if ((substr_count($before, '$') % 2) === 1) {
- continue;
- }
- $len = strlen($text);
- $out = substr_replace($out, $text.'$', $byteOffset, $len);
- }
- return $out;
- }
- /**
- * 选择题常见:答案区写成 $=\left(\quad\right)$,意图为答题横线而非 LaTeX 括号间距。
- * 将段尾的 $=\left(\quad\right)$ / $=\left(\qquad\right)$ 拆成「公式到等号为止」+ 段外连续下划线,后续由 _{2,} 规则换成标准空位。
- */
- private static function moveTrailingLeftQuadRightAnswerBlankToUnderscores(string $content): string
- {
- $suffixQuad = '=\\left(\\quad\\right)';
- $suffixQquad = '=\\left(\\qquad\\right)';
- $out = preg_replace_callback(
- '/\$(?:[^\$]|\\\\.)*?\\$/u',
- static function (array $m) use ($suffixQuad, $suffixQquad): string {
- $full = $m[0];
- $inner = mb_substr($full, 1, mb_strlen($full) - 2);
- $suffixLen = null;
- if (str_ends_with($inner, $suffixQuad)) {
- $suffixLen = mb_strlen($suffixQuad);
- } elseif (str_ends_with($inner, $suffixQquad)) {
- $suffixLen = mb_strlen($suffixQquad);
- }
- if ($suffixLen !== null && $suffixLen <= mb_strlen($inner)) {
- $prefix = mb_substr($inner, 0, mb_strlen($inner) - $suffixLen);
- return '$'.$prefix.'=$'.'__________';
- }
- return $full;
- },
- $content
- );
- return $out ?? $content;
- }
- /**
- * 将题干中的空括号/下划线/部分异常占位符统一替换为标准空位样式。
- *
- * @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 = self::closeMissingDollarAfterUnderscoreBlank($content);
- $renderedContent = self::moveTrailingLeftQuadRightAnswerBlankToUnderscores($renderedContent);
- $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);
- // \left(\quad\right) 先保护,避免下方 \quad 替换误伤(见选择题题干中的合法间距)。
- [$inner, $lrQuadRestore] = self::protectLeftRightQuadPairs($inner);
- // 数学环境内也可能包含填空占位符(如 $\\underline{\\qquad}$ / $\\angle A=\\underline{\\quad}$)
- $blankToken = '<<<BLANK_IN_MATH_'.$counter.'>>>';
- $innerWithBlanks = preg_replace(
- [
- self::BLANK_UNDERLINE_PATTERN,
- '/\\\\+qquad+/u',
- '/\\\\+quad+/u',
- '/[((](?:\s| | | )*[))]/u',
- '/_{2,}/u',
- ],
- $blankToken,
- $inner,
- -1,
- $blankCount
- );
- $innerWithBlanks = self::restoreProtectedLeftRightQuadPairs($innerWithBlanks, $lrQuadRestore);
- if ($blankCount > 0) {
- $parts = explode($blankToken, $innerWithBlanks);
- return self::rebuildMathSegmentsWithBlankSpans($parts, $blankSpan);
- }
- $placeholder = '<<<LATEX_BLANK_'.$counter.'>>>';
- $latexPlaceholders[$placeholder] = $latexContent;
- $counter++;
- return $placeholder;
- }, $renderedContent);
- // 兼容常见空位写法:\underline{...}、\qquad、空括号(含 nbsp 等空白)、连续下划线、尾部 \\$
- $patterns = [
- self::BLANK_UNDERLINE_PATTERN,
- '/\\\\+qquad+/u',
- '/[((](?:\s| | | )*[))]/u',
- '/_{2,}/u',
- '/\\\\+\$(?=\s*$)/u',
- ];
- $renderedContent = preg_replace($patterns, $blankSpan, $renderedContent);
- if ($collapseAdjacentBlanks) {
- $quotedBlankSpan = preg_quote($blankSpan, '/');
- $renderedContent = preg_replace('/(?:'.$quotedBlankSpan.'(?:\s| | | )*){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;
- }
- // 末尾若是图像类媒体标签(例如 <image .../>),不追加标点,避免出现独立一行的孤立小点。
- if (preg_match('/(?:<\s*image\b[^>]*\/?>|<\s*img\b[^>]*\/?>|<\s*svg\b[\s\S]*<\/\s*svg\s*>)(?:\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$/iu', $content)) {
- return $content;
- }
- // 句尾若已有终止符号(按“可见文本”判断,避免把 HTML 实体中的分号误判为终止符)
- $visibleText = html_entity_decode(strip_tags($content), ENT_QUOTES | ENT_HTML5, 'UTF-8');
- $visibleText = rtrim($visibleText);
- if ($visibleText !== '' && preg_match('/[\..。!!\??;;::]$/u', $visibleText)) {
- return $content;
- }
- return rtrim($content).$punctuation;
- }
- /**
- * 填空题常见写法:"...。 (说明)" / "...。(说明)"(后面可能跟图片标签)
- * 将该处句号统一为英文小点,避免句尾媒体标签场景下出现错误补点。
- */
- public static function normalizePeriodBeforeTrailingParentheticalNote(string $content, string $replacement = '.'): string
- {
- return preg_replace(
- '/[。.\.](?=\s*[((][^))]{1,80}[))]\s*(?:(?:<[^>]+>\s*)*)$)/u',
- $replacement,
- $content,
- 1
- ) ?? $content;
- }
- private static function normalizeChineseTerminalPeriod(string $content): string
- {
- // 仅在存在中文语境时,把句末英文句号统一为中文句号。
- if (! preg_match('/\p{Han}/u', $content)) {
- return $content;
- }
- return self::normalizeTerminalPunctuation($content, 'cn');
- }
- }
|