'; // 仅匹配“空白占位”型 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} */ 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 = '<<>>'; $map[$key] = $m[0]; $idx++; return $key; }, $inner ); return [$protected ?? $inner, $map]; } /** * @param array $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 $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 = '<<>>'; $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 = '<<>>'; $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 标签,如 )。 // 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*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'); } }