Download source/scripts/export-deck.mjs from FineEnvs/multi-harness-rl-slides: direct link, hf CLI and curl.
- Browser
- Download file 8.97 kB
-
https://huggingface.co/spaces/FineEnvs/multi-harness-rl-slides/resolve/main/source/scripts/export-deck.mjs
- Command line
-
hf download hf://spaces/FineEnvs/multi-harness-rl-slides/source/scripts/export-deck.mjs
-
curl -L -o export-deck.mjs https://huggingface.co/spaces/FineEnvs/multi-harness-rl-slides/resolve/main/source/scripts/export-deck.mjs
8.97 kB
| /** | |
| * 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)`); | |
| } | |