paper-drift 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * @file src/markdown/converter.js
3
+ * @description Direct WebAssembly PDF to GitHub Flavored Markdown (GFM) conversion engine.
4
+ * Converts academic papers and enterprise documents directly into clean, token-efficient Markdown
5
+ * with spatial reading order, GFM pipe tables, LaTeX math restoration, and typography detection.
6
+ * Part of paper-drift - https://github.com/Sooriya04/Paper-Drift
7
+ */
8
+
9
+ import mupdf from 'mupdf';
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import { parsePageRange } from '../converter.js';
13
+ import { resolveReadingOrder } from '../spatial/reading-order.js';
14
+ import { reconstructTableFromLines } from '../tables/reconstructor.js';
15
+ import { mapUnicodeToLatex, normalizeMathIndices } from '../math/index.js';
16
+
17
+ /**
18
+ * Converts a structured page JSON representation into clean, structured Markdown.
19
+ *
20
+ * @param {Object} pageJson - Parsed JSON from `page.toStructuredText().asJSON()`
21
+ * @param {number} widthPt - Page width in points
22
+ * @param {number} heightPt - Page height in points
23
+ * @param {Object} [options={}]
24
+ * @returns {{ markdown: string, tablesCount: number, headingsCount: number }}
25
+ */
26
+ export function pageToMarkdown(pageJson, widthPt, heightPt, options = {}) {
27
+ const spatial = resolveReadingOrder(pageJson, widthPt, heightPt);
28
+ const blocks = spatial.blocks || [];
29
+
30
+ if (blocks.length === 0) {
31
+ return { markdown: '', tablesCount: 0, headingsCount: 0 };
32
+ }
33
+
34
+ // Calculate median body font size across all lines on this page
35
+ const fontSizes = [];
36
+ for (const b of blocks) {
37
+ for (const l of (b.lines || [])) {
38
+ if (l.font?.size) fontSizes.push(l.font.size);
39
+ }
40
+ }
41
+ fontSizes.sort((a, b) => a - b);
42
+ const medianFontSize = fontSizes.length > 0 ? fontSizes[Math.floor(fontSizes.length / 2)] : 10.0;
43
+
44
+ const mdSections = [];
45
+ let tablesCount = 0;
46
+ let headingsCount = 0;
47
+
48
+ let i = 0;
49
+ while (i < blocks.length) {
50
+ const block = blocks[i];
51
+ const lines = block.lines || [];
52
+ if (lines.length === 0) {
53
+ i++;
54
+ continue;
55
+ }
56
+
57
+ const blockText = lines.map(l => l.text).join(' ').trim();
58
+
59
+ // 1. Check for Table Caption (e.g. "Table 1: ...", "Table 2. ...", "TABLE I: ...")
60
+ if (/^(Table|TABLE)\s+([0-9]+|[IVXLCDM]+)[:.]/i.test(blockText)) {
61
+ mdSections.push(`**${blockText}**`);
62
+
63
+ // Look ahead to collect consecutive table rows/blocks directly beneath this caption
64
+ const tableLines = [];
65
+ let j = i + 1;
66
+ let lastBlockBottom = block.bbox.y + block.bbox.h;
67
+
68
+ while (j < blocks.length) {
69
+ const nextB = blocks[j];
70
+ const nextLines = nextB.lines || [];
71
+ if (nextLines.length === 0) { j++; continue; }
72
+
73
+ // Stop if vertical gap between blocks exceeds standard table row spacing (> 20pt)
74
+ const gap = nextB.bbox.y - lastBlockBottom;
75
+ if (tableLines.length > 0 && gap > 20) {
76
+ break;
77
+ }
78
+
79
+ // Stop if next block is a full-width paragraph sentence (> 240pt with > 8 words)
80
+ const isParagraph = nextLines.some(l => (l.bbox?.w || 0) > 240 && (l.text || '').split(' ').length > 8);
81
+ if (isParagraph) {
82
+ break;
83
+ }
84
+
85
+ tableLines.push(...nextLines);
86
+ lastBlockBottom = nextB.bbox.y + nextB.bbox.h;
87
+ j++;
88
+ }
89
+
90
+ if (tableLines.length >= 4) {
91
+ const tableMd = reconstructTableFromLines(tableLines);
92
+ if (tableMd) {
93
+ mdSections.push(tableMd);
94
+ tablesCount++;
95
+ i = j;
96
+ continue;
97
+ }
98
+ }
99
+
100
+ i++;
101
+ continue;
102
+ }
103
+
104
+ // 2. Check for Table Grid within a single block
105
+ if (lines.length >= 4 && !lines.some(l => (l.bbox?.w || 0) > 250 && (l.text || '').split(' ').length > 8)) {
106
+ const tableMd = reconstructTableFromLines(lines);
107
+ if (tableMd) {
108
+ mdSections.push(tableMd);
109
+ tablesCount++;
110
+ i++;
111
+ continue;
112
+ }
113
+ }
114
+
115
+ // 3. Check for Display Math Equations (e.g. PE(pos, 2i) = sin(...) or expressions ending in equation tags (1))
116
+ const isFormula = (
117
+ lines.length <= 3 &&
118
+ (/=\s*(sin|cos|tan|softmax|exp|\sum|\int|arg)/i.test(blockText) ||
119
+ /PE\(/i.test(blockText) ||
120
+ /\(\d+\)$/.test(blockText) && /[=+\-·×/]/.test(blockText))
121
+ );
122
+
123
+ if (isFormula) {
124
+ let formulaText = mapUnicodeToLatex(blockText);
125
+ formulaText = formulaText.replace(/\((\d+)\)$/, '\\tag{$1}');
126
+ mdSections.push(`$$\n${formulaText}\n$$`);
127
+ i++;
128
+ continue;
129
+ }
130
+
131
+ // 4. Check for Document Typography & Headings
132
+ const firstLine = lines[0];
133
+ const fontSize = firstLine.font?.size || medianFontSize;
134
+ const isBold = firstLine.font?.weight >= 700 || /bold/i.test(firstLine.font?.family || '');
135
+ const isNumberedHeading = /^(\d+(\.\d+)*)\s+[A-Z]/.test(blockText);
136
+
137
+ if (fontSize >= medianFontSize * 1.45) {
138
+ mdSections.push(`# ${blockText}`);
139
+ headingsCount++;
140
+ } else if (fontSize >= medianFontSize * 1.15 || isNumberedHeading) {
141
+ mdSections.push(`## ${blockText}`);
142
+ headingsCount++;
143
+ } else if (fontSize >= medianFontSize * 1.05 && isBold && blockText.length < 80) {
144
+ mdSections.push(`### ${blockText}`);
145
+ headingsCount++;
146
+ } else if (/^[•–\-*]\s+/.test(blockText)) {
147
+ // Unordered list item
148
+ mdSections.push(`- ${blockText.replace(/^[•–\-*]\s+/, '')}`);
149
+ } else {
150
+ // 5. Standard body paragraph
151
+ const formattedPara = lines
152
+ .map(l => l.text)
153
+ .join(' ')
154
+ .replace(/\s+/g, ' ')
155
+ .trim();
156
+
157
+ if (formattedPara) {
158
+ mdSections.push(formattedPara);
159
+ }
160
+ }
161
+
162
+ i++;
163
+ }
164
+
165
+ return {
166
+ markdown: mdSections.join('\n\n'),
167
+ tablesCount,
168
+ headingsCount
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Converts a PDF buffer or file path directly into GitHub Flavored Markdown (GFM).
174
+ *
175
+ * @param {string|Buffer|Uint8Array} input - File path or binary buffer of the PDF.
176
+ * @param {Object} [options={}] - Conversion options.
177
+ * @param {string|number[]} [options.pageRange='all'] - Page range to process (e.g. "1-5", "2,4", "all").
178
+ * @param {function} [options.onProgress] - Optional progress callback `({ current, total, pageNumber }) => void`.
179
+ * @returns {Promise<{ markdown: string, pages: Array<Object>, totalPages: number, pagesProcessed: number, durationMs: number }>}
180
+ */
181
+ export async function convertPdfToMarkdown(input, options = {}) {
182
+ const startTime = Date.now();
183
+ const pageRangeStr = options.pageRange || 'all';
184
+
185
+ let pdfBuffer;
186
+ if (typeof input === 'string') {
187
+ if (!fs.existsSync(input)) {
188
+ throw new Error(`File not found: ${input}`);
189
+ }
190
+ pdfBuffer = fs.readFileSync(input);
191
+ } else if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
192
+ pdfBuffer = input;
193
+ } else {
194
+ throw new TypeError('Input must be a valid file path string, Buffer, or Uint8Array.');
195
+ }
196
+
197
+ const doc = mupdf.Document.openDocument(pdfBuffer, 'application/pdf');
198
+ const totalPages = doc.countPages();
199
+ const targetIndices = parsePageRange(pageRangeStr, totalPages);
200
+
201
+ if (targetIndices.length === 0) {
202
+ throw new Error(`No matching pages found for page range: "${pageRangeStr}" (Document has ${totalPages} pages).`);
203
+ }
204
+
205
+ const pagesMarkdown = [];
206
+ const processedPages = [];
207
+
208
+ for (let step = 0; step < targetIndices.length; step++) {
209
+ const pageIndex = targetIndices[step];
210
+ const pageNumber = pageIndex + 1;
211
+
212
+ if (typeof options.onProgress === 'function') {
213
+ options.onProgress({
214
+ current: step + 1,
215
+ total: targetIndices.length,
216
+ pageNumber,
217
+ totalPages
218
+ });
219
+ }
220
+
221
+ const page = doc.loadPage(pageIndex);
222
+ const bounds = page.getBounds();
223
+ const widthPt = bounds[2] - bounds[0];
224
+ const heightPt = bounds[3] - bounds[1];
225
+
226
+ const structuredText = page.toStructuredText();
227
+ const pageJson = JSON.parse(structuredText.asJSON());
228
+
229
+ const result = pageToMarkdown(pageJson, widthPt, heightPt, options);
230
+
231
+ processedPages.push({
232
+ pageNumber,
233
+ width: widthPt,
234
+ height: heightPt,
235
+ tablesCount: result.tablesCount,
236
+ headingsCount: result.headingsCount,
237
+ markdown: result.markdown
238
+ });
239
+
240
+ if (result.markdown) {
241
+ pagesMarkdown.push(result.markdown);
242
+ }
243
+ }
244
+
245
+ const fullMarkdown = pagesMarkdown.join('\n\n---\n\n');
246
+
247
+ return {
248
+ markdown: fullMarkdown,
249
+ pages: processedPages,
250
+ totalPages,
251
+ pagesProcessed: targetIndices.length,
252
+ durationMs: Date.now() - startTime
253
+ };
254
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @file src/markdown/index.js
3
+ * @description Direct Markdown conversion module entrypoint.
4
+ * Part of paper-drift - https://github.com/Sooriya04/Paper-Drift
5
+ */
6
+
7
+ export {
8
+ convertPdfToMarkdown,
9
+ pageToMarkdown
10
+ } from './converter.js';
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @file src/math/index.js
3
+ * @description Main export for the paper-drift LaTeX math restoration engine.
4
+ * Provides symbol mapping, sub/superscript resolution, equation tagging, and math block formatting.
5
+ * Part of paper-drift - https://github.com/Sooriya04/Paper-Drift
6
+ */
7
+
8
+ export {
9
+ restoreLineMath,
10
+ restoreBlockMath,
11
+ formatInlineMath,
12
+ normalizeMathIndices,
13
+ isMathFont,
14
+ containsMathSymbols
15
+ } from './restorer.js';
16
+
17
+ export {
18
+ ALL_MATH_SYMBOLS,
19
+ mapUnicodeToLatex,
20
+ GREEK_LOWERCASE,
21
+ GREEK_UPPERCASE,
22
+ MATH_OPERATORS,
23
+ MATH_RELATIONS,
24
+ ARROWS_AND_LOGIC,
25
+ BLACKBOARD_SETS
26
+ } from './symbols.js';
@@ -0,0 +1,221 @@
1
+ /**
2
+ * @file src/math/restorer.js
3
+ * @description Advanced LaTeX math heuristic restoration engine for academic and scientific PDFs.
4
+ * Reconstructs sub/superscripts, fractions, square roots, Greek/symbol glyphs,
5
+ * and frames expressions into inline ($...$) or display ($$...$$) LaTeX equations.
6
+ * Part of paper-drift - https://github.com/Sooriya04/Paper-Drift
7
+ */
8
+
9
+ import { ALL_MATH_SYMBOLS, mapUnicodeToLatex } from './symbols.js';
10
+
11
+ const MATH_FONT_PATTERNS = [
12
+ /cmr/i,
13
+ /cmsy/i,
14
+ /cmmi/i,
15
+ /math/i,
16
+ /symbol/i,
17
+ /stix/i,
18
+ /euler/i,
19
+ /msam/i,
20
+ /msbm/i
21
+ ];
22
+
23
+ /**
24
+ * Checks if a font name indicates a LaTeX or mathematical typography font.
25
+ * @param {string} fontName
26
+ * @returns {boolean}
27
+ */
28
+ export function isMathFont(fontName) {
29
+ if (!fontName) return false;
30
+ return MATH_FONT_PATTERNS.some(p => p.test(fontName));
31
+ }
32
+
33
+ /**
34
+ * Checks if a string contains mathematical or Greek symbols.
35
+ * @param {string} str
36
+ * @returns {boolean}
37
+ */
38
+ export function containsMathSymbols(str) {
39
+ if (!str) return false;
40
+ for (const sym of Object.keys(ALL_MATH_SYMBOLS)) {
41
+ if (str.includes(sym)) return true;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ /**
47
+ * Normalizes math variable indices (e.g. dk -> d_k, dmodel -> d_{model}, headi -> head_i).
48
+ * @param {string} str
49
+ * @returns {string}
50
+ */
51
+ export function normalizeMathIndices(str) {
52
+ if (!str) return '';
53
+
54
+ // Common academic math variable patterns
55
+ return str
56
+ .replace(/\b([dD])([kqv])\b/g, '$1_{$2}')
57
+ .replace(/\b(d)(model)\b/g, '$1_{\\text{$2}}')
58
+ .replace(/\b(head)([i-k])\b/g, '\\text{$1}_{$2}')
59
+ .replace(/\b([Ww])([0-9a-zA-Z])\b/g, '$1_{$2}')
60
+ .replace(/\b([XxYyZz])([i-k0-9])\b/g, '$1_{$2}');
61
+ }
62
+
63
+ /**
64
+ * Restores sub/superscripts, symbols, and operators in a horizontal line of text tokens.
65
+ *
66
+ * @param {Array<{ str: string, x: number, y: number, fontSize: number, fontName?: string, w?: number, h?: number }>} tokens
67
+ * @param {Object} [options={}]
68
+ * @param {boolean} [options.bottomUp=false] - True if coordinates are bottom-up (PDF standard) vs top-down (DOM)
69
+ * @returns {string} Reconstructed LaTeX expression
70
+ */
71
+ export function restoreLineMath(tokens, options = {}) {
72
+ const { bottomUp = false } = options;
73
+ if (!tokens || tokens.length === 0) return '';
74
+
75
+ // Sort primarily by X coordinate
76
+ const sorted = [...tokens]
77
+ .filter(t => t.str && t.str.trim() !== '')
78
+ .sort((a, b) => a.x - b.x);
79
+
80
+ if (sorted.length === 0) return '';
81
+
82
+ let out = '';
83
+ let prevToken = null;
84
+
85
+ for (let i = 0; i < sorted.length; i++) {
86
+ const curr = sorted[i];
87
+ let tokenText = mapUnicodeToLatex(curr.str);
88
+
89
+ // Apply index normalization for math variables
90
+ if (isMathFont(curr.fontName) || curr.fontSize <= 9) {
91
+ tokenText = normalizeMathIndices(tokenText);
92
+ }
93
+
94
+ if (prevToken) {
95
+ const isSmaller = curr.fontSize < prevToken.fontSize * 0.85;
96
+ const yDiff = bottomUp ? (curr.y - prevToken.y) : (prevToken.y - curr.y);
97
+
98
+ // Superscript check: smaller font and elevated baseline
99
+ if (isSmaller && yDiff > 1.0) {
100
+ out = out.trimEnd() + `^{${tokenText}}`;
101
+ prevToken = curr;
102
+ continue;
103
+ }
104
+
105
+ // Subscript check: smaller font and lowered baseline
106
+ if (isSmaller && yDiff < -1.0) {
107
+ out = out.trimEnd() + `_{${tokenText}}`;
108
+ prevToken = curr;
109
+ continue;
110
+ }
111
+
112
+ // Spacing between distinct mathematical terms
113
+ const xGap = curr.x - (prevToken.x + (prevToken.w || prevToken.fontSize * 0.45));
114
+ if (xGap > 2.0 && !out.endsWith(' ') && !out.endsWith('{') && !out.endsWith('_') && !out.endsWith('^') && !out.endsWith('\\')) {
115
+ out += ' ';
116
+ }
117
+ }
118
+
119
+ // Format square roots: √x -> \sqrt{x}
120
+ if (tokenText.startsWith('\\sqrt')) {
121
+ const remainder = tokenText.slice(5).trim();
122
+ tokenText = remainder.length > 0 ? `\\sqrt{${remainder}}` : '\\sqrt';
123
+ }
124
+
125
+ out += tokenText;
126
+ prevToken = curr;
127
+ }
128
+
129
+ return out.replace(/\s+/g, ' ').trim();
130
+ }
131
+
132
+ /**
133
+ * Reconstructs a full mathematical block, framing as display ($$...$$) or inline ($...$).
134
+ *
135
+ * @param {Object} block - Text block with lines of tokens
136
+ * @param {Object} [options={}]
137
+ * @returns {{ isMath: boolean, latex: string }}
138
+ */
139
+ export function restoreBlockMath(block, options = {}) {
140
+ if (!block || !block.lines || block.lines.length === 0) {
141
+ return { isMath: false, latex: '' };
142
+ }
143
+
144
+ // Extract all tokens across lines
145
+ const allTokens = [];
146
+ let mathTokensCount = 0;
147
+
148
+ for (const line of block.lines) {
149
+ const tokens = line.tokens || [{
150
+ str: line.text,
151
+ x: line.bbox?.x ?? line.x ?? 0,
152
+ y: line.bbox?.y ?? line.y ?? 0,
153
+ w: line.bbox?.w,
154
+ h: line.bbox?.h,
155
+ fontSize: line.font?.size || line.bbox?.h || 10,
156
+ fontName: line.font?.name || ''
157
+ }];
158
+
159
+ for (const t of tokens) {
160
+ allTokens.push(t);
161
+ if (isMathFont(t.fontName) || containsMathSymbols(t.str)) {
162
+ mathTokensCount++;
163
+ }
164
+ }
165
+ }
166
+
167
+ const rawText = block.lines.map(l => l.text).join(' ').trim();
168
+ const mathDensity = allTokens.length > 0 ? (mathTokensCount / allTokens.length) : 0;
169
+
170
+ // Check for equation tag pattern at the end: (1), (2.4), (12)
171
+ const equationLabelMatch = rawText.match(/\((\d+(\.\d+)?)\)\s*$/);
172
+ const hasEquationLabel = Boolean(equationLabelMatch);
173
+
174
+ // High math likelihood triggers
175
+ const isEquation = mathDensity >= 0.25 ||
176
+ hasEquationLabel ||
177
+ (containsMathSymbols(rawText) && (rawText.includes('=') || rawText.includes('+')));
178
+
179
+ if (!isEquation) {
180
+ return { isMath: false, latex: rawText };
181
+ }
182
+
183
+ // Reconstruct math expression
184
+ let restored = restoreLineMath(allTokens, options);
185
+
186
+ // If there's an equation tag at the end, convert to \tag{N}
187
+ if (equationLabelMatch) {
188
+ const label = equationLabelMatch[1];
189
+ restored = restored.replace(/\(\d+(\.\d+)?\)\s*$/, '').trim();
190
+ return {
191
+ isMath: true,
192
+ latex: `$$\n${restored} \\tag{${label}}\n$$`
193
+ };
194
+ }
195
+
196
+ return {
197
+ isMath: true,
198
+ latex: `$$\n${restored}\n$$`
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Scans regular text paragraphs and converts inline math expressions (e.g. d_k, x^2, \alpha)
204
+ * into standard inline LaTeX ($...$).
205
+ *
206
+ * @param {string} text
207
+ * @returns {string} Text with inline math wrapped in $...$
208
+ */
209
+ export function formatInlineMath(text) {
210
+ if (!text) return '';
211
+
212
+ let result = mapUnicodeToLatex(text);
213
+ result = normalizeMathIndices(result);
214
+
215
+ // Wrap isolated sub/superscript tokens or Greek symbols in $...$
216
+ result = result.replace(/(^|\s)([a-zA-Z0-9\\]+[\^_]\{[^{}]+\})($|\s|[.,;:])/g, (match, prefix, math, suffix) => {
217
+ return `${prefix}$${math}$${suffix}`;
218
+ });
219
+
220
+ return result;
221
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @file src/math/symbols.js
3
+ * @description Comprehensive Unicode-to-LaTeX mathematical symbol and Greek alphabet mapping dictionary.
4
+ * Translates raw glyphs from academic PDF fonts (CMR, CMSY, CMMI, Symbol) into clean LaTeX expressions.
5
+ * Part of paper-drift - https://github.com/Sooriya04/Paper-Drift
6
+ */
7
+
8
+ export const GREEK_LOWERCASE = {
9
+ 'α': '\\alpha',
10
+ 'β': '\\beta',
11
+ 'γ': '\\gamma',
12
+ 'δ': '\\delta',
13
+ 'ε': '\\epsilon',
14
+ 'ϵ': '\\varepsilon',
15
+ 'ζ': '\\zeta',
16
+ 'η': '\\eta',
17
+ 'θ': '\\theta',
18
+ 'ϑ': '\\vartheta',
19
+ 'ι': '\\iota',
20
+ 'κ': '\\kappa',
21
+ 'λ': '\\lambda',
22
+ 'μ': '\\mu',
23
+ 'ν': '\\nu',
24
+ 'ξ': '\\xi',
25
+ 'π': '\\pi',
26
+ 'ϖ': '\\varpi',
27
+ 'ρ': '\\rho',
28
+ 'ϱ': '\\varrho',
29
+ 'σ': '\\sigma',
30
+ 'ς': '\\varsigma',
31
+ 'τ': '\\tau',
32
+ 'υ': '\\upsilon',
33
+ 'φ': '\\phi',
34
+ 'ϕ': '\\varphi',
35
+ 'χ': '\\chi',
36
+ 'ψ': '\\psi',
37
+ 'ω': '\\omega'
38
+ };
39
+
40
+ export const GREEK_UPPERCASE = {
41
+ 'Γ': '\\Gamma',
42
+ 'Δ': '\\Delta',
43
+ 'Θ': '\\Theta',
44
+ 'Λ': '\\Lambda',
45
+ 'Ξ': '\\Xi',
46
+ 'Π': '\\Pi',
47
+ 'Σ': '\\Sigma',
48
+ 'Υ': '\\Upsilon',
49
+ 'Φ': '\\Phi',
50
+ 'Ψ': '\\Psi',
51
+ 'Ω': '\\Omega'
52
+ };
53
+
54
+ export const MATH_OPERATORS = {
55
+ '∑': '\\sum',
56
+ '∏': '\\prod',
57
+ '∐': '\\coprod',
58
+ '∫': '\\int',
59
+ '∬': '\\iint',
60
+ '∭': '\\iiint',
61
+ '∮': '\\oint',
62
+ '√': '\\sqrt',
63
+ '±': '\\pm',
64
+ '∓': '\\mp',
65
+ '×': '\\times',
66
+ '÷': '\\div',
67
+ '·': '\\cdot',
68
+ '∘': '\\circ',
69
+ '∙': '\\bullet',
70
+ '⊗': '\\otimes',
71
+ '⊕': '\\oplus',
72
+ '⊙': '\\odot',
73
+ '∗': '*',
74
+ '⋆': '\\star'
75
+ };
76
+
77
+ export const MATH_RELATIONS = {
78
+ '≤': '\\le',
79
+ '≥': '\\ge',
80
+ '≠': '\\neq',
81
+ '≈': '\\approx',
82
+ '≡': '\\equiv',
83
+ '∼': '\\sim',
84
+ '≃': '\\simeq',
85
+ '∝': '\\propto',
86
+ '≪': '\\ll',
87
+ '≫': '\\gg',
88
+ '∈': '\\in',
89
+ '∉': '\\notin',
90
+ '∋': '\\owns',
91
+ '⊂': '\\subset',
92
+ '⊃': '\\supset',
93
+ '⊆': '\\subseteq',
94
+ '⊇': '\\supseteq',
95
+ '∩': '\\cap',
96
+ '∪': '\\cup'
97
+ };
98
+
99
+ export const ARROWS_AND_LOGIC = {
100
+ '→': '\\to',
101
+ '←': '\\leftarrow',
102
+ '↔': '\\leftrightarrow',
103
+ '⇒': '\\implies',
104
+ '⇐': '\\impliedby',
105
+ '⇔': '\\iff',
106
+ '↑': '\\uparrow',
107
+ '↓': '\\downarrow',
108
+ '∀': '\\forall',
109
+ '∃': '\\exists',
110
+ '∄': '\\nexists',
111
+ '∇': '\\nabla',
112
+ '∂': '\\partial',
113
+ '∞': '\\infty',
114
+ '∠': '\\angle',
115
+ '⊥': '\\perp'
116
+ };
117
+
118
+ export const BLACKBOARD_SETS = {
119
+ 'ℝ': '\\mathbb{R}',
120
+ 'ℂ': '\\mathbb{C}',
121
+ 'ℕ': '\\mathbb{N}',
122
+ 'ℤ': '\\mathbb{Z}',
123
+ 'ℚ': '\\mathbb{Q}',
124
+ '𝔼': '\\mathbb{E}',
125
+ 'ℙ': '\\mathbb{P}',
126
+ '𝟙': '\\mathbb{1}'
127
+ };
128
+
129
+ // Unified lookup map
130
+ export const ALL_MATH_SYMBOLS = {
131
+ ...GREEK_LOWERCASE,
132
+ ...GREEK_UPPERCASE,
133
+ ...MATH_OPERATORS,
134
+ ...MATH_RELATIONS,
135
+ ...ARROWS_AND_LOGIC,
136
+ ...BLACKBOARD_SETS
137
+ };
138
+
139
+ /**
140
+ * Replaces unicode math symbols with their LaTeX equivalent.
141
+ * @param {string} text
142
+ * @returns {string}
143
+ */
144
+ export function mapUnicodeToLatex(text) {
145
+ if (!text) return '';
146
+ let out = text;
147
+ for (const [sym, latex] of Object.entries(ALL_MATH_SYMBOLS)) {
148
+ if (out.includes(sym)) {
149
+ out = out.split(sym).join(` ${latex} `);
150
+ }
151
+ }
152
+ return out.replace(/\s+/g, ' ').trim();
153
+ }