interactive-post-processor.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /**
  2. * Interactive HTML Post-Processor
  3. *
  4. * Ported from Python's PostProcessor class (learn-your-way/concept_to_html.py:287-385)
  5. *
  6. * Handles:
  7. * - LaTeX delimiter conversion ($$...$$ -> \[...\], $...$ -> \(...\))
  8. * - KaTeX CSS/JS injection with auto-render and MutationObserver
  9. * - Script tag protection during LaTeX conversion
  10. */
  11. /**
  12. * Main entry point: post-process generated interactive HTML
  13. * Converts LaTeX delimiters and injects KaTeX rendering resources.
  14. */
  15. export function postProcessInteractiveHtml(html: string): string {
  16. // Convert LaTeX delimiters while protecting script tags
  17. let processed = convertLatexDelimiters(html);
  18. // Inject KaTeX resources if not already present
  19. if (!processed.toLowerCase().includes('katex')) {
  20. processed = injectKatex(processed);
  21. }
  22. return processed;
  23. }
  24. /**
  25. * Convert LaTeX delimiters while protecting <script> tags.
  26. *
  27. * - Protects script blocks from modification
  28. * - Converts $$...$$ to \[...\] (display math)
  29. * - Converts $...$ to \(...\) (inline math)
  30. * - Restores script blocks after conversion
  31. */
  32. function convertLatexDelimiters(html: string): string {
  33. const scriptBlocks: string[] = [];
  34. // Protect script tags by replacing them with placeholders
  35. let processed = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, (match) => {
  36. scriptBlocks.push(match);
  37. return `__SCRIPT_BLOCK_${scriptBlocks.length - 1}__`;
  38. });
  39. // Convert display math: $$...$$ -> \[...\]
  40. processed = processed.replace(/\$\$([^$]+)\$\$/g, '\\[$1\\]');
  41. // Convert inline math: $...$ -> \(...\)
  42. // Use non-greedy match and exclude newlines to avoid false positives
  43. processed = processed.replace(/\$([^$\n]+?)\$/g, '\\($1\\)');
  44. // Restore script blocks using indexOf + substring (not .replace())
  45. // because script content may contain $ characters that .replace()
  46. // would interpret as special substitution patterns.
  47. for (let i = 0; i < scriptBlocks.length; i++) {
  48. const placeholder = `__SCRIPT_BLOCK_${i}__`;
  49. const idx = processed.indexOf(placeholder);
  50. if (idx !== -1) {
  51. processed =
  52. processed.substring(0, idx) +
  53. scriptBlocks[i] +
  54. processed.substring(idx + placeholder.length);
  55. }
  56. }
  57. return processed;
  58. }
  59. /**
  60. * Inject KaTeX CSS, JS, auto-render, and MutationObserver before </head>.
  61. * Falls back to appending at end if </head> is not found.
  62. */
  63. function injectKatex(html: string): string {
  64. const katexInjection = `
  65. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
  66. <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
  67. <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
  68. <script>
  69. document.addEventListener("DOMContentLoaded", function() {
  70. const katexOptions = {
  71. delimiters: [
  72. {left: '\\\\[', right: '\\\\]', display: true},
  73. {left: '\\\\(', right: '\\\\)', display: false},
  74. {left: '$$', right: '$$', display: true},
  75. {left: '$', right: '$', display: false}
  76. ],
  77. throwOnError: false,
  78. strict: false,
  79. trust: true
  80. };
  81. let renderTimeout;
  82. function safeRender() {
  83. if (renderTimeout) clearTimeout(renderTimeout);
  84. renderTimeout = setTimeout(() => {
  85. renderMathInElement(document.body, katexOptions);
  86. }, 100);
  87. }
  88. renderMathInElement(document.body, katexOptions);
  89. const observer = new MutationObserver((mutations) => {
  90. let shouldRender = false;
  91. mutations.forEach((mutation) => {
  92. if (mutation.target &&
  93. mutation.target.className &&
  94. typeof mutation.target.className === 'string' &&
  95. mutation.target.className.includes('katex')) {
  96. return;
  97. }
  98. shouldRender = true;
  99. });
  100. if (shouldRender) {
  101. safeRender();
  102. }
  103. });
  104. observer.observe(document.body, {
  105. childList: true,
  106. subtree: true,
  107. characterData: true
  108. });
  109. setInterval(() => {
  110. const text = document.body.innerText;
  111. if (text.includes('\\\\(') || text.includes('$$')) {
  112. safeRender();
  113. }
  114. }, 2000);
  115. });
  116. </script>`;
  117. // Use indexOf + substring instead of String.replace() because the
  118. // katexInjection string contains '$' characters that .replace() would
  119. // interpret as special substitution patterns ($$ → $, $' → post-match text).
  120. const headCloseIdx = html.indexOf('</head>');
  121. if (headCloseIdx !== -1) {
  122. return (
  123. html.substring(0, headCloseIdx) +
  124. katexInjection +
  125. '\n</head>' +
  126. html.substring(headCloseIdx + 7)
  127. );
  128. }
  129. // Fallback: inject before </body> if </head> is missing
  130. const bodyCloseIdx = html.indexOf('</body>');
  131. if (bodyCloseIdx !== -1) {
  132. return (
  133. html.substring(0, bodyCloseIdx) +
  134. katexInjection +
  135. '\n</body>' +
  136. html.substring(bodyCloseIdx + 7)
  137. );
  138. }
  139. // Last resort: append at end
  140. return html + katexInjection;
  141. }