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/lib/layouts/image.js
CHANGED
|
@@ -1,1010 +1,1010 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* layouts/image.js — layout_check action=image:渲染结果(UI 截图 / 海报 / banner / 社交卡片)的
|
|
3
|
-
* 「构图几何」体检。没有 OCR,所以只量几何与密度,不判文字可读性(P7 会明确声明这条能力边界)。
|
|
4
|
-
*
|
|
5
|
-
* 判定链路(纯函数、确定性:同一 buffer 永远同一结果):
|
|
6
|
-
* 1. 解码 PNG(./png.js,零依赖;不在本文件重写解码)。
|
|
7
|
-
* 2. 得到 content mask:
|
|
8
|
-
* - 有透明度(通道含 alpha,且存在 a<250 的像素)→ mask = a>16(透明底图;细线/描边也能抓到);
|
|
9
|
-
* - 否则取四角像素估计背景色(每通道 ±6 容差聚类,取出现最多的一组),
|
|
10
|
-
* mask = 与背景色 RGB 欧氏距离 >24 的像素。
|
|
11
|
-
* 3. 一次扫描得到:内容包围盒、四边 margin、覆盖率、左右/上下内容像素数与「视觉重量」。
|
|
12
|
-
* 4. 行/列投影(连续 ≥2px 的内容区间 = run)→ 块结构:行带(水平内容带)与列带(垂直内容带)、
|
|
13
|
-
* 行带内的列 run(= 块)与块起点,用于判断对齐。
|
|
14
|
-
* 5. P1–P7 七条规则 → findings,交给 common.js finalize 统一算分(0–100,越低越好)。
|
|
15
|
-
*
|
|
16
|
-
* 阈值依据(为什么是这些数):
|
|
17
|
-
* - 边距 <2%:1000px 画布只剩 20px,出血/裁切/圆角遮罩/屏幕边缘必然切到内容 → error;
|
|
18
|
-
* <4%:肉眼就能看出「贴边」,像没排完版 → warn。safe area 惯例 6–8%(印刷 3–5mm 在 1000px
|
|
19
|
-
* 画布上也接近 6%),所以修复建议统一给 6–8%。
|
|
20
|
-
* - 边距比 >3 且最窄边 <8%:常见版式四边在 6–15% 一档,超过 3 倍就是「一边贴边、一边大留白」的
|
|
21
|
-
* 失衡;另一类是同一条轴上一侧 >30%、对边 <5%(内容被挤到一侧、对侧几乎顶边)。
|
|
22
|
-
* - 覆盖率 >85%:几乎没留白,观感闷(海报最低可以到 70% 左右,但要有成组的呼吸区);
|
|
23
|
-
* <6%:主体缺失,或背景估计失败 —— 纯白/纯透明底是最常见的误判来源,所以 message 直接提示这两种可能。
|
|
24
|
-
* - 左右/上下不平衡 >25%:人眼对重心偏移的容忍阈值接近 1/4。像素数和视觉重量(像素数×距中心距离)
|
|
25
|
-
* 两条都算:只数像素会漏掉「大面积浅色块 vs 少而集中的重色块」。
|
|
26
|
-
* - 块起点 ≥5 个且没有两条重合(容差 2px):各块各自为政、没有可依循的对齐线;≥2 种对齐方式
|
|
27
|
-
* 各 ≥2 块:视线被迫左右跳。
|
|
28
|
-
* - 分辨率 <300px:位图放大 2× 就明显糊(文字和 1px 描边先死);<800px:小屏够用,大屏/印刷会虚。
|
|
29
|
-
* - P7:固定 info,声明本模式的能力边界,避免使用者以为「图里的文字排版也体检过了」。
|
|
30
|
-
*
|
|
31
|
-
* 已知误报(写进报告提醒,不试图消除):
|
|
32
|
-
* - 渐变底/照片背景:背景色只取四角,四角颜色不一致时一半画面会被当成内容 → 覆盖率偏高、
|
|
33
|
-
* P4/P5 可能误报;这类图要么先确认意图,要么改用 action=svg / action=ui。
|
|
34
|
-
* - 纯色大面积铺底 + 少量内容:会被判「过空」(message 里已提示可能是纯白/纯透明底)。
|
|
35
|
-
*/
|
|
36
|
-
import { decodePng, pixelAt } from "./png.js";
|
|
37
|
-
import { finalize, finding } from "./common.js";
|
|
38
|
-
|
|
39
|
-
/* ----------------------------- 阈值常量 ----------------------------- */
|
|
40
|
-
|
|
41
|
-
const ALPHA_CONTENT = 16; // 透明底:a>16 才算可见内容(更低的是渲染残留/阴影边缘)
|
|
42
|
-
const ALPHA_OPAQUE = 250; // 存在 a<250 的像素 = 这张图带透明度(不是纯不透明底)
|
|
43
|
-
const BG_TOLERANCE = 6; // 四角同色聚类:每通道 ±6 视为同一背景(吸 8-bit 抖动/极轻渐变)
|
|
44
|
-
const BG_DIST_SQ = 24 * 24; // 与背景色的欧氏距离 >24 才算内容(≈单通道 14,吃掉抗锯齿过渡带)
|
|
45
|
-
const MIN_RUN = 2; // 连续 ≥2px 才算「块」/内容段(1px 线是描边、分隔线、水印,不构成块结构)
|
|
46
|
-
const MARGIN_ERROR_PCT = 2;
|
|
47
|
-
const MARGIN_WARN_PCT = 4;
|
|
48
|
-
const ASYM_RATIO = 3;
|
|
49
|
-
const ASYM_MIN_PCT = 8; // 「最小边 <8%」才有资格算严重不对称
|
|
50
|
-
const ASYM_SINGLE_MAX_PCT = 30; // 单边 >30%
|
|
51
|
-
const ASYM_OPPOSITE_MIN_PCT = 5; // 且对边 <5%
|
|
52
|
-
const CROWDED_COVERAGE = 0.85;
|
|
53
|
-
/** 过空阈值:文字型海报/封面天然只有 2–5% 墨迹覆盖率(细字形),不能按图形占比要求。
|
|
54
|
-
* 真正该报的是接近空白(<1.5%,大概率没渲染/纯占位)。 */
|
|
55
|
-
const EMPTY_COVERAGE = 0.015;
|
|
56
|
-
/** 低于这个覆盖率视为「稀疏文字类版面」:边距对称性/重心/对齐这些构图规则在没有体量块时
|
|
57
|
-
* 没有参考价值(左对齐文字天然左重),跳过以免误判。 */
|
|
58
|
-
const SUBSTANTIAL_COVERAGE = 0.08;
|
|
59
|
-
/**
|
|
60
|
-
* 满血版面(full-bleed):内容四边都几乎碰到画布(每边 <2%)。
|
|
61
|
-
* 这通常意味着「这不是一张作品,而是一个应用窗口的截图」:侧栏、顶栏、网格本来就该铺满窗口,
|
|
62
|
-
* 贴边是正确设计而不是缺陷。
|
|
63
|
-
*
|
|
64
|
-
* 实测教训(2026-11-07,拿 Flow-Pilot 的真实截图验证):7 张 1440×900 的应用截图
|
|
65
|
-
* **全部**被判 62/100 bad,唯一原因就是 P1 贴边 error×3 —— 纯噪声。
|
|
66
|
-
* 一个对真实截图全错的规则比没有规则更糟。
|
|
67
|
-
*/
|
|
68
|
-
const FULL_BLEED_MAX_PCT = 2;
|
|
69
|
-
const BALANCE_LIMIT = 0.25; // 左右/上下失衡阈值
|
|
70
|
-
const START_TOL_PX = 2; // 块起点去重容差
|
|
71
|
-
const START_CHAOS_MIN = 5; // ≥5 个互不相同且各出现 1 次 = 没有对齐关系
|
|
72
|
-
const ALIGN_MIN_BANDS = 2; // 一种对齐方式至少 2 个行带才算「大量存在」
|
|
73
|
-
const RES_WARN = 300;
|
|
74
|
-
const RES_INFO = 800;
|
|
75
|
-
|
|
76
|
-
const SIDE_CN = { left: "左", top: "上", right: "右", bottom: "下" };
|
|
77
|
-
const MODE_CN = { left: "左", center: "居中", right: "右" };
|
|
78
|
-
|
|
79
|
-
/** 四舍五入到 2 位小数;把 -0 归一成 0(否则 JSON 无损往返会翻车)。 */
|
|
80
|
-
function round2(value) {
|
|
81
|
-
const r = Math.round(value * 100) / 100;
|
|
82
|
-
return r === 0 ? 0 : r;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* 背景色估计:沿整个边框采样(而非只看四角),按「每通道 ±6」聚类取最大组均值。
|
|
87
|
-
* 边框采样比四角稳:窗口截图顶部深色标题栏会让四角平票,进而选错背景。
|
|
88
|
-
*/
|
|
89
|
-
function estimateBackground(img) {
|
|
90
|
-
const { width: w, height: h } = img;
|
|
91
|
-
// 沿**整个边框**采样,不只四角:应用窗口顶部是深色标题栏时,
|
|
92
|
-
// 两个角在深色、两个在浅色,四角聚类会平票并可能选中深色当背景,
|
|
93
|
-
// 导致整个内容区被当成「内容」、边距全错(实测 Flow-Pilot 窗口截图)。
|
|
94
|
-
const step = Math.max(1, Math.floor((w + h) / 128)); // 最多 ~256 个采样点
|
|
95
|
-
const samples = [];
|
|
96
|
-
for (let x = 0; x < w; x += step) {
|
|
97
|
-
samples.push(pixelAt(img, x, 0), pixelAt(img, x, h - 1));
|
|
98
|
-
}
|
|
99
|
-
for (let y = 0; y < h; y += step) {
|
|
100
|
-
samples.push(pixelAt(img, 0, y), pixelAt(img, w - 1, y));
|
|
101
|
-
}
|
|
102
|
-
const groups = [];
|
|
103
|
-
for (const c of samples) {
|
|
104
|
-
let group = groups.find(
|
|
105
|
-
(g) =>
|
|
106
|
-
Math.abs(g.rep.r - c.r) <= BG_TOLERANCE &&
|
|
107
|
-
Math.abs(g.rep.g - c.g) <= BG_TOLERANCE &&
|
|
108
|
-
Math.abs(g.rep.b - c.b) <= BG_TOLERANCE
|
|
109
|
-
);
|
|
110
|
-
if (group === undefined) {
|
|
111
|
-
group = { rep: c, count: 0, sumR: 0, sumG: 0, sumB: 0 };
|
|
112
|
-
groups.push(group);
|
|
113
|
-
}
|
|
114
|
-
group.count += 1;
|
|
115
|
-
group.sumR += c.r;
|
|
116
|
-
group.sumG += c.g;
|
|
117
|
-
group.sumB += c.b;
|
|
118
|
-
}
|
|
119
|
-
let best = groups[0];
|
|
120
|
-
for (const g of groups) if (g.count > best.count) best = g;
|
|
121
|
-
return {
|
|
122
|
-
r: Math.round(best.sumR / best.count),
|
|
123
|
-
g: Math.round(best.sumG / best.count),
|
|
124
|
-
b: Math.round(best.sumB / best.count)
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** 一维投影(0/1 数组)→ 连续 1 的区间(长度 >= minLen),inclusive 起止。 */
|
|
129
|
-
function contentRuns(flags, minLen) {
|
|
130
|
-
const out = [];
|
|
131
|
-
let start = -1;
|
|
132
|
-
for (let i = 0; i <= flags.length; i += 1) {
|
|
133
|
-
const on = i < flags.length && flags[i] === 1;
|
|
134
|
-
if (on && start === -1) start = i;
|
|
135
|
-
else if (!on && start !== -1) {
|
|
136
|
-
if (i - start >= minLen) out.push({ start, end: i - 1 });
|
|
137
|
-
start = -1;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return out;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** 起点去重:排序后按 ±tol 聚簇(链式,够用且确定性)。 */
|
|
144
|
-
function clusterStarts(starts, tol) {
|
|
145
|
-
const sorted = [...starts].sort((a, b) => a - b);
|
|
146
|
-
const clusters = [];
|
|
147
|
-
for (const s of sorted) {
|
|
148
|
-
const last = clusters[clusters.length - 1];
|
|
149
|
-
if (last !== undefined && s - last.rep <= tol) last.count += 1;
|
|
150
|
-
else clusters.push({ rep: s, count: 1 });
|
|
151
|
-
}
|
|
152
|
-
return clusters;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function toHex(rgb) {
|
|
156
|
-
return `#${[rgb.r, rgb.g, rgb.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* PNG buffer → 构图体检报告(mode=image)。纯函数、确定性。
|
|
161
|
-
* @param {Buffer|Uint8Array} buffer PNG 字节(工具层已读文件)
|
|
162
|
-
* @param {{name?: string}} [options]
|
|
163
|
-
*/
|
|
164
|
-
/**
|
|
165
|
-
* 版式剖面:从截图反推「这页是怎么排的」。
|
|
166
|
-
*
|
|
167
|
-
* **为什么值得做成工具**:做 UI 参照改造的第一步永远是“量原图”——列边界在哪、
|
|
168
|
-
* 行距多少、主色是什么。实测:做视频站动态页参照实现时,同一种色带扫描手写了三遍;
|
|
169
|
-
* 同一个需求写第三遍,就该进工具。
|
|
170
|
-
*
|
|
171
|
-
* 纯像素统计,不靠 OCR、不靠模型:
|
|
172
|
-
* 1. 主色板:量化到 16 级后取 top 6 + 面积占比;
|
|
173
|
-
* 2. 垂直色带:逐列统计「与页面底色不同」的占比,跳变处即栏位/卡片的水平范围;
|
|
174
|
-
* ——适配“大底色 + 白卡片”这类客户端/后台界面;
|
|
175
|
-
* 3. 行节奏:在内容最宽的那一栏竖向扫描,取内容→空白转换间距的中位数。
|
|
176
|
-
*/
|
|
177
|
-
/**
|
|
178
|
-
* 卡片矩形检测:近白像素的连通域 → 包围盒。
|
|
179
|
-
*
|
|
180
|
-
* 为什么判据是「近白」而不是「与页面底色不同」:渐变背景下任何固定的底色都不成立
|
|
181
|
-
* (实测下载工具仪表板的背景从 #dde5f2 渐变到 #f2f5fa,逐点比较必然失效)。
|
|
182
|
-
* 而现代 UI 的卡片几乎总是近白,这个判据对渐变底、柔和阴影、深色主题反色都更稳。
|
|
183
|
-
*
|
|
184
|
-
* @returns {Array<{x:number,y:number,w:number,h:number,area:number}>} 按面积降序
|
|
185
|
-
*/
|
|
186
|
-
function detectCards(at, w, h, threshold = 250) {
|
|
187
|
-
const NEAR_WHITE = threshold; // 三通道都 ≥ 这个值才算“卡片面”(调用方按页面底色算)
|
|
188
|
-
const mask = new Uint8Array(w * h);
|
|
189
|
-
for (let y = 0; y < h; y += 1) {
|
|
190
|
-
const row = y * w;
|
|
191
|
-
for (let x = 0; x < w; x += 1) {
|
|
192
|
-
const p = at(x, y);
|
|
193
|
-
if (p[0] >= NEAR_WHITE && p[1] >= NEAR_WHITE && p[2] >= NEAR_WHITE) mask[row + x] = 1;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// 两遍连通域(4 邻域):第一遍打临时标号,第二遍合并等价类
|
|
198
|
-
const labels = new Int32Array(w * h).fill(-1);
|
|
199
|
-
const parent = [];
|
|
200
|
-
const find = (i) => {
|
|
201
|
-
let r = i;
|
|
202
|
-
while (parent[r] !== r) r = parent[r];
|
|
203
|
-
while (parent[i] !== r) {
|
|
204
|
-
const next = parent[i];
|
|
205
|
-
parent[i] = r;
|
|
206
|
-
i = next;
|
|
207
|
-
}
|
|
208
|
-
return r;
|
|
209
|
-
};
|
|
210
|
-
const union = (a, b) => {
|
|
211
|
-
const ra = find(a);
|
|
212
|
-
const rb = find(b);
|
|
213
|
-
if (ra !== rb) parent[rb] = ra;
|
|
214
|
-
};
|
|
215
|
-
let next = 0;
|
|
216
|
-
for (let y = 0; y < h; y += 1) {
|
|
217
|
-
for (let x = 0; x < w; x += 1) {
|
|
218
|
-
const idx = y * w + x;
|
|
219
|
-
if (mask[idx] === 0) continue;
|
|
220
|
-
const left = x > 0 ? labels[idx - 1] : -1;
|
|
221
|
-
const up = y > 0 ? labels[idx - w] : -1;
|
|
222
|
-
if (left === -1 && up === -1) {
|
|
223
|
-
labels[idx] = next;
|
|
224
|
-
parent[next] = next;
|
|
225
|
-
next += 1;
|
|
226
|
-
} else if (left !== -1 && up !== -1) {
|
|
227
|
-
labels[idx] = Math.min(left, up);
|
|
228
|
-
union(left, up);
|
|
229
|
-
} else {
|
|
230
|
-
labels[idx] = left !== -1 ? left : up;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const boxes = new Map();
|
|
236
|
-
for (let y = 0; y < h; y += 1) {
|
|
237
|
-
for (let x = 0; x < w; x += 1) {
|
|
238
|
-
const idx = y * w + x;
|
|
239
|
-
if (labels[idx] === -1) continue;
|
|
240
|
-
const root = find(labels[idx]);
|
|
241
|
-
const box = boxes.get(root);
|
|
242
|
-
if (box === undefined) boxes.set(root, { x0: x, y0: y, x1: x, y1: y, n: 1 });
|
|
243
|
-
else {
|
|
244
|
-
if (x < box.x0) box.x0 = x;
|
|
245
|
-
if (x > box.x1) box.x1 = x;
|
|
246
|
-
if (y < box.y0) box.y0 = y;
|
|
247
|
-
if (y > box.y1) box.y1 = y;
|
|
248
|
-
box.n += 1;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// 过滤:太小的是文字/图标/高光,不是卡片
|
|
254
|
-
const minW = Math.max(24, Math.round(w * 0.03));
|
|
255
|
-
const minH = Math.max(16, Math.round(h * 0.015));
|
|
256
|
-
const out = [];
|
|
257
|
-
for (const box of boxes.values()) {
|
|
258
|
-
const bw = box.x1 - box.x0 + 1;
|
|
259
|
-
const bh = box.y1 - box.y0 + 1;
|
|
260
|
-
if (bw < minW || bh < minH) continue;
|
|
261
|
-
// 充填率太低说明是散碎白点连成的细长形,不是卡片
|
|
262
|
-
if (box.n / (bw * bh) < 0.55) continue;
|
|
263
|
-
out.push({ x: box.x0, y: box.y0, w: bw, h: bh, area: bw * bh });
|
|
264
|
-
}
|
|
265
|
-
return out.sort((a, b) => b.area - a.area);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function layoutProfile(img, bgHint) {
|
|
269
|
-
const { width: w, height: h, data, channels } = img;
|
|
270
|
-
|
|
271
|
-
// 页面底色 = **图像四边**出现最多的颜色。
|
|
272
|
-
// 实测坑:直接用全图最长见的颜色(estimateBackground)会把“白卡片”(占 45%)当底,
|
|
273
|
-
// 于是灰色版心间隙被当成内容,整张图变成一栏。而四边一定是页面底色,不会错。
|
|
274
|
-
const edgeBuckets = new Map();
|
|
275
|
-
const bump = (x, y) => {
|
|
276
|
-
const i = (y * w + x) * channels;
|
|
277
|
-
const r = data[i];
|
|
278
|
-
const g = data[i + 1];
|
|
279
|
-
const b = data[i + 2];
|
|
280
|
-
const key = `${Math.round(r / 8)},${Math.round(g / 8)},${Math.round(b / 8)}`;
|
|
281
|
-
const slot = edgeBuckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
|
|
282
|
-
slot.n += 1;
|
|
283
|
-
slot.r += r;
|
|
284
|
-
slot.g += g;
|
|
285
|
-
slot.b += b;
|
|
286
|
-
edgeBuckets.set(key, slot);
|
|
287
|
-
};
|
|
288
|
-
// 用四角小块采样:顶部边线常被顶栏占满(全是白色),按边计票会把白卡片当底,
|
|
289
|
-
// 进而把灰色版心间隙当成“内容”,列结构完全反过来(实测踩过)。四角更稳。
|
|
290
|
-
const PATCH = Math.max(2, Math.round(Math.min(w, h) * 0.012));
|
|
291
|
-
for (const [cx, cy] of [[0, 0], [w - PATCH, 0], [0, h - PATCH], [w - PATCH, h - PATCH]]) {
|
|
292
|
-
for (let y = cy; y < cy + PATCH; y += 1) for (let x = cx; x < cx + PATCH; x += 1) bump(x, y);
|
|
293
|
-
}
|
|
294
|
-
const edgeTop = [...edgeBuckets.values()].sort((a, b) => b.n - a.n)[0];
|
|
295
|
-
// 统一成 [r,g,b] 数组:near() 按下标比较,传对象会让比较全变 NaN → 整图都算“内容”,
|
|
296
|
-
// 结果永远只有一列(实测踩过)。
|
|
297
|
-
const bgArray = edgeTop === undefined ? [bgHint.r, bgHint.g, bgHint.b] : [Math.round(edgeTop.r / edgeTop.n), Math.round(edgeTop.g / edgeTop.n), Math.round(edgeTop.b / edgeTop.n)];
|
|
298
|
-
const bg = { r: bgArray[0], g: bgArray[1], b: bgArray[2] };
|
|
299
|
-
const bgRef = bgArray;
|
|
300
|
-
|
|
301
|
-
const at = (x, y) => {
|
|
302
|
-
const i = (y * w + x) * channels;
|
|
303
|
-
return [data[i], data[i + 1], data[i + 2]];
|
|
304
|
-
};
|
|
305
|
-
const near = (p, q, tol) => Math.abs(p[0] - q[0]) <= tol && Math.abs(p[1] - q[1]) <= tol && Math.abs(p[2] - q[2]) <= tol;
|
|
306
|
-
|
|
307
|
-
/* 1) 主色板 */
|
|
308
|
-
const buckets = new Map();
|
|
309
|
-
let total = 0;
|
|
310
|
-
for (let y = 0; y < h; y += 3) {
|
|
311
|
-
for (let x = 0; x < w; x += 3) {
|
|
312
|
-
const p = at(x, y);
|
|
313
|
-
const key = `${Math.round(p[0] / 16)},${Math.round(p[1] / 16)},${Math.round(p[2] / 16)}`;
|
|
314
|
-
const slot = buckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
|
|
315
|
-
slot.n += 1;
|
|
316
|
-
slot.r += p[0];
|
|
317
|
-
slot.g += p[1];
|
|
318
|
-
slot.b += p[2];
|
|
319
|
-
buckets.set(key, slot);
|
|
320
|
-
total += 1;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
const palette = [...buckets.values()]
|
|
324
|
-
.sort((a, b) => b.n - a.n)
|
|
325
|
-
.slice(0, 6)
|
|
326
|
-
.map((s) => ({
|
|
327
|
-
hex: toHex({ r: Math.round(s.r / s.n), g: Math.round(s.g / s.n), b: Math.round(s.b / s.n) }),
|
|
328
|
-
share: round2((s.n / total) * 100)
|
|
329
|
-
}));
|
|
330
|
-
|
|
331
|
-
/* 2) 垂直色带(列结构) */
|
|
332
|
-
const colFrac = new Array(w).fill(0);
|
|
333
|
-
let rowsChecked = 0;
|
|
334
|
-
for (let y = 0; y < h; y += 2) {
|
|
335
|
-
rowsChecked += 1;
|
|
336
|
-
for (let x = 0; x < w; x += 1) {
|
|
337
|
-
if (!near(at(x, y), bgRef, 6)) colFrac[x] += 1;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
const colRatio = colFrac.map((v) => v / rowsChecked);
|
|
341
|
-
const minCol = Math.max(24, Math.round(w * 0.02));
|
|
342
|
-
const columns = [];
|
|
343
|
-
let start = -1;
|
|
344
|
-
for (let x = 0; x < w; x += 1) {
|
|
345
|
-
const busy = colRatio[x] > 0.5;
|
|
346
|
-
if (busy && start === -1) start = x;
|
|
347
|
-
else if (!busy && start !== -1) {
|
|
348
|
-
if (x - start >= minCol) columns.push([start, x - 1]);
|
|
349
|
-
start = -1;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
if (start !== -1 && w - start >= minCol) columns.push([start, w - 1]);
|
|
353
|
-
|
|
354
|
-
/* 2b) 垂直分界线:办公/客户端界面几乎全白,色带法分不开栏位(实测在线文档参考图:
|
|
355
|
-
* 色带只报出「90~1538 | 1551~2030」,左图标栏与二级侧栏都丢了)。
|
|
356
|
-
* 但栏位之间**一定有一条贯穿全高的分界线**——逐行比较相邻两列的颜色变化,
|
|
357
|
-
* 变化率高的 x 就是真实的栏位边界。这比“与页面底色不同”稳得多。 */
|
|
358
|
-
const vEdges = [];
|
|
359
|
-
{
|
|
360
|
-
const ratios = new Float64Array(w);
|
|
361
|
-
for (let x = 1; x < w; x += 1) {
|
|
362
|
-
let changed = 0;
|
|
363
|
-
let rows = 0;
|
|
364
|
-
for (let y = 0; y < h; y += 3) {
|
|
365
|
-
const a1 = at(x, y);
|
|
366
|
-
const b1 = at(x - 1, y);
|
|
367
|
-
const d = (Math.abs(a1[0] - b1[0]) + Math.abs(a1[1] - b1[1]) + Math.abs(a1[2] - b1[2])) / 3;
|
|
368
|
-
if (d >= 2) changed += 1;
|
|
369
|
-
rows += 1;
|
|
370
|
-
}
|
|
371
|
-
ratios[x] = rows === 0 ? 0 : changed / rows;
|
|
372
|
-
}
|
|
373
|
-
// 连续 ≥85% 变化的列聚成一条边;取其中最靠中间的值
|
|
374
|
-
let run = null;
|
|
375
|
-
for (let x = 1; x < w; x += 1) {
|
|
376
|
-
if (ratios[x] >= 0.85) {
|
|
377
|
-
if (run === null) run = { start: x, end: x };
|
|
378
|
-
else run.end = x;
|
|
379
|
-
} else if (run !== null) {
|
|
380
|
-
vEdges.push(Math.round((run.start + run.end) / 2));
|
|
381
|
-
run = null;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
if (run !== null) vEdges.push(Math.round((run.start + run.end) / 2));
|
|
385
|
-
// 去掉图像边框(贴边那两条不是栏位边界),并把相距 ≤8px 的重复边合并
|
|
386
|
-
const kept = vEdges.filter((x) => x >= 7 && x <= w - 8);
|
|
387
|
-
vEdges.length = 0;
|
|
388
|
-
for (const x of kept) {
|
|
389
|
-
const last = vEdges[vEdges.length - 1];
|
|
390
|
-
if (last !== undefined && x - last <= 10) continue;
|
|
391
|
-
vEdges.push(x);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/* 3) 列表/表格行节奏:用**窄竖条**采样,取节奏最稳的那一条
|
|
396
|
-
* 为什么不能用整条列带:表格型界面里侧栏与内容区往往连成一条宽列,
|
|
397
|
-
* 整行处处有内容 → 行间隙检测不出来(实测在线文档参考图:整列带法返回 null)。
|
|
398
|
-
* 窄竖条(~40px)只看一列文字,行与行之间的空白就显出来了。
|
|
399
|
-
* 顺带产出**行高一致性**:表格里某一行比其它行高/矮是典型缺陷。 */
|
|
400
|
-
let pitch = null;
|
|
401
|
-
let listStats = null;
|
|
402
|
-
{
|
|
403
|
-
const strips = [];
|
|
404
|
-
// ① 各列带内取几条窄条
|
|
405
|
-
for (const [left, right] of columns) {
|
|
406
|
-
const bandW = right - left + 1;
|
|
407
|
-
if (bandW < 60) continue;
|
|
408
|
-
for (const frac of [0.15, 0.35, 0.55, 0.75]) {
|
|
409
|
-
const sx = Math.round(left + bandW * frac);
|
|
410
|
-
if (sx + 40 <= right) strips.push(sx);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
// ② 整幅均匀撒几条,兜住跨全宽的表
|
|
414
|
-
for (let i = 1; i <= 10; i += 1) {
|
|
415
|
-
const sx = Math.round((w * i) / 11);
|
|
416
|
-
if (sx + 40 < w) strips.push(sx);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
let best = null;
|
|
420
|
-
for (const sx of strips) {
|
|
421
|
-
const SW = 40;
|
|
422
|
-
const buckets = new Map();
|
|
423
|
-
for (let y = 0; y < h; y += 2) {
|
|
424
|
-
for (let x = sx; x < sx + SW; x += 2) {
|
|
425
|
-
const px = at(x, y);
|
|
426
|
-
const k = `${px[0] >> 3},${px[1] >> 3},${px[2] >> 3}`;
|
|
427
|
-
buckets.set(k, (buckets.get(k) ?? 0) + 1);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
const top = [...buckets.entries()].sort((a1, b1) => b1[1] - a1[1])[0];
|
|
431
|
-
if (top === undefined) continue;
|
|
432
|
-
const localBg = top[0].split(",").map((v) => Number(v) * 8);
|
|
433
|
-
const starts = [];
|
|
434
|
-
let blank = true;
|
|
435
|
-
let runStart = 0;
|
|
436
|
-
for (let y = 0; y < h; y += 1) {
|
|
437
|
-
let has = false;
|
|
438
|
-
for (let x = sx; x < sx + SW; x += 2) {
|
|
439
|
-
const px = at(x, y);
|
|
440
|
-
if (Math.abs(px[0] - localBg[0]) > 12 || Math.abs(px[1] - localBg[1]) > 12 || Math.abs(px[2] - localBg[2]) > 12) {
|
|
441
|
-
has = true;
|
|
442
|
-
break;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
if (has && blank) {
|
|
446
|
-
blank = false;
|
|
447
|
-
runStart = y;
|
|
448
|
-
} else if (!has && !blank) {
|
|
449
|
-
blank = true;
|
|
450
|
-
if (y - runStart >= 4) starts.push(runStart);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
const gaps = [];
|
|
454
|
-
for (let i = 1; i < starts.length; i += 1) {
|
|
455
|
-
const g = starts[i] - starts[i - 1];
|
|
456
|
-
if (g >= 12 && g <= h / 3) gaps.push(g);
|
|
457
|
-
}
|
|
458
|
-
if (gaps.length < 4) continue;
|
|
459
|
-
const mean = gaps.reduce((a1, b1) => a1 + b1, 0) / gaps.length;
|
|
460
|
-
const variance = gaps.reduce((a1, b1) => a1 + (b1 - mean) ** 2, 0) / gaps.length;
|
|
461
|
-
const cv = Math.sqrt(variance) / mean;
|
|
462
|
-
if (best === null || cv < best.cv) best = { cv, gaps, sx };
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
if (best !== null) {
|
|
466
|
-
const sorted = [...best.gaps].sort((a1, b1) => a1 - b1);
|
|
467
|
-
pitch = sorted[Math.floor(sorted.length / 2)];
|
|
468
|
-
// 行高一致性:以中位数为基准,±15% 以内算“同一档”
|
|
469
|
-
const within = best.gaps.filter((g) => g >= pitch * 0.85 && g <= pitch * 1.15);
|
|
470
|
-
const outliers = best.gaps.filter((g) => g < pitch * 0.85 || g > pitch * 1.15);
|
|
471
|
-
const uniformPct = Math.round((within.length / best.gaps.length) * 1000) / 10;
|
|
472
|
-
listStats = {
|
|
473
|
-
pitch,
|
|
474
|
-
count: best.gaps.length,
|
|
475
|
-
uniformPct,
|
|
476
|
-
// 单行列表 vs 多行列表:邮件/聊天/卡片列表里每「行」含 2–3 行文字,
|
|
477
|
-
// 行内行距会把间隙分布拉成双峰。这时「行高不一致」判据根本不成立
|
|
478
|
-
// (实测邮箱界面:39 个间隙里 19 个偏离中位,51.3% —— 全是行内行距,
|
|
479
|
-
// 却会被判成「19 行行高异常」)。低于 80% 一致就直接不做判断。
|
|
480
|
-
singleLine: uniformPct >= 80,
|
|
481
|
-
outliers: [...new Set(outliers)].sort((a1, b1) => a1 - b1).slice(0, 6),
|
|
482
|
-
outlierCount: outliers.length,
|
|
483
|
-
stripX: best.sx
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/* 4) 卡片矩形:现代 UI 的基本单元是“卡片”,不是“色带”。
|
|
489
|
-
* 动机:拿一张渐变背景 + 柔和阴影的仪表板(下载工具)试,上面的列检测直接退回
|
|
490
|
-
* “整图一列”——因为渐变让 near(bg) 处处不成立。色带法只适合“大底色 + 规整栏位”,
|
|
491
|
-
* 一旦是卡片栅格就失效。
|
|
492
|
-
* 做法:近白像素 → 连通域 → 包围盒。卡片几乎总是近白(或至少比页面底色亮),
|
|
493
|
-
* 这个判据对渐变底、柔和阴影都稳。
|
|
494
|
-
* 阈值必须**相对页面底色**:视频站页面底色 #F6F7F9 三通道都 ≥246,固定 246 会把
|
|
495
|
-
* 整页底都当成“卡片面”,连通成一张 2072×1414 的巨块。 */
|
|
496
|
-
const bgLum = 0.299 * bgRef[0] + 0.587 * bgRef[1] + 0.114 * bgRef[2];
|
|
497
|
-
const surfaceThreshold = Math.min(254, Math.max(250, Math.round(bgLum) + 4));
|
|
498
|
-
const cards = detectCards(at, w, h, surfaceThreshold);
|
|
499
|
-
|
|
500
|
-
/* 5) 卡片栅格:x 起点聚类 = 列;y 起点聚类 = 行;间距按「同一行/同一列内相邻」算 */
|
|
501
|
-
const cluster = (values, tol) => {
|
|
502
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
503
|
-
const out = [];
|
|
504
|
-
for (const v of sorted) {
|
|
505
|
-
const last = out[out.length - 1];
|
|
506
|
-
if (last !== undefined && Math.abs(v - last) <= tol) continue;
|
|
507
|
-
out.push(v);
|
|
508
|
-
}
|
|
509
|
-
return out;
|
|
510
|
-
};
|
|
511
|
-
const colStarts = cluster(cards.map((c) => c.x), 8);
|
|
512
|
-
const rowStarts = cluster(cards.map((c) => c.y), 8);
|
|
513
|
-
const horizontalGaps = () => {
|
|
514
|
-
const out = [];
|
|
515
|
-
for (const rowY of cluster(cards.map((c) => c.y), 8)) {
|
|
516
|
-
const row = cards.filter((c) => Math.abs(c.y - rowY) <= 8).sort((a, b) => a.x - b.x);
|
|
517
|
-
for (let i = 1; i < row.length; i += 1) {
|
|
518
|
-
const gap = row[i].x - (row[i - 1].x + row[i - 1].w);
|
|
519
|
-
if (gap >= 0 && gap <= w * 0.15) out.push(gap);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
return out.sort((a, b) => a - b);
|
|
523
|
-
};
|
|
524
|
-
const verticalGaps = () => {
|
|
525
|
-
const out = [];
|
|
526
|
-
for (const colX of cluster(cards.map((c) => c.x), 8)) {
|
|
527
|
-
const col = cards.filter((c) => Math.abs(c.x - colX) <= 8).sort((a, b) => a.y - b.y);
|
|
528
|
-
for (let i = 1; i < col.length; i += 1) {
|
|
529
|
-
const gap = col[i].y - (col[i - 1].y + col[i - 1].h);
|
|
530
|
-
if (gap >= 0 && gap <= h * 0.15) out.push(gap);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
return out.sort((a, b) => a - b);
|
|
534
|
-
};
|
|
535
|
-
const medianOf = (arr) => (arr.length === 0 ? null : arr[Math.floor(arr.length / 2)]);
|
|
536
|
-
const hGaps = horizontalGaps();
|
|
537
|
-
const vGaps = verticalGaps();
|
|
538
|
-
|
|
539
|
-
return {
|
|
540
|
-
palette,
|
|
541
|
-
columns: columns.map(([a, b]) => ({ left: a, right: b, width: b - a + 1 })),
|
|
542
|
-
rowPitch: pitch,
|
|
543
|
-
list: listStats,
|
|
544
|
-
vEdges,
|
|
545
|
-
cards: cards.slice(0, 24),
|
|
546
|
-
cardGrid: {
|
|
547
|
-
count: cards.length,
|
|
548
|
-
colStarts,
|
|
549
|
-
rowStarts,
|
|
550
|
-
colGap: medianOf(hGaps),
|
|
551
|
-
rowGap: medianOf(vGaps),
|
|
552
|
-
colGapSamples: hGaps.length,
|
|
553
|
-
rowGapSamples: vGaps.length
|
|
554
|
-
}
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* 卡片栅格剖面(对外暴露给 taste-image 复用)。
|
|
560
|
-
*
|
|
561
|
-
* 为什么直接复用而不是再写一个:卡片检测的坑(近白阈值必须相对页面底色、
|
|
562
|
-
* 渐变底会让色带法整图变一列)都是 layout_check image 这边踩过并校准过的,
|
|
563
|
-
* 在另一边重写一份等于重新踩一遍。
|
|
564
|
-
*/
|
|
565
|
-
export function cardGridOf(buffer) {
|
|
566
|
-
const img = decodePng(buffer);
|
|
567
|
-
const { cards, cardGrid } = layoutProfile(img);
|
|
568
|
-
return { ...cardGrid, cards, width: img.width, height: img.height };
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
export function auditImage(buffer, { name = "image" } = {}) {
|
|
572
|
-
const img = decodePng(buffer);
|
|
573
|
-
const { width: w, height: h, channels, data } = img;
|
|
574
|
-
if (w < 1 || h < 1) throw new Error(`PNG 尺寸非法:${w}x${h}`);
|
|
575
|
-
const stride = w * channels;
|
|
576
|
-
const alphaIdx = channels === 4 ? 3 : channels === 2 ? 1 : -1;
|
|
577
|
-
|
|
578
|
-
/* ---------- 1. 透明度判定:决定用 alpha mask 还是背景色差 mask ---------- */
|
|
579
|
-
let transparent = false;
|
|
580
|
-
if (alphaIdx >= 0) {
|
|
581
|
-
for (let i = alphaIdx; i < data.length; i += channels) {
|
|
582
|
-
if (data[i] < ALPHA_OPAQUE) {
|
|
583
|
-
transparent = true;
|
|
584
|
-
break;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
const bg = transparent ? null : estimateBackground(img);
|
|
589
|
-
|
|
590
|
-
/* ---------- 2. 一次扫描:mask + 包围盒 + 四半区像素数/视觉重量 ---------- */
|
|
591
|
-
const mask = new Uint8Array(w * h);
|
|
592
|
-
const colHas = new Uint8Array(w);
|
|
593
|
-
const rowHas = new Uint8Array(h);
|
|
594
|
-
let count = 0;
|
|
595
|
-
let minX = w;
|
|
596
|
-
let minY = h;
|
|
597
|
-
let maxX = -1;
|
|
598
|
-
let maxY = -1;
|
|
599
|
-
let leftN = 0;
|
|
600
|
-
let rightN = 0;
|
|
601
|
-
let topN = 0;
|
|
602
|
-
let bottomN = 0;
|
|
603
|
-
let leftW = 0;
|
|
604
|
-
let rightW = 0;
|
|
605
|
-
let topW = 0;
|
|
606
|
-
let bottomW = 0;
|
|
607
|
-
const cx = (w - 1) / 2;
|
|
608
|
-
const cy = (h - 1) / 2;
|
|
609
|
-
for (let y = 0; y < h; y += 1) {
|
|
610
|
-
const rowBase = y * stride;
|
|
611
|
-
for (let x = 0; x < w; x += 1) {
|
|
612
|
-
const i = rowBase + x * channels;
|
|
613
|
-
let content;
|
|
614
|
-
if (transparent) {
|
|
615
|
-
content = alphaIdx >= 0 ? data[i + alphaIdx] > ALPHA_CONTENT : true;
|
|
616
|
-
} else {
|
|
617
|
-
// channels=1/2 时 RGB 同值(灰度),channels>=3 时取前三个通道
|
|
618
|
-
const r = data[i];
|
|
619
|
-
const g = channels >= 3 ? data[i + 1] : r;
|
|
620
|
-
const b = channels >= 3 ? data[i + 2] : r;
|
|
621
|
-
const dr = r - bg.r;
|
|
622
|
-
const dg = g - bg.g;
|
|
623
|
-
const db = b - bg.b;
|
|
624
|
-
content = dr * dr + dg * dg + db * db > BG_DIST_SQ;
|
|
625
|
-
}
|
|
626
|
-
if (!content) continue;
|
|
627
|
-
mask[y * w + x] = 1;
|
|
628
|
-
count += 1;
|
|
629
|
-
if (x < minX) minX = x;
|
|
630
|
-
if (y < minY) minY = y;
|
|
631
|
-
if (x > maxX) maxX = x;
|
|
632
|
-
if (y > maxY) maxY = y;
|
|
633
|
-
colHas[x] = 1;
|
|
634
|
-
rowHas[y] = 1;
|
|
635
|
-
const dx = Math.abs(x - cx);
|
|
636
|
-
const dy = Math.abs(y - cy);
|
|
637
|
-
if (x < cx) {
|
|
638
|
-
leftN += 1;
|
|
639
|
-
leftW += dx;
|
|
640
|
-
} else {
|
|
641
|
-
rightN += 1;
|
|
642
|
-
rightW += dx;
|
|
643
|
-
}
|
|
644
|
-
if (y < cy) {
|
|
645
|
-
topN += 1;
|
|
646
|
-
topW += dy;
|
|
647
|
-
} else {
|
|
648
|
-
bottomN += 1;
|
|
649
|
-
bottomW += dy;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/* ---------- 3. 指标 ---------- */
|
|
655
|
-
const box = count > 0 ? { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 } : { x: 0, y: 0, w: 0, h: 0 };
|
|
656
|
-
// 无内容时 margin 记 0(用 contentPixels===0 区分「没有内容」与「内容贴边」),规则另有 count>0 守卫
|
|
657
|
-
const marginPx =
|
|
658
|
-
count > 0
|
|
659
|
-
? { left: minX, top: minY, right: w - 1 - maxX, bottom: h - 1 - maxY }
|
|
660
|
-
: { left: 0, top: 0, right: 0, bottom: 0 };
|
|
661
|
-
const marginsPct = {
|
|
662
|
-
left: round2((marginPx.left / w) * 100),
|
|
663
|
-
top: round2((marginPx.top / h) * 100),
|
|
664
|
-
right: round2((marginPx.right / w) * 100),
|
|
665
|
-
bottom: round2((marginPx.bottom / h) * 100)
|
|
666
|
-
};
|
|
667
|
-
const coverage = count / (w * h);
|
|
668
|
-
const balance = {
|
|
669
|
-
lr: count > 0 ? round2((rightN - leftN) / count) : 0, // 正 = 右侧重
|
|
670
|
-
tb: count > 0 ? round2((bottomN - topN) / count) : 0 // 正 = 下侧重
|
|
671
|
-
};
|
|
672
|
-
const weightBalance = {
|
|
673
|
-
lr: count > 0 ? round2(leftW + rightW > 0 ? (rightW - leftW) / (leftW + rightW) : 0) : 0,
|
|
674
|
-
tb: count > 0 ? round2(topW + bottomW > 0 ? (bottomW - topW) / (topW + bottomW) : 0) : 0
|
|
675
|
-
};
|
|
676
|
-
const colSegs = contentRuns(colHas, MIN_RUN);
|
|
677
|
-
const rowSegs = contentRuns(rowHas, MIN_RUN);
|
|
678
|
-
const coveragePct = round2(coverage * 100);
|
|
679
|
-
// 稀疏版面(纯文字/线稿)不做构图类判定,避免把正常的文字排版当成失衡
|
|
680
|
-
const sparse = coverage < SUBSTANTIAL_COVERAGE;
|
|
681
|
-
const findings = [];
|
|
682
|
-
|
|
683
|
-
// 满血版面:四边都 <2% → 应用窗口截图 / 全屏布局,边距类规则不适用
|
|
684
|
-
const fullBleed = count > 0 && [marginsPct.left, marginsPct.top, marginsPct.right, marginsPct.bottom].every((v) => v < FULL_BLEED_MAX_PCT);
|
|
685
|
-
|
|
686
|
-
/* ---------- 4. 规则 ---------- */
|
|
687
|
-
|
|
688
|
-
// P1 内容贴边/出血不足:逐边报(每条边的修法不同,合成一条反而不好改)。
|
|
689
|
-
// 满血版面跳过:应用窗口铺满窗口是设计本身,不是缺陷。
|
|
690
|
-
if (count > 0 && !fullBleed) {
|
|
691
|
-
for (const side of ["left", "top", "right", "bottom"]) {
|
|
692
|
-
const pct = marginsPct[side];
|
|
693
|
-
if (pct >= MARGIN_WARN_PCT) continue;
|
|
694
|
-
const isError = pct < MARGIN_ERROR_PCT;
|
|
695
|
-
findings.push(
|
|
696
|
-
finding({
|
|
697
|
-
severity: isError ? "error" : "warn",
|
|
698
|
-
rule: "P1",
|
|
699
|
-
where: `margin:${side}`,
|
|
700
|
-
message: isError
|
|
701
|
-
? `${SIDE_CN[side]}边距只有 ${pct}%(<2%):内容贴边/出血不足,裁切、圆角遮罩或屏幕边缘会切到内容`
|
|
702
|
-
: `${SIDE_CN[side]}边距 ${pct}%(<4%):留白不足,看起来像排版没收尾、内容顶到画布边缘`,
|
|
703
|
-
fix: `把${SIDE_CN[side]}边距提到 6–8%(1000px 画布约 60–80px);海报/截图都留安全边,别让内容顶边`
|
|
704
|
-
})
|
|
705
|
-
);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// P2 边距严重不对称(满血版面边距都没意义,跳过)
|
|
710
|
-
if (count > 0 && !sparse && !fullBleed) {
|
|
711
|
-
const four = Object.values(marginsPct);
|
|
712
|
-
const minPct = Math.min(...four);
|
|
713
|
-
const maxPct = Math.max(...four);
|
|
714
|
-
const ratio = minPct === 0 ? Infinity : maxPct / minPct;
|
|
715
|
-
const ratioBad = ratio > ASYM_RATIO && minPct < ASYM_MIN_PCT;
|
|
716
|
-
let edgeBad = false;
|
|
717
|
-
for (const [side, opposite] of [
|
|
718
|
-
["left", "right"],
|
|
719
|
-
["right", "left"],
|
|
720
|
-
["top", "bottom"],
|
|
721
|
-
["bottom", "top"]
|
|
722
|
-
]) {
|
|
723
|
-
if (marginsPct[side] > ASYM_SINGLE_MAX_PCT && marginsPct[opposite] < ASYM_OPPOSITE_MIN_PCT) edgeBad = true;
|
|
724
|
-
}
|
|
725
|
-
if (ratioBad || edgeBad) {
|
|
726
|
-
const ratioText = ratio === Infinity ? "∞" : `${round2(ratio)}x`;
|
|
727
|
-
findings.push(
|
|
728
|
-
finding({
|
|
729
|
-
severity: "warn",
|
|
730
|
-
rule: "P2",
|
|
731
|
-
message:
|
|
732
|
-
`边距严重不对称:左 ${marginsPct.left}% / 右 ${marginsPct.right}% / 上 ${marginsPct.top}% / 下 ${marginsPct.bottom}%` +
|
|
733
|
-
`(${ratioBad ? `最大/最小 = ${ratioText},最窄边 <${ASYM_MIN_PCT}%` : `单边 >${ASYM_SINGLE_MAX_PCT}% 且对边 <${ASYM_OPPOSITE_MIN_PCT}%`})`,
|
|
734
|
-
fix: `四条边距应落在同一量级(同一视觉栅格):把最窄边加宽、或把最宽边收窄到 6–8% 那一档`
|
|
735
|
-
})
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// P3 密度异常(拥挤 / 过空)。
|
|
741
|
-
// **满血版面(应用窗口截图)不做密度判定**:窗口本来就铺满整个画布,
|
|
742
|
-
// 留白不存在是正常的。实测:安全软件、邮箱客户端两张界面截图都被报
|
|
743
|
-
// 「覆盖率 99%,画面过满」——那是把「截图边界」当成了「设计留白」。
|
|
744
|
-
if (!fullBleed && coverage > CROWDED_COVERAGE) {
|
|
745
|
-
findings.push(
|
|
746
|
-
finding({
|
|
747
|
-
severity: "warn",
|
|
748
|
-
rule: "P3",
|
|
749
|
-
where: `coverage:${coveragePct}%`,
|
|
750
|
-
message: `内容覆盖率 ${coveragePct}%(>85%):画面过满、几乎没有留白,观感会闷(海报/截图也需要呼吸区)`,
|
|
751
|
-
fix: `删掉次要元素或加大画布,把留白提到 15–40%;拥挤图先按「一屏一个主信息」砍内容`
|
|
752
|
-
})
|
|
753
|
-
);
|
|
754
|
-
} else if (coverage < EMPTY_COVERAGE) {
|
|
755
|
-
findings.push(
|
|
756
|
-
finding({
|
|
757
|
-
severity: "warn",
|
|
758
|
-
rule: "P3",
|
|
759
|
-
where: `coverage:${coveragePct}%`,
|
|
760
|
-
message:
|
|
761
|
-
`内容覆盖率仅 ${coveragePct}%(<${round2(EMPTY_COVERAGE * 100)}%):画面几乎是空的;也可能整张图是纯白/纯透明底、内容根本没渲染出来` +
|
|
762
|
-
`(先确认渲染链路,再判断是不是设计上过空)`,
|
|
763
|
-
fix: `确认体检的是内容图而不是空白占位图;若确实过空,放大主体或加视觉锚点(标题/图形/色块)`
|
|
764
|
-
})
|
|
765
|
-
);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// P4 视觉失衡:像素数与视觉重量都算,任一轴超 25% 即报(稀疏版面不做构图判定)。
|
|
769
|
-
if (count > 0 && !sparse) {
|
|
770
|
-
const candidates = [
|
|
771
|
-
{ axis: "lr", side: balance.lr > 0 ? "右" : "左", label: "左右内容像素差", abs: Math.abs(balance.lr) },
|
|
772
|
-
{ axis: "tb", side: balance.tb > 0 ? "下" : "上", label: "上下内容像素差", abs: Math.abs(balance.tb) },
|
|
773
|
-
{ axis: "wlr", side: weightBalance.lr > 0 ? "右" : "左", label: "左右视觉重量差", abs: Math.abs(weightBalance.lr) },
|
|
774
|
-
{ axis: "wtb", side: weightBalance.tb > 0 ? "下" : "上", label: "上下视觉重量差", abs: Math.abs(weightBalance.tb) }
|
|
775
|
-
];
|
|
776
|
-
const worst = candidates.reduce((a, b) => (b.abs > a.abs ? b : a));
|
|
777
|
-
if (worst.abs > BALANCE_LIMIT) {
|
|
778
|
-
const detail =
|
|
779
|
-
`左右像素差 ${Math.round(Math.abs(balance.lr) * 100)}% · 上下像素差 ${Math.round(Math.abs(balance.tb) * 100)}% · ` +
|
|
780
|
-
`左右视觉重量差 ${Math.round(Math.abs(weightBalance.lr) * 100)}% · 上下视觉重量差 ${Math.round(Math.abs(weightBalance.tb) * 100)}%`;
|
|
781
|
-
findings.push(
|
|
782
|
-
finding({
|
|
783
|
-
severity: "warn",
|
|
784
|
-
rule: "P4",
|
|
785
|
-
where: `side:${worst.side}`,
|
|
786
|
-
message: `视觉失衡:重心偏${worst.side}(${worst.label} ${Math.round(worst.abs * 100)}% > ${BALANCE_LIMIT * 100}%);${detail}`,
|
|
787
|
-
fix:
|
|
788
|
-
`把重心拉回画布中心:重的一侧减量/缩小,或在对侧补同量级内容;主体优先按居中或 1/3 黄金分割摆。` +
|
|
789
|
-
// 静态上下文,不做「外壳检测」——试过用像素判侧栏,下载工具测不出、视频站测错位置,
|
|
790
|
-
// 靠不住。宁可不猜,把判断依据交给调用方。
|
|
791
|
-
`若本图是「侧边导航 + 内容区」的作业型界面,左右不对称通常是设计意图,确认后再决定是否改`
|
|
792
|
-
})
|
|
793
|
-
);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// P5 对齐混乱:块结构来自行带内的列 run(块的左边界 = 块起点)
|
|
798
|
-
if (count > 0 && rowSegs.length > 0 && !sparse) {
|
|
799
|
-
const bands = [];
|
|
800
|
-
for (const seg of rowSegs) {
|
|
801
|
-
// 单次总成本 O(非空行数 × 宽) ≤ O(画布像素),不会因为行带多而爆
|
|
802
|
-
const bandCol = new Uint8Array(w);
|
|
803
|
-
for (let y = seg.start; y <= seg.end; y += 1) {
|
|
804
|
-
const base = y * w;
|
|
805
|
-
for (let x = 0; x < w; x += 1) if (mask[base + x] === 1) bandCol[x] = 1;
|
|
806
|
-
}
|
|
807
|
-
const blocks = contentRuns(bandCol, MIN_RUN);
|
|
808
|
-
if (blocks.length === 0) continue;
|
|
809
|
-
bands.push({
|
|
810
|
-
left: blocks[0].start,
|
|
811
|
-
right: blocks[blocks.length - 1].end,
|
|
812
|
-
center: (blocks[0].start + blocks[blocks.length - 1].end) / 2
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
// P5a:块起点 ≥5 个且没有两条重合(最大簇只有 1)→ 找不到对齐关系
|
|
816
|
-
const clusters = clusterStarts(
|
|
817
|
-
bands.map((b) => b.left),
|
|
818
|
-
START_TOL_PX
|
|
819
|
-
);
|
|
820
|
-
const maxCluster = clusters.reduce((m, c) => Math.max(m, c.count), 0);
|
|
821
|
-
if (clusters.length >= START_CHAOS_MIN && maxCluster === 1) {
|
|
822
|
-
findings.push(
|
|
823
|
-
finding({
|
|
824
|
-
severity: "info",
|
|
825
|
-
rule: "P5",
|
|
826
|
-
where: `blocks:${clusters.length}`,
|
|
827
|
-
message: `块结构没有对齐关系:${clusters.length} 个块起点互不相同(容差 ${START_TOL_PX}px),没有两块共享同一条左边线`,
|
|
828
|
-
fix: `把各块起点收敛到 1–2 条对齐线(首选统一左对齐栅格);块多时用列栅格而不是各自定位`
|
|
829
|
-
})
|
|
830
|
-
);
|
|
831
|
-
}
|
|
832
|
-
// P5b:≥2 种对齐方式同时大量存在(各 ≥2 个行带)
|
|
833
|
-
const tol = Math.max(START_TOL_PX, Math.round(Math.min(w, h) * 0.005));
|
|
834
|
-
const modes = { left: 0, center: 0, right: 0 };
|
|
835
|
-
const boxRight = box.x + box.w - 1;
|
|
836
|
-
const boxCenter = box.x + (box.w - 1) / 2;
|
|
837
|
-
for (const band of bands) {
|
|
838
|
-
const hits = [];
|
|
839
|
-
if (Math.abs(band.left - box.x) <= tol) hits.push("left");
|
|
840
|
-
if (Math.abs(band.right - boxRight) <= tol) hits.push("right");
|
|
841
|
-
if (Math.abs(band.center - boxCenter) <= tol) hits.push("center");
|
|
842
|
-
// 命中 0 或 ≥2 种(例如满宽行)都不算对齐证据
|
|
843
|
-
if (hits.length === 1) modes[hits[0]] += 1;
|
|
844
|
-
}
|
|
845
|
-
const bigModes = Object.entries(modes).filter(([, n]) => n >= ALIGN_MIN_BANDS);
|
|
846
|
-
if (bigModes.length >= 2) {
|
|
847
|
-
findings.push(
|
|
848
|
-
finding({
|
|
849
|
-
severity: "warn",
|
|
850
|
-
rule: "P5",
|
|
851
|
-
where: `align:${bigModes.map(([mode]) => mode).join("+")}`,
|
|
852
|
-
message: `对齐方式混乱:${bigModes.map(([mode, n]) => `${MODE_CN[mode]}对齐 ${n} 个内容带`).join("、")} 同时存在,视线要左右跳`,
|
|
853
|
-
fix: `整张图统一一种对齐(正文左对齐最稳),最多保留一种刻意的居中标题;别在同层混用左/右对齐`
|
|
854
|
-
})
|
|
855
|
-
);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// P6 分辨率过低
|
|
860
|
-
const minSide = Math.min(w, h);
|
|
861
|
-
if (minSide < RES_WARN) {
|
|
862
|
-
findings.push(
|
|
863
|
-
finding({
|
|
864
|
-
severity: "warn",
|
|
865
|
-
rule: "P6",
|
|
866
|
-
where: `canvas:${w}x${h}`,
|
|
867
|
-
message: `分辨率 ${w}×${h} 偏小(短边 <${RES_WARN}px):位图放大后文字/1px 描边会糊`,
|
|
868
|
-
fix: `按最终展示尺寸的 1.5–2 倍导出(短边至少 ${RES_WARN}px,大屏建议 ≥1200px),用时再缩小`
|
|
869
|
-
})
|
|
870
|
-
);
|
|
871
|
-
} else if (minSide < RES_INFO) {
|
|
872
|
-
findings.push(
|
|
873
|
-
finding({
|
|
874
|
-
severity: "info",
|
|
875
|
-
rule: "P6",
|
|
876
|
-
where: `canvas:${w}x${h}`,
|
|
877
|
-
message: `分辨率 ${w}×${h} 一般(短边 <${RES_INFO}px):小屏够用,大屏/印刷会发虚`,
|
|
878
|
-
fix: `需要大屏或印刷就再导一份短边 ≥1200px 的版本(矢量源另存,别靠放大位图)`
|
|
879
|
-
})
|
|
880
|
-
);
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
/* ---------- P8 卡片栅格一致性 ----------
|
|
884
|
-
* 既然能量出卡片矩形,就能查「栅格到底齐不齐」——这是卡片型 UI 最经典的一类缺陷:
|
|
885
|
-
* 同一行的卡片宽度差 3px、列间距一处 24 一处 32,肉眼说不出哪里不对,但整页就是“飘”。
|
|
886
|
-
* 实测(下载工具仪表板):4 列 KPI 宽 246/246/245/246、间距 24/24,直接判齐。
|
|
887
|
-
* 只在真的检测到栅格时才判(至少 2 行或 2 列),避免把非卡片布局误判。 */
|
|
888
|
-
const profile = transparent ? null : layoutProfile(img, bg);
|
|
889
|
-
if (!transparent) {
|
|
890
|
-
const cards = profile.cards;
|
|
891
|
-
const tol = 4; // 抗锯齿与圆角会让 1–2px 抖动,4px 才是有意义的不齐
|
|
892
|
-
if (cards.length >= 4) {
|
|
893
|
-
// 按行分组:y 相近的算同一行
|
|
894
|
-
const rowsOfCards = [];
|
|
895
|
-
for (const card of [...cards].sort((a, b) => a.y - b.y)) {
|
|
896
|
-
const row = rowsOfCards.find((r) => Math.abs(r.y - card.y) <= 8);
|
|
897
|
-
if (row === undefined) rowsOfCards.push({ y: card.y, items: [card] });
|
|
898
|
-
else row.items.push(card);
|
|
899
|
-
}
|
|
900
|
-
const issues = [];
|
|
901
|
-
for (const row of rowsOfCards) {
|
|
902
|
-
if (row.items.length < 2) continue;
|
|
903
|
-
const sorted = [...row.items].sort((a, b) => a.x - b.x);
|
|
904
|
-
const widths = sorted.map((c) => c.w);
|
|
905
|
-
// 只判「真栅格」:同一行 ≥3 张、且最大/最小宽度比 ≤2.5。
|
|
906
|
-
// 实测反例:在线文档参考图里主内容区(1081px)与右侧工具面板(478px)并排,
|
|
907
|
-
// 它们是不同性质的面板,本来就该不等宽——不设这条会误报“栅格不齐 差 603px”。
|
|
908
|
-
if (sorted.length < 3 || Math.max(...widths) / Math.max(1, Math.min(...widths)) > 2.5) continue;
|
|
909
|
-
const gaps = [];
|
|
910
|
-
for (let i = 1; i < sorted.length; i += 1) gaps.push(sorted[i].x - (sorted[i - 1].x + sorted[i - 1].w));
|
|
911
|
-
if (Math.max(...widths) - Math.min(...widths) > tol) {
|
|
912
|
-
issues.push(`y≈${Math.round(row.y)} 这行卡片宽 ${widths.join("/")} 差 ${Math.max(...widths) - Math.min(...widths)}px`);
|
|
913
|
-
}
|
|
914
|
-
if (gaps.length >= 2 && Math.max(...gaps) - Math.min(...gaps) > tol) {
|
|
915
|
-
issues.push(`y≈${Math.round(row.y)} 这行列间距 ${gaps.join("/")} 不一致`);
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
if (issues.length > 0) {
|
|
919
|
-
findings.push(
|
|
920
|
-
finding({
|
|
921
|
-
severity: "warn",
|
|
922
|
-
rule: "P8",
|
|
923
|
-
message: `卡片栅格不齐:${issues.slice(0, 3).join(";")}`,
|
|
924
|
-
fix: `同一行的卡片宽度与列间距应完全一致(用栅格系统或 flex:1 + 统一 gap,不要逐个手调宽度;差 >${tol}px 人眼就能看出“飘”)`
|
|
925
|
-
})
|
|
926
|
-
);
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
/* ---------- P9 列表/表格行高一致性 ----------
|
|
932
|
-
* 表格与列表是办公类界面最密集的部分,而「某一行比其它行高/矮」是这类界面
|
|
933
|
-
* 最典型、也最难靠眼睛发现的缺陷(尤其行数多的时候)。
|
|
934
|
-
* 判据来自 profile.list:以中位行距为基准,±15% 以内算同一档。
|
|
935
|
-
* 实测校准:在线文档参考表 10 行 100% 一致(不报)、视频站动态 17 行 94.1%(不报)、
|
|
936
|
-
* 故意做歪的合成表 10 行 90%(报,含一个双倍高行)。
|
|
937
|
-
* 阈值取 <93:92–95% 这一带在真实界面里常是“某一行文字换行了”,属可接受范围。
|
|
938
|
-
* 样本 <6 时行距本身没有统计意义,直接跳过。
|
|
939
|
-
*/
|
|
940
|
-
if (!transparent) {
|
|
941
|
-
const listInfo = profile.list;
|
|
942
|
-
if (listInfo !== null && listInfo !== undefined && listInfo.count >= 6 && listInfo.singleLine === true && listInfo.uniformPct < 93) {
|
|
943
|
-
findings.push(
|
|
944
|
-
finding({
|
|
945
|
-
severity: "warn",
|
|
946
|
-
rule: "P9",
|
|
947
|
-
where: `list:${listInfo.count}行`,
|
|
948
|
-
message:
|
|
949
|
-
`列表行高不一致:${listInfo.count} 行里 ${listInfo.outlierCount} 行偏离中位 ${listInfo.pitch}px(一致性 ${listInfo.uniformPct}%)` +
|
|
950
|
-
(listInfo.outliers.length === 0 ? "" : `,异常间距 ${listInfo.outliers.join("/")}px`),
|
|
951
|
-
fix: `表格/列表的行高应该是同一档:给行设固定 height 或统一的 padding,文字超长用省略号而不是自动换行撑高——换行的那一行会把整张表的节奏打乱`
|
|
952
|
-
})
|
|
953
|
-
);
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
/* 关于「区块间距挤压」为什么没做成规则(记录下来,避免重复尝试):
|
|
958
|
-
* 真实缺陷:参照改造时用 `margin-top:-12px` 把表格硬拉上去,导致 tabs 下划线与表头贴死。
|
|
959
|
-
* 试过在像素上判「块间间距 < 块内间距的一半」,结果**全线误报**:
|
|
960
|
-
* 视频站 gap 1px/中位 7px、下载工具 gap 3px/中位 27px、连修好的版本也报——
|
|
961
|
-
* 因为像素层面**无法可靠区分「段落内的行距」与「区块之间的间距」**,
|
|
962
|
-
* 两者都是“文字行之间的空白”,要区分必须先做块分组,而块分组在密集界面里不可靠。
|
|
963
|
-
* 结论:这条改由 CSS 侧的 S14(负外边距)抓根因。宁可不报,也不要报错的。
|
|
964
|
-
* (同样的取舍记录也见「应用外壳检测」:两种像素判据都不可靠,最后改为静态提示。)
|
|
965
|
-
*/
|
|
966
|
-
|
|
967
|
-
// P7 能力边界(固定 info,文案固定)
|
|
968
|
-
findings.push(
|
|
969
|
-
finding({
|
|
970
|
-
severity: "info",
|
|
971
|
-
rule: "P7",
|
|
972
|
-
message: "本模式不做文字/对比度检测(无 OCR),排版文字问题请用 action=ui / action=svg",
|
|
973
|
-
fix: "文字可读性/字号/对比度:action=ui 看 HTML/CSS 源码,action=svg 看成品矢量文字几何"
|
|
974
|
-
})
|
|
975
|
-
);
|
|
976
|
-
|
|
977
|
-
/* ---------- 5. 统一算分(common.js) ---------- */
|
|
978
|
-
return finalize({
|
|
979
|
-
mode: "image",
|
|
980
|
-
target: name,
|
|
981
|
-
metrics: {
|
|
982
|
-
canvas: [w, h],
|
|
983
|
-
contentBox: box,
|
|
984
|
-
marginsPct,
|
|
985
|
-
coveragePct,
|
|
986
|
-
balance,
|
|
987
|
-
segments: { cols: colSegs.length, rows: rowSegs.length },
|
|
988
|
-
contentPixels: count,
|
|
989
|
-
weightBalance,
|
|
990
|
-
background: transparent ? "transparent" : toHex(bg),
|
|
991
|
-
// 覆盖度声明:clean 但 rulesRun=0 时,调用方要能看出「什么都没测」,而不是「测了且过了」。
|
|
992
|
-
rulesRun: 7 - (fullBleed ? 2 : 0) + ((profile?.cards?.length ?? 0) >= 4 ? 1 : 0) + ((profile?.list?.count ?? 0) >= 6 ? 1 : 0),
|
|
993
|
-
measured: [
|
|
994
|
-
"边距/出血",
|
|
995
|
-
"密度",
|
|
996
|
-
"视觉平衡",
|
|
997
|
-
"对齐",
|
|
998
|
-
"分辨率",
|
|
999
|
-
...((profile?.cards?.length ?? 0) >= 4 ? [`卡片栅格×${profile.cards.length}`] : []),
|
|
1000
|
-
...((profile?.list?.count ?? 0) >= 6 ? [`列表行高×${profile.list.count}`] : [])
|
|
1001
|
-
],
|
|
1002
|
-
// 满血版面:边缘贴边是应用窗口的正常形态,P1/P2 已跳过。
|
|
1003
|
-
// 告诉调用方「为什么没报贴边」,避免以为检查漏了。
|
|
1004
|
-
...(fullBleed ? { fullBleed: true, skippedRules: ["P1", "P2"] } : {}),
|
|
1005
|
-
// 版式剖面:参照改造时用来“量原图”,不进评分、不产生 findings。
|
|
1006
|
-
...(profile ? { profile } : {})
|
|
1007
|
-
},
|
|
1008
|
-
findings
|
|
1009
|
-
});
|
|
1010
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* layouts/image.js — layout_check action=image:渲染结果(UI 截图 / 海报 / banner / 社交卡片)的
|
|
3
|
+
* 「构图几何」体检。没有 OCR,所以只量几何与密度,不判文字可读性(P7 会明确声明这条能力边界)。
|
|
4
|
+
*
|
|
5
|
+
* 判定链路(纯函数、确定性:同一 buffer 永远同一结果):
|
|
6
|
+
* 1. 解码 PNG(./png.js,零依赖;不在本文件重写解码)。
|
|
7
|
+
* 2. 得到 content mask:
|
|
8
|
+
* - 有透明度(通道含 alpha,且存在 a<250 的像素)→ mask = a>16(透明底图;细线/描边也能抓到);
|
|
9
|
+
* - 否则取四角像素估计背景色(每通道 ±6 容差聚类,取出现最多的一组),
|
|
10
|
+
* mask = 与背景色 RGB 欧氏距离 >24 的像素。
|
|
11
|
+
* 3. 一次扫描得到:内容包围盒、四边 margin、覆盖率、左右/上下内容像素数与「视觉重量」。
|
|
12
|
+
* 4. 行/列投影(连续 ≥2px 的内容区间 = run)→ 块结构:行带(水平内容带)与列带(垂直内容带)、
|
|
13
|
+
* 行带内的列 run(= 块)与块起点,用于判断对齐。
|
|
14
|
+
* 5. P1–P7 七条规则 → findings,交给 common.js finalize 统一算分(0–100,越低越好)。
|
|
15
|
+
*
|
|
16
|
+
* 阈值依据(为什么是这些数):
|
|
17
|
+
* - 边距 <2%:1000px 画布只剩 20px,出血/裁切/圆角遮罩/屏幕边缘必然切到内容 → error;
|
|
18
|
+
* <4%:肉眼就能看出「贴边」,像没排完版 → warn。safe area 惯例 6–8%(印刷 3–5mm 在 1000px
|
|
19
|
+
* 画布上也接近 6%),所以修复建议统一给 6–8%。
|
|
20
|
+
* - 边距比 >3 且最窄边 <8%:常见版式四边在 6–15% 一档,超过 3 倍就是「一边贴边、一边大留白」的
|
|
21
|
+
* 失衡;另一类是同一条轴上一侧 >30%、对边 <5%(内容被挤到一侧、对侧几乎顶边)。
|
|
22
|
+
* - 覆盖率 >85%:几乎没留白,观感闷(海报最低可以到 70% 左右,但要有成组的呼吸区);
|
|
23
|
+
* <6%:主体缺失,或背景估计失败 —— 纯白/纯透明底是最常见的误判来源,所以 message 直接提示这两种可能。
|
|
24
|
+
* - 左右/上下不平衡 >25%:人眼对重心偏移的容忍阈值接近 1/4。像素数和视觉重量(像素数×距中心距离)
|
|
25
|
+
* 两条都算:只数像素会漏掉「大面积浅色块 vs 少而集中的重色块」。
|
|
26
|
+
* - 块起点 ≥5 个且没有两条重合(容差 2px):各块各自为政、没有可依循的对齐线;≥2 种对齐方式
|
|
27
|
+
* 各 ≥2 块:视线被迫左右跳。
|
|
28
|
+
* - 分辨率 <300px:位图放大 2× 就明显糊(文字和 1px 描边先死);<800px:小屏够用,大屏/印刷会虚。
|
|
29
|
+
* - P7:固定 info,声明本模式的能力边界,避免使用者以为「图里的文字排版也体检过了」。
|
|
30
|
+
*
|
|
31
|
+
* 已知误报(写进报告提醒,不试图消除):
|
|
32
|
+
* - 渐变底/照片背景:背景色只取四角,四角颜色不一致时一半画面会被当成内容 → 覆盖率偏高、
|
|
33
|
+
* P4/P5 可能误报;这类图要么先确认意图,要么改用 action=svg / action=ui。
|
|
34
|
+
* - 纯色大面积铺底 + 少量内容:会被判「过空」(message 里已提示可能是纯白/纯透明底)。
|
|
35
|
+
*/
|
|
36
|
+
import { decodePng, pixelAt } from "./png.js";
|
|
37
|
+
import { finalize, finding } from "./common.js";
|
|
38
|
+
|
|
39
|
+
/* ----------------------------- 阈值常量 ----------------------------- */
|
|
40
|
+
|
|
41
|
+
const ALPHA_CONTENT = 16; // 透明底:a>16 才算可见内容(更低的是渲染残留/阴影边缘)
|
|
42
|
+
const ALPHA_OPAQUE = 250; // 存在 a<250 的像素 = 这张图带透明度(不是纯不透明底)
|
|
43
|
+
const BG_TOLERANCE = 6; // 四角同色聚类:每通道 ±6 视为同一背景(吸 8-bit 抖动/极轻渐变)
|
|
44
|
+
const BG_DIST_SQ = 24 * 24; // 与背景色的欧氏距离 >24 才算内容(≈单通道 14,吃掉抗锯齿过渡带)
|
|
45
|
+
const MIN_RUN = 2; // 连续 ≥2px 才算「块」/内容段(1px 线是描边、分隔线、水印,不构成块结构)
|
|
46
|
+
const MARGIN_ERROR_PCT = 2;
|
|
47
|
+
const MARGIN_WARN_PCT = 4;
|
|
48
|
+
const ASYM_RATIO = 3;
|
|
49
|
+
const ASYM_MIN_PCT = 8; // 「最小边 <8%」才有资格算严重不对称
|
|
50
|
+
const ASYM_SINGLE_MAX_PCT = 30; // 单边 >30%
|
|
51
|
+
const ASYM_OPPOSITE_MIN_PCT = 5; // 且对边 <5%
|
|
52
|
+
const CROWDED_COVERAGE = 0.85;
|
|
53
|
+
/** 过空阈值:文字型海报/封面天然只有 2–5% 墨迹覆盖率(细字形),不能按图形占比要求。
|
|
54
|
+
* 真正该报的是接近空白(<1.5%,大概率没渲染/纯占位)。 */
|
|
55
|
+
const EMPTY_COVERAGE = 0.015;
|
|
56
|
+
/** 低于这个覆盖率视为「稀疏文字类版面」:边距对称性/重心/对齐这些构图规则在没有体量块时
|
|
57
|
+
* 没有参考价值(左对齐文字天然左重),跳过以免误判。 */
|
|
58
|
+
const SUBSTANTIAL_COVERAGE = 0.08;
|
|
59
|
+
/**
|
|
60
|
+
* 满血版面(full-bleed):内容四边都几乎碰到画布(每边 <2%)。
|
|
61
|
+
* 这通常意味着「这不是一张作品,而是一个应用窗口的截图」:侧栏、顶栏、网格本来就该铺满窗口,
|
|
62
|
+
* 贴边是正确设计而不是缺陷。
|
|
63
|
+
*
|
|
64
|
+
* 实测教训(2026-11-07,拿 Flow-Pilot 的真实截图验证):7 张 1440×900 的应用截图
|
|
65
|
+
* **全部**被判 62/100 bad,唯一原因就是 P1 贴边 error×3 —— 纯噪声。
|
|
66
|
+
* 一个对真实截图全错的规则比没有规则更糟。
|
|
67
|
+
*/
|
|
68
|
+
const FULL_BLEED_MAX_PCT = 2;
|
|
69
|
+
const BALANCE_LIMIT = 0.25; // 左右/上下失衡阈值
|
|
70
|
+
const START_TOL_PX = 2; // 块起点去重容差
|
|
71
|
+
const START_CHAOS_MIN = 5; // ≥5 个互不相同且各出现 1 次 = 没有对齐关系
|
|
72
|
+
const ALIGN_MIN_BANDS = 2; // 一种对齐方式至少 2 个行带才算「大量存在」
|
|
73
|
+
const RES_WARN = 300;
|
|
74
|
+
const RES_INFO = 800;
|
|
75
|
+
|
|
76
|
+
const SIDE_CN = { left: "左", top: "上", right: "右", bottom: "下" };
|
|
77
|
+
const MODE_CN = { left: "左", center: "居中", right: "右" };
|
|
78
|
+
|
|
79
|
+
/** 四舍五入到 2 位小数;把 -0 归一成 0(否则 JSON 无损往返会翻车)。 */
|
|
80
|
+
function round2(value) {
|
|
81
|
+
const r = Math.round(value * 100) / 100;
|
|
82
|
+
return r === 0 ? 0 : r;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 背景色估计:沿整个边框采样(而非只看四角),按「每通道 ±6」聚类取最大组均值。
|
|
87
|
+
* 边框采样比四角稳:窗口截图顶部深色标题栏会让四角平票,进而选错背景。
|
|
88
|
+
*/
|
|
89
|
+
function estimateBackground(img) {
|
|
90
|
+
const { width: w, height: h } = img;
|
|
91
|
+
// 沿**整个边框**采样,不只四角:应用窗口顶部是深色标题栏时,
|
|
92
|
+
// 两个角在深色、两个在浅色,四角聚类会平票并可能选中深色当背景,
|
|
93
|
+
// 导致整个内容区被当成「内容」、边距全错(实测 Flow-Pilot 窗口截图)。
|
|
94
|
+
const step = Math.max(1, Math.floor((w + h) / 128)); // 最多 ~256 个采样点
|
|
95
|
+
const samples = [];
|
|
96
|
+
for (let x = 0; x < w; x += step) {
|
|
97
|
+
samples.push(pixelAt(img, x, 0), pixelAt(img, x, h - 1));
|
|
98
|
+
}
|
|
99
|
+
for (let y = 0; y < h; y += step) {
|
|
100
|
+
samples.push(pixelAt(img, 0, y), pixelAt(img, w - 1, y));
|
|
101
|
+
}
|
|
102
|
+
const groups = [];
|
|
103
|
+
for (const c of samples) {
|
|
104
|
+
let group = groups.find(
|
|
105
|
+
(g) =>
|
|
106
|
+
Math.abs(g.rep.r - c.r) <= BG_TOLERANCE &&
|
|
107
|
+
Math.abs(g.rep.g - c.g) <= BG_TOLERANCE &&
|
|
108
|
+
Math.abs(g.rep.b - c.b) <= BG_TOLERANCE
|
|
109
|
+
);
|
|
110
|
+
if (group === undefined) {
|
|
111
|
+
group = { rep: c, count: 0, sumR: 0, sumG: 0, sumB: 0 };
|
|
112
|
+
groups.push(group);
|
|
113
|
+
}
|
|
114
|
+
group.count += 1;
|
|
115
|
+
group.sumR += c.r;
|
|
116
|
+
group.sumG += c.g;
|
|
117
|
+
group.sumB += c.b;
|
|
118
|
+
}
|
|
119
|
+
let best = groups[0];
|
|
120
|
+
for (const g of groups) if (g.count > best.count) best = g;
|
|
121
|
+
return {
|
|
122
|
+
r: Math.round(best.sumR / best.count),
|
|
123
|
+
g: Math.round(best.sumG / best.count),
|
|
124
|
+
b: Math.round(best.sumB / best.count)
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 一维投影(0/1 数组)→ 连续 1 的区间(长度 >= minLen),inclusive 起止。 */
|
|
129
|
+
function contentRuns(flags, minLen) {
|
|
130
|
+
const out = [];
|
|
131
|
+
let start = -1;
|
|
132
|
+
for (let i = 0; i <= flags.length; i += 1) {
|
|
133
|
+
const on = i < flags.length && flags[i] === 1;
|
|
134
|
+
if (on && start === -1) start = i;
|
|
135
|
+
else if (!on && start !== -1) {
|
|
136
|
+
if (i - start >= minLen) out.push({ start, end: i - 1 });
|
|
137
|
+
start = -1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 起点去重:排序后按 ±tol 聚簇(链式,够用且确定性)。 */
|
|
144
|
+
function clusterStarts(starts, tol) {
|
|
145
|
+
const sorted = [...starts].sort((a, b) => a - b);
|
|
146
|
+
const clusters = [];
|
|
147
|
+
for (const s of sorted) {
|
|
148
|
+
const last = clusters[clusters.length - 1];
|
|
149
|
+
if (last !== undefined && s - last.rep <= tol) last.count += 1;
|
|
150
|
+
else clusters.push({ rep: s, count: 1 });
|
|
151
|
+
}
|
|
152
|
+
return clusters;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toHex(rgb) {
|
|
156
|
+
return `#${[rgb.r, rgb.g, rgb.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* PNG buffer → 构图体检报告(mode=image)。纯函数、确定性。
|
|
161
|
+
* @param {Buffer|Uint8Array} buffer PNG 字节(工具层已读文件)
|
|
162
|
+
* @param {{name?: string}} [options]
|
|
163
|
+
*/
|
|
164
|
+
/**
|
|
165
|
+
* 版式剖面:从截图反推「这页是怎么排的」。
|
|
166
|
+
*
|
|
167
|
+
* **为什么值得做成工具**:做 UI 参照改造的第一步永远是“量原图”——列边界在哪、
|
|
168
|
+
* 行距多少、主色是什么。实测:做视频站动态页参照实现时,同一种色带扫描手写了三遍;
|
|
169
|
+
* 同一个需求写第三遍,就该进工具。
|
|
170
|
+
*
|
|
171
|
+
* 纯像素统计,不靠 OCR、不靠模型:
|
|
172
|
+
* 1. 主色板:量化到 16 级后取 top 6 + 面积占比;
|
|
173
|
+
* 2. 垂直色带:逐列统计「与页面底色不同」的占比,跳变处即栏位/卡片的水平范围;
|
|
174
|
+
* ——适配“大底色 + 白卡片”这类客户端/后台界面;
|
|
175
|
+
* 3. 行节奏:在内容最宽的那一栏竖向扫描,取内容→空白转换间距的中位数。
|
|
176
|
+
*/
|
|
177
|
+
/**
|
|
178
|
+
* 卡片矩形检测:近白像素的连通域 → 包围盒。
|
|
179
|
+
*
|
|
180
|
+
* 为什么判据是「近白」而不是「与页面底色不同」:渐变背景下任何固定的底色都不成立
|
|
181
|
+
* (实测下载工具仪表板的背景从 #dde5f2 渐变到 #f2f5fa,逐点比较必然失效)。
|
|
182
|
+
* 而现代 UI 的卡片几乎总是近白,这个判据对渐变底、柔和阴影、深色主题反色都更稳。
|
|
183
|
+
*
|
|
184
|
+
* @returns {Array<{x:number,y:number,w:number,h:number,area:number}>} 按面积降序
|
|
185
|
+
*/
|
|
186
|
+
function detectCards(at, w, h, threshold = 250) {
|
|
187
|
+
const NEAR_WHITE = threshold; // 三通道都 ≥ 这个值才算“卡片面”(调用方按页面底色算)
|
|
188
|
+
const mask = new Uint8Array(w * h);
|
|
189
|
+
for (let y = 0; y < h; y += 1) {
|
|
190
|
+
const row = y * w;
|
|
191
|
+
for (let x = 0; x < w; x += 1) {
|
|
192
|
+
const p = at(x, y);
|
|
193
|
+
if (p[0] >= NEAR_WHITE && p[1] >= NEAR_WHITE && p[2] >= NEAR_WHITE) mask[row + x] = 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 两遍连通域(4 邻域):第一遍打临时标号,第二遍合并等价类
|
|
198
|
+
const labels = new Int32Array(w * h).fill(-1);
|
|
199
|
+
const parent = [];
|
|
200
|
+
const find = (i) => {
|
|
201
|
+
let r = i;
|
|
202
|
+
while (parent[r] !== r) r = parent[r];
|
|
203
|
+
while (parent[i] !== r) {
|
|
204
|
+
const next = parent[i];
|
|
205
|
+
parent[i] = r;
|
|
206
|
+
i = next;
|
|
207
|
+
}
|
|
208
|
+
return r;
|
|
209
|
+
};
|
|
210
|
+
const union = (a, b) => {
|
|
211
|
+
const ra = find(a);
|
|
212
|
+
const rb = find(b);
|
|
213
|
+
if (ra !== rb) parent[rb] = ra;
|
|
214
|
+
};
|
|
215
|
+
let next = 0;
|
|
216
|
+
for (let y = 0; y < h; y += 1) {
|
|
217
|
+
for (let x = 0; x < w; x += 1) {
|
|
218
|
+
const idx = y * w + x;
|
|
219
|
+
if (mask[idx] === 0) continue;
|
|
220
|
+
const left = x > 0 ? labels[idx - 1] : -1;
|
|
221
|
+
const up = y > 0 ? labels[idx - w] : -1;
|
|
222
|
+
if (left === -1 && up === -1) {
|
|
223
|
+
labels[idx] = next;
|
|
224
|
+
parent[next] = next;
|
|
225
|
+
next += 1;
|
|
226
|
+
} else if (left !== -1 && up !== -1) {
|
|
227
|
+
labels[idx] = Math.min(left, up);
|
|
228
|
+
union(left, up);
|
|
229
|
+
} else {
|
|
230
|
+
labels[idx] = left !== -1 ? left : up;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const boxes = new Map();
|
|
236
|
+
for (let y = 0; y < h; y += 1) {
|
|
237
|
+
for (let x = 0; x < w; x += 1) {
|
|
238
|
+
const idx = y * w + x;
|
|
239
|
+
if (labels[idx] === -1) continue;
|
|
240
|
+
const root = find(labels[idx]);
|
|
241
|
+
const box = boxes.get(root);
|
|
242
|
+
if (box === undefined) boxes.set(root, { x0: x, y0: y, x1: x, y1: y, n: 1 });
|
|
243
|
+
else {
|
|
244
|
+
if (x < box.x0) box.x0 = x;
|
|
245
|
+
if (x > box.x1) box.x1 = x;
|
|
246
|
+
if (y < box.y0) box.y0 = y;
|
|
247
|
+
if (y > box.y1) box.y1 = y;
|
|
248
|
+
box.n += 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 过滤:太小的是文字/图标/高光,不是卡片
|
|
254
|
+
const minW = Math.max(24, Math.round(w * 0.03));
|
|
255
|
+
const minH = Math.max(16, Math.round(h * 0.015));
|
|
256
|
+
const out = [];
|
|
257
|
+
for (const box of boxes.values()) {
|
|
258
|
+
const bw = box.x1 - box.x0 + 1;
|
|
259
|
+
const bh = box.y1 - box.y0 + 1;
|
|
260
|
+
if (bw < minW || bh < minH) continue;
|
|
261
|
+
// 充填率太低说明是散碎白点连成的细长形,不是卡片
|
|
262
|
+
if (box.n / (bw * bh) < 0.55) continue;
|
|
263
|
+
out.push({ x: box.x0, y: box.y0, w: bw, h: bh, area: bw * bh });
|
|
264
|
+
}
|
|
265
|
+
return out.sort((a, b) => b.area - a.area);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function layoutProfile(img, bgHint) {
|
|
269
|
+
const { width: w, height: h, data, channels } = img;
|
|
270
|
+
|
|
271
|
+
// 页面底色 = **图像四边**出现最多的颜色。
|
|
272
|
+
// 实测坑:直接用全图最长见的颜色(estimateBackground)会把“白卡片”(占 45%)当底,
|
|
273
|
+
// 于是灰色版心间隙被当成内容,整张图变成一栏。而四边一定是页面底色,不会错。
|
|
274
|
+
const edgeBuckets = new Map();
|
|
275
|
+
const bump = (x, y) => {
|
|
276
|
+
const i = (y * w + x) * channels;
|
|
277
|
+
const r = data[i];
|
|
278
|
+
const g = data[i + 1];
|
|
279
|
+
const b = data[i + 2];
|
|
280
|
+
const key = `${Math.round(r / 8)},${Math.round(g / 8)},${Math.round(b / 8)}`;
|
|
281
|
+
const slot = edgeBuckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
|
|
282
|
+
slot.n += 1;
|
|
283
|
+
slot.r += r;
|
|
284
|
+
slot.g += g;
|
|
285
|
+
slot.b += b;
|
|
286
|
+
edgeBuckets.set(key, slot);
|
|
287
|
+
};
|
|
288
|
+
// 用四角小块采样:顶部边线常被顶栏占满(全是白色),按边计票会把白卡片当底,
|
|
289
|
+
// 进而把灰色版心间隙当成“内容”,列结构完全反过来(实测踩过)。四角更稳。
|
|
290
|
+
const PATCH = Math.max(2, Math.round(Math.min(w, h) * 0.012));
|
|
291
|
+
for (const [cx, cy] of [[0, 0], [w - PATCH, 0], [0, h - PATCH], [w - PATCH, h - PATCH]]) {
|
|
292
|
+
for (let y = cy; y < cy + PATCH; y += 1) for (let x = cx; x < cx + PATCH; x += 1) bump(x, y);
|
|
293
|
+
}
|
|
294
|
+
const edgeTop = [...edgeBuckets.values()].sort((a, b) => b.n - a.n)[0];
|
|
295
|
+
// 统一成 [r,g,b] 数组:near() 按下标比较,传对象会让比较全变 NaN → 整图都算“内容”,
|
|
296
|
+
// 结果永远只有一列(实测踩过)。
|
|
297
|
+
const bgArray = edgeTop === undefined ? [bgHint.r, bgHint.g, bgHint.b] : [Math.round(edgeTop.r / edgeTop.n), Math.round(edgeTop.g / edgeTop.n), Math.round(edgeTop.b / edgeTop.n)];
|
|
298
|
+
const bg = { r: bgArray[0], g: bgArray[1], b: bgArray[2] };
|
|
299
|
+
const bgRef = bgArray;
|
|
300
|
+
|
|
301
|
+
const at = (x, y) => {
|
|
302
|
+
const i = (y * w + x) * channels;
|
|
303
|
+
return [data[i], data[i + 1], data[i + 2]];
|
|
304
|
+
};
|
|
305
|
+
const near = (p, q, tol) => Math.abs(p[0] - q[0]) <= tol && Math.abs(p[1] - q[1]) <= tol && Math.abs(p[2] - q[2]) <= tol;
|
|
306
|
+
|
|
307
|
+
/* 1) 主色板 */
|
|
308
|
+
const buckets = new Map();
|
|
309
|
+
let total = 0;
|
|
310
|
+
for (let y = 0; y < h; y += 3) {
|
|
311
|
+
for (let x = 0; x < w; x += 3) {
|
|
312
|
+
const p = at(x, y);
|
|
313
|
+
const key = `${Math.round(p[0] / 16)},${Math.round(p[1] / 16)},${Math.round(p[2] / 16)}`;
|
|
314
|
+
const slot = buckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
|
|
315
|
+
slot.n += 1;
|
|
316
|
+
slot.r += p[0];
|
|
317
|
+
slot.g += p[1];
|
|
318
|
+
slot.b += p[2];
|
|
319
|
+
buckets.set(key, slot);
|
|
320
|
+
total += 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const palette = [...buckets.values()]
|
|
324
|
+
.sort((a, b) => b.n - a.n)
|
|
325
|
+
.slice(0, 6)
|
|
326
|
+
.map((s) => ({
|
|
327
|
+
hex: toHex({ r: Math.round(s.r / s.n), g: Math.round(s.g / s.n), b: Math.round(s.b / s.n) }),
|
|
328
|
+
share: round2((s.n / total) * 100)
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
/* 2) 垂直色带(列结构) */
|
|
332
|
+
const colFrac = new Array(w).fill(0);
|
|
333
|
+
let rowsChecked = 0;
|
|
334
|
+
for (let y = 0; y < h; y += 2) {
|
|
335
|
+
rowsChecked += 1;
|
|
336
|
+
for (let x = 0; x < w; x += 1) {
|
|
337
|
+
if (!near(at(x, y), bgRef, 6)) colFrac[x] += 1;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const colRatio = colFrac.map((v) => v / rowsChecked);
|
|
341
|
+
const minCol = Math.max(24, Math.round(w * 0.02));
|
|
342
|
+
const columns = [];
|
|
343
|
+
let start = -1;
|
|
344
|
+
for (let x = 0; x < w; x += 1) {
|
|
345
|
+
const busy = colRatio[x] > 0.5;
|
|
346
|
+
if (busy && start === -1) start = x;
|
|
347
|
+
else if (!busy && start !== -1) {
|
|
348
|
+
if (x - start >= minCol) columns.push([start, x - 1]);
|
|
349
|
+
start = -1;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (start !== -1 && w - start >= minCol) columns.push([start, w - 1]);
|
|
353
|
+
|
|
354
|
+
/* 2b) 垂直分界线:办公/客户端界面几乎全白,色带法分不开栏位(实测在线文档参考图:
|
|
355
|
+
* 色带只报出「90~1538 | 1551~2030」,左图标栏与二级侧栏都丢了)。
|
|
356
|
+
* 但栏位之间**一定有一条贯穿全高的分界线**——逐行比较相邻两列的颜色变化,
|
|
357
|
+
* 变化率高的 x 就是真实的栏位边界。这比“与页面底色不同”稳得多。 */
|
|
358
|
+
const vEdges = [];
|
|
359
|
+
{
|
|
360
|
+
const ratios = new Float64Array(w);
|
|
361
|
+
for (let x = 1; x < w; x += 1) {
|
|
362
|
+
let changed = 0;
|
|
363
|
+
let rows = 0;
|
|
364
|
+
for (let y = 0; y < h; y += 3) {
|
|
365
|
+
const a1 = at(x, y);
|
|
366
|
+
const b1 = at(x - 1, y);
|
|
367
|
+
const d = (Math.abs(a1[0] - b1[0]) + Math.abs(a1[1] - b1[1]) + Math.abs(a1[2] - b1[2])) / 3;
|
|
368
|
+
if (d >= 2) changed += 1;
|
|
369
|
+
rows += 1;
|
|
370
|
+
}
|
|
371
|
+
ratios[x] = rows === 0 ? 0 : changed / rows;
|
|
372
|
+
}
|
|
373
|
+
// 连续 ≥85% 变化的列聚成一条边;取其中最靠中间的值
|
|
374
|
+
let run = null;
|
|
375
|
+
for (let x = 1; x < w; x += 1) {
|
|
376
|
+
if (ratios[x] >= 0.85) {
|
|
377
|
+
if (run === null) run = { start: x, end: x };
|
|
378
|
+
else run.end = x;
|
|
379
|
+
} else if (run !== null) {
|
|
380
|
+
vEdges.push(Math.round((run.start + run.end) / 2));
|
|
381
|
+
run = null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (run !== null) vEdges.push(Math.round((run.start + run.end) / 2));
|
|
385
|
+
// 去掉图像边框(贴边那两条不是栏位边界),并把相距 ≤8px 的重复边合并
|
|
386
|
+
const kept = vEdges.filter((x) => x >= 7 && x <= w - 8);
|
|
387
|
+
vEdges.length = 0;
|
|
388
|
+
for (const x of kept) {
|
|
389
|
+
const last = vEdges[vEdges.length - 1];
|
|
390
|
+
if (last !== undefined && x - last <= 10) continue;
|
|
391
|
+
vEdges.push(x);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/* 3) 列表/表格行节奏:用**窄竖条**采样,取节奏最稳的那一条
|
|
396
|
+
* 为什么不能用整条列带:表格型界面里侧栏与内容区往往连成一条宽列,
|
|
397
|
+
* 整行处处有内容 → 行间隙检测不出来(实测在线文档参考图:整列带法返回 null)。
|
|
398
|
+
* 窄竖条(~40px)只看一列文字,行与行之间的空白就显出来了。
|
|
399
|
+
* 顺带产出**行高一致性**:表格里某一行比其它行高/矮是典型缺陷。 */
|
|
400
|
+
let pitch = null;
|
|
401
|
+
let listStats = null;
|
|
402
|
+
{
|
|
403
|
+
const strips = [];
|
|
404
|
+
// ① 各列带内取几条窄条
|
|
405
|
+
for (const [left, right] of columns) {
|
|
406
|
+
const bandW = right - left + 1;
|
|
407
|
+
if (bandW < 60) continue;
|
|
408
|
+
for (const frac of [0.15, 0.35, 0.55, 0.75]) {
|
|
409
|
+
const sx = Math.round(left + bandW * frac);
|
|
410
|
+
if (sx + 40 <= right) strips.push(sx);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// ② 整幅均匀撒几条,兜住跨全宽的表
|
|
414
|
+
for (let i = 1; i <= 10; i += 1) {
|
|
415
|
+
const sx = Math.round((w * i) / 11);
|
|
416
|
+
if (sx + 40 < w) strips.push(sx);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let best = null;
|
|
420
|
+
for (const sx of strips) {
|
|
421
|
+
const SW = 40;
|
|
422
|
+
const buckets = new Map();
|
|
423
|
+
for (let y = 0; y < h; y += 2) {
|
|
424
|
+
for (let x = sx; x < sx + SW; x += 2) {
|
|
425
|
+
const px = at(x, y);
|
|
426
|
+
const k = `${px[0] >> 3},${px[1] >> 3},${px[2] >> 3}`;
|
|
427
|
+
buckets.set(k, (buckets.get(k) ?? 0) + 1);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const top = [...buckets.entries()].sort((a1, b1) => b1[1] - a1[1])[0];
|
|
431
|
+
if (top === undefined) continue;
|
|
432
|
+
const localBg = top[0].split(",").map((v) => Number(v) * 8);
|
|
433
|
+
const starts = [];
|
|
434
|
+
let blank = true;
|
|
435
|
+
let runStart = 0;
|
|
436
|
+
for (let y = 0; y < h; y += 1) {
|
|
437
|
+
let has = false;
|
|
438
|
+
for (let x = sx; x < sx + SW; x += 2) {
|
|
439
|
+
const px = at(x, y);
|
|
440
|
+
if (Math.abs(px[0] - localBg[0]) > 12 || Math.abs(px[1] - localBg[1]) > 12 || Math.abs(px[2] - localBg[2]) > 12) {
|
|
441
|
+
has = true;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (has && blank) {
|
|
446
|
+
blank = false;
|
|
447
|
+
runStart = y;
|
|
448
|
+
} else if (!has && !blank) {
|
|
449
|
+
blank = true;
|
|
450
|
+
if (y - runStart >= 4) starts.push(runStart);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const gaps = [];
|
|
454
|
+
for (let i = 1; i < starts.length; i += 1) {
|
|
455
|
+
const g = starts[i] - starts[i - 1];
|
|
456
|
+
if (g >= 12 && g <= h / 3) gaps.push(g);
|
|
457
|
+
}
|
|
458
|
+
if (gaps.length < 4) continue;
|
|
459
|
+
const mean = gaps.reduce((a1, b1) => a1 + b1, 0) / gaps.length;
|
|
460
|
+
const variance = gaps.reduce((a1, b1) => a1 + (b1 - mean) ** 2, 0) / gaps.length;
|
|
461
|
+
const cv = Math.sqrt(variance) / mean;
|
|
462
|
+
if (best === null || cv < best.cv) best = { cv, gaps, sx };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (best !== null) {
|
|
466
|
+
const sorted = [...best.gaps].sort((a1, b1) => a1 - b1);
|
|
467
|
+
pitch = sorted[Math.floor(sorted.length / 2)];
|
|
468
|
+
// 行高一致性:以中位数为基准,±15% 以内算“同一档”
|
|
469
|
+
const within = best.gaps.filter((g) => g >= pitch * 0.85 && g <= pitch * 1.15);
|
|
470
|
+
const outliers = best.gaps.filter((g) => g < pitch * 0.85 || g > pitch * 1.15);
|
|
471
|
+
const uniformPct = Math.round((within.length / best.gaps.length) * 1000) / 10;
|
|
472
|
+
listStats = {
|
|
473
|
+
pitch,
|
|
474
|
+
count: best.gaps.length,
|
|
475
|
+
uniformPct,
|
|
476
|
+
// 单行列表 vs 多行列表:邮件/聊天/卡片列表里每「行」含 2–3 行文字,
|
|
477
|
+
// 行内行距会把间隙分布拉成双峰。这时「行高不一致」判据根本不成立
|
|
478
|
+
// (实测邮箱界面:39 个间隙里 19 个偏离中位,51.3% —— 全是行内行距,
|
|
479
|
+
// 却会被判成「19 行行高异常」)。低于 80% 一致就直接不做判断。
|
|
480
|
+
singleLine: uniformPct >= 80,
|
|
481
|
+
outliers: [...new Set(outliers)].sort((a1, b1) => a1 - b1).slice(0, 6),
|
|
482
|
+
outlierCount: outliers.length,
|
|
483
|
+
stripX: best.sx
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* 4) 卡片矩形:现代 UI 的基本单元是“卡片”,不是“色带”。
|
|
489
|
+
* 动机:拿一张渐变背景 + 柔和阴影的仪表板(下载工具)试,上面的列检测直接退回
|
|
490
|
+
* “整图一列”——因为渐变让 near(bg) 处处不成立。色带法只适合“大底色 + 规整栏位”,
|
|
491
|
+
* 一旦是卡片栅格就失效。
|
|
492
|
+
* 做法:近白像素 → 连通域 → 包围盒。卡片几乎总是近白(或至少比页面底色亮),
|
|
493
|
+
* 这个判据对渐变底、柔和阴影都稳。
|
|
494
|
+
* 阈值必须**相对页面底色**:视频站页面底色 #F6F7F9 三通道都 ≥246,固定 246 会把
|
|
495
|
+
* 整页底都当成“卡片面”,连通成一张 2072×1414 的巨块。 */
|
|
496
|
+
const bgLum = 0.299 * bgRef[0] + 0.587 * bgRef[1] + 0.114 * bgRef[2];
|
|
497
|
+
const surfaceThreshold = Math.min(254, Math.max(250, Math.round(bgLum) + 4));
|
|
498
|
+
const cards = detectCards(at, w, h, surfaceThreshold);
|
|
499
|
+
|
|
500
|
+
/* 5) 卡片栅格:x 起点聚类 = 列;y 起点聚类 = 行;间距按「同一行/同一列内相邻」算 */
|
|
501
|
+
const cluster = (values, tol) => {
|
|
502
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
503
|
+
const out = [];
|
|
504
|
+
for (const v of sorted) {
|
|
505
|
+
const last = out[out.length - 1];
|
|
506
|
+
if (last !== undefined && Math.abs(v - last) <= tol) continue;
|
|
507
|
+
out.push(v);
|
|
508
|
+
}
|
|
509
|
+
return out;
|
|
510
|
+
};
|
|
511
|
+
const colStarts = cluster(cards.map((c) => c.x), 8);
|
|
512
|
+
const rowStarts = cluster(cards.map((c) => c.y), 8);
|
|
513
|
+
const horizontalGaps = () => {
|
|
514
|
+
const out = [];
|
|
515
|
+
for (const rowY of cluster(cards.map((c) => c.y), 8)) {
|
|
516
|
+
const row = cards.filter((c) => Math.abs(c.y - rowY) <= 8).sort((a, b) => a.x - b.x);
|
|
517
|
+
for (let i = 1; i < row.length; i += 1) {
|
|
518
|
+
const gap = row[i].x - (row[i - 1].x + row[i - 1].w);
|
|
519
|
+
if (gap >= 0 && gap <= w * 0.15) out.push(gap);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return out.sort((a, b) => a - b);
|
|
523
|
+
};
|
|
524
|
+
const verticalGaps = () => {
|
|
525
|
+
const out = [];
|
|
526
|
+
for (const colX of cluster(cards.map((c) => c.x), 8)) {
|
|
527
|
+
const col = cards.filter((c) => Math.abs(c.x - colX) <= 8).sort((a, b) => a.y - b.y);
|
|
528
|
+
for (let i = 1; i < col.length; i += 1) {
|
|
529
|
+
const gap = col[i].y - (col[i - 1].y + col[i - 1].h);
|
|
530
|
+
if (gap >= 0 && gap <= h * 0.15) out.push(gap);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return out.sort((a, b) => a - b);
|
|
534
|
+
};
|
|
535
|
+
const medianOf = (arr) => (arr.length === 0 ? null : arr[Math.floor(arr.length / 2)]);
|
|
536
|
+
const hGaps = horizontalGaps();
|
|
537
|
+
const vGaps = verticalGaps();
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
palette,
|
|
541
|
+
columns: columns.map(([a, b]) => ({ left: a, right: b, width: b - a + 1 })),
|
|
542
|
+
rowPitch: pitch,
|
|
543
|
+
list: listStats,
|
|
544
|
+
vEdges,
|
|
545
|
+
cards: cards.slice(0, 24),
|
|
546
|
+
cardGrid: {
|
|
547
|
+
count: cards.length,
|
|
548
|
+
colStarts,
|
|
549
|
+
rowStarts,
|
|
550
|
+
colGap: medianOf(hGaps),
|
|
551
|
+
rowGap: medianOf(vGaps),
|
|
552
|
+
colGapSamples: hGaps.length,
|
|
553
|
+
rowGapSamples: vGaps.length
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 卡片栅格剖面(对外暴露给 taste-image 复用)。
|
|
560
|
+
*
|
|
561
|
+
* 为什么直接复用而不是再写一个:卡片检测的坑(近白阈值必须相对页面底色、
|
|
562
|
+
* 渐变底会让色带法整图变一列)都是 layout_check image 这边踩过并校准过的,
|
|
563
|
+
* 在另一边重写一份等于重新踩一遍。
|
|
564
|
+
*/
|
|
565
|
+
export function cardGridOf(buffer) {
|
|
566
|
+
const img = decodePng(buffer);
|
|
567
|
+
const { cards, cardGrid } = layoutProfile(img);
|
|
568
|
+
return { ...cardGrid, cards, width: img.width, height: img.height };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function auditImage(buffer, { name = "image" } = {}) {
|
|
572
|
+
const img = decodePng(buffer);
|
|
573
|
+
const { width: w, height: h, channels, data } = img;
|
|
574
|
+
if (w < 1 || h < 1) throw new Error(`PNG 尺寸非法:${w}x${h}`);
|
|
575
|
+
const stride = w * channels;
|
|
576
|
+
const alphaIdx = channels === 4 ? 3 : channels === 2 ? 1 : -1;
|
|
577
|
+
|
|
578
|
+
/* ---------- 1. 透明度判定:决定用 alpha mask 还是背景色差 mask ---------- */
|
|
579
|
+
let transparent = false;
|
|
580
|
+
if (alphaIdx >= 0) {
|
|
581
|
+
for (let i = alphaIdx; i < data.length; i += channels) {
|
|
582
|
+
if (data[i] < ALPHA_OPAQUE) {
|
|
583
|
+
transparent = true;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const bg = transparent ? null : estimateBackground(img);
|
|
589
|
+
|
|
590
|
+
/* ---------- 2. 一次扫描:mask + 包围盒 + 四半区像素数/视觉重量 ---------- */
|
|
591
|
+
const mask = new Uint8Array(w * h);
|
|
592
|
+
const colHas = new Uint8Array(w);
|
|
593
|
+
const rowHas = new Uint8Array(h);
|
|
594
|
+
let count = 0;
|
|
595
|
+
let minX = w;
|
|
596
|
+
let minY = h;
|
|
597
|
+
let maxX = -1;
|
|
598
|
+
let maxY = -1;
|
|
599
|
+
let leftN = 0;
|
|
600
|
+
let rightN = 0;
|
|
601
|
+
let topN = 0;
|
|
602
|
+
let bottomN = 0;
|
|
603
|
+
let leftW = 0;
|
|
604
|
+
let rightW = 0;
|
|
605
|
+
let topW = 0;
|
|
606
|
+
let bottomW = 0;
|
|
607
|
+
const cx = (w - 1) / 2;
|
|
608
|
+
const cy = (h - 1) / 2;
|
|
609
|
+
for (let y = 0; y < h; y += 1) {
|
|
610
|
+
const rowBase = y * stride;
|
|
611
|
+
for (let x = 0; x < w; x += 1) {
|
|
612
|
+
const i = rowBase + x * channels;
|
|
613
|
+
let content;
|
|
614
|
+
if (transparent) {
|
|
615
|
+
content = alphaIdx >= 0 ? data[i + alphaIdx] > ALPHA_CONTENT : true;
|
|
616
|
+
} else {
|
|
617
|
+
// channels=1/2 时 RGB 同值(灰度),channels>=3 时取前三个通道
|
|
618
|
+
const r = data[i];
|
|
619
|
+
const g = channels >= 3 ? data[i + 1] : r;
|
|
620
|
+
const b = channels >= 3 ? data[i + 2] : r;
|
|
621
|
+
const dr = r - bg.r;
|
|
622
|
+
const dg = g - bg.g;
|
|
623
|
+
const db = b - bg.b;
|
|
624
|
+
content = dr * dr + dg * dg + db * db > BG_DIST_SQ;
|
|
625
|
+
}
|
|
626
|
+
if (!content) continue;
|
|
627
|
+
mask[y * w + x] = 1;
|
|
628
|
+
count += 1;
|
|
629
|
+
if (x < minX) minX = x;
|
|
630
|
+
if (y < minY) minY = y;
|
|
631
|
+
if (x > maxX) maxX = x;
|
|
632
|
+
if (y > maxY) maxY = y;
|
|
633
|
+
colHas[x] = 1;
|
|
634
|
+
rowHas[y] = 1;
|
|
635
|
+
const dx = Math.abs(x - cx);
|
|
636
|
+
const dy = Math.abs(y - cy);
|
|
637
|
+
if (x < cx) {
|
|
638
|
+
leftN += 1;
|
|
639
|
+
leftW += dx;
|
|
640
|
+
} else {
|
|
641
|
+
rightN += 1;
|
|
642
|
+
rightW += dx;
|
|
643
|
+
}
|
|
644
|
+
if (y < cy) {
|
|
645
|
+
topN += 1;
|
|
646
|
+
topW += dy;
|
|
647
|
+
} else {
|
|
648
|
+
bottomN += 1;
|
|
649
|
+
bottomW += dy;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/* ---------- 3. 指标 ---------- */
|
|
655
|
+
const box = count > 0 ? { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 } : { x: 0, y: 0, w: 0, h: 0 };
|
|
656
|
+
// 无内容时 margin 记 0(用 contentPixels===0 区分「没有内容」与「内容贴边」),规则另有 count>0 守卫
|
|
657
|
+
const marginPx =
|
|
658
|
+
count > 0
|
|
659
|
+
? { left: minX, top: minY, right: w - 1 - maxX, bottom: h - 1 - maxY }
|
|
660
|
+
: { left: 0, top: 0, right: 0, bottom: 0 };
|
|
661
|
+
const marginsPct = {
|
|
662
|
+
left: round2((marginPx.left / w) * 100),
|
|
663
|
+
top: round2((marginPx.top / h) * 100),
|
|
664
|
+
right: round2((marginPx.right / w) * 100),
|
|
665
|
+
bottom: round2((marginPx.bottom / h) * 100)
|
|
666
|
+
};
|
|
667
|
+
const coverage = count / (w * h);
|
|
668
|
+
const balance = {
|
|
669
|
+
lr: count > 0 ? round2((rightN - leftN) / count) : 0, // 正 = 右侧重
|
|
670
|
+
tb: count > 0 ? round2((bottomN - topN) / count) : 0 // 正 = 下侧重
|
|
671
|
+
};
|
|
672
|
+
const weightBalance = {
|
|
673
|
+
lr: count > 0 ? round2(leftW + rightW > 0 ? (rightW - leftW) / (leftW + rightW) : 0) : 0,
|
|
674
|
+
tb: count > 0 ? round2(topW + bottomW > 0 ? (bottomW - topW) / (topW + bottomW) : 0) : 0
|
|
675
|
+
};
|
|
676
|
+
const colSegs = contentRuns(colHas, MIN_RUN);
|
|
677
|
+
const rowSegs = contentRuns(rowHas, MIN_RUN);
|
|
678
|
+
const coveragePct = round2(coverage * 100);
|
|
679
|
+
// 稀疏版面(纯文字/线稿)不做构图类判定,避免把正常的文字排版当成失衡
|
|
680
|
+
const sparse = coverage < SUBSTANTIAL_COVERAGE;
|
|
681
|
+
const findings = [];
|
|
682
|
+
|
|
683
|
+
// 满血版面:四边都 <2% → 应用窗口截图 / 全屏布局,边距类规则不适用
|
|
684
|
+
const fullBleed = count > 0 && [marginsPct.left, marginsPct.top, marginsPct.right, marginsPct.bottom].every((v) => v < FULL_BLEED_MAX_PCT);
|
|
685
|
+
|
|
686
|
+
/* ---------- 4. 规则 ---------- */
|
|
687
|
+
|
|
688
|
+
// P1 内容贴边/出血不足:逐边报(每条边的修法不同,合成一条反而不好改)。
|
|
689
|
+
// 满血版面跳过:应用窗口铺满窗口是设计本身,不是缺陷。
|
|
690
|
+
if (count > 0 && !fullBleed) {
|
|
691
|
+
for (const side of ["left", "top", "right", "bottom"]) {
|
|
692
|
+
const pct = marginsPct[side];
|
|
693
|
+
if (pct >= MARGIN_WARN_PCT) continue;
|
|
694
|
+
const isError = pct < MARGIN_ERROR_PCT;
|
|
695
|
+
findings.push(
|
|
696
|
+
finding({
|
|
697
|
+
severity: isError ? "error" : "warn",
|
|
698
|
+
rule: "P1",
|
|
699
|
+
where: `margin:${side}`,
|
|
700
|
+
message: isError
|
|
701
|
+
? `${SIDE_CN[side]}边距只有 ${pct}%(<2%):内容贴边/出血不足,裁切、圆角遮罩或屏幕边缘会切到内容`
|
|
702
|
+
: `${SIDE_CN[side]}边距 ${pct}%(<4%):留白不足,看起来像排版没收尾、内容顶到画布边缘`,
|
|
703
|
+
fix: `把${SIDE_CN[side]}边距提到 6–8%(1000px 画布约 60–80px);海报/截图都留安全边,别让内容顶边`
|
|
704
|
+
})
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// P2 边距严重不对称(满血版面边距都没意义,跳过)
|
|
710
|
+
if (count > 0 && !sparse && !fullBleed) {
|
|
711
|
+
const four = Object.values(marginsPct);
|
|
712
|
+
const minPct = Math.min(...four);
|
|
713
|
+
const maxPct = Math.max(...four);
|
|
714
|
+
const ratio = minPct === 0 ? Infinity : maxPct / minPct;
|
|
715
|
+
const ratioBad = ratio > ASYM_RATIO && minPct < ASYM_MIN_PCT;
|
|
716
|
+
let edgeBad = false;
|
|
717
|
+
for (const [side, opposite] of [
|
|
718
|
+
["left", "right"],
|
|
719
|
+
["right", "left"],
|
|
720
|
+
["top", "bottom"],
|
|
721
|
+
["bottom", "top"]
|
|
722
|
+
]) {
|
|
723
|
+
if (marginsPct[side] > ASYM_SINGLE_MAX_PCT && marginsPct[opposite] < ASYM_OPPOSITE_MIN_PCT) edgeBad = true;
|
|
724
|
+
}
|
|
725
|
+
if (ratioBad || edgeBad) {
|
|
726
|
+
const ratioText = ratio === Infinity ? "∞" : `${round2(ratio)}x`;
|
|
727
|
+
findings.push(
|
|
728
|
+
finding({
|
|
729
|
+
severity: "warn",
|
|
730
|
+
rule: "P2",
|
|
731
|
+
message:
|
|
732
|
+
`边距严重不对称:左 ${marginsPct.left}% / 右 ${marginsPct.right}% / 上 ${marginsPct.top}% / 下 ${marginsPct.bottom}%` +
|
|
733
|
+
`(${ratioBad ? `最大/最小 = ${ratioText},最窄边 <${ASYM_MIN_PCT}%` : `单边 >${ASYM_SINGLE_MAX_PCT}% 且对边 <${ASYM_OPPOSITE_MIN_PCT}%`})`,
|
|
734
|
+
fix: `四条边距应落在同一量级(同一视觉栅格):把最窄边加宽、或把最宽边收窄到 6–8% 那一档`
|
|
735
|
+
})
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// P3 密度异常(拥挤 / 过空)。
|
|
741
|
+
// **满血版面(应用窗口截图)不做密度判定**:窗口本来就铺满整个画布,
|
|
742
|
+
// 留白不存在是正常的。实测:安全软件、邮箱客户端两张界面截图都被报
|
|
743
|
+
// 「覆盖率 99%,画面过满」——那是把「截图边界」当成了「设计留白」。
|
|
744
|
+
if (!fullBleed && coverage > CROWDED_COVERAGE) {
|
|
745
|
+
findings.push(
|
|
746
|
+
finding({
|
|
747
|
+
severity: "warn",
|
|
748
|
+
rule: "P3",
|
|
749
|
+
where: `coverage:${coveragePct}%`,
|
|
750
|
+
message: `内容覆盖率 ${coveragePct}%(>85%):画面过满、几乎没有留白,观感会闷(海报/截图也需要呼吸区)`,
|
|
751
|
+
fix: `删掉次要元素或加大画布,把留白提到 15–40%;拥挤图先按「一屏一个主信息」砍内容`
|
|
752
|
+
})
|
|
753
|
+
);
|
|
754
|
+
} else if (coverage < EMPTY_COVERAGE) {
|
|
755
|
+
findings.push(
|
|
756
|
+
finding({
|
|
757
|
+
severity: "warn",
|
|
758
|
+
rule: "P3",
|
|
759
|
+
where: `coverage:${coveragePct}%`,
|
|
760
|
+
message:
|
|
761
|
+
`内容覆盖率仅 ${coveragePct}%(<${round2(EMPTY_COVERAGE * 100)}%):画面几乎是空的;也可能整张图是纯白/纯透明底、内容根本没渲染出来` +
|
|
762
|
+
`(先确认渲染链路,再判断是不是设计上过空)`,
|
|
763
|
+
fix: `确认体检的是内容图而不是空白占位图;若确实过空,放大主体或加视觉锚点(标题/图形/色块)`
|
|
764
|
+
})
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// P4 视觉失衡:像素数与视觉重量都算,任一轴超 25% 即报(稀疏版面不做构图判定)。
|
|
769
|
+
if (count > 0 && !sparse) {
|
|
770
|
+
const candidates = [
|
|
771
|
+
{ axis: "lr", side: balance.lr > 0 ? "右" : "左", label: "左右内容像素差", abs: Math.abs(balance.lr) },
|
|
772
|
+
{ axis: "tb", side: balance.tb > 0 ? "下" : "上", label: "上下内容像素差", abs: Math.abs(balance.tb) },
|
|
773
|
+
{ axis: "wlr", side: weightBalance.lr > 0 ? "右" : "左", label: "左右视觉重量差", abs: Math.abs(weightBalance.lr) },
|
|
774
|
+
{ axis: "wtb", side: weightBalance.tb > 0 ? "下" : "上", label: "上下视觉重量差", abs: Math.abs(weightBalance.tb) }
|
|
775
|
+
];
|
|
776
|
+
const worst = candidates.reduce((a, b) => (b.abs > a.abs ? b : a));
|
|
777
|
+
if (worst.abs > BALANCE_LIMIT) {
|
|
778
|
+
const detail =
|
|
779
|
+
`左右像素差 ${Math.round(Math.abs(balance.lr) * 100)}% · 上下像素差 ${Math.round(Math.abs(balance.tb) * 100)}% · ` +
|
|
780
|
+
`左右视觉重量差 ${Math.round(Math.abs(weightBalance.lr) * 100)}% · 上下视觉重量差 ${Math.round(Math.abs(weightBalance.tb) * 100)}%`;
|
|
781
|
+
findings.push(
|
|
782
|
+
finding({
|
|
783
|
+
severity: "warn",
|
|
784
|
+
rule: "P4",
|
|
785
|
+
where: `side:${worst.side}`,
|
|
786
|
+
message: `视觉失衡:重心偏${worst.side}(${worst.label} ${Math.round(worst.abs * 100)}% > ${BALANCE_LIMIT * 100}%);${detail}`,
|
|
787
|
+
fix:
|
|
788
|
+
`把重心拉回画布中心:重的一侧减量/缩小,或在对侧补同量级内容;主体优先按居中或 1/3 黄金分割摆。` +
|
|
789
|
+
// 静态上下文,不做「外壳检测」——试过用像素判侧栏,下载工具测不出、视频站测错位置,
|
|
790
|
+
// 靠不住。宁可不猜,把判断依据交给调用方。
|
|
791
|
+
`若本图是「侧边导航 + 内容区」的作业型界面,左右不对称通常是设计意图,确认后再决定是否改`
|
|
792
|
+
})
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// P5 对齐混乱:块结构来自行带内的列 run(块的左边界 = 块起点)
|
|
798
|
+
if (count > 0 && rowSegs.length > 0 && !sparse) {
|
|
799
|
+
const bands = [];
|
|
800
|
+
for (const seg of rowSegs) {
|
|
801
|
+
// 单次总成本 O(非空行数 × 宽) ≤ O(画布像素),不会因为行带多而爆
|
|
802
|
+
const bandCol = new Uint8Array(w);
|
|
803
|
+
for (let y = seg.start; y <= seg.end; y += 1) {
|
|
804
|
+
const base = y * w;
|
|
805
|
+
for (let x = 0; x < w; x += 1) if (mask[base + x] === 1) bandCol[x] = 1;
|
|
806
|
+
}
|
|
807
|
+
const blocks = contentRuns(bandCol, MIN_RUN);
|
|
808
|
+
if (blocks.length === 0) continue;
|
|
809
|
+
bands.push({
|
|
810
|
+
left: blocks[0].start,
|
|
811
|
+
right: blocks[blocks.length - 1].end,
|
|
812
|
+
center: (blocks[0].start + blocks[blocks.length - 1].end) / 2
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
// P5a:块起点 ≥5 个且没有两条重合(最大簇只有 1)→ 找不到对齐关系
|
|
816
|
+
const clusters = clusterStarts(
|
|
817
|
+
bands.map((b) => b.left),
|
|
818
|
+
START_TOL_PX
|
|
819
|
+
);
|
|
820
|
+
const maxCluster = clusters.reduce((m, c) => Math.max(m, c.count), 0);
|
|
821
|
+
if (clusters.length >= START_CHAOS_MIN && maxCluster === 1) {
|
|
822
|
+
findings.push(
|
|
823
|
+
finding({
|
|
824
|
+
severity: "info",
|
|
825
|
+
rule: "P5",
|
|
826
|
+
where: `blocks:${clusters.length}`,
|
|
827
|
+
message: `块结构没有对齐关系:${clusters.length} 个块起点互不相同(容差 ${START_TOL_PX}px),没有两块共享同一条左边线`,
|
|
828
|
+
fix: `把各块起点收敛到 1–2 条对齐线(首选统一左对齐栅格);块多时用列栅格而不是各自定位`
|
|
829
|
+
})
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
// P5b:≥2 种对齐方式同时大量存在(各 ≥2 个行带)
|
|
833
|
+
const tol = Math.max(START_TOL_PX, Math.round(Math.min(w, h) * 0.005));
|
|
834
|
+
const modes = { left: 0, center: 0, right: 0 };
|
|
835
|
+
const boxRight = box.x + box.w - 1;
|
|
836
|
+
const boxCenter = box.x + (box.w - 1) / 2;
|
|
837
|
+
for (const band of bands) {
|
|
838
|
+
const hits = [];
|
|
839
|
+
if (Math.abs(band.left - box.x) <= tol) hits.push("left");
|
|
840
|
+
if (Math.abs(band.right - boxRight) <= tol) hits.push("right");
|
|
841
|
+
if (Math.abs(band.center - boxCenter) <= tol) hits.push("center");
|
|
842
|
+
// 命中 0 或 ≥2 种(例如满宽行)都不算对齐证据
|
|
843
|
+
if (hits.length === 1) modes[hits[0]] += 1;
|
|
844
|
+
}
|
|
845
|
+
const bigModes = Object.entries(modes).filter(([, n]) => n >= ALIGN_MIN_BANDS);
|
|
846
|
+
if (bigModes.length >= 2) {
|
|
847
|
+
findings.push(
|
|
848
|
+
finding({
|
|
849
|
+
severity: "warn",
|
|
850
|
+
rule: "P5",
|
|
851
|
+
where: `align:${bigModes.map(([mode]) => mode).join("+")}`,
|
|
852
|
+
message: `对齐方式混乱:${bigModes.map(([mode, n]) => `${MODE_CN[mode]}对齐 ${n} 个内容带`).join("、")} 同时存在,视线要左右跳`,
|
|
853
|
+
fix: `整张图统一一种对齐(正文左对齐最稳),最多保留一种刻意的居中标题;别在同层混用左/右对齐`
|
|
854
|
+
})
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// P6 分辨率过低
|
|
860
|
+
const minSide = Math.min(w, h);
|
|
861
|
+
if (minSide < RES_WARN) {
|
|
862
|
+
findings.push(
|
|
863
|
+
finding({
|
|
864
|
+
severity: "warn",
|
|
865
|
+
rule: "P6",
|
|
866
|
+
where: `canvas:${w}x${h}`,
|
|
867
|
+
message: `分辨率 ${w}×${h} 偏小(短边 <${RES_WARN}px):位图放大后文字/1px 描边会糊`,
|
|
868
|
+
fix: `按最终展示尺寸的 1.5–2 倍导出(短边至少 ${RES_WARN}px,大屏建议 ≥1200px),用时再缩小`
|
|
869
|
+
})
|
|
870
|
+
);
|
|
871
|
+
} else if (minSide < RES_INFO) {
|
|
872
|
+
findings.push(
|
|
873
|
+
finding({
|
|
874
|
+
severity: "info",
|
|
875
|
+
rule: "P6",
|
|
876
|
+
where: `canvas:${w}x${h}`,
|
|
877
|
+
message: `分辨率 ${w}×${h} 一般(短边 <${RES_INFO}px):小屏够用,大屏/印刷会发虚`,
|
|
878
|
+
fix: `需要大屏或印刷就再导一份短边 ≥1200px 的版本(矢量源另存,别靠放大位图)`
|
|
879
|
+
})
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/* ---------- P8 卡片栅格一致性 ----------
|
|
884
|
+
* 既然能量出卡片矩形,就能查「栅格到底齐不齐」——这是卡片型 UI 最经典的一类缺陷:
|
|
885
|
+
* 同一行的卡片宽度差 3px、列间距一处 24 一处 32,肉眼说不出哪里不对,但整页就是“飘”。
|
|
886
|
+
* 实测(下载工具仪表板):4 列 KPI 宽 246/246/245/246、间距 24/24,直接判齐。
|
|
887
|
+
* 只在真的检测到栅格时才判(至少 2 行或 2 列),避免把非卡片布局误判。 */
|
|
888
|
+
const profile = transparent ? null : layoutProfile(img, bg);
|
|
889
|
+
if (!transparent) {
|
|
890
|
+
const cards = profile.cards;
|
|
891
|
+
const tol = 4; // 抗锯齿与圆角会让 1–2px 抖动,4px 才是有意义的不齐
|
|
892
|
+
if (cards.length >= 4) {
|
|
893
|
+
// 按行分组:y 相近的算同一行
|
|
894
|
+
const rowsOfCards = [];
|
|
895
|
+
for (const card of [...cards].sort((a, b) => a.y - b.y)) {
|
|
896
|
+
const row = rowsOfCards.find((r) => Math.abs(r.y - card.y) <= 8);
|
|
897
|
+
if (row === undefined) rowsOfCards.push({ y: card.y, items: [card] });
|
|
898
|
+
else row.items.push(card);
|
|
899
|
+
}
|
|
900
|
+
const issues = [];
|
|
901
|
+
for (const row of rowsOfCards) {
|
|
902
|
+
if (row.items.length < 2) continue;
|
|
903
|
+
const sorted = [...row.items].sort((a, b) => a.x - b.x);
|
|
904
|
+
const widths = sorted.map((c) => c.w);
|
|
905
|
+
// 只判「真栅格」:同一行 ≥3 张、且最大/最小宽度比 ≤2.5。
|
|
906
|
+
// 实测反例:在线文档参考图里主内容区(1081px)与右侧工具面板(478px)并排,
|
|
907
|
+
// 它们是不同性质的面板,本来就该不等宽——不设这条会误报“栅格不齐 差 603px”。
|
|
908
|
+
if (sorted.length < 3 || Math.max(...widths) / Math.max(1, Math.min(...widths)) > 2.5) continue;
|
|
909
|
+
const gaps = [];
|
|
910
|
+
for (let i = 1; i < sorted.length; i += 1) gaps.push(sorted[i].x - (sorted[i - 1].x + sorted[i - 1].w));
|
|
911
|
+
if (Math.max(...widths) - Math.min(...widths) > tol) {
|
|
912
|
+
issues.push(`y≈${Math.round(row.y)} 这行卡片宽 ${widths.join("/")} 差 ${Math.max(...widths) - Math.min(...widths)}px`);
|
|
913
|
+
}
|
|
914
|
+
if (gaps.length >= 2 && Math.max(...gaps) - Math.min(...gaps) > tol) {
|
|
915
|
+
issues.push(`y≈${Math.round(row.y)} 这行列间距 ${gaps.join("/")} 不一致`);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (issues.length > 0) {
|
|
919
|
+
findings.push(
|
|
920
|
+
finding({
|
|
921
|
+
severity: "warn",
|
|
922
|
+
rule: "P8",
|
|
923
|
+
message: `卡片栅格不齐:${issues.slice(0, 3).join(";")}`,
|
|
924
|
+
fix: `同一行的卡片宽度与列间距应完全一致(用栅格系统或 flex:1 + 统一 gap,不要逐个手调宽度;差 >${tol}px 人眼就能看出“飘”)`
|
|
925
|
+
})
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/* ---------- P9 列表/表格行高一致性 ----------
|
|
932
|
+
* 表格与列表是办公类界面最密集的部分,而「某一行比其它行高/矮」是这类界面
|
|
933
|
+
* 最典型、也最难靠眼睛发现的缺陷(尤其行数多的时候)。
|
|
934
|
+
* 判据来自 profile.list:以中位行距为基准,±15% 以内算同一档。
|
|
935
|
+
* 实测校准:在线文档参考表 10 行 100% 一致(不报)、视频站动态 17 行 94.1%(不报)、
|
|
936
|
+
* 故意做歪的合成表 10 行 90%(报,含一个双倍高行)。
|
|
937
|
+
* 阈值取 <93:92–95% 这一带在真实界面里常是“某一行文字换行了”,属可接受范围。
|
|
938
|
+
* 样本 <6 时行距本身没有统计意义,直接跳过。
|
|
939
|
+
*/
|
|
940
|
+
if (!transparent) {
|
|
941
|
+
const listInfo = profile.list;
|
|
942
|
+
if (listInfo !== null && listInfo !== undefined && listInfo.count >= 6 && listInfo.singleLine === true && listInfo.uniformPct < 93) {
|
|
943
|
+
findings.push(
|
|
944
|
+
finding({
|
|
945
|
+
severity: "warn",
|
|
946
|
+
rule: "P9",
|
|
947
|
+
where: `list:${listInfo.count}行`,
|
|
948
|
+
message:
|
|
949
|
+
`列表行高不一致:${listInfo.count} 行里 ${listInfo.outlierCount} 行偏离中位 ${listInfo.pitch}px(一致性 ${listInfo.uniformPct}%)` +
|
|
950
|
+
(listInfo.outliers.length === 0 ? "" : `,异常间距 ${listInfo.outliers.join("/")}px`),
|
|
951
|
+
fix: `表格/列表的行高应该是同一档:给行设固定 height 或统一的 padding,文字超长用省略号而不是自动换行撑高——换行的那一行会把整张表的节奏打乱`
|
|
952
|
+
})
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/* 关于「区块间距挤压」为什么没做成规则(记录下来,避免重复尝试):
|
|
958
|
+
* 真实缺陷:参照改造时用 `margin-top:-12px` 把表格硬拉上去,导致 tabs 下划线与表头贴死。
|
|
959
|
+
* 试过在像素上判「块间间距 < 块内间距的一半」,结果**全线误报**:
|
|
960
|
+
* 视频站 gap 1px/中位 7px、下载工具 gap 3px/中位 27px、连修好的版本也报——
|
|
961
|
+
* 因为像素层面**无法可靠区分「段落内的行距」与「区块之间的间距」**,
|
|
962
|
+
* 两者都是“文字行之间的空白”,要区分必须先做块分组,而块分组在密集界面里不可靠。
|
|
963
|
+
* 结论:这条改由 CSS 侧的 S14(负外边距)抓根因。宁可不报,也不要报错的。
|
|
964
|
+
* (同样的取舍记录也见「应用外壳检测」:两种像素判据都不可靠,最后改为静态提示。)
|
|
965
|
+
*/
|
|
966
|
+
|
|
967
|
+
// P7 能力边界(固定 info,文案固定)
|
|
968
|
+
findings.push(
|
|
969
|
+
finding({
|
|
970
|
+
severity: "info",
|
|
971
|
+
rule: "P7",
|
|
972
|
+
message: "本模式不做文字/对比度检测(无 OCR),排版文字问题请用 action=ui / action=svg",
|
|
973
|
+
fix: "文字可读性/字号/对比度:action=ui 看 HTML/CSS 源码,action=svg 看成品矢量文字几何"
|
|
974
|
+
})
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
/* ---------- 5. 统一算分(common.js) ---------- */
|
|
978
|
+
return finalize({
|
|
979
|
+
mode: "image",
|
|
980
|
+
target: name,
|
|
981
|
+
metrics: {
|
|
982
|
+
canvas: [w, h],
|
|
983
|
+
contentBox: box,
|
|
984
|
+
marginsPct,
|
|
985
|
+
coveragePct,
|
|
986
|
+
balance,
|
|
987
|
+
segments: { cols: colSegs.length, rows: rowSegs.length },
|
|
988
|
+
contentPixels: count,
|
|
989
|
+
weightBalance,
|
|
990
|
+
background: transparent ? "transparent" : toHex(bg),
|
|
991
|
+
// 覆盖度声明:clean 但 rulesRun=0 时,调用方要能看出「什么都没测」,而不是「测了且过了」。
|
|
992
|
+
rulesRun: 7 - (fullBleed ? 2 : 0) + ((profile?.cards?.length ?? 0) >= 4 ? 1 : 0) + ((profile?.list?.count ?? 0) >= 6 ? 1 : 0),
|
|
993
|
+
measured: [
|
|
994
|
+
"边距/出血",
|
|
995
|
+
"密度",
|
|
996
|
+
"视觉平衡",
|
|
997
|
+
"对齐",
|
|
998
|
+
"分辨率",
|
|
999
|
+
...((profile?.cards?.length ?? 0) >= 4 ? [`卡片栅格×${profile.cards.length}`] : []),
|
|
1000
|
+
...((profile?.list?.count ?? 0) >= 6 ? [`列表行高×${profile.list.count}`] : [])
|
|
1001
|
+
],
|
|
1002
|
+
// 满血版面:边缘贴边是应用窗口的正常形态,P1/P2 已跳过。
|
|
1003
|
+
// 告诉调用方「为什么没报贴边」,避免以为检查漏了。
|
|
1004
|
+
...(fullBleed ? { fullBleed: true, skippedRules: ["P1", "P2"] } : {}),
|
|
1005
|
+
// 版式剖面:参照改造时用来“量原图”,不进评分、不产生 findings。
|
|
1006
|
+
...(profile ? { profile } : {})
|
|
1007
|
+
},
|
|
1008
|
+
findings
|
|
1009
|
+
});
|
|
1010
|
+
}
|