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.
- package/CHANGELOG.md +14 -0
- package/README.md +20 -4
- package/data/assets/illustrations/openpeeps/LICENSE +1 -1
- package/lib/contrast-render.js +213 -213
- package/lib/detect.js +701 -701
- package/lib/diff.js +153 -153
- package/lib/icon-render.js +107 -107
- package/lib/index.js +1289 -1289
- package/lib/layouts/image.js +1010 -1010
- package/lib/layouts/source.js +1961 -1961
- package/lib/render-html.js +255 -255
- package/lib/rules.js +267 -267
- package/lib/style.js +688 -688
- package/lib/svg.js +729 -729
- package/lib/taste.js +219 -219
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
本文件记录所有值得注意的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
|
4
4
|
版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
|
|
5
5
|
|
|
6
|
+
## [0.0.2] — 2026-11-09
|
|
7
|
+
|
|
8
|
+
安装文档与工程流程修正,功能与契约不变(`0.0.x`)。
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **安装说明重写**:改为「DSH 内按包名安装(推荐)/ 命令行 / 源码」三种方式,对齐同系列插件;顶部加 npm 版本、平台与许可徽章。
|
|
13
|
+
- **README 不再写死版本号**:版本以 npm 徽章与 `npm view dsh-visualkit version` 为准,运行时要求指向 `package.json` 的 `engines`,避免每次发版后说明过期。
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **CI 四处修正**:测试脚本改显式 glob(Node 22 不接受目录参数);ubuntu 作业 `npm ci --force`(根包声明 `os: win32`);包测试自带 tar 解析(GitHub Windows runner 读不了中文文件名);渲染类用例只在 win32 跑(无 resvg 绑定、字体校准差异)。
|
|
18
|
+
- **发布工作流**:`release.yml` 同样改用 `npm ci --force`,推送 tag 即可自动发布并附带 provenance。
|
|
19
|
+
|
|
6
20
|
## [0.0.1] — 2026-11-08
|
|
7
21
|
|
|
8
22
|
首个公开发布版本。
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-visualkit
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-visualkit) [](#安装) [](LICENSE)
|
|
4
|
+
|
|
3
5
|
**视觉设计工具箱**:把「好看」变成可执行、可验收的东西。
|
|
4
6
|
|
|
5
7
|
> 模型不缺产能,缺美术判断:它做出的界面能跑但难看——字号没有层级、间距靠手调、图标用 emoji、配色是紫渐变。`dsh-visualkit` 提供一条工艺链:**取真资产 → 定规格 → 量审美 → 出成品**,全部本地离线,不依赖任何系统软件。
|
|
@@ -13,7 +15,7 @@
|
|
|
13
15
|
| 项 | 值 |
|
|
14
16
|
|---|---|
|
|
15
17
|
| 包名 / 仓库 | `dsh-visualkit` |
|
|
16
|
-
|
|
|
18
|
+
| 最新版本 | 见顶部 npm 徽章,或 `npm view dsh-visualkit version` |
|
|
17
19
|
| 许可 | MIT |
|
|
18
20
|
| 外部依赖 | **无**(resvg 已 vendored,不需要 Python / Krita / Playwright / 外部工作区) |
|
|
19
21
|
|
|
@@ -32,13 +34,27 @@
|
|
|
32
34
|
|
|
33
35
|
## 安装
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
**方式一:DSH 内安装(推荐)**
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
1. 打开 DSH →「插件」→「添加插件」
|
|
40
|
+
2. 输入包名 `dsh-visualkit`,安装源保持「npm 官方源」
|
|
41
|
+
3. 点「安装」,装好后重启 DSH 生效
|
|
42
|
+
|
|
43
|
+
**方式二:命令行(同样走 npm)**
|
|
44
|
+
|
|
45
|
+
```bash
|
|
38
46
|
dsh plugin add dsh-visualkit
|
|
39
47
|
```
|
|
40
48
|
|
|
41
|
-
|
|
49
|
+
**方式三:源码**(开发或未发布版本)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/ikeyuchen/dsh-visualkit.git
|
|
53
|
+
dsh plugin --profile web add ./dsh-visualkit
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
运行环境:Windows x64、Node 与 DSH 版本要求见 `package.json` 的 `engines` / `os` / `cpu`。
|
|
57
|
+
改本地代码后只需触发一次 profile 重组合,不必重装。
|
|
42
58
|
|
|
43
59
|
### 按需加载:`toolsetProfile`
|
|
44
60
|
|
|
@@ -5,4 +5,4 @@ License: CC0 1.0 Universal (public domain dedication)
|
|
|
5
5
|
License URL: https://creativecommons.org/publicdomain/zero/1.0/
|
|
6
6
|
|
|
7
7
|
The SVG files in this directory are from the Open Peeps library.
|
|
8
|
-
CC0 requires no attribution; this notice is kept for provenance only.
|
|
8
|
+
CC0 requires no attribution; this notice is kept for provenance only.
|
package/lib/contrast-render.js
CHANGED
|
@@ -1,213 +1,213 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* contrast-render.js — 在**真实渲染像素**上量文字对比度。
|
|
3
|
-
*
|
|
4
|
-
* 为什么需要它:静态解析 CSS 遇到 `rgba()`、CSS 变量、渐变背景时算不出真实对比度,
|
|
5
|
-
* 只能诚实跳过(就是体检里的 contrast-invalid)。而这三类恰好是现在最常用的写法。
|
|
6
|
-
*
|
|
7
|
-
* 做法(不靠 OCR、不靠 DOM):同一页面渲两遍——
|
|
8
|
-
* A:正常渲染 → 某个像素 = 文字色与背景色的混合结果
|
|
9
|
-
* B:把文字染成透明(`color: transparent`),布局不变 → 该像素 = **真实背景**
|
|
10
|
-
* A 与 B 差异大的地方就是文字墨迹;把墨迹最深处(最接近纯文字色)和它背后的 B
|
|
11
|
-
* 像素拿来算 WCAG 对比度。渐变、图片、半透明图层全部自动覆盖——因为量的是像素。
|
|
12
|
-
*
|
|
13
|
-
* 为什么不取纯前景色:文字有抗锯齿与字重差异,细字的“有效对比度”本来就低于
|
|
14
|
-
* 声明值。直接量像素反而更接近人眼看到的。
|
|
15
|
-
*/
|
|
16
|
-
import { relativeLuminance } from "./layouts/rules-contrast.js";
|
|
17
|
-
import { decodePng } from "./layouts/png.js";
|
|
18
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
19
|
-
import { renderHtml } from "./render-html.js";
|
|
20
|
-
|
|
21
|
-
/** 把页面里的文字全部染成透明(保留布局),用于取得“真实背景”。 */
|
|
22
|
-
export function hideTextCss() {
|
|
23
|
-
return `*{color:transparent !important;text-shadow:none !important;caret-color:transparent !important;}`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** 把注入用的 <style> 插到 </head> 前;没有 head 就插到最前。 */
|
|
27
|
-
export function injectCss(html, css) {
|
|
28
|
-
const tag = `<style data-dsh-inject>${css}</style>`;
|
|
29
|
-
const at = html.toLowerCase().lastIndexOf("</head>");
|
|
30
|
-
if (at !== -1) return html.slice(0, at) + tag + html.slice(at);
|
|
31
|
-
return tag + html;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const srgb = (v) => {
|
|
35
|
-
const c = v / 255;
|
|
36
|
-
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
37
|
-
};
|
|
38
|
-
const lum = (r, g, b) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
|
39
|
-
|
|
40
|
-
/** WCAG 对比度(1–21),两端用像素实际值。 */
|
|
41
|
-
export function pixelContrast(a, b) {
|
|
42
|
-
const la = lum(a[0], a[1], a[2]);
|
|
43
|
-
const lb = lum(b[0], b[1], b[2]);
|
|
44
|
-
const hi = Math.max(la, lb);
|
|
45
|
-
const lo = Math.min(la, lb);
|
|
46
|
-
return (hi + 0.05) / (lo + 0.05);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* 量一张页面的真实文字对比度。
|
|
51
|
-
* @param {object} opts
|
|
52
|
-
* @param {string} opts.htmlPath HTML 文件路径
|
|
53
|
-
* @param {string} opts.outDir 两遍渲染的临时输出目录
|
|
54
|
-
* @param {number} [opts.width] 视口宽
|
|
55
|
-
* @param {number} [opts.height] 视口高
|
|
56
|
-
* @param {string} [opts.browserPath]
|
|
57
|
-
* @returns {{worst:object, samples:number, pairs:object[], rendered:boolean, reason?:string}}
|
|
58
|
-
*/
|
|
59
|
-
export function measureRenderedContrast(opts) {
|
|
60
|
-
const width = Number.isFinite(opts.width) ? opts.width : 1280;
|
|
61
|
-
const height = Number.isFinite(opts.height) ? opts.height : 800;
|
|
62
|
-
const plain = `${opts.outDir}/__contrast_plain.png`;
|
|
63
|
-
const bare = `${opts.outDir}/__contrast_bare.png`;
|
|
64
|
-
|
|
65
|
-
const barePath = `${opts.outDir}/__contrast_bare.html`;
|
|
66
|
-
|
|
67
|
-
// 读文件与渲染都在 try 里:缺文件、没浏览器、路径不可写,都应该返回结构化的
|
|
68
|
-
// rendered:false + reason,而不是把异常抛给调用方(那样上层就没法降级了)。
|
|
69
|
-
try {
|
|
70
|
-
const source = readFileSync(opts.htmlPath, "utf8");
|
|
71
|
-
writeFileSync(barePath, injectCss(source, hideTextCss()), "utf8");
|
|
72
|
-
renderHtml({ html: opts.htmlPath, out: plain, width, height, browserPath: opts.browserPath });
|
|
73
|
-
renderHtml({ html: barePath, out: bare, width, height, browserPath: opts.browserPath });
|
|
74
|
-
} catch (error) {
|
|
75
|
-
return { rendered: false, reason: error.message, worst: null, samples: 0, pairs: [] };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const A = decodePng(readFileSync(plain));
|
|
79
|
-
const B = decodePng(readFileSync(bare));
|
|
80
|
-
const w = Math.min(A.width, B.width);
|
|
81
|
-
const h = Math.min(A.height, B.height);
|
|
82
|
-
const ch = A.channels;
|
|
83
|
-
const chB = B.channels;
|
|
84
|
-
const at = (img, c, x, y) => {
|
|
85
|
-
const i = (y * img.width + x) * c;
|
|
86
|
-
return [img.data[i], img.data[i + 1], img.data[i + 2]];
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
// 连通域分组:一个文字块就是一个连通域,里面**最墨的像素**才是它的本色。
|
|
90
|
-
// 为什么不用「按颜色聚类」:抗锯齿边缘的颜色恰好是“背景到文字色”的中间调,
|
|
91
|
-
// 会自成一个簇,被当成一种低对比文字报出来(实测误报 1.97:1 / 2.23:1)。
|
|
92
|
-
// 连通域天然把边缘和本体绑在一起,取域内 max-delta 像素就拿到纯色。
|
|
93
|
-
const W2 = w;
|
|
94
|
-
const H2 = h;
|
|
95
|
-
const inked = new Uint8Array(W2 * H2);
|
|
96
|
-
const deltas = new Uint16Array(W2 * H2);
|
|
97
|
-
let totalSamples = 0;
|
|
98
|
-
for (let y = 0; y < H2; y += 1) {
|
|
99
|
-
for (let x = 0; x < W2; x += 1) {
|
|
100
|
-
const a = at(A, ch, x, y);
|
|
101
|
-
const b = at(B, chB, x, y);
|
|
102
|
-
const d = Math.round((Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3);
|
|
103
|
-
if (d < 40) continue;
|
|
104
|
-
inked[y * W2 + x] = 1;
|
|
105
|
-
deltas[y * W2 + x] = d;
|
|
106
|
-
totalSamples += 1;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const labels = new Int32Array(W2 * H2).fill(-1);
|
|
111
|
-
const parent = [];
|
|
112
|
-
const find = (i) => {
|
|
113
|
-
let r = i;
|
|
114
|
-
while (parent[r] !== r) r = parent[r];
|
|
115
|
-
while (parent[i] !== r) {
|
|
116
|
-
const next = parent[i];
|
|
117
|
-
parent[i] = r;
|
|
118
|
-
i = next;
|
|
119
|
-
}
|
|
120
|
-
return r;
|
|
121
|
-
};
|
|
122
|
-
const union = (a1, b1) => {
|
|
123
|
-
const ra = find(a1);
|
|
124
|
-
const rb = find(b1);
|
|
125
|
-
if (ra !== rb) parent[rb] = ra;
|
|
126
|
-
};
|
|
127
|
-
let next = 0;
|
|
128
|
-
for (let y = 0; y < H2; y += 1) {
|
|
129
|
-
for (let x = 0; x < W2; x += 1) {
|
|
130
|
-
const idx = y * W2 + x;
|
|
131
|
-
if (inked[idx] === 0) continue;
|
|
132
|
-
const left = x > 0 ? labels[idx - 1] : -1;
|
|
133
|
-
const up = y > 0 ? labels[idx - W2] : -1;
|
|
134
|
-
if (left === -1 && up === -1) {
|
|
135
|
-
labels[idx] = next;
|
|
136
|
-
parent[next] = next;
|
|
137
|
-
next += 1;
|
|
138
|
-
} else if (left !== -1 && up !== -1) {
|
|
139
|
-
labels[idx] = Math.min(left, up);
|
|
140
|
-
union(left, up);
|
|
141
|
-
} else {
|
|
142
|
-
labels[idx] = left !== -1 ? left : up;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// 汇总每个连通域:像素数 + 最墨的那个像素(它的 fg/bg 就是该文字的纯色与真实背景)
|
|
148
|
-
const comps = new Map();
|
|
149
|
-
for (let y = 0; y < H2; y += 1) {
|
|
150
|
-
for (let x = 0; x < W2; x += 1) {
|
|
151
|
-
const idx = y * W2 + x;
|
|
152
|
-
if (labels[idx] === -1) continue;
|
|
153
|
-
const root = find(labels[idx]);
|
|
154
|
-
const d = deltas[idx];
|
|
155
|
-
const cur = comps.get(root);
|
|
156
|
-
if (cur === undefined) {
|
|
157
|
-
comps.set(root, { n: 1, best: d, x, y, fg: at(A, ch, x, y), bg: at(B, chB, x, y) });
|
|
158
|
-
} else {
|
|
159
|
-
cur.n += 1;
|
|
160
|
-
if (d > cur.best) {
|
|
161
|
-
cur.best = d;
|
|
162
|
-
cur.x = x;
|
|
163
|
-
cur.y = y;
|
|
164
|
-
cur.fg = at(A, ch, x, y);
|
|
165
|
-
cur.bg = at(B, chB, x, y);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// 太小的连通域是网点/描边/图标碎片,不是正文
|
|
172
|
-
const MIN_PIXELS = 30;
|
|
173
|
-
const bands = [...comps.values()]
|
|
174
|
-
.filter((c) => c.n >= MIN_PIXELS)
|
|
175
|
-
.map((c) => ({
|
|
176
|
-
ratio: pixelContrast(c.fg, c.bg),
|
|
177
|
-
fg: c.fg,
|
|
178
|
-
bg: c.bg,
|
|
179
|
-
x: c.x,
|
|
180
|
-
y: c.y,
|
|
181
|
-
pixels: c.n
|
|
182
|
-
}))
|
|
183
|
-
.sort((p1, p2) => p1.ratio - p2.ratio);
|
|
184
|
-
|
|
185
|
-
// 同一色文字会切成很多连通域(中文每个字都是独立的),按「前景色」去重:
|
|
186
|
-
// 每种文字色只留最差的那一处,否则报告里会刷屏同一行字的六个字。
|
|
187
|
-
const byColor = new Map();
|
|
188
|
-
for (const bnd of bands) {
|
|
189
|
-
const k = bnd.fg.map((v) => Math.round(v / 16)).join(",");
|
|
190
|
-
const cur = byColor.get(k);
|
|
191
|
-
if (cur === undefined || bnd.ratio < cur.ratio) byColor.set(k, bnd);
|
|
192
|
-
}
|
|
193
|
-
const distinct = [...byColor.values()].sort((p1, p2) => p1.ratio - p2.ratio);
|
|
194
|
-
|
|
195
|
-
return {
|
|
196
|
-
rendered: true,
|
|
197
|
-
samples: totalSamples,
|
|
198
|
-
bands: distinct.length,
|
|
199
|
-
allRuns: bands.length,
|
|
200
|
-
worst: distinct[0],
|
|
201
|
-
// 按对比度从低到高给出前几套文字配色,便于一眼看出哪几种字最危险
|
|
202
|
-
pairs: distinct.slice(0, 6).map((bnd) => ({
|
|
203
|
-
ratio: Math.round(bnd.ratio * 100) / 100,
|
|
204
|
-
fg: `rgb(${bnd.fg.join(",")})`,
|
|
205
|
-
bg: `rgb(${bnd.bg.join(",")})`,
|
|
206
|
-
pixels: bnd.pixels,
|
|
207
|
-
x: bnd.x,
|
|
208
|
-
y: bnd.y
|
|
209
|
-
}))
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
void relativeLuminance;
|
|
1
|
+
/**
|
|
2
|
+
* contrast-render.js — 在**真实渲染像素**上量文字对比度。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:静态解析 CSS 遇到 `rgba()`、CSS 变量、渐变背景时算不出真实对比度,
|
|
5
|
+
* 只能诚实跳过(就是体检里的 contrast-invalid)。而这三类恰好是现在最常用的写法。
|
|
6
|
+
*
|
|
7
|
+
* 做法(不靠 OCR、不靠 DOM):同一页面渲两遍——
|
|
8
|
+
* A:正常渲染 → 某个像素 = 文字色与背景色的混合结果
|
|
9
|
+
* B:把文字染成透明(`color: transparent`),布局不变 → 该像素 = **真实背景**
|
|
10
|
+
* A 与 B 差异大的地方就是文字墨迹;把墨迹最深处(最接近纯文字色)和它背后的 B
|
|
11
|
+
* 像素拿来算 WCAG 对比度。渐变、图片、半透明图层全部自动覆盖——因为量的是像素。
|
|
12
|
+
*
|
|
13
|
+
* 为什么不取纯前景色:文字有抗锯齿与字重差异,细字的“有效对比度”本来就低于
|
|
14
|
+
* 声明值。直接量像素反而更接近人眼看到的。
|
|
15
|
+
*/
|
|
16
|
+
import { relativeLuminance } from "./layouts/rules-contrast.js";
|
|
17
|
+
import { decodePng } from "./layouts/png.js";
|
|
18
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { renderHtml } from "./render-html.js";
|
|
20
|
+
|
|
21
|
+
/** 把页面里的文字全部染成透明(保留布局),用于取得“真实背景”。 */
|
|
22
|
+
export function hideTextCss() {
|
|
23
|
+
return `*{color:transparent !important;text-shadow:none !important;caret-color:transparent !important;}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 把注入用的 <style> 插到 </head> 前;没有 head 就插到最前。 */
|
|
27
|
+
export function injectCss(html, css) {
|
|
28
|
+
const tag = `<style data-dsh-inject>${css}</style>`;
|
|
29
|
+
const at = html.toLowerCase().lastIndexOf("</head>");
|
|
30
|
+
if (at !== -1) return html.slice(0, at) + tag + html.slice(at);
|
|
31
|
+
return tag + html;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const srgb = (v) => {
|
|
35
|
+
const c = v / 255;
|
|
36
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
37
|
+
};
|
|
38
|
+
const lum = (r, g, b) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
|
39
|
+
|
|
40
|
+
/** WCAG 对比度(1–21),两端用像素实际值。 */
|
|
41
|
+
export function pixelContrast(a, b) {
|
|
42
|
+
const la = lum(a[0], a[1], a[2]);
|
|
43
|
+
const lb = lum(b[0], b[1], b[2]);
|
|
44
|
+
const hi = Math.max(la, lb);
|
|
45
|
+
const lo = Math.min(la, lb);
|
|
46
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 量一张页面的真实文字对比度。
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @param {string} opts.htmlPath HTML 文件路径
|
|
53
|
+
* @param {string} opts.outDir 两遍渲染的临时输出目录
|
|
54
|
+
* @param {number} [opts.width] 视口宽
|
|
55
|
+
* @param {number} [opts.height] 视口高
|
|
56
|
+
* @param {string} [opts.browserPath]
|
|
57
|
+
* @returns {{worst:object, samples:number, pairs:object[], rendered:boolean, reason?:string}}
|
|
58
|
+
*/
|
|
59
|
+
export function measureRenderedContrast(opts) {
|
|
60
|
+
const width = Number.isFinite(opts.width) ? opts.width : 1280;
|
|
61
|
+
const height = Number.isFinite(opts.height) ? opts.height : 800;
|
|
62
|
+
const plain = `${opts.outDir}/__contrast_plain.png`;
|
|
63
|
+
const bare = `${opts.outDir}/__contrast_bare.png`;
|
|
64
|
+
|
|
65
|
+
const barePath = `${opts.outDir}/__contrast_bare.html`;
|
|
66
|
+
|
|
67
|
+
// 读文件与渲染都在 try 里:缺文件、没浏览器、路径不可写,都应该返回结构化的
|
|
68
|
+
// rendered:false + reason,而不是把异常抛给调用方(那样上层就没法降级了)。
|
|
69
|
+
try {
|
|
70
|
+
const source = readFileSync(opts.htmlPath, "utf8");
|
|
71
|
+
writeFileSync(barePath, injectCss(source, hideTextCss()), "utf8");
|
|
72
|
+
renderHtml({ html: opts.htmlPath, out: plain, width, height, browserPath: opts.browserPath });
|
|
73
|
+
renderHtml({ html: barePath, out: bare, width, height, browserPath: opts.browserPath });
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { rendered: false, reason: error.message, worst: null, samples: 0, pairs: [] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const A = decodePng(readFileSync(plain));
|
|
79
|
+
const B = decodePng(readFileSync(bare));
|
|
80
|
+
const w = Math.min(A.width, B.width);
|
|
81
|
+
const h = Math.min(A.height, B.height);
|
|
82
|
+
const ch = A.channels;
|
|
83
|
+
const chB = B.channels;
|
|
84
|
+
const at = (img, c, x, y) => {
|
|
85
|
+
const i = (y * img.width + x) * c;
|
|
86
|
+
return [img.data[i], img.data[i + 1], img.data[i + 2]];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// 连通域分组:一个文字块就是一个连通域,里面**最墨的像素**才是它的本色。
|
|
90
|
+
// 为什么不用「按颜色聚类」:抗锯齿边缘的颜色恰好是“背景到文字色”的中间调,
|
|
91
|
+
// 会自成一个簇,被当成一种低对比文字报出来(实测误报 1.97:1 / 2.23:1)。
|
|
92
|
+
// 连通域天然把边缘和本体绑在一起,取域内 max-delta 像素就拿到纯色。
|
|
93
|
+
const W2 = w;
|
|
94
|
+
const H2 = h;
|
|
95
|
+
const inked = new Uint8Array(W2 * H2);
|
|
96
|
+
const deltas = new Uint16Array(W2 * H2);
|
|
97
|
+
let totalSamples = 0;
|
|
98
|
+
for (let y = 0; y < H2; y += 1) {
|
|
99
|
+
for (let x = 0; x < W2; x += 1) {
|
|
100
|
+
const a = at(A, ch, x, y);
|
|
101
|
+
const b = at(B, chB, x, y);
|
|
102
|
+
const d = Math.round((Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3);
|
|
103
|
+
if (d < 40) continue;
|
|
104
|
+
inked[y * W2 + x] = 1;
|
|
105
|
+
deltas[y * W2 + x] = d;
|
|
106
|
+
totalSamples += 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const labels = new Int32Array(W2 * H2).fill(-1);
|
|
111
|
+
const parent = [];
|
|
112
|
+
const find = (i) => {
|
|
113
|
+
let r = i;
|
|
114
|
+
while (parent[r] !== r) r = parent[r];
|
|
115
|
+
while (parent[i] !== r) {
|
|
116
|
+
const next = parent[i];
|
|
117
|
+
parent[i] = r;
|
|
118
|
+
i = next;
|
|
119
|
+
}
|
|
120
|
+
return r;
|
|
121
|
+
};
|
|
122
|
+
const union = (a1, b1) => {
|
|
123
|
+
const ra = find(a1);
|
|
124
|
+
const rb = find(b1);
|
|
125
|
+
if (ra !== rb) parent[rb] = ra;
|
|
126
|
+
};
|
|
127
|
+
let next = 0;
|
|
128
|
+
for (let y = 0; y < H2; y += 1) {
|
|
129
|
+
for (let x = 0; x < W2; x += 1) {
|
|
130
|
+
const idx = y * W2 + x;
|
|
131
|
+
if (inked[idx] === 0) continue;
|
|
132
|
+
const left = x > 0 ? labels[idx - 1] : -1;
|
|
133
|
+
const up = y > 0 ? labels[idx - W2] : -1;
|
|
134
|
+
if (left === -1 && up === -1) {
|
|
135
|
+
labels[idx] = next;
|
|
136
|
+
parent[next] = next;
|
|
137
|
+
next += 1;
|
|
138
|
+
} else if (left !== -1 && up !== -1) {
|
|
139
|
+
labels[idx] = Math.min(left, up);
|
|
140
|
+
union(left, up);
|
|
141
|
+
} else {
|
|
142
|
+
labels[idx] = left !== -1 ? left : up;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 汇总每个连通域:像素数 + 最墨的那个像素(它的 fg/bg 就是该文字的纯色与真实背景)
|
|
148
|
+
const comps = new Map();
|
|
149
|
+
for (let y = 0; y < H2; y += 1) {
|
|
150
|
+
for (let x = 0; x < W2; x += 1) {
|
|
151
|
+
const idx = y * W2 + x;
|
|
152
|
+
if (labels[idx] === -1) continue;
|
|
153
|
+
const root = find(labels[idx]);
|
|
154
|
+
const d = deltas[idx];
|
|
155
|
+
const cur = comps.get(root);
|
|
156
|
+
if (cur === undefined) {
|
|
157
|
+
comps.set(root, { n: 1, best: d, x, y, fg: at(A, ch, x, y), bg: at(B, chB, x, y) });
|
|
158
|
+
} else {
|
|
159
|
+
cur.n += 1;
|
|
160
|
+
if (d > cur.best) {
|
|
161
|
+
cur.best = d;
|
|
162
|
+
cur.x = x;
|
|
163
|
+
cur.y = y;
|
|
164
|
+
cur.fg = at(A, ch, x, y);
|
|
165
|
+
cur.bg = at(B, chB, x, y);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 太小的连通域是网点/描边/图标碎片,不是正文
|
|
172
|
+
const MIN_PIXELS = 30;
|
|
173
|
+
const bands = [...comps.values()]
|
|
174
|
+
.filter((c) => c.n >= MIN_PIXELS)
|
|
175
|
+
.map((c) => ({
|
|
176
|
+
ratio: pixelContrast(c.fg, c.bg),
|
|
177
|
+
fg: c.fg,
|
|
178
|
+
bg: c.bg,
|
|
179
|
+
x: c.x,
|
|
180
|
+
y: c.y,
|
|
181
|
+
pixels: c.n
|
|
182
|
+
}))
|
|
183
|
+
.sort((p1, p2) => p1.ratio - p2.ratio);
|
|
184
|
+
|
|
185
|
+
// 同一色文字会切成很多连通域(中文每个字都是独立的),按「前景色」去重:
|
|
186
|
+
// 每种文字色只留最差的那一处,否则报告里会刷屏同一行字的六个字。
|
|
187
|
+
const byColor = new Map();
|
|
188
|
+
for (const bnd of bands) {
|
|
189
|
+
const k = bnd.fg.map((v) => Math.round(v / 16)).join(",");
|
|
190
|
+
const cur = byColor.get(k);
|
|
191
|
+
if (cur === undefined || bnd.ratio < cur.ratio) byColor.set(k, bnd);
|
|
192
|
+
}
|
|
193
|
+
const distinct = [...byColor.values()].sort((p1, p2) => p1.ratio - p2.ratio);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
rendered: true,
|
|
197
|
+
samples: totalSamples,
|
|
198
|
+
bands: distinct.length,
|
|
199
|
+
allRuns: bands.length,
|
|
200
|
+
worst: distinct[0],
|
|
201
|
+
// 按对比度从低到高给出前几套文字配色,便于一眼看出哪几种字最危险
|
|
202
|
+
pairs: distinct.slice(0, 6).map((bnd) => ({
|
|
203
|
+
ratio: Math.round(bnd.ratio * 100) / 100,
|
|
204
|
+
fg: `rgb(${bnd.fg.join(",")})`,
|
|
205
|
+
bg: `rgb(${bnd.bg.join(",")})`,
|
|
206
|
+
pixels: bnd.pixels,
|
|
207
|
+
x: bnd.x,
|
|
208
|
+
y: bnd.y
|
|
209
|
+
}))
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
void relativeLuminance;
|