File size: 8,967 Bytes
3bc016b 92a9ea2 3bc016b 92a9ea2 3bc016b 92a9ea2 3bc016b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 | /**
* 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)`);
}
|