#!/usr/bin/env node /* ========================================================= manuales-factorpesca / scripts/build-manual.js Convierte templates/manual.html en un PDF Letter profesional. Uso: node scripts/build-manual.js node scripts/build-manual.js --html ruta/al/manual.html node scripts/build-manual.js --out dist/manual-v1.0.0.pdf node scripts/build-manual.js --slug capturas-app Por defecto busca el HTML en templates/manual.html y escribe el PDF en dist/manual.pdf. ========================================================= */ 'use strict'; const path = require('node:path'); const fs = require('node:fs'); const os = require('node:os'); const SKILL_DIR = path.resolve(__dirname, '..'); const DEFAULT_HTML = path.join(SKILL_DIR, 'templates', 'manual.html'); const DEFAULT_OUT_DIR = path.join(SKILL_DIR, 'dist'); const ARTIFACTS_DIR = path.join(SKILL_DIR, 'artifacts'); function parseArgs(argv) { const out = { html: DEFAULT_HTML, outDir: DEFAULT_OUT_DIR, outFile: null, printBackground: true, landscape: false, marginTop: '0', marginBottom: '0', marginLeft: '0', marginRight: '0', preferCSSPageSize: true, displayHeaderFooter: false, }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; switch (a) { case '--html': out.html = path.resolve(argv[++i]); break; case '--out': out.outFile = path.resolve(argv[++i]); break; case '--out-dir': out.outDir = path.resolve(argv[++i]); break; case '--no-background': out.printBackground = false; break; case '--landscape': out.landscape = true; break; case '--margin-top': out.marginTop = argv[++i]; break; case '--margin-bottom': out.marginBottom = argv[++i]; break; case '--margin-left': out.marginLeft = argv[++i]; break; case '--margin-right': out.marginRight = argv[++i]; break; case '--no-css-page-size': out.preferCSSPageSize = false; break; case '--show-header-footer': out.displayHeaderFooter = true; break; case '--help': case '-h': printHelp(); process.exit(0); break; default: console.warn(`[build-manual] argumento desconocido ignorado: ${a}`); } } return out; } function printHelp() { console.log(`Uso: node build-manual.js [opciones] Opciones: --html Ruta al HTML fuente. Por defecto templates/manual.html --out Ruta completa del PDF de salida. --out-dir Carpeta de salida. Por defecto dist/. --no-background No imprimir colores de fondo. --landscape Orientación horizontal. --margin-top Margen superior CSS (p. ej. 22mm). --margin-bottom Margen inferior CSS. --margin-left Margen izquierdo CSS. --margin-right Margen derecho CSS. --no-css-page-size Ignorar @page del CSS y usar los márgenes dados. --show-header-footer Habilita la cabecera/pie de Chromium (no usado). -h, --help Muestra esta ayuda. `); } function ensurePlaywright() { try { return require('playwright'); } catch (err) { console.error(`[build-manual] No se encontró 'playwright' en el PATH. Instálalo ejecutando: npm install playwright o usa la skill playwright-skill de lackeyjb para preparar el entorno: npx skills add lackeyjb/playwright-skill --skill playwright-skill --yes `); process.exit(1); } } function buildPdfUrl(htmlPath) { const abs = path.resolve(htmlPath); if (!fs.existsSync(abs)) { console.error(`[build-manual] No se encuentra el HTML: ${abs}`); process.exit(1); } return 'file://' + abs; } function copyArtifactsIntoTemp(htmlPath) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'manual-build-')); const htmlDst = path.join(tmp, path.basename(htmlPath)); fs.copyFileSync(htmlPath, htmlDst); const cssSrc = path.join(path.dirname(htmlPath), 'manual.css'); const jsSrc = path.join(path.dirname(htmlPath), 'manual.js'); if (fs.existsSync(cssSrc)) fs.copyFileSync(cssSrc, path.join(tmp, 'manual.css')); if (fs.existsSync(jsSrc)) fs.copyFileSync(jsSrc, path.join(tmp, 'manual.js')); if (fs.existsSync(ARTIFACTS_DIR)) { copyDir(ARTIFACTS_DIR, path.join(tmp, 'artifacts')); console.log(`[build-manual] Capturas copiadas a ${path.join(tmp, 'artifacts')}`); } return tmp; } function copyDir(src, dst) { if (!fs.existsSync(src)) return; fs.mkdirSync(dst, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const s = path.join(src, entry.name); const d = path.join(dst, entry.name); if (entry.isDirectory()) copyDir(s, d); else fs.copyFileSync(s, d); } } function defaultOutFile(htmlPath) { const html = fs.readFileSync(htmlPath, 'utf8'); const base = path.basename(htmlPath, path.extname(htmlPath)); const versionMatch = html.match(/data-meta-version[^>]*>([^<]+)/); const version = (versionMatch ? versionMatch[1].trim() : '0.0.0').replace(/^v/i, '').replace(/[^a-zA-Z0-9._-]/g, '') || '0.0.0'; const slug = base.replace(/^manual[-_]?/, '').replace(/[^a-zA-Z0-9._-]/g, '') || 'app'; return `manual-${slug}-v${version}.pdf`; } async function waitForFonts(page) { try { await page.evaluate(() => document.fonts && document.fonts.ready); } catch (_) {} } async function main() { const args = parseArgs(process.argv); const playwright = ensurePlaywright(); const { chromium } = playwright; if (!fs.existsSync(args.html)) { console.error(`[build-manual] HTML no encontrado: ${args.html}`); process.exit(1); } fs.mkdirSync(args.outDir, { recursive: true }); const outFile = args.outFile || path.join(args.outDir, defaultOutFile(args.html)); const assetsTmp = copyArtifactsIntoTemp(args.html); const url = 'file://' + path.join(assetsTmp, path.basename(args.html)); console.log(`[build-manual] Renderizando: ${args.html}`); console.log(`[build-manual] Salida: ${outFile}`); const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); try { const context = await browser.newContext({ viewport: { width: 1100, height: 1400 }, deviceScaleFactor: 2, }); const page = await context.newPage(); page.on('pageerror', (e) => console.error('[page error]', e)); page.on('console', (msg) => { if (msg.type() === 'error') console.error('[page console]', msg.text()); }); await page.goto(url, { waitUntil: 'networkidle', timeout: 60_000 }); await waitForFonts(page); await page.waitForTimeout(400); const pdfOptions = { path: outFile, printBackground: args.printBackground, preferCSSPageSize: args.preferCSSPageSize, displayHeaderFooter: args.displayHeaderFooter, }; if (!args.preferCSSPageSize) { pdfOptions.format = args.landscape ? { width: '11in', height: '8.5in' } : { width: '8.5in', height: '11in' }; pdfOptions.margin = { top: args.marginTop, bottom: args.marginBottom, left: args.marginLeft, right: args.marginRight, }; } await page.pdf(pdfOptions); const stat = fs.statSync(outFile); console.log(`[build-manual] PDF generado: ${outFile} (${(stat.size / 1024).toFixed(1)} KB)`); } finally { await browser.close(); if (assetsTmp) { try { fs.rmSync(assetsTmp, { recursive: true, force: true }); } catch (_) {} } } } main().catch((err) => { console.error('[build-manual] Error inesperado:', err); process.exit(1); });