MathFormulaProcessor.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. <?php
  2. namespace App\Services;
  3. class MathFormulaProcessor
  4. {
  5. /**
  6. * 处理数学公式,确保有正确的 LaTeX 标记
  7. *
  8. * 优化策略:最小化干预,只修复真正需要修复的问题
  9. * 1. 检查是否已有正确的 LaTeX 标记,如有则直接返回
  10. * 2. 只在检测到明显错误时才进行修复
  11. * 3. 优先保护正确的数学表达式不被破坏
  12. */
  13. public static function processFormulas(string $content): string
  14. {
  15. if (empty($content)) {
  16. return $content;
  17. }
  18. // 0. 基础清理:解码 HTML 实体
  19. $decoded = html_entity_decode($content, ENT_QUOTES, 'UTF-8');
  20. while ($decoded !== $content) {
  21. $content = $decoded;
  22. $decoded = html_entity_decode($content, ENT_QUOTES, 'UTF-8');
  23. }
  24. $content = trim($content);
  25. // 0.5 将自定义 <image> 标签转换为标准 <img> 标签
  26. $content = self::convertImageTags($content);
  27. // 1. 【关键修复】处理公式内的双反斜杠 -> 单反斜杠
  28. // 数据库存储时 \sqrt 变成 \\sqrt,需要还原
  29. $content = self::normalizeBackslashesInDelimiters($content);
  30. // 2. 如果内容中包含定界符,清理内部 HTML
  31. if (self::containsDelimiters($content)) {
  32. $content = self::cleanInsideDelimiters($content);
  33. }
  34. // 3. 检测内容类型:纯数学、混合内容还是纯文本
  35. $contentType = self::detectContentType($content);
  36. // 4. 根据内容类型采取不同的处理策略
  37. switch ($contentType) {
  38. case 'pure_math':
  39. // 纯数学表达式,如 "4x^2 - 25y^2" 或 "f(x) = x^2 - 4x + 5"
  40. return self::wrapPureMath($content);
  41. case 'mixed_content':
  42. // 混合内容,如 "已知函数 f(x) = x^2 - 4x + 5,求最小值"
  43. return self::smartWrapMixedContent($content);
  44. case 'plain_text':
  45. default:
  46. // 纯文本,不需要处理
  47. return $content;
  48. }
  49. }
  50. /**
  51. * 将自定义 <image> 标签转换为标准 <img> 标签
  52. * 例如:<image src="https://example.com/1.png"/> => <img src="https://example.com/1.png" />
  53. */
  54. private static function convertImageTags(string $content): string
  55. {
  56. // 匹配 <image src="..." /> 或 <image src="..."></image> 格式
  57. return preg_replace(
  58. '/<image\s+src=["\']([^"\']+)["\'](?:\s*\/>|><\/image>)/i',
  59. '<img src="$1" />',
  60. $content
  61. );
  62. }
  63. /**
  64. * 【新增】将公式定界符内的双反斜杠转为单反斜杠
  65. * 与前端 MathText.tsx 的 preprocessText 逻辑保持一致
  66. */
  67. private static function normalizeBackslashesInDelimiters(string $content): string
  68. {
  69. // 1. 处理 $$...$$ 块级公式内的双反斜杠
  70. $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) {
  71. $tex = str_replace('\\\\', '\\', $matches[1]);
  72. return '$$' . $tex . '$$';
  73. }, $content);
  74. // 2. 处理 $...$ 行内公式内的双反斜杠(避免与$$冲突)
  75. $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) {
  76. $tex = str_replace('\\\\', '\\', $matches[1]);
  77. return '$' . $tex . '$';
  78. }, $content);
  79. // 3. 处理 \(...\) 行内公式
  80. $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) {
  81. $tex = str_replace('\\\\', '\\', $matches[1]);
  82. return '\\(' . $tex . '\\)';
  83. }, $content);
  84. // 4. 处理 \[...\] 块级公式
  85. $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) {
  86. $tex = str_replace('\\\\', '\\', $matches[1]);
  87. return '\\[' . $tex . '\\]';
  88. }, $content);
  89. return $content;
  90. }
  91. /**
  92. * 【新增】检查内容中是否包含任意定界符(不要求整个字符串被包裹)
  93. */
  94. private static function containsDelimiters(string $content): bool
  95. {
  96. // 检查是否包含 $...$, $$...$$, \(...\), \[...\]
  97. return preg_match('/\$\$[\s\S]*?\$\$|\$[^$\n]+?\$|\\\\\([\s\S]*?\\\\\)|\\\\\[[\s\S]*?\\\\\]/', $content) === 1;
  98. }
  99. /**
  100. * 检测内容类型
  101. * 优化:加入中文检测,避免包裹包含中文的混合内容
  102. */
  103. private static function detectContentType(string $content): string
  104. {
  105. // 检查是否有定界符
  106. if (self::hasDelimiters($content)) {
  107. return 'delimited';
  108. }
  109. // 检查是否包含数学特征
  110. $hasMathFeatures = self::containsMathFeatures($content);
  111. // 如果不包含数学特征,返回纯文本
  112. if (!$hasMathFeatures) {
  113. return 'plain_text';
  114. }
  115. // 检查是否包含中文字符
  116. if (preg_match('/[\x{4e00}-\x{9fa5}]/u', $content)) {
  117. // 包含中文 + 数学特征 = 混合内容,需要智能提取数学部分
  118. return 'mixed_content';
  119. }
  120. // 检查是否包含长文本(超过一定长度的字母组合)
  121. $hasLongText = preg_match('/[a-zA-Z]{8,}/', $content);
  122. if ($hasLongText) {
  123. // 包含长文本,可能是混合内容,但不包裹(保守策略)
  124. return 'plain_text';
  125. }
  126. // 检查是纯数学还是混合内容
  127. // 混合内容:同时包含数学特征和普通英文单词
  128. $hasPlainText = preg_match('/\b[a-zA-Z]{3,7}\b/', $content) &&
  129. !preg_match('/^[a-zA-Z0-9\+\-\*\/\=\s\.\^\(\)\_\{\}]+$/', $content);
  130. if ($hasPlainText) {
  131. return 'mixed_content';
  132. }
  133. return 'pure_math';
  134. }
  135. /**
  136. * 包裹纯数学表达式
  137. * 优化:只添加定界符,不修改内容本身
  138. */
  139. private static function wrapPureMath(string $content): string
  140. {
  141. // 已经是纯数学格式,直接用 $ 包裹
  142. return '$' . $content . '$';
  143. }
  144. /**
  145. * 清理定界符内部的 HTML 标签
  146. */
  147. private static function cleanInsideDelimiters(string $content): string
  148. {
  149. // 修复:使用更精确的正则表达式,避免模式冲突
  150. // 先处理 $$...$$ 模式,然后处理单个 $...$ 模式(但确保不被$$包含)
  151. // 1. 处理 $$...$$ 显示公式
  152. $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) {
  153. $cleanContent = strip_tags($matches[1]);
  154. $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
  155. $cleanContent = trim($cleanContent);
  156. return '$$' . $cleanContent . '$$';
  157. }, $content);
  158. // 2. 处理 \(...\) 行内公式
  159. $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) {
  160. $cleanContent = strip_tags($matches[1]);
  161. $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
  162. $cleanContent = trim($cleanContent);
  163. return '\\(' . $cleanContent . '\\)';
  164. }, $content);
  165. // 3. 处理 \[...\] 显示公式
  166. $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) {
  167. $cleanContent = strip_tags($matches[1]);
  168. $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
  169. $cleanContent = trim($cleanContent);
  170. return '\\[' . $cleanContent . '\\]';
  171. }, $content);
  172. // 4. 最后处理 $...$ 行内公式(避免与$$冲突)
  173. $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) {
  174. $cleanContent = strip_tags($matches[1]);
  175. $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
  176. $cleanContent = trim($cleanContent);
  177. return '$' . $cleanContent . '$';
  178. }, $content);
  179. return $content;
  180. }
  181. /**
  182. * 智能识别并包裹富文本中的数学公式
  183. * 支持:函数定义、导数表达式、LaTeX命令、数学运算
  184. */
  185. private static function smartWrapMixedContent(string $content): string
  186. {
  187. // 匹配策略:只匹配明确的数学表达式,避免误判
  188. $tagPattern = '<[^>]+>';
  189. $existingDelimiterPattern = '(?:\$\$[\s\S]*?\$\$|\$[\s\S]*?\$|\\\\\([\s\S]*?\\\\\)|\\\\\[[\s\S]*?\\\\\])';
  190. // 数学公式模式(按优先级排列)
  191. $patterns = [
  192. // 1. 函数定义: f(x) = 2x^3 - 3x^2 + 4x - 5
  193. "[a-zA-Z]'?\\([a-zA-Z0-9,\\s]+\\)\\s*=\\s*[a-zA-Z0-9\\+\\-\\*\\/\\^\\s\\.\\(\\)\\_\\{\\}]+",
  194. // 2. 导数/函数调用: f'(1), g(5), sin(x)
  195. "[a-zA-Z]+'?\\([a-zA-Z0-9\\+\\-\\*\\/\\^\\s\\.]+\\)",
  196. // 3. LaTeX 命令: \frac{1}{2}
  197. "\\\\[a-zA-Z]+\\{[^}]*\\}(?:\\{[^}]*\\})?",
  198. // 4. 数学表达式: x^2 + y^2, 2x - 3
  199. "[a-zA-Z0-9]+[\\^_][a-zA-Z0-9\\{\\}]+(?:\\s*[\\+\\-\\*\\/]\\s*[a-zA-Z0-9\\^_\\{\\}\\.]+)*",
  200. ];
  201. $mathPattern = '(?:' . implode('|', $patterns) . ')';
  202. $pattern = "/($tagPattern)|($existingDelimiterPattern)|($mathPattern)/u";
  203. return preg_replace_callback($pattern, function ($matches) {
  204. // HTML 标签,原样返回
  205. if (!empty($matches[1])) {
  206. return $matches[1];
  207. }
  208. // 已有的定界符,原样返回
  209. if (!empty($matches[2])) {
  210. return $matches[2];
  211. }
  212. // 数学公式,添加 $ 包裹
  213. if (!empty($matches[3])) {
  214. $math = trim($matches[3]);
  215. // 再次检查是否已经包裹
  216. if (str_contains($math, '$')) {
  217. return $math;
  218. }
  219. return '$' . $math . '$';
  220. }
  221. return $matches[0];
  222. }, $content);
  223. }
  224. /**
  225. * 检查是否已有定界符
  226. */
  227. private static function hasDelimiters(string $content): bool
  228. {
  229. $content = trim($content);
  230. // 检查 $$...$$
  231. if (str_starts_with($content, '$$') && str_ends_with($content, '$$')) {
  232. return true;
  233. }
  234. // 检查 $...$
  235. if (str_starts_with($content, '$') && str_ends_with($content, '$')) {
  236. return true;
  237. }
  238. // 检查 \[...\]
  239. if (str_starts_with($content, '\\[') && str_ends_with($content, '\\]')) {
  240. return true;
  241. }
  242. // 检查 \(...\)
  243. if (str_starts_with($content, '\\(') && str_ends_with($content, '\\)')) {
  244. return true;
  245. }
  246. return false;
  247. }
  248. /**
  249. * 检测数学特征
  250. * 优化:更精确的检测,减少误判
  251. */
  252. private static function containsMathFeatures(string $content): bool
  253. {
  254. // 1. 检查是否有 LaTeX 命令
  255. if (preg_match('/\\\\[a-zA-Z]+\{?/', $content)) {
  256. return true;
  257. }
  258. // 2. 检查函数定义或等式(如 f(x) =, g(x) =)
  259. // 必须是:字母+括号+等号+数学内容
  260. if (preg_match('/[a-zA-Z]\([a-zA-Z0-9,\s]+\)\s*=\s*[a-zA-Z0-9\+\-\*\/\^\.\(\)\s\\\\_\{]+/', $content)) {
  261. return true;
  262. }
  263. // 3. 检查纯数学表达式(只包含数字、变量、运算符、括号)
  264. // 严格的数学表达式:必须包含字母和运算符,且没有中文字符
  265. if (preg_match('/^[a-zA-Z0-9\+\-\*\/\=\s\.\^\(\)\_\{\}]+$/', $content) &&
  266. preg_match('/[a-zA-Z]/', $content) &&
  267. preg_match('/[\+\-\*\/\=\^]/', $content)) {
  268. return true;
  269. }
  270. // 4. 检查包含变量的数学表达式(带约束)
  271. // 必须有明确的运算符连接,且周围是数学内容
  272. if (preg_match('/[a-zA-Z0-9\.\^\_\{\}]\s*[\+\-\*\/]\s*[a-zA-Z0-9\.\^\_\{\}\(\)]/', $content)) {
  273. return true;
  274. }
  275. // 5. 检查分数形式(如 \frac{}{})
  276. if (preg_match('/\\\\frac\{/', $content)) {
  277. return true;
  278. }
  279. // 6. 检查上标或下标(仅当与数字/字母组合时)
  280. if (preg_match('/[a-zA-Z0-9]\s*[\^_]\s*[a-zA-Z0-9]/', $content)) {
  281. return true;
  282. }
  283. return false;
  284. }
  285. /**
  286. * 批量处理
  287. */
  288. public static function processArray(array $data, array $fieldsToProcess): array
  289. {
  290. foreach ($data as $key => &$value) {
  291. if (in_array($key, $fieldsToProcess) && is_string($value)) {
  292. $value = self::processFormulas($value);
  293. } elseif (is_array($value)) {
  294. $value = self::processArray($value, $fieldsToProcess);
  295. }
  296. }
  297. return $data;
  298. }
  299. /**
  300. * 处理题目数据
  301. */
  302. public static function processQuestionData(array $question): array
  303. {
  304. $fieldsToProcess = [
  305. 'stem', 'content', 'question_text', 'answer',
  306. 'correct_answer', 'student_answer', 'explanation',
  307. 'solution', 'question_content', 'options'
  308. ];
  309. return self::processArray($question, $fieldsToProcess);
  310. }
  311. /**
  312. * 修复被污染的数学公式(包含重复的转义字符)
  313. */
  314. private static function fixCorruptedFormulas(string $content): string
  315. {
  316. // 简化的修复策略,只处理明确的问题
  317. // 1. 将超过2个连续的$符号减少为2个
  318. $content = preg_replace('/\${3,}/', '$$', $content);
  319. // 2. 修复$$B . - \frac{1}{2}$$ 这种格式,在选项前加空格
  320. $content = preg_replace('/\$\$([A-Z])\s*\.\s*/', '$$ $1. ', $content);
  321. // 3. 修复不完整的frac命令:\frac{1}{2} -> \frac{1}{2}
  322. $content = preg_replace('/\\\\frac\\\\({[^}]+)([^}]*)\\\\/', '\\\\frac$1}{$2}', $content);
  323. // 4. 移除孤立的反斜杠(在非LaTeX命令前的)
  324. $content = preg_replace('/\\\\(?![a-zA-Z{])/', '', $content);
  325. return $content;
  326. }
  327. }