Repository navigation
Expand file tree
/
Copy pathrender.mjs
More file actions
130 lines (119 loc) · 5.31 KB
/
Copy pathrender.mjs
File metadata and controls
130 lines (119 loc) · 5.31 KB
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
/**
* Render deck.html to ProofSettle-deck.pdf, one slide per page.
*
* node deck/render.mjs
*/
import pw from 'playwright';
import { readFileSync, writeFileSync } from 'node:fs';
const { chromium } = pw;
// Facts that change between runs live here, so the deck is never edited by hand and never
// carries a stale hash.
const facts = JSON.parse(readFileSync(new URL('./facts.json', import.meta.url), 'utf8'));
let html = readFileSync(new URL('./deck.html', import.meta.url), 'utf8');
for (const [k, v] of Object.entries(facts)) {
const token = `__${k}__`;
if (!html.includes(token)) { console.error(`WARNING: token ${token} not present in deck.html`); }
html = html.replaceAll(token, v);
}
const left = html.match(/__[A-Z_]+__/g);
if (left) { console.error('UNSUBSTITUTED TOKENS:', [...new Set(left)].join(', ')); process.exit(1); }
writeFileSync(new URL('./deck.rendered.html', import.meta.url), html);
const b = await chromium.launch({ executablePath: process.env.PW_CHROMIUM ?? chromium.executablePath() });
const p = await b.newPage({ viewport: { width: 1280, height: 720 } });
await p.setContent(html, { waitUntil: 'load' });
await p.emulateMedia({ media: 'screen' });
/*
* Number the footers from their position rather than from what is typed in the markup, so
* inserting a slide never means renumbering every footer after it by hand.
*
* Done through the DOM, not a regex over the HTML. The first attempt matched every
* <span class="n">, and the bar charts use that class for their values, so it quietly rewrote a
* column of measured numbers into slide numbers. A selector cannot make that mistake.
*/
const slideNo = await p.$$eval('.slide', (ns) => {
ns.forEach((n, i) => {
const el = n.querySelector('.foot .n');
if (el) el.textContent = String(i + 1);
});
return ns.length;
});
/*
* An element given an explicit width must actually have one. The bar chart set width on an inline
* span, which CSS ignores, so seven bars rendered empty and the slide still passed every other
* check. A zero-width element that was told to be 40% wide is always a mistake.
*/
const zeroWidth = await p.$$eval('[style*="width"]', (els) => els
.filter((e) => /width:\s*[1-90]/.test(e.getAttribute('style') || ''))
.filter((e) => e.getBoundingClientRect().width < 0.5)
.map((e) => (e.className || e.tagName) + ' ' + e.getAttribute('style')));
if (zeroWidth.length) {
console.error('ELEMENTS SIZED BUT NOT RENDERED:', JSON.stringify(zeroWidth, null, 1));
process.exit(1);
}
/*
* Every slide must be exactly one page.
*
* This used to compare the slide's own scrollHeight against 720, which was worthless: .slide sets
* overflow:hidden, so scrollHeight can never exceed clientHeight. A slide whose content spilled
* up over its heading and down through its footer still measured 720 and printed as a blank page.
* Three measurements now, because content can go wrong in three different ways:
*
* above/below/sides content escaping the slide box entirely
* bodyAbove/bodyBelow content taller than the space between the heading and the footer, which
* .body centres, so it overflows in both directions at once
* underFoot content reaching the footer rule on slides that have no .body
*/
const overflow = await p.$$eval('.slide', (ns) => ns.map((n, i) => {
const r = n.getBoundingClientRect();
const union = (root, skipFoot) => {
let top = Infinity, bottom = -Infinity, leftX = Infinity, rightX = -Infinity;
const foot = n.querySelector('.foot');
for (const el of root.querySelectorAll('*')) {
if (skipFoot && foot && (el === foot || foot.contains(el))) continue;
const bb = el.getBoundingClientRect();
if (bb.width === 0 && bb.height === 0) continue;
top = Math.min(top, bb.top); bottom = Math.max(bottom, bb.bottom);
leftX = Math.min(leftX, bb.left); rightX = Math.max(rightX, bb.right);
}
return { top, bottom, left: leftX, right: rightX };
};
const all = union(n, false);
const body = n.querySelector('.body');
let bodyAbove = 0, bodyBelow = 0;
if (body) {
const br = body.getBoundingClientRect();
const u = union(body, false);
if (u.top !== Infinity) {
bodyAbove = Math.round(Math.max(0, br.top - u.top));
bodyBelow = Math.round(Math.max(0, u.bottom - br.bottom));
}
}
let underFoot = 0;
const foot = n.querySelector('.foot');
if (foot) {
const fr = foot.getBoundingClientRect();
const u = union(n, true);
if (u.bottom !== -Infinity) underFoot = Math.round(Math.max(0, u.bottom - fr.top));
}
return {
i: i + 1,
above: Math.round(Math.max(0, r.top - all.top)),
below: Math.round(Math.max(0, all.bottom - r.bottom)),
sides: Math.round(Math.max(0, r.left - all.left) + Math.max(0, all.right - r.right)),
bodyAbove, bodyBelow, underFoot,
};
}).filter((s) => s.above > 1 || s.below > 1 || s.sides > 1
|| s.bodyAbove > 1 || s.bodyBelow > 1 || s.underFoot > 1));
if (overflow.length) {
console.error('SLIDES OVERFLOWING (px outside where they belong):', JSON.stringify(overflow));
process.exit(1);
}
await p.pdf({
path: new URL('./ProofSettle-deck.pdf', import.meta.url).pathname,
width: '1280px', height: '720px',
printBackground: true,
margin: { top: '0', right: '0', bottom: '0', left: '0' },
pageRanges: '',
});
console.log(`rendered ${slideNo} slides`);
await b.close();