@liascript/exporter 3.0.0--1.0.3 → 3.0.2--1.0.3

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.
Files changed (82) hide show
  1. package/dist/assets/capacitor/{index.bfe7363b.js → index.44a6a9b3.js} +1 -1
  2. package/dist/assets/capacitor/index.html +1 -1
  3. package/dist/assets/capacitor/{jszip.min.f6eda75b.js → jszip.min.2f991499.js} +1 -1
  4. package/dist/assets/capacitor/{trystero-ipfs.min.b27a61d7.js → trystero-ipfs.min.f25fe3e7.js} +1 -1
  5. package/dist/assets/indexeddb/{index.599a57d6.js → index.4aceca2f.js} +1 -1
  6. package/dist/assets/indexeddb/index.html +1 -1
  7. package/dist/assets/pdf/{index.aa124b49.js → index.49b3a4d9.js} +1 -1
  8. package/dist/assets/pdf/index.html +1 -1
  9. package/dist/assets/scorm2004/{index.45f7501b.js → index.031764e4.js} +1 -1
  10. package/dist/assets/scorm2004/index.html +1 -1
  11. package/dist/assets/{xapi/jszip.min.eaecf580.js → scorm2004/jszip.min.19c66d77.js} +1 -1
  12. package/dist/assets/web/{index.39a3e3eb.js → index.98993ded.js} +1 -1
  13. package/dist/assets/web/{index.1062f2c5.js → index.e84d3f3b.js} +1 -1
  14. package/dist/assets/web/index.html +1 -1
  15. package/dist/assets/web/{jszip.min.eaecf580.js → jszip.min.19c66d77.js} +1 -1
  16. package/dist/assets/xapi/{index.018a032a.js → index.f2e89e49.js} +1 -1
  17. package/dist/assets/xapi/{index.780a0ec3.js → index.f5402f58.js} +1 -1
  18. package/dist/assets/xapi/index.html +1 -1
  19. package/dist/assets/{scorm2004/jszip.min.eaecf580.js → xapi/jszip.min.19c66d77.js} +1 -1
  20. package/dist/index.js +3 -3
  21. package/dist/server/presets.json +94 -0
  22. package/dist/server/presets.yaml +120 -0
  23. package/dist/server/public/app.js +1 -0
  24. package/dist/server/public/assets/android.svg +38 -0
  25. package/dist/server/public/assets/cmi.svg +154 -0
  26. package/dist/server/public/assets/docx.svg +20 -0
  27. package/dist/server/public/assets/edX.svg +75 -0
  28. package/dist/server/public/assets/edx.svg +75 -0
  29. package/dist/server/public/assets/epub.svg +18 -0
  30. package/dist/server/public/assets/icon.svg +82 -0
  31. package/dist/server/public/assets/ilias.png +0 -0
  32. package/dist/server/public/assets/json.svg +4 -0
  33. package/dist/server/public/assets/learnworlds.png +0 -0
  34. package/dist/server/public/assets/moodle.svg +190 -0
  35. package/dist/server/public/assets/opal.png +0 -0
  36. package/dist/server/public/assets/openolat.png +0 -0
  37. package/dist/server/public/assets/pdf.svg +4 -0
  38. package/dist/server/public/assets/rdf.svg +4 -0
  39. package/dist/server/public/assets/scorm.png +0 -0
  40. package/dist/server/public/assets/web.png +0 -0
  41. package/dist/server/public/assets/xapi.png +0 -0
  42. package/dist/server/public/i18n.js +1 -0
  43. package/dist/server/public/index.html +1587 -0
  44. package/dist/server/public/locales/de.json +247 -0
  45. package/dist/server/public/locales/en.json +247 -0
  46. package/dist/server/public/status.html +251 -0
  47. package/dist/server/public/styles.css +712 -0
  48. package/package.json +5 -1
  49. package/.parcelrc +0 -3
  50. package/DESKTOP_APP_README.md +0 -58
  51. package/DOCKERHUB_DESCRIPTION.md +0 -52
  52. package/Dockerfile +0 -129
  53. package/PLAYSTORE_GUIDE.md +0 -172
  54. package/action.yml +0 -157
  55. package/custom.css +0 -10
  56. package/electron-builder.json +0 -149
  57. package/src/cli.ts +0 -69
  58. package/src/colorize.ts +0 -115
  59. package/src/export/android.ts +0 -419
  60. package/src/export/docx.ts +0 -1025
  61. package/src/export/epub.ts +0 -1306
  62. package/src/export/h5p.ts +0 -390
  63. package/src/export/helper.ts +0 -360
  64. package/src/export/ims.ts +0 -191
  65. package/src/export/pdf.ts +0 -406
  66. package/src/export/presets.ts +0 -220
  67. package/src/export/project.ts +0 -829
  68. package/src/export/rdf.ts +0 -551
  69. package/src/export/scorm12.ts +0 -167
  70. package/src/export/scorm2004.ts +0 -140
  71. package/src/export/web.ts +0 -306
  72. package/src/export/xapi.ts +0 -424
  73. package/src/exporter.ts +0 -296
  74. package/src/index.ts +0 -96
  75. package/src/parser.ts +0 -373
  76. package/src/presets.yaml +0 -219
  77. package/src/types.ts +0 -82
  78. package/tsconfig.json +0 -24
  79. /package/dist/assets/{pdf → indexeddb}/jszip.min.4fbcc13f.js +0 -0
  80. /package/dist/assets/{indexeddb → pdf}/jszip.min.63142cc8.js +0 -0
  81. /package/dist/assets/{xapi → web}/jszip.min.63142cc8.js +0 -0
  82. /package/dist/assets/{web → xapi}/jszip.min.4fbcc13f.js +0 -0
