@entropicwarrior/sdoc 0.2.17 → 0.2.19
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.
- package/README.md +5 -2
- package/docs/reference/sdoc-authoring.sdoc +1 -1
- package/docs/reference/slide-authoring.sdoc +441 -20
- package/package.json +4 -1
- package/src/sdoc.js +4 -1
- package/src/slide-geometry.js +470 -0
- package/src/slide-layouts.js +514 -0
- package/src/slide-pdf.js +9 -3
- package/src/slide-pptx.js +599 -0
- package/src/slide-renderer.js +135 -55
- package/src/theme.js +141 -0
- package/src/zip.js +102 -0
|
@@ -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(/&/g, "&")
|
|
398
|
+
.replace(/</g, "<")
|
|
399
|
+
.replace(/>/g, ">")
|
|
400
|
+
.replace(/"/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 };
|