/** * Export the deck to a PDF and a PPTX, one page per slide, 1280x720 landscape. * * Both come out of a single walk of the deck, because the expensive part is * waiting: the deck is a single-page app with entry animations, and the figures * are iframes that have to lay out before anything is captured. * * The PPTX is one full-bleed image per slide rather than editable shapes. That * is deliberate: the figures are sandboxed iframes, so there is nothing to * convert into native shapes, and an image per slide is what survives an import * into Google Slides, Keynote or PowerPoint intact. * * npm run build && npm run export * * Outputs land in public/ so they ship with the Space and the settings panel * can link straight at them. * * Env: PORT (default 7899), SCALE (default 2, the PPTX image density), SLIDES * (default: read from the built bundle). */ import { chromium } from "playwright"; import { createServer } from "node:http"; import { readFile, stat, mkdir, writeFile } from "node:fs/promises"; import { join, extname, dirname, resolve } from "node:path"; import { PDFDocument } from "pdf-lib"; import PptxGenJS from "pptxgenjs"; const ROOT = resolve(dirname(new URL(import.meta.url).pathname), ".."); const DIST = join(ROOT, "dist"); const OUTDIR = join(ROOT, "public"); const NAME = "multi-harness-rl-slides"; const PORT = Number(process.env.PORT || 7899); const SCALE = Number(process.env.SCALE || 2); const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".jpg": "image/jpeg", ".webp": "image/webp", ".pdf": "application/pdf", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", }; // A static server for dist/, so the export runs against exactly what ships. function serve() { return new Promise((ok) => { const s = createServer(async (req, res) => { const path = decodeURIComponent((req.url || "/").split("?")[0]); let file = join(DIST, path === "/" ? "index.html" : path); try { if ((await stat(file)).isDirectory()) file = join(file, "index.html"); } catch { file = join(DIST, "index.html"); // SPA fallback } try { const body = await readFile(file); res.writeHead(200, { "Content-Type": MIME[extname(file)] || "application/octet-stream" }); res.end(body); } catch { res.writeHead(404).end("not found"); } }); s.listen(PORT, () => ok(s)); }); } const server = await serve(); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: SCALE }); await page.goto(`http://localhost:${PORT}/`, { waitUntil: "networkidle" }); // start at the first slide regardless of what the last session left behind await page.evaluate(() => localStorage.setItem("rlenv-slides-index", "0")); await page.reload({ waitUntil: "networkidle" }); const total = await page.evaluate(() => { const m = document.body.innerText.match(/SLIDES\s*ยท\s*(\d+)/); return m ? Number(m[1]) : null; }); const count = Number(process.env.SLIDES || total || 35); console.log(`exporting ${count} slides`); // Layout check, run on every slide before it is captured. Two things a rendered // deck hides well and a projector shows badly: text wider or taller than the box // it sits in, and two pieces of text drawn on top of each other. function findLayoutProblems() { const visible = (e) => { const r = e.getBoundingClientRect(); const cs = getComputedStyle(e); return r.width > 0 && r.height > 0 && r.right > 0 && r.left < 1280 && r.top < 720 && cs.visibility !== "hidden" && Number(cs.opacity) > 0.05; }; // only the slide on screen: the drawer and the print view hold text too const roots = [...document.querySelectorAll("[data-slide-root]")].filter(visible); const root = roots[roots.length - 1]; if (!root) return ["no slide root found"]; const els = [...root.querySelectorAll("*")] .filter((e) => !e.closest("svg") && e.tagName !== "IFRAME" && visible(e)); const label = (e) => `"${(e.innerText || e.textContent || "").trim().replace(/\s+/g, " ").slice(0, 50)}"`; const out = []; for (const e of els) { const cs = getComputedStyle(e); if (cs.overflowX === "visible" && e.clientWidth > 0 && e.scrollWidth > e.clientWidth + 2 && (e.innerText || "").trim()) out.push(`spills ${e.scrollWidth - e.clientWidth}px out of its box: ${label(e)}`); } const leaves = els.filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())); // one rect per rendered line, so a phrase that wraps is not treated as the // whole block between its first and last line const rects = leaves.map((e) => { const range = document.createRange(); range.selectNodeContents(e); return [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0); }); const hit = (a, b) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 3 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 3; for (let i = 0; i < leaves.length; i++) { for (let j = i + 1; j < leaves.length; j++) { if (leaves[i].contains(leaves[j]) || leaves[j].contains(leaves[i])) continue; if (rects[i].some((a) => rects[j].some((b) => hit(a, b)))) out.push(`text overlaps: ${label(leaves[i])} and ${label(leaves[j])}`); } } // text drawn over a box it does not belong to (a caption running into a code // panel, a link under a card), which the text-to-text check above misses const boxes = els.filter((e) => { const cs = getComputedStyle(e); const r = e.getBoundingClientRect(); const drawn = (cs.backgroundColor !== "rgba(0, 0, 0, 0)" && cs.backgroundColor !== "transparent") || ["Top", "Right", "Bottom", "Left"].every((side) => parseFloat(cs[`border${side}Width`]) > 0); return drawn && r.width < 1200 && r.height < 650; }); leaves.forEach((t, i) => { for (const bx of boxes) { if (bx.contains(t) || t.contains(bx)) continue; const b = bx.getBoundingClientRect(); if (rects[i].some((a) => hit(a, b))) { out.push(`text over a box it is not in: ${label(t)} on ${label(bx)}`); break; } } }); return out; } const problems = []; const pdfPages = []; const shots = []; for (let i = 0; i < count; i++) { // let the entry animation finish and every figure iframe lay out await page.waitForTimeout(3200); await page.evaluate(async () => { await Promise.all([...document.querySelectorAll("iframe")].map((f) => f.contentDocument && f.contentDocument.readyState === "complete" ? Promise.resolve() : new Promise((r) => f.addEventListener("load", r, { once: true })))); }); for (const p of await page.evaluate(findLayoutProblems)) problems.push(`slide ${i + 1}: ${p}`); pdfPages.push(await page.pdf({ width: "1280px", height: "720px", printBackground: true, pageRanges: "1", margin: { top: 0, right: 0, bottom: 0, left: 0 }, })); // JPEG rather than PNG: a 35-slide deck of 2560x1440 PNGs is an order of // magnitude larger than the whole Space, and these are photographs of a // rendered page, not line art that needs lossless. // no `scale` option, so the shot comes out at the device scale (2560x1440), // which is what keeps the type crisp on a projector. shots.push(await page.screenshot({ type: "jpeg", quality: 90, clip: { x: 0, y: 0, width: 1280, height: 720 } })); process.stdout.write(` ${i + 1}/${count}\r`); if (i < count - 1) await page.keyboard.press("ArrowRight"); } await browser.close(); server.close(); if (problems.length) { console.log(`\n${problems.length} layout problem(s), fix them before using the export:`); for (const p of problems) console.log(` ${p}`); } else { console.log("\nlayout check: no text spills or overlaps"); } await mkdir(OUTDIR, { recursive: true }); // stitch the single-page PDFs into one deck const out = await PDFDocument.create(); for (const buf of pdfPages) { const src = await PDFDocument.load(buf); const [p] = await out.copyPages(src, [0]); out.addPage(p); } const pdfPath = join(OUTDIR, `${NAME}.pdf`); await writeFile(pdfPath, await out.save()); // one full-bleed image per PPTX slide, 16:9 at the standard 13.333 x 7.5 in const pptx = new PptxGenJS(); pptx.layout = "LAYOUT_16x9"; pptx.title = "Training a coding agent through a harness you did not write"; pptx.author = "Sergio Paniego Blanco"; for (const shot of shots) { const s = pptx.addSlide(); s.background = { color: "07090F" }; s.addImage({ data: `image/jpeg;base64,${shot.toString("base64")}`, x: 0, y: 0, w: 13.333, h: 7.5 }); } const pptxPath = join(OUTDIR, `${NAME}.pptx`); await pptx.writeFile({ fileName: pptxPath }); for (const p of [pdfPath, pptxPath]) { const { size } = await stat(p); console.log(`\nwrote ${p} (${(size / 1e6).toFixed(1)} MB)`); }