dsh-visualkit 0.0.1 → 0.0.2

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.
@@ -1,255 +1,255 @@
1
- /**
2
- * render-html.js — HTML → PNG(调用本机已安装的浏览器无头模式)。
3
- *
4
- * **为什么必须有**:很多桌面软件的内部 UI 本身就是 web 版;不能渲染 HTML 就等于
5
- * “看不到自己的输出”,排版/间距/密度的问题根本无法发现。实测:参照稿在静态体检
6
- * 拿到 clean 之后,一渲染就看出头像权重、行高、字号全线偏大。
7
- *
8
- * **对“零外部进程”承诺的取舍**:核心渲染(SVG)依旧完全自包含(vendored resvg);
9
- * 只有 HTML 这一项需要本机浏览器。这是有意为之:
10
- * - 自己写 HTML/CSS 布局引擎去“验证”用户界面的布局,等于用可能错的尺子量东西,
11
- * 结论不可信;真实浏览器的引擎才是唯一可信的参照;
12
- * - 探测不到浏览器时**明确报错并给方案**,绝不静默回退成“看起来对”的假渲染。
13
- */
14
- import { spawnSync } from "node:child_process";
15
- import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
- import { tmpdir } from "node:os";
17
- import { join, resolve } from "node:path";
18
- import { pathToFileURL } from "node:url";
19
-
20
- /** 常见安装位置(Windows 优先,macOS/Linux 也列上)。按可靠性排序。 */
21
- const CANDIDATES = [
22
- // Windows
23
- "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
24
- "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
25
- "C:/Program Files/Google/Chrome/Application/chrome.exe",
26
- "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
27
- // macOS
28
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
29
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
30
- // Linux
31
- "/usr/bin/google-chrome",
32
- "/usr/bin/chromium",
33
- "/usr/bin/chromium-browser",
34
- "/usr/bin/microsoft-edge"
35
- ];
36
-
37
- /**
38
- * 找一个可用的 Chromium 系浏览器。
39
- * @param {string} [explicit] 配置里明确指定的路径(优先级最高)
40
- * @returns {{path: string, source: "config"|"detected"}|null}
41
- */
42
- export function findBrowser(explicit) {
43
- if (typeof explicit === "string" && explicit.trim() !== "") {
44
- const p = resolve(explicit.trim());
45
- return existsSync(p) ? { path: p, source: "config" } : null;
46
- }
47
- for (const candidate of CANDIDATES) {
48
- if (existsSync(candidate)) return { path: candidate, source: "detected" };
49
- }
50
- return null;
51
- }
52
-
53
- /** 探测不到时的错误文案:给可执行的下一步,而不是只说“没找到”。 */
54
- export function missingBrowserError() {
55
- return new Error(
56
- "没有找到可用于渲染 HTML 的浏览器。\n" +
57
- "HTML 渲染需要本机已安装 Chromium 系浏览器(Edge / Chrome / Chromium)。\n" +
58
- "可选做法:1) 安装 Edge 或 Chrome;2) 在插件配置里指定 browserPath 指向浏览器可执行文件;" +
59
- "3) 改用 action=render 处理 SVG(完全离线,不依赖浏览器)。"
60
- );
61
- }
62
-
63
- /**
64
- * 量出页面真实内容高(CSS px)。
65
- *
66
- * 为什么不用 `--screenshot` 自己“整页”:Chromium 无头模式的所有截图都只覆盖视口
67
- * (`--screenshot` 不传 `--window-size` 时用默认窗口),拿不到内容高就必须先算出来。
68
- * 这里在副本里插一段脚本,让**页面自己**把 scrollHeight 写进 title,用 `--dump-dom`
69
- * 读回来——全程不依赖 CDP,也不依赖页面配合。
70
- *
71
- * 副本里同时插 `<base href>`:副本在临时目录,不补 base 的话相对路径的 CSS/图片全部
72
- * 404,量到的是裸文本高度(会偏小得离谱)。
73
- */
74
- function measureHeight({ browser, pageUrl, html, baseUrl, width, timeoutMs }) {
75
- const dir = mkdtempSync(join(tmpdir(), "dsh-measure-"));
76
- const profileDir = mkdtempSync(join(tmpdir(), "dsh-chrome-"));
77
- const file = join(dir, "measure.html");
78
- const inject =
79
- `<base href="${baseUrl}"><script>addEventListener("load",()=>{` +
80
- `const d=document.documentElement;` +
81
- `document.title="H:"+Math.max(d.scrollHeight,d.getBoundingClientRect().height)});</script>`;
82
- // 插在 <head> 后面(base 必须先于其它相对 URL 出现);没有 head 就塞到 <html> 后
83
- let copy = String(html);
84
- const headAt = copy.search(/<head[^>]*>/i);
85
- if (headAt === -1) copy = inject + copy;
86
- else {
87
- const end = headAt + copy.slice(headAt).indexOf(">") + 1;
88
- copy = copy.slice(0, end) + inject + copy.slice(end);
89
- }
90
- writeFileSync(file, copy, "utf8");
91
-
92
- try {
93
- const run = spawnSync(
94
- browser.path,
95
- [
96
- "--headless=new",
97
- "--disable-gpu",
98
- "--hide-scrollbars",
99
- "--no-first-run",
100
- "--no-default-browser-check",
101
- "--disable-extensions",
102
- "--disable-lcd-text",
103
- `--window-size=${width},800`,
104
- "--virtual-time-budget=3000",
105
- "--dump-dom",
106
- `--user-data-dir=${profileDir}`,
107
- pathToFileURL(file).href
108
- ],
109
- { windowsHide: true, timeout: timeoutMs, encoding: "utf8" }
110
- );
111
- const hit = /H:(\d+)/.exec(String(run.stdout ?? ""));
112
- if (hit === null) {
113
- throw new Error(
114
- "整页截图需要先量内容高,但页面没有回报高度(--dump-dom 输出里没有 H: 标记,页面可能报了 JS 错)。\n" +
115
- `改成给明确 height 的视口截图,或手动跑:\n "${browser.path}" --headless=new --dump-dom "${pageUrl}"`
116
- );
117
- }
118
- // 卡上限:防止某层 height:100vh 把“整页”拉成几万像素而截不出来
119
- return Math.min(30_000, Math.max(1, Number.parseInt(hit[1], 10)));
120
- } finally {
121
- for (const d of [dir, profileDir]) rmSync(d, { recursive: true, force: true });
122
- }
123
- }
124
-
125
- /** 读 PNG 的 IHDR 取真实像素尺寸;解析失败返回 0(不因回报失败而连渲染一起报错)。 */
126
- function pngSize(file) {
127
- try {
128
- const buf = readFileSync(file);
129
- const sig = buf.subarray(0, 8).toString("hex");
130
- if (sig !== "89504e470d0a1a0a") return { width: 0, height: 0 };
131
- return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
132
- } catch {
133
- return { width: 0, height: 0 };
134
- }
135
- }
136
-
137
- /**
138
- * 把 HTML 渲染成 PNG。
139
- * @param {object} opts
140
- * @param {string} opts.html HTML 文件路径,或内联 HTML 标记
141
- * @param {string} opts.out 输出 .png 绝对路径
142
- * @param {number} [opts.width] 视口宽(默认 1280)
143
- * @param {number} [opts.height] 视口高(默认 800);传 "full" 语义用整页截图
144
- * @param {number} [opts.scale] 设备像素比(默认 1;2 = 两倍图)
145
- * @param {boolean} [opts.fullPage] 整页截图(两趟:先量出内容高,再按该高截图)
146
- * @param {string} [opts.browserPath] 浏览器可执行文件
147
- * @param {number} [opts.timeoutMs] 超时(默认 45s)
148
- */
149
- export function renderHtml(opts) {
150
- const browser = findBrowser(opts.browserPath);
151
- if (browser === null) throw missingBrowserError();
152
-
153
- const width = Number.isFinite(opts.width) ? Math.max(1, Math.round(opts.width)) : 1280;
154
- const height = Number.isFinite(opts.height) ? Math.max(1, Math.round(opts.height)) : 800;
155
- const scale = Number.isFinite(opts.scale) ? Math.min(4, Math.max(0.5, opts.scale)) : 1;
156
- const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 45_000;
157
-
158
- // 内联 HTML 先落到临时文件(浏览器只接受 URL/路径)
159
- let pageUrl;
160
- let tempDir = null;
161
- const raw = String(opts.html ?? "");
162
- const looksLikePath = /[\\/]|\.html?$/i.test(raw) && !/[<>]/.test(raw);
163
- if (looksLikePath && existsSync(resolve(raw))) {
164
- pageUrl = pathToFileURL(resolve(raw)).href;
165
- } else {
166
- tempDir = mkdtempSync(join(tmpdir(), "dsh-html-"));
167
- const file = join(tempDir, "page.html");
168
- writeFileSync(file, raw, "utf8");
169
- pageUrl = pathToFileURL(file).href;
170
- }
171
-
172
- // 输出路径必须**绝对化**:浏览器以临时目录为 cwd 启动,相对路径会落到别处
173
- // (实测报“Failed to write file ... 系统找不到指定的路径”)。
174
- const outAbs = resolve(opts.out);
175
- const profileDir = mkdtempSync(join(tmpdir(), "dsh-chrome-"));
176
-
177
- // 整页截图必须**两趟**。
178
- //
179
- // 曾经直接写成 `--screenshot`(不带 --window-size)就当成整页——实际上浏览器会按
180
- // 自己的默认窗口(实测 750×485)截,既不是整页也不是调用方传的宽高,
181
- // 但返回值里又把 width/height 原样回填,于是“报 1100×1500、实际 750×485”,
182
- // 看上去成功、图却是残的(靠人眼看图才发现)。
183
- //
184
- // 正确做法:先用 `--dump-dom` 让页面自己把 scrollHeight 写进 title,读出真实内容高,
185
- // 再用 `--window-size=W,内容高` 截第二趟。测高副本里插 `<base>`,保证相对路径的
186
- // CSS/图片仍按原目录解析(否则测高页面是裸文本,高度全错)。
187
- let shotHeight = height;
188
- let measured = false;
189
- if (opts.fullPage === true) {
190
- shotHeight = measureHeight({
191
- browser, pageUrl, width, timeoutMs,
192
- html: looksLikePath && existsSync(resolve(raw)) ? readFileSync(resolve(raw), "utf8") : raw,
193
- baseUrl: new URL(".", pageUrl).href
194
- });
195
- measured = true;
196
- }
197
-
198
- const args = [
199
- "--headless=new",
200
- "--disable-gpu",
201
- "--hide-scrollbars",
202
- "--no-first-run",
203
- "--no-default-browser-check",
204
- "--disable-extensions",
205
- // 关掉 LCD 子像素渲染:否则文字边缘会出现橙/蓝彩色条纹,
206
- // 按像素量对比度时会把它们当成“低对比文字”(实测报出 1.97:1 的假结果)。
207
- // 灰度抗锯齿下边缘像素与文字同色,量出来的才是真正的对比度。
208
- "--disable-lcd-text",
209
- `--force-device-scale-factor=${scale}`,
210
- `--window-size=${width},${shotHeight}`,
211
- "--virtual-time-budget=3000",
212
- `--screenshot=${outAbs}`,
213
- `--user-data-dir=${profileDir}`,
214
- pageUrl
215
- ];
216
-
217
- try {
218
- const run = spawnSync(browser.path, args, {
219
- windowsHide: true,
220
- timeout: timeoutMs,
221
- encoding: "utf8",
222
- cwd: tempDir ?? undefined
223
- });
224
- // Chromium 系即使成功也常往 stderr 写噪音(如 QQBrowser 导入器),不能用 stderr 判成败
225
- if (!existsSync(outAbs)) {
226
- const stderr = String(run.stderr ?? "").trim().split("\n").slice(-4).join("\n");
227
- throw new Error(
228
- `HTML 渲染未产出文件(浏览器退出码 ${run.status ?? "null"})。\n` +
229
- `可能的下一步:1) 确认路径可写;2) 加大 timeoutMs;3) 手动跑一次:\n` +
230
- ` "${browser.path}" ${args.join(" ")}\n` +
231
- (stderr === "" ? "" : `浏览器输出:\n${stderr}`)
232
- );
233
- }
234
- // 回报**实测**尺寸而不是请求尺寸:读 PNG 的 IHDR,报假数字比报错更坏
235
- const actual = pngSize(outAbs);
236
- return {
237
- path: outAbs,
238
- bytes: statSync(outAbs).size,
239
- width: actual.width === 0 ? width : Math.round(actual.width / scale),
240
- height: actual.height === 0 ? shotHeight : Math.round(actual.height / scale),
241
- pixelWidth: actual.width,
242
- pixelHeight: actual.height,
243
- scale,
244
- fullPage: opts.fullPage === true,
245
- measured,
246
- browser: browser.path,
247
- browserSource: browser.source
248
- };
249
- } finally {
250
- for (const dir of [tempDir, profileDir]) {
251
- if (dir !== null) rmSync(dir, { recursive: true, force: true });
252
- }
253
- }
254
- }
255
-
1
+ /**
2
+ * render-html.js — HTML → PNG(调用本机已安装的浏览器无头模式)。
3
+ *
4
+ * **为什么必须有**:很多桌面软件的内部 UI 本身就是 web 版;不能渲染 HTML 就等于
5
+ * “看不到自己的输出”,排版/间距/密度的问题根本无法发现。实测:参照稿在静态体检
6
+ * 拿到 clean 之后,一渲染就看出头像权重、行高、字号全线偏大。
7
+ *
8
+ * **对“零外部进程”承诺的取舍**:核心渲染(SVG)依旧完全自包含(vendored resvg);
9
+ * 只有 HTML 这一项需要本机浏览器。这是有意为之:
10
+ * - 自己写 HTML/CSS 布局引擎去“验证”用户界面的布局,等于用可能错的尺子量东西,
11
+ * 结论不可信;真实浏览器的引擎才是唯一可信的参照;
12
+ * - 探测不到浏览器时**明确报错并给方案**,绝不静默回退成“看起来对”的假渲染。
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join, resolve } from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+
20
+ /** 常见安装位置(Windows 优先,macOS/Linux 也列上)。按可靠性排序。 */
21
+ const CANDIDATES = [
22
+ // Windows
23
+ "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
24
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
25
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
26
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
27
+ // macOS
28
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
29
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
30
+ // Linux
31
+ "/usr/bin/google-chrome",
32
+ "/usr/bin/chromium",
33
+ "/usr/bin/chromium-browser",
34
+ "/usr/bin/microsoft-edge"
35
+ ];
36
+
37
+ /**
38
+ * 找一个可用的 Chromium 系浏览器。
39
+ * @param {string} [explicit] 配置里明确指定的路径(优先级最高)
40
+ * @returns {{path: string, source: "config"|"detected"}|null}
41
+ */
42
+ export function findBrowser(explicit) {
43
+ if (typeof explicit === "string" && explicit.trim() !== "") {
44
+ const p = resolve(explicit.trim());
45
+ return existsSync(p) ? { path: p, source: "config" } : null;
46
+ }
47
+ for (const candidate of CANDIDATES) {
48
+ if (existsSync(candidate)) return { path: candidate, source: "detected" };
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** 探测不到时的错误文案:给可执行的下一步,而不是只说“没找到”。 */
54
+ export function missingBrowserError() {
55
+ return new Error(
56
+ "没有找到可用于渲染 HTML 的浏览器。\n" +
57
+ "HTML 渲染需要本机已安装 Chromium 系浏览器(Edge / Chrome / Chromium)。\n" +
58
+ "可选做法:1) 安装 Edge 或 Chrome;2) 在插件配置里指定 browserPath 指向浏览器可执行文件;" +
59
+ "3) 改用 action=render 处理 SVG(完全离线,不依赖浏览器)。"
60
+ );
61
+ }
62
+
63
+ /**
64
+ * 量出页面真实内容高(CSS px)。
65
+ *
66
+ * 为什么不用 `--screenshot` 自己“整页”:Chromium 无头模式的所有截图都只覆盖视口
67
+ * (`--screenshot` 不传 `--window-size` 时用默认窗口),拿不到内容高就必须先算出来。
68
+ * 这里在副本里插一段脚本,让**页面自己**把 scrollHeight 写进 title,用 `--dump-dom`
69
+ * 读回来——全程不依赖 CDP,也不依赖页面配合。
70
+ *
71
+ * 副本里同时插 `<base href>`:副本在临时目录,不补 base 的话相对路径的 CSS/图片全部
72
+ * 404,量到的是裸文本高度(会偏小得离谱)。
73
+ */
74
+ function measureHeight({ browser, pageUrl, html, baseUrl, width, timeoutMs }) {
75
+ const dir = mkdtempSync(join(tmpdir(), "dsh-measure-"));
76
+ const profileDir = mkdtempSync(join(tmpdir(), "dsh-chrome-"));
77
+ const file = join(dir, "measure.html");
78
+ const inject =
79
+ `<base href="${baseUrl}"><script>addEventListener("load",()=>{` +
80
+ `const d=document.documentElement;` +
81
+ `document.title="H:"+Math.max(d.scrollHeight,d.getBoundingClientRect().height)});</script>`;
82
+ // 插在 <head> 后面(base 必须先于其它相对 URL 出现);没有 head 就塞到 <html> 后
83
+ let copy = String(html);
84
+ const headAt = copy.search(/<head[^>]*>/i);
85
+ if (headAt === -1) copy = inject + copy;
86
+ else {
87
+ const end = headAt + copy.slice(headAt).indexOf(">") + 1;
88
+ copy = copy.slice(0, end) + inject + copy.slice(end);
89
+ }
90
+ writeFileSync(file, copy, "utf8");
91
+
92
+ try {
93
+ const run = spawnSync(
94
+ browser.path,
95
+ [
96
+ "--headless=new",
97
+ "--disable-gpu",
98
+ "--hide-scrollbars",
99
+ "--no-first-run",
100
+ "--no-default-browser-check",
101
+ "--disable-extensions",
102
+ "--disable-lcd-text",
103
+ `--window-size=${width},800`,
104
+ "--virtual-time-budget=3000",
105
+ "--dump-dom",
106
+ `--user-data-dir=${profileDir}`,
107
+ pathToFileURL(file).href
108
+ ],
109
+ { windowsHide: true, timeout: timeoutMs, encoding: "utf8" }
110
+ );
111
+ const hit = /H:(\d+)/.exec(String(run.stdout ?? ""));
112
+ if (hit === null) {
113
+ throw new Error(
114
+ "整页截图需要先量内容高,但页面没有回报高度(--dump-dom 输出里没有 H: 标记,页面可能报了 JS 错)。\n" +
115
+ `改成给明确 height 的视口截图,或手动跑:\n "${browser.path}" --headless=new --dump-dom "${pageUrl}"`
116
+ );
117
+ }
118
+ // 卡上限:防止某层 height:100vh 把“整页”拉成几万像素而截不出来
119
+ return Math.min(30_000, Math.max(1, Number.parseInt(hit[1], 10)));
120
+ } finally {
121
+ for (const d of [dir, profileDir]) rmSync(d, { recursive: true, force: true });
122
+ }
123
+ }
124
+
125
+ /** 读 PNG 的 IHDR 取真实像素尺寸;解析失败返回 0(不因回报失败而连渲染一起报错)。 */
126
+ function pngSize(file) {
127
+ try {
128
+ const buf = readFileSync(file);
129
+ const sig = buf.subarray(0, 8).toString("hex");
130
+ if (sig !== "89504e470d0a1a0a") return { width: 0, height: 0 };
131
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
132
+ } catch {
133
+ return { width: 0, height: 0 };
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 把 HTML 渲染成 PNG。
139
+ * @param {object} opts
140
+ * @param {string} opts.html HTML 文件路径,或内联 HTML 标记
141
+ * @param {string} opts.out 输出 .png 绝对路径
142
+ * @param {number} [opts.width] 视口宽(默认 1280)
143
+ * @param {number} [opts.height] 视口高(默认 800);传 "full" 语义用整页截图
144
+ * @param {number} [opts.scale] 设备像素比(默认 1;2 = 两倍图)
145
+ * @param {boolean} [opts.fullPage] 整页截图(两趟:先量出内容高,再按该高截图)
146
+ * @param {string} [opts.browserPath] 浏览器可执行文件
147
+ * @param {number} [opts.timeoutMs] 超时(默认 45s)
148
+ */
149
+ export function renderHtml(opts) {
150
+ const browser = findBrowser(opts.browserPath);
151
+ if (browser === null) throw missingBrowserError();
152
+
153
+ const width = Number.isFinite(opts.width) ? Math.max(1, Math.round(opts.width)) : 1280;
154
+ const height = Number.isFinite(opts.height) ? Math.max(1, Math.round(opts.height)) : 800;
155
+ const scale = Number.isFinite(opts.scale) ? Math.min(4, Math.max(0.5, opts.scale)) : 1;
156
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 45_000;
157
+
158
+ // 内联 HTML 先落到临时文件(浏览器只接受 URL/路径)
159
+ let pageUrl;
160
+ let tempDir = null;
161
+ const raw = String(opts.html ?? "");
162
+ const looksLikePath = /[\\/]|\.html?$/i.test(raw) && !/[<>]/.test(raw);
163
+ if (looksLikePath && existsSync(resolve(raw))) {
164
+ pageUrl = pathToFileURL(resolve(raw)).href;
165
+ } else {
166
+ tempDir = mkdtempSync(join(tmpdir(), "dsh-html-"));
167
+ const file = join(tempDir, "page.html");
168
+ writeFileSync(file, raw, "utf8");
169
+ pageUrl = pathToFileURL(file).href;
170
+ }
171
+
172
+ // 输出路径必须**绝对化**:浏览器以临时目录为 cwd 启动,相对路径会落到别处
173
+ // (实测报“Failed to write file ... 系统找不到指定的路径”)。
174
+ const outAbs = resolve(opts.out);
175
+ const profileDir = mkdtempSync(join(tmpdir(), "dsh-chrome-"));
176
+
177
+ // 整页截图必须**两趟**。
178
+ //
179
+ // 曾经直接写成 `--screenshot`(不带 --window-size)就当成整页——实际上浏览器会按
180
+ // 自己的默认窗口(实测 750×485)截,既不是整页也不是调用方传的宽高,
181
+ // 但返回值里又把 width/height 原样回填,于是“报 1100×1500、实际 750×485”,
182
+ // 看上去成功、图却是残的(靠人眼看图才发现)。
183
+ //
184
+ // 正确做法:先用 `--dump-dom` 让页面自己把 scrollHeight 写进 title,读出真实内容高,
185
+ // 再用 `--window-size=W,内容高` 截第二趟。测高副本里插 `<base>`,保证相对路径的
186
+ // CSS/图片仍按原目录解析(否则测高页面是裸文本,高度全错)。
187
+ let shotHeight = height;
188
+ let measured = false;
189
+ if (opts.fullPage === true) {
190
+ shotHeight = measureHeight({
191
+ browser, pageUrl, width, timeoutMs,
192
+ html: looksLikePath && existsSync(resolve(raw)) ? readFileSync(resolve(raw), "utf8") : raw,
193
+ baseUrl: new URL(".", pageUrl).href
194
+ });
195
+ measured = true;
196
+ }
197
+
198
+ const args = [
199
+ "--headless=new",
200
+ "--disable-gpu",
201
+ "--hide-scrollbars",
202
+ "--no-first-run",
203
+ "--no-default-browser-check",
204
+ "--disable-extensions",
205
+ // 关掉 LCD 子像素渲染:否则文字边缘会出现橙/蓝彩色条纹,
206
+ // 按像素量对比度时会把它们当成“低对比文字”(实测报出 1.97:1 的假结果)。
207
+ // 灰度抗锯齿下边缘像素与文字同色,量出来的才是真正的对比度。
208
+ "--disable-lcd-text",
209
+ `--force-device-scale-factor=${scale}`,
210
+ `--window-size=${width},${shotHeight}`,
211
+ "--virtual-time-budget=3000",
212
+ `--screenshot=${outAbs}`,
213
+ `--user-data-dir=${profileDir}`,
214
+ pageUrl
215
+ ];
216
+
217
+ try {
218
+ const run = spawnSync(browser.path, args, {
219
+ windowsHide: true,
220
+ timeout: timeoutMs,
221
+ encoding: "utf8",
222
+ cwd: tempDir ?? undefined
223
+ });
224
+ // Chromium 系即使成功也常往 stderr 写噪音(如 QQBrowser 导入器),不能用 stderr 判成败
225
+ if (!existsSync(outAbs)) {
226
+ const stderr = String(run.stderr ?? "").trim().split("\n").slice(-4).join("\n");
227
+ throw new Error(
228
+ `HTML 渲染未产出文件(浏览器退出码 ${run.status ?? "null"})。\n` +
229
+ `可能的下一步:1) 确认路径可写;2) 加大 timeoutMs;3) 手动跑一次:\n` +
230
+ ` "${browser.path}" ${args.join(" ")}\n` +
231
+ (stderr === "" ? "" : `浏览器输出:\n${stderr}`)
232
+ );
233
+ }
234
+ // 回报**实测**尺寸而不是请求尺寸:读 PNG 的 IHDR,报假数字比报错更坏
235
+ const actual = pngSize(outAbs);
236
+ return {
237
+ path: outAbs,
238
+ bytes: statSync(outAbs).size,
239
+ width: actual.width === 0 ? width : Math.round(actual.width / scale),
240
+ height: actual.height === 0 ? shotHeight : Math.round(actual.height / scale),
241
+ pixelWidth: actual.width,
242
+ pixelHeight: actual.height,
243
+ scale,
244
+ fullPage: opts.fullPage === true,
245
+ measured,
246
+ browser: browser.path,
247
+ browserSource: browser.source
248
+ };
249
+ } finally {
250
+ for (const dir of [tempDir, profileDir]) {
251
+ if (dir !== null) rmSync(dir, { recursive: true, force: true });
252
+ }
253
+ }
254
+ }
255
+