BlankPlaceholderRenderer.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. <?php
  2. namespace App\Support;
  3. class BlankPlaceholderRenderer
  4. {
  5. private const DEFAULT_BLANK_SPAN = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
  6. // 仅匹配“空白占位”型 underline,不匹配 \underline{\frac{...}} 这类有内容公式下划线
  7. 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';
  8. /**
  9. * \left(\quad\right) / \left(\qquad\right) 中的 \quad 是合法间距,不是填空占位;替换前临时保护以免误伤。
  10. *
  11. * @return array{0:string,1:array<string,string>}
  12. */
  13. private static function protectLeftRightQuadPairs(string $inner): array
  14. {
  15. $map = [];
  16. $idx = 0;
  17. // 使用 \x5C 避免 PCRE 将 \left / \quad 中的 \l、\q 解析为无效转义
  18. $protected = preg_replace_callback(
  19. '/\x5Cleft\s*\(\s*(?:\x5Cquad|\x5Cqquad)\s*\x5Cright\s*\)/u',
  20. static function (array $m) use (&$map, &$idx): string {
  21. $key = '<<<LR_PAIR_'.$idx.'>>>';
  22. $map[$key] = $m[0];
  23. $idx++;
  24. return $key;
  25. },
  26. $inner
  27. );
  28. return [$protected ?? $inner, $map];
  29. }
  30. /**
  31. * @param array<string,string> $restoreMap
  32. */
  33. private static function restoreProtectedLeftRightQuadPairs(string $inner, array $restoreMap): string
  34. {
  35. if ($restoreMap === []) {
  36. return $inner;
  37. }
  38. return str_replace(array_keys($restoreMap), array_values($restoreMap), $inner);
  39. }
  40. /**
  41. * 数学片段内因占位拆分后的分段,按顺序交替输出「小段 $...$」与 HTML 空位;避免首尾空分段导致半截 `$` 或与 HTML 错位。
  42. *
  43. * @param array<int,string> $parts
  44. */
  45. private static function rebuildMathSegmentsWithBlankSpans(array $parts, string $blankSpan): string
  46. {
  47. $rebuilt = '';
  48. $lastIndex = count($parts) - 1;
  49. foreach ($parts as $index => $part) {
  50. if ($part !== '') {
  51. if (preg_match('/^[\..。]$/u', $part)) {
  52. $rebuilt .= $part;
  53. } else {
  54. $rebuilt .= htmlspecialchars('$'.$part.'$', ENT_QUOTES | ENT_HTML5, 'UTF-8');
  55. }
  56. }
  57. if ($index < $lastIndex) {
  58. $rebuilt .= $blankSpan;
  59. }
  60. }
  61. return $rebuilt === '' ? $blankSpan : $rebuilt;
  62. }
  63. /**
  64. * 脏数据常见:$a=______ 后紧跟中文但漏写闭合 $,导致下一个 $...$ 整段被吞并。
  65. * 在连续下划线占位后、若紧接着汉字/全角逗号且中间仍无第二个 $,则补一个闭合 $。
  66. *
  67. * 规范写法「$a$=__________时」在横线前的 $ 是 $a$ 的闭合符,紧跟 =,不得在此插 $(否则会得到 ...=____$时)。
  68. * 因此仅当「该 $ 后面第一个非 $ 片段不是以 = 开头接上横线」时…… 更简:(?! =) 在匹配起点 $ 之后:若紧跟 = 则本规则不锚定在此 $(见下 (?!=))。
  69. */
  70. private static function closeMissingDollarAfterUnderscoreBlank(string $content): string
  71. {
  72. // 不能在「已成对的 $…$」的闭合 $ 上锚定:否则会把 $40^{\circ}$ 的收尾 $ 当成新段开头,
  73. // 一路吞到后面 ______度,错误插入「______$度」(见 questions.id=332)。
  74. if (! preg_match_all('/\$(?!=)([^$]*_{2,})(?=[\p{Han},。;])/u', $content, $matches, PREG_OFFSET_CAPTURE)) {
  75. return $content;
  76. }
  77. $out = $content;
  78. foreach (array_reverse($matches[0]) as [$text, $byteOffset]) {
  79. $before = substr($out, 0, $byteOffset);
  80. if ((substr_count($before, '$') % 2) === 1) {
  81. continue;
  82. }
  83. $len = strlen($text);
  84. $out = substr_replace($out, $text.'$', $byteOffset, $len);
  85. }
  86. return $out;
  87. }
  88. /**
  89. * 选择题常见:答案区写成 $=\left(\quad\right)$,意图为答题横线而非 LaTeX 括号间距。
  90. * 将段尾的 $=\left(\quad\right)$ / $=\left(\qquad\right)$ 拆成「公式到等号为止」+ 段外连续下划线,后续由 _{2,} 规则换成标准空位。
  91. */
  92. private static function moveTrailingLeftQuadRightAnswerBlankToUnderscores(string $content): string
  93. {
  94. $suffixQuad = '=\\left(\\quad\\right)';
  95. $suffixQquad = '=\\left(\\qquad\\right)';
  96. $out = preg_replace_callback(
  97. '/\$(?:[^\$]|\\\\.)*?\\$/u',
  98. static function (array $m) use ($suffixQuad, $suffixQquad): string {
  99. $full = $m[0];
  100. $inner = mb_substr($full, 1, mb_strlen($full) - 2);
  101. $suffixLen = null;
  102. if (str_ends_with($inner, $suffixQuad)) {
  103. $suffixLen = mb_strlen($suffixQuad);
  104. } elseif (str_ends_with($inner, $suffixQquad)) {
  105. $suffixLen = mb_strlen($suffixQquad);
  106. }
  107. if ($suffixLen !== null && $suffixLen <= mb_strlen($inner)) {
  108. $prefix = mb_substr($inner, 0, mb_strlen($inner) - $suffixLen);
  109. return '$'.$prefix.'=$'.'__________';
  110. }
  111. return $full;
  112. },
  113. $content
  114. );
  115. return $out ?? $content;
  116. }
  117. /**
  118. * 将题干中的空括号/下划线/部分异常占位符统一替换为标准空位样式。
  119. *
  120. * @return array{0:string,1:bool} [renderedContent, replacedAnyPlaceholder]
  121. */
  122. public static function replaceToBlankSpan(
  123. string $content,
  124. ?string $blankSpan = null,
  125. bool $collapseAdjacentBlanks = false,
  126. bool $normalizeChineseTerminalPeriod = true
  127. ): array
  128. {
  129. $blankSpan = $blankSpan ?: self::DEFAULT_BLANK_SPAN;
  130. $renderedContent = self::closeMissingDollarAfterUnderscoreBlank($content);
  131. $renderedContent = self::moveTrailingLeftQuadRightAnswerBlankToUnderscores($renderedContent);
  132. $latexPlaceholders = [];
  133. $counter = 0;
  134. // 非贪婪:遇到第一个闭合 $ 即结束;避免紧邻多段 "$...$…$…$" 时被吞成一段(混入中文标点,破坏公式边界)。
  135. $renderedContent = preg_replace_callback('/\$(?:[^\$]|\\\\.)*?\\$/u', function ($matches) use (&$latexPlaceholders, &$counter, $blankSpan) {
  136. $latexContent = $matches[0];
  137. $inner = mb_substr($latexContent, 1, mb_strlen($latexContent) - 2);
  138. // \left(\quad\right) 先保护,避免下方 \quad 替换误伤(见选择题题干中的合法间距)。
  139. [$inner, $lrQuadRestore] = self::protectLeftRightQuadPairs($inner);
  140. // 数学环境内也可能包含填空占位符(如 $\\underline{\\qquad}$ / $\\angle A=\\underline{\\quad}$)
  141. $blankToken = '<<<BLANK_IN_MATH_'.$counter.'>>>';
  142. $innerWithBlanks = preg_replace(
  143. [
  144. self::BLANK_UNDERLINE_PATTERN,
  145. '/\\\\+qquad+/u',
  146. '/\\\\+quad+/u',
  147. '/[((](?:\s|&nbsp;|&#160;| )*[))]/u',
  148. '/_{2,}/u',
  149. ],
  150. $blankToken,
  151. $inner,
  152. -1,
  153. $blankCount
  154. );
  155. $innerWithBlanks = self::restoreProtectedLeftRightQuadPairs($innerWithBlanks, $lrQuadRestore);
  156. if ($blankCount > 0) {
  157. $parts = explode($blankToken, $innerWithBlanks);
  158. return self::rebuildMathSegmentsWithBlankSpans($parts, $blankSpan);
  159. }
  160. $placeholder = '<<<LATEX_BLANK_'.$counter.'>>>';
  161. $latexPlaceholders[$placeholder] = $latexContent;
  162. $counter++;
  163. return $placeholder;
  164. }, $renderedContent);
  165. // 兼容常见空位写法:\underline{...}、\qquad、空括号(含 nbsp 等空白)、连续下划线、尾部 \\$
  166. $patterns = [
  167. self::BLANK_UNDERLINE_PATTERN,
  168. '/\\\\+qquad+/u',
  169. '/[((](?:\s|&nbsp;|&#160;| )*[))]/u',
  170. '/_{2,}/u',
  171. '/\\\\+\$(?=\s*$)/u',
  172. ];
  173. $renderedContent = preg_replace($patterns, $blankSpan, $renderedContent);
  174. if ($collapseAdjacentBlanks) {
  175. $quotedBlankSpan = preg_quote($blankSpan, '/');
  176. $renderedContent = preg_replace('/(?:'.$quotedBlankSpan.'(?:\s|&nbsp;|&#160;| )*){2,}/u', $blankSpan, $renderedContent);
  177. }
  178. // 兼容脏数据:空位后紧跟孤立 "$" 且位于句尾(如 "...=____$."),移除该孤立 "$"。
  179. // 仅作用在“标准空位 + 句尾”场景,不影响正常数学公式分隔符。
  180. $quotedBlankSpan = preg_quote($blankSpan, '/');
  181. $renderedContent = preg_replace(
  182. '/('.$quotedBlankSpan.')\s*\$(?=\s*[\..。]?(?:\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$)/u',
  183. '$1',
  184. $renderedContent
  185. ) ?? $renderedContent;
  186. foreach ($latexPlaceholders as $placeholder => $latexContent) {
  187. if (preg_match('/^\$(.*?)(\\\\+)\$$/u', $latexContent, $match)) {
  188. $inner = rtrim($match[1]);
  189. if ($inner === '' || preg_match('/[=::]\s*$/u', $inner)) {
  190. if ($inner === '') {
  191. $replacement = $blankSpan;
  192. } else {
  193. $replacement = htmlspecialchars('$'.$inner.'$', ENT_QUOTES | ENT_HTML5, 'UTF-8').' '.$blankSpan;
  194. }
  195. $renderedContent = str_replace($placeholder, $replacement, $renderedContent);
  196. continue;
  197. }
  198. }
  199. $encodedLatex = htmlspecialchars($latexContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
  200. $renderedContent = str_replace($placeholder, $encodedLatex, $renderedContent);
  201. }
  202. if ($normalizeChineseTerminalPeriod) {
  203. $renderedContent = self::normalizeChineseTerminalPeriod($renderedContent);
  204. }
  205. return [$renderedContent, $renderedContent !== $content];
  206. }
  207. public static function defaultBlankSpan(): string
  208. {
  209. return self::DEFAULT_BLANK_SPAN;
  210. }
  211. /**
  212. * 统一句尾标点(仅处理句尾,不影响中间小数/表达式)
  213. *
  214. * $mode:
  215. * - remove: 去掉句尾句号
  216. * - dot: 句尾统一为英文实心点 "."
  217. * - cn: 句尾统一为中文句号 "。"
  218. */
  219. public static function normalizeTerminalPunctuation(string $content, string $mode): string
  220. {
  221. $replacement = match ($mode) {
  222. 'remove' => '',
  223. 'dot' => '.',
  224. 'cn' => '。',
  225. default => null,
  226. };
  227. if ($replacement === null) {
  228. return $content;
  229. }
  230. // 仅处理句尾最后一个标点(允许句尾带 HTML 标签,如 <image .../>)。
  231. // 1) 先处理数学片段尾点(如 "$.$" / "$。$" / "$.$")。
  232. if (preg_match('/^(.*)\$\s*[\..。]\s*\$(\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$/us', $content, $m)) {
  233. return $m[1].$replacement.$m[2];
  234. }
  235. // 2) 再处理普通句尾点(只替换最后一个,不影响中间文本)。
  236. if (preg_match('/^(.*?)([\..。])(\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$/us', $content, $m)) {
  237. return $m[1].$replacement.$m[3];
  238. }
  239. return $content;
  240. }
  241. /**
  242. * 仅当句尾不存在句号类标点时,追加目标标点。
  243. * 不会覆盖已存在的句尾标点,也不处理正文中间内容。
  244. */
  245. public static function appendTerminalPunctuationIfMissing(string $content, string $punctuation): string
  246. {
  247. if ($punctuation === '') {
  248. return $content;
  249. }
  250. // 末尾若是图像类媒体标签(例如 <image .../>),不追加标点,避免出现独立一行的孤立小点。
  251. if (preg_match('/(?:<\s*image\b[^>]*\/?>|<\s*img\b[^>]*\/?>|<\s*svg\b[\s\S]*<\/\s*svg\s*>)(?:\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$/iu', $content)) {
  252. return $content;
  253. }
  254. // 句尾若已有终止符号(按“可见文本”判断,避免把 HTML 实体中的分号误判为终止符)
  255. $visibleText = html_entity_decode(strip_tags($content), ENT_QUOTES | ENT_HTML5, 'UTF-8');
  256. $visibleText = rtrim($visibleText);
  257. if ($visibleText !== '' && preg_match('/[\..。!!\??;;::]$/u', $visibleText)) {
  258. return $content;
  259. }
  260. return rtrim($content).$punctuation;
  261. }
  262. /**
  263. * 填空题常见写法:"...。 (说明)" / "...。(说明)"(后面可能跟图片标签)
  264. * 将该处句号统一为英文小点,避免句尾媒体标签场景下出现错误补点。
  265. */
  266. public static function normalizePeriodBeforeTrailingParentheticalNote(string $content, string $replacement = '.'): string
  267. {
  268. return preg_replace(
  269. '/[。.\.](?=\s*[((][^))]{1,80}[))]\s*(?:(?:<[^>]+>\s*)*)$)/u',
  270. $replacement,
  271. $content,
  272. 1
  273. ) ?? $content;
  274. }
  275. private static function normalizeChineseTerminalPeriod(string $content): string
  276. {
  277. // 仅在存在中文语境时,把句末英文句号统一为中文句号。
  278. if (! preg_match('/\p{Han}/u', $content)) {
  279. return $content;
  280. }
  281. return self::normalizeTerminalPunctuation($content, 'cn');
  282. }
  283. }