MathFormulaProcessor.php 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  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. $content = preg_replace('/\\\\+([a-zA-Z])/', '\\\\$1', $content);
  29. // 3. 修复常见 LaTeX 命令
  30. $commands = [
  31. 'sqrt', 'frac', 'times', 'div', 'pm', 'cdot',
  32. 'sin', 'cos', 'tan', 'log', 'ln', 'lim',
  33. 'alpha', 'beta', 'gamma', 'theta', 'pi', 'sigma', 'omega', 'Delta',
  34. 'leq', 'geq', 'neq', 'approx', 'infty',
  35. 'sum', 'prod', 'int', 'partial', 'nabla'
  36. ];
  37. $pattern = '/(?<!\\\\)\b(' . implode('|', $commands) . ')\b/';
  38. $content = preg_replace($pattern, '\\\\$1', $content);
  39. // 4. 处理定界符
  40. // 如果内容已经是完整的公式(被 $ 或 $$ 包裹),则保持原样
  41. if (self::hasDelimiters($content)) {
  42. $content = self::cleanInsideDelimiters($content);
  43. return $content;
  44. }
  45. // 5. 智能包装 (统一处理混合内容)
  46. // 无论是纯文本还是富文本,都使用智能识别来包裹公式
  47. // 这能同时处理:
  48. // - "已知函数 f(x) = ..." (未包裹的混合内容)
  49. // - "验证:$2x...$" (部分包裹的混合内容)
  50. // - "4x^2 - 25y^2" (未包裹的纯公式)
  51. // 先清理已有的定界符内部
  52. $content = self::cleanInsideDelimiters($content);
  53. // 然后智能包裹剩余的数学部分
  54. $content = self::smartWrapMixedContent($content);
  55. return $content;
  56. }
  57. /**
  58. * 清理定界符内部的 HTML 标签
  59. */
  60. private static function cleanInsideDelimiters(string $content): string
  61. {
  62. // 定义定界符模式
  63. $patterns = [
  64. '/\$\$([\s\S]*?)\$\$/', // $$...$$
  65. '/\$([\s\S]*?)\$/', // $...$
  66. '/\\\\\(([\s\S]*?)\\\\\)/', // \(...\)
  67. '/\\\\\[([\s\S]*?)\\\\\]/' // \[...\]
  68. ];
  69. foreach ($patterns as $pattern) {
  70. $content = preg_replace_callback($pattern, function ($matches) {
  71. // $matches[0] 是完整匹配 (如 $...$)
  72. // $matches[1] 是内部内容
  73. // 清理内部的 HTML 标签
  74. $cleanContent = strip_tags($matches[1]);
  75. // 解码实体
  76. $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
  77. $cleanContent = trim($cleanContent);
  78. // 重建定界符 (保持原样)
  79. // 注意:我们需要根据原来的定界符类型来重建
  80. // 这里简单起见,我们直接用匹配到的完整字符串的定界符部分
  81. // 但 preg_replace_callback 不容易直接获取定界符,所以我们硬编码
  82. if (str_starts_with($matches[0], '$$')) return '$$' . $cleanContent . '$$';
  83. if (str_starts_with($matches[0], '$')) return '$' . $cleanContent . '$';
  84. if (str_starts_with($matches[0], '\[')) return '\[' . $cleanContent . '\]';
  85. if (str_starts_with($matches[0], '\(')) return '\(' . $cleanContent . '\)';
  86. // 默认回退 (不应该发生)
  87. return $matches[0];
  88. }, $content);
  89. }
  90. return $content;
  91. }
  92. /**
  93. * 智能识别并包裹富文本中的数学公式
  94. */
  95. private static function smartWrapMixedContent(string $content): string
  96. {
  97. // 正则策略:匹配 HTML 标签 OR 数学公式候选
  98. // 捕获组 1: HTML 标签 (忽略)
  99. // 捕获组 2: 已有的定界符 (忽略)
  100. // 捕获组 3: 数学公式 (处理)
  101. $tagPattern = '<[^>]+>';
  102. // 匹配已有的定界符
  103. $existingDelimiterPattern = '(?:\$\$[\s\S]*?\$\$|\$[\s\S]*?\$|\\\\\([\s\S]*?\\\\\)|\\\\\[[\s\S]*?\\\\\])';
  104. // 数学公式特征:
  105. // 1. 函数定义: f(x) = ...
  106. // 2. 等式/不等式: ... = ..., ... > ..., ... < ...
  107. // 3. 包含 LaTeX 命令: \sqrt, \frac 等
  108. // 4. 包含上标/下标: x^2, a_n
  109. // 匹配函数定义或等式 (例如 f(x) = 2x^2 + 1)
  110. // 必须包含 = 或 > 或 <,且周围有类数学字符
  111. $equationPattern = '(?<![\w\\\\])(?:[a-zA-Z]\([a-zA-Z0-9,]+\)|[a-zA-Z0-9\^_\{\}]+)\s*[=<>]\s*[\w\s\+\-\*\/\^\.\(\)\{\}\\\\]+(?=\s|$|<|[.,;])';
  112. // 匹配显式 LaTeX 命令 (例如 \sqrt{...})
  113. $latexPattern = '\\\\[a-zA-Z]+(?:\{[^\}]*\})?';
  114. // 匹配简单的代数项 (例如 x^2, a_n) - 需谨慎,避免匹配普通单词
  115. $algebraPattern = '(?<![\w\\\\])[a-zA-Z0-9]+\^[\w\{]+';
  116. // 匹配多项式/复杂表达式 (例如 4x^2 - 25y^2, 2x \times 2 + ...)
  117. // 特征:包含变量、数字、运算符 (+, -, *, /)、LaTeX命令、上标/下标
  118. // 必须包含至少一个运算符,且长度适中
  119. $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]+(?:\{[^\}]*\})?))+';
  120. $pattern = "/($tagPattern)|($existingDelimiterPattern)|($equationPattern|$polynomialPattern|$latexPattern|$algebraPattern)/u";
  121. return preg_replace_callback($pattern, function ($matches) {
  122. // 如果是 HTML 标签 (组1),原样返回
  123. if (!empty($matches[1])) {
  124. return $matches[1];
  125. }
  126. // 如果是已有的定界符 (组2),原样返回
  127. if (!empty($matches[2])) {
  128. return $matches[2];
  129. }
  130. // 如果是数学公式 (组3)
  131. if (!empty($matches[3])) {
  132. $math = $matches[3];
  133. // 再次检查是否已经被包裹 (虽然外层逻辑应该处理了,但为了安全)
  134. if (str_contains($math, '$')) {
  135. return $math;
  136. }
  137. // 排除纯数字或普通单词误判
  138. if (preg_match('/^[a-zA-Z0-9\s]+$/', $math)) {
  139. return $math;
  140. }
  141. return '$' . $math . '$';
  142. }
  143. return $matches[0];
  144. }, $content);
  145. }
  146. /**
  147. * 检查是否已有定界符
  148. */
  149. private static function hasDelimiters(string $content): bool
  150. {
  151. $content = trim($content);
  152. // 检查 $$...$$
  153. if (str_starts_with($content, '$$') && str_ends_with($content, '$$')) {
  154. return true;
  155. }
  156. // 检查 $...$
  157. if (str_starts_with($content, '$') && str_ends_with($content, '$')) {
  158. return true;
  159. }
  160. // 检查 \[...\]
  161. if (str_starts_with($content, '\\[') && str_ends_with($content, '\\]')) {
  162. return true;
  163. }
  164. // 检查 \(...\)
  165. if (str_starts_with($content, '\\(') && str_ends_with($content, '\\)')) {
  166. return true;
  167. }
  168. return false;
  169. }
  170. /**
  171. * 检测数学特征
  172. */
  173. private static function containsMathFeatures(string $content): bool
  174. {
  175. // 1. 检查是否有 LaTeX 命令
  176. if (strpos($content, '\\') !== false) {
  177. return true;
  178. }
  179. // 2. 检查数学符号
  180. $symbols = ['+', '-', '*', '/', '=', '<', '>', '^', '_', '{', '}'];
  181. foreach ($symbols as $symbol) {
  182. if (strpos($content, $symbol) !== false) {
  183. // 排除普通文本中的符号(如连字符),这里做一个简单的宽容判断
  184. // 如果有数字紧随其后,或者是特定组合
  185. return true;
  186. }
  187. }
  188. // 3. 检查数字和字母的组合 (如 2x, x^2)
  189. if (preg_match('/[a-zA-Z]\d|\d[a-zA-Z]/', $content)) {
  190. return true;
  191. }
  192. return false;
  193. }
  194. /**
  195. * 批量处理
  196. */
  197. public static function processArray(array $data, array $fieldsToProcess): array
  198. {
  199. foreach ($data as $key => &$value) {
  200. if (in_array($key, $fieldsToProcess) && is_string($value)) {
  201. $value = self::processFormulas($value);
  202. } elseif (is_array($value)) {
  203. $value = self::processArray($value, $fieldsToProcess);
  204. }
  205. }
  206. return $data;
  207. }
  208. /**
  209. * 处理题目数据
  210. */
  211. public static function processQuestionData(array $question): array
  212. {
  213. $fieldsToProcess = [
  214. 'stem', 'content', 'question_text', 'answer',
  215. 'correct_answer', 'student_answer', 'explanation',
  216. 'solution', 'question_content'
  217. ];
  218. return self::processArray($question, $fieldsToProcess);
  219. }
  220. }