const puppeteer = require('puppeteer'); const fs = require('fs'); const path = require('path'); function resolveChromeExecutable () { const fromEnv = (process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || '').trim(); if (fromEnv) { try { if (fs.existsSync(fromEnv)) return fromEnv; } catch (_e) { /* ignore */ } console.warn( '[export] Ignoring PUPPETEER_EXECUTABLE_PATH / CHROME_PATH (not an existing file): ' + fromEnv ); } const candidates = []; if (process.platform === 'darwin') { candidates.push( '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', '/Applications/Arc.app/Contents/MacOS/Arc', '/Applications/Vivaldi.app/Contents/MacOS/Vivaldi', '/Applications/Chromium.app/Contents/MacOS/Chromium' ); } else if (process.platform === 'linux') { candidates.push( '/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/microsoft-edge-stable', '/snap/bin/chromium' ); } else if (process.platform === 'win32') { const programFiles = process.env['PROGRAMFILES'] || 'C:\\Program Files'; const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)'; candidates.push( path.join(programFiles, 'Google/Chrome/Application/chrome.exe'), path.join(programFilesX86, 'Google/Chrome/Application/chrome.exe'), path.join(programFiles, 'Microsoft/Edge/Application/msedge.exe') ); } for (const p of candidates) { try { if (p && fs.existsSync(p)) return p; } catch (_e) { /* ignore */ } } return ''; } const http = require('http'); const config = require('../config'); const { getResumeSlug } = require('./resumeSlug'); const devPort = process.env.PORT || config.dev.port; const { interval } = require('rxjs'); const { filter, first, mergeMap } = require('rxjs/operators'); const fetchResponse = () => { return new Promise((res, rej) => { try { const req = http.request(`http://localhost:${devPort}/#/`, response => res(response.statusCode)); req.on('error', (err) => rej(err)); req.end(); } catch (err) { rej(err); } }); }; const waitForServerReachable = () => { return interval(1000).pipe( mergeMap(async () => { try { const statusCode = await fetchResponse(); if (statusCode === 200) return true; } catch (err) {} return false; }), filter(ok => !!ok) ); }; const baseLaunchOpts = () => ({ headless: true, args: ['--no-sandbox', '--font-render-hinting=none'] }); /** * PDF export needs a headless Chromium engine to run the Vue app and call page.pdf(). * Try several launch strategies so a typical Mac (Edge-only, Arc-only, etc.) still works. */ async function launchPuppeteerBrowser () { const base = baseLaunchOpts(); const attempts = []; const resolved = resolveChromeExecutable(); if (resolved) { attempts.push({ ...base, executablePath: resolved }); } const preferredChannel = (process.env.PUPPETEER_CHANNEL || '').trim(); const channelOrder = preferredChannel ? [preferredChannel] : ['msedge', 'chrome', 'chrome-beta', 'chrome-canary']; for (const ch of channelOrder) { attempts.push({ ...base, channel: ch }); } let bundled = ''; try { if (typeof puppeteer.executablePath === 'function') { bundled = puppeteer.executablePath(); } } catch (_e) { bundled = ''; } if (bundled && fs.existsSync(bundled) && bundled !== resolved) { attempts.push({ ...base, executablePath: bundled }); } attempts.push({ ...base }); const errors = []; for (const opts of attempts) { try { const browser = await puppeteer.launch(opts); const how = opts.executablePath ? ('executablePath: ' + opts.executablePath) : (opts.channel ? ('channel: ' + opts.channel) : 'puppeteer default'); console.log('[export] Using browser — ' + how); return browser; } catch (e) { errors.push(e && e.message ? e.message : String(e)); } } console.error('[export] All browser launch attempts failed:\n - ' + errors.join('\n - ')); throw new Error( 'No Chromium-based browser available for PDF export. Install Google Chrome or Microsoft Edge, ' + 'or run: npx puppeteer browsers install chrome' ); } /* const timedOut = timeout => { return new Promise(res => { setTimeout(res, timeout); }); }; */ const convert = async () => { await waitForServerReachable().pipe( first() ).toPromise(); console.log('Connected to server ...'); console.log('Exporting ...'); try { const fullDirectoryPath = path.join(__dirname, '../pdf/'); const dataSlug = getResumeSlug(); let directories = getResumesFromDirectories(); const resumeFilterRaw = (process.env.EXPORT_RESUME || process.argv[2] || '').trim(); const resumeFilter = resumeFilterRaw.replace(/\.vue$/i, '').toLowerCase(); if (resumeFilter) { directories = directories.filter(d => d.name.toLowerCase() === resumeFilter); if (directories.length === 0) { console.error( `No resume template "${resumeFilterRaw}". Expected a name like "green" (see src/resumes/*.vue).` ); process.exit(1); } console.log('Resume filter: ' + directories.map(d => d.name).join(', ')); } console.log('Resume data (RESUME_NAME): ' + dataSlug); if (!fs.existsSync(fullDirectoryPath)) { fs.mkdirSync(fullDirectoryPath); } const browser = await launchPuppeteerBrowser(); try { for (const dir of directories) { const page = await browser.newPage(); try { await page.goto(`http://localhost:${devPort}/#/resume/` + dir.name, { waitUntil: 'load', timeout: 120000 }); try { await page.evaluate(() => document.fonts.ready); } catch (_err) { /* ignore if fonts API missing */ } await page.emulateMediaType('print'); await new Promise((r) => setTimeout(r, 300)); let pdfScale = 1; const rawScale = process.env.PDF_SCALE; if (rawScale !== undefined && String(rawScale).trim() !== '') { const n = parseFloat(String(rawScale), 10); if (Number.isFinite(n) && n >= 0.1 && n <= 2) { pdfScale = n; } } await page.pdf({ path: fullDirectoryPath + dir.name + '-' + dataSlug + '.pdf', format: 'A4', printBackground: true, margin: { top: '0', bottom: '0', left: '0', right: '0' }, scale: pdfScale }); } finally { await page.close(); } } } finally { await browser.close(); } } catch (err) { throw err instanceof Error ? err : new Error(String(err)); } console.log('Finished exports.'); }; const getResumesFromDirectories = () => { const directories = getDirectories(); return directories .map(dir => { const fileName = dir.replace('.vue', ''); return { path: fileName, name: fileName }; }); }; const getDirectories = () => { const srcpath = path.join(__dirname, '../src/resumes'); return fs.readdirSync(srcpath) .filter(file => file !== 'resumes.js' && file !== 'template.vue' && file !== 'options.js'); }; convert();