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)`);
}