// Genera un PDF 16:9 de un deck construido con el sistema sd. // // El deck es una web con animaciones que revelan contenido al entrar en // viewport (motion `whileInView`), un canvas de gravedad y un vídeo, así que un // "imprimir a PDF" plano dejaría medias slides en blanco. Aquí abrimos el deck // con un Chrome headless, pasamos el portal de contraseña, alineamos cada slide // a pantalla completa, esperamos a que su animación termine y capturamos un // fotograma. Luego componemos las capturas en un PDF, una slide por página. // // Uso: node scripts/deck-to-pdf.mjs [http://localhost:3000] [salida.pdf] import { readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; import puppeteer from "puppeteer-core"; const BASE = (process.argv[2] || "http://localhost:3000").replace(/\/$/, ""); const OUT = resolve(process.argv[3] || "deck.pdf"); const WORK = resolve(".pdf-build"); const WIDTH = 1920; const HEIGHT = 1080; const SCALE = 2; // nitidez retina en las capturas // Margen para que el orbe/canvas pinte un fotograma y todo asiente. Con // prefers-reduced-motion los reveals ya son instantáneos, así que basta poco. const SETTLE_MS = 1200; // ── Contraseña del portal (solo local, desde .env.local, nunca al bundle) ── function readSitePassword() { if (process.env.SITE_PASSWORD?.trim()) return process.env.SITE_PASSWORD.trim(); try { const raw = readFileSync(resolve(".env.local"), "utf8"); for (const line of raw.split(/\r?\n/)) { const m = line.match(/^\s*SITE_PASSWORD\s*=\s*(.*)\s*$/); if (m) return m[1].replace(/^["']|["']$/g, "").trim(); } } catch {} return null; } function findChrome() { const candidates = [ "C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "C:/Program Files/Microsoft/Edge/Application/msedge.exe", ]; return candidates.find((p) => existsSync(p)) || null; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function main() { const password = readSitePassword(); if (!password) { console.error("No encuentro SITE_PASSWORD (ni en el entorno ni en .env.local)."); process.exit(1); } const chrome = findChrome(); if (!chrome) { console.error("No encuentro Chrome ni Edge instalados."); process.exit(1); } rmSync(WORK, { recursive: true, force: true }); mkdirSync(WORK, { recursive: true }); const browser = await puppeteer.launch({ executablePath: chrome, headless: "new", defaultViewport: { width: WIDTH, height: HEIGHT, deviceScaleFactor: SCALE }, args: [ `--window-size=${WIDTH},${HEIGHT}`, "--hide-scrollbars", "--force-color-profile=srgb", "--disable-features=CalculateNativeWinOcclusion", "--autoplay-policy=no-user-gesture-required", ], }); try { const page = await browser.newPage(); page.setDefaultTimeout(60000); // El deck respeta prefers-reduced-motion mostrando el estado final al // instante (InView -> is-active, reveals sin transición). Justo lo que un // PDF estático necesita: nada de esperar a coreografías a medias. await page.emulateMediaFeatures([ { name: "prefers-reduced-motion", value: "reduce" }, ]); // ── Portal de contraseña ── console.log("· Entrando por el portal…"); await page.goto(`${BASE}/login?next=/`, { waitUntil: "networkidle2" }); await page.type("#gate-password", password); await Promise.allSettled([ page.waitForNavigation({ waitUntil: "networkidle2", timeout: 15000 }), page.click('button[type="submit"]'), ]); // Segunda carga limpia del deck ya con la cookie puesta. await page.goto(`${BASE}/`, { waitUntil: "networkidle2" }); await page.waitForSelector("[data-slide]", { timeout: 30000 }); // Fuentes cargadas (evita capturar con tipografía de reserva). await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready; }); // ── Oculta el chrome de navegación web (barra de progreso + puntos) y // el indicador de desarrollo de Next.js ── await page.addStyleTag({ content: ` nav[aria-label="Navegación de slides"] { display: none !important; } .fixed.origin-left { display: none !important; } nextjs-portal, [data-nextjs-toast], #__next-build-watcher, [data-next-badge-root], [data-next-badge] { display: none !important; } html, body { scrollbar-width: none !important; } *::-webkit-scrollbar { display: none !important; } `, }); // Desactiva snap + scroll suave. El deck usa `scroll-snap-type: y mandatory` // y `scroll-behavior: smooth`: con eso, `window.scrollTo` es asíncrono y el // snap deja el viewport entre secciones (capturábamos el fondo del body). // Un estilo inline en con !important gana a la hoja del deck y hace // el scroll instantáneo y exacto. await page.evaluate(() => { const de = document.documentElement; de.style.setProperty("scroll-snap-type", "none", "important"); de.style.setProperty("scroll-behavior", "auto", "important"); }); const count = await page.$$eval("[data-slide]", (els) => els.length); console.log(`· ${count} slides.`); const shots = []; for (let i = 0; i < count; i++) { // Alinea la slide i al borde superior del viewport, al píxel y sin snap. await page.evaluate((idx) => { const el = document.querySelector(`[data-slide][data-index="${idx}"]`); if (!el) return; const top = el.getBoundingClientRect().top + window.scrollY; window.scrollTo({ top, left: 0, behavior: "auto" }); }, i); // Espera a que las imágenes de la slide activa terminen de cargar // (si no, el layout crece tras el scroll y descuadra la captura). await page.evaluate(async (idx) => { const el = document.querySelector(`[data-slide][data-index="${idx}"]`); if (!el) return; const imgs = Array.from(el.querySelectorAll("img")); await Promise.all( imgs.map((img) => img.complete ? Promise.resolve() : new Promise((res) => { img.addEventListener("load", res, { once: true }); img.addEventListener("error", res, { once: true }); }), ), ); }, i); // Reafirma la posición ya con las imágenes cargadas. await page.evaluate((idx) => { const el = document.querySelector(`[data-slide][data-index="${idx}"]`); if (!el) return; const top = el.getBoundingClientRect().top + window.scrollY; window.scrollTo({ top, left: 0, behavior: "auto" }); }, i); // Vídeo: fijamos el fotograma del segundo 5 (o el final si dura menos) y // quitamos los controles. Esperamos al evento `seeked` para asegurar que // el fotograma está decodificado y pintado antes de capturar. await page.evaluate(async () => { const videos = Array.from(document.querySelectorAll("video")); await Promise.all( videos.map(async (v) => { try { v.controls = false; // Espera a tener metadata (duración) para poder hacer seek. if (v.readyState < 1) { await new Promise((res) => { const t = setTimeout(res, 5000); v.addEventListener( "loadedmetadata", () => { clearTimeout(t); res(); }, { once: true }, ); }); } v.pause(); const target = Number.isFinite(v.duration) && v.duration > 0 ? Math.min(5, v.duration - 0.05) : 5; await new Promise((res) => { const t = setTimeout(res, 8000); v.addEventListener( "seeked", () => { clearTimeout(t); res(); }, { once: true }, ); v.currentTime = target; }); } catch {} }), ); }); await sleep(SETTLE_MS); // Sin `clip`: con captureBeyondViewport (por defecto) el clip sería // relativo al documento, no al viewport. La captura del viewport ya es // exactamente WIDTH×HEIGHT porque así lo fijamos. const file = join(WORK, `slide-${String(i + 1).padStart(2, "0")}.png`); await page.screenshot({ path: file, captureBeyondViewport: false }); shots.push(file); process.stdout.write(` captura ${i + 1}/${count}\r`); } console.log("\n· Componiendo el PDF…"); // ── HTML con una imagen por página, tamaño exacto 1920×1080 ── const imgs = shots .map( (f) => ``, ) .join(""); const html = `${imgs}`; const htmlPath = join(WORK, "deck.html"); writeFileSync(htmlPath, html, "utf8"); const pdfPage = await browser.newPage(); await pdfPage.goto(pathToFileURL(htmlPath).href, { waitUntil: "networkidle0" }); await pdfPage.pdf({ path: OUT, width: `${WIDTH}px`, height: `${HEIGHT}px`, printBackground: true, preferCSSPageSize: true, }); console.log(`✓ PDF generado: ${OUT}`); } finally { await browser.close(); } } main().catch((err) => { console.error(err); process.exit(1); });