MathFormulaProcessor.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\Log;
  4. class MathFormulaProcessor
  5. {
  6. /**
  7. * 处理数学公式,确保有正确的 LaTeX 标记
  8. *
  9. * 优化策略:最小化干预,只修复真正需要修复的问题
  10. * 1. 检查是否已有正确的 LaTeX 标记,如有则直接返回
  11. * 2. 只在检测到明显错误时才进行修复
  12. * 3. 优先保护正确的数学表达式不被破坏
  13. */
  14. public static function processFormulas(?string $content): string
  15. {
  16. if ($content === null || $content === '') {
  17. return '';
  18. }
  19. $content = (string) $content;
  20. // 0. 基础清理:解码 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. // 0.5 将自定义 <image> 标签转换为标准 <img> 标签
  28. $content = self::convertImageTags($content);
  29. // 0.6 规范化几何平行符:AB||CD -> AB∥CD
  30. // 仅处理大写点位字母场景,避免误伤通用竖线表达式
  31. $content = self::normalizeParallelSymbol($content);
  32. // 1. 【关键修复】处理公式内的双反斜杠 -> 单反斜杠
  33. // 数据库存储时 \sqrt 变成 \\sqrt,需要还原
  34. $content = self::normalizeBackslashesInDelimiters($content);
  35. // 2. 如果内容中包含定界符,清理内部 HTML
  36. if (self::containsDelimiters($content)) {
  37. $content = self::cleanInsideDelimiters($content);
  38. }
  39. // 3. 检测内容类型:纯数学、混合内容还是纯文本
  40. $contentType = self::detectContentType($content);
  41. // 4. 根据内容类型采取不同的处理策略
  42. switch ($contentType) {
  43. case 'pure_math':
  44. // 纯数学表达式,如 "4x^2 - 25y^2" 或 "f(x) = x^2 - 4x + 5"
  45. return self::wrapPureMath($content);
  46. case 'mixed_content':
  47. // 混合内容,如 "已知函数 f(x) = x^2 - 4x + 5,求最小值"
  48. return self::smartWrapMixedContent($content);
  49. case 'delimited':
  50. // 已包含定界符的内容($...$, $$...$$, \(...\), \[...\])
  51. // cleanInsideDelimiters() 已经清理了内部内容
  52. // 渲染工作由客户端 KaTeX 或服务端 KatexRenderer 完成
  53. // 【关键修复】将定界符内的 < > 编码为 HTML 实体,避免被浏览器当作 HTML 标签处理
  54. return self::encodeAngleBracketsInDelimiters($content);
  55. case 'plain_text':
  56. default:
  57. // 纯文本,不需要处理
  58. return $content;
  59. }
  60. }
  61. /**
  62. * 将自定义 <image> 标签转换为标准 <img> 标签
  63. * 例如:<image src="https://example.com/1.png"/> => <img src="https://example.com/1.png" />
  64. */
  65. private static function convertImageTags(string $content): string
  66. {
  67. // 匹配 <image src="..." /> 或 <image src="..."></image> 格式
  68. return preg_replace(
  69. '/<image\s+src=["\']([^"\']+)["\'](?:\s*\/>|><\/image>)/i',
  70. '<img src="$1" />',
  71. $content
  72. );
  73. }
  74. /**
  75. * 将几何文本中的 ASCII 平行符替换为数学平行符(∥)
  76. * 例如:AB||CD、AB || CD -> AB∥CD
  77. */
  78. private static function normalizeParallelSymbol(string $content): string
  79. {
  80. return preg_replace('/(?<!\\\\)([A-Z]{1,4})\s*\|\|\s*([A-Z]{1,4})/', '$1∥$2', $content) ?? $content;
  81. }
  82. /**
  83. * 【新增】将公式定界符内被JSON双重转义的LaTeX命令还原
  84. * 例如:\\sqrt -> \sqrt, \\frac -> \frac
  85. * 但保留矩阵换行符 \\ (后面不跟字母的情况)
  86. */
  87. private static function normalizeBackslashesInDelimiters(string $content): string
  88. {
  89. // 只替换 \\+小写字母 的情况(被JSON转义的LaTeX命令,如 \\sqrt -> \sqrt)
  90. // 保留 \\+大写字母 的情况(换行符后跟文本,如 \\CD 应保持为 \\CD)
  91. // 保留 \\+数字 或 \\+空白 的情况(矩阵换行符)
  92. $fixEscapedCommands = function ($tex) {
  93. // 保护多行环境中的换行符 \\,避免被误判为 LaTeX 命令
  94. $placeholder = '__KATEX_BR__';
  95. $originalTex = $tex;
  96. $protectedEnvs = [];
  97. $environments = [
  98. 'cases',
  99. 'aligned',
  100. 'align',
  101. 'align*',
  102. 'array',
  103. 'matrix',
  104. 'pmatrix',
  105. 'bmatrix',
  106. 'vmatrix',
  107. 'Vmatrix',
  108. 'gather',
  109. 'split',
  110. 'eqnarray',
  111. ];
  112. foreach ($environments as $env) {
  113. $pattern = '/\\\\begin\{' . preg_quote($env, '/') . '\}([\s\S]*?)\\\\end\{' . preg_quote($env, '/') . '\}/';
  114. $tex = preg_replace_callback($pattern, function ($m) use ($env, $placeholder) {
  115. $content = str_replace('\\\\', $placeholder, $m[1]);
  116. return '\\begin{' . $env . '}' . $content . '\\end{' . $env . '}';
  117. }, $tex);
  118. if ($tex !== $originalTex && !in_array($env, $protectedEnvs, true)) {
  119. $protectedEnvs[] = $env;
  120. }
  121. }
  122. // \\sqrt -> \sqrt, \\frac -> \frac, 但 \\CD 或 \\2 保持不变
  123. // 【修复】只匹配小写字母,因为 LaTeX 命令都是小写
  124. $tex = preg_replace('/\\\\\\\\([a-z])/', '\\\\$1', $tex);
  125. // 还原多行环境换行
  126. $tex = str_replace($placeholder, '\\\\', $tex);
  127. if ($protectedEnvs) {
  128. Log::debug('MathFormulaProcessor: protected multiline line breaks', [
  129. 'envs' => $protectedEnvs,
  130. ]);
  131. }
  132. return $tex;
  133. };
  134. // 1. 处理 $$...$$ 块级公式
  135. $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) use ($fixEscapedCommands) {
  136. return '$$'.$fixEscapedCommands($matches[1]).'$$';
  137. }, $content);
  138. // 2. 处理 $...$ 行内公式(避免与$$冲突)
  139. $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) use ($fixEscapedCommands) {
  140. return '$'.$fixEscapedCommands($matches[1]).'$';
  141. }, $content);
  142. // 3. 处理 \(...\) 行内公式
  143. $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) use ($fixEscapedCommands) {
  144. return '\\('.$fixEscapedCommands($matches[1]).'\\)';
  145. }, $content);
  146. // 4. 处理 \[...\] 块级公式
  147. $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) use ($fixEscapedCommands) {
  148. return '\\['.$fixEscapedCommands($matches[1]).'\\]';
  149. }, $content);
  150. return $content;
  151. }
  152. /**
  153. * 【新增】将定界符内的 < > 编码为 HTML 实体
  154. * 避免 LaTeX 公式中的 < > 被浏览器当作 HTML 标签处理
  155. * 例如:$x<4$ 中的 <4 会被浏览器当作无效标签移除
  156. */
  157. private static function encodeAngleBracketsInDelimiters(string $content): string
  158. {
  159. $encodeInner = function (string $tex): string {
  160. // 将 < 和 > 编码为 HTML 实体,KaTeX 会正确处理这些实体
  161. return str_replace(['<', '>'], ['&lt;', '&gt;'], $tex);
  162. };
  163. // 1. 处理 $$...$$ 块级公式
  164. $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) use ($encodeInner) {
  165. return '$$' . $encodeInner($matches[1]) . '$$';
  166. }, $content);
  167. // 2. 处理 $...$ 行内公式(避免与$$冲突)
  168. $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) use ($encodeInner) {
  169. return '$' . $encodeInner($matches[1]) . '$';
  170. }, $content);
  171. // 3. 处理 \(...\) 行内公式
  172. $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) use ($encodeInner) {
  173. return '\\(' . $encodeInner($matches[1]) . '\\)';
  174. }, $content);
  175. // 4. 处理 \[...\] 块级公式
  176. $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) use ($encodeInner) {
  177. return '\\[' . $encodeInner($matches[1]) . '\\]';
  178. }, $content);
  179. return $content;
  180. }
  181. /**
  182. * 【新增】检查内容中是否包含任意定界符(不要求整个字符串被包裹)
  183. */
  184. private static function containsDelimiters(string $content): bool
  185. {
  186. // 检查是否包含 $...$, $$...$$, \(...\), \[...\]
  187. return preg_match('/\$\$[\s\S]*?\$\$|\$[^$\n]+?\$|\\\\\([\s\S]*?\\\\\)|\\\\\[[\s\S]*?\\\\\]/', $content) === 1;
  188. }
  189. /**
  190. * 检测内容类型
  191. * 优化:加入中文检测,避免包裹包含中文的混合内容
  192. */
  193. private static function detectContentType(string $content): string
  194. {
  195. // 优先检查是否包含定界符(使用 containsDelimiters 检测混合内容中的公式)
  196. if (self::containsDelimiters($content)) {
  197. return 'delimited';
  198. }
  199. // 检查是否包含数学特征
  200. $hasMathFeatures = self::containsMathFeatures($content);
  201. // 如果不包含数学特征,返回纯文本
  202. if (! $hasMathFeatures) {
  203. return 'plain_text';
  204. }
  205. // 检查是否包含中文字符
  206. if (preg_match('/[\x{4e00}-\x{9fa5}]/u', $content)) {
  207. // 包含中文 + 数学特征 = 混合内容,需要智能提取数学部分
  208. return 'mixed_content';
  209. }
  210. // 检查是否包含长文本(超过一定长度的字母组合)
  211. $hasLongText = preg_match('/[a-zA-Z]{8,}/', $content);
  212. if ($hasLongText) {
  213. // 包含长文本,可能是混合内容,但不包裹(保守策略)
  214. return 'plain_text';
  215. }
  216. // 检查是纯数学还是混合内容
  217. // 混合内容:同时包含数学特征和普通英文单词
  218. $hasPlainText = preg_match('/\b[a-zA-Z]{3,7}\b/', $content) &&
  219. ! preg_match('/^[a-zA-Z0-9\+\-\*\/\=\s\.\^\(\)\_\{\}]+$/', $content);
  220. if ($hasPlainText) {
  221. return 'mixed_content';
  222. }
  223. return 'pure_math';
  224. }
  225. /**
  226. * 包裹纯数学表达式
  227. * 优化:只添加定界符,不修改内容本身
  228. */
  229. private static function wrapPureMath(string $content): string
  230. {
  231. // 已经是纯数学格式,直接用 $ 包裹
  232. // 【修复】编码 < > 避免被浏览器当作 HTML 标签
  233. $encoded = str_replace(['<', '>'], ['&lt;', '&gt;'], $content);
  234. return '$' . $encoded . '$';
  235. }
  236. /**
  237. * 清理定界符内部的 HTML 标签
  238. * 【修复】不再使用 strip_tags(),因为它会把 LaTeX 中的 < > 当作标签删除
  239. * 例如:$x<4$ 中的 <4 会被 strip_tags 误删
  240. */
  241. private static function cleanInsideDelimiters(string $content): string
  242. {
  243. // 修复:使用更精确的正则表达式,避免模式冲突
  244. // 只移除真正的 HTML 标签(如 <span>, <br>, </div> 等),保留数学符号 < >
  245. // 定义安全的 HTML 标签清理函数(只移除真正的 HTML 标签)
  246. $cleanHtmlTags = function (string $tex): string {
  247. // 只移除看起来像 HTML 标签的内容(以字母开头的标签)
  248. // 例如:<span>, </div>, <br/>, 但保留 <4, >0, x<y 等数学表达式
  249. $tex = preg_replace('/<\/?[a-zA-Z][a-zA-Z0-9]*[^>]*>/', '', $tex);
  250. // 解码 HTML 实体
  251. $tex = html_entity_decode($tex, ENT_QUOTES, 'UTF-8');
  252. return trim($tex);
  253. };
  254. // 1. 处理 $$...$$ 显示公式
  255. $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) use ($cleanHtmlTags) {
  256. return '$$' . $cleanHtmlTags($matches[1]) . '$$';
  257. }, $content);
  258. // 2. 处理 \(...\) 行内公式
  259. $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) use ($cleanHtmlTags) {
  260. return '\\(' . $cleanHtmlTags($matches[1]) . '\\)';
  261. }, $content);
  262. // 3. 处理 \[...\] 显示公式
  263. $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) use ($cleanHtmlTags) {
  264. return '\\[' . $cleanHtmlTags($matches[1]) . '\\]';
  265. }, $content);
  266. // 4. 最后处理 $...$ 行内公式(避免与$$冲突)
  267. $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) use ($cleanHtmlTags) {
  268. return '$' . $cleanHtmlTags($matches[1]) . '$';
  269. }, $content);
  270. return $content;
  271. }
  272. /**
  273. * 智能识别并包裹富文本中的数学公式
  274. * 支持:函数定义、导数表达式、LaTeX命令、数学运算
  275. */
  276. private static function smartWrapMixedContent(string $content): string
  277. {
  278. // 匹配策略:只匹配明确的数学表达式,避免误判
  279. $tagPattern = '<[^>]+>';
  280. $existingDelimiterPattern = '(?:\$\$[\s\S]*?\$\$|\$[\s\S]*?\$|\\\\\([\s\S]*?\\\\\)|\\\\\[[\s\S]*?\\\\\])';
  281. // 数学公式模式(按优先级排列)
  282. $patterns = [
  283. // 1. 函数定义: f(x) = 2x^3 - 3x^2 + 4x - 5
  284. "[a-zA-Z]'?\\([a-zA-Z0-9,\\s]+\\)\\s*=\\s*[a-zA-Z0-9\\+\\-\\*\\/\\^\\s\\.\\(\\)\\_\\{\\}]+",
  285. // 2. 导数/函数调用: f'(1), g(5), sin(x)
  286. "[a-zA-Z]+'?\\([a-zA-Z0-9\\+\\-\\*\\/\\^\\s\\.]+\\)",
  287. // 3. LaTeX 命令: \frac{1}{2}
  288. '\\\\[a-zA-Z]+\\{[^}]*\\}(?:\\{[^}]*\\})?',
  289. // 4. 数学表达式: x^2 + y^2, 2x - 3
  290. '[a-zA-Z0-9]+[\\^_][a-zA-Z0-9\\{\\}]+(?:\\s*[\\+\\-\\*\\/]\\s*[a-zA-Z0-9\\^_\\{\\}\\.]+)*',
  291. ];
  292. $mathPattern = '(?:'.implode('|', $patterns).')';
  293. $pattern = "/($tagPattern)|($existingDelimiterPattern)|($mathPattern)/u";
  294. return preg_replace_callback($pattern, function ($matches) {
  295. // HTML 标签,原样返回
  296. if (! empty($matches[1])) {
  297. return $matches[1];
  298. }
  299. // 已有的定界符,原样返回
  300. if (! empty($matches[2])) {
  301. return $matches[2];
  302. }
  303. // 数学公式,添加 $ 包裹
  304. if (! empty($matches[3])) {
  305. $math = trim($matches[3]);
  306. // 再次检查是否已经包裹
  307. if (str_contains($math, '$')) {
  308. return $math;
  309. }
  310. // 【修复】编码 < > 避免被浏览器当作 HTML 标签
  311. $encoded = str_replace(['<', '>'], ['&lt;', '&gt;'], $math);
  312. return '$' . $encoded . '$';
  313. }
  314. return $matches[0];
  315. }, $content);
  316. }
  317. /**
  318. * 检查是否已有定界符
  319. */
  320. private static function hasDelimiters(string $content): bool
  321. {
  322. $content = trim($content);
  323. // 检查 $$...$$
  324. if (str_starts_with($content, '$$') && str_ends_with($content, '$$')) {
  325. return true;
  326. }
  327. // 检查 $...$
  328. if (str_starts_with($content, '$') && str_ends_with($content, '$')) {
  329. return true;
  330. }
  331. // 检查 \[...\]
  332. if (str_starts_with($content, '\\[') && str_ends_with($content, '\\]')) {
  333. return true;
  334. }
  335. // 检查 \(...\)
  336. if (str_starts_with($content, '\\(') && str_ends_with($content, '\\)')) {
  337. return true;
  338. }
  339. return false;
  340. }
  341. /**
  342. * 检测数学特征
  343. * 优化:更精确的检测,减少误判
  344. */
  345. private static function containsMathFeatures(string $content): bool
  346. {
  347. // 1. 检查是否有 LaTeX 命令
  348. if (preg_match('/\\\\[a-zA-Z]+\{?/', $content)) {
  349. return true;
  350. }
  351. // 2. 检查函数定义或等式(如 f(x) =, g(x) =)
  352. // 必须是:字母+括号+等号+数学内容
  353. if (preg_match('/[a-zA-Z]\([a-zA-Z0-9,\s]+\)\s*=\s*[a-zA-Z0-9\+\-\*\/\^\.\(\)\s\\\\_\{]+/', $content)) {
  354. return true;
  355. }
  356. // 3. 检查纯数学表达式(只包含数字、变量、运算符、括号)
  357. // 严格的数学表达式:必须包含字母和运算符,且没有中文字符
  358. if (preg_match('/^[a-zA-Z0-9\+\-\*\/\=\s\.\^\(\)\_\{\}]+$/', $content) &&
  359. preg_match('/[a-zA-Z]/', $content) &&
  360. preg_match('/[\+\-\*\/\=\^]/', $content)) {
  361. return true;
  362. }
  363. // 4. 检查包含变量的数学表达式(带约束)
  364. // 必须有明确的运算符连接,且周围是数学内容
  365. if (preg_match('/[a-zA-Z0-9\.\^\_\{\}]\s*[\+\-\*\/]\s*[a-zA-Z0-9\.\^\_\{\}\(\)]/', $content)) {
  366. return true;
  367. }
  368. // 5. 检查分数形式(如 \frac{}{})
  369. if (preg_match('/\\\\frac\{/', $content)) {
  370. return true;
  371. }
  372. // 6. 检查上标或下标(仅当与数字/字母组合时)
  373. if (preg_match('/[a-zA-Z0-9]\s*[\^_]\s*[a-zA-Z0-9]/', $content)) {
  374. return true;
  375. }
  376. return false;
  377. }
  378. /**
  379. * 批量处理
  380. */
  381. public static function processArray(array $data, array $fieldsToProcess): array
  382. {
  383. foreach ($data as $key => &$value) {
  384. if (in_array($key, $fieldsToProcess)) {
  385. if (is_string($value)) {
  386. $value = self::processFormulas($value);
  387. } elseif (is_array($value)) {
  388. // 【修复】当字段在处理列表中且值是数组时(如 options),处理数组中的每个字符串元素
  389. $value = self::processArrayValues($value);
  390. }
  391. } elseif (is_array($value)) {
  392. $value = self::processArray($value, $fieldsToProcess);
  393. }
  394. }
  395. return $data;
  396. }
  397. /**
  398. * 【新增】递归处理数组中的所有字符串值
  399. * 用于处理 options 等数组类型的字段
  400. */
  401. private static function processArrayValues(array $arr): array
  402. {
  403. foreach ($arr as $key => &$value) {
  404. if (is_string($value)) {
  405. $value = self::processFormulas($value);
  406. } elseif (is_array($value)) {
  407. $value = self::processArrayValues($value);
  408. }
  409. }
  410. return $arr;
  411. }
  412. /**
  413. * 处理题目数据
  414. */
  415. public static function processQuestionData(array $question): array
  416. {
  417. $fieldsToProcess = [
  418. 'stem', 'content', 'question_text', 'answer',
  419. 'correct_answer', 'student_answer', 'explanation',
  420. 'solution', 'question_content', 'options',
  421. ];
  422. // 学情报告等场景:题干已按判卷口径完整处理(含 processFormulas),避免二次处理
  423. if (! empty($question['question_text_preprocessed'])) {
  424. $fieldsToProcess = array_values(array_diff($fieldsToProcess, ['question_text']));
  425. }
  426. unset($question['question_text_preprocessed']);
  427. return self::processArray($question, $fieldsToProcess);
  428. }
  429. /**
  430. * 修复被污染的数学公式(包含重复的转义字符)
  431. */
  432. private static function fixCorruptedFormulas(string $content): string
  433. {
  434. // 简化的修复策略,只处理明确的问题
  435. // 1. 将超过2个连续的$符号减少为2个
  436. $content = preg_replace('/\${3,}/', '$$', $content);
  437. // 2. 修复$$B . - \frac{1}{2}$$ 这种格式,在选项前加空格
  438. $content = preg_replace('/\$\$([A-Z])\s*\.\s*/', '$$ $1. ', $content);
  439. // 3. 修复不完整的frac命令:\frac{1}{2} -> \frac{1}{2}
  440. $content = preg_replace('/\\\\frac\\\\({[^}]+)([^}]*)\\\\/', '\\\\frac$1}{$2}', $content);
  441. // 4. 移除孤立的反斜杠(在非LaTeX命令前的)
  442. $content = preg_replace('/\\\\(?![a-zA-Z{])/', '', $content);
  443. return $content;
  444. }
  445. }