@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/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
+ };