@entropicwarrior/sdoc 0.2.18 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,470 @@
1
+ // SDOC Slides — geometry harvest.
2
+ //
3
+ // Reads a built deck back out of the browser: every box, every run of text,
4
+ // every image, with its position and computed style in design-box pixels.
5
+ //
6
+ // This exists so the PowerPoint / Google Slides exporter does not need its own
7
+ // layout engine. The CSS is the layout engine; this module asks the browser
8
+ // what the CSS decided, and the exporter turns the answer into shapes. Any
9
+ // theme, any layout, any future layout, exports without the exporter knowing
10
+ // anything about it.
11
+ //
12
+ // Zero dependencies: the measuring script is injected into a copy of the deck
13
+ // and the result is read back through headless Chrome's --dump-dom.
14
+
15
+ const { spawn } = require("child_process");
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const os = require("os");
19
+ const { findChrome } = require("./slide-pdf");
20
+
21
+ const SENTINEL = "__SDOC_GEOMETRY_END__";
22
+
23
+ // The measuring script, injected before </body>. It runs after layout, walks
24
+ // each slide in turn, and leaves its findings in a <script type="application/json">
25
+ // element that --dump-dom hands back.
26
+ const MEASURE_SCRIPT = `
27
+ (function () {
28
+ function rgba(value) {
29
+ if (!value) return null;
30
+ var m = value.match(/rgba?\\(([^)]+)\\)/);
31
+ if (!m) return null;
32
+ var parts = m[1].split(",").map(function (p) { return parseFloat(p.trim()); });
33
+ var a = parts.length > 3 ? parts[3] : 1;
34
+ if (!(a > 0)) return null;
35
+ function hex(n) { return ("0" + Math.max(0, Math.min(255, Math.round(n))).toString(16)).slice(-2); }
36
+ return { hex: (hex(parts[0]) + hex(parts[1]) + hex(parts[2])).toUpperCase(), alpha: a };
37
+ }
38
+
39
+ function firstFamily(stack) {
40
+ if (!stack) return null;
41
+ var first = stack.split(",")[0].trim();
42
+ return first.replace(/^["']|["']$/g, "");
43
+ }
44
+
45
+ function transformText(text, mode) {
46
+ if (mode === "uppercase") return text.toUpperCase();
47
+ if (mode === "lowercase") return text.toLowerCase();
48
+ return text;
49
+ }
50
+
51
+ // A run of text: one contiguous piece with one computed style.
52
+ function runsOf(el) {
53
+ var runs = [];
54
+ function walk(node, style) {
55
+ for (var i = 0; i < node.childNodes.length; i++) {
56
+ var child = node.childNodes[i];
57
+ if (child.nodeType === 3) {
58
+ var raw = child.nodeValue.replace(/\\s+/g, " ");
59
+ if (!raw.trim()) {
60
+ // Keep a single separating space between runs, drop the rest.
61
+ if (runs.length && !/ $/.test(runs[runs.length - 1].text)) {
62
+ runs[runs.length - 1].text += " ";
63
+ }
64
+ continue;
65
+ }
66
+ var cs = style;
67
+ var colour = rgba(cs.color);
68
+ runs.push({
69
+ text: transformText(raw, cs.textTransform),
70
+ font: firstFamily(cs.fontFamily),
71
+ size: parseFloat(cs.fontSize),
72
+ weight: parseInt(cs.fontWeight, 10) || 400,
73
+ italic: cs.fontStyle === "italic",
74
+ color: colour ? colour.hex : "FFFFFF",
75
+ spacing: cs.letterSpacing === "normal" ? 0 : parseFloat(cs.letterSpacing) || 0
76
+ });
77
+ } else if (child.nodeType === 1) {
78
+ var cs2 = getComputedStyle(child);
79
+ if (cs2.display === "none" || cs2.visibility === "hidden") continue;
80
+ walk(child, cs2);
81
+ }
82
+ }
83
+ }
84
+ walk(el, getComputedStyle(el));
85
+ return runs.filter(function (r) { return r.text.trim().length > 0 || r.text === " "; });
86
+ }
87
+
88
+ // An element is a text leaf when it holds text and no block-level child does.
89
+ function isTextLeaf(el) {
90
+ if (!el.textContent || !el.textContent.trim()) return false;
91
+ for (var i = 0; i < el.children.length; i++) {
92
+ var child = el.children[i];
93
+ var display = getComputedStyle(child).display;
94
+ if (display !== "inline" && display !== "inline-block" && display !== "contents") return false;
95
+ if (display === "contents" && !isTextLeaf(child)) return false;
96
+ }
97
+ return true;
98
+ }
99
+
100
+ function borderOf(cs) {
101
+ var sides = ["Top", "Right", "Bottom", "Left"];
102
+ var widths = sides.map(function (s) { return parseFloat(cs["border" + s + "Width"]) || 0; });
103
+ var maxWidth = Math.max.apply(null, widths);
104
+ if (maxWidth <= 0) return null;
105
+ var styles = sides.map(function (s) { return cs["border" + s + "Style"]; });
106
+ if (styles.every(function (s) { return s === "none" || s === "hidden"; })) return null;
107
+ // Read the colour from a side that actually has width: an element with
108
+ // only a bottom border still reports border-top-color as currentColor.
109
+ var colour = null;
110
+ for (var i = 0; i < sides.length; i++) {
111
+ if (widths[i] > 0 && styles[i] !== "none" && styles[i] !== "hidden") {
112
+ colour = rgba(cs["border" + sides[i] + "Color"]);
113
+ if (colour) break;
114
+ }
115
+ }
116
+ var all = widths.every(function (w) { return Math.abs(w - widths[0]) < 0.01 && w > 0; });
117
+ return {
118
+ width: maxWidth,
119
+ colour: colour ? colour.hex : "FFFFFF",
120
+ alpha: colour ? colour.alpha : 1,
121
+ sides: all ? "all" : widths.map(function (w, i) { return w > 0 ? sides[i].toLowerCase() : null; }).filter(Boolean)
122
+ };
123
+ }
124
+
125
+ function measureSlide(slide) {
126
+ var origin = slide.getBoundingClientRect();
127
+ var scs = getComputedStyle(slide);
128
+ var pad = {
129
+ top: parseFloat(scs.paddingTop) || 0,
130
+ right: parseFloat(scs.paddingRight) || 0,
131
+ bottom: parseFloat(scs.paddingBottom) || 0,
132
+ left: parseFloat(scs.paddingLeft) || 0
133
+ };
134
+ var atoms = [];
135
+
136
+ // Chrome — the footer and its page indicator — sits in the slide's
137
+ // margin on purpose. It is exported like anything else, but it must not
138
+ // count towards the content extent or every slide reports an overflow.
139
+ var inChrome = false;
140
+ function push(atom) { atom.chrome = inChrome; atoms.push(atom); }
141
+
142
+ function visit(el) {
143
+ // The nav chevrons are a screen affordance, not deck content: nothing
144
+ // clicks them in a PDF or a .pptx. (The theme runtime also hides one of
145
+ // the pair at load time, so measuring them exported a lone arrow.)
146
+ if (el.classList && (el.classList.contains("nav-prev") || el.classList.contains("nav-next"))) return;
147
+
148
+ var cs = getComputedStyle(el);
149
+ if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity) === 0) return;
150
+
151
+ var rect = el.getBoundingClientRect();
152
+ var box = {
153
+ x: rect.left - origin.left,
154
+ y: rect.top - origin.top,
155
+ w: rect.width,
156
+ h: rect.height
157
+ };
158
+
159
+ if (el.tagName === "IMG") {
160
+ push({ kind: "image", box: box, src: el.getAttribute("src"), alt: el.getAttribute("alt") || "" });
161
+ return;
162
+ }
163
+
164
+ // A painted box: a background, a border, or both.
165
+ var fill = rgba(cs.backgroundColor);
166
+ var border = borderOf(cs);
167
+ if ((fill || border) && box.w > 0 && box.h > 0 && !el.classList.contains("slide")) {
168
+ push({
169
+ kind: "box",
170
+ box: box,
171
+ fill: fill ? fill.hex : null,
172
+ fillAlpha: fill ? fill.alpha : 0,
173
+ border: border,
174
+ radius: parseFloat(cs.borderTopLeftRadius) || 0,
175
+ cls: el.className || ""
176
+ });
177
+ }
178
+
179
+ if (isTextLeaf(el)) {
180
+ var runs = runsOf(el);
181
+ if (runs.length) {
182
+ var lineHeight = cs.lineHeight === "normal"
183
+ ? parseFloat(cs.fontSize) * 1.2
184
+ : parseFloat(cs.lineHeight);
185
+ push({
186
+ kind: "text",
187
+ box: box,
188
+ runs: runs,
189
+ align: cs.textAlign,
190
+ lineHeight: lineHeight,
191
+ fontSize: parseFloat(cs.fontSize),
192
+ cls: el.className || "",
193
+ tag: el.tagName.toLowerCase()
194
+ });
195
+ }
196
+ return;
197
+ }
198
+
199
+ for (var i = 0; i < el.children.length; i++) visit(el.children[i]);
200
+ }
201
+
202
+ for (var i = 0; i < slide.children.length; i++) {
203
+ var child = slide.children[i];
204
+ if (child.classList && child.classList.contains("notes")) continue;
205
+ inChrome = !!(child.classList && child.classList.contains("slide-footer"));
206
+ visit(child);
207
+ }
208
+ inChrome = false;
209
+
210
+ // Content extent, for the overflow report.
211
+ var maxX = 0, maxY = 0, minX = 1e9, minY = 1e9;
212
+ atoms.forEach(function (a) {
213
+ if (a.chrome) return;
214
+ maxX = Math.max(maxX, a.box.x + a.box.w);
215
+ maxY = Math.max(maxY, a.box.y + a.box.h);
216
+ minX = Math.min(minX, a.box.x);
217
+ minY = Math.min(minY, a.box.y);
218
+ });
219
+
220
+ return {
221
+ id: slide.id || null,
222
+ spine: parseInt(slide.getAttribute("data-spine") || "0", 10),
223
+ detail: parseInt(slide.getAttribute("data-detail") || "0", 10),
224
+ layout: (slide.className.match(/layout-([a-z0-9-]+)/) || [null, null])[1],
225
+ classes: slide.className,
226
+ background: rgba(getComputedStyle(slide).backgroundColor),
227
+ notes: (function () {
228
+ var aside = slide.querySelector(".notes");
229
+ return aside ? aside.textContent.replace(/\\s+/g, " ").trim() : "";
230
+ })(),
231
+ padding: pad,
232
+ extent: { minX: minX === 1e9 ? 0 : minX, minY: minY === 1e9 ? 0 : minY, maxX: maxX, maxY: maxY },
233
+ atoms: atoms
234
+ };
235
+ }
236
+
237
+ function run() {
238
+ var docEl = document.documentElement;
239
+ // Measure at the design size, not at whatever the window happens to be.
240
+ docEl.style.setProperty("--sdoc-slide-scale", "1");
241
+
242
+ var slides = Array.prototype.slice.call(document.querySelectorAll(".slide"));
243
+ var box = { w: 0, h: 0 };
244
+ var out = [];
245
+
246
+ slides.forEach(function (slide) {
247
+ var wasActive = slide.classList.contains("active");
248
+ slide.classList.add("active");
249
+ // Park the slide at the origin, unscaled, so measurement is in design px.
250
+ var prior = slide.getAttribute("style") || "";
251
+ slide.setAttribute("style", prior + ";position:absolute;top:0;left:0;transform:none;");
252
+ box.w = Math.max(box.w, slide.offsetWidth);
253
+ box.h = Math.max(box.h, slide.offsetHeight);
254
+ out.push(measureSlide(slide));
255
+ slide.setAttribute("style", prior);
256
+ if (!wasActive) slide.classList.remove("active");
257
+ });
258
+
259
+ var payload = { box: box, slides: out };
260
+ var el = document.createElement("script");
261
+ el.type = "application/json";
262
+ el.id = "sdoc-geometry";
263
+ el.textContent = JSON.stringify(payload) + "\\n/*${SENTINEL}*/";
264
+ document.body.appendChild(el);
265
+ }
266
+
267
+ function start() {
268
+ if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
269
+ document.fonts.ready.then(function () { requestAnimationFrame(run); });
270
+ } else {
271
+ requestAnimationFrame(run);
272
+ }
273
+ }
274
+
275
+ if (document.readyState === "complete") start();
276
+ else window.addEventListener("load", start);
277
+ })();
278
+ `;
279
+
280
+ // Runs Chrome with --dump-dom, waiting for the sentinel rather than for the
281
+ // process to exit: Chrome flushes the DOM promptly but does not always exit on
282
+ // its own, so waiting on exit adds tens of seconds to every build.
283
+ function dumpDom(chrome, fileUrl, outPath, timeoutMs) {
284
+ return new Promise((resolve, reject) => {
285
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), "sdoc-geom-"));
286
+ const out = fs.openSync(outPath, "w");
287
+
288
+ const child = spawn(
289
+ chrome,
290
+ [
291
+ "--headless=new",
292
+ "--disable-gpu",
293
+ "--no-first-run",
294
+ "--no-default-browser-check",
295
+ "--hide-scrollbars",
296
+ "--window-size=1920,1080",
297
+ // --dump-dom serialises the page once and does not wait for async
298
+ // work. The measurement waits for webfonts, because text measured in
299
+ // a fallback face is the wrong size, so the page must be held open
300
+ // past that: a virtual-time budget fast-forwards timers and delays
301
+ // the dump until the budget is spent.
302
+ "--virtual-time-budget=8000",
303
+ "--user-data-dir=" + profile,
304
+ "--dump-dom",
305
+ fileUrl,
306
+ ],
307
+ { stdio: ["ignore", out, "ignore"] }
308
+ );
309
+
310
+ let settled = false;
311
+ const finish = (err, value) => {
312
+ if (settled) return;
313
+ settled = true;
314
+ clearInterval(poll);
315
+ clearTimeout(limit);
316
+ try { fs.closeSync(out); } catch {}
317
+ try { child.kill("SIGKILL"); } catch {}
318
+ fs.rm(profile, { recursive: true, force: true }, () => {});
319
+ err ? reject(err) : resolve(value);
320
+ };
321
+
322
+ const poll = setInterval(() => {
323
+ let text = "";
324
+ try { text = fs.readFileSync(outPath, "utf-8"); } catch { return; }
325
+ if (text.includes(SENTINEL)) finish(null, text);
326
+ }, 120);
327
+
328
+ const limit = setTimeout(
329
+ () => finish(new Error(`Chrome did not report slide geometry within ${timeoutMs} ms`)),
330
+ timeoutMs
331
+ );
332
+
333
+
334
+ child.on("error", (err) => finish(err));
335
+ child.on("exit", () => {
336
+ // Chrome exited. Give the pipe a moment to drain before deciding it
337
+ // never reported: under load the last write can land after the exit.
338
+ setTimeout(() => {
339
+ let text = "";
340
+ try { text = fs.readFileSync(outPath, "utf-8"); } catch {}
341
+ if (text.includes(SENTINEL)) finish(null, text);
342
+ else finish(new Error("Chrome exited before reporting slide geometry"));
343
+ }, 400);
344
+ });
345
+ });
346
+ }
347
+
348
+ // Measures a built deck. `htmlPath` must be a file the browser can open with
349
+ // its relative assets intact, so the caller writes the temp copy beside the
350
+ // original rather than in the system temp directory.
351
+ async function harvestGeometry(htmlPath, options = {}) {
352
+ const chrome = findChrome();
353
+ if (!chrome) {
354
+ throw new Error(
355
+ "Chrome/Chromium not found. Install Google Chrome or set CHROME_PATH environment variable."
356
+ );
357
+ }
358
+
359
+ const resolved = path.resolve(htmlPath);
360
+ const html = fs.readFileSync(resolved, "utf-8");
361
+ const injected = html.replace(/<\/body>/i, `<script>${MEASURE_SCRIPT}</script>\n</body>`);
362
+
363
+ const dir = path.dirname(resolved);
364
+ const stamp = Date.now();
365
+ const tmpHtml = path.join(dir, `.sdoc-geometry-${stamp}.html`);
366
+ fs.writeFileSync(tmpHtml, injected, "utf-8");
367
+
368
+ // A browser measuring a page is not a fast, reliable operation when the
369
+ // machine is busy: another Chrome on the same box (a parallel test run, a
370
+ // CI job doing something else) delays startup and the virtual-time budget
371
+ // past a fixed deadline, and every attempt then fails identically. So the
372
+ // window grows with each attempt rather than staying put — a contended run
373
+ // gets the time it needs, and only a genuinely broken page spends the lot.
374
+ const attempts = options.attempts || 3;
375
+ const baseTimeout = options.timeoutMs || 30000;
376
+ let lastError = null;
377
+
378
+ try {
379
+ for (let attempt = 1; attempt <= attempts; attempt++) {
380
+ const tmpOut = path.join(os.tmpdir(), `sdoc-geometry-${stamp}-${attempt}.dom`);
381
+ try {
382
+ // Give the previous attempt's Chrome time to exit and release the
383
+ // machine before competing with it.
384
+ if (attempt > 1) await new Promise((r) => setTimeout(r, 500 * (attempt - 1)));
385
+ const dom = await dumpDom(chrome, "file://" + tmpHtml, tmpOut, baseTimeout * attempt);
386
+ const match = dom.match(
387
+ new RegExp(
388
+ '<script type="application/json" id="sdoc-geometry">([\\s\\S]*?)\\n/\\*' + SENTINEL + '\\*/'
389
+ )
390
+ );
391
+ if (!match) {
392
+ const tail = dom.slice(-160).replace(/\s+/g, " ");
393
+ throw new Error(`the page was serialised before it reported its geometry; DOM ended: ...${tail}`);
394
+ }
395
+ // --dump-dom serialises the script body with HTML entities escaped.
396
+ const json = match[1]
397
+ .replace(/&amp;/g, "&")
398
+ .replace(/&lt;/g, "<")
399
+ .replace(/&gt;/g, ">")
400
+ .replace(/&quot;/g, '"');
401
+ return JSON.parse(json);
402
+ } catch (err) {
403
+ lastError = err;
404
+ } finally {
405
+ try { fs.unlinkSync(tmpOut); } catch {}
406
+ }
407
+ }
408
+ throw new Error(
409
+ `${lastError.message} (${attempts} attempts; another browser running on this ` +
410
+ `machine can delay the measurement past the deadline)`
411
+ );
412
+ } finally {
413
+ try { fs.unlinkSync(tmpHtml); } catch {}
414
+ }
415
+ }
416
+
417
+ // Slides whose content runs outside where it belongs.
418
+ //
419
+ // Two thresholds. Content past the design box is *clipped* — it is not in the
420
+ // PDF or the export at all. Content merely past the slide's own padding has
421
+ // broken out of the margin the theme reserved: it still renders, but it
422
+ // collides with the footer or runs to the edge, which is a defect the author
423
+ // has to see. Pass { strict: false } to report only clipping.
424
+ function overflowReport(geometry, options = {}) {
425
+ const tolerance = options.tolerance !== undefined ? options.tolerance : 1;
426
+ const strict = options.strict !== false;
427
+ const { box, slides } = geometry;
428
+ const findings = [];
429
+
430
+ for (const slide of slides) {
431
+ // A slide with no content atoms has no extent to compare. Its extent
432
+ // reads as all zeros, which would otherwise be reported as content
433
+ // sitting outside the left and top margins.
434
+ if (!slide.atoms.some((a) => !a.chrome)) continue;
435
+
436
+ const pad = slide.padding || { top: 0, right: 0, bottom: 0, left: 0 };
437
+ const limits = strict
438
+ ? { left: pad.left, top: pad.top, right: box.w - pad.right, bottom: box.h - pad.bottom }
439
+ : { left: 0, top: 0, right: box.w, bottom: box.h };
440
+
441
+ const over = [];
442
+ const check = (amount, edge) => {
443
+ if (amount > tolerance) {
444
+ const clipped =
445
+ (edge === "right" && slide.extent.maxX > box.w + tolerance) ||
446
+ (edge === "bottom" && slide.extent.maxY > box.h + tolerance) ||
447
+ (edge === "left" && slide.extent.minX < -tolerance) ||
448
+ (edge === "top" && slide.extent.minY < -tolerance);
449
+ over.push(`${edge} by ${Math.round(amount)}px${clipped ? " (clipped)" : ""}`);
450
+ }
451
+ };
452
+
453
+ check(slide.extent.maxX - limits.right, "right");
454
+ check(slide.extent.maxY - limits.bottom, "bottom");
455
+ check(limits.left - slide.extent.minX, "left");
456
+ check(limits.top - slide.extent.minY, "top");
457
+
458
+ if (over.length) {
459
+ findings.push({
460
+ slide: slide.detail ? `${slide.spine}.${slide.detail}` : String(slide.spine),
461
+ id: slide.id,
462
+ layout: slide.layout,
463
+ over,
464
+ });
465
+ }
466
+ }
467
+ return findings;
468
+ }
469
+
470
+ module.exports = { harvestGeometry, overflowReport, MEASURE_SCRIPT };