|
| 1 | +import katex from 'katex' |
1 | 2 | import { tex2typst } from 'tex2typst' |
2 | 3 |
|
3 | 4 | /** |
@@ -44,3 +45,91 @@ export function convertToTypst(code: string) { |
44 | 45 | const cleanedCode = code.replace(/~/g, '\\ ') |
45 | 46 | return tex2typst(cleanedCode) |
46 | 47 | } |
| 48 | + |
| 49 | +/** |
| 50 | + * Sanitizes MathML content for compatibility with Microsoft Word. |
| 51 | + * |
| 52 | + * Removes layout hacks and spacing elements that Word renders incorrectly as blank boxes. |
| 53 | + * Handles both server-side (regex-based) and client-side (DOM-based) sanitization. |
| 54 | + * |
| 55 | + * @param mathml - The MathML string to sanitize |
| 56 | + * @returns The sanitized MathML string. Returns the original input if parsing fails or if DOM APIs are unavailable. |
| 57 | + * |
| 58 | + * @remarks |
| 59 | + * - If `DOMParser` and `XMLSerializer` are unavailable (server-side), uses regex patterns to remove: |
| 60 | + * - All `<mpadded>` elements |
| 61 | + * - `<mspace>` elements with width="1em" or width="1.0em" |
| 62 | + * - If DOM APIs are available (client-side), parses the MathML and: |
| 63 | + * - Unwraps `<mpadded>` elements by moving their children to the parent |
| 64 | + * - Removes `<mspace>` elements with width >= 1em |
| 65 | + * |
| 66 | + * @example |
| 67 | + * ```ts |
| 68 | + * const original = '<math><mpadded><mi>x</mi></mpadded></math>'; |
| 69 | + * const sanitized = sanitizeMathMLForWord(original); |
| 70 | + * // Returns: '<math><mi>x</mi></math>' |
| 71 | + * ``` |
| 72 | + */ |
| 73 | +function sanitizeMathMLForWord(mathml: string): string { |
| 74 | + if (!mathml) return mathml |
| 75 | + |
| 76 | + // Strip layout hacks that Word renders as blank boxes. |
| 77 | + if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') { |
| 78 | + return mathml |
| 79 | + .replace(/<mpadded[\s\S]*?<\/mpadded>/g, '') |
| 80 | + .replace( |
| 81 | + /<mspace\b[^>]*\bwidth=(['"])?1(?:\.0+)?em\1[^>]*>[\s\S]*?<\/mspace>/g, |
| 82 | + '' |
| 83 | + ) |
| 84 | + .replace(/<mspace\b[^>]*\bwidth=(['"])?1(?:\.0+)?em\1[^>]*\/>/g, '') |
| 85 | + } |
| 86 | + |
| 87 | + const doc = new DOMParser().parseFromString(mathml, 'application/xml') |
| 88 | + const root = doc.documentElement |
| 89 | + if (!root || root.nodeName === 'parsererror') return mathml |
| 90 | + |
| 91 | + root.querySelectorAll('mpadded').forEach((node) => { |
| 92 | + const parent = node.parentNode |
| 93 | + if (!parent) return |
| 94 | + while (node.firstChild) { |
| 95 | + parent.insertBefore(node.firstChild, node) |
| 96 | + } |
| 97 | + parent.removeChild(node) |
| 98 | + }) |
| 99 | + |
| 100 | + root.querySelectorAll('mspace').forEach((node) => { |
| 101 | + const width = node.getAttribute('width') |
| 102 | + if (!width) return |
| 103 | + const match = width.trim().match(/^([0-9]*\.?[0-9]+)em$/) |
| 104 | + if (!match) return |
| 105 | + const value = Number(match[1]) |
| 106 | + if (Number.isFinite(value) && value >= 1) { |
| 107 | + node.remove() |
| 108 | + } |
| 109 | + }) |
| 110 | + |
| 111 | + return new XMLSerializer().serializeToString(root) |
| 112 | +} |
| 113 | + |
| 114 | +export function convertToMathML(code: string) { |
| 115 | + const cleanedCode = code.trim() |
| 116 | + if (!cleanedCode) return '' |
| 117 | + |
| 118 | + const rendered = katex.renderToString(cleanedCode, { |
| 119 | + throwOnError: false, |
| 120 | + displayMode: true, |
| 121 | + output: 'mathml' |
| 122 | + }) |
| 123 | + |
| 124 | + const mathmlMatch = rendered.match(/<math[\s\S]*<\/math>/) |
| 125 | + if (!mathmlMatch) return rendered |
| 126 | + |
| 127 | + let mathml = mathmlMatch[0] |
| 128 | + mathml = mathml.replace(/<annotation[\s\S]*?<\/annotation>/g, '') |
| 129 | + mathml = mathml.replace(/<\/?semantics[^>]*>/g, '') |
| 130 | + mathml = mathml.replace( |
| 131 | + /<mtext>([\s\u00A0\u2000-\u200A\u202F\u205F\u3000]+)<\/mtext>/g, |
| 132 | + '<mspace width="0.2em"/>' |
| 133 | + ) |
| 134 | + return sanitizeMathMLForWord(mathml) |
| 135 | +} |
0 commit comments