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.
- package/README.md +160 -2
- package/bin/cli.js +174 -0
- package/package.json +46 -4
- package/src/converter.js +194 -0
- package/src/index.js +49 -0
- package/src/markdown/converter.js +254 -0
- package/src/markdown/index.js +10 -0
- package/src/math/index.js +26 -0
- package/src/math/restorer.js +221 -0
- package/src/math/symbols.js +153 -0
- package/src/spatial/geometry.js +110 -0
- package/src/spatial/reading-order.js +179 -0
- package/src/tables/index.js +11 -0
- package/src/tables/reconstructor.js +195 -0
- package/src/utils/html-template.js +118 -0
|
@@ -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,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
|
+
}
|