/** * Word EQ field code → LaTeX converter. * * EQ fields are the legacy math format used before OMML (Office 2003 and earlier). * Common in educational PPTs where formulas were authored in Word and pasted as OLE. * * Supported constructs: * \f(num,den) → \frac{num}{den} * \o(base,\s\up N(overlay)) → \overrightarrow{base} (when overlay is →) * \b\lc X\rc Y(content) → \left X content \right Y * \a\vs N\al\co M(item1,item2,...) → column array * \s\up N(text) → ^{text} * \s\do N(text) → _{text} * \r(N,expr) → \sqrt[N]{expr} or \sqrt{expr} * \i(,,expr,expr) → \int_{a}^{b} * * Reference: MS-OE376 §2.16.5.22 (EQ field) and Word field code documentation. */ /** * Convert a single EQ field instruction string to LaTeX. * Input should be the raw instrText content (e.g. "eq \\f(4,3)"). */ export function eqFieldToLatex(instrText: string): string { let code = instrText.trim(); if (/^eq\s+/i.test(code)) { code = code.replace(/^eq\s+/i, ''); } try { return parseEqExpression(code, 0).latex; } catch { return code; } } interface ParseResult { latex: string; endPos: number; } function parseEqExpression(code: string, pos: number): ParseResult { const parts: string[] = []; while (pos < code.length) { const ch = code[pos]; if (ch === ')') break; if (ch === '\\') { const result = parseCommand(code, pos); parts.push(result.latex); pos = result.endPos; continue; } parts.push(ch); pos++; } return { latex: parts.join(''), endPos: pos }; } function parseCommand(code: string, pos: number): ParseResult { // pos is at '\' pos++; if (pos >= code.length) return { latex: '\\', endPos: pos }; const cmdStart = pos; while (pos < code.length && /[a-zA-Z]/.test(code[pos])) pos++; const cmd = code.substring(cmdStart, pos); switch (cmd) { case 'f': return parseFraction(code, pos); case 'o': return parseOverlay(code, pos); case 'b': return parseBracket(code, pos); case 'a': return parseArray(code, pos); case 's': return parseShift(code, pos); case 'r': return parseRadical(code, pos); case 'i': return parseIntegral(code, pos); case 'lc': return parseLcRcModifier(code, pos, 'lc'); case 'rc': return parseLcRcModifier(code, pos, 'rc'); default: return { latex: '\\' + cmd, endPos: pos }; } } /** \f(numerator,denominator) → \frac{num}{den} */ function parseFraction(code: string, pos: number): ParseResult { const args = readParenArgs(code, pos); if (!args) return { latex: '\\frac{}{}', endPos: pos }; const num = args.items[0] ?? ''; const den = args.items[1] ?? ''; return { latex: `\\frac{${convertArg(num)}}{${convertArg(den)}}`, endPos: args.endPos, }; } /** \o(base, \s\up N(overlay)) → overrightarrow etc. */ function parseOverlay(code: string, pos: number): ParseResult { const args = readParenArgs(code, pos); if (!args) return { latex: '', endPos: pos }; const base = args.items[0] ?? ''; const overlay = args.items.slice(1).join(','); if (overlay.includes('→') || overlay.includes('\\s\\up')) { return { latex: `\\overrightarrow{${convertArg(base)}}`, endPos: args.endPos }; } if (overlay.includes('̲') || overlay.includes('_')) { return { latex: `\\underline{${convertArg(base)}}`, endPos: args.endPos }; } return { latex: `\\overrightarrow{${convertArg(base)}}`, endPos: args.endPos }; } /** * \b\lc X\rc Y(content) → \left X content \right Y * \b may also appear without \lc/\rc for simple brackets. */ function parseBracket(code: string, pos: number): ParseResult { let leftChar = '('; let rightChar = ')'; while (pos < code.length && code[pos] === '\\') { const modResult = parseBracketModifier(code, pos); if (modResult.type === 'lc') { leftChar = modResult.char; pos = modResult.endPos; } else if (modResult.type === 'rc') { rightChar = modResult.char; pos = modResult.endPos; } else { break; } } const args = readParenArgs(code, pos); if (!args) return { latex: `\\left${leftChar}\\right${rightChar}`, endPos: pos }; const inner = args.items.map(convertArg).join(', '); return { latex: `\\left${leftChar}${inner}\\right${rightChar}`, endPos: args.endPos, }; } function parseBracketModifier( code: string, pos: number, ): { type: string; char: string; endPos: number } { if (code.substring(pos, pos + 3) === '\\lc') { pos += 3; const ch = readBracketChar(code, pos); return { type: 'lc', char: ch.char, endPos: ch.endPos }; } if (code.substring(pos, pos + 3) === '\\rc') { pos += 3; const ch = readBracketChar(code, pos); return { type: 'rc', char: ch.char, endPos: ch.endPos }; } return { type: 'unknown', char: '', endPos: pos }; } function readBracketChar(code: string, pos: number): { char: string; endPos: number } { if (pos >= code.length) return { char: '.', endPos: pos }; if (code[pos] === '\\' && pos + 1 < code.length) { // Escaped char like \( \) \{ \} \| \[ \] return { char: code[pos + 1], endPos: pos + 2 }; } return { char: code[pos], endPos: pos + 1 }; } /** \a\vs N\al\co M(items) → \begin{array}{c} items \end{array} */ function parseArray(code: string, pos: number): ParseResult { let cols = 1; // Parse modifiers: \vs N, \al, \co M, etc. while (pos < code.length && code[pos] === '\\') { const sub = code.substring(pos); const coMatch = sub.match(/^\\co(\d+)/); if (coMatch) { cols = parseInt(coMatch[1], 10) || 1; pos += coMatch[0].length; continue; } const modMatch = sub.match(/^\\[a-zA-Z]+\d*/); if (modMatch) { pos += modMatch[0].length; continue; } break; } const args = readParenArgs(code, pos); if (!args) return { latex: '', endPos: pos }; const colSpec = 'c'.repeat(cols); const items = args.items.map(convertArg); if (cols === 1) { return { latex: `\\begin{array}{${colSpec}}${items.join('\\\\')}\\end{array}`, endPos: args.endPos, }; } // Multi-column: chunk items into rows const rows: string[] = []; for (let i = 0; i < items.length; i += cols) { rows.push(items.slice(i, i + cols).join(' & ')); } return { latex: `\\begin{array}{${colSpec}}${rows.join('\\\\')}\\end{array}`, endPos: args.endPos, }; } /** \s\up N(text) → ^{text} or \s\do N(text) → _{text} */ function parseShift(code: string, pos: number): ParseResult { // Skip \s and read direction let dir = 'up'; if (code.substring(pos, pos + 3) === '\\up') { dir = 'up'; pos += 3; } else if (code.substring(pos, pos + 3) === '\\do') { dir = 'down'; pos += 3; } // Skip numeric argument (shift amount in half-points) while (pos < code.length && /\d/.test(code[pos])) pos++; const args = readParenArgs(code, pos); if (!args) return { latex: '', endPos: pos }; const content = convertArg(args.items[0] ?? ''); if (dir === 'up') return { latex: `^{${content}}`, endPos: args.endPos }; return { latex: `_{${content}}`, endPos: args.endPos }; } /** \r(degree, radicand) or \r(,radicand) → \sqrt[degree]{radicand} */ function parseRadical(code: string, pos: number): ParseResult { const args = readParenArgs(code, pos); if (!args) return { latex: '\\sqrt{}', endPos: pos }; if (args.items.length >= 2 && args.items[0].trim()) { return { latex: `\\sqrt[${convertArg(args.items[0])}]{${convertArg(args.items[1])}}`, endPos: args.endPos, }; } const radicand = args.items[args.items.length - 1] ?? ''; return { latex: `\\sqrt{${convertArg(radicand)}}`, endPos: args.endPos }; } /** \i(type,sub,sup,integrand) → \int_{sub}^{sup} integrand */ function parseIntegral(code: string, pos: number): ParseResult { const args = readParenArgs(code, pos); if (!args) return { latex: '\\int', endPos: pos }; const sub = args.items[2] ? `_{${convertArg(args.items[2])}}` : ''; const sup = args.items[3] ? `^{${convertArg(args.items[3])}}` : ''; return { latex: `\\int${sub}${sup}`, endPos: args.endPos }; } /** Handle standalone \lc or \rc encountered outside \b (shouldn't happen, but be robust). */ function parseLcRcModifier(code: string, pos: number, _type: string): ParseResult { const ch = readBracketChar(code, pos); return { latex: ch.char, endPos: ch.endPos }; } /** * Read a parenthesized argument list: (arg1,arg2,...). * Handles nested parentheses and EQ sub-commands. * Uses full-width comma (,) as separator in addition to ASCII comma. */ function readParenArgs(code: string, pos: number): { items: string[]; endPos: number } | null { // Skip whitespace while (pos < code.length && code[pos] === ' ') pos++; if (pos >= code.length || code[pos] !== '(') return null; pos++; // skip '(' const items: string[] = []; let current = ''; let depth = 0; while (pos < code.length) { const ch = code[pos]; if (ch === '(') { depth++; current += ch; pos++; } else if (ch === ')') { if (depth === 0) { items.push(current); pos++; // skip ')' return { items, endPos: pos }; } depth--; current += ch; pos++; } else if ((ch === ',' || ch === ',') && depth === 0) { items.push(current); current = ''; pos++; } else { current += ch; pos++; } } // Unterminated — return what we have items.push(current); return { items, endPos: pos }; } /** Recursively convert a sub-expression that may itself contain EQ commands. */ function convertArg(arg: string): string { const trimmed = arg.trim(); if (!trimmed) return ''; if (trimmed.includes('\\')) { try { return parseEqExpression(trimmed, 0).latex; } catch { return trimmed; } } return trimmed; } // --------------------------------------------------------------------------- // Word document.xml → text + EQ fields extraction // --------------------------------------------------------------------------- export interface DocxMathContent { /** LaTeX representation of the full line (text + formulas). */ latex: string; /** Plain text representation (formulas simplified to readable text). */ plainText: string; } /** * Parse word/document.xml content and extract text + EQ fields, * converting the full paragraph content into LaTeX and plain text. * * Structure: ...(w:t | w:fldChar+w:instrText)...... * * @param xmlString raw XML content of word/document.xml */ export function parseDocxMathContent(xmlString: string): DocxMathContent { const latexParts: string[] = []; const textParts: string[] = []; // Extract all w:r runs in order const runs = extractRuns(xmlString); let inField = false; let fieldCode = ''; for (const run of runs) { if (run.type === 'fldChar') { if (run.fldCharType === 'begin') { inField = true; fieldCode = ''; } else if (run.fldCharType === 'separate') { // Display text follows; we ignore it and use our own conversion } else if (run.fldCharType === 'end') { if (fieldCode.trim()) { const latex = eqFieldToLatex(fieldCode); latexParts.push(latex); textParts.push(eqFieldToPlainText(fieldCode)); } inField = false; fieldCode = ''; } } else if (run.type === 'instrText') { fieldCode += run.text; } else if (run.type === 'text') { if (!inField) { if (run.isItalic) { latexParts.push(`\\textit{${run.text}}`); } else { latexParts.push(`\\text{${run.text}}`); } textParts.push(run.text); } } } return { latex: latexParts .join('') .replace(/\\text\{}\s*/g, '') .trim(), plainText: textParts.join('').trim(), }; } interface DocxRun { type: 'text' | 'instrText' | 'fldChar'; text: string; isItalic?: boolean; fldCharType?: string; } /** Simple XML regex extraction — avoids needing a full DOM parser. */ function extractRuns(xml: string): DocxRun[] { const runs: DocxRun[] = []; // Match each ... block const runRegex = /][\s\S]*?<\/w:r>/g; let match: RegExpExecArray | null; while ((match = runRegex.exec(xml)) !== null) { const runXml = match[0]; // Check for italic const isItalic = //.test(runXml) || /]*>([\s\S]*?)<\/w:instrText>/); if (instrMatch) { runs.push({ type: 'instrText', text: instrMatch[1] }); continue; } // Regular text const textMatch = runXml.match(/]*>([\s\S]*?)<\/w:t>/); if (textMatch) { runs.push({ type: 'text', text: textMatch[1], isItalic }); } } return runs; } /** * Simplified plain-text version of an EQ field. * Re-uses the parser but maps to readable text instead of LaTeX. */ function eqFieldToPlainText(instrText: string): string { let code = instrText.trim(); if (/^eq\s+/i.test(code)) code = code.replace(/^eq\s+/i, ''); try { return eqToPlain(code, 0).text; } catch { return code .replace(/\\[a-zA-Z]+\d*/g, '') .replace(/\s+/g, ' ') .trim(); } } function eqToPlain(code: string, pos: number): { text: string; endPos: number } { const parts: string[] = []; while (pos < code.length) { const ch = code[pos]; if (ch === ')') break; if (ch === '\\') { const cmdStart = pos + 1; let p = cmdStart; while (p < code.length && /[a-zA-Z]/.test(code[p])) p++; const cmd = code.substring(cmdStart, p); if (cmd === 'f') { const args = readParenArgs(code, p); if (args) { parts.push(`${plainArg(args.items[0])}/${plainArg(args.items[1])}`); pos = args.endPos; } else { pos = p; } } else if (cmd === 'o') { const args = readParenArgs(code, p); if (args) { parts.push(plainArg(args.items[0]) + '→'); pos = args.endPos; } else { pos = p; } } else if (cmd === 'b') { while (p < code.length && code[p] === '\\') { const mod = code.substring(p).match(/^\\[a-zA-Z]+\\?./); if (mod) p += mod[0].length; else break; } const args = readParenArgs(code, p); if (args) { parts.push('(' + args.items.map(plainArg).join(', ') + ')'); pos = args.endPos; } else { pos = p; } } else if (cmd === 'a') { while (p < code.length && code[p] === '\\') { const mod = code.substring(p).match(/^\\[a-zA-Z]+\d*/); if (mod) p += mod[0].length; else break; } const args = readParenArgs(code, p); if (args) { parts.push(args.items.map(plainArg).join(', ')); pos = args.endPos; } else { pos = p; } } else if (cmd === 's') { // skip \up N or \do N if (code.substring(p, p + 3) === '\\up' || code.substring(p, p + 3) === '\\do') p += 3; while (p < code.length && /\d/.test(code[p])) p++; const args = readParenArgs(code, p); if (args) { parts.push(plainArg(args.items[0])); pos = args.endPos; } else { pos = p; } } else if (cmd === 'r') { const args = readParenArgs(code, p); if (args) { parts.push('√(' + args.items.map(plainArg).join(',') + ')'); pos = args.endPos; } else { pos = p; } } else { // Unknown command — skip pos = p; } continue; } parts.push(ch === ',' ? ', ' : ch); pos++; } return { text: parts.join(''), endPos: pos }; } function plainArg(s: string | undefined): string { if (!s) return ''; const t = s.trim(); if (t.includes('\\')) { try { return eqToPlain(t, 0).text; } catch { /* fall through */ } } return t; }