// 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 = `