katex-render.mjs 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. #!/usr/bin/env node
  2. /**
  3. * KaTeX 服务端渲染脚本
  4. * 用法: echo "HTML内容" | node katex-render.js
  5. * 或: node katex-render.js < input.html > output.html
  6. *
  7. * 将 HTML 中的 LaTeX 公式渲染为 KaTeX HTML
  8. */
  9. import { createRequire } from 'module';
  10. const require = createRequire(import.meta.url);
  11. // 尝试多个路径加载 KaTeX
  12. let katex;
  13. const possiblePaths = [
  14. '/usr/local/lib/node_modules/katex', // npm -g 全局安装路径
  15. '/usr/lib/node_modules/katex', // Alpine 系统路径
  16. 'katex', // 本地 node_modules
  17. ];
  18. let loadError = null;
  19. for (const modulePath of possiblePaths) {
  20. try {
  21. katex = require(modulePath);
  22. break;
  23. } catch (e) {
  24. loadError = e;
  25. }
  26. }
  27. if (!katex) {
  28. console.error('Error: KaTeX module not found.');
  29. console.error('Tried paths:', possiblePaths.join(', '));
  30. if (loadError) console.error('Last error:', loadError.message);
  31. process.exit(1);
  32. }
  33. // 读取标准输入
  34. let input = '';
  35. process.stdin.setEncoding('utf8');
  36. process.stdin.on('readable', () => {
  37. let chunk;
  38. while ((chunk = process.stdin.read()) !== null) {
  39. input += chunk;
  40. }
  41. });
  42. process.stdin.on('end', () => {
  43. try {
  44. const output = renderMathInHtml(input);
  45. process.stdout.write(output);
  46. } catch (error) {
  47. console.error('KaTeX render error:', error.message);
  48. process.exit(1);
  49. }
  50. });
  51. /**
  52. * 解码 HTML 实体
  53. */
  54. function decodeHtmlEntities(text) {
  55. return text
  56. .replace(/&lt;/g, '<')
  57. .replace(/&gt;/g, '>')
  58. .replace(/&amp;/g, '&')
  59. .replace(/&quot;/g, '"')
  60. .replace(/&#39;/g, "'")
  61. .replace(/&apos;/g, "'")
  62. .replace(/&nbsp;/g, ' ');
  63. }
  64. /**
  65. * 编码 HTML 实体(用于安全输出)
  66. */
  67. function encodeHtmlEntities(text) {
  68. return text
  69. .replace(/&/g, '&amp;')
  70. .replace(/</g, '&lt;')
  71. .replace(/>/g, '&gt;');
  72. }
  73. /**
  74. * 渲染 HTML 中的所有数学公式
  75. */
  76. function renderMathInHtml(html) {
  77. // 保护 script/style/pre/textarea,避免把JS/CSS代码里的 $...$ 误当成公式
  78. const protectedBlocks = [];
  79. const protectedHtml = html.replace(
  80. /<(script|style|textarea|pre)\b[\s\S]*?<\/\1>/gi,
  81. (block) => {
  82. const marker = `__KATEX_PROTECTED_BLOCK_${protectedBlocks.length}__`;
  83. protectedBlocks.push(block);
  84. return marker;
  85. }
  86. );
  87. // 定界符配置(按优先级排序)
  88. const delimiters = [
  89. { left: '$$', right: '$$', display: true },
  90. { left: '\\[', right: '\\]', display: true },
  91. { left: '\\(', right: '\\)', display: false },
  92. { left: '$', right: '$', display: false },
  93. ];
  94. let result = protectedHtml;
  95. // 按顺序处理每种定界符
  96. for (const delimiter of delimiters) {
  97. result = processDelimiter(result, delimiter.left, delimiter.right, delimiter.display);
  98. }
  99. // 恢复被保护的代码块
  100. return result.replace(/__KATEX_PROTECTED_BLOCK_(\d+)__/g, (_, idx) => {
  101. const i = Number(idx);
  102. return Number.isInteger(i) && protectedBlocks[i] ? protectedBlocks[i] : '';
  103. });
  104. }
  105. /**
  106. * 处理特定定界符的公式
  107. */
  108. function processDelimiter(html, left, right, displayMode) {
  109. // 转义正则特殊字符
  110. const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  111. const leftEscaped = escapeRegex(left);
  112. const rightEscaped = escapeRegex(right);
  113. // 构建正则表达式
  114. // 【修复】允许数学符号 < 和 >,只排除明确的 HTML 标签(如 <span>, </div>)
  115. let pattern;
  116. if (left === '$' && right === '$') {
  117. // 单个 $...$:不匹配 $$,允许 < > 但不匹配换行
  118. pattern = new RegExp(`(?<!\\$)\\$(?!\\$)([^$\\n]+?)(?<!\\$)\\$(?!\\$)`, 'g');
  119. } else if (left === '$$' && right === '$$') {
  120. // $$...$$:允许多行和 < >
  121. pattern = new RegExp(`\\$\\$([\\s\\S]*?)\\$\\$`, 'g');
  122. } else {
  123. // \(...\) 和 \[...\]:允许 < >
  124. pattern = new RegExp(`${leftEscaped}([\\s\\S]*?)${rightEscaped}`, 'g');
  125. }
  126. return html.replace(pattern, (match, latex) => {
  127. try {
  128. // 清理 LaTeX 内容 - 先解码 HTML 实体
  129. let cleanLatex = decodeHtmlEntities(latex.trim());
  130. // 跳过空内容
  131. if (!cleanLatex) {
  132. return match;
  133. }
  134. // 【新增】跳过包含 HTML 标签的内容(如 <span>, </div>, <br>)
  135. // 但允许数学符号 < 和 >(如 a > 0, x < y)
  136. if (/<\/?[a-zA-Z][^>]*>/.test(cleanLatex)) {
  137. return match;
  138. }
  139. // 【修复】处理公式内的换行符:将 \n 替换成空格,避免破坏公式
  140. // 使用负向前瞻 (?![a-zA-Z]) 避免误伤 LaTeX 命令如 \neq, \ne, \newline, \nu 等
  141. cleanLatex = cleanLatex.replace(/\\n(?![a-zA-Z])/g, ' ').replace(/\n/g, ' ');
  142. // 渲染 KaTeX
  143. const rendered = katex.renderToString(cleanLatex, {
  144. displayMode: displayMode,
  145. throwOnError: false,
  146. strict: false,
  147. trust: true,
  148. output: 'html',
  149. });
  150. return rendered;
  151. } catch (error) {
  152. // 渲染失败时保留原始内容
  153. // console.error(`KaTeX error for "${latex.substring(0, 50)}...":`, error.message);
  154. return match;
  155. }
  156. });
  157. }