@@ -1,1306 +0,0 @@
1
- import * as helper from './helper'
2
- import * as COLOR from '../colorize'
3
- import * as path from 'path'
4
- import puppeteer, { Browser, Page } from 'puppeteer'
5
- import * as fs from 'fs'
6
- import { tmpdir } from 'os'
7
-
8
- // Default EPUB generation settings
9
- const DEFAULT_TIMEOUT_MS = 15000 // 15 seconds
10
- const DEFAULT_EPUB_VERSION = 3
11
- const DEFAULT_LANG = 'en'
12
- const DEFAULT_TOC_TITLE = 'Table Of Contents'
13
- const DEFAULT_APPEND_CHAPTER_TITLES = true
14
- const DEFAULT_HIDE_TOC = false
15
-
16
- /**
17
- * Displays help information about EPUB export options and settings.
18
- */
19
- export function help() {
20
- console.log('')
21
- console.log(COLOR.heading('EPUB settings:'), '\n')
22
-
23
- COLOR.info(
24
- 'EPUB export generates e-books from your LiaScript course using Puppeteer to render the content and the @lesjoursfr/html-to-epub library to create the EPUB file. This allows for high-quality e-books compatible with most e-readers.',
25
- )
26
-
27
- console.log(
28
- '\nLearn more: https://www.npmjs.com/package/@lesjoursfr/html-to-epub \n',
29
- )
30
-
31
- console.log(COLOR.heading('Required settings:'), '\n')
32
- COLOR.command(
33
- null,
34
- '--epub-title',
35
- ' Title of the book (required)',
36
- )
37
- COLOR.command(
38
- null,
39
- '--epub-author',
40
- ' Author name(s), semicolon-separated for multiple authors',
41
- )
42
-
43
- console.log('')
44
- console.log(COLOR.heading('Optional settings:'), '\n')
45
-
46
- COLOR.command(null, '--epub-publisher', ' Publisher name')
47
- COLOR.command(
48
- null,
49
- '--epub-cover',
50
- ' Path to cover image (absolute path or URL)',
51
- )
52
- COLOR.command(null, '--epub-description', ' Book description')
53
- COLOR.command(
54
- null,
55
- '--epub-language',
56
- ` Language code in 2 letters (default: ${DEFAULT_LANG})`,
57
- )
58
- COLOR.command(
59
- null,
60
- '--epub-version',
61
- ` EPUB version: 2 or 3 (default: ${DEFAULT_EPUB_VERSION})`,
62
- )
63
- COLOR.command(
64
- null,
65
- '--epub-stylesheet',
66
- ' Path to custom CSS file for styling',
67
- )
68
- COLOR.command(
69
- null,
70
- '--epub-theme',
71
- ' LiaScript theme: default, turquoise, blue, red, yellow',
72
- )
73
- COLOR.command(
74
- null,
75
- '--epub-toc-title',
76
- ` Title for table of contents (default: "${DEFAULT_TOC_TITLE}")`,
77
- )
78
- COLOR.command(
79
- null,
80
- '--epub-hide-toc',
81
- ' Hide table of contents in the generated EPUB (default: false)',
82
- )
83
- COLOR.command(
84
- null,
85
- '--epub-timeout',
86
- ` Additional wait time for rendering in ms (default: ${DEFAULT_TIMEOUT_MS})`,
87
- )
88
- COLOR.command(
89
- null,
90
- '--epub-fonts',
91
- ' Comma-separated paths to custom font files to embed',
92
- )
93
- COLOR.command(
94
- null,
95
- '--epub-chapter-title',
96
- ' Custom title for the main chapter (default: course title)',
97
- )
98
- COLOR.command(
99
- null,
100
- '--epub-preview',
101
- ' Open preview browser for debugging (default: false)',
102
- )
103
- }
104
-
105
- /**
106
- * Configuration options for EPUB export.
107
- */
108
- export interface EpubExportArguments {
109
- input: string
110
- output: string
111
-
112
- // Required EPUB settings
113
- 'epub-title': string
114
- 'epub-author'?: string
115
-
116
- // Optional EPUB settings
117
- 'epub-publisher'?: string
118
- 'epub-cover'?: string
119
- 'epub-description'?: string
120
- 'epub-language'?: string
121
- 'epub-version'?: 2 | 3
122
- 'epub-stylesheet'?: string
123
- 'epub-theme'?: string
124
- 'epub-toc-title'?: string
125
- 'epub-hide-toc'?: boolean
126
- 'epub-timeout'?: number
127
- 'epub-fonts'?: string
128
- 'epub-chapter-title'?: string
129
- 'epub-preview'?: boolean
130
- }
131
-
132
- export const format = 'epub'
133
-
134
- /**
135
- * Exports a LiaScript course to EPUB format using Puppeteer and @lesjoursfr/html-to-epub.
136
- *
137
- * This function launches a headless Chrome browser, loads the LiaScript content,
138
- * applies any custom styling or themes, extracts the rendered HTML DOM,
139
- * and generates an EPUB file.
140
- *
141
- * @param argument - Configuration options for the EPUB export
142
- * @throws {Error} If browser launch fails, page navigation fails, or EPUB generation fails
143
- *
144
- * @example
145
- * ```typescript
146
- * await exporter({
147
- * input: './course.md',
148
- * output: './output/book',
149
- * 'epub-title': 'My Course',
150
- * 'epub-author': 'John Doe',
151
- * 'epub-language': 'en'
152
- * })
153
- * ```
154
- */
155
- export async function exporter(argument: EpubExportArguments, json: any) {
156
- const dirname = helper.dirname()
157
-
158
- let url = `file://${dirname}/assets/pdf/index.html?`
159
- if (helper.isURL(argument.input)) {
160
- url += argument.input
161
- } else {
162
- url += 'file://' + path.resolve(argument.input)
163
- }
164
-
165
- let browser: Browser | null = null
166
- let page: Page | null = null
167
-
168
- try {
169
- // Configure browser launch options
170
- const launchOptions: any = {
171
- pipe: true,
172
- args: [
173
- '--no-sandbox',
174
- '--disable-web-security',
175
- '--disable-features=IsolateOrigins',
176
- '--disable-site-isolation-trials',
177
- '--unhandled-rejections=strict',
178
- '--disable-features=BlockInsecurePrivateNetworkRequests',
179
- '--allow-file-access-from-files',
180
- '--enable-local-file-accesses',
181
- '--enable-features=ExperimentalJavaScript',
182
- ],
183
- headless: !argument['epub-preview'],
184
- }
185
-
186
- // Use custom executable path if provided, otherwise use Chrome channel
187
- if (process.env.PUPPETEER_EXECUTABLE_PATH) {
188
- launchOptions.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH
189
- } else {
190
- launchOptions.channel = 'chrome'
191
- }
192
-
193
- try {
194
- browser = await puppeteer.launch(launchOptions)
195
- } catch (launchError) {
196
- throw new Error(
197
- `Failed to launch browser for EPUB generation. Make sure Chrome is installed. ${launchError}`,
198
- )
199
- }
200
-
201
- page = await browser.newPage()
202
- console.log(
203
- 'Loading course content... This may take a while for large courses.',
204
- )
205
-
206
- page.on('dialog', async (dialog) => {
207
- await dialog.accept()
208
- })
209
-
210
- // Set up render done listener BEFORE navigating to catch the signal
211
- let renderDoneResolve: () => void
212
- const renderDonePromise = new Promise<void>((resolve) => {
213
- renderDoneResolve = resolve
214
- })
215
-
216
- page.on('console', (msg) => {
217
- if (msg.text().startsWith('__RENDER_DONE__')) {
218
- renderDoneResolve()
219
- }
220
- })
221
-
222
- await page.setExtraHTTPHeaders({
223
- referer: 'https://liascript.github.io/',
224
- })
225
- // Wait for page to load completely
226
- // Using 'networkidle2' ensures all network requests are complete
227
- // Timeout set to 0 (unlimited) to handle large courses
228
- await page.goto(url, {
229
- waitUntil: 'networkidle2',
230
- timeout: DEFAULT_TIMEOUT_MS,
231
- })
232
-
233
- argument['epub-title'] = argument['epub-title'] || json.lia.str_title
234
- argument['epub-cover'] = argument['epub-cover'] || json.lia.definition.logo
235
- argument['epub-author'] = argument['epub-author'] || json.lia.definition.author
236
- argument['epub-language'] = argument['epub-language'] || json.lia.definition.language
237
- argument['epub-description'] = argument['epub-description'] || json.lia.definition.comment
238
-
239
- if (argument['epub-stylesheet']) {
240
- const href = path.resolve(dirname + '/../', argument['epub-stylesheet'])
241
- try {
242
- await page.evaluate(async (href) => {
243
- const link = document.createElement('link')
244
- link.rel = 'stylesheet'
245
- link.href = href
246
- const promise = new Promise((resolve, reject) => {
247
- link.onload = resolve
248
- link.onerror = reject
249
- })
250
- document.head.appendChild(link)
251
- await promise
252
- }, href)
253
- } catch (e) {
254
- throw new Error(
255
- `Failed to load custom stylesheet from '${argument['epub-stylesheet']}': ${e}`,
256
- )
257
- }
258
- }
259
-
260
- if (argument['epub-theme']) {
261
- try {
262
- await page.evaluate(async (theme) => {
263
- document.documentElement.classList.remove('lia-theme-default')
264
- document.documentElement.classList.add('lia-theme-' + theme)
265
- }, argument['epub-theme'])
266
- } catch (e) {
267
- throw new Error(
268
- `Failed to apply theme '${argument['epub-theme']}': ${e}`,
269
- )
270
- }
271
- }
272
-
273
- if (!argument['epub-preview']) {
274
- // Wait for LiaScript to signal rendering is complete
275
- await renderDonePromise
276
-
277
- if (argument['epub-timeout']) {
278
- await helper.sleep(argument['epub-timeout'])
279
- }
280
-
281
- await toEPUB(argument, page, dirname)
282
- } else {
283
- console.log('Preview mode enabled - browser will remain open')
284
- }
285
- } catch (e) {
286
- const error = e as Error
287
- console.error('EPUB export failed:', error.message)
288
- throw new Error(`Failed to export EPUB: ${error.message}`)
289
- } finally {
290
- if (argument['epub-preview']) {
291
- console.log('Browser kept open for preview. Close manually when done.')
292
- } else {
293
- if (page) {
294
- try {
295
- await page.close()
296
- } catch (closeError) {
297
- console.error('Failed to close page:', closeError)
298
- }
299
- }
300
- if (browser) {
301
- try {
302
- await browser.close()
303
- } catch (closeError) {
304
- console.error('Failed to close browser:', closeError)
305
- }
306
- }
307
- }
308
- }
309
- }
310
-
311
- /**
312
- * Generates an EPUB file from a Puppeteer page.
313
- *
314
- * Extracts the rendered HTML DOM, processes images and resources,
315
- * and creates an EPUB file using @lesjoursfr/html-to-epub.
316
- *
317
- * @param argument - EPUB export configuration options
318
- * @param page - Puppeteer page instance containing the rendered content
319
- * @param dirname - Base directory path
320
- * @throws {Error} If EPUB generation fails or HTML extraction fails
321
- */
322
- async function toEPUB(
323
- argument: EpubExportArguments,
324
- page: Page,
325
- dirname: string,
326
- ) {
327
- try {
328
- console.log('Converting SVG diagrams to PNG images...')
329
- const svgImages = await screenshotTaggedElements(
330
- page,
331
- 'figure.lia-figure',
332
- 'data-svg-index',
333
- 'SVG diagram',
334
- `return !!el.querySelector('.lia-figure__media svg')`,
335
- )
336
- console.log(`Converted ${svgImages.size} SVG diagrams to PNG`)
337
-
338
- console.log('Extracting syntax-highlighted code blocks...')
339
- const highlightedBlocks = await extractHighlightedCode(page)
340
- console.log(`Extracted ${highlightedBlocks.size} highlighted code block(s)`)
341
-
342
- console.log('Screenshotting ABC music notation blocks...')
343
- const abcImages = await screenshotAbcBlocks(page)
344
- console.log(`Captured ${abcImages.size} ABC notation block(s)`)
345
-
346
- console.log('Screenshotting standalone ABC music notation blocks...')
347
- const standaloneAbcImages = await screenshotStandaloneAbcBlocks(page)
348
- console.log(
349
- `Captured ${standaloneAbcImages.size} standalone ABC notation block(s)`,
350
- )
351
-
352
- console.log('Screenshotting embedded media (Spotify, SoundCloud, etc.)...')
353
- const embedImages = await screenshotTaggedElements(
354
- page,
355
- 'lia-embed',
356
- 'data-embed-index',
357
- 'embed',
358
- )
359
- console.log(`Captured ${embedImages.size} embed cover(s)`)
360
-
361
- console.log('Extracting ECharts diagrams...')
362
- const chartImages = await extractChartSvgs(page)
363
- console.log(`Extracted ${chartImages.size} chart(s)`)
364
-
365
- console.log('Extracting math formulas...')
366
- const formulaHtml = await extractFormulaHtml(page)
367
- console.log(`Extracted ${formulaHtml.size} formula(s)`)
368
-
369
- // Hide formula accessibility/MathML content inside SVG foreignObjects before screenshotting
370
- await page.evaluate(() => {
371
- document
372
- .querySelectorAll('svg foreignObject lia-formula')
373
- .forEach((formula) => {
374
- formula.childNodes.forEach((child) => {
375
- if (child.nodeType === Node.ELEMENT_NODE) {
376
- ;(child as HTMLElement).style.display = 'none'
377
- }
378
- })
379
- const shadow = formula.shadowRoot
380
- if (shadow) {
381
- shadow.querySelectorAll('.katex-mathml').forEach((mathml) => {
382
- ;(mathml as HTMLElement).style.display = 'none'
383
- })
384
- }
385
- })
386
- })
387
-
388
- console.log('Screenshotting inline SVGs (foreignObject, interactive)...')
389
- const inlineSvgImages = await screenshotTaggedElements(
390
- page,
391
- 'svg',
392
- 'data-inline-svg-index',
393
- 'inline SVG',
394
- // Only content SVGs with viewBox, not inside containers already handled
395
- `if (!el.hasAttribute('viewBox')) return false;
396
- const skip = el.closest('.lia-figure, .lia-code, lia-chart, lia-abcjs, lia-embed');
397
- if (skip) return false;
398
- // Skip tiny decorative SVGs (icons, spinners, etc.)
399
- const rect = el.getBoundingClientRect();
400
- if (rect.width < 50 || rect.height < 50) return false;
401
- return true;`,
402
- )
403
- console.log(`Captured ${inlineSvgImages.size} inline SVG(s)`)
404
-
405
- const toEntries = (map: Map<number, string>) =>
406
- Array.from(map.entries()) as [number, string][]
407
- const payload = {
408
- svgImages: toEntries(svgImages),
409
- codeBlocks: toEntries(highlightedBlocks),
410
- abcImages: toEntries(abcImages),
411
- standaloneAbcImages: toEntries(standaloneAbcImages),
412
- embedImages: toEntries(embedImages),
413
- chartImages: toEntries(chartImages),
414
- formulas: toEntries(formulaHtml),
415
- inlineSvgImages: toEntries(inlineSvgImages),
416
- }
417
- const chapters = await page.evaluate((data) => {
418
- const {
419
- svgImages: svgImagesData,
420
- codeBlocks,
421
- abcImages: abcImagesData,
422
- standaloneAbcImages: standaloneAbcImagesData,
423
- embedImages: embedImagesData,
424
- chartImages: chartImagesData,
425
- formulas: formulasData,
426
- inlineSvgImages: inlineSvgImagesData,
427
- } = data
428
- const bodyClone = document.body.cloneNode(true) as HTMLElement
429
-
430
- // Remove problematic link tags
431
- bodyClone
432
- .querySelectorAll(
433
- 'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"], ' +
434
- 'link[rel="manifest"], link[rel="preconnect"], link[rel="dns-prefetch"], ' +
435
- 'link[rel="preload"], link[rel="stylesheet"]',
436
- )
437
- .forEach((link: any) => link.remove())
438
-
439
- // Remove the copy-to-clipboard button from code blocks
440
- bodyClone
441
- .querySelectorAll('.lia-code__copy, .lia-code__copy--inverted')
442
- .forEach((btn: Element) => btn.remove())
443
-
444
- // Remove run/version control toolbar
445
- bodyClone
446
- .querySelectorAll('.lia-code-control')
447
- .forEach((ctrl: Element) => ctrl.remove())
448
-
449
- // Helper: replace tagged elements with their pre-captured images
450
- const replaceWithImage = (
451
- selector: string,
452
- dataAttr: string,
453
- images: [number, string][],
454
- options: {
455
- alt: string | ((el: Element) => string)
456
- wrapInFigure?: boolean // true → wrap <img> in a new <figure>
457
- reuseAsContainer?: boolean // true → clear element innerHTML and append <img> inside it
458
- imgStyle?: string | ((el: Element) => string)
459
- figureStyle?: string
460
- },
461
- ) => {
462
- const defaultImgStyle =
463
- 'max-width: 100%; height: auto; display: block; margin: 0 auto;'
464
- bodyClone.querySelectorAll(selector).forEach((el: Element) => {
465
- try {
466
- const idx = parseInt(el.getAttribute(dataAttr) || '-1')
467
- const entry = images.find((item: any) => item[0] === idx)
468
- if (!entry || !entry[1]) {
469
- el.remove()
470
- return
471
- }
472
-
473
- const img = document.createElement('img')
474
- img.src = entry[1]
475
- img.alt =
476
- typeof options.alt === 'function' ? options.alt(el) : options.alt
477
- const style =
478
- typeof options.imgStyle === 'function'
479
- ? options.imgStyle(el)
480
- : (options.imgStyle ?? defaultImgStyle)
481
- img.setAttribute('style', style)
482
-
483
- if (options.reuseAsContainer) {
484
- el.innerHTML = ''
485
- el.appendChild(img)
486
- if (options.figureStyle)
487
- el.setAttribute('style', options.figureStyle)
488
- } else if (options.wrapInFigure) {
489
- const figure = document.createElement('figure')
490
- figure.setAttribute(
491
- 'style',
492
- options.figureStyle ||
493
- 'margin: 1.5em auto; text-align: center; page-break-inside: avoid;',
494
- )
495
- figure.appendChild(img)
496
- el.replaceWith(figure)
497
- } else {
498
- el.replaceWith(img)
499
- }
500
- } catch (e) {
501
- console.error(`Error replacing ${selector}:`, e)
502
- }
503
- })
504
- }
505
-
506
- // Replace <lia-embed> elements with their pre-captured cover screenshots
507
- replaceWithImage(
508
- 'lia-embed[data-embed-index]',
509
- 'data-embed-index',
510
- embedImagesData,
511
- {
512
- alt: 'Embedded media',
513
- imgStyle: (el) =>
514
- el.getAttribute('style') || 'width:250px;height:250px;',
515
- },
516
- )
517
-
518
- // Replace <lia-chart> elements with their extracted SVG images
519
- replaceWithImage(
520
- 'lia-chart[data-chart-index]',
521
- 'data-chart-index',
522
- chartImagesData,
523
- {
524
- alt: (el) => el.getAttribute('aria-label') || 'Chart',
525
- wrapInFigure: true,
526
- },
527
- )
528
-
529
- // Replace ABC notation blocks with pre-captured SVG
530
- replaceWithImage(
531
- '.lia-code-terminal[data-abc-index]',
532
- 'data-abc-index',
533
- abcImagesData,
534
- {
535
- alt: 'ABC Music Notation',
536
- wrapInFigure: true,
537
- },
538
- )
539
-
540
- // Replace standalone ABC notation elements (from template macros like @ABCJS.render)
541
- replaceWithImage(
542
- 'lia-abcjs[data-standalone-abc-index]',
543
- 'data-standalone-abc-index',
544
- standaloneAbcImagesData,
545
- {
546
- alt: 'ABC Music Notation',
547
- wrapInFigure: true,
548
- },
549
- )
550
-
551
- // Replace SVG diagrams with PNG images for better EPUB compatibility
552
- replaceWithImage(
553
- 'figure.lia-figure[data-svg-index]',
554
- 'data-svg-index',
555
- svgImagesData,
556
- {
557
- alt: 'ASCII Diagram',
558
- reuseAsContainer: true,
559
- figureStyle:
560
- 'margin: 1.5em auto; padding: 1.5em; background-color: #f8f9fa; ' +
561
- 'border: 1px solid #dee2e6; border-radius: 4px; text-align: center; ' +
562
- 'page-break-inside: avoid; max-width: 90%;',
563
- },
564
- )
565
-
566
- // Replace inline SVGs (foreignObject, interactive content) with PNG screenshots
567
- replaceWithImage(
568
- 'svg[data-inline-svg-index]',
569
- 'data-inline-svg-index',
570
- inlineSvgImagesData,
571
- {
572
- alt: 'SVG Graphic',
573
- wrapInFigure: true,
574
- figureStyle:
575
- 'margin: 1.5em auto; text-align: center; page-break-inside: avoid;',
576
- },
577
- )
578
-
579
- // Replace <lia-formula> elements with their pre-extracted MathML/KaTeX HTML
580
- bodyClone
581
- .querySelectorAll('lia-formula[data-formula-index]')
582
- .forEach((formula: Element) => {
583
- try {
584
- const idx = parseInt(
585
- formula.getAttribute('data-formula-index') || '-1',
586
- )
587
- const entry = formulasData.find((item: any) => item[0] === idx)
588
- if (entry && entry[1]) {
589
- const isBlock = formula.getAttribute('displaymode') === 'true'
590
- const wrapper = document.createElement(isBlock ? 'div' : 'span')
591
- wrapper.innerHTML = entry[1]
592
-
593
- // Set display attribute on <math> element for EPUB3 MathML rendering
594
- const mathEl = wrapper.querySelector('math')
595
- if (mathEl) {
596
- mathEl.setAttribute('display', isBlock ? 'block' : 'inline')
597
- if (isBlock) {
598
- wrapper.setAttribute(
599
- 'style',
600
- 'text-align: center; margin: 1em 0;',
601
- )
602
- }
603
- }
604
-
605
- formula.replaceWith(wrapper)
606
- }
607
- } catch (e) {
608
- console.error('Error replacing formula:', e)
609
- }
610
- })
611
-
612
- // Process terminal output blocks
613
- bodyClone
614
- .querySelectorAll('.lia-code-terminal')
615
- .forEach((terminal: Element) => {
616
- try {
617
- // Skip if this is an ABC block (already handled above)
618
- if (terminal.querySelector('lia-abcjs')) {
619
- terminal.remove()
620
- return
621
- }
622
-
623
- // If the terminal contains rendered MathML (from @runFormula macro)
624
- // Extract the formula and replace the terminal with it
625
- const mathEls = terminal.querySelectorAll('math')
626
- if (mathEls.length > 0) {
627
- const container = document.createElement('div')
628
- container.setAttribute(
629
- 'style',
630
- 'text-align: center; margin: 1em 0; background-color: #1e1e1e; color: #d4d4d4; padding: 1em; border-radius: 4px;',
631
- )
632
- mathEls.forEach((m) => {
633
- m.setAttribute('display', 'block')
634
- container.appendChild(m.cloneNode(true))
635
- })
636
- terminal.replaceWith(container)
637
- return
638
- }
639
-
640
- // If terminal still has an unreplaced <lia-formula> (no data extracted), remove it
641
- if (terminal.querySelector('lia-formula')) {
642
- terminal.remove()
643
- return
644
- }
645
-
646
- const terminalOutput = terminal.querySelector('lia-terminal')
647
- if (!terminalOutput) return
648
-
649
- const pre = document.createElement('pre')
650
- const code = document.createElement('code')
651
-
652
- pre.setAttribute(
653
- 'style',
654
- 'background-color: #1e1e1e; color: #d4d4d4; padding: 1em; ' +
655
- 'border-radius: 4px; font-family: monospace; overflow-x: auto;',
656
- )
657
-
658
- const textDivs = terminalOutput.querySelectorAll(
659
- 'div[class^="text-"]',
660
- )
661
- if (textDivs.length > 0) {
662
- textDivs.forEach((div: Element) => {
663
- const span = document.createElement('span')
664
- span.textContent = div.textContent || ''
665
- if (div.classList.contains('text-error')) {
666
- span.setAttribute('style', 'color: #f48771;')
667
- } else if (div.classList.contains('text-warning')) {
668
- span.setAttribute('style', 'color: #dcdcaa;')
669
- }
670
- code.appendChild(span)
671
- code.appendChild(document.createTextNode('\n'))
672
- })
673
- } else {
674
- code.textContent = terminalOutput.textContent || ''
675
- }
676
-
677
- pre.appendChild(code)
678
- terminal.replaceWith(pre)
679
- } catch (e) {
680
- console.error('Error processing terminal block:', e)
681
- }
682
- })
683
-
684
- // Replace Ace Editor code blocks with pre-extracted syntax-highlighted HTML
685
- bodyClone
686
- .querySelectorAll('.lia-code__input')
687
- .forEach((codeInput: Element) => {
688
- try {
689
- const idx = parseInt(
690
- codeInput.getAttribute('data-code-index') || '-1',
691
- )
692
- const entry = codeBlocks.find((item) => item[0] === idx)
693
- if (entry && entry[1]) {
694
- const wrapper = document.createElement('div')
695
- wrapper.innerHTML = entry[1]
696
- codeInput.replaceWith(wrapper.firstChild || wrapper)
697
- }
698
- } catch (e) {
699
- console.error('Error injecting highlighted code block:', e)
700
- }
701
- })
702
-
703
- // Extract chapters from <main> elements
704
- const mainElements = bodyClone.querySelectorAll('main')
705
- const chapterList: Array<{ title: string; data: string }> = []
706
-
707
- if (mainElements.length > 0) {
708
- mainElements.forEach((main: Element, index: number) => {
709
- let chapterTitle = `Chapter ${index + 1}`
710
- const headerEl = main.querySelector('header')
711
- const hTag = headerEl?.querySelector('.h1, .h2, .h3, .h4, .h5, .h6')
712
- if (hTag?.textContent) {
713
- chapterTitle = hTag.textContent.trim()
714
- }
715
- // Remove the header from DOM to avoid duplicate titles
716
- if (headerEl) {
717
- headerEl.remove()
718
- }
719
-
720
- main.querySelectorAll('script').forEach((el: Element) => el.remove())
721
-
722
- const tempDiv = document.createElement('div')
723
- tempDiv.appendChild(main.cloneNode(true))
724
- chapterList.push({ title: chapterTitle, data: tempDiv.innerHTML })
725
- })
726
-
727
- return chapterList
728
- } else {
729
- bodyClone
730
- .querySelectorAll('script')
731
- .forEach((el: Element) => el.remove())
732
- return [{ title: 'Content', data: bodyClone.outerHTML }]
733
- }
734
- }, payload)
735
-
736
- // Convert external/URL-based images to data URIs
737
- console.log('Fetching external/URL-based images as data URIs...')
738
- for (const chapter of chapters) {
739
- chapter.data = await page.evaluate(async (html: string) => {
740
- const parser = new DOMParser()
741
- const doc = parser.parseFromString(html, 'text/html')
742
-
743
- const imgs = Array.from(doc.querySelectorAll('img[src]'))
744
- await Promise.all(
745
- imgs.map(async (img) => {
746
- const src = img.getAttribute('src')!
747
- // Skip already-inlined data URIs and local file:// paths
748
- if (src.startsWith('data:') || src.startsWith('file://')) return
749
-
750
- try {
751
- const response = await fetch(src)
752
- if (!response.ok) return
753
- const blob = await response.blob()
754
- const dataUri = await new Promise<string>((resolve) => {
755
- const reader = new FileReader()
756
- reader.onload = () => resolve(reader.result as string)
757
- reader.readAsDataURL(blob)
758
- })
759
- img.setAttribute('src', dataUri)
760
- } catch (e) {
761
- console.warn('Failed to fetch image:', src, e)
762
- }
763
- }),
764
- )
765
-
766
- return doc.body.innerHTML
767
- }, chapter.data)
768
- }
769
-
770
- // Sanitize chapter HTML for XHTML/XML compatibility
771
- for (const chapter of chapters) {
772
- // Strip HTML comments — XML forbids "--" inside comment bodies
773
- chapter.data = chapter.data.replace(/<!--[\s\S]*?-->/g, '')
774
-
775
- // Escape namespace-prefixed tags like <jc:trillian.mit.edu> that aren't valid HTML elements
776
- chapter.data = chapter.data.replace(
777
- /<((?!\/?\s*(?:svg|math|xlink|xml|xmlns)[:\s>])[a-zA-Z][a-zA-Z0-9]*:[^\s>]+[^>]*)>/g,
778
- '&lt;$1&gt;',
779
- )
780
- }
781
-
782
- // Read CSS and fonts from the pdf assets folder
783
- const pdfAssetsPath = path.join(dirname, 'assets', 'pdf')
784
- const cssFiles = fs
785
- .readdirSync(pdfAssetsPath)
786
- .filter((f) => f.endsWith('.css'))
787
-
788
- let allCSS = ''
789
- const fontPaths: string[] = []
790
-
791
- if (cssFiles.length > 0) {
792
- cssFiles.forEach((cssFile) => {
793
- allCSS +=
794
- fs.readFileSync(path.join(pdfAssetsPath, cssFile), 'utf-8') + '\n'
795
- })
796
-
797
- const fontMatches = allCSS.match(
798
- /url\(['"]?([^'")\s]+\.(?:woff2?|ttf|otf|eot))['"]?\)/gi,
799
- )
800
- if (fontMatches) {
801
- const uniqueFonts = new Set<string>()
802
- fontMatches.forEach((match) => {
803
- const fontMatch = match.match(/url\(['"]?([^'")\s]+)['"]?\)/)
804
- if (fontMatch?.[1]) {
805
- uniqueFonts.add(path.basename(fontMatch[1]))
806
- }
807
- })
808
- uniqueFonts.forEach((fontFilename) => {
809
- const fontPath = path.join(pdfAssetsPath, fontFilename)
810
- if (fs.existsSync(fontPath)) {
811
- fontPaths.push(fontPath)
812
- } else {
813
- console.warn(`Warning: Font file not found: ${fontPath}`)
814
- }
815
- })
816
- }
817
- } else {
818
- console.warn('Warning: No CSS files found in pdf assets folder')
819
- }
820
-
821
- // Include KaTeX font files not already referenced in CSS
822
- fs.readdirSync(pdfAssetsPath)
823
- .filter(
824
- (f) =>
825
- f.startsWith('KaTeX_') &&
826
- (f.endsWith('.woff') || f.endsWith('.woff2') || f.endsWith('.ttf')),
827
- )
828
- .forEach((fontFile) => {
829
- const fontPath = path.join(pdfAssetsPath, fontFile)
830
- if (!fontPaths.includes(fontPath) && fs.existsSync(fontPath)) {
831
- fontPaths.push(fontPath)
832
- }
833
- })
834
-
835
- console.log(
836
- `Building EPUB with ${chapters.length} chapter(s) and ${fontPaths.length} font(s)...`,
837
- )
838
-
839
- let authors: string | string[] = argument['epub-author'] || 'Unknown'
840
- if (typeof authors === 'string' && authors.includes(';')) {
841
- authors = authors.split(';').map((a) => a.trim())
842
- }
843
-
844
-
845
- let fonts: string[] = [...fontPaths]
846
- if (argument['epub-fonts']) {
847
- fonts.push(...argument['epub-fonts'].split(',').map((f) => f.trim()))
848
- }
849
-
850
- let customCSS = allCSS
851
- if (argument['epub-stylesheet']) {
852
- try {
853
- customCSS =
854
- customCSS +
855
- '\n' +
856
- fs.readFileSync(path.resolve(argument['epub-stylesheet']), 'utf-8')
857
- } catch (e) {
858
- console.warn(`Warning: Could not read custom stylesheet: ${e}`)
859
- }
860
- }
861
-
862
-
863
-
864
- const epubOptions: any = {
865
- title: argument['epub-title'] ,
866
- author: authors,
867
- lang: argument['epub-language'] || DEFAULT_LANG,
868
- tocTitle: argument['epub-toc-title'] || DEFAULT_TOC_TITLE,
869
- appendChapterTitles:
870
- argument['epub-chapter-title'] === undefined &&
871
- DEFAULT_APPEND_CHAPTER_TITLES,
872
- hideToC: argument['epub-hide-toc'] ?? DEFAULT_HIDE_TOC,
873
- css: customCSS,
874
- version: (argument['epub-version'] || DEFAULT_EPUB_VERSION) as 2 | 3,
875
- content: chapters,
876
- verbose: false,
877
- tempDir: path.join(tmpdir(), 'liaex-epub-temp'),
878
- }
879
-
880
- if (argument['epub-publisher'])
881
- epubOptions.publisher = argument['epub-publisher']
882
- if (argument['epub-cover']) epubOptions.cover = argument['epub-cover']
883
- if (argument['epub-description'])
884
- epubOptions.description = argument['epub-description']
885
- if (fonts.length > 0) epubOptions.fonts = fonts
886
-
887
- const outputPath = argument.output.endsWith('.epub')
888
- ? argument.output
889
- : argument.output + '.epub'
890
-
891
- // Use dynamic import with a variable to prevent Parcel from converting it to require()
892
- // The @lesjoursfr/html-to-epub package is ESM-only and cannot be require()'d
893
- const epubModuleName = '@lesjoursfr/html-to-epub'
894
- const epubModule = await (
895
- new Function('m', 'return import(m)') as (m: string) => Promise<any>
896
- )(epubModuleName)
897
- const EPub = epubModule.EPub
898
- await new EPub(epubOptions, outputPath).render()
899
- console.log(`EPUB successfully generated: ${outputPath}`)
900
- } catch (e) {
901
- const error = e as Error
902
- throw new Error(`Failed to generate EPUB: ${error.message}`)
903
- }
904
- }
905
-
906
- /**
907
- * Extracts syntax-highlighted HTML from all Ace Editor code blocks in the live page
908
- *
909
- * @param page - Puppeteer page instance
910
- * @returns Map of block indexes to static highlighted HTML strings
911
- */
912
- async function extractHighlightedCode(
913
- page: Page,
914
- ): Promise<Map<number, string>> {
915
- const result = await page.evaluate(() => {
916
- const escape = (text: string) =>
917
- text
918
- .replace(/&/g, '&amp;')
919
- .replace(/</g, '&lt;')
920
- .replace(/>/g, '&gt;')
921
- .replace(/"/g, '&quot;')
922
- .replace(/'/g, '&#39;')
923
-
924
- const blocks: [number, string][] = []
925
- let idx = 0
926
-
927
- document
928
- .querySelectorAll('.lia-code__input')
929
- .forEach((codeInput: Element) => {
930
- codeInput.setAttribute('data-code-index', idx.toString())
931
-
932
- try {
933
- const aceEditor = codeInput.querySelector('.ace_editor')
934
- const aceContent = aceEditor?.querySelector('.ace_text-layer')
935
-
936
- if (!aceEditor || !aceContent) {
937
- blocks.push([idx, ''])
938
- idx++
939
- return
940
- }
941
-
942
- const gutterCells = aceEditor.querySelectorAll('.ace_gutter-cell')
943
- const lineNumbers: string[] = []
944
- gutterCells.forEach((cell: Element) => {
945
- const n = cell.textContent?.trim()
946
- if (n && !isNaN(parseInt(n))) lineNumbers.push(n)
947
- })
948
-
949
- const bgColor =
950
- window.getComputedStyle(aceEditor as HTMLElement).backgroundColor ||
951
- '#f5f5f5'
952
-
953
- let codeHTML = ''
954
- let lineIndex = 0
955
-
956
- // Helper: render a single .ace_line element's content as HTML string
957
- const renderLineContent = (line: Element): string => {
958
- let lineHTML = ''
959
- const hasTokenSpans =
960
- line.querySelector('span[class*="ace_"]') !== null
961
- if (hasTokenSpans) {
962
- line.childNodes.forEach((node: ChildNode) => {
963
- if (node.nodeType === Node.TEXT_NODE) {
964
- // Plain text node — raw space/whitespace between tokens
965
- const text = node.textContent || ''
966
- // Strip Ace zero-width / invisible control chars but keep real spaces
967
- const cleaned = text.replace(/[\u200B-\u200D\uFEFF]/g, '')
968
- if (cleaned) lineHTML += escape(cleaned)
969
- } else if (node.nodeType === Node.ELEMENT_NODE) {
970
- const tokenEl = node as HTMLElement
971
- // Recurse one level: Ace sometimes nests spans (e.g. ace_indent_guide inside ace_indent)
972
- const text = tokenEl.textContent || ''
973
- if (!text) return
974
- const cleaned = text.replace(/[\u200B-\u200D\uFEFF]/g, '')
975
- if (!cleaned) return
976
- const color = window.getComputedStyle(tokenEl).color
977
- const fontWeight = window.getComputedStyle(tokenEl).fontWeight
978
- const fontStyle = window.getComputedStyle(tokenEl).fontStyle
979
- const escaped = escape(cleaned)
980
- let style = ''
981
- if (
982
- color &&
983
- color !== 'rgb(0, 0, 0)' &&
984
- color !== 'rgba(0, 0, 0, 0)'
985
- ) {
986
- style += `color:${color};`
987
- }
988
- if (fontWeight === 'bold' || parseInt(fontWeight) >= 700)
989
- style += 'font-weight:bold;'
990
- if (fontStyle === 'italic') style += 'font-style:italic;'
991
- lineHTML += style
992
- ? `<span style="${style}">${escaped}</span>`
993
- : escaped
994
- }
995
- })
996
- } else {
997
- const cleanText = (line.textContent || '').replace(
998
- /[\u200B-\u200D\uFEFF\n]/g,
999
- '',
1000
- )
1001
- lineHTML += escape(cleanText)
1002
- }
1003
- return lineHTML
1004
- }
1005
-
1006
- aceContent
1007
- .querySelectorAll('.ace_line_group')
1008
- .forEach((lineGroup: Element) => {
1009
- // Each .ace_line_group represents ONE logical source line
1010
- const subLines = lineGroup.querySelectorAll('.ace_line')
1011
- if (subLines.length === 0) return
1012
-
1013
- let lineHTML = ''
1014
-
1015
- // Emit the gutter line number for this logical line
1016
- if (lineIndex < lineNumbers.length) {
1017
- lineHTML += `<span style="color:#858585;display:inline-block;width:3em;text-align:right;margin-right:1em;user-select:none;">${lineNumbers[lineIndex]}</span>`
1018
- lineIndex++
1019
- }
1020
-
1021
- // Concatenate content from every sub-line (soft-wrap continuations)
1022
- subLines.forEach((line: Element) => {
1023
- lineHTML += renderLineContent(line)
1024
- })
1025
-
1026
- codeHTML += `<span style="display:block;">${lineHTML}\n</span>`
1027
- })
1028
-
1029
- const html =
1030
- `<pre style="background-color:${bgColor};padding:1em;border-radius:4px;` +
1031
- `border-left:3px solid #4caf50;overflow-x:auto;font-family:monospace;` +
1032
- `white-space:pre;margin:0.5em 0;">` +
1033
- `<code style="display:block;">${codeHTML}</code></pre>`
1034
-
1035
- blocks.push([idx, html])
1036
- } catch (e) {
1037
- blocks.push([idx, ''])
1038
- }
1039
-
1040
- idx++
1041
- })
1042
-
1043
- return blocks
1044
- })
1045
-
1046
- return new Map(result)
1047
- }
1048
-
1049
- /**
1050
- * Screenshots every <lia-abcjs> music-notation element
1051
- *
1052
- * @param page - Puppeteer page instance
1053
- * @returns Map of ABC block indexes to base64 PNG data URIs
1054
- */
1055
- async function screenshotAbcBlocks(page: Page): Promise<Map<number, string>> {
1056
- const abcImages = new Map<number, string>()
1057
-
1058
- // Tag each .lia-code-terminal that wraps a <lia-abcjs> element
1059
- const abcCount = await page.evaluate(() => {
1060
- let abcIndex = 0
1061
- document
1062
- .querySelectorAll('.lia-code-terminal')
1063
- .forEach((terminal: Element) => {
1064
- if (terminal.querySelector('lia-abcjs')) {
1065
- terminal.setAttribute('data-abc-index', abcIndex.toString())
1066
- abcIndex++
1067
- }
1068
- })
1069
- return abcIndex
1070
- })
1071
-
1072
- // Find each <lia-abcjs> element and capture a screenshot of its rendered SVG content
1073
- for (let i = 0; i < abcCount; i++) {
1074
- try {
1075
- const elements = await page.$$('lia-abcjs')
1076
- const el = elements[i]
1077
- if (!el) continue
1078
-
1079
- const svgDataUri = await page.evaluate((host: Element) => {
1080
- const svg = host.shadowRoot
1081
- ?.getElementById('paper')
1082
- ?.querySelector('svg')
1083
- if (!svg) return null
1084
- const svgString = new XMLSerializer().serializeToString(svg)
1085
- return 'data:image/svg+xml;base64,' + btoa(svgString)
1086
- }, el)
1087
-
1088
- if (svgDataUri) {
1089
- abcImages.set(i, svgDataUri)
1090
- }
1091
- } catch (e) {
1092
- console.error(`Failed to convert ABC block ${i}:`, e)
1093
- }
1094
- }
1095
-
1096
- return abcImages
1097
- }
1098
-
1099
- /**
1100
- * Extracts SVG from standalone <lia-abcjs> elements (NOT inside .lia-code-terminal).
1101
- * These come from imported template macros like @ABCJS.render.
1102
- *
1103
- * @param page - Puppeteer page instance
1104
- * @returns Map of standalone ABC indexes to base64 SVG data URIs
1105
- */
1106
- async function screenshotStandaloneAbcBlocks(
1107
- page: Page,
1108
- ): Promise<Map<number, string>> {
1109
- const standaloneAbcImages = new Map<number, string>()
1110
-
1111
- // Tag each <lia-abcjs> that is NOT inside a .lia-code-terminal
1112
- const count = await page.evaluate(() => {
1113
- let idx = 0
1114
- document.querySelectorAll('lia-abcjs').forEach((el: Element) => {
1115
- if (!el.closest('.lia-code-terminal')) {
1116
- el.setAttribute('data-standalone-abc-index', idx.toString())
1117
- idx++
1118
- }
1119
- })
1120
- return idx
1121
- })
1122
-
1123
- // Extract SVG from each standalone <lia-abcjs> element's Shadow DOM
1124
- for (let i = 0; i < count; i++) {
1125
- try {
1126
- const el = await page.$(`lia-abcjs[data-standalone-abc-index="${i}"]`)
1127
- if (!el) continue
1128
-
1129
- const svgDataUri = await page.evaluate((host: Element) => {
1130
- const svg = host.shadowRoot
1131
- ?.getElementById('paper')
1132
- ?.querySelector('svg')
1133
- if (!svg) return null
1134
- const svgString = new XMLSerializer().serializeToString(svg)
1135
- return 'data:image/svg+xml;base64,' + btoa(svgString)
1136
- }, el)
1137
-
1138
- if (svgDataUri) {
1139
- standaloneAbcImages.set(i, svgDataUri)
1140
- }
1141
- } catch (e) {
1142
- console.error(`Failed to convert standalone ABC block ${i}:`, e)
1143
- }
1144
- }
1145
-
1146
- return standaloneAbcImages
1147
- }
1148
-
1149
- /**
1150
- * Extracts SVG from every <lia-chart> element (ECharts, open Shadow DOM)
1151
- *
1152
- * @param page - Puppeteer page instance
1153
- * @returns Map of chart indexes to base64 SVG data URIs
1154
- */
1155
- async function extractChartSvgs(page: Page): Promise<Map<number, string>> {
1156
- const count = await page.evaluate(() => {
1157
- let idx = 0
1158
- document.querySelectorAll('lia-chart').forEach((el: Element) => {
1159
- el.setAttribute('data-chart-index', idx.toString())
1160
- idx++
1161
- })
1162
- return idx
1163
- })
1164
-
1165
- const images = new Map<number, string>()
1166
- const elements = await page.$$('lia-chart')
1167
-
1168
- for (let i = 0; i < count; i++) {
1169
- try {
1170
- const el = elements[i]
1171
- if (!el) continue
1172
-
1173
- const svgDataUri = await page.evaluate((host: Element) => {
1174
- const svg = host.shadowRoot?.querySelector('svg')
1175
- if (!svg) return null
1176
- const svgString = new XMLSerializer().serializeToString(svg)
1177
- return 'data:image/svg+xml;base64,' + btoa(svgString)
1178
- }, el)
1179
-
1180
- if (svgDataUri) {
1181
- images.set(i, svgDataUri)
1182
- }
1183
- } catch (e) {
1184
- console.error(`Failed to extract chart ${i}:`, e)
1185
- }
1186
- }
1187
-
1188
- return images
1189
- }
1190
-
1191
- /**
1192
- * Extracts rendered KaTeX HTML from every <lia-formula> element (open Shadow DOM)
1193
- *
1194
- * @param page - Puppeteer page instance
1195
- * @returns Map of formula indexes to rendered KaTeX HTML strings
1196
- */
1197
- async function extractFormulaHtml(page: Page): Promise<Map<number, string>> {
1198
- const result = await page.evaluate(() => {
1199
- const formulas: [number, string][] = []
1200
- let idx = 0
1201
-
1202
- document.querySelectorAll('lia-formula').forEach((el: Element) => {
1203
- el.setAttribute('data-formula-index', idx.toString())
1204
-
1205
- try {
1206
- const shadow = el.shadowRoot
1207
- if (!shadow) {
1208
- formulas.push([idx, ''])
1209
- idx++
1210
- return
1211
- }
1212
-
1213
- // Collect all <style> content from the Shadow DOM
1214
- let styleHTML = ''
1215
- shadow.querySelectorAll('style').forEach((s: Element) => {
1216
- styleHTML += `<style>${s.textContent || ''}</style>`
1217
- })
1218
-
1219
- // Get the rendered KaTeX span
1220
- const span = shadow.querySelector('span')
1221
- if (!span) {
1222
- formulas.push([idx, ''])
1223
- idx++
1224
- return
1225
- }
1226
-
1227
- const spanClone = span.cloneNode(true) as HTMLElement
1228
- const mathml = spanClone.querySelector('.katex-mathml')
1229
-
1230
- if (mathml) {
1231
- // Extract the <math> element from inside .katex-mathml
1232
- const mathEl = mathml.querySelector('math')
1233
- formulas.push([idx, mathEl ? mathEl.outerHTML : spanClone.outerHTML])
1234
- } else {
1235
- // Fallback: strip MathML and use visual HTML if no MathML found
1236
- spanClone.querySelectorAll('.katex-mathml').forEach((m) => m.remove())
1237
- formulas.push([idx, styleHTML + spanClone.outerHTML])
1238
- }
1239
- } catch (e) {
1240
- formulas.push([idx, ''])
1241
- }
1242
-
1243
- idx++
1244
- })
1245
-
1246
- return formulas
1247
- })
1248
-
1249
- return new Map(result)
1250
- }
1251
-
1252
- /**
1253
- * Tags matching elements with a data attribute index, then screenshots each one
1254
- *
1255
- * @param page - Puppeteer page instance
1256
- * @param selector - CSS selector to find elements
1257
- * @param dataAttr - data-attribute name used for indexing (e.g. 'data-svg-index')
1258
- * @param label - human-readable label for error messages
1259
- * @param tagFilter - optional filter run inside page.evaluate(); receives each
1260
- * matched element and returns true to tag it
1261
- * @returns Map of element indexes to base64 PNG data URIs
1262
- */
1263
- async function screenshotTaggedElements(
1264
- page: Page,
1265
- selector: string,
1266
- dataAttr: string,
1267
- label: string,
1268
- tagFilter?: string, // JS function body: (el) => boolean, serialised as string
1269
- ): Promise<Map<number, string>> {
1270
- const count = await page.evaluate(
1271
- (sel: string, attr: string, filterBody: string | null) => {
1272
- let idx = 0
1273
- const filterFn = filterBody
1274
- ? (new Function('el', filterBody) as (el: Element) => boolean)
1275
- : null
1276
- document.querySelectorAll(sel).forEach((el: Element) => {
1277
- if (!filterFn || filterFn(el)) {
1278
- el.setAttribute(attr, idx.toString())
1279
- idx++
1280
- }
1281
- })
1282
- return idx
1283
- },
1284
- selector,
1285
- dataAttr,
1286
- tagFilter ?? null,
1287
- )
1288
-
1289
- const images = new Map<number, string>()
1290
- for (let i = 0; i < count; i++) {
1291
- try {
1292
- const el = await page.$(`${selector}[${dataAttr}="${i}"]`)
1293
- if (el) {
1294
- const screenshot = await el.screenshot({ type: 'png' })
1295
- images.set(
1296
- i,
1297
- `data:image/png;base64,${Buffer.from(screenshot).toString('base64')}`,
1298
- )
1299
- }
1300
- } catch (e) {
1301
- console.error(`Failed to screenshot ${label} ${i}:`, e)
1302
- }
1303
- }
1304
-
1305
- return images
1306
- }