Repository navigation
Expand file tree
/
Copy pathrender.js
More file actions
151 lines (137 loc) · 8.13 KB
/
Copy pathrender.js
File metadata and controls
151 lines (137 loc) · 8.13 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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
#!/usr/bin/env node
/* 读 content.json → 生成每页 HTML(build/)→ 无头 Chrome 截图(out/)。
* 无第三方依赖:用 Node 内置 WebSocket 直接说 Chrome DevTools Protocol(需要 Node ≥ 22)。
*
* node render.js 全部重新生成
* node render.js --only 03 只渲染文件名以 03 开头的页(可多次传入)
* node render.js --force 调试:检查不通过也出图,但仍以失败状态退出
* CHROME=/path/to/chrome node render.js
*
* 每页截图前会做溢出检查,发现问题直接报错退出(退出码 1)。
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const { pathToFileURL } = require('url');
const { buildPages } = require('./templates/pages.js');
const { validate } = require('./lib/validate.js');
const ROOT = __dirname;
const W = 1242, H = 1660;
const SAFE = 36; // 文字离画布边缘至少这么远
const content = JSON.parse(fs.readFileSync(path.join(ROOT, 'content.json'), 'utf8'));
const pages = buildPages(content);
const v = validate(content, pages.length);
v.warns.forEach((w) => console.warn('提示:' + w));
if (v.errs.length) {
console.error('content.json 校验失败:\n - ' + v.errs.join('\n - '));
process.exit(1);
}
console.log(`内容校验通过:${content.questions.length} 题,${pages.length} 页;字母分值分布(1/2/3 分各出现几次)`,
Object.entries(v.dist).map(([k, a]) => `${k}:${a.join('/')}`).join(' '));
console.log('类型命中(81 种组合):', Object.entries(v.typeHits).map(([k, n]) => `${k} ${n}`).join(','));
const FORCE = process.argv.includes('--force'); // 调试用:检查不过也照常出图(最终仍以失败退出)
const only = [];
for (let i = 2; i < process.argv.length; i++) if (process.argv[i] === '--only') only.push(process.argv[++i]);
const todo = only.length ? pages.filter((p) => only.some((o) => p.file.startsWith(o))) : pages;
const BUILD = path.join(ROOT, 'build');
const OUT = path.join(ROOT, 'out');
fs.mkdirSync(BUILD, { recursive: true });
fs.mkdirSync(OUT, { recursive: true });
if (!only.length) {
for (const f of fs.readdirSync(OUT)) if (/^\d\d-.*\.png$/.test(f)) fs.unlinkSync(path.join(OUT, f));
}
// 页内检查:在浏览器里执行,返回问题列表
const CHECK = `(async () => {
await document.fonts.ready;
const W = ${W}, H = ${H}, SAFE = ${SAFE};
const page = document.querySelector('.page');
const minFont = +page.dataset.minfont || 0;
const errs = [];
const name = (el) => (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).join('.') : el.tagName.toLowerCase()) + ' 「' + (el.textContent || '').trim().slice(0, 14) + '」';
const els = [page, ...page.querySelectorAll('*')];
for (const el of els) {
if (el.closest('[data-deco]')) continue;
const cs = getComputedStyle(el);
if (cs.display === 'inline' || cs.display === 'none') continue;
const isPage = el === page;
const maxFs = Math.max(parseFloat(cs.fontSize), ...[...el.querySelectorAll('*')].map((d) => parseFloat(getComputedStyle(d).fontSize)));
const tol = Math.max(3, maxFs * 0.3); // 行盒比字形矮几像素是正常的,真溢出至少差一行
if (!isPage && el.clientHeight > 0 && el.scrollHeight > el.clientHeight + tol) {
const eb = el.getBoundingClientRect().bottom;
const worst = [...el.querySelectorAll('*')].map((d) => [d, d.getBoundingClientRect().bottom]).sort((a, b) => b[1] - a[1])[0];
errs.push('纵向溢出 ' + name(el) + ' scrollH=' + el.scrollHeight + ' clientH=' + el.clientHeight + (worst ? ';最低的子元素 ' + name(worst[0]) + ' 底边超出 ' + Math.round(worst[1] - eb) + 'px' : ''));
}
if (!isPage && el.clientWidth > 0 && el.scrollWidth > el.clientWidth + tol) errs.push('横向溢出 ' + name(el) + ' scrollW=' + el.scrollWidth + ' clientW=' + el.clientWidth);
const hasText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
if (!hasText) continue;
const r = el.getBoundingClientRect();
if (r.left < SAFE - 0.5 || r.top < SAFE - 0.5 || r.right > W - SAFE + 0.5 || r.bottom > H - SAFE + 0.5)
errs.push('文字越出安全区 ' + name(el) + ' [' + [r.left, r.top, r.right, r.bottom].map(Math.round).join(',') + ']');
// 文字块必须落在最近的卡片/容器之内
const box = el.closest('.qcard,.rcard,.key,.step,.lv,.ty,.cta,.band,.tile,.qhead');
if (box) {
const b = box.getBoundingClientRect();
if (r.left < b.left - 0.5 || r.right > b.right + 0.5 || r.top < b.top - 0.5 || r.bottom > b.bottom + 0.5)
errs.push('文字超出卡片 ' + name(el));
}
if (minFont && !el.closest('.top,.bot') && parseFloat(cs.fontSize) < minFont - 0.01)
errs.push('字号 ' + cs.fontSize + ' < ' + minFont + 'px ' + name(el));
}
const body = page.querySelector('.body');
if (body && body.scrollHeight > body.clientHeight + 2) errs.push('正文区溢出 scrollH=' + body.scrollHeight + ' clientH=' + body.clientHeight);
const rootOver = document.documentElement.scrollHeight > H || document.documentElement.scrollWidth > W;
if (rootOver) errs.push('页面超出 ' + W + 'x' + H + ' 画布');
const used = body ? [...body.children].reduce((m, ch) => Math.max(m, ch.getBoundingClientRect().bottom), 0) : 0;
const bottomGap = body ? Math.round(body.getBoundingClientRect().bottom - used) : null;
return { errs, bottomGap };
})()`;
const { CDP, findChrome, sleep } = require('./lib/cdp.js');
async function main() {
for (const p of pages) fs.writeFileSync(path.join(BUILD, p.file + '.html'), p.html);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'xhs-render-'));
const chrome = spawn(findChrome(), [
'--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-first-run', '--no-default-browser-check',
'--force-device-scale-factor=1', '--font-render-hinting=none', '--allow-file-access-from-files',
`--user-data-dir=${profile}`, '--remote-debugging-port=0', 'about:blank',
], { stdio: 'ignore' });
const cleanup = () => { try { chrome.kill(); } catch (e) { /* ignore */ } try { fs.rmSync(profile, { recursive: true, force: true }); } catch (e) { /* ignore */ } };
process.on('exit', cleanup);
let wsUrl = null;
for (let i = 0; i < 150 && !wsUrl; i++) {
try {
const [port, p] = fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').trim().split('\n');
wsUrl = `ws://127.0.0.1:${port}${p}`;
} catch (e) { await sleep(100); }
}
if (!wsUrl) throw new Error('Chrome 没有启动成功');
const cdp = await CDP.connect(wsUrl);
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
const S = (m, p) => cdp.send(m, p, sessionId);
await S('Page.enable');
await S('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 1, mobile: false });
let failed = 0;
for (const p of todo) {
const loaded = cdp.waitEvent('Page.loadEventFired', sessionId);
await S('Page.navigate', { url: pathToFileURL(path.join(BUILD, p.file + '.html')).href });
await loaded;
const res = await S('Runtime.evaluate', { expression: CHECK, awaitPromise: true, returnByValue: true });
if (res.exceptionDetails) throw new Error('检查脚本出错:' + JSON.stringify(res.exceptionDetails));
const { errs, bottomGap } = res.result.value;
if (errs.length) {
failed++;
console.error(`✗ ${p.file}\n ` + errs.join('\n '));
if (!FORCE) continue;
}
const shot = await S('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width: W, height: H, scale: 1 } });
fs.writeFileSync(path.join(OUT, p.file + '.png'), Buffer.from(shot.data, 'base64'));
console.log(`✓ out/${p.file}.png (正文区底部余量 ${bottomGap}px)`);
}
cdp.ws.close();
cleanup();
if (failed) { console.error(`\n${failed} 页未通过溢出检查,已中止(该页没有生成 PNG)。`); process.exit(1); }
console.log(`\n完成:${todo.length} 张 ${W}×${H} PNG → out/`);
}
main().catch((e) => { console.error(e); process.exit(1); });