MathFormulaProcessor.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. <?php
  2. namespace App\Services;
  3. class MathFormulaProcessor
  4. {
  5. /**
  6. * 处理数学公式,确保有正确的 LaTeX 标记
  7. *
  8. * 策略:
  9. * 1. 清理 HTML 标签和实体
  10. * 2. 规范化反斜杠(处理多重转义)
  11. * 3. 修复丢失反斜杠的常见 LaTeX 命令
  12. * 4. 确保公式被正确的定界符包裹
  13. */
  14. public static function processFormulas(string $content): string
  15. {
  16. if (empty($content)) {
  17. return $content;
  18. }
  19. // 1. 基础清理
  20. // 递归解码 HTML 实体
  21. $decoded = html_entity_decode($content, ENT_QUOTES, 'UTF-8');
  22. while ($decoded !== $content) {
  23. $content = $decoded;
  24. $decoded = html_entity_decode($content, ENT_QUOTES, 'UTF-8');
  25. }
  26. $content = trim($content);
  27. // 2. 规范化反斜杠 - 修复重复转义问题
  28. // 暂时禁用修复功能,避免进一步污染
  29. // $content = self::fixCorruptedFormulas($content);
  30. // 3. 修复常见 LaTeX 命令
  31. $commands = [
  32. 'sqrt', 'frac', 'times', 'div', 'pm', 'cdot',
  33. 'sin', 'cos', 'tan', 'log', 'ln', 'lim',
  34. 'alpha', 'beta', 'gamma', 'theta', 'pi', 'sigma', 'omega', 'Delta',
  35. 'leq', 'geq', 'neq', 'approx', 'infty',
  36. 'sum', 'prod', 'int', 'partial', 'nabla'
  37. ];
  38. $pattern = '/(?<!\\\\)\b(' . implode('|', $commands) . ')\b/';
  39. $content = preg_replace($pattern, '\\\\$1', $content);
  40. // 4. 规范化 LaTeX 命令中的空格 (OCR 常见问题)
  41. // 4.1 移除 LaTeX 命令后的空格: \frac { -> \frac{
  42. $content = preg_replace('/\\\\([a-zA-Z]+)\s+\{/', '\\\\$1{', $content);
  43. // 4.2 移除花括号内的前导和尾随空格: { 1 } -> {1}
  44. $content = preg_replace('/\{\s+/', '{', $content);
  45. $content = preg_replace('/\s+\}/', '}', $content);
  46. // 4.3 移除上标/下标符号周围的空格: x ^ { a } -> x^{a}
  47. $content = preg_replace('/\s*\^\s*\{\s*/', '^{', $content);
  48. $content = preg_replace('/\s*_\s*\{\s*/', '_{', $content);
  49. // 4.4 移除 \left 和 \right 后的空格: \left ( -> \left(
  50. $content = preg_replace('/\\\\(left|right)\s+/', '\\\\$1', $content);
  51. // 4.5 移除括号内侧的空格: ( x ) -> (x)
  52. $content = preg_replace('/\(\s+/', '(', $content);
  53. $content = preg_replace('/\s+\)/', ')', $content);
  54. // 4.6 规范化多个连续空格为单个空格
  55. $content = preg_replace('/\s+/', ' ', $content);
  56. // 4.7 清理 OCR 错误产生的多余 $ 符号
  57. // 移除花括号内的 $: {a$} -> {a}
  58. $content = preg_replace('/\{([^}]*)\$+([^}]*)\}/', '{$1$2}', $content);
  59. // 移除末尾的多余 $$$
  60. $content = preg_replace('/\$+\s*$/', '', $content);
  61. // 移除开头的多余 $$$
  62. $content = preg_replace('/^\s*\$+/', '', $content);
  63. // 移除连续的 $$$ (3个或更多)
  64. $content = preg_replace('/\$\$\$+/', '$$', $content);
  65. // 5. 处理定界符
  66. // 如果内容已经是完整的公式(被 $ 或 $$ 包裹),则保持原样
  67. if (self::hasDelimiters($content)) {
  68. $content = self::cleanInsideDelimiters($content);
  69. return $content;
  70. }
  71. // 6. 智能包装 (统一处理混合内容)
  72. // 无论是纯文本还是富文本,都使用智能识别来包裹公式
  73. // 这能同时处理:
  74. // - "已知函数 f(x) = ..." (未包裹的混合内容)
  75. // - "验证:$2x...$" (部分包裹的混合内容)
  76. // - "4x^2 - 25y^2" (未包裹的纯公式)
  77. // 先清理已有的定界符内部
  78. $content = self::cleanInsideDelimiters($content);
  79. // 然后智能包裹剩余的数学部分
  80. $content = self::smartWrapMixedContent($content);
  81. return $content;
  82. }
  83. /**
  84. * 清理定界符内部的 HTML 标签
  85. */
  86. private static function cleanInsideDelimiters(string $content): string
  87. {
  88. // 修复:使用更精确的正则表达式,避免模式冲突
  89. // 先处理 $$...$$ 模式,然后处理单个 $...$ 模式(但确保不被$$包含)
  90. // 1. 处理 $$...$$ 显示公式
  91. $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) {
  92. $cleanContent = strip_tags($matches[1]);
  93. $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
  94. $cleanContent = trim($cleanContent);
  95. return '$$' . $cleanContent . '$$';
  96. }, $content);
  97. // 2. 处理 \(...\) 行内公式
  98. $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) {
  99. $cleanContent = strip_tags($matches[1]);
  100. $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
  101. $cleanContent = trim($cleanContent);
  102. return '\\(' . $cleanContent . '\\)';
  103. }, $content);
  104. // 3. 处理 \[...\] 显示公式
  105. $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) {
  106. $cleanContent = strip_tags($matches[1]);
  107. $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
  108. $cleanContent = trim($cleanContent);
  109. return '\\[' . $cleanContent . '\\]';
  110. }, $content);
  111. // 4. 最后处理 $...$ 行内公式(避免与$$冲突)
  112. $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) {
  113. $cleanContent = strip_tags($matches[1]);
  114. $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
  115. $cleanContent = trim($cleanContent);
  116. return '$' . $cleanContent . '$';
  117. }, $content);
  118. return $content;
  119. }
  120. /**
  121. * 智能识别并包裹富文本中的数学公式
  122. */
  123. private static function smartWrapMixedContent(string $content): string
  124. {
  125. // 正则策略:匹配 HTML 标签 OR 数学公式候选
  126. // 捕获组 1: HTML 标签 (忽略)
  127. // 捕获组 2: 已有的定界符 (忽略)
  128. // 捕获组 3: 数学公式 (处理)
  129. $tagPattern = '<[^>]+>';
  130. // 匹配已有的定界符
  131. $existingDelimiterPattern = '(?:\$\$[\s\S]*?\$\$|\$[\s\S]*?\$|\\\\\([\s\S]*?\\\\\)|\\\\\[[\s\S]*?\\\\\])';
  132. // 数学公式特征:
  133. // 1. 函数定义: f(x) = ...
  134. // 2. 等式/不等式: ... = ..., ... > ..., ... < ...
  135. // 3. 包含 LaTeX 命令: \sqrt, \frac 等
  136. // 4. 包含上标/下标: x^2, a_n
  137. // 匹配函数定义或等式 (例如 f(x) = 2x^2 + 1)
  138. // 必须包含 = 或 > 或 <,且周围有类数学字符
  139. $equationPattern = '(?<![\w\\\\])(?:[a-zA-Z]\([a-zA-Z0-9,]+\)|[a-zA-Z0-9\^_\{\}]+)\s*[=<>]\s*[\w\s\+\-\*\/\^\.\(\)\{\}\\\\]+(?=\s|$|<|[.,;])';
  140. // 匹配显式 LaTeX 命令 (例如 \sqrt{...})
  141. $latexPattern = '\\\\[a-zA-Z]+(?:\{[^\}]*\})?';
  142. // 匹配简单的代数项 (例如 x^2, a_n) - 需谨慎,避免匹配普通单词
  143. $algebraPattern = '(?<![\w\\\\])[a-zA-Z0-9]+\^[\w\{]+';
  144. // 匹配多项式/复杂表达式 (例如 4x^2 - 25y^2, 2x \times 2 + ...)
  145. // 特征:包含变量、数字、运算符 (+, -, *, /)、LaTeX命令、上标/下标
  146. // 必须包含至少一个运算符,且长度适中
  147. $polynomialPattern = '(?<![\w\\\\])(?:[a-zA-Z0-9\.]+(?:[\^_\{\}][a-zA-Z0-9\.\{\}]+)?|\\\\[a-zA-Z]+(?:\{[^\}]*\})?)(?:\s*[\+\-\*\/]\s*(?:[a-zA-Z0-9\.]+(?:[\^_\{\}][a-zA-Z0-9\.\{\}]+)?|\\\\[a-zA-Z]+(?:\{[^\}]*\})?))+';
  148. $pattern = "/($tagPattern)|($existingDelimiterPattern)|($equationPattern|$polynomialPattern|$latexPattern|$algebraPattern)/u";
  149. return preg_replace_callback($pattern, function ($matches) {
  150. // 如果是 HTML 标签 (组1),原样返回
  151. if (!empty($matches[1])) {
  152. return $matches[1];
  153. }
  154. // 如果是已有的定界符 (组2),原样返回
  155. if (!empty($matches[2])) {
  156. return $matches[2];
  157. }
  158. // 如果是数学公式 (组3)
  159. if (!empty($matches[3])) {
  160. $math = $matches[3];
  161. // 再次检查是否已经被包裹 (虽然外层逻辑应该处理了,但为了安全)
  162. if (str_contains($math, '$')) {
  163. return $math;
  164. }
  165. // 排除纯数字或普通单词误判
  166. if (preg_match('/^[a-zA-Z0-9\s]+$/', $math)) {
  167. return $math;
  168. }
  169. return '$' . $math . '$';
  170. }
  171. return $matches[0];
  172. }, $content);
  173. }
  174. /**
  175. * 检查是否已有定界符
  176. */
  177. private static function hasDelimiters(string $content): bool
  178. {
  179. $content = trim($content);
  180. // 检查 $$...$$
  181. if (str_starts_with($content, '$$') && str_ends_with($content, '$$')) {
  182. return true;
  183. }
  184. // 检查 $...$
  185. if (str_starts_with($content, '$') && str_ends_with($content, '$')) {
  186. return true;
  187. }
  188. // 检查 \[...\]
  189. if (str_starts_with($content, '\\[') && str_ends_with($content, '\\]')) {
  190. return true;
  191. }
  192. // 检查 \(...\)
  193. if (str_starts_with($content, '\\(') && str_ends_with($content, '\\)')) {
  194. return true;
  195. }
  196. return false;
  197. }
  198. /**
  199. * 检测数学特征
  200. */
  201. private static function containsMathFeatures(string $content): bool
  202. {
  203. // 1. 检查是否有 LaTeX 命令
  204. if (strpos($content, '\\') !== false) {
  205. return true;
  206. }
  207. // 2. 检查数学符号
  208. $symbols = ['+', '-', '*', '/', '=', '<', '>', '^', '_', '{', '}'];
  209. foreach ($symbols as $symbol) {
  210. if (strpos($content, $symbol) !== false) {
  211. // 排除普通文本中的符号(如连字符),这里做一个简单的宽容判断
  212. // 如果有数字紧随其后,或者是特定组合
  213. return true;
  214. }
  215. }
  216. // 3. 检查数字和字母的组合 (如 2x, x^2)
  217. if (preg_match('/[a-zA-Z]\d|\d[a-zA-Z]/', $content)) {
  218. return true;
  219. }
  220. return false;
  221. }
  222. /**
  223. * 批量处理
  224. */
  225. public static function processArray(array $data, array $fieldsToProcess): array
  226. {
  227. foreach ($data as $key => &$value) {
  228. if (in_array($key, $fieldsToProcess) && is_string($value)) {
  229. $value = self::processFormulas($value);
  230. } elseif (is_array($value)) {
  231. $value = self::processArray($value, $fieldsToProcess);
  232. }
  233. }
  234. return $data;
  235. }
  236. /**
  237. * 处理题目数据
  238. */
  239. public static function processQuestionData(array $question): array
  240. {
  241. $fieldsToProcess = [
  242. 'stem', 'content', 'question_text', 'answer',
  243. 'correct_answer', 'student_answer', 'explanation',
  244. 'solution', 'question_content'
  245. ];
  246. return self::processArray($question, $fieldsToProcess);
  247. }
  248. /**
  249. * 修复被污染的数学公式(包含重复的转义字符)
  250. */
  251. private static function fixCorruptedFormulas(string $content): string
  252. {
  253. // 简化的修复策略,只处理明确的问题
  254. // 1. 将超过2个连续的$符号减少为2个
  255. $content = preg_replace('/\${3,}/', '$$', $content);
  256. // 2. 修复$$B . - \frac{1}{2}$$ 这种格式,在选项前加空格
  257. $content = preg_replace('/\$\$([A-Z])\s*\.\s*/', '$$ $1. ', $content);
  258. // 3. 修复不完整的frac命令:\frac{1}{2} -> \frac{1}{2}
  259. $content = preg_replace('/\\\\frac\\\\({[^}]+)([^}]*)\\\\/', '\\\\frac$1}{$2}', $content);
  260. // 4. 移除孤立的反斜杠(在非LaTeX命令前的)
  261. $content = preg_replace('/\\\\(?![a-zA-Z{])/', '', $content);
  262. return $content;
  263. }
  264. }