@siglum/engine 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/LICENSE +21 -0
- package/README.md +318 -0
- package/package.json +55 -0
- package/src/bundles.js +545 -0
- package/src/compiler.js +1164 -0
- package/src/ctan.js +818 -0
- package/src/hash.js +102 -0
- package/src/index.js +32 -0
- package/src/storage.js +642 -0
- package/src/utils.js +33 -0
- package/src/worker.js +2217 -0
- package/types/bundles.d.ts +143 -0
- package/types/compiler.d.ts +288 -0
- package/types/ctan.d.ts +156 -0
- package/types/hash.d.ts +25 -0
- package/types/index.d.ts +5 -0
- package/types/storage.d.ts +124 -0
- package/types/utils.d.ts +16 -0
package/src/worker.js
ADDED
|
@@ -0,0 +1,2217 @@
|
|
|
1
|
+
// BusyTeX Compilation Worker
|
|
2
|
+
// Uses VirtualFileSystem for unified file mounting
|
|
3
|
+
|
|
4
|
+
// ============ Virtual FileSystem (inlined for worker compatibility) ============
|
|
5
|
+
|
|
6
|
+
class VirtualFileSystem {
|
|
7
|
+
constructor(FS, options = {}) {
|
|
8
|
+
this.FS = FS;
|
|
9
|
+
this.MEMFS = FS.filesystems.MEMFS;
|
|
10
|
+
this.onLog = options.onLog || (() => {});
|
|
11
|
+
this.mountedFiles = new Set();
|
|
12
|
+
this.mountedDirs = new Set();
|
|
13
|
+
this.pendingFontMaps = new Set();
|
|
14
|
+
this.bundleCache = new Map();
|
|
15
|
+
this.lazyEnabled = options.lazyEnabled || false;
|
|
16
|
+
this.lazyMarkerSymbol = '__siglum_lazy__';
|
|
17
|
+
this.deferredMarkerSymbol = '__siglum_deferred__';
|
|
18
|
+
|
|
19
|
+
// Deferred bundle loading - for font bundles loaded on demand
|
|
20
|
+
this.deferredBundles = new Map(); // bundleName -> {manifest entries}
|
|
21
|
+
this.onBundleNeeded = options.onBundleNeeded || null; // async callback
|
|
22
|
+
|
|
23
|
+
// External cache for Range-fetched files (persists across VFS instances)
|
|
24
|
+
this.fetchedFiles = options.fetchedFilesCache || new Map();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
mount(path, content, trackFontMaps = true) {
|
|
28
|
+
this._ensureDirectory(path);
|
|
29
|
+
const data = typeof content === 'string' ? new TextEncoder().encode(content) : content;
|
|
30
|
+
try {
|
|
31
|
+
this.FS.writeFile(path, data);
|
|
32
|
+
this.mountedFiles.add(path);
|
|
33
|
+
if (trackFontMaps) this._trackFontFile(path);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
this.onLog(`Failed to mount ${path}: ${e.message}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
mountLazy(path, bundleName, start, end, trackFontMaps = true) {
|
|
40
|
+
this._ensureDirectory(path);
|
|
41
|
+
const dirPath = path.substring(0, path.lastIndexOf('/'));
|
|
42
|
+
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
|
43
|
+
try {
|
|
44
|
+
const parentNode = this.FS.lookupPath(dirPath).node;
|
|
45
|
+
if (parentNode.contents?.[fileName]) return;
|
|
46
|
+
const node = this.MEMFS.createNode(parentNode, fileName, 33206, 0);
|
|
47
|
+
node.contents = this._createLazyMarker(bundleName, start, end);
|
|
48
|
+
node.usedBytes = end - start;
|
|
49
|
+
this.mountedFiles.add(path);
|
|
50
|
+
if (trackFontMaps) this._trackFontFile(path);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
this.onLog(`Failed to mount lazy ${path}: ${e.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Register a bundle as deferred - files are marked but not loaded
|
|
58
|
+
* When a deferred file is accessed, it triggers a bundle fetch request
|
|
59
|
+
*/
|
|
60
|
+
mountDeferredBundle(bundleName, manifest, bundleMeta = null) {
|
|
61
|
+
const bundleFiles = this._getBundleFiles(bundleName, manifest, bundleMeta);
|
|
62
|
+
if (bundleFiles.length === 0) return 0;
|
|
63
|
+
|
|
64
|
+
// Store manifest info for later loading
|
|
65
|
+
this.deferredBundles.set(bundleName, { files: bundleFiles, manifest, meta: bundleMeta });
|
|
66
|
+
|
|
67
|
+
// Create directory structure
|
|
68
|
+
const dirs = new Set();
|
|
69
|
+
for (const [path] of bundleFiles) {
|
|
70
|
+
const dir = path.substring(0, path.lastIndexOf('/'));
|
|
71
|
+
if (dir) dirs.add(dir);
|
|
72
|
+
}
|
|
73
|
+
for (const dir of dirs) this._ensureDirectoryPath(dir);
|
|
74
|
+
|
|
75
|
+
// Mount files as deferred markers
|
|
76
|
+
let mounted = 0;
|
|
77
|
+
for (const [path, info] of bundleFiles) {
|
|
78
|
+
if (this.mountedFiles.has(path)) continue;
|
|
79
|
+
this._mountDeferredFile(path, bundleName, info.start, info.end);
|
|
80
|
+
mounted++;
|
|
81
|
+
}
|
|
82
|
+
this.onLog(`Registered ${mounted} deferred files from bundle ${bundleName}`);
|
|
83
|
+
return mounted;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_mountDeferredFile(path, bundleName, start, end) {
|
|
87
|
+
this._ensureDirectory(path);
|
|
88
|
+
const dirPath = path.substring(0, path.lastIndexOf('/'));
|
|
89
|
+
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
|
90
|
+
try {
|
|
91
|
+
const parentNode = this.FS.lookupPath(dirPath).node;
|
|
92
|
+
if (parentNode.contents?.[fileName]) return;
|
|
93
|
+
const node = this.MEMFS.createNode(parentNode, fileName, 33206, 0);
|
|
94
|
+
node.contents = this._createDeferredMarker(bundleName, start, end);
|
|
95
|
+
node.usedBytes = end - start;
|
|
96
|
+
this.mountedFiles.add(path);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
this.onLog(`Failed to mount deferred ${path}: ${e.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_createDeferredMarker(bundleName, start, end) {
|
|
103
|
+
return { [this.deferredMarkerSymbol]: true, bundleName, start, end, length: end - start, byteLength: end - start };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
isDeferredMarker(obj) {
|
|
107
|
+
return obj && typeof obj === 'object' && obj[this.deferredMarkerSymbol] === true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_getBundleFiles(bundleName, manifest, bundleMeta) {
|
|
111
|
+
// Use pre-indexed lookup if available (O(1) vs O(n))
|
|
112
|
+
// Note: Returns cached array reference - callers must not modify!
|
|
113
|
+
if (filesByBundle?.has(bundleName)) {
|
|
114
|
+
return filesByBundle.get(bundleName);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fallback: scan manifest (for dynamically loaded bundles not in index)
|
|
118
|
+
const bundleFiles = [];
|
|
119
|
+
for (const [path, info] of Object.entries(manifest)) {
|
|
120
|
+
if (info.bundle === bundleName) bundleFiles.push([path, info]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// If no files found in manifest, use bundle-specific metadata
|
|
124
|
+
if (bundleFiles.length === 0 && bundleMeta?.files) {
|
|
125
|
+
for (const fileInfo of bundleMeta.files) {
|
|
126
|
+
const fullPath = `${fileInfo.path}/${fileInfo.name}`;
|
|
127
|
+
bundleFiles.push([fullPath, { start: fileInfo.start, end: fileInfo.end }]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return bundleFiles;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
mountBundle(bundleName, bundleData, manifest, bundleMeta = null) {
|
|
135
|
+
this.bundleCache.set(bundleName, bundleData);
|
|
136
|
+
let mounted = 0;
|
|
137
|
+
const bundleFiles = this._getBundleFiles(bundleName, manifest, bundleMeta);
|
|
138
|
+
|
|
139
|
+
const dirs = new Set();
|
|
140
|
+
for (const [path] of bundleFiles) {
|
|
141
|
+
const dir = path.substring(0, path.lastIndexOf('/'));
|
|
142
|
+
if (dir) dirs.add(dir);
|
|
143
|
+
}
|
|
144
|
+
for (const dir of dirs) this._ensureDirectoryPath(dir);
|
|
145
|
+
|
|
146
|
+
// Track font files for later pdftex.map rewriting
|
|
147
|
+
const isFontBundle = bundleName === 'cm-super' || bundleName.startsWith('fonts-');
|
|
148
|
+
|
|
149
|
+
for (const [path, info] of bundleFiles) {
|
|
150
|
+
if (this.mountedFiles.has(path)) continue;
|
|
151
|
+
if (this.lazyEnabled && !this._shouldEagerLoad(path)) {
|
|
152
|
+
this.mountLazy(path, bundleName, info.start, info.end, false);
|
|
153
|
+
} else {
|
|
154
|
+
const content = new Uint8Array(bundleData.slice(info.start, info.end));
|
|
155
|
+
this.mount(path, content, false);
|
|
156
|
+
}
|
|
157
|
+
mounted++;
|
|
158
|
+
|
|
159
|
+
// Track font files for pdftex.map rewriting
|
|
160
|
+
if (isFontBundle && (path.endsWith('.pfb') || path.endsWith('.enc'))) {
|
|
161
|
+
const filename = path.substring(path.lastIndexOf('/') + 1);
|
|
162
|
+
this.fontFileLocations = this.fontFileLocations || new Map();
|
|
163
|
+
this.fontFileLocations.set(filename, path);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.onLog(`Mounted ${mounted} files from bundle ${bundleName}`);
|
|
167
|
+
return mounted;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
mountCtanFiles(files, options = {}) {
|
|
171
|
+
const { forceOverride = false } = options;
|
|
172
|
+
const filesMap = files instanceof Map ? files : new Map(Object.entries(files));
|
|
173
|
+
let mounted = 0;
|
|
174
|
+
let overridden = 0;
|
|
175
|
+
for (const [path, content] of filesMap) {
|
|
176
|
+
const alreadyMounted = this.mountedFiles.has(path);
|
|
177
|
+
if (alreadyMounted && !forceOverride) continue;
|
|
178
|
+
|
|
179
|
+
const data = typeof content === 'string'
|
|
180
|
+
? (content.startsWith('base64:') ? this._decodeBase64(content.slice(7)) : new TextEncoder().encode(content))
|
|
181
|
+
: content;
|
|
182
|
+
this.mount(path, data, true); // Track font maps for CTAN packages
|
|
183
|
+
|
|
184
|
+
if (alreadyMounted) {
|
|
185
|
+
overridden++;
|
|
186
|
+
} else {
|
|
187
|
+
mounted++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (overridden > 0) {
|
|
191
|
+
this.onLog(`Mounted ${mounted} CTAN files, overrode ${overridden} bundle files`);
|
|
192
|
+
} else {
|
|
193
|
+
this.onLog(`Mounted ${mounted} CTAN files`);
|
|
194
|
+
}
|
|
195
|
+
return mounted + overridden;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
processFontMaps() {
|
|
199
|
+
if (this.pendingFontMaps.size === 0) return;
|
|
200
|
+
const PDFTEX_MAP_PATH = '/texlive/texmf-dist/texmf-var/fonts/map/pdftex/updmap/pdftex.map';
|
|
201
|
+
let existingMap = '';
|
|
202
|
+
try {
|
|
203
|
+
existingMap = new TextDecoder().decode(this.FS.readFile(PDFTEX_MAP_PATH));
|
|
204
|
+
} catch (e) {
|
|
205
|
+
this._ensureDirectoryPath(PDFTEX_MAP_PATH.substring(0, PDFTEX_MAP_PATH.lastIndexOf('/')));
|
|
206
|
+
}
|
|
207
|
+
let appended = 0;
|
|
208
|
+
for (const mapPath of this.pendingFontMaps) {
|
|
209
|
+
try {
|
|
210
|
+
const mapContent = new TextDecoder().decode(this.FS.readFile(mapPath));
|
|
211
|
+
const rewrittenContent = this._rewriteMapPaths(mapContent, mapPath);
|
|
212
|
+
existingMap += `\n% Added from ${mapPath}\n${rewrittenContent}\n`;
|
|
213
|
+
appended++;
|
|
214
|
+
} catch (e) {
|
|
215
|
+
this.onLog(`Failed to process font map ${mapPath}: ${e.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (appended > 0) {
|
|
219
|
+
this.FS.writeFile(PDFTEX_MAP_PATH, existingMap);
|
|
220
|
+
this.onLog(`Processed ${appended} font maps`);
|
|
221
|
+
}
|
|
222
|
+
this.pendingFontMaps.clear();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
_rewriteMapPaths(mapContent, mapFilePath) {
|
|
226
|
+
const lines = mapContent.split('\n');
|
|
227
|
+
const mapDir = mapFilePath.substring(0, mapFilePath.lastIndexOf('/'));
|
|
228
|
+
const packageMatch = mapFilePath.match(/\/([^\/]+)\/[^\/]+\.map$/);
|
|
229
|
+
const packageName = packageMatch ? packageMatch[1] : '';
|
|
230
|
+
const searchPaths = {
|
|
231
|
+
pfb: [`/texlive/texmf-dist/fonts/type1/public/${packageName}`, '/texlive/texmf-dist/fonts/type1/public/cm-super', mapDir],
|
|
232
|
+
enc: [`/texlive/texmf-dist/fonts/enc/dvips/${packageName}`, '/texlive/texmf-dist/fonts/enc/dvips/cm-super', `/texlive/texmf-dist/fonts/type1/public/${packageName}`, mapDir]
|
|
233
|
+
};
|
|
234
|
+
return lines.map(line => {
|
|
235
|
+
if (line.trim().startsWith('%') || line.trim() === '') return line;
|
|
236
|
+
let rewritten = line;
|
|
237
|
+
const fileRefPattern = /<<?([a-zA-Z0-9_-]+\.(pfb|enc))/g;
|
|
238
|
+
let match;
|
|
239
|
+
while ((match = fileRefPattern.exec(line)) !== null) {
|
|
240
|
+
const [fullMatch, filename, ext] = match;
|
|
241
|
+
const prefix = fullMatch.startsWith('<<') ? '<<' : '<';
|
|
242
|
+
const paths = searchPaths[ext] || [];
|
|
243
|
+
for (const searchDir of paths) {
|
|
244
|
+
const candidatePath = `${searchDir}/${filename}`;
|
|
245
|
+
try {
|
|
246
|
+
if (this.FS.analyzePath(candidatePath).exists) {
|
|
247
|
+
rewritten = rewritten.replace(fullMatch, `${prefix}${candidatePath}`);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
} catch (e) {}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return rewritten;
|
|
254
|
+
}).join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
generateLsR(basePath = '/texlive/texmf-dist') {
|
|
258
|
+
const dirContents = new Map();
|
|
259
|
+
dirContents.set(basePath, { files: [], subdirs: [] });
|
|
260
|
+
const getDir = (dirPath) => {
|
|
261
|
+
if (!dirContents.has(dirPath)) dirContents.set(dirPath, { files: [], subdirs: [] });
|
|
262
|
+
return dirContents.get(dirPath);
|
|
263
|
+
};
|
|
264
|
+
for (const path of this.mountedFiles) {
|
|
265
|
+
if (!path.startsWith(basePath)) continue;
|
|
266
|
+
const lastSlash = path.lastIndexOf('/');
|
|
267
|
+
if (lastSlash < 0) continue;
|
|
268
|
+
const dirPath = path.substring(0, lastSlash);
|
|
269
|
+
const fileName = path.substring(lastSlash + 1);
|
|
270
|
+
let current = basePath;
|
|
271
|
+
for (const part of dirPath.substring(basePath.length + 1).split('/').filter(p => p)) {
|
|
272
|
+
const parent = getDir(current);
|
|
273
|
+
current = `${current}/${part}`;
|
|
274
|
+
if (!parent.subdirs.includes(part)) parent.subdirs.push(part);
|
|
275
|
+
getDir(current);
|
|
276
|
+
}
|
|
277
|
+
getDir(dirPath).files.push(fileName);
|
|
278
|
+
}
|
|
279
|
+
const output = ['% ls-R -- filename database.', '% Created by Siglum VFS', ''];
|
|
280
|
+
const outputDir = (dirPath) => {
|
|
281
|
+
const contents = dirContents.get(dirPath);
|
|
282
|
+
if (!contents) return;
|
|
283
|
+
output.push(`${dirPath}:`);
|
|
284
|
+
contents.files.sort().forEach(f => output.push(f));
|
|
285
|
+
contents.subdirs.sort().forEach(d => output.push(d));
|
|
286
|
+
output.push('');
|
|
287
|
+
contents.subdirs.sort().forEach(subdir => outputDir(`${dirPath}/${subdir}`));
|
|
288
|
+
};
|
|
289
|
+
outputDir(basePath);
|
|
290
|
+
const lsRContent = output.join('\n');
|
|
291
|
+
this.FS.writeFile(`${basePath}/ls-R`, lsRContent);
|
|
292
|
+
return lsRContent;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
finalize() {
|
|
296
|
+
this.processFontMaps();
|
|
297
|
+
this.rewritePdftexMapPaths();
|
|
298
|
+
this.generateLsR();
|
|
299
|
+
this.onLog(`VFS finalized: ${this.mountedFiles.size} files`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
rewritePdftexMapPaths() {
|
|
303
|
+
// Rewrite pdftex.map to use absolute paths for font files
|
|
304
|
+
// This ensures pdfTeX can find fonts without relying on kpathsea search
|
|
305
|
+
if (!this.fontFileLocations || this.fontFileLocations.size === 0) return;
|
|
306
|
+
|
|
307
|
+
const PDFTEX_MAP_PATH = '/texlive/texmf-dist/texmf-var/fonts/map/pdftex/updmap/pdftex.map';
|
|
308
|
+
try {
|
|
309
|
+
const mapContent = new TextDecoder().decode(this.FS.readFile(PDFTEX_MAP_PATH));
|
|
310
|
+
const lines = mapContent.split('\n');
|
|
311
|
+
let modifiedCount = 0;
|
|
312
|
+
|
|
313
|
+
const rewrittenLines = lines.map(line => {
|
|
314
|
+
if (line.trim().startsWith('%') || line.trim() === '') return line;
|
|
315
|
+
|
|
316
|
+
let rewritten = line;
|
|
317
|
+
// Match font file references: <filename.pfb or <<filename.pfb or <filename.enc
|
|
318
|
+
const fileRefPattern = /<<?([a-zA-Z0-9_-]+\.(pfb|enc))/g;
|
|
319
|
+
let match;
|
|
320
|
+
while ((match = fileRefPattern.exec(line)) !== null) {
|
|
321
|
+
const [fullMatch, filename] = match;
|
|
322
|
+
const absolutePath = this.fontFileLocations.get(filename);
|
|
323
|
+
if (absolutePath) {
|
|
324
|
+
const prefix = fullMatch.startsWith('<<') ? '<<' : '<';
|
|
325
|
+
rewritten = rewritten.replace(fullMatch, `${prefix}${absolutePath}`);
|
|
326
|
+
modifiedCount++;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return rewritten;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (modifiedCount > 0) {
|
|
333
|
+
const newMapContent = rewrittenLines.join('\n');
|
|
334
|
+
this.FS.writeFile(PDFTEX_MAP_PATH, newMapContent);
|
|
335
|
+
this.onLog(`Rewrote pdftex.map: ${modifiedCount} font paths resolved`);
|
|
336
|
+
}
|
|
337
|
+
} catch (e) {
|
|
338
|
+
// pdftex.map might not exist yet, that's OK
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_createLazyMarker(bundleName, start, end) {
|
|
343
|
+
return { [this.lazyMarkerSymbol]: true, bundleName, start, end, length: end - start, byteLength: end - start };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
isLazyMarker(obj) {
|
|
347
|
+
return obj && typeof obj === 'object' && obj[this.lazyMarkerSymbol] === true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
resolveLazy(marker) {
|
|
351
|
+
const bundleData = this.bundleCache.get(marker.bundleName);
|
|
352
|
+
if (!bundleData) {
|
|
353
|
+
this.onLog(`ERROR: Bundle not in cache: ${marker.bundleName}`);
|
|
354
|
+
return new Uint8Array(0);
|
|
355
|
+
}
|
|
356
|
+
return new Uint8Array(bundleData.slice(marker.start, marker.end));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Resolve a deferred marker - returns data if bundle loaded, tracks request if not
|
|
361
|
+
* For per-file loading: tracks individual files to fetch via Range requests
|
|
362
|
+
*/
|
|
363
|
+
resolveDeferred(marker) {
|
|
364
|
+
const bundleData = this.bundleCache.get(marker.bundleName);
|
|
365
|
+
if (bundleData) {
|
|
366
|
+
// Bundle is now loaded - return the actual data
|
|
367
|
+
return new Uint8Array(bundleData.slice(marker.start, marker.end));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Check if file was already fetched individually via Range request
|
|
371
|
+
const fileKey = `${marker.bundleName}:${marker.start}:${marker.end}`;
|
|
372
|
+
if (this.fetchedFiles.has(fileKey)) {
|
|
373
|
+
return this.fetchedFiles.get(fileKey);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Track individual file request for Range-based fetching (avoid duplicates)
|
|
377
|
+
this.pendingDeferredFiles = this.pendingDeferredFiles || [];
|
|
378
|
+
const alreadyPending = this.pendingDeferredFiles.some(
|
|
379
|
+
f => f.bundleName === marker.bundleName && f.start === marker.start && f.end === marker.end
|
|
380
|
+
);
|
|
381
|
+
if (!alreadyPending) {
|
|
382
|
+
this.pendingDeferredFiles.push({
|
|
383
|
+
bundleName: marker.bundleName,
|
|
384
|
+
start: marker.start,
|
|
385
|
+
end: marker.end,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Return empty data - this will cause TeX to fail with a file not found error
|
|
390
|
+
// The retry loop will detect this and fetch individual files via Range requests
|
|
391
|
+
return new Uint8Array(0);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Store fetched file data for later resolution
|
|
396
|
+
* Uses LRU-style eviction when cache exceeds max entries
|
|
397
|
+
*/
|
|
398
|
+
storeFetchedFile(bundleName, start, end, data) {
|
|
399
|
+
const key = `${bundleName}:${start}:${end}`;
|
|
400
|
+
// Evict oldest entries if cache is at limit (200 entries max)
|
|
401
|
+
const maxEntries = 200;
|
|
402
|
+
while (this.fetchedFiles.size >= maxEntries) {
|
|
403
|
+
const oldestKey = this.fetchedFiles.keys().next().value;
|
|
404
|
+
this.fetchedFiles.delete(oldestKey);
|
|
405
|
+
}
|
|
406
|
+
this.fetchedFiles.set(key, data);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Get list of individual files that need to be fetched via Range requests
|
|
411
|
+
*/
|
|
412
|
+
getPendingDeferredFiles() {
|
|
413
|
+
const pending = this.pendingDeferredFiles || [];
|
|
414
|
+
this.pendingDeferredFiles = [];
|
|
415
|
+
return pending;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Get list of deferred bundles (legacy fallback - not used with per-file loading)
|
|
420
|
+
*/
|
|
421
|
+
getPendingDeferredBundles() {
|
|
422
|
+
const pending = this.pendingDeferredBundles ? [...this.pendingDeferredBundles] : [];
|
|
423
|
+
if (this.pendingDeferredBundles) this.pendingDeferredBundles.clear();
|
|
424
|
+
return pending;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Upgrade deferred markers to lazy markers when bundle is loaded
|
|
429
|
+
* Call this after a deferred bundle's data is added to bundleCache
|
|
430
|
+
*/
|
|
431
|
+
activateDeferredBundle(bundleName) {
|
|
432
|
+
if (!this.bundleCache.has(bundleName)) {
|
|
433
|
+
this.onLog(`Cannot activate deferred bundle ${bundleName}: not in cache`);
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const bundleInfo = this.deferredBundles.get(bundleName);
|
|
438
|
+
if (!bundleInfo) return 0;
|
|
439
|
+
|
|
440
|
+
let activated = 0;
|
|
441
|
+
for (const [path] of bundleInfo.files) {
|
|
442
|
+
try {
|
|
443
|
+
const node = this.FS.lookupPath(path).node;
|
|
444
|
+
if (this.isDeferredMarker(node.contents)) {
|
|
445
|
+
// Convert deferred marker to lazy marker (same structure, different symbol)
|
|
446
|
+
const marker = node.contents;
|
|
447
|
+
node.contents = this._createLazyMarker(marker.bundleName, marker.start, marker.end);
|
|
448
|
+
activated++;
|
|
449
|
+
}
|
|
450
|
+
} catch (e) {}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
this.deferredBundles.delete(bundleName);
|
|
454
|
+
this.onLog(`Activated ${activated} files from deferred bundle ${bundleName}`);
|
|
455
|
+
return activated;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
patchForLazyLoading() {
|
|
459
|
+
const vfs = this;
|
|
460
|
+
const ensureResolved = (node) => {
|
|
461
|
+
// Fast path: if already a Uint8Array, no resolution needed
|
|
462
|
+
const contents = node.contents;
|
|
463
|
+
if (contents instanceof Uint8Array) return;
|
|
464
|
+
|
|
465
|
+
if (vfs.isLazyMarker(contents)) {
|
|
466
|
+
const resolved = vfs.resolveLazy(contents);
|
|
467
|
+
node.contents = resolved;
|
|
468
|
+
node.usedBytes = resolved.length;
|
|
469
|
+
} else if (vfs.isDeferredMarker(contents)) {
|
|
470
|
+
const resolved = vfs.resolveDeferred(contents);
|
|
471
|
+
// Always replace marker with resolved data (even if empty)
|
|
472
|
+
// This is required because MEMFS.read expects node.contents to be a Uint8Array
|
|
473
|
+
// The bundle tracking happens inside resolveDeferred() before returning empty
|
|
474
|
+
node.contents = resolved;
|
|
475
|
+
node.usedBytes = resolved.length;
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
const originalRead = this.MEMFS.stream_ops.read;
|
|
479
|
+
this.MEMFS.stream_ops.read = function(stream, buffer, offset, length, position) {
|
|
480
|
+
ensureResolved(stream.node);
|
|
481
|
+
return originalRead.call(this, stream, buffer, offset, length, position);
|
|
482
|
+
};
|
|
483
|
+
if (this.MEMFS.ops_table?.file?.stream?.read) {
|
|
484
|
+
const originalTableRead = this.MEMFS.ops_table.file.stream.read;
|
|
485
|
+
this.MEMFS.ops_table.file.stream.read = function(stream, buffer, offset, length, position) {
|
|
486
|
+
ensureResolved(stream.node);
|
|
487
|
+
return originalTableRead.call(this, stream, buffer, offset, length, position);
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
if (this.MEMFS.stream_ops.mmap) {
|
|
491
|
+
const originalMmap = this.MEMFS.stream_ops.mmap;
|
|
492
|
+
this.MEMFS.stream_ops.mmap = function(stream, length, position, prot, flags) {
|
|
493
|
+
ensureResolved(stream.node);
|
|
494
|
+
return originalMmap.call(this, stream, length, position, prot, flags);
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
this.lazyEnabled = true;
|
|
498
|
+
this.onLog('VFS: Lazy loading enabled');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
_ensureDirectory(filePath) {
|
|
502
|
+
const dirPath = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
503
|
+
this._ensureDirectoryPath(dirPath);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
_ensureDirectoryPath(dirPath) {
|
|
507
|
+
if (this.mountedDirs.has(dirPath)) return;
|
|
508
|
+
const parts = dirPath.split('/').filter(p => p);
|
|
509
|
+
let current = '';
|
|
510
|
+
for (const part of parts) {
|
|
511
|
+
current += '/' + part;
|
|
512
|
+
if (this.mountedDirs.has(current)) continue;
|
|
513
|
+
try { this.FS.stat(current); } catch (e) { try { this.FS.mkdir(current); } catch (e2) {} }
|
|
514
|
+
this.mountedDirs.add(current);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
_shouldEagerLoad(path) {
|
|
519
|
+
// Eager load critical files that kpathsea needs to find
|
|
520
|
+
return path.endsWith('.fmt') ||
|
|
521
|
+
path.endsWith('texmf.cnf') ||
|
|
522
|
+
path.endsWith('.map') ||
|
|
523
|
+
path.endsWith('.pfb') || // Type1 fonts - needed by pdfTeX
|
|
524
|
+
path.endsWith('.enc'); // Encoding files - needed by pdfTeX
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
_trackFontFile(path) {
|
|
528
|
+
// Track font maps for later processing (append to pdftex.map)
|
|
529
|
+
// Only called for CTAN packages - bundles pass trackFontMaps=false
|
|
530
|
+
if (path.endsWith('.map') && !path.endsWith('pdftex.map')) {
|
|
531
|
+
this.pendingFontMaps.add(path);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
_decodeBase64(base64) {
|
|
536
|
+
const binary = atob(base64);
|
|
537
|
+
const bytes = new Uint8Array(binary.length);
|
|
538
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
539
|
+
return bytes;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function configureTexEnvironment(ENV) {
|
|
544
|
+
ENV['TEXMFCNF'] = '/texlive/texmf-dist/web2c';
|
|
545
|
+
ENV['TEXMFROOT'] = '/texlive';
|
|
546
|
+
ENV['TEXMFDIST'] = '/texlive/texmf-dist';
|
|
547
|
+
ENV['TEXMFVAR'] = '/texlive/texmf-dist/texmf-var';
|
|
548
|
+
ENV['TEXMFSYSVAR'] = '/texlive/texmf-dist/texmf-var';
|
|
549
|
+
ENV['TEXMFSYSCONFIG'] = '/texlive/texmf-dist';
|
|
550
|
+
ENV['TEXMFLOCAL'] = '/texlive/texmf-dist';
|
|
551
|
+
ENV['TEXMFHOME'] = '/texlive/texmf-dist';
|
|
552
|
+
ENV['TEXMFCONFIG'] = '/texlive/texmf-dist';
|
|
553
|
+
ENV['TEXMFAUXTREES'] = '';
|
|
554
|
+
ENV['TEXMF'] = '/texlive/texmf-dist';
|
|
555
|
+
ENV['TEXMFDOTDIR'] = '.';
|
|
556
|
+
ENV['TEXINPUTS'] = '.:/texlive/texmf-dist/tex/latex//:/texlive/texmf-dist/tex/generic//:/texlive/texmf-dist/tex//:';
|
|
557
|
+
ENV['T1FONTS'] = '.:/texlive/texmf-dist/fonts/type1//';
|
|
558
|
+
ENV['ENCFONTS'] = '.:/texlive/texmf-dist/fonts/enc//';
|
|
559
|
+
ENV['TFMFONTS'] = '.:/texlive/texmf-dist/fonts/tfm//';
|
|
560
|
+
ENV['VFFONTS'] = '.:/texlive/texmf-dist/fonts/vf//';
|
|
561
|
+
ENV['TEXFONTMAPS'] = '.:/texlive/texmf-dist/fonts/map/dvips//:/texlive/texmf-dist/fonts/map/pdftex//:/texlive/texmf-dist/texmf-var/fonts/map//';
|
|
562
|
+
ENV['TEXPSHEADERS'] = '.:/texlive/texmf-dist/dvips//:/texlive/texmf-dist/fonts/enc//:/texlive/texmf-dist/fonts/type1//:/texlive/texmf-dist/fonts/type42//';
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ============ Worker Code ============
|
|
566
|
+
|
|
567
|
+
const BUNDLE_BASE = 'packages/bundles';
|
|
568
|
+
|
|
569
|
+
// Worker state
|
|
570
|
+
let cachedWasmModule = null;
|
|
571
|
+
let busytexJsUrl = null;
|
|
572
|
+
let fileManifest = null;
|
|
573
|
+
let packageMap = null;
|
|
574
|
+
let bundleDeps = null;
|
|
575
|
+
let bundleRegistry = null;
|
|
576
|
+
let verboseLogging = false; // When false, skip TeX stdout logging for performance
|
|
577
|
+
|
|
578
|
+
// Pre-indexed manifest: bundleName → [[path, info], ...]
|
|
579
|
+
// Allows O(1) lookup instead of O(n) scan per mountBundle call
|
|
580
|
+
let filesByBundle = null;
|
|
581
|
+
|
|
582
|
+
// Font file index: basename (e.g., "lmbx12.pfb") → bundle name
|
|
583
|
+
// Enables dynamic font bundle resolution for any font in any bundle
|
|
584
|
+
let fontFileToBundle = null;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Index manifest by bundle name for fast lookup.
|
|
588
|
+
* Also builds font file index for dynamic font resolution.
|
|
589
|
+
* Called once at worker init.
|
|
590
|
+
*/
|
|
591
|
+
function ensureManifestIndexed(manifest) {
|
|
592
|
+
if (filesByBundle || !manifest) return;
|
|
593
|
+
|
|
594
|
+
filesByBundle = new Map();
|
|
595
|
+
fontFileToBundle = new Map();
|
|
596
|
+
|
|
597
|
+
for (const [path, info] of Object.entries(manifest)) {
|
|
598
|
+
const bundle = info.bundle;
|
|
599
|
+
if (!bundle) continue;
|
|
600
|
+
if (!filesByBundle.has(bundle)) {
|
|
601
|
+
filesByBundle.set(bundle, []);
|
|
602
|
+
}
|
|
603
|
+
filesByBundle.get(bundle).push([path, info]);
|
|
604
|
+
|
|
605
|
+
// Index font files by basename for dynamic lookup
|
|
606
|
+
if (path.endsWith('.pfb') || path.endsWith('.tfm')) {
|
|
607
|
+
const basename = path.substring(path.lastIndexOf('/') + 1);
|
|
608
|
+
// Only store first occurrence (some fonts may be in multiple bundles)
|
|
609
|
+
if (!fontFileToBundle.has(basename)) {
|
|
610
|
+
fontFileToBundle.set(basename, bundle);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
workerLog(`Indexed manifest: ${filesByBundle.size} bundles, ${fontFileToBundle.size} font files`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// SharedArrayBuffer support - check once at startup
|
|
618
|
+
const sharedArrayBufferAvailable = typeof SharedArrayBuffer !== 'undefined';
|
|
619
|
+
|
|
620
|
+
// Global Module instance - reused across compilations to avoid memory leaks
|
|
621
|
+
// Each initBusyTeX call creates a 512MB WASM heap; we want only ONE
|
|
622
|
+
let globalModule = null;
|
|
623
|
+
let globalModulePromise = null;
|
|
624
|
+
|
|
625
|
+
// Pending requests
|
|
626
|
+
const pendingCtanRequests = new Map();
|
|
627
|
+
const pendingBundleRequests = new Map();
|
|
628
|
+
const pendingFileRangeRequests = new Map();
|
|
629
|
+
|
|
630
|
+
// Global cache for Range-fetched files (persists across compiles)
|
|
631
|
+
const globalFetchedFilesCache = new Map();
|
|
632
|
+
|
|
633
|
+
// Operation queue to serialize compile and format-generate operations
|
|
634
|
+
// (async onmessage doesn't block new messages from being processed concurrently)
|
|
635
|
+
let operationQueue = Promise.resolve();
|
|
636
|
+
|
|
637
|
+
function workerLog(msg) {
|
|
638
|
+
self.postMessage({ type: 'log', message: msg });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function workerProgress(stage, detail) {
|
|
642
|
+
self.postMessage({ type: 'progress', stage, detail });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ============ External Fetch Requests ============
|
|
646
|
+
|
|
647
|
+
// Supported TexLive years for version fallback (newest first)
|
|
648
|
+
const SUPPORTED_TL_YEARS = [2025, 2024, 2023];
|
|
649
|
+
const DEFAULT_TL_YEAR = 2025;
|
|
650
|
+
|
|
651
|
+
function requestCtanFetch(packageName, originalFileName = null, tlYear = null) {
|
|
652
|
+
return new Promise((resolve, reject) => {
|
|
653
|
+
const requestId = crypto.randomUUID();
|
|
654
|
+
pendingCtanRequests.set(requestId, { resolve, reject });
|
|
655
|
+
|
|
656
|
+
self.postMessage({
|
|
657
|
+
type: 'ctan-fetch-request',
|
|
658
|
+
requestId,
|
|
659
|
+
packageName,
|
|
660
|
+
fileName: originalFileName || packageName + '.sty', // For file-to-package lookup
|
|
661
|
+
tlYear, // Optional: request specific TexLive year
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
setTimeout(() => {
|
|
665
|
+
if (pendingCtanRequests.has(requestId)) {
|
|
666
|
+
pendingCtanRequests.delete(requestId);
|
|
667
|
+
reject(new Error('CTAN fetch timeout'));
|
|
668
|
+
}
|
|
669
|
+
}, 60000);
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function requestBundleFetch(bundleName) {
|
|
674
|
+
return new Promise((resolve, reject) => {
|
|
675
|
+
const requestId = crypto.randomUUID();
|
|
676
|
+
pendingBundleRequests.set(requestId, { resolve, reject });
|
|
677
|
+
|
|
678
|
+
self.postMessage({
|
|
679
|
+
type: 'bundle-fetch-request',
|
|
680
|
+
requestId,
|
|
681
|
+
bundleName,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
setTimeout(() => {
|
|
685
|
+
if (pendingBundleRequests.has(requestId)) {
|
|
686
|
+
pendingBundleRequests.delete(requestId);
|
|
687
|
+
reject(new Error('Bundle fetch timeout'));
|
|
688
|
+
}
|
|
689
|
+
}, 60000);
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function requestFileRangeFetch(bundleName, start, end) {
|
|
694
|
+
return new Promise((resolve, reject) => {
|
|
695
|
+
const requestId = crypto.randomUUID();
|
|
696
|
+
pendingFileRangeRequests.set(requestId, { resolve, reject });
|
|
697
|
+
|
|
698
|
+
self.postMessage({
|
|
699
|
+
type: 'file-range-fetch-request',
|
|
700
|
+
requestId,
|
|
701
|
+
bundleName,
|
|
702
|
+
start,
|
|
703
|
+
end,
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
setTimeout(() => {
|
|
707
|
+
if (pendingFileRangeRequests.has(requestId)) {
|
|
708
|
+
pendingFileRangeRequests.delete(requestId);
|
|
709
|
+
reject(new Error('File range fetch timeout'));
|
|
710
|
+
}
|
|
711
|
+
}, 30000);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ============ Source Processing ============
|
|
716
|
+
|
|
717
|
+
function injectMicrotypeWorkaround(source) {
|
|
718
|
+
if (!source.includes('microtype')) return source;
|
|
719
|
+
const documentclassMatch = source.match(/\\documentclass/);
|
|
720
|
+
if (!documentclassMatch) return source;
|
|
721
|
+
const insertPos = documentclassMatch.index;
|
|
722
|
+
const workaround = '% Siglum: Disable microtype font expansion\n\\PassOptionsToPackage{expansion=false}{microtype}\n';
|
|
723
|
+
workerLog('Injecting microtype expansion=false workaround');
|
|
724
|
+
return source.slice(0, insertPos) + workaround + source.slice(insertPos);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Compatibility shims for package version issues
|
|
728
|
+
// These fix issues where CTAN packages expect features not in our kernel
|
|
729
|
+
|
|
730
|
+
function injectKernelCompatShim(source) {
|
|
731
|
+
// Reserved for future use - version fallback system will handle compatibility
|
|
732
|
+
return source;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Patterns that indicate LaTeX3 tagging/accessibility features not in our kernel
|
|
736
|
+
// These are used to detect when a package needs version fallback or shimming
|
|
737
|
+
// Detection is pattern-based, not hardcoded to specific commands
|
|
738
|
+
const KERNEL_INCOMPATIBLE_PATTERNS = [
|
|
739
|
+
/^tag(struct|mc|pdf)/i, // tagstructbegin, tagmcend, tagpdfparaOff, etc.
|
|
740
|
+
/Structure(Name|Role)/i, // NewStructureName, AssignStructureRole, etc.
|
|
741
|
+
/TaggingSocket/i, // NewTaggingSocket, UseTaggingSocket, etc.
|
|
742
|
+
/DocumentMetadata/i, // DocumentMetadata, DeclareDocumentMetadata
|
|
743
|
+
/PDFManagement/i, // IfPDFManagementActiveTF
|
|
744
|
+
/^socket_/i, // socket_new, socket_set, etc.
|
|
745
|
+
];
|
|
746
|
+
|
|
747
|
+
// Check if a command matches kernel incompatibility patterns
|
|
748
|
+
function isKernelIncompatibleCommand(cmd) {
|
|
749
|
+
return KERNEL_INCOMPATIBLE_PATTERNS.some(pattern => pattern.test(cmd));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Check if undefined commands indicate kernel incompatibility
|
|
753
|
+
// Returns list of packages that should be tried with older TL versions
|
|
754
|
+
function detectKernelIncompatibility(logContent, undefinedCommands) {
|
|
755
|
+
const incompatiblePackages = new Set();
|
|
756
|
+
|
|
757
|
+
// Check if any undefined commands match kernel incompatibility patterns
|
|
758
|
+
for (const cmd of undefinedCommands.keys()) {
|
|
759
|
+
if (isKernelIncompatibleCommand(cmd)) {
|
|
760
|
+
workerLog(`[KERNEL] Detected kernel-incompatible command: \\${cmd}`);
|
|
761
|
+
|
|
762
|
+
// Try to identify which package triggered this
|
|
763
|
+
const pkgMatch = identifyPackageFromLog(logContent, cmd);
|
|
764
|
+
if (pkgMatch) {
|
|
765
|
+
incompatiblePackages.add(pkgMatch);
|
|
766
|
+
workerLog(`[KERNEL] Command \\${cmd} is from package: ${pkgMatch}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return incompatiblePackages;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Try to identify which package is causing the undefined command
|
|
775
|
+
// Parses TeX log file loading structure to find the currently-loading file
|
|
776
|
+
function identifyPackageFromLog(logContent, cmd) {
|
|
777
|
+
const errorIndex = logContent.indexOf(`\\${cmd}`);
|
|
778
|
+
if (errorIndex === -1) return null;
|
|
779
|
+
|
|
780
|
+
// Get context before the error
|
|
781
|
+
let contextBefore = logContent.slice(Math.max(0, errorIndex - 10000), errorIndex);
|
|
782
|
+
|
|
783
|
+
// Preprocess: Remove [TeX] and [TeX ERR] prefixes from each line
|
|
784
|
+
contextBefore = contextBefore.replace(/^\[TeX( ERR)?\] /gm, '');
|
|
785
|
+
|
|
786
|
+
// Preprocess: Join wrapped lines (TeX wraps at ~79 chars)
|
|
787
|
+
contextBefore = contextBefore.replace(/([a-zA-Z0-9_.-])\n([a-zA-Z0-9_.-])/g, '$1$2');
|
|
788
|
+
|
|
789
|
+
// Find all .sty and .cls file opens with their positions
|
|
790
|
+
const fileOpens = [];
|
|
791
|
+
const openRegex = /\(([^\s()"']*\/([^\/]+)\.(sty|cls))/gi;
|
|
792
|
+
for (const match of contextBefore.matchAll(openRegex)) {
|
|
793
|
+
fileOpens.push({
|
|
794
|
+
pos: match.index,
|
|
795
|
+
path: match[1],
|
|
796
|
+
name: match[2]
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// For each file open (from last to first), check if it's still open
|
|
801
|
+
// by counting ( and ) between the open position and end of context
|
|
802
|
+
for (let i = fileOpens.length - 1; i >= 0; i--) {
|
|
803
|
+
const file = fileOpens[i];
|
|
804
|
+
const afterOpen = contextBefore.slice(file.pos);
|
|
805
|
+
|
|
806
|
+
// Count parens in the text after this file open
|
|
807
|
+
let depth = 0;
|
|
808
|
+
for (const char of afterOpen) {
|
|
809
|
+
if (char === '(') depth++;
|
|
810
|
+
else if (char === ')') depth--;
|
|
811
|
+
// If depth goes negative, this file has been closed
|
|
812
|
+
if (depth < 0) break;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// If depth > 0, this file is still open at the error point
|
|
816
|
+
// (depth=0 means balanced parens = file was closed)
|
|
817
|
+
if (depth > 0) {
|
|
818
|
+
workerLog(`[KERNEL] Found open package: ${file.name} (depth=${depth})`);
|
|
819
|
+
return file.name;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Fallback: Try to match "Package foo" pattern in log messages
|
|
824
|
+
workerLog(`[KERNEL] No open package found, trying Package pattern fallback`);
|
|
825
|
+
const packageMatches = [...contextBefore.matchAll(/Package\s+([\w-]+)\s+(?:Info|Warning|Error)/g)];
|
|
826
|
+
if (packageMatches.length > 0) {
|
|
827
|
+
const fallbackPkg = packageMatches[packageMatches.length - 1][1];
|
|
828
|
+
workerLog(`[KERNEL] Fallback found: ${fallbackPkg}`);
|
|
829
|
+
return fallbackPkg;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Extract undefined control sequences from TeX log with argument count detection
|
|
836
|
+
function extractUndefinedCommands(logContent) {
|
|
837
|
+
const commands = new Map(); // cmd -> argCount
|
|
838
|
+
// Pattern: "! Undefined control sequence." followed by lines with command and args
|
|
839
|
+
// Log format (with [TeX] prefixes):
|
|
840
|
+
// [TeX] ! Undefined control sequence.
|
|
841
|
+
// [TeX] l.76 \NewStructureName
|
|
842
|
+
// [TeX] {tcb/box}
|
|
843
|
+
// Or with multiple commands:
|
|
844
|
+
// [TeX] l.81 {\par\tagstructbegin
|
|
845
|
+
// [TeX] {tag=...}
|
|
846
|
+
// The LAST command before the line break is the undefined one
|
|
847
|
+
|
|
848
|
+
const errorPattern = /! Undefined control sequence\./g;
|
|
849
|
+
let errorMatch;
|
|
850
|
+
|
|
851
|
+
while ((errorMatch = errorPattern.exec(logContent)) !== null) {
|
|
852
|
+
// Get the next ~500 chars to find the command and its arguments
|
|
853
|
+
const context = logContent.slice(errorMatch.index, errorMatch.index + 500);
|
|
854
|
+
|
|
855
|
+
// Find the line with "l.N" - this contains the undefined command
|
|
856
|
+
const lineMatch = context.match(/l\.\d+[^\n]*/);
|
|
857
|
+
if (!lineMatch) continue;
|
|
858
|
+
|
|
859
|
+
const errorLine = lineMatch[0];
|
|
860
|
+
|
|
861
|
+
// Find ALL commands on this line - the LAST one is the undefined one
|
|
862
|
+
const cmdMatches = [...errorLine.matchAll(/\\([a-zA-Z]+)/g)];
|
|
863
|
+
if (cmdMatches.length === 0) continue;
|
|
864
|
+
|
|
865
|
+
// The undefined command is the last one on the line
|
|
866
|
+
const lastMatch = cmdMatches[cmdMatches.length - 1];
|
|
867
|
+
const cmd = lastMatch[1];
|
|
868
|
+
if (cmd.length < 2 || cmd.length > 50) continue;
|
|
869
|
+
|
|
870
|
+
// Get everything after this command to count arguments
|
|
871
|
+
const afterCmd = context.slice(lineMatch.index + lastMatch.index + lastMatch[0].length);
|
|
872
|
+
// Stop at next error, "==>" marker, or "Transcript"
|
|
873
|
+
const endMatch = afterCmd.match(/!|==>|Transcript/);
|
|
874
|
+
const argContext = endMatch ? afterCmd.slice(0, endMatch.index) : afterCmd;
|
|
875
|
+
|
|
876
|
+
// Count opening braces
|
|
877
|
+
const braceCount = (argContext.match(/\{/g) || []).length;
|
|
878
|
+
const argCount = Math.min(braceCount, 9);
|
|
879
|
+
|
|
880
|
+
if (!commands.has(cmd) || commands.get(cmd) < argCount) {
|
|
881
|
+
commands.set(cmd, argCount);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return commands;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Inject auto-generated stubs for undefined commands
|
|
888
|
+
// undefinedCommands is a Map<commandName, argCount>
|
|
889
|
+
function injectAutoShims(source, undefinedCommands) {
|
|
890
|
+
if (undefinedCommands.size === 0) return source;
|
|
891
|
+
|
|
892
|
+
// Find the end of \documentclass[...]{...} to insert after it
|
|
893
|
+
const documentclassMatch = source.match(/\\documentclass(\[[^\]]*\])?\{[^}]+\}/);
|
|
894
|
+
if (!documentclassMatch) return source;
|
|
895
|
+
|
|
896
|
+
// Generate stubs with detected argument counts
|
|
897
|
+
const stubs = [];
|
|
898
|
+
const cmdList = [];
|
|
899
|
+
for (const [cmd, argCount] of undefinedCommands) {
|
|
900
|
+
cmdList.push(`${cmd}[${argCount}]`);
|
|
901
|
+
if (argCount === 0) {
|
|
902
|
+
stubs.push(`\\providecommand{\\${cmd}}{}`);
|
|
903
|
+
} else {
|
|
904
|
+
stubs.push(`\\providecommand{\\${cmd}}[${argCount}]{}`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const insertPos = documentclassMatch.index + documentclassMatch[0].length;
|
|
909
|
+
const shimBlock = `
|
|
910
|
+
% Siglum: Auto-generated stubs for undefined commands
|
|
911
|
+
${stubs.join('%\n')}%
|
|
912
|
+
`;
|
|
913
|
+
workerLog(`Auto-shimming ${undefinedCommands.size} commands: ${cmdList.join(', ')}`);
|
|
914
|
+
return source.slice(0, insertPos) + shimBlock + source.slice(insertPos);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function injectPdfMapFileCommands(source, mapFilePaths) {
|
|
918
|
+
if (mapFilePaths.length === 0) return source;
|
|
919
|
+
const newMaps = mapFilePaths.filter(p => !source.includes(p));
|
|
920
|
+
if (newMaps.length === 0) return source;
|
|
921
|
+
|
|
922
|
+
const mapCommands = newMaps.map(p => '\\pdfmapfile{+' + p + '}').join('\n');
|
|
923
|
+
const documentclassMatch = source.match(/\\documentclass(\[[^\]]*\])?\{[^}]+\}/);
|
|
924
|
+
|
|
925
|
+
if (documentclassMatch) {
|
|
926
|
+
const insertPos = documentclassMatch.index + documentclassMatch[0].length;
|
|
927
|
+
const preambleInsert = '\n% Font maps injected by Siglum\n' + mapCommands + '\n';
|
|
928
|
+
workerLog('Injecting ' + newMaps.length + ' \\pdfmapfile commands');
|
|
929
|
+
return source.slice(0, insertPos) + preambleInsert + source.slice(insertPos);
|
|
930
|
+
}
|
|
931
|
+
return source;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ============ Missing File Detection ============
|
|
935
|
+
|
|
936
|
+
function extractMissingFile(logContent, alreadyFetched) {
|
|
937
|
+
const files = extractAllMissingFiles(logContent, alreadyFetched);
|
|
938
|
+
return files.length > 0 ? files[0] : null;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Extract ALL missing files from log (for parallel fetching)
|
|
942
|
+
function extractAllMissingFiles(logContent, alreadyFetched) {
|
|
943
|
+
const patterns = [
|
|
944
|
+
/! LaTeX Error: File `([^']+)' not found/g,
|
|
945
|
+
/! I can't find file `([^']+)'/g,
|
|
946
|
+
/LaTeX Warning:.*File `([^']+)' not found/g,
|
|
947
|
+
/Package .* Error:.*`([^']+)' not found/g,
|
|
948
|
+
/! Font [^=]+=([a-z0-9-]+) at .* not loadable: Metric \(TFM\) file/g,
|
|
949
|
+
/!pdfTeX error:.*\(file ([a-z0-9-]+)\): Font .* not found/g,
|
|
950
|
+
/! Font ([a-z0-9-]+) at [0-9]+ not found/g,
|
|
951
|
+
// Generic PGF/TeX: "I looked for files named X.code.tex" (captures first filename)
|
|
952
|
+
/I looked for files named ([a-z0-9_-]+\.code\.tex)/gi,
|
|
953
|
+
// xdvipdfmx: Could not locate a virtual/physical font for TFM "ec-lmbx12"
|
|
954
|
+
/xdvipdfmx.*Could not locate.*TFM "([a-z0-9_-]+)"/gi,
|
|
955
|
+
// xdvipdfmx: This font is mapped to a physical font "lmbx12.pfb"
|
|
956
|
+
/xdvipdfmx.*mapped to.*"([a-z0-9_-]+\.pfb)"/gi,
|
|
957
|
+
];
|
|
958
|
+
const fetchedSet = alreadyFetched || new Set();
|
|
959
|
+
const missingFiles = [];
|
|
960
|
+
const seenPkgs = new Set();
|
|
961
|
+
|
|
962
|
+
for (const pattern of patterns) {
|
|
963
|
+
let match;
|
|
964
|
+
while ((match = pattern.exec(logContent)) !== null) {
|
|
965
|
+
const missingFile = match[1];
|
|
966
|
+
const pkgName = getPackageFromFile(missingFile);
|
|
967
|
+
if (!fetchedSet.has(pkgName) && !seenPkgs.has(pkgName)) {
|
|
968
|
+
seenPkgs.add(pkgName);
|
|
969
|
+
missingFiles.push(missingFile);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return missingFiles;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function getFontPackage(fontName) {
|
|
977
|
+
if (!fontName) return null;
|
|
978
|
+
|
|
979
|
+
// Strip font extension if present
|
|
980
|
+
const baseName = fontName.replace(/\.(pfb|tfm)$/i, '');
|
|
981
|
+
|
|
982
|
+
// Dynamic lookup: check font file index first (covers ALL fonts in bundles)
|
|
983
|
+
if (fontFileToBundle) {
|
|
984
|
+
// Try as .pfb file (physical font)
|
|
985
|
+
let bundle = fontFileToBundle.get(baseName + '.pfb');
|
|
986
|
+
if (bundle) return bundle;
|
|
987
|
+
|
|
988
|
+
// Try as .tfm file (TFM name like "ec-lmbx12")
|
|
989
|
+
bundle = fontFileToBundle.get(baseName + '.tfm');
|
|
990
|
+
if (bundle) return bundle;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Fallback: Latin Modern patterns for CTAN fetch (when not in local bundles)
|
|
994
|
+
if (/^(rm|cs|ec|ts|qx|t5|l7x)-?lm/.test(baseName)) return 'lm';
|
|
995
|
+
if (/^lm[a-z]{1,4}\d+$/.test(baseName)) return 'lm';
|
|
996
|
+
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function getPackageFromFile(filename) {
|
|
1001
|
+
const fontPkg = getFontPackage(filename);
|
|
1002
|
+
if (fontPkg) return fontPkg;
|
|
1003
|
+
|
|
1004
|
+
// Strip extension - the file-to-package index handles the mapping
|
|
1005
|
+
return filename.replace(/\.(sty|cls|def|clo|fd|cfg|tex|code\.tex)$/, '');
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// ============ Aux File Handling ============
|
|
1009
|
+
|
|
1010
|
+
function collectAuxFiles(FS) {
|
|
1011
|
+
const auxExtensions = ['.aux', '.toc', '.lof', '.lot', '.out', '.nav', '.snm', '.bbl', '.blg'];
|
|
1012
|
+
const files = {};
|
|
1013
|
+
for (const ext of auxExtensions) {
|
|
1014
|
+
const path = '/document' + ext;
|
|
1015
|
+
try {
|
|
1016
|
+
files[ext] = FS.readFile(path, { encoding: 'utf8' });
|
|
1017
|
+
} catch (e) {}
|
|
1018
|
+
}
|
|
1019
|
+
return files;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function restoreAuxFiles(FS, auxFiles) {
|
|
1023
|
+
let restored = 0;
|
|
1024
|
+
for (const [ext, content] of Object.entries(auxFiles)) {
|
|
1025
|
+
try {
|
|
1026
|
+
FS.writeFile('/document' + ext, content);
|
|
1027
|
+
restored++;
|
|
1028
|
+
} catch (e) {}
|
|
1029
|
+
}
|
|
1030
|
+
return restored;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ============ WASM Initialization ============
|
|
1034
|
+
|
|
1035
|
+
// Track if busytex.js has been loaded
|
|
1036
|
+
let busytexScriptLoaded = false;
|
|
1037
|
+
|
|
1038
|
+
async function initBusyTeX(wasmModule, jsUrl, memorySnapshot = null) {
|
|
1039
|
+
const startTime = performance.now();
|
|
1040
|
+
|
|
1041
|
+
// Only load busytex.js once - it defines the global `busytex` function
|
|
1042
|
+
// Use fetch + Blob URL to support cross-origin CDN loading (importScripts has stricter CORS)
|
|
1043
|
+
if (!busytexScriptLoaded) {
|
|
1044
|
+
const response = await fetch(jsUrl);
|
|
1045
|
+
if (!response.ok) throw new Error(`Failed to fetch busytex.js: ${response.status}`);
|
|
1046
|
+
const code = await response.text();
|
|
1047
|
+
const blob = new Blob([code], { type: 'application/javascript' });
|
|
1048
|
+
importScripts(URL.createObjectURL(blob));
|
|
1049
|
+
busytexScriptLoaded = true;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Output capture for stdout/stderr - accessible by print/printErr callbacks via closure
|
|
1053
|
+
// Use arrays for O(n) performance instead of string concat O(n²)
|
|
1054
|
+
const outputCapture = { stdout: [], stderr: [] };
|
|
1055
|
+
|
|
1056
|
+
const moduleConfig = {
|
|
1057
|
+
thisProgram: '/bin/busytex',
|
|
1058
|
+
noInitialRun: true,
|
|
1059
|
+
noExitRuntime: true,
|
|
1060
|
+
instantiateWasm: (imports, successCallback) => {
|
|
1061
|
+
WebAssembly.instantiate(wasmModule, imports).then(instance => {
|
|
1062
|
+
// Restore memory from snapshot if available (skips ~3s TeX initialization)
|
|
1063
|
+
if (memorySnapshot) {
|
|
1064
|
+
try {
|
|
1065
|
+
const memory = instance.exports.memory;
|
|
1066
|
+
const snapshotView = memorySnapshot instanceof Uint8Array
|
|
1067
|
+
? memorySnapshot
|
|
1068
|
+
: new Uint8Array(memorySnapshot);
|
|
1069
|
+
const memoryView = new Uint8Array(memory.buffer);
|
|
1070
|
+
|
|
1071
|
+
// Only restore if snapshot fits in current memory
|
|
1072
|
+
if (snapshotView.byteLength <= memoryView.byteLength) {
|
|
1073
|
+
memoryView.set(snapshotView);
|
|
1074
|
+
workerLog(`Restored memory snapshot (${(snapshotView.byteLength / 1024 / 1024).toFixed(1)}MB)`);
|
|
1075
|
+
} else {
|
|
1076
|
+
// Need to grow memory to fit snapshot
|
|
1077
|
+
const currentPages = memory.buffer.byteLength / 65536;
|
|
1078
|
+
const neededPages = Math.ceil(snapshotView.byteLength / 65536);
|
|
1079
|
+
const pagesToGrow = neededPages - currentPages;
|
|
1080
|
+
if (pagesToGrow > 0) {
|
|
1081
|
+
memory.grow(pagesToGrow);
|
|
1082
|
+
const grownView = new Uint8Array(memory.buffer);
|
|
1083
|
+
grownView.set(snapshotView);
|
|
1084
|
+
workerLog(`Restored memory snapshot (${(snapshotView.byteLength / 1024 / 1024).toFixed(1)}MB) after growing memory`);
|
|
1085
|
+
} else {
|
|
1086
|
+
workerLog('Memory snapshot size mismatch, skipping restore');
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
workerLog('Failed to restore memory snapshot: ' + e.message);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
successCallback(instance);
|
|
1094
|
+
});
|
|
1095
|
+
return {};
|
|
1096
|
+
},
|
|
1097
|
+
print: (text) => {
|
|
1098
|
+
// Suppress noisy font map warnings
|
|
1099
|
+
if (text.includes('ambiguous entry') ||
|
|
1100
|
+
text.includes('duplicates ignored') ||
|
|
1101
|
+
text.includes('will be treated as font file not present') ||
|
|
1102
|
+
text.includes('font file present but not included') ||
|
|
1103
|
+
text.includes('invalid entry for') ||
|
|
1104
|
+
text.includes('SlantFont/ExtendFont')) return;
|
|
1105
|
+
// Only log TeX stdout in verbose mode (saves ~4000 postMessage calls)
|
|
1106
|
+
if (verboseLogging) workerLog('[TeX] ' + text);
|
|
1107
|
+
// Capture stdout for error detection
|
|
1108
|
+
outputCapture.stdout.push(text);
|
|
1109
|
+
},
|
|
1110
|
+
printErr: (text) => {
|
|
1111
|
+
// Suppress font generation attempts (not supported in WASM)
|
|
1112
|
+
if (text.includes('mktexpk') || text.includes('kpathsea: fork')) return;
|
|
1113
|
+
// Always log errors regardless of verbose mode
|
|
1114
|
+
workerLog('[TeX ERR] ' + text);
|
|
1115
|
+
// Capture stderr for error detection
|
|
1116
|
+
outputCapture.stderr.push(text);
|
|
1117
|
+
},
|
|
1118
|
+
locateFile: (path) => path,
|
|
1119
|
+
preRun: [function() {
|
|
1120
|
+
moduleConfig.ENV = moduleConfig.ENV || {};
|
|
1121
|
+
configureTexEnvironment(moduleConfig.ENV);
|
|
1122
|
+
}],
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
const Module = await busytex(moduleConfig);
|
|
1126
|
+
const FS = Module.FS;
|
|
1127
|
+
try { FS.mkdir('/bin'); } catch (e) {}
|
|
1128
|
+
try { FS.writeFile('/bin/busytex', ''); } catch (e) {}
|
|
1129
|
+
|
|
1130
|
+
Module.setPrefix = function(prefix) {
|
|
1131
|
+
Module.thisProgram = '/bin/' + prefix;
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
Module.callMainWithRedirects = function(args = [], print = false) {
|
|
1135
|
+
Module.do_print = print;
|
|
1136
|
+
// Reset output capture before each call
|
|
1137
|
+
outputCapture.stdout.length = 0;
|
|
1138
|
+
outputCapture.stderr.length = 0;
|
|
1139
|
+
if (args.length > 0) Module.setPrefix(args[0]);
|
|
1140
|
+
const exit_code = Module.callMain(args);
|
|
1141
|
+
Module._flush_streams();
|
|
1142
|
+
// Join arrays into strings for return (single O(n) operation)
|
|
1143
|
+
return { exit_code, stdout: outputCapture.stdout.join('\n'), stderr: outputCapture.stderr.join('\n') };
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
const elapsed = (performance.now() - startTime).toFixed(0);
|
|
1147
|
+
workerLog(`WASM ready in ${elapsed}ms`);
|
|
1148
|
+
return Module;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Create a fresh Module instance for each operation.
|
|
1153
|
+
*
|
|
1154
|
+
* We create fresh each time because pdfTeX has internal C globals
|
|
1155
|
+
* (glyph_unicode_tree, etc.) that don't reset between invocations,
|
|
1156
|
+
* causing assertion failures and memory issues.
|
|
1157
|
+
*
|
|
1158
|
+
* With memory snapshot, fresh Module creation is fast (~300ms vs ~3s).
|
|
1159
|
+
*/
|
|
1160
|
+
async function getOrCreateModule() {
|
|
1161
|
+
// NOTE: Memory snapshots are DISABLED
|
|
1162
|
+
// pdfTeX has internal C globals (glyph_unicode_tree) that cause assertion failures
|
|
1163
|
+
// when restored from a post-compilation snapshot. Fast recompiles come from
|
|
1164
|
+
// format caching (.fmt files) instead, which properly handles TeX state.
|
|
1165
|
+
return await initBusyTeX(cachedWasmModule, busytexJsUrl, null);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Reset the filesystem for a fresh compilation
|
|
1170
|
+
* Removes all files except core TeX directories
|
|
1171
|
+
*/
|
|
1172
|
+
function resetFS(FS) {
|
|
1173
|
+
// Remove /texlive entirely and recreate structure
|
|
1174
|
+
try {
|
|
1175
|
+
// Remove dynamically created directories
|
|
1176
|
+
const dirsToClean = ['/texlive', '/document.pdf', '/document.log', '/document.aux'];
|
|
1177
|
+
for (const path of dirsToClean) {
|
|
1178
|
+
try {
|
|
1179
|
+
const stat = FS.stat(path);
|
|
1180
|
+
if (FS.isDir(stat.mode)) {
|
|
1181
|
+
// Recursively remove directory
|
|
1182
|
+
const removeDir = (dirPath) => {
|
|
1183
|
+
try {
|
|
1184
|
+
const contents = FS.readdir(dirPath);
|
|
1185
|
+
for (const name of contents) {
|
|
1186
|
+
if (name === '.' || name === '..') continue;
|
|
1187
|
+
const fullPath = dirPath + '/' + name;
|
|
1188
|
+
const s = FS.stat(fullPath);
|
|
1189
|
+
if (FS.isDir(s.mode)) {
|
|
1190
|
+
removeDir(fullPath);
|
|
1191
|
+
} else {
|
|
1192
|
+
FS.unlink(fullPath);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
FS.rmdir(dirPath);
|
|
1196
|
+
} catch (e) {}
|
|
1197
|
+
};
|
|
1198
|
+
removeDir(path);
|
|
1199
|
+
} else {
|
|
1200
|
+
FS.unlink(path);
|
|
1201
|
+
}
|
|
1202
|
+
} catch (e) {}
|
|
1203
|
+
}
|
|
1204
|
+
} catch (e) {
|
|
1205
|
+
workerLog('FS reset warning: ' + e.message);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// ============ Pass Prediction ============
|
|
1210
|
+
|
|
1211
|
+
// Pre-compiled regexes for pass prediction (avoid recreating on each call)
|
|
1212
|
+
// Features requiring 3 passes (ToC, index)
|
|
1213
|
+
const MULTIPASS_3_REGEX = /\\(?:tableofcontents|listoffigures|listoftables|printindex|makeindex)\b/;
|
|
1214
|
+
// Features requiring 2+ passes (refs, cites, labels, bibliography)
|
|
1215
|
+
const MULTIPASS_2_REGEX = /\\(?:ref\{|pageref\{|eqref\{|autoref\{|cite[pt]?\{|citep\{|citet\{|autocite\{|textcite\{|label\{|bibliography\{|printbibliography|addbibresource)/;
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Predict minimum passes needed based on source analysis.
|
|
1219
|
+
* Returns 1 for simple docs, 2-3 for docs with cross-references.
|
|
1220
|
+
* Uses pre-compiled regexes and early exit for efficiency.
|
|
1221
|
+
*/
|
|
1222
|
+
function predictRequiredPasses(source) {
|
|
1223
|
+
if (!source) return 1;
|
|
1224
|
+
|
|
1225
|
+
// Check for 3-pass features first (ToC, index)
|
|
1226
|
+
if (MULTIPASS_3_REGEX.test(source)) {
|
|
1227
|
+
return 3;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Check for 2-pass features (refs, cites, labels, bib)
|
|
1231
|
+
if (MULTIPASS_2_REGEX.test(source)) {
|
|
1232
|
+
return 2;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// No cross-reference features → single pass sufficient
|
|
1236
|
+
return 1;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// ============ Aux File Hashing ============
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Fast DJB2 hash for aux file comparison.
|
|
1243
|
+
* Faster than string comparison for large files.
|
|
1244
|
+
*/
|
|
1245
|
+
function quickHash(content) {
|
|
1246
|
+
let hash = 5381 >>> 0;
|
|
1247
|
+
const len = content.length;
|
|
1248
|
+
for (let i = 0; i < len; i++) {
|
|
1249
|
+
hash = ((hash * 33) ^ content.charCodeAt(i)) >>> 0;
|
|
1250
|
+
}
|
|
1251
|
+
return hash;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Aux file extensions in fixed order for consistent hashing
|
|
1255
|
+
const AUX_EXTENSIONS = ['.aux', '.bbl', '.blg', '.lof', '.lot', '.nav', '.out', '.snm', '.toc'];
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Hash all aux files into a single combined hash.
|
|
1259
|
+
* Used to detect changes between compilation passes.
|
|
1260
|
+
* Uses fixed extension order to avoid sort() allocation.
|
|
1261
|
+
*/
|
|
1262
|
+
function hashAuxFiles(auxFiles) {
|
|
1263
|
+
if (!auxFiles) return 0;
|
|
1264
|
+
|
|
1265
|
+
let combined = 0;
|
|
1266
|
+
for (const ext of AUX_EXTENSIONS) {
|
|
1267
|
+
const content = auxFiles[ext];
|
|
1268
|
+
if (content) {
|
|
1269
|
+
combined ^= quickHash(content);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return combined;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// ============ Compilation ============
|
|
1276
|
+
|
|
1277
|
+
async function handleCompile(request) {
|
|
1278
|
+
const { id, source, engine, options, bundleData, bundleNames, ctanFiles, cachedFormat, cachedAuxFiles, deferredBundleNames } = request;
|
|
1279
|
+
|
|
1280
|
+
// Allow runtime verbose toggle via compile options
|
|
1281
|
+
if (options?.verbose !== undefined) {
|
|
1282
|
+
verboseLogging = options.verbose;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
workerLog('=== Compilation Started ===');
|
|
1286
|
+
const totalStart = performance.now();
|
|
1287
|
+
|
|
1288
|
+
// Fallback to bundleDeps.deferred if not passed in message (for older compilers)
|
|
1289
|
+
const effectiveDeferredBundles = deferredBundleNames || bundleDeps?.deferred || [];
|
|
1290
|
+
workerLog(`deferredBundleNames: ${JSON.stringify(effectiveDeferredBundles)}`);
|
|
1291
|
+
|
|
1292
|
+
if (!fileManifest) throw new Error('fileManifest not set');
|
|
1293
|
+
|
|
1294
|
+
// Track accumulated resources across retries
|
|
1295
|
+
const bundleDataMap = bundleData instanceof Map ? bundleData : new Map(Object.entries(bundleData || {}));
|
|
1296
|
+
const bundleMetaMap = new Map(); // Store bundle metadata for dynamically loaded bundles
|
|
1297
|
+
const accumulatedCtanFiles = new Map();
|
|
1298
|
+
|
|
1299
|
+
// Bundles to load on-demand (e.g., font bundles like cm-super)
|
|
1300
|
+
const deferredBundles = new Set(effectiveDeferredBundles);
|
|
1301
|
+
|
|
1302
|
+
// Add CTAN files from current request
|
|
1303
|
+
if (ctanFiles) {
|
|
1304
|
+
const ctanFilesMap = ctanFiles instanceof Map ? ctanFiles : new Map(Object.entries(ctanFiles));
|
|
1305
|
+
for (const [path, content] of ctanFilesMap) accumulatedCtanFiles.set(path, content);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
let pdfData = null;
|
|
1309
|
+
let syncTexData = null; // SyncTeX data for source/PDF synchronization
|
|
1310
|
+
let compileSuccess = false;
|
|
1311
|
+
let retryCount = 0;
|
|
1312
|
+
const maxRetries = options.maxRetries ?? 15; // Configurable, default 15
|
|
1313
|
+
const fetchedPackages = new Set();
|
|
1314
|
+
// Use global cache for Range-fetched files (persists across compiles)
|
|
1315
|
+
let lastExitCode = -1;
|
|
1316
|
+
let Module = null;
|
|
1317
|
+
let FS = null;
|
|
1318
|
+
|
|
1319
|
+
// Auto-rerun tracking for cross-references/TOC
|
|
1320
|
+
// Predict passes needed based on source analysis
|
|
1321
|
+
const predictedPasses = predictRequiredPasses(source);
|
|
1322
|
+
let rerunPass = 0;
|
|
1323
|
+
const maxRerunPasses = predictedPasses - 1; // 1 initial + N reruns
|
|
1324
|
+
|
|
1325
|
+
if (predictedPasses === 1) {
|
|
1326
|
+
workerLog('Single-pass mode: no cross-references detected');
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Auto-shim tracking for undefined control sequences
|
|
1330
|
+
// Map<commandName, argCount>
|
|
1331
|
+
const shimmedCommands = new Map();
|
|
1332
|
+
|
|
1333
|
+
// Version fallback tracking for packages with kernel incompatibility
|
|
1334
|
+
// Map<packageName, tlYear> - which TL year to use for each package
|
|
1335
|
+
const packageTLVersions = new Map();
|
|
1336
|
+
|
|
1337
|
+
while (!compileSuccess && retryCount < maxRetries) {
|
|
1338
|
+
if (retryCount > 0) {
|
|
1339
|
+
workerLog(`Retry #${retryCount}...`);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
try {
|
|
1343
|
+
// Get or create global WASM instance (reused to avoid memory leaks)
|
|
1344
|
+
Module = await getOrCreateModule();
|
|
1345
|
+
FS = Module.FS;
|
|
1346
|
+
|
|
1347
|
+
// Reset filesystem for clean compilation
|
|
1348
|
+
resetFS(FS);
|
|
1349
|
+
|
|
1350
|
+
// Create VFS with unified mount handling
|
|
1351
|
+
const vfs = new VirtualFileSystem(FS, {
|
|
1352
|
+
onLog: workerLog,
|
|
1353
|
+
lazyEnabled: options.enableLazyFS,
|
|
1354
|
+
fetchedFilesCache: globalFetchedFilesCache // Persist across compiles
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
// Only patch for lazy loading once (on first use)
|
|
1358
|
+
if (options.enableLazyFS && !Module._lazyPatchApplied) {
|
|
1359
|
+
vfs.patchForLazyLoading();
|
|
1360
|
+
Module._lazyPatchApplied = true;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Mount all bundles (regular and deferred)
|
|
1364
|
+
workerProgress('mount', 'Mounting files...');
|
|
1365
|
+
for (const [bundleName, data] of bundleDataMap) {
|
|
1366
|
+
const meta = bundleMetaMap.get(bundleName) || null;
|
|
1367
|
+
vfs.mountBundle(bundleName, data, fileManifest, meta);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Mount deferred bundles (file markers without data - loaded on demand)
|
|
1371
|
+
for (const bundleName of deferredBundles) {
|
|
1372
|
+
if (!bundleDataMap.has(bundleName)) {
|
|
1373
|
+
const count = vfs.mountDeferredBundle(bundleName, fileManifest, null);
|
|
1374
|
+
workerLog(`Deferred bundle ${bundleName}: mounted ${count} file markers`);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Mount CTAN files
|
|
1379
|
+
// Use forceOverride when we have version fallback packages to override bundle files
|
|
1380
|
+
if (accumulatedCtanFiles.size > 0) {
|
|
1381
|
+
const hasVersionFallback = packageTLVersions.size > 0;
|
|
1382
|
+
vfs.mountCtanFiles(accumulatedCtanFiles, { forceOverride: hasVersionFallback });
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Restore aux files
|
|
1386
|
+
if (cachedAuxFiles && Object.keys(cachedAuxFiles).length > 0) {
|
|
1387
|
+
const restored = restoreAuxFiles(FS, cachedAuxFiles);
|
|
1388
|
+
if (restored > 0) workerLog(`Restored ${restored} aux files`);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Finalize VFS - processes font maps, generates ls-R
|
|
1392
|
+
vfs.finalize();
|
|
1393
|
+
|
|
1394
|
+
// Prepare document source
|
|
1395
|
+
let docSource = source;
|
|
1396
|
+
let fmtPath = engine === 'pdflatex'
|
|
1397
|
+
? '/texlive/texmf-dist/texmf-var/web2c/pdftex/pdflatex.fmt'
|
|
1398
|
+
: '/texlive/texmf-dist/texmf-var/web2c/xetex/xelatex.fmt';
|
|
1399
|
+
|
|
1400
|
+
if (cachedFormat && engine === 'pdflatex' && cachedFormat.fmtData) {
|
|
1401
|
+
// Verify buffer isn't detached before using
|
|
1402
|
+
if (cachedFormat.fmtData.buffer && cachedFormat.fmtData.buffer.byteLength > 0) {
|
|
1403
|
+
FS.writeFile('/custom.fmt', cachedFormat.fmtData);
|
|
1404
|
+
fmtPath = '/custom.fmt';
|
|
1405
|
+
workerLog('Using custom format');
|
|
1406
|
+
const beginDocIdx = source.indexOf('\\begin{document}');
|
|
1407
|
+
if (beginDocIdx !== -1) docSource = source.substring(beginDocIdx);
|
|
1408
|
+
} else {
|
|
1409
|
+
workerLog('Custom format buffer is detached, using default format');
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
if (engine === 'pdflatex' && !cachedFormat) {
|
|
1414
|
+
docSource = injectMicrotypeWorkaround(docSource);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Inject kernel compatibility shim for packages using TL2026+ tagging features
|
|
1418
|
+
docSource = injectKernelCompatShim(docSource);
|
|
1419
|
+
|
|
1420
|
+
// Inject auto-shims for undefined commands from previous attempts
|
|
1421
|
+
if (shimmedCommands.size > 0) {
|
|
1422
|
+
docSource = injectAutoShims(docSource, shimmedCommands);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Font maps are now handled by VFS.processFontMaps() - no need to inject \pdfmapfile commands
|
|
1426
|
+
|
|
1427
|
+
FS.writeFile('/document.tex', docSource);
|
|
1428
|
+
|
|
1429
|
+
// Run compilation
|
|
1430
|
+
workerProgress('compile', `Running ${engine}...`);
|
|
1431
|
+
let result;
|
|
1432
|
+
|
|
1433
|
+
if (engine === 'pdflatex') {
|
|
1434
|
+
result = Module.callMainWithRedirects([
|
|
1435
|
+
'pdflatex', '--no-shell-escape', '--interaction=nonstopmode',
|
|
1436
|
+
'--halt-on-error', '--synctex=-1', '--fmt=' + fmtPath, '/document.tex'
|
|
1437
|
+
]);
|
|
1438
|
+
} else {
|
|
1439
|
+
result = Module.callMainWithRedirects([
|
|
1440
|
+
'xelatex', '--no-shell-escape', '--interaction=nonstopmode',
|
|
1441
|
+
'--halt-on-error', '--synctex=-1', '--no-pdf',
|
|
1442
|
+
'--fmt=/texlive/texmf-dist/texmf-var/web2c/xetex/xelatex.fmt',
|
|
1443
|
+
'/document.tex'
|
|
1444
|
+
]);
|
|
1445
|
+
if (result.exit_code === 0) {
|
|
1446
|
+
result = Module.callMainWithRedirects([
|
|
1447
|
+
'xdvipdfmx', '-o', '/document.pdf', '/document.xdv'
|
|
1448
|
+
]);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
lastExitCode = result.exit_code;
|
|
1453
|
+
|
|
1454
|
+
if (result.exit_code === 0) {
|
|
1455
|
+
try {
|
|
1456
|
+
pdfData = FS.readFile('/document.pdf');
|
|
1457
|
+
compileSuccess = true;
|
|
1458
|
+
workerLog('Compilation successful!');
|
|
1459
|
+
|
|
1460
|
+
// Read SyncTeX data for source/PDF synchronization
|
|
1461
|
+
// --synctex=-1 generates uncompressed .synctex file
|
|
1462
|
+
try {
|
|
1463
|
+
const syncTexBytes = FS.readFile('/document.synctex');
|
|
1464
|
+
syncTexData = new TextDecoder().decode(syncTexBytes);
|
|
1465
|
+
workerLog(`SyncTeX data: ${(syncTexBytes.byteLength / 1024).toFixed(1)}KB`);
|
|
1466
|
+
} catch (e) {
|
|
1467
|
+
workerLog('No SyncTeX file generated');
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Auto-rerun loop for cross-references/TOC
|
|
1471
|
+
// LaTeX needs multiple passes to resolve forward references
|
|
1472
|
+
// Regex for rerun detection (single pass through log, case-insensitive)
|
|
1473
|
+
const rerunPattern = /Rerun to get|Label\(s\) may have changed|There were undefined references|Rerun LaTeX/i;
|
|
1474
|
+
|
|
1475
|
+
// Track aux files via hash for faster comparison
|
|
1476
|
+
let prevAuxHash = cachedAuxFiles ? hashAuxFiles(cachedAuxFiles) : 0;
|
|
1477
|
+
let prevAuxFiles = cachedAuxFiles || {};
|
|
1478
|
+
|
|
1479
|
+
while (rerunPass < maxRerunPasses) {
|
|
1480
|
+
// Check if log suggests rerun is needed
|
|
1481
|
+
let logSaysRerun = false;
|
|
1482
|
+
try {
|
|
1483
|
+
const logContent = FS.readFile('/document.log', { encoding: 'utf8' });
|
|
1484
|
+
logSaysRerun = rerunPattern.test(logContent);
|
|
1485
|
+
} catch (e) {}
|
|
1486
|
+
|
|
1487
|
+
if (!logSaysRerun) break;
|
|
1488
|
+
|
|
1489
|
+
// Collect current aux files and compare hash with previous pass
|
|
1490
|
+
// If hash is identical, the "Rerun" warning is a false positive
|
|
1491
|
+
const currentAuxFiles = collectAuxFiles(FS);
|
|
1492
|
+
const currentAuxHash = hashAuxFiles(currentAuxFiles);
|
|
1493
|
+
|
|
1494
|
+
if (currentAuxHash === prevAuxHash) {
|
|
1495
|
+
workerLog('Aux hash unchanged, skipping unnecessary rerun');
|
|
1496
|
+
break;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
prevAuxHash = currentAuxHash;
|
|
1500
|
+
|
|
1501
|
+
prevAuxFiles = currentAuxFiles;
|
|
1502
|
+
|
|
1503
|
+
rerunPass++;
|
|
1504
|
+
workerLog(`Auto-rerun pass ${rerunPass}/${maxRerunPasses}: resolving cross-references...`);
|
|
1505
|
+
workerProgress('compile', `Rerun ${rerunPass}/${maxRerunPasses}...`);
|
|
1506
|
+
|
|
1507
|
+
// IMPORTANT: pdfTeX has internal C globals (glyph_unicode_tree, etc.) that
|
|
1508
|
+
// don't reset between invocations, causing assertion failures. We MUST
|
|
1509
|
+
// create a fresh WASM module for each rerun pass.
|
|
1510
|
+
|
|
1511
|
+
// Use aux files we already collected for comparison
|
|
1512
|
+
const rerunAuxFiles = prevAuxFiles;
|
|
1513
|
+
|
|
1514
|
+
// Create fresh WASM module
|
|
1515
|
+
Module = await getOrCreateModule();
|
|
1516
|
+
FS = Module.FS;
|
|
1517
|
+
resetFS(FS);
|
|
1518
|
+
|
|
1519
|
+
// Recreate VFS with same configuration
|
|
1520
|
+
const rerunVfs = new VirtualFileSystem(FS, {
|
|
1521
|
+
onLog: workerLog,
|
|
1522
|
+
lazyEnabled: options.enableLazyFS,
|
|
1523
|
+
fetchedFilesCache: globalFetchedFilesCache
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
if (options.enableLazyFS && !Module._lazyPatchApplied) {
|
|
1527
|
+
rerunVfs.patchForLazyLoading();
|
|
1528
|
+
Module._lazyPatchApplied = true;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Remount all bundles
|
|
1532
|
+
for (const [bundleName, data] of bundleDataMap) {
|
|
1533
|
+
const meta = bundleMetaMap.get(bundleName) || null;
|
|
1534
|
+
rerunVfs.mountBundle(bundleName, data, fileManifest, meta);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// Remount deferred bundles
|
|
1538
|
+
for (const bundleName of deferredBundles) {
|
|
1539
|
+
if (!bundleDataMap.has(bundleName)) {
|
|
1540
|
+
rerunVfs.mountDeferredBundle(bundleName, fileManifest, null);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// Remount CTAN files (with override for version fallback)
|
|
1545
|
+
if (accumulatedCtanFiles.size > 0) {
|
|
1546
|
+
const hasVersionFallback = packageTLVersions.size > 0;
|
|
1547
|
+
rerunVfs.mountCtanFiles(accumulatedCtanFiles, { forceOverride: hasVersionFallback });
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Restore aux files from previous pass (critical for TOC/refs)
|
|
1551
|
+
restoreAuxFiles(FS, rerunAuxFiles);
|
|
1552
|
+
|
|
1553
|
+
// Finalize VFS
|
|
1554
|
+
rerunVfs.finalize();
|
|
1555
|
+
|
|
1556
|
+
// Rewrite document source
|
|
1557
|
+
FS.writeFile('/document.tex', docSource);
|
|
1558
|
+
|
|
1559
|
+
// Mount custom format if used
|
|
1560
|
+
if (fmtPath === '/custom.fmt' && cachedFormat?.fmtData?.buffer?.byteLength > 0) {
|
|
1561
|
+
FS.writeFile('/custom.fmt', cachedFormat.fmtData);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// Run compilation (use same format path as initial compilation)
|
|
1565
|
+
let rerunResult;
|
|
1566
|
+
if (engine === 'pdflatex') {
|
|
1567
|
+
rerunResult = Module.callMainWithRedirects([
|
|
1568
|
+
'pdflatex', '--no-shell-escape', '--interaction=nonstopmode',
|
|
1569
|
+
'--halt-on-error', '--synctex=-1', '--fmt=' + fmtPath, '/document.tex'
|
|
1570
|
+
]);
|
|
1571
|
+
} else {
|
|
1572
|
+
// XeLaTeX: two-step process (xelatex -> xdvipdfmx)
|
|
1573
|
+
rerunResult = Module.callMainWithRedirects([
|
|
1574
|
+
'xelatex', '--no-shell-escape', '--interaction=nonstopmode',
|
|
1575
|
+
'--halt-on-error', '--synctex=-1', '--no-pdf',
|
|
1576
|
+
'--fmt=' + fmtPath, '/document.tex'
|
|
1577
|
+
]);
|
|
1578
|
+
if (rerunResult.exit_code === 0) {
|
|
1579
|
+
rerunResult = Module.callMainWithRedirects([
|
|
1580
|
+
'xdvipdfmx', '-o', '/document.pdf', '/document.xdv'
|
|
1581
|
+
]);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (rerunResult.exit_code === 0) {
|
|
1586
|
+
pdfData = FS.readFile('/document.pdf');
|
|
1587
|
+
workerLog(`Rerun ${rerunPass} successful`);
|
|
1588
|
+
// Update SyncTeX data
|
|
1589
|
+
try {
|
|
1590
|
+
const syncTexBytes = FS.readFile('/document.synctex');
|
|
1591
|
+
syncTexData = new TextDecoder().decode(syncTexBytes);
|
|
1592
|
+
} catch (e) {}
|
|
1593
|
+
} else {
|
|
1594
|
+
workerLog(`Rerun ${rerunPass} failed, keeping previous PDF`);
|
|
1595
|
+
break;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Log summary if reruns occurred
|
|
1600
|
+
if (rerunPass > 0) {
|
|
1601
|
+
workerLog(`Completed ${rerunPass + 1} passes (1 initial + ${rerunPass} reruns)`);
|
|
1602
|
+
}
|
|
1603
|
+
} catch (e) {
|
|
1604
|
+
workerLog('Failed to read PDF: ' + e.message);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// Handle missing files and deferred bundles
|
|
1609
|
+
if (!compileSuccess) {
|
|
1610
|
+
// Check for individual file Range requests from deferred bundles
|
|
1611
|
+
const pendingFiles = vfs.getPendingDeferredFiles();
|
|
1612
|
+
if (pendingFiles.length > 0) {
|
|
1613
|
+
// Group pending files by bundle
|
|
1614
|
+
const filesByBundle = new Map();
|
|
1615
|
+
for (const f of pendingFiles) {
|
|
1616
|
+
if (!filesByBundle.has(f.bundleName)) filesByBundle.set(f.bundleName, []);
|
|
1617
|
+
filesByBundle.get(f.bundleName).push(f);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
let fetchedAny = false;
|
|
1621
|
+
|
|
1622
|
+
// For each bundle with pending files, decide: full bundle fetch or Range requests
|
|
1623
|
+
for (const [bundleName, files] of filesByBundle) {
|
|
1624
|
+
// If bundle is deferred (not yet loaded), consider loading the whole thing
|
|
1625
|
+
// This is more efficient when many files are needed from the same bundle
|
|
1626
|
+
if (deferredBundles.has(bundleName) && !bundleDataMap.has(bundleName)) {
|
|
1627
|
+
workerLog(`Deferred ${bundleName}: ${files.length} files requested - loading full bundle`);
|
|
1628
|
+
try {
|
|
1629
|
+
const bundleResult = await requestBundleFetch(bundleName);
|
|
1630
|
+
if (bundleResult.success) {
|
|
1631
|
+
bundleDataMap.set(bundleName, bundleResult.bundleData);
|
|
1632
|
+
if (bundleResult.bundleMeta) {
|
|
1633
|
+
bundleMetaMap.set(bundleName, bundleResult.bundleMeta);
|
|
1634
|
+
}
|
|
1635
|
+
deferredBundles.delete(bundleName);
|
|
1636
|
+
workerLog(`Loaded full ${bundleName} bundle (${(bundleResult.bundleData.byteLength / 1024 / 1024).toFixed(1)}MB)`);
|
|
1637
|
+
fetchedAny = true;
|
|
1638
|
+
continue; // Skip Range requests for this bundle
|
|
1639
|
+
}
|
|
1640
|
+
} catch (e) {
|
|
1641
|
+
workerLog(`Failed to load ${bundleName} bundle: ${e.message}, trying Range requests`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// Use parallel Range requests for files from already-loaded bundles or if full fetch failed
|
|
1646
|
+
workerLog(`Fetching ${files.length} files from ${bundleName} via parallel Range requests...`);
|
|
1647
|
+
const rangePromises = files.map(async (fileReq) => {
|
|
1648
|
+
try {
|
|
1649
|
+
const fileResult = await requestFileRangeFetch(fileReq.bundleName, fileReq.start, fileReq.end);
|
|
1650
|
+
if (fileResult.success) {
|
|
1651
|
+
vfs.storeFetchedFile(fileReq.bundleName, fileReq.start, fileReq.end, fileResult.data);
|
|
1652
|
+
return true;
|
|
1653
|
+
}
|
|
1654
|
+
} catch (e) {
|
|
1655
|
+
workerLog(`Failed to fetch file range [${fileReq.start}:${fileReq.end}]: ${e.message}`);
|
|
1656
|
+
}
|
|
1657
|
+
return false;
|
|
1658
|
+
});
|
|
1659
|
+
const results = await Promise.all(rangePromises);
|
|
1660
|
+
const successCount = results.filter(Boolean).length;
|
|
1661
|
+
if (successCount > 0) {
|
|
1662
|
+
workerLog(`Loaded ${successCount}/${files.length} files from ${bundleName}`);
|
|
1663
|
+
fetchedAny = true;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (fetchedAny) {
|
|
1668
|
+
retryCount++;
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Fallback: check if any deferred bundles were accessed but not loaded
|
|
1674
|
+
const pendingDeferred = vfs.getPendingDeferredBundles();
|
|
1675
|
+
workerLog(`Checking pending deferred bundles: ${pendingDeferred.length > 0 ? pendingDeferred.join(', ') : 'none'}`);
|
|
1676
|
+
if (pendingDeferred.length > 0) {
|
|
1677
|
+
workerLog(`Deferred bundles needed: ${pendingDeferred.join(', ')}`);
|
|
1678
|
+
let fetchedAny = false;
|
|
1679
|
+
for (const bundleName of pendingDeferred) {
|
|
1680
|
+
if (bundleDataMap.has(bundleName)) continue;
|
|
1681
|
+
try {
|
|
1682
|
+
const bundleResult = await requestBundleFetch(bundleName);
|
|
1683
|
+
if (bundleResult.success) {
|
|
1684
|
+
bundleDataMap.set(bundleName, bundleResult.bundleData);
|
|
1685
|
+
if (bundleResult.bundleMeta) {
|
|
1686
|
+
bundleMetaMap.set(bundleName, bundleResult.bundleMeta);
|
|
1687
|
+
}
|
|
1688
|
+
// Remove from deferred set since it's now loaded
|
|
1689
|
+
deferredBundles.delete(bundleName);
|
|
1690
|
+
fetchedAny = true;
|
|
1691
|
+
workerLog(`Loaded deferred bundle: ${bundleName}`);
|
|
1692
|
+
}
|
|
1693
|
+
} catch (e) {
|
|
1694
|
+
workerLog(`Failed to load deferred bundle ${bundleName}: ${e.message}`);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
if (fetchedAny) {
|
|
1698
|
+
retryCount++;
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// Then check for missing files via log parsing (CTAN fallback)
|
|
1704
|
+
workerLog(`[RETRY] enableCtan=${options.enableCtan}`);
|
|
1705
|
+
if (options.enableCtan) {
|
|
1706
|
+
let logContent = '';
|
|
1707
|
+
try { logContent = new TextDecoder().decode(FS.readFile('/document.log')); } catch (e) {}
|
|
1708
|
+
const allOutput = logContent + ' ' + (result.stdout || '') + ' ' + (result.stderr || '');
|
|
1709
|
+
|
|
1710
|
+
// Extract ALL missing files for parallel fetching
|
|
1711
|
+
const missingFiles = extractAllMissingFiles(allOutput, fetchedPackages);
|
|
1712
|
+
workerLog(`[RETRY] missingFiles=${missingFiles.length > 0 ? missingFiles.join(', ') : 'none'}`);
|
|
1713
|
+
|
|
1714
|
+
if (missingFiles.length > 0) {
|
|
1715
|
+
// Categorize packages: bundles vs CTAN
|
|
1716
|
+
const bundlesToFetch = [];
|
|
1717
|
+
const ctanToFetch = [];
|
|
1718
|
+
|
|
1719
|
+
for (const missingFile of missingFiles) {
|
|
1720
|
+
const pkgName = getPackageFromFile(missingFile);
|
|
1721
|
+
|
|
1722
|
+
// Check if pkgName is already a bundle name (from font index lookup)
|
|
1723
|
+
// or if it maps to a bundle via packageMap
|
|
1724
|
+
let bundleName = bundleRegistry?.has(pkgName) ? pkgName : packageMap?.[pkgName];
|
|
1725
|
+
|
|
1726
|
+
if (bundleName && !bundleDataMap.has(bundleName)) {
|
|
1727
|
+
bundlesToFetch.push({ missingFile, pkgName, bundleName });
|
|
1728
|
+
} else if (!bundleName) {
|
|
1729
|
+
ctanToFetch.push({ missingFile, pkgName });
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
workerLog(`[RETRY] Fetching ${bundlesToFetch.length} bundles, ${ctanToFetch.length} CTAN packages in parallel`);
|
|
1734
|
+
|
|
1735
|
+
// Fetch all bundles in parallel
|
|
1736
|
+
const bundlePromises = bundlesToFetch.map(async ({ missingFile, pkgName, bundleName }) => {
|
|
1737
|
+
workerLog(`Missing: ${missingFile}, loading bundle ${bundleName}...`);
|
|
1738
|
+
try {
|
|
1739
|
+
const bundleResult = await requestBundleFetch(bundleName);
|
|
1740
|
+
if (bundleResult.success) {
|
|
1741
|
+
return { type: 'bundle', pkgName, bundleName, data: bundleResult };
|
|
1742
|
+
}
|
|
1743
|
+
} catch (e) {
|
|
1744
|
+
workerLog(`Bundle fetch failed for ${bundleName}: ${e.message}`);
|
|
1745
|
+
}
|
|
1746
|
+
return null;
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
// Fetch all CTAN packages in parallel
|
|
1750
|
+
// Use version preference if set (for kernel incompatibility fallback)
|
|
1751
|
+
const ctanPromises = ctanToFetch.map(async ({ missingFile, pkgName }) => {
|
|
1752
|
+
const tlYear = packageTLVersions.get(pkgName) || null;
|
|
1753
|
+
const yearLabel = tlYear ? ` (TL${tlYear})` : '';
|
|
1754
|
+
workerLog(`Missing: ${missingFile}, fetching ${pkgName}${yearLabel} from CTAN...`);
|
|
1755
|
+
try {
|
|
1756
|
+
const ctanData = await requestCtanFetch(pkgName, missingFile, tlYear);
|
|
1757
|
+
if (ctanData.success) {
|
|
1758
|
+
return { type: 'ctan', pkgName, data: ctanData };
|
|
1759
|
+
}
|
|
1760
|
+
} catch (e) {
|
|
1761
|
+
workerLog(`CTAN fetch failed for ${pkgName}: ${e.message}`);
|
|
1762
|
+
}
|
|
1763
|
+
return null;
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
// Wait for all fetches to complete
|
|
1767
|
+
const allResults = await Promise.all([...bundlePromises, ...ctanPromises]);
|
|
1768
|
+
let fetchedAny = false;
|
|
1769
|
+
|
|
1770
|
+
for (const result of allResults) {
|
|
1771
|
+
if (!result) continue;
|
|
1772
|
+
fetchedAny = true;
|
|
1773
|
+
|
|
1774
|
+
if (result.type === 'bundle') {
|
|
1775
|
+
fetchedPackages.add(result.pkgName);
|
|
1776
|
+
bundleDataMap.set(result.bundleName, result.data.bundleData);
|
|
1777
|
+
if (result.data.bundleMeta) {
|
|
1778
|
+
bundleMetaMap.set(result.bundleName, result.data.bundleMeta);
|
|
1779
|
+
}
|
|
1780
|
+
} else if (result.type === 'ctan') {
|
|
1781
|
+
fetchedPackages.add(result.pkgName);
|
|
1782
|
+
const files = result.data.files instanceof Map
|
|
1783
|
+
? result.data.files
|
|
1784
|
+
: new Map(Object.entries(result.data.files));
|
|
1785
|
+
for (const [path, content] of files) {
|
|
1786
|
+
accumulatedCtanFiles.set(path, content);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
if (fetchedAny) {
|
|
1792
|
+
retryCount++;
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// Check for undefined control sequences
|
|
1798
|
+
const undefinedCmds = extractUndefinedCommands(allOutput);
|
|
1799
|
+
|
|
1800
|
+
// FIRST: Check for kernel incompatibility - try older package versions
|
|
1801
|
+
// Version fallback works for ALL packages, including bundle packages
|
|
1802
|
+
// (CTAN fetch with older version will override the bundle version)
|
|
1803
|
+
if (undefinedCmds.size > 0) {
|
|
1804
|
+
const incompatiblePkgs = detectKernelIncompatibility(allOutput, undefinedCmds);
|
|
1805
|
+
|
|
1806
|
+
if (incompatiblePkgs.size > 0) {
|
|
1807
|
+
let needsVersionFallback = false;
|
|
1808
|
+
|
|
1809
|
+
for (const pkgName of incompatiblePkgs) {
|
|
1810
|
+
// Get current TL year for this package (default to 2025)
|
|
1811
|
+
const currentYear = packageTLVersions.get(pkgName) || DEFAULT_TL_YEAR;
|
|
1812
|
+
const yearIndex = SUPPORTED_TL_YEARS.indexOf(currentYear);
|
|
1813
|
+
|
|
1814
|
+
// Try next older year if available
|
|
1815
|
+
if (yearIndex < SUPPORTED_TL_YEARS.length - 1) {
|
|
1816
|
+
const olderYear = SUPPORTED_TL_YEARS[yearIndex + 1];
|
|
1817
|
+
packageTLVersions.set(pkgName, olderYear);
|
|
1818
|
+
|
|
1819
|
+
// Note if this is a bundle package - CTAN fetch will override it
|
|
1820
|
+
const bundleName = packageMap?.[pkgName];
|
|
1821
|
+
if (bundleName) {
|
|
1822
|
+
workerLog(`[VERSION FALLBACK] ${pkgName}: in bundle "${bundleName}", fetching TL${olderYear} from CTAN to override`);
|
|
1823
|
+
} else {
|
|
1824
|
+
workerLog(`[VERSION FALLBACK] ${pkgName}: trying TL${olderYear} instead of TL${currentYear}`);
|
|
1825
|
+
}
|
|
1826
|
+
needsVersionFallback = true;
|
|
1827
|
+
|
|
1828
|
+
// Remove the package from fetched so it gets re-fetched with older version
|
|
1829
|
+
fetchedPackages.delete(pkgName);
|
|
1830
|
+
|
|
1831
|
+
// Remove any files from this package from accumulatedCtanFiles
|
|
1832
|
+
for (const [path, _] of accumulatedCtanFiles) {
|
|
1833
|
+
if (path.includes(`/${pkgName}/`) || path.includes(`/${pkgName}.`)) {
|
|
1834
|
+
accumulatedCtanFiles.delete(path);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
} else {
|
|
1838
|
+
workerLog(`[VERSION FALLBACK] ${pkgName}: exhausted all TL versions (2025→2024→2023), will auto-shim`);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
if (needsVersionFallback) {
|
|
1843
|
+
// Actively fetch older versions for packages that need them
|
|
1844
|
+
// This is needed because bundle packages already have files mounted
|
|
1845
|
+
const versionFetchPromises = [];
|
|
1846
|
+
for (const pkgName of incompatiblePkgs) {
|
|
1847
|
+
const tlYear = packageTLVersions.get(pkgName);
|
|
1848
|
+
if (tlYear && tlYear !== DEFAULT_TL_YEAR) {
|
|
1849
|
+
workerLog(`[VERSION FALLBACK] Fetching ${pkgName} from TL${tlYear}...`);
|
|
1850
|
+
versionFetchPromises.push(
|
|
1851
|
+
requestCtanFetch(pkgName, `${pkgName}.sty`, tlYear)
|
|
1852
|
+
.then(ctanData => {
|
|
1853
|
+
if (ctanData.success) {
|
|
1854
|
+
fetchedPackages.add(pkgName);
|
|
1855
|
+
const files = ctanData.files instanceof Map
|
|
1856
|
+
? ctanData.files
|
|
1857
|
+
: new Map(Object.entries(ctanData.files));
|
|
1858
|
+
for (const [path, content] of files) {
|
|
1859
|
+
accumulatedCtanFiles.set(path, content);
|
|
1860
|
+
}
|
|
1861
|
+
workerLog(`[VERSION FALLBACK] Got ${files.size} files for ${pkgName} from TL${tlYear}`);
|
|
1862
|
+
return true;
|
|
1863
|
+
}
|
|
1864
|
+
return false;
|
|
1865
|
+
})
|
|
1866
|
+
.catch(e => {
|
|
1867
|
+
workerLog(`[VERSION FALLBACK] Failed to fetch ${pkgName} from TL${tlYear}: ${e.message}`);
|
|
1868
|
+
return false;
|
|
1869
|
+
})
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (versionFetchPromises.length > 0) {
|
|
1875
|
+
await Promise.all(versionFetchPromises);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
retryCount++;
|
|
1879
|
+
continue;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// SECOND: Auto-shim any remaining undefined commands
|
|
1885
|
+
let foundNew = false;
|
|
1886
|
+
for (const [cmd, argCount] of undefinedCmds) {
|
|
1887
|
+
// Add if new, or update if we found more args than before
|
|
1888
|
+
if (!shimmedCommands.has(cmd) || shimmedCommands.get(cmd) < argCount) {
|
|
1889
|
+
shimmedCommands.set(cmd, argCount);
|
|
1890
|
+
foundNew = true;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
if (foundNew) {
|
|
1894
|
+
const cmdList = [...undefinedCmds.entries()].map(([c, n]) => `${c}[${n}]`);
|
|
1895
|
+
workerLog(`[RETRY] Found undefined commands: ${cmdList.join(', ')}`);
|
|
1896
|
+
retryCount++;
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// No more retries possible
|
|
1903
|
+
if (!compileSuccess) break;
|
|
1904
|
+
|
|
1905
|
+
} catch (e) {
|
|
1906
|
+
workerLog(`Error: ${e.message}`);
|
|
1907
|
+
break;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
const auxFiles = compileSuccess ? collectAuxFiles(FS) : null;
|
|
1912
|
+
const totalTime = performance.now() - totalStart;
|
|
1913
|
+
workerLog(`Total time: ${totalTime.toFixed(0)}ms`);
|
|
1914
|
+
|
|
1915
|
+
// NOTE: Memory snapshot capture is DISABLED
|
|
1916
|
+
// pdfTeX's internal C globals (glyph_unicode_tree) cause assertion failures when
|
|
1917
|
+
// we try to restore a post-compilation snapshot. Fast recompiles come from format
|
|
1918
|
+
// caching (.fmt files with pre-compiled preambles) instead.
|
|
1919
|
+
|
|
1920
|
+
// Help GC by clearing references we no longer need
|
|
1921
|
+
// The Module/FS will be recreated on next compile anyway
|
|
1922
|
+
Module = null;
|
|
1923
|
+
FS = null;
|
|
1924
|
+
|
|
1925
|
+
// Build response message once, share between paths
|
|
1926
|
+
const stats = { compileTimeMs: totalTime, bundlesUsed: [...bundleDataMap.keys()] };
|
|
1927
|
+
|
|
1928
|
+
// Use SharedArrayBuffer for zero-copy PDF transfer when available
|
|
1929
|
+
// SharedArrayBuffer: allows main thread to access PDF data directly without serialization
|
|
1930
|
+
// ArrayBuffer transfer: efficient but transfers ownership (receiver gets the buffer)
|
|
1931
|
+
if (pdfData && sharedArrayBufferAvailable) {
|
|
1932
|
+
const sharedBuffer = new SharedArrayBuffer(pdfData.byteLength);
|
|
1933
|
+
new Uint8Array(sharedBuffer).set(pdfData);
|
|
1934
|
+
|
|
1935
|
+
self.postMessage({
|
|
1936
|
+
type: 'compile-response',
|
|
1937
|
+
id,
|
|
1938
|
+
success: compileSuccess,
|
|
1939
|
+
pdfData: sharedBuffer,
|
|
1940
|
+
pdfDataIsShared: true,
|
|
1941
|
+
syncTexData,
|
|
1942
|
+
exitCode: lastExitCode,
|
|
1943
|
+
auxFilesToCache: auxFiles,
|
|
1944
|
+
stats
|
|
1945
|
+
});
|
|
1946
|
+
} else {
|
|
1947
|
+
// Fallback to transferable ArrayBuffer
|
|
1948
|
+
self.postMessage({
|
|
1949
|
+
type: 'compile-response',
|
|
1950
|
+
id,
|
|
1951
|
+
success: compileSuccess,
|
|
1952
|
+
pdfData: pdfData ? pdfData.buffer : null,
|
|
1953
|
+
pdfDataIsShared: false,
|
|
1954
|
+
syncTexData,
|
|
1955
|
+
exitCode: lastExitCode,
|
|
1956
|
+
auxFilesToCache: auxFiles,
|
|
1957
|
+
stats
|
|
1958
|
+
}, pdfData ? [pdfData.buffer] : []);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// ============ Format Generation ============
|
|
1963
|
+
|
|
1964
|
+
async function handleFormatGenerate(request) {
|
|
1965
|
+
const { id, preambleContent, engine, manifest, packageMapData, bundleDepsData, bundleRegistryData, bundleData, ctanFiles, maxRetries: maxRetriesOption } = request;
|
|
1966
|
+
|
|
1967
|
+
workerLog('=== Format Generation Started ===');
|
|
1968
|
+
const startTime = performance.now();
|
|
1969
|
+
|
|
1970
|
+
fileManifest = manifest;
|
|
1971
|
+
packageMap = packageMapData;
|
|
1972
|
+
bundleDeps = bundleDepsData;
|
|
1973
|
+
bundleRegistry = new Set(bundleRegistryData);
|
|
1974
|
+
|
|
1975
|
+
const bundleDataMap = bundleData instanceof Map ? bundleData : new Map(Object.entries(bundleData));
|
|
1976
|
+
const bundleMetaMap = new Map(); // Store bundle metadata for dynamically loaded bundles
|
|
1977
|
+
const accumulatedCtanFiles = new Map();
|
|
1978
|
+
|
|
1979
|
+
if (ctanFiles) {
|
|
1980
|
+
const ctanFilesMap = ctanFiles instanceof Map ? ctanFiles : new Map(Object.entries(ctanFiles));
|
|
1981
|
+
for (const [path, content] of ctanFilesMap) accumulatedCtanFiles.set(path, content);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
let retryCount = 0;
|
|
1985
|
+
const maxRetries = maxRetriesOption ?? 15; // Configurable, default 15
|
|
1986
|
+
const fetchedPackages = new Set();
|
|
1987
|
+
|
|
1988
|
+
while (retryCount < maxRetries) {
|
|
1989
|
+
try {
|
|
1990
|
+
const Module = await getOrCreateModule();
|
|
1991
|
+
const FS = Module.FS;
|
|
1992
|
+
|
|
1993
|
+
// Reset filesystem for clean format generation
|
|
1994
|
+
resetFS(FS);
|
|
1995
|
+
|
|
1996
|
+
const vfs = new VirtualFileSystem(FS, { onLog: workerLog });
|
|
1997
|
+
|
|
1998
|
+
for (const [bundleName, data] of bundleDataMap) {
|
|
1999
|
+
const meta = bundleMetaMap.get(bundleName) || null;
|
|
2000
|
+
vfs.mountBundle(bundleName, data, fileManifest, meta);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
if (accumulatedCtanFiles.size > 0) {
|
|
2004
|
+
vfs.mountCtanFiles(accumulatedCtanFiles);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
vfs.finalize();
|
|
2008
|
+
|
|
2009
|
+
// Apply kernel compat shim for format generation too
|
|
2010
|
+
const shimmedPreamble = injectKernelCompatShim(preambleContent);
|
|
2011
|
+
FS.writeFile('/myformat.ini', shimmedPreamble + '\n\\dump\n');
|
|
2012
|
+
|
|
2013
|
+
// Use the correct engine for format generation
|
|
2014
|
+
let formatArgs;
|
|
2015
|
+
if (engine === 'xelatex') {
|
|
2016
|
+
formatArgs = [
|
|
2017
|
+
'xelatex', '-ini', '-jobname=myformat', '-interaction=nonstopmode',
|
|
2018
|
+
'&/texlive/texmf-dist/texmf-var/web2c/xetex/xelatex', '/myformat.ini'
|
|
2019
|
+
];
|
|
2020
|
+
} else {
|
|
2021
|
+
// Default to pdflatex
|
|
2022
|
+
formatArgs = [
|
|
2023
|
+
'pdflatex', '-ini', '-jobname=myformat', '-interaction=nonstopmode',
|
|
2024
|
+
'&/texlive/texmf-dist/texmf-var/web2c/pdftex/pdflatex', '/myformat.ini'
|
|
2025
|
+
];
|
|
2026
|
+
}
|
|
2027
|
+
workerLog(`Generating format with engine: ${engine}`);
|
|
2028
|
+
const result = Module.callMainWithRedirects(formatArgs);
|
|
2029
|
+
|
|
2030
|
+
if (result.exit_code === 0) {
|
|
2031
|
+
const formatData = FS.readFile('/myformat.fmt');
|
|
2032
|
+
workerLog(`Format generated: ${(formatData.byteLength / 1024 / 1024).toFixed(1)}MB in ${(performance.now() - startTime).toFixed(0)}ms`);
|
|
2033
|
+
|
|
2034
|
+
self.postMessage({
|
|
2035
|
+
type: 'format-generate-response', id, success: true, formatData: formatData.buffer
|
|
2036
|
+
}, [formatData.buffer]);
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// Check for missing packages - extract ALL and fetch in parallel
|
|
2041
|
+
let logContent = '';
|
|
2042
|
+
try { logContent = new TextDecoder().decode(FS.readFile('/myformat.log')); } catch (e) {}
|
|
2043
|
+
const allOutput = logContent + ' ' + (result.stdout || '') + ' ' + (result.stderr || '');
|
|
2044
|
+
const missingFiles = extractAllMissingFiles(allOutput, fetchedPackages);
|
|
2045
|
+
|
|
2046
|
+
if (missingFiles.length > 0) {
|
|
2047
|
+
workerLog(`[FORMAT] Missing ${missingFiles.length} packages: ${missingFiles.join(', ')}`);
|
|
2048
|
+
|
|
2049
|
+
// Categorize packages: bundles vs CTAN
|
|
2050
|
+
const bundlesToFetch = [];
|
|
2051
|
+
const ctanToFetch = [];
|
|
2052
|
+
|
|
2053
|
+
for (const missingFile of missingFiles) {
|
|
2054
|
+
const pkgName = getPackageFromFile(missingFile);
|
|
2055
|
+
const bundleName = packageMap?.[pkgName];
|
|
2056
|
+
|
|
2057
|
+
if (bundleName && !bundleDataMap.has(bundleName)) {
|
|
2058
|
+
bundlesToFetch.push({ missingFile, pkgName, bundleName });
|
|
2059
|
+
} else if (!bundleName) {
|
|
2060
|
+
ctanToFetch.push({ missingFile, pkgName });
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// Fetch all in parallel
|
|
2065
|
+
const bundlePromises = bundlesToFetch.map(async ({ missingFile, pkgName, bundleName }) => {
|
|
2066
|
+
workerLog(`Format missing: ${missingFile}, loading bundle ${bundleName}...`);
|
|
2067
|
+
try {
|
|
2068
|
+
const bundleResult = await requestBundleFetch(bundleName);
|
|
2069
|
+
if (bundleResult.success) {
|
|
2070
|
+
return { type: 'bundle', pkgName, bundleName, data: bundleResult };
|
|
2071
|
+
}
|
|
2072
|
+
} catch (e) {
|
|
2073
|
+
workerLog(`Bundle fetch failed for ${bundleName}: ${e.message}`);
|
|
2074
|
+
}
|
|
2075
|
+
return null;
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
const ctanPromises = ctanToFetch.map(async ({ missingFile, pkgName }) => {
|
|
2079
|
+
workerLog(`Format missing: ${missingFile}, fetching ${pkgName} from CTAN...`);
|
|
2080
|
+
try {
|
|
2081
|
+
const ctanData = await requestCtanFetch(pkgName, missingFile);
|
|
2082
|
+
if (ctanData.success) {
|
|
2083
|
+
return { type: 'ctan', pkgName, data: ctanData };
|
|
2084
|
+
}
|
|
2085
|
+
} catch (e) {
|
|
2086
|
+
workerLog(`CTAN fetch failed for ${pkgName}: ${e.message}`);
|
|
2087
|
+
}
|
|
2088
|
+
return null;
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
const allResults = await Promise.all([...bundlePromises, ...ctanPromises]);
|
|
2092
|
+
let fetchedAny = false;
|
|
2093
|
+
|
|
2094
|
+
for (const result of allResults) {
|
|
2095
|
+
if (!result) continue;
|
|
2096
|
+
fetchedAny = true;
|
|
2097
|
+
|
|
2098
|
+
if (result.type === 'bundle') {
|
|
2099
|
+
fetchedPackages.add(result.pkgName);
|
|
2100
|
+
bundleDataMap.set(result.bundleName, result.data.bundleData);
|
|
2101
|
+
if (result.data.bundleMeta) {
|
|
2102
|
+
bundleMetaMap.set(result.bundleName, result.data.bundleMeta);
|
|
2103
|
+
}
|
|
2104
|
+
} else if (result.type === 'ctan') {
|
|
2105
|
+
fetchedPackages.add(result.pkgName);
|
|
2106
|
+
const files = result.data.files instanceof Map
|
|
2107
|
+
? result.data.files
|
|
2108
|
+
: new Map(Object.entries(result.data.files));
|
|
2109
|
+
for (const [path, content] of files) {
|
|
2110
|
+
accumulatedCtanFiles.set(path, content);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
if (fetchedAny) {
|
|
2116
|
+
retryCount++;
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
throw new Error(`Format generation failed with exit code ${result.exit_code}`);
|
|
2122
|
+
|
|
2123
|
+
} catch (e) {
|
|
2124
|
+
if (retryCount >= maxRetries - 1) {
|
|
2125
|
+
workerLog(`Format generation error: ${e.message}`);
|
|
2126
|
+
self.postMessage({ type: 'format-generate-response', id, success: false, error: e.message });
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
retryCount++;
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
workerLog(`Format generation failed after ${maxRetries} retries`);
|
|
2134
|
+
self.postMessage({ type: 'format-generate-response', id, success: false, error: 'Max retries exceeded' });
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// ============ Message Handler ============
|
|
2138
|
+
|
|
2139
|
+
self.onmessage = async function(e) {
|
|
2140
|
+
const msg = e.data;
|
|
2141
|
+
|
|
2142
|
+
switch (msg.type) {
|
|
2143
|
+
case 'init':
|
|
2144
|
+
busytexJsUrl = msg.busytexJsUrl;
|
|
2145
|
+
verboseLogging = msg.verbose ?? false;
|
|
2146
|
+
if (msg.manifest) {
|
|
2147
|
+
fileManifest = msg.manifest;
|
|
2148
|
+
packageMap = msg.packageMapData;
|
|
2149
|
+
bundleDeps = msg.bundleDepsData;
|
|
2150
|
+
bundleRegistry = new Set(msg.bundleRegistryData || []);
|
|
2151
|
+
// Pre-index manifest by bundle for O(1) lookup
|
|
2152
|
+
ensureManifestIndexed(fileManifest);
|
|
2153
|
+
}
|
|
2154
|
+
cachedWasmModule = msg.wasmModule;
|
|
2155
|
+
self.postMessage({ type: 'ready' });
|
|
2156
|
+
break;
|
|
2157
|
+
|
|
2158
|
+
case 'compile':
|
|
2159
|
+
// Queue compile operations to prevent concurrent execution
|
|
2160
|
+
operationQueue = operationQueue.then(() => handleCompile(msg)).catch(e => {
|
|
2161
|
+
workerLog(`Compile queue error: ${e.message}`);
|
|
2162
|
+
// IMPORTANT: Send error response so main thread doesn't hang
|
|
2163
|
+
self.postMessage({
|
|
2164
|
+
type: 'compile-response',
|
|
2165
|
+
id: msg.id,
|
|
2166
|
+
success: false,
|
|
2167
|
+
error: e.message,
|
|
2168
|
+
});
|
|
2169
|
+
});
|
|
2170
|
+
break;
|
|
2171
|
+
|
|
2172
|
+
case 'generate-format':
|
|
2173
|
+
// Queue format operations to prevent concurrent execution
|
|
2174
|
+
operationQueue = operationQueue.then(() => handleFormatGenerate(msg)).catch(e => {
|
|
2175
|
+
workerLog(`Format queue error: ${e.message}`);
|
|
2176
|
+
// IMPORTANT: Send error response so main thread doesn't hang
|
|
2177
|
+
self.postMessage({
|
|
2178
|
+
type: 'format-generate-response',
|
|
2179
|
+
id: msg.id,
|
|
2180
|
+
success: false,
|
|
2181
|
+
error: e.message,
|
|
2182
|
+
});
|
|
2183
|
+
});
|
|
2184
|
+
break;
|
|
2185
|
+
|
|
2186
|
+
case 'ctan-fetch-response':
|
|
2187
|
+
const pending = pendingCtanRequests.get(msg.requestId);
|
|
2188
|
+
if (pending) {
|
|
2189
|
+
pendingCtanRequests.delete(msg.requestId);
|
|
2190
|
+
if (msg.success) pending.resolve(msg);
|
|
2191
|
+
else pending.reject(new Error(msg.error || 'CTAN fetch failed'));
|
|
2192
|
+
}
|
|
2193
|
+
break;
|
|
2194
|
+
|
|
2195
|
+
case 'bundle-fetch-response':
|
|
2196
|
+
const pendingBundle = pendingBundleRequests.get(msg.requestId);
|
|
2197
|
+
if (pendingBundle) {
|
|
2198
|
+
pendingBundleRequests.delete(msg.requestId);
|
|
2199
|
+
if (msg.success) pendingBundle.resolve(msg);
|
|
2200
|
+
else pendingBundle.reject(new Error(msg.error || 'Bundle fetch failed'));
|
|
2201
|
+
}
|
|
2202
|
+
break;
|
|
2203
|
+
|
|
2204
|
+
case 'file-range-fetch-response':
|
|
2205
|
+
const pendingFileRange = pendingFileRangeRequests.get(msg.requestId);
|
|
2206
|
+
if (pendingFileRange) {
|
|
2207
|
+
pendingFileRangeRequests.delete(msg.requestId);
|
|
2208
|
+
if (msg.success) pendingFileRange.resolve(msg);
|
|
2209
|
+
else pendingFileRange.reject(new Error(msg.error || 'File range fetch failed'));
|
|
2210
|
+
}
|
|
2211
|
+
break;
|
|
2212
|
+
}
|
|
2213
|
+
};
|
|
2214
|
+
|
|
2215
|
+
self.onerror = function(e) {
|
|
2216
|
+
self.postMessage({ type: 'log', message: 'Worker error: ' + e.message });
|
|
2217
|
+
};
|