Skip to content

Commit 7a3f0c5

Browse files
PDF: module improvement
1 parent 89ed1d2 commit 7a3f0c5

1 file changed

Lines changed: 198 additions & 114 deletions

File tree

src/export/pdf.ts

Lines changed: 198 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
import * as helper from './helper'
2-
32
import * as COLOR from '../colorize'
4-
const path = require('path')
5-
import puppeteer from 'puppeteer'
3+
import * as path from 'path'
4+
import puppeteer, { Browser, Page } from 'puppeteer'
5+
6+
// Default PDF generation settings
7+
const DEFAULT_TIMEOUT_MS = 60000 // 60 seconds
8+
const DEFAULT_MARGIN_TOP = 80
9+
const DEFAULT_MARGIN_BOTTOM = 80
10+
const DEFAULT_MARGIN_LEFT = 30
11+
const DEFAULT_MARGIN_RIGHT = 30
12+
const DEFAULT_SCALE = 1
13+
const DEFAULT_FORMAT = 'a4'
14+
const DEFAULT_PRINT_BACKGROUND = true
15+
const DEFAULT_DISPLAY_HEADER_FOOTER = false
16+
const DEFAULT_LANDSCAPE = false
17+
const DEFAULT_OMIT_BACKGROUND = false
618

19+
/**
20+
* Displays help information about PDF export options and settings.
21+
* Shows both LiaScript-specific settings and Puppeteer PDF options.
22+
*/
723
export function help() {
824
console.log('')
925
console.log(COLOR.heading('PDF settings:'), '\n')
@@ -27,7 +43,7 @@ export function help() {
2743
COLOR.command(
2844
null,
2945
'--pdf-timeout',
30-
' Set an additional time horizon to wait until finished.'
46+
` Set an additional time horizon to wait until finished (default ${DEFAULT_TIMEOUT_MS} ms)`
3147
)
3248
COLOR.command(
3349
null,
@@ -125,18 +141,17 @@ export function help() {
125141
)
126142
}
127143

128-
export async function exporter(argument: {
144+
/**
145+
* Configuration options for PDF export.
146+
*/
147+
interface PdfExportArguments {
129148
input: string
130-
readme: string
131149
output: string
132-
format: string
133-
path: string
134-
key?: string
135150

136-
// special cases for PDF
151+
// PDF-specific settings
137152
'pdf-preview'?: boolean
138153
'pdf-scale'?: number
139-
'pdf-displayHeaderFooter'?: string
154+
'pdf-displayHeaderFooter'?: boolean
140155
'pdf-headerTemplate'?: string
141156
'pdf-footerTemplate'?: string
142157
'pdf-printBackground'?: boolean
@@ -151,10 +166,32 @@ export async function exporter(argument: {
151166
'pdf-preferCSSPageSize'?: boolean
152167
'pdf-omitBackground'?: boolean
153168
'pdf-timeout'?: number
169+
'pdf-pageRanges'?: string
154170

155171
'pdf-stylesheet'?: string
156172
'pdf-theme'?: string
157-
}) {
173+
}
174+
175+
/**
176+
* Exports a LiaScript course to PDF format using Puppeteer.
177+
*
178+
* This function launches a headless Chrome browser, loads the LiaScript content,
179+
* applies any custom styling or themes, and generates a PDF file.
180+
*
181+
* @param argument - Configuration options for the PDF export
182+
* @throws {Error} If browser launch fails, page navigation fails, or PDF generation fails
183+
*
184+
* @example
185+
* ```typescript
186+
* await exporter({
187+
* input: './course.md',
188+
* output: './output',
189+
* 'pdf-format': 'a4',
190+
* 'pdf-printBackground': true
191+
* })
192+
* ```
193+
*/
194+
export async function exporter(argument: PdfExportArguments) {
158195
const dirname = helper.dirname()
159196

160197
let url = `file://${dirname}/assets/pdf/index.html?`
@@ -165,132 +202,179 @@ export async function exporter(argument: {
165202
url += 'file://' + path.resolve(argument.input)
166203
}
167204

168-
const browser = await puppeteer.launch({
169-
pipe: true,
170-
args: [
171-
'--no-sandbox',
172-
'--disable-web-security',
173-
'--disable-features=IsolateOrigins',
174-
'--disable-site-isolation-trials',
175-
'--unhandled-rejections=strict',
176-
'--disable-features=BlockInsecurePrivateNetworkRequests',
177-
'--allow-file-access-from-files',
178-
'--enable-local-file-accesses',
179-
'--enable-features=ExperimentalJavaScript',
180-
],
181-
headless: argument['pdf-preview'] ? false : true,
182-
// Try to use the system Chrome if available
183-
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
184-
// Add this to use the installed browser if no executable path is provided
185-
channel: process.env.PUPPETEER_EXECUTABLE_PATH ? undefined : 'chrome',
186-
})
187-
const page = await browser.newPage()
205+
let browser: Browser | null = null
206+
let page: Page | null = null
188207

189-
console.warn(
190-
'depending on the size of the course, this can take a while, please be patient...'
191-
)
208+
try {
209+
// Configure browser launch options
210+
const launchOptions: any = {
211+
pipe: true,
212+
args: [
213+
'--no-sandbox',
214+
'--disable-web-security',
215+
'--disable-features=IsolateOrigins',
216+
'--disable-site-isolation-trials',
217+
'--unhandled-rejections=strict',
218+
'--disable-features=BlockInsecurePrivateNetworkRequests',
219+
'--allow-file-access-from-files',
220+
'--enable-local-file-accesses',
221+
'--enable-features=ExperimentalJavaScript',
222+
],
223+
headless: !argument['pdf-preview'],
224+
}
192225

193-
// this handle the alert - boxes, so that these are not blocking
194-
page.on('dialog', async (dialog) => {
195-
console.log(dialog.type())
196-
console.log(dialog.message())
197-
await dialog.accept()
198-
})
226+
// Use custom executable path if provided, otherwise use Chrome channel
227+
if (process.env.PUPPETEER_EXECUTABLE_PATH) {
228+
launchOptions.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH
229+
} else {
230+
launchOptions.channel = 'chrome'
231+
}
199232

200-
try {
233+
try {
234+
browser = await puppeteer.launch(launchOptions)
235+
} catch (launchError) {
236+
throw new Error(
237+
`Failed to launch browser. Make sure Chrome is installed. ${launchError}`
238+
)
239+
}
240+
page = await browser.newPage()
241+
242+
console.log(
243+
'Loading course content... This may take a while for large courses.'
244+
)
245+
246+
// Handle alert dialogs automatically to prevent blocking
247+
page.on('dialog', async (dialog) => {
248+
console.log(`[Dialog ${dialog.type()}]: ${dialog.message()}`)
249+
await dialog.accept()
250+
})
251+
252+
// Wait for page to load completely
253+
// Using 'networkidle2' ensures all network requests are complete
254+
// Timeout set to 0 (unlimited) to handle large courses
201255
await page.goto(url, {
202256
waitUntil: 'networkidle2',
203-
// remove timeout
204-
timeout: 0,
257+
timeout: DEFAULT_TIMEOUT_MS,
205258
})
206-
} catch (e) {
207-
console.warn('pdf generation failed:', e)
208-
}
209259

210-
if (argument['pdf-stylesheet']) {
211-
const href = path.resolve(dirname + '/../', argument['pdf-stylesheet'])
260+
if (argument['pdf-stylesheet']) {
261+
const href = path.resolve(dirname + '/../', argument['pdf-stylesheet'])
212262

213-
await page.evaluate(async (href) => {
214-
const link = document.createElement('link')
215-
link.rel = 'stylesheet'
216-
link.href = href
263+
try {
264+
await page.evaluate(async (href) => {
265+
const link = document.createElement('link')
266+
link.rel = 'stylesheet'
267+
link.href = href
217268

218-
const promise = new Promise((resolve, reject) => {
219-
link.onload = resolve
220-
link.onerror = reject
221-
})
222-
document.head.appendChild(link)
223-
await promise
224-
}, href)
225-
}
269+
const promise = new Promise((resolve, reject) => {
270+
link.onload = resolve
271+
link.onerror = reject
272+
})
273+
document.head.appendChild(link)
274+
await promise
275+
}, href)
276+
} catch (e) {
277+
throw new Error(
278+
`Failed to load custom stylesheet from '${argument['pdf-stylesheet']}': ${e}`
279+
)
280+
}
281+
}
226282

227-
if (argument['pdf-theme']) {
228-
await page.evaluate(async (theme) => {
229-
document.documentElement.classList.remove('lia-theme-default')
230-
document.documentElement.classList.add('lia-theme-' + theme)
231-
}, argument['pdf-theme'])
232-
}
283+
if (argument['pdf-theme']) {
284+
try {
285+
await page.evaluate(async (theme) => {
286+
document.documentElement.classList.remove('lia-theme-default')
287+
document.documentElement.classList.add('lia-theme-' + theme)
288+
}, argument['pdf-theme'])
289+
} catch (e) {
290+
throw new Error(
291+
`Failed to apply theme '${argument['pdf-theme']}': ${e}`
292+
)
293+
}
294+
}
233295

234-
/*
235-
await page.evaluate(async () => {
236-
const style = document.createElement('style')
237-
style.type = 'text/css'
238-
const content = `
239-
:root {
240-
241-
--color-highlight: 2,255,0;
242-
--color-background: 122,122,122;
243-
--color-border: 0,0,0;
244-
--color-highlight-dark: 0,0,0;
245-
--color-highlight-menu: 0,0,0;
246-
--color-text: 0,0,255;
247-
--global-font-size: 1rem;
248-
--font-size-multiplier: 2;
296+
if (!argument['pdf-preview']) {
297+
await helper.sleep(argument['pdf-timeout'] || DEFAULT_TIMEOUT_MS)
298+
await toPDF(argument, page)
299+
} else {
300+
console.log('Preview mode enabled - browser will remain open')
249301
}
250-
`
251-
style.appendChild(document.createTextNode(content))
252-
const promise = new Promise((resolve, reject) => {
253-
style.onload = resolve
254-
style.onerror = reject
255-
})
256-
document.head.appendChild(style)
257-
await promise
258-
})
302+
} catch (e) {
303+
const error = e as Error
304+
console.error('PDF export failed:', error.message)
305+
throw new Error(`Failed to export PDF: ${error.message}`)
306+
} finally {
307+
// Clean up resources based on mode
308+
if (argument['pdf-preview']) {
309+
// In preview mode, keep browser open but inform user
310+
console.log('Browser kept open for preview. Close manually when done.')
311+
} else {
312+
// In normal mode, always close browser and page
313+
if (page) {
314+
try {
315+
await page.close()
316+
} catch (closeError) {
317+
console.error('Failed to close page:', closeError)
318+
}
319+
}
259320

260-
console.warn(argument)
261-
*/
262-
if (!argument['pdf-preview']) {
263-
await helper.sleep(argument['pdf-timeout'] || 60000)
264-
await toPDF(argument, browser, page)
321+
if (browser) {
322+
try {
323+
await browser.close()
324+
} catch (closeError) {
325+
console.error('Failed to close browser:', closeError)
326+
}
327+
}
328+
}
265329
}
266330
}
267331

268-
async function toPDF(argument: any, browser: any, page: any) {
332+
/**
333+
* Generates a PDF file from a Puppeteer page.
334+
*
335+
* Emulates screen media type for proper rendering and applies all PDF-specific
336+
* settings like margins, format, scaling, etc.
337+
*
338+
* @param argument - PDF export configuration options
339+
* @param page - Puppeteer page instance containing the rendered content
340+
* @throws {Error} If PDF generation fails or media type emulation fails
341+
*/
342+
async function toPDF(argument: PdfExportArguments, page: Page) {
269343
try {
270344
await page.emulateMediaType('screen')
345+
} catch (e) {
346+
throw new Error(`Failed to emulate media type: ${e}`)
347+
}
348+
349+
try {
271350
await page.pdf({
272351
path: argument.output + '.pdf',
273-
format: argument['pdf-format'] || 'a4',
274-
printBackground: argument['pdf-printBackground'] || true,
275-
displayHeaderFooter: argument['pdf-displayHeaderFooter'] || false,
352+
format: (argument['pdf-format'] as any) || DEFAULT_FORMAT,
353+
printBackground:
354+
argument['pdf-printBackground'] ?? DEFAULT_PRINT_BACKGROUND,
355+
displayHeaderFooter:
356+
argument['pdf-displayHeaderFooter'] ?? DEFAULT_DISPLAY_HEADER_FOOTER,
276357
margin: {
277-
top: argument['pdf-margin-top'] || 80,
278-
bottom: argument['pdf-margin-bottom'] || 80,
279-
left: argument['pdf-margin-left'] || 30,
280-
right: argument['pdf-margin-right'] || 30,
358+
top: argument['pdf-margin-top'] || DEFAULT_MARGIN_TOP,
359+
bottom: argument['pdf-margin-bottom'] || DEFAULT_MARGIN_BOTTOM,
360+
left: argument['pdf-margin-left'] || DEFAULT_MARGIN_LEFT,
361+
right: argument['pdf-margin-right'] || DEFAULT_MARGIN_RIGHT,
281362
},
282-
scale: argument['pdf-scale'] || 1,
363+
scale: argument['pdf-scale'] ?? DEFAULT_SCALE,
283364
headerTemplate: argument['pdf-headerTemplate'],
284-
footerTemplate: argument['pdf-footerTemplate'] || '',
285-
landscape: argument['pdf-landscape'] || false,
286-
width: argument['pdf-width'] || '',
287-
height: argument['pdf-height'] || '',
288-
//preferCSSPageSize: argument['pdf-preferCSSPageSize'] || '',
289-
omitBackground: argument['pdf-omitBackground'] || false,
365+
footerTemplate: argument['pdf-footerTemplate'] ?? '',
366+
landscape: argument['pdf-landscape'] ?? DEFAULT_LANDSCAPE,
367+
width: argument['pdf-width'] ?? '',
368+
height: argument['pdf-height'] ?? '',
369+
pageRanges: argument['pdf-pageRanges'],
370+
preferCSSPageSize: argument['pdf-preferCSSPageSize'],
371+
omitBackground: argument['pdf-omitBackground'] ?? DEFAULT_OMIT_BACKGROUND,
290372
})
373+
console.log(`PDF successfully generated: ${argument.output}.pdf`)
291374
} catch (e) {
292-
console.warn('failed to print to pdf', e)
375+
const error = e as Error
376+
throw new Error(
377+
`Failed to generate PDF at '${argument.output}.pdf': ${error.message}`
378+
)
293379
}
294-
295-
await browser.close()
296380
}

0 commit comments

Comments
 (0)