android2harmony 0.1.3 → 0.1.5
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/README.md +1 -407
- package/agents/self-tester.md +376 -339
- package/dist/index.js +131 -586
- package/dist/index.js.map +4 -4
- package/package.json +36 -37
- package/skills/a2h-resource-convert/SKILL.md +902 -0
- package/skills/a2h-resource-convert/references/code-vector-icon-rules.md +335 -0
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/conversion-rules.md +15 -2
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/dependency-analysis-rules.md +24 -2
- package/skills/a2h-resource-convert/references/lottie-conversion-rules.md +219 -0
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/resource-mapping-rules.md +27 -2
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/xml-drawable-to-svg-rules.md +118 -1
- package/skills/a2h-resource-convert/scripts/a2h_resource_convert.js +2166 -0
- package/skills/a2h-resource-convert/scripts/code_vector_icons.js +607 -0
- package/skills/a2h-resource-convert/scripts/package.json +3 -0
- package/skills/a2h-resource-convert/scripts/svg_fidelity_check.js +632 -0
- package/skills/a2h-ui-transfer/SKILL.md +420 -0
- package/skills/a2h-ui-transfer/references/conversion-procedure.md +572 -0
- package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/_/350/214/203/345/274/217/351/200/211/346/213/251/350/257/264/346/230/216.md +1 -1
- package/skills/a2h-ui-transfer/references/mvvm-v2/_/350/243/205/351/245/260/345/231/250/351/200/237/346/237/245.md +133 -0
- package/skills/{hmos-batch-ui-align/scripts/android_parse_fast.ts → a2h-ui-transfer/scripts/android_parse_fast.js} +397 -283
- package/skills/a2h-ui-transfer/scripts/arkts_static_check.js +1624 -0
- package/skills/a2h-ui-transfer/scripts/measure_pack.js +1005 -0
- package/skills/a2h-ui-transfer/scripts/package.json +3 -0
- package/skills/hmos-incremental-ui-align/README.md +251 -251
- package/skills/hmos-incremental-ui-align/SKILL.md +364 -364
- package/skills/hmos-incremental-ui-align/references/State_Model_Template.md +2 -2
- package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.js +790 -0
- package/skills/hmos-incremental-ui-align/scripts/extract_checklist.js +285 -0
- package/skills/hmos-incremental-ui-align/scripts/navigation-capure.md +76 -76
- package/skills/hmos-incremental-ui-align/scripts/page_capture.js +756 -0
- package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.js +155 -0
- package/skills/hmos-batch-ui-align/SKILL.md +0 -141
- package/skills/hmos-batch-ui-align/references/conversion-procedure.md +0 -217
- package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.ts +0 -999
- package/skills/hmos-incremental-ui-align/scripts/extract_checklist.ts +0 -343
- package/skills/hmos-incremental-ui-align/scripts/page_capture.ts +0 -977
- package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.ts +0 -188
- package/skills/hmos-resources-convert/SKILL.md +0 -654
- package/skills/hmos-resources-convert/template/AppScope/app.json5 +0 -10
- package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +0 -8
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +0 -7
- package/skills/hmos-resources-convert/template/build-profile.json5 +0 -42
- package/skills/hmos-resources-convert/template/code-linter.json5 +0 -32
- package/skills/hmos-resources-convert/template/entry/build-profile.json5 +0 -33
- package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +0 -6
- package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +0 -23
- package/skills/hmos-resources-convert/template/entry/oh-package.json5 +0 -10
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +0 -48
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +0 -16
- package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +0 -23
- package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +0 -55
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +0 -16
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +0 -7
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +0 -3
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +0 -5
- package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +0 -2
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +0 -35
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +0 -5
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +0 -16
- package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +0 -5
- package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +0 -33
- package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +0 -23
- package/skills/hmos-resources-convert/template/hvigorfile.ts +0 -6
- package/skills/hmos-resources-convert/template/oh-package-lock.json5 +0 -28
- package/skills/hmos-resources-convert/template/oh-package.json5 +0 -10
- package/tools/autotest/deps/autotest-agent-0.1.1.tgz +0 -0
- package/tools/autotest/engine/batch-launcher.ts +0 -326
- package/tools/autotest/engine/report-tool.ts +0 -773
- package/tools/autotest/engine/self-test-runner.ts +0 -1024
- package/tools/autotest/engine/testcases-tool.ts +0 -246
- package/tools/autotest/resolve-metadata-tool.ts +0 -143
- package/tools/autotest/runner/logger.ts +0 -45
- package/tools/autotest/runner/process-utils.ts +0 -41
- package/tools/autotest/validate.ts +0 -115
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/!!/350/257/255/346/263/225/357/274/232/345/217/214/345/220/221/347/273/221/345/256/232.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Computed/350/243/205/351/245/260/345/231/250/357/274/232/350/256/241/347/256/227/345/261/236/346/200/247.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Event/350/243/205/351/245/260/345/231/250/357/274/232/350/247/204/350/214/203/347/273/204/344/273/266/350/276/223/345/207/272.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Local/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/351/203/250/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Monitor/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/344/277/256/346/224/271/345/274/202/346/255/245/347/233/221/345/220/254.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@ObservedV2/350/243/205/351/245/260/345/231/250/345/222/214@Trace/350/243/205/351/245/260/345/231/250/357/274/232/347/261/273/345/261/236/346/200/247/345/217/230/345/214/226/350/247/202/346/265/213.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Once/350/243/205/351/245/260/345/231/250/357/274/232/345/210/235/345/247/213/345/214/226/345/220/214/346/255/245/344/270/200/346/254/241.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Param/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/244/226/351/203/250/350/276/223/345/205/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Provider/350/243/205/351/245/260/345/231/250/345/222/214@Consumer/350/243/205/351/245/260/345/231/250/357/274/232/350/267/250/347/273/204/344/273/266/345/261/202/347/272/247/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Type/350/243/205/351/245/260/345/231/250/357/274/232/346/240/207/350/256/260/347/261/273/345/261/236/346/200/247/347/232/204/347/261/273/345/236/213.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/AppStorageV2/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/MVVM/346/250/241/345/274/217/357/274/210V2/357/274/211.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/PersistenceV2/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2//347/212/266/346/200/201/347/256/241/347/220/206V1/345/220/221V2/350/277/201/347/247/273/344/270/216/346/267/267/347/224/250/346/214/207/345/257/274.md" +0 -0
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* measure_pack.js —— 把「每页都要重算一遍」的参考量测,前移到父流程一次算完。
|
|
3
|
+
*
|
|
4
|
+
* 用法(纯 JavaScript ESM,无需编译、无外部依赖):
|
|
5
|
+
* node measure_pack.js --ui-info <page_NNNN_X 目录> [--out <file.json>] [--quiet] [--density 2.625]
|
|
6
|
+
*
|
|
7
|
+
* --density 显式给出采集设备密度,优先级最高。通常不需要:android_parse_fast.js 已把
|
|
8
|
+
* `wm density` 的真值写进每页 meta.json 的 device 字段。两者都没有时才退回节点反解。
|
|
9
|
+
*
|
|
10
|
+
* ── 为什么存在 ────────────────────────────────────────────────────────────
|
|
11
|
+
* 实测一次单页转换(96 次工具调用 / 61.4 min):**工具真实执行合计 17.9 秒,占 0.5%**,
|
|
12
|
+
* 而「拿到工具结果之后的模型思考+生成」占 71.3%(96 轮 × 均值 27.4s)。
|
|
13
|
+
* 也就是说成本 ≈ 轮次数 × 单轮延迟,压轮次是唯一有意义的加速方向。
|
|
14
|
+
*
|
|
15
|
+
* 那次运行里子 agent 为了测密度、ink bbox、取色,跑了 11 轮 PIL(约 5 min)。
|
|
16
|
+
* 这些量测有两个特点:(a) 同一采集设备上**每页同值**,(b) 判据纯算术、可复算。
|
|
17
|
+
* 于是它们属于「父侧一次算完、批内摊薄」的活,不该每页各花若干轮重算。
|
|
18
|
+
*
|
|
19
|
+
* ── 职责边界(重要) ──────────────────────────────────────────────────────
|
|
20
|
+
* 本脚本**只做量测与算术,不做判断**。它不推断沉浸式、不推断锚定方向、不给图标分级 ——
|
|
21
|
+
* 那些需要回读 Android 源码与约束声明,是 Phase 4/5 的事。
|
|
22
|
+
* 每个输出值都带 `source` 字段标注来源,便于子 agent 引用与父侧复核。
|
|
23
|
+
*
|
|
24
|
+
* 参考:conversion-procedure.md Phase 4.1「LAYOUT_EVIDENCE」与 Phase 4.2「坐标与窗口模式规则」。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fs from 'fs';
|
|
28
|
+
import * as path from 'path';
|
|
29
|
+
// 静态 import,不能用 require:本文件是 ESM(同目录 package.json 声明 type=module),require 未定义。
|
|
30
|
+
// 用 require 会让 PNG 解码整个失败,而失败点在 try/catch 里 —— 症状是密度算得出、
|
|
31
|
+
// 而 image/edge_colors/ink 全为空,极易被当成「截图有问题」。
|
|
32
|
+
import * as zlib from 'zlib';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------- 类型
|
|
35
|
+
|
|
36
|
+
// interface Node {
|
|
37
|
+
// idx: number;
|
|
38
|
+
// cls: string;
|
|
39
|
+
// text: string;
|
|
40
|
+
// rid: string;
|
|
41
|
+
// desc: string;
|
|
42
|
+
// clickable: boolean;
|
|
43
|
+
// scrollable: boolean;
|
|
44
|
+
// enabled: boolean;
|
|
45
|
+
// checkable: boolean;
|
|
46
|
+
// checked: boolean;
|
|
47
|
+
// x1: number;
|
|
48
|
+
// y1: number;
|
|
49
|
+
// x2: number;
|
|
50
|
+
// y2: number;
|
|
51
|
+
// w: number;
|
|
52
|
+
// h: number;
|
|
53
|
+
// }
|
|
54
|
+
|
|
55
|
+
// interface Png {
|
|
56
|
+
// width: number;
|
|
57
|
+
// height: number;
|
|
58
|
+
// /** RGBA,每像素 4 字节 */
|
|
59
|
+
// px: Uint8Array;
|
|
60
|
+
// }
|
|
61
|
+
|
|
62
|
+
// interface InkBox {
|
|
63
|
+
// x1: number;
|
|
64
|
+
// y1: number;
|
|
65
|
+
// x2: number;
|
|
66
|
+
// y2: number;
|
|
67
|
+
// w: number;
|
|
68
|
+
// h: number;
|
|
69
|
+
// cx: number;
|
|
70
|
+
// cy: number;
|
|
71
|
+
// /**
|
|
72
|
+
// * 背景不均匀(渐变/图片底)时置位。此时本 ink bbox **不可信** ——
|
|
73
|
+
// * 「与背景有差异」的判据会把渐变本身也算成墨迹,量出来的往往就是整个区域。
|
|
74
|
+
// */
|
|
75
|
+
// unreliable?: boolean;
|
|
76
|
+
// reason?: string;
|
|
77
|
+
// /** 四角在最差通道上的色值跨度。0 表示纯色底,越大越说明底是渐变/图片。 */
|
|
78
|
+
// bgSpread?: number;
|
|
79
|
+
// }
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- view.xml
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 解析 uiautomator dump。用正则而非 XML 解析器:该格式是扁平的 <node .../> 与嵌套
|
|
85
|
+
* <node ...> 混用,属性集固定,正则足够且不引依赖。属性顺序不可假定,逐个按名取。
|
|
86
|
+
*/
|
|
87
|
+
function parseNodes(xml) {
|
|
88
|
+
const out = [];
|
|
89
|
+
const re = /<node ([^>]*?)\/?>/g;
|
|
90
|
+
let m;
|
|
91
|
+
let i = 0;
|
|
92
|
+
while ((m = re.exec(xml)) !== null) {
|
|
93
|
+
const a = m[1];
|
|
94
|
+
const get = (k) => {
|
|
95
|
+
const r = new RegExp(k + '="([^"]*)"').exec(a);
|
|
96
|
+
return r ? r[1] : '';
|
|
97
|
+
};
|
|
98
|
+
const b = /bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(a);
|
|
99
|
+
if (!b) {
|
|
100
|
+
i++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const x1 = parseInt(b[1], 10);
|
|
104
|
+
const y1 = parseInt(b[2], 10);
|
|
105
|
+
const x2 = parseInt(b[3], 10);
|
|
106
|
+
const y2 = parseInt(b[4], 10);
|
|
107
|
+
out.push({
|
|
108
|
+
idx: i++,
|
|
109
|
+
cls: get('class'),
|
|
110
|
+
text: get('text'),
|
|
111
|
+
rid: get('resource-id'),
|
|
112
|
+
desc: get('content-desc'),
|
|
113
|
+
clickable: get('clickable') === 'true',
|
|
114
|
+
scrollable: get('scrollable') === 'true',
|
|
115
|
+
enabled: get('enabled') === 'true',
|
|
116
|
+
checkable: get('checkable') === 'true',
|
|
117
|
+
checked: get('checked') === 'true',
|
|
118
|
+
x1, y1, x2, y2,
|
|
119
|
+
w: x2 - x1,
|
|
120
|
+
h: y2 - y1
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------- 密度反解
|
|
127
|
+
|
|
128
|
+
// interface DensityEvidence {
|
|
129
|
+
// node: number;
|
|
130
|
+
// sidePx: number;
|
|
131
|
+
// dp: number;
|
|
132
|
+
// density: number;
|
|
133
|
+
// }
|
|
134
|
+
|
|
135
|
+
// interface DensityResult {
|
|
136
|
+
// density: number | null;
|
|
137
|
+
// /** 'device'=meta.json 里采集期记录的真值 / 'cli'=--density 显式传入 / 'inferred'=由节点边长反解 */
|
|
138
|
+
// source: 'device' | 'cli' | 'inferred';
|
|
139
|
+
// /** 反解是否可信:命中 >= 2 个不同 dp 档,且对次优候选有明确优势。真值来源恒为 true。 */
|
|
140
|
+
// confident: boolean;
|
|
141
|
+
// /** 次优候选(通常是谐波混叠的那一档)。null=无竞争者。 */
|
|
142
|
+
// rival: { density: number; hits: number } | null;
|
|
143
|
+
// /** 真值与反解冲突、或闸门被放开时的告警;正常为 null。 */
|
|
144
|
+
// crosscheck: string | null;
|
|
145
|
+
// /** 反解为 null 或低置信时,说明**为什么** —— 否则调用方只看到一个数字/空值,无从处置。 */
|
|
146
|
+
// reason: string | null;
|
|
147
|
+
// evidence: DensityEvidence[];
|
|
148
|
+
// }
|
|
149
|
+
|
|
150
|
+
/** 标准密度档。含 1.75(280dpi)、3.2 —— 真机上这两档存在,漏掉会给出自信的错值。 */
|
|
151
|
+
const STD_DENSITIES = [1, 1.5, 1.75, 2, 2.25, 2.5, 2.625, 2.75, 3, 3.2, 3.5, 4];
|
|
152
|
+
/** Material/AndroidX 的固有尺寸档(dp)。 */
|
|
153
|
+
const COMMON_DP = [16, 20, 24, 28, 32, 36, 40, 44, 48, 56, 64, 72, 80, 96];
|
|
154
|
+
|
|
155
|
+
// interface DensityScore {
|
|
156
|
+
// d: number;
|
|
157
|
+
// hits: number;
|
|
158
|
+
// residual: number;
|
|
159
|
+
// dpKinds: number;
|
|
160
|
+
// std: boolean;
|
|
161
|
+
// evidence: DensityEvidence[];
|
|
162
|
+
// }
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 给每个候选密度打分。`sides` 已按**不同边长**去重(见 deriveDensity)。
|
|
166
|
+
*
|
|
167
|
+
* 排序判据:命中数 → 残差 → 标准档 → **大密度优先**。
|
|
168
|
+
* 最后一档不可反向:小密度总能用更大的 dp 把同一批 px 混叠解释掉(px=72 → 72dp@1.0),
|
|
169
|
+
* 平局倒向小密度正是旧实现把 3.0 判成 1.0、2.0 判成 1.0 的直接原因。
|
|
170
|
+
*/
|
|
171
|
+
function scoreDensities(
|
|
172
|
+
sides,
|
|
173
|
+
shortPx,
|
|
174
|
+
longPx,
|
|
175
|
+
useGate
|
|
176
|
+
) {
|
|
177
|
+
const cands = new Set(STD_DENSITIES);
|
|
178
|
+
// 候选表不能写死:把「由数据本身反推出来的档」也加进来,才能覆盖非标准档与
|
|
179
|
+
// `wm density` 改过的设备。落表外时旧实现不报"测不出",而是给出一个自信的错值。
|
|
180
|
+
for (const s of sides) {
|
|
181
|
+
for (const dp of COMMON_DP) {
|
|
182
|
+
const d = Math.round((s.px / dp) * 1000) / 1000;
|
|
183
|
+
if (d >= 0.9 && d <= 4.5) { cands.add(d); }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const d of [...cands].sort((a, b) => a - b)) {
|
|
188
|
+
// 屏幕尺寸合理性闸门 —— 节点尺寸只能把密度定到一个**谐波族**内(px=72 被
|
|
189
|
+
// 24dp@3.0 / 36dp@2.0 / 48dp@1.5 / 72dp@1.0 同等解释,纯算术无法区分),
|
|
190
|
+
// 唯一的独立信息是屏幕换算成 dp 后必须物理存在。
|
|
191
|
+
//
|
|
192
|
+
// **两条边都要卡**:只卡短边不够。实测 1080x2274 的 420dpi 机器上,谐波候选
|
|
193
|
+
// 1.323 的短边是 816dp(像 10 寸平板,短边闸门放过),但长边 1719dp —— 现实中
|
|
194
|
+
// 最大的平板长边约 1280dp,不存在 1700dp 的竖屏设备。长边闸门一加即被排除。
|
|
195
|
+
if (useGate && shortPx > 0 && longPx > 0) {
|
|
196
|
+
const sDp = shortPx / d;
|
|
197
|
+
const lDp = longPx / d;
|
|
198
|
+
if (sDp < 300 || sDp > 840) { continue; } // 手机 320-480,平板竖屏宽到 ~840
|
|
199
|
+
if (lDp < 480 || lDp > 1400) { continue; } // 最大真机长边 ~1280,留余量
|
|
200
|
+
}
|
|
201
|
+
let residual = 0;
|
|
202
|
+
const dpKinds = new Set();
|
|
203
|
+
const evidence = [];
|
|
204
|
+
for (const s of sides) {
|
|
205
|
+
let bestR = Infinity;
|
|
206
|
+
let bestDp = 0;
|
|
207
|
+
for (const dp of COMMON_DP) {
|
|
208
|
+
// 在 **px 空间**比,容忍 ±1px:Android 是 px = round(dp × density),反解回来的
|
|
209
|
+
// dp 误差可达 0.5/d(≈0.19dp @2.625)。在 dp 空间用固定小容差(旧值 0.02)会把
|
|
210
|
+
// 非整数密度的真命中大量误杀,而整数密度永不受损 —— 系统性偏向 1/2/3/4。
|
|
211
|
+
const r = Math.abs(s.px - Math.round(dp * d));
|
|
212
|
+
if (r < bestR) { bestR = r; bestDp = dp; }
|
|
213
|
+
}
|
|
214
|
+
if (bestR <= 1) {
|
|
215
|
+
residual += bestR;
|
|
216
|
+
dpKinds.add(bestDp);
|
|
217
|
+
evidence.push({ node: s.idx, sidePx: s.px, dp: bestDp, density: d });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (evidence.length > 0) {
|
|
221
|
+
out.push({
|
|
222
|
+
d, hits: evidence.length, residual, dpKinds: dpKinds.size,
|
|
223
|
+
std: STD_DENSITIES.includes(d), evidence
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// 把相对距离 <= 1.5% 的候选并成一簇 —— 它们描述的是**同一个物理密度**,差异只是
|
|
228
|
+
// round() 噪声。簇代表优先取标准档:实测 420dpi 机器上,由 127px/48dp 反推出的
|
|
229
|
+
// 2.646 与标准档 2.625 同簇,而 2.646 在自己生成的那个 px 上残差为 0,不并簇就会
|
|
230
|
+
// 让一个 3 位小数的伪档赢过真值。簇的分数取簇内最好值(最多命中、最小残差)。
|
|
231
|
+
out.sort((a, b) => a.d - b.d);
|
|
232
|
+
const clusters = [];
|
|
233
|
+
let group = [];
|
|
234
|
+
const flush = () => {
|
|
235
|
+
if (group.length === 0) { return; }
|
|
236
|
+
const rep = group.find((g) => g.std) ??
|
|
237
|
+
[...group].sort((a, b) => b.hits - a.hits || a.residual - b.residual || b.d - a.d)[0];
|
|
238
|
+
const bestHits = [...group].sort((a, b) => b.hits - a.hits)[0];
|
|
239
|
+
clusters.push({
|
|
240
|
+
d: rep.d,
|
|
241
|
+
hits: bestHits.hits,
|
|
242
|
+
residual: Math.min(...group.map((g) => g.residual)),
|
|
243
|
+
dpKinds: Math.max(...group.map((g) => g.dpKinds)),
|
|
244
|
+
std: group.some((g) => g.std),
|
|
245
|
+
evidence: rep.hits >= bestHits.hits ? rep.evidence : bestHits.evidence
|
|
246
|
+
});
|
|
247
|
+
group = [];
|
|
248
|
+
};
|
|
249
|
+
for (const c of out) {
|
|
250
|
+
if (group.length > 0 && (c.d - group[group.length - 1].d) / group[group.length - 1].d > 0.015) {
|
|
251
|
+
flush();
|
|
252
|
+
}
|
|
253
|
+
group.push(c);
|
|
254
|
+
}
|
|
255
|
+
flush();
|
|
256
|
+
// 命中数 → **标准档** → 残差 → 大密度优先。
|
|
257
|
+
// 标准档必须排在残差之前:由数据反推出的候选在生成它的那个 px 上残差恒为 0,
|
|
258
|
+
// 若先比残差,伪档与其 1/2 谐波会一起压过真值(实测 2.625 被 2.646 / 1.323 击败)。
|
|
259
|
+
// 大密度优先也不可反向:小密度总能用更大的 dp 混叠解释同一批 px(px=72 → 72dp@1.0)。
|
|
260
|
+
clusters.sort((a, b) =>
|
|
261
|
+
b.hits - a.hits ||
|
|
262
|
+
(b.std ? 1 : 0) - (a.std ? 1 : 0) ||
|
|
263
|
+
a.residual - b.residual ||
|
|
264
|
+
b.d - a.d);
|
|
265
|
+
return clusters;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 定出屏幕密度:**有真值就用真值**,没有才由 bounds 反解。
|
|
270
|
+
*
|
|
271
|
+
* 真值(`meta.json` 的 `device.density`,由 android_parse_fast.js 采集期用 `wm density` 记录;
|
|
272
|
+
* 或 `--density` 显式传入)优先,因为反解在数学上不唯一 —— 一旦解错,该页所有 vp 整体差
|
|
273
|
+
* 2~3 倍,而编译、引用检查、跨页一致性全都照样通过(每页错得一样),是最难归因的一类缺陷。
|
|
274
|
+
*
|
|
275
|
+
* 反解只看**正方形**节点:矩形的宽高多由约束求解得出,不是声明值。四条硬规则见
|
|
276
|
+
* scoreDensities 的注释(按不同边长计分 / px 空间 ±1px / 屏宽闸门 / 候选表不写死)。
|
|
277
|
+
*
|
|
278
|
+
* 输出 evidence + confident + rival,而非单一数字 —— Phase 5 要求每个尺寸可溯源,
|
|
279
|
+
* 且"5 路平局"与"12:2 压倒性胜出"必须在输出里长得不一样。
|
|
280
|
+
*/
|
|
281
|
+
function deriveDensity(
|
|
282
|
+
nodes,
|
|
283
|
+
known,
|
|
284
|
+
screen
|
|
285
|
+
) {
|
|
286
|
+
// 按**不同边长**去重:工具栏上 10 个同尺寸图标不提供 10 份证据,只提供 1 份。
|
|
287
|
+
// 按节点个数计分会让所有混叠候选一起刷到 10 分,平局锁死后倒向最小密度。
|
|
288
|
+
// 容忍 1px 的非方误差:round() + 约束求解会让声明为正方形的视图偶尔出现 63x64。
|
|
289
|
+
const seen = new Set();
|
|
290
|
+
const sides = [];
|
|
291
|
+
for (const n of nodes) {
|
|
292
|
+
if (n.w <= 0 || Math.abs(n.w - n.h) > 1) { continue; }
|
|
293
|
+
const px = Math.max(n.w, n.h);
|
|
294
|
+
if (seen.has(px)) { continue; }
|
|
295
|
+
seen.add(px);
|
|
296
|
+
sides.push({ px, idx: n.idx });
|
|
297
|
+
}
|
|
298
|
+
// 屏幕尺寸优先取 meta.json 的 device(采集期 `wm size` 真值);没有才用节点包围盒 ——
|
|
299
|
+
// uiautomator 只 dump 获焦窗口,对话框页面的包围盒**不是整屏**,闸门会因此判错。
|
|
300
|
+
const boundsW = nodes.length > 0 ? Math.max(...nodes.map((n) => n.x2)) : 0;
|
|
301
|
+
const boundsH = nodes.length > 0 ? Math.max(...nodes.map((n) => n.y2)) : 0;
|
|
302
|
+
const scrW = screen ? screen.widthPx : boundsW;
|
|
303
|
+
const scrH = screen ? screen.heightPx : boundsH;
|
|
304
|
+
const shortPx = Math.min(scrW, scrH);
|
|
305
|
+
const longPx = Math.max(scrW, scrH);
|
|
306
|
+
|
|
307
|
+
// 没有正方形节点时反解无从下手 —— 但**真值仍然可用**,不能因为无法回代验证就把它丢掉。
|
|
308
|
+
// (这一顺序踩过一次:早退分支写在 known 之前,导致 --density 在这些页面上被静默忽略。)
|
|
309
|
+
if (sides.length === 0) {
|
|
310
|
+
if (known) {
|
|
311
|
+
return {
|
|
312
|
+
density: known.value, source: known.source, confident: true,
|
|
313
|
+
rival: null, crosscheck: null, evidence: [],
|
|
314
|
+
reason: `view tree 里没有正方形节点(共 ${nodes.length} 个),真值无法回代验证 —— ` +
|
|
315
|
+
'数值本身可信,但本页没有可引用的密度证据行'
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
density: null, source: 'inferred', confident: false, rival: null, crosscheck: null,
|
|
320
|
+
evidence: [],
|
|
321
|
+
reason: `view tree 里没有正方形节点(共 ${nodes.length} 个节点),无法反解密度 —— ` +
|
|
322
|
+
'请用 `--density <值>` 显式给出(`adb shell wm density` 可查),' +
|
|
323
|
+
'或重跑采集以把 device.density 写进 meta.json。' +
|
|
324
|
+
'密度缺失时本包所有 vp 字段均为 null,下游尺寸门禁会整页静默跳过。'
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let scored = scoreDensities(sides, shortPx, longPx, true);
|
|
329
|
+
let gateRelaxed = false;
|
|
330
|
+
if (scored.length === 0) {
|
|
331
|
+
// 对话框/分屏/画中画的 dump 里包围盒不是整屏,闸门可能把所有候选滤光 —— 放开重试并降置信度
|
|
332
|
+
scored = scoreDensities(sides, shortPx, longPx, false);
|
|
333
|
+
gateRelaxed = true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const top = scored[0] ?? null;
|
|
337
|
+
const rival = top
|
|
338
|
+
? (scored.find((s) => Math.abs(s.d - top.d) / top.d > 0.02) ?? null)
|
|
339
|
+
: null;
|
|
340
|
+
const rivalOut =
|
|
341
|
+
rival ? { density: rival.d, hits: rival.hits } : null;
|
|
342
|
+
// 兑现"两条独立命中才足以支撑一个密度结论":必须命中两个**不同 dp 档**,
|
|
343
|
+
// 且对最近的非同族候选有明确优势(命中更多,或残差更小)。
|
|
344
|
+
const inferredConfident = !!top && !gateRelaxed && top.dpKinds >= 2 &&
|
|
345
|
+
(!rival || rival.hits < top.hits || rival.residual > top.residual);
|
|
346
|
+
|
|
347
|
+
if (known) {
|
|
348
|
+
const match =
|
|
349
|
+
scored.find((s) => Math.abs(s.d - known.value) / known.value <= 0.02);
|
|
350
|
+
let crosscheck = null;
|
|
351
|
+
if (top && inferredConfident && Math.abs(top.d - known.value) / known.value > 0.02) {
|
|
352
|
+
crosscheck = `真值 ${known.value}(${known.source})与节点反解 ${top.d}` +
|
|
353
|
+
`(${top.hits}/${sides.length} 命中,${top.dpKinds} 个 dp 档)不一致 —— ` +
|
|
354
|
+
'请确认 meta.json 与 view.xml 同源,且采集后未执行过 wm density';
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
density: known.value,
|
|
358
|
+
source: known.source,
|
|
359
|
+
confident: true,
|
|
360
|
+
rival: rivalOut,
|
|
361
|
+
crosscheck,
|
|
362
|
+
evidence: (match?.evidence ?? top?.evidence ?? []).slice(0, 8),
|
|
363
|
+
reason: match ? null : '真值未能被任何正方形节点回代验证(可能该页尺寸档不在 COMMON_DP 里),真值本身仍然可信'
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
let reason = null;
|
|
367
|
+
if (!top) {
|
|
368
|
+
reason = `${sides.length} 个不同边长都无法用常见 dp 档解释,无法反解密度 —— 请用 --density 显式给出`;
|
|
369
|
+
} else if (!inferredConfident) {
|
|
370
|
+
reason = top.dpKinds < 2
|
|
371
|
+
? `只命中 1 个 dp 档(${top.evidence.length} 个边长),不足以定档 —— ` +
|
|
372
|
+
'同一批 px 在整个谐波族里都自洽,请用 --density 或重采集以写入 device.density'
|
|
373
|
+
: rival
|
|
374
|
+
? `次优候选 ${rival.d} 与之势均力敌(命中 ${rival.hits} vs ${top.hits},残差 ${rival.residual} vs ${top.residual})`
|
|
375
|
+
: '未通过置信判据';
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
density: top ? top.d : null,
|
|
379
|
+
source: 'inferred',
|
|
380
|
+
confident: inferredConfident,
|
|
381
|
+
rival: rivalOut,
|
|
382
|
+
crosscheck: gateRelaxed
|
|
383
|
+
? '屏幕尺寸合理性闸门无候选通过(可能是对话框/分屏/画中画 dump),已放开闸门,结果仅供参考'
|
|
384
|
+
: null,
|
|
385
|
+
evidence: top ? top.evidence.slice(0, 8) : [],
|
|
386
|
+
reason
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------- PNG 解码
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 最小 PNG 解码器:zlib inflate + 逐行反滤波。仅支持 8bit 非隔行的
|
|
394
|
+
* 灰度/RGB/调色板/灰度+A/RGBA(colorType 0/2/3/4/6)。
|
|
395
|
+
*
|
|
396
|
+
* 不支持的变体**显式抛错并给出 PIL 回退命令**,绝不静默降级 ——
|
|
397
|
+
* 一个悄悄返回错像素的量测器,比没有量测器更危险。
|
|
398
|
+
*/
|
|
399
|
+
function decodePng(file) {
|
|
400
|
+
const buf = fs.readFileSync(file);
|
|
401
|
+
const SIG = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
402
|
+
for (let i = 0; i < 8; i++) {
|
|
403
|
+
if (buf[i] !== SIG[i]) {
|
|
404
|
+
throw new Error(`不是 PNG 文件: ${file}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
let off = 8;
|
|
408
|
+
let w = 0;
|
|
409
|
+
let h = 0;
|
|
410
|
+
let depth = 0;
|
|
411
|
+
let ctype = 0;
|
|
412
|
+
let interlace = 0;
|
|
413
|
+
const idat = [];
|
|
414
|
+
let plte = null;
|
|
415
|
+
let trns = null;
|
|
416
|
+
while (off < buf.length) {
|
|
417
|
+
const len = buf.readUInt32BE(off);
|
|
418
|
+
const type = buf.toString('ascii', off + 4, off + 8);
|
|
419
|
+
const data = buf.subarray(off + 8, off + 8 + len);
|
|
420
|
+
if (type === 'IHDR') {
|
|
421
|
+
w = data.readUInt32BE(0);
|
|
422
|
+
h = data.readUInt32BE(4);
|
|
423
|
+
depth = data[8];
|
|
424
|
+
ctype = data[9];
|
|
425
|
+
interlace = data[12];
|
|
426
|
+
} else if (type === 'IDAT') {
|
|
427
|
+
idat.push(data);
|
|
428
|
+
} else if (type === 'PLTE') {
|
|
429
|
+
plte = Buffer.from(data);
|
|
430
|
+
} else if (type === 'tRNS') {
|
|
431
|
+
trns = Buffer.from(data);
|
|
432
|
+
} else if (type === 'IEND') {
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
off += 12 + len;
|
|
436
|
+
}
|
|
437
|
+
if (depth !== 8 || interlace !== 0 || ![0, 2, 3, 4, 6].includes(ctype)) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`不支持的 PNG 变体 (bitDepth=${depth}, colorType=${ctype}, interlace=${interlace})。\n` +
|
|
440
|
+
'本解码器只支持 8bit 非隔行的 colorType 0/2/3/4/6。\n' +
|
|
441
|
+
'回退方案(需 Pillow):\n' +
|
|
442
|
+
` python -c "from PIL import Image; im=Image.open(r'${file}').convert('RGBA'); ` +
|
|
443
|
+
`im.save(r'${file}.rgba8.png')"\n` +
|
|
444
|
+
'然后对转换后的文件重跑本脚本。'
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
const CH = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
448
|
+
const ch = CH[ctype];
|
|
449
|
+
const raw = zlib.inflateSync(Buffer.concat(idat));
|
|
450
|
+
const stride = w * ch;
|
|
451
|
+
const px = new Uint8Array(w * h * 4);
|
|
452
|
+
const prev = new Uint8Array(stride);
|
|
453
|
+
const cur = new Uint8Array(stride);
|
|
454
|
+
let rp = 0;
|
|
455
|
+
for (let y = 0; y < h; y++) {
|
|
456
|
+
const filter = raw[rp++];
|
|
457
|
+
for (let i = 0; i < stride; i++) {
|
|
458
|
+
cur[i] = raw[rp + i];
|
|
459
|
+
}
|
|
460
|
+
rp += stride;
|
|
461
|
+
// 反滤波(PNG spec 9.2)
|
|
462
|
+
for (let i = 0; i < stride; i++) {
|
|
463
|
+
const a = i >= ch ? cur[i - ch] : 0;
|
|
464
|
+
const b = prev[i];
|
|
465
|
+
const c = i >= ch ? prev[i - ch] : 0;
|
|
466
|
+
let v = cur[i];
|
|
467
|
+
if (filter === 1) {
|
|
468
|
+
v = v + a;
|
|
469
|
+
} else if (filter === 2) {
|
|
470
|
+
v = v + b;
|
|
471
|
+
} else if (filter === 3) {
|
|
472
|
+
v = v + ((a + b) >> 1);
|
|
473
|
+
} else if (filter === 4) {
|
|
474
|
+
const p = a + b - c;
|
|
475
|
+
const pa = Math.abs(p - a);
|
|
476
|
+
const pb = Math.abs(p - b);
|
|
477
|
+
const pc = Math.abs(p - c);
|
|
478
|
+
v = v + (pa <= pb && pa <= pc ? a : (pb <= pc ? b : c));
|
|
479
|
+
}
|
|
480
|
+
cur[i] = v & 0xff;
|
|
481
|
+
}
|
|
482
|
+
// 转 RGBA
|
|
483
|
+
for (let x = 0; x < w; x++) {
|
|
484
|
+
const o = (y * w + x) * 4;
|
|
485
|
+
const s = x * ch;
|
|
486
|
+
if (ctype === 3) {
|
|
487
|
+
const pi = cur[s];
|
|
488
|
+
if (plte) {
|
|
489
|
+
px[o] = plte[pi * 3];
|
|
490
|
+
px[o + 1] = plte[pi * 3 + 1];
|
|
491
|
+
px[o + 2] = plte[pi * 3 + 2];
|
|
492
|
+
}
|
|
493
|
+
px[o + 3] = trns && pi < trns.length ? trns[pi] : 255;
|
|
494
|
+
} else if (ctype === 0 || ctype === 4) {
|
|
495
|
+
px[o] = px[o + 1] = px[o + 2] = cur[s];
|
|
496
|
+
px[o + 3] = ctype === 4 ? cur[s + 1] : 255;
|
|
497
|
+
} else {
|
|
498
|
+
px[o] = cur[s];
|
|
499
|
+
px[o + 1] = cur[s + 1];
|
|
500
|
+
px[o + 2] = cur[s + 2];
|
|
501
|
+
px[o + 3] = ctype === 6 ? cur[s + 3] : 255;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
prev.set(cur);
|
|
505
|
+
}
|
|
506
|
+
return { width: w, height: h, px };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------- 像素量测
|
|
510
|
+
|
|
511
|
+
function px(im, x, y) {
|
|
512
|
+
const o = (y * im.width + x) * 4;
|
|
513
|
+
return [im.px[o], im.px[o + 1], im.px[o + 2], im.px[o + 3]];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* 在给定矩形内求「墨迹」包围盒 —— 即与背景色差异超过 tol 的像素的外接矩形。
|
|
518
|
+
*
|
|
519
|
+
* 用途:参考截图里一个元素的**声明框**(view.xml 的 bounds / 语义框)与它**实际画出来的
|
|
520
|
+
* 图形**往往不同尺寸。区分二者是硬需求:本项目实测中,SeekBar 的语义框左内缩 58px(22.1dp),
|
|
521
|
+
* 而实际绘制的轨道(含圆角端帽)起于 75px —— 若把 58px 当成声明 padding,会得出 22dp 的
|
|
522
|
+
* 错误结论,而源码写的是 32dp。ink bbox 是唯一能把两者分开的量测。
|
|
523
|
+
*
|
|
524
|
+
* 背景色默认取该矩形四角的中位数,避免取到元素自身。
|
|
525
|
+
*/
|
|
526
|
+
function inkBox(im, x1, y1, x2, y2,
|
|
527
|
+
tol = 12, bg) {
|
|
528
|
+
const corners = [
|
|
529
|
+
px(im, x1, y1), px(im, x2 - 1, y1), px(im, x1, y2 - 1), px(im, x2 - 1, y2 - 1)
|
|
530
|
+
];
|
|
531
|
+
const base = bg || [
|
|
532
|
+
median(corners.map((c) => c[0])),
|
|
533
|
+
median(corners.map((c) => c[1])),
|
|
534
|
+
median(corners.map((c) => c[2]))
|
|
535
|
+
];
|
|
536
|
+
// 背景均匀性自检:四角在任一通道上的跨度若接近/超过容差,说明底不是纯色
|
|
537
|
+
// (渐变、图片、模糊底),此时「与背景有差异」这个判据本身失效。
|
|
538
|
+
// 实测:本项目唱片区底为粉色渐变,四角绿通道 248→237(跨度 11,容差 12),
|
|
539
|
+
// 通用 ink bbox 量出 861x818(几乎整块),而针对图形颜色筛选量出的是 655x724。
|
|
540
|
+
// 与其把容差调到刚好凑上某个已知答案,不如让工具**自报不可信** ——
|
|
541
|
+
// 前者是把工具拟合到一个样本,后者才在别的页面上仍然成立。
|
|
542
|
+
let spread = 0;
|
|
543
|
+
for (let c = 0; c < 3; c++) {
|
|
544
|
+
const vals = corners.map((q) => q[c]);
|
|
545
|
+
spread = Math.max(spread, Math.max(...vals) - Math.min(...vals));
|
|
546
|
+
}
|
|
547
|
+
const shaky = spread >= tol * 0.8;
|
|
548
|
+
let mnx = Number.MAX_SAFE_INTEGER;
|
|
549
|
+
let mny = Number.MAX_SAFE_INTEGER;
|
|
550
|
+
let mxx = -1;
|
|
551
|
+
let mxy = -1;
|
|
552
|
+
for (let y = y1; y < y2; y++) {
|
|
553
|
+
for (let x = x1; x < x2; x++) {
|
|
554
|
+
const p = px(im, x, y);
|
|
555
|
+
if (Math.abs(p[0] - base[0]) > tol ||
|
|
556
|
+
Math.abs(p[1] - base[1]) > tol ||
|
|
557
|
+
Math.abs(p[2] - base[2]) > tol) {
|
|
558
|
+
if (x < mnx) { mnx = x; }
|
|
559
|
+
if (x > mxx) { mxx = x; }
|
|
560
|
+
if (y < mny) { mny = y; }
|
|
561
|
+
if (y > mxy) { mxy = y; }
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (mxx < 0) {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
const box = {
|
|
569
|
+
x1: mnx, y1: mny, x2: mxx, y2: mxy,
|
|
570
|
+
w: mxx - mnx + 1,
|
|
571
|
+
h: mxy - mny + 1,
|
|
572
|
+
cx: (mnx + mxx) / 2,
|
|
573
|
+
cy: (mny + mxy) / 2
|
|
574
|
+
};
|
|
575
|
+
// 底不均匀即判不可信。**不要**试图再用「ink 是否小于声明框」去细分可信与否 ——
|
|
576
|
+
// 本项目实测证伪过这条思路:SeekBar 区域的 ink 量出 928x42,宽度 x 75..1002 恰好与
|
|
577
|
+
// 按颜色筛选的结果逐点吻合,看起来「可信」;但高度 42 正是声明框全高,而真实绘制的
|
|
578
|
+
// 轨道只有 26px —— 宽度对是因为轨道本就近乎满宽,**纯属碰巧**。
|
|
579
|
+
// 即:同一个 ink bbox 可以一维正确、另一维被渐变污染,不存在能把两者分开的整体判据。
|
|
580
|
+
//
|
|
581
|
+
// 于是整页铺渐变时,大多数区域被标记是**事实而非噪声**。如实报出,并附实测跨度,
|
|
582
|
+
// 由使用者按需对具体元素做颜色筛选 —— 这比给一个看起来精确的错数字诚实得多。
|
|
583
|
+
box.bgSpread = spread;
|
|
584
|
+
if (shaky) {
|
|
585
|
+
box.unreliable = true;
|
|
586
|
+
box.reason = `区域背景不均匀(四角通道跨度 ${spread},容差 ${tol}),疑为渐变/图片/模糊底。` +
|
|
587
|
+
'ink bbox 会把底色差异也算成墨迹,**任一维都可能被污染**(实测出现过「宽对高错」),' +
|
|
588
|
+
'故不可据此断言实绘尺寸。需按图形自身颜色单独筛选后再量。';
|
|
589
|
+
}
|
|
590
|
+
return box;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function median(a) {
|
|
594
|
+
const s = a.slice().sort((p, q) => p - q);
|
|
595
|
+
return s[Math.floor(s.length / 2)];
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ---------------------------------------------------------------- main
|
|
599
|
+
|
|
600
|
+
// interface RegionOut {
|
|
601
|
+
// node: number;
|
|
602
|
+
// role: string;
|
|
603
|
+
// boundsPx: string;
|
|
604
|
+
// wPx: number;
|
|
605
|
+
// hPx: number;
|
|
606
|
+
// wVp: number | null;
|
|
607
|
+
// hVp: number | null;
|
|
608
|
+
// xRatio: number;
|
|
609
|
+
// yRatio: number;
|
|
610
|
+
// wRatio: number;
|
|
611
|
+
// hRatio: number;
|
|
612
|
+
// clickable: boolean;
|
|
613
|
+
// text: string;
|
|
614
|
+
// desc: string;
|
|
615
|
+
// ink: InkBox | null;
|
|
616
|
+
// inkNote?: string;
|
|
617
|
+
// /** 该区域的前景色(最深像素)与底色(占比最大的像素),十六进制。 */
|
|
618
|
+
// inkColor?: string;
|
|
619
|
+
// bgColor?: string;
|
|
620
|
+
// }
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* 区域取色 —— 前景取**最深像素**,底色取**频次最高**的像素。
|
|
624
|
+
*
|
|
625
|
+
* 为什么不能沿用 inkBox 的四角中位数:四角必然落在**底**上,所以它能给底色、
|
|
626
|
+
* 永远给不出文字/描边的颜色。而"这段文字是什么颜色"是每个 Text 都要回答的问题
|
|
627
|
+
* (M3 的 onSurface / onSurfaceVariant / primary / tertiary 在截图里只差几十个灰阶,
|
|
628
|
+
* 靠肉眼看截图分不出来),于是不内置就等于每页都要另起一轮像素探测。
|
|
629
|
+
*
|
|
630
|
+
* 成本几乎为零:inkBox 本来就把这些像素走了一遍,这里只是在同一趟里多记两个值。
|
|
631
|
+
*/
|
|
632
|
+
function regionColors(im, x1, y1, x2, y2)
|
|
633
|
+
{
|
|
634
|
+
const hex = (r, g, b) =>
|
|
635
|
+
'#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0').toUpperCase()).join('');
|
|
636
|
+
let dr = 255, dg = 255, db = 255, dl = 1e9;
|
|
637
|
+
const hist = new Map();
|
|
638
|
+
for (let y = y1; y < y2; y++) {
|
|
639
|
+
for (let x = x1; x < x2; x++) {
|
|
640
|
+
const o = (y * im.width + x) * 4;
|
|
641
|
+
const r = im.px[o], g = im.px[o + 1], b = im.px[o + 2];
|
|
642
|
+
const l = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
643
|
+
if (l < dl) { dl = l; dr = r; dg = g; db = b; }
|
|
644
|
+
const k = (r << 16) | (g << 8) | b;
|
|
645
|
+
hist.set(k, (hist.get(k) || 0) + 1);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
let bk = 0, bn = -1;
|
|
649
|
+
for (const [k, n] of hist) { if (n > bn) { bn = n; bk = k; } }
|
|
650
|
+
return {
|
|
651
|
+
ink: hex(dr, dg, db),
|
|
652
|
+
bg: hex((bk >> 16) & 255, (bk >> 8) & 255, bk & 255)
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---------------------------------------------------------------- 探针模式
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* `--probe` —— 对一个矩形做**逐像素追问**,回答默认输出的 ink bbox 答不了的问题。
|
|
660
|
+
*
|
|
661
|
+
* 为什么必须内置:本包已经有 PNG 解码器(decodePng),但只把它用在 ink bbox 上。
|
|
662
|
+
* 于是每当需要「这个图标的实际几何是什么」「这个色值是多少」「这条圆角胶囊多大」,
|
|
663
|
+
* 子 agent 就得自己在 /tmp 里重写一遍解码器 —— 实测**同一次单页转换里重写了 7 遍**
|
|
664
|
+
* 逐字节相同的 inflate + 反滤波样板,每遍还各占一轮工具往返。
|
|
665
|
+
* 而这些量测本身是纯算术、对同一台采集设备恒定,正是本包存在的理由。
|
|
666
|
+
*
|
|
667
|
+
* 四种输出各对应一类回答不了的问题:
|
|
668
|
+
* colors 某几个点/整块区域的真实色值(取最深像素 + 频次直方图,
|
|
669
|
+
* 而不是四角取样 —— 文字/描边的"主色"永远不在四角)
|
|
670
|
+
* runs 逐行的暗像素区间。线条图标(勾、条、箭头)的顶点坐标只能从这里读出;
|
|
671
|
+
* ink bbox 只给外接矩形,拿它复原不出折线。
|
|
672
|
+
* ascii 把区域渲染成字符画,用于**先看清形状**再决定量什么。
|
|
673
|
+
* filter 按**指定颜色**求包围盒,而非"与背景不同"。渐变/图片底上
|
|
674
|
+
* bgSpread 会把 ink 判为不可靠(见 InkBox.unreliable),此时只有
|
|
675
|
+
* 按颜色过滤能量出真实尺寸 —— 例如 M3 选中态胶囊。
|
|
676
|
+
*
|
|
677
|
+
* 用法:
|
|
678
|
+
* node measure_pack.js --probe <png> --rect x1,y1,x2,y2 --mode colors|runs|ascii|filter
|
|
679
|
+
* [--color RRGGBB] [--tol 14] [--density 2.625]
|
|
680
|
+
*/
|
|
681
|
+
function runProbe(argv, arg) {
|
|
682
|
+
const file = arg('--probe');
|
|
683
|
+
const rectS = arg('--rect');
|
|
684
|
+
const mode = arg('--mode') || 'colors';
|
|
685
|
+
if (!file || !fs.existsSync(file)) {
|
|
686
|
+
console.error('--probe 需要一个存在的 png 路径');
|
|
687
|
+
process.exit(2);
|
|
688
|
+
}
|
|
689
|
+
const im = decodePng(file);
|
|
690
|
+
const r = (rectS || `0,0,${im.width},${im.height}`).split(',').map((v) => parseInt(v, 10));
|
|
691
|
+
const x1 = Math.max(0, r[0]);
|
|
692
|
+
const y1 = Math.max(0, r[1]);
|
|
693
|
+
const x2 = Math.min(im.width - 1, r[2]);
|
|
694
|
+
const y2 = Math.min(im.height - 1, r[3]);
|
|
695
|
+
const dens = parseFloat(arg('--density') || '0');
|
|
696
|
+
const tol = parseInt(arg('--tol') || '14', 10);
|
|
697
|
+
const hex = (c) =>
|
|
698
|
+
'#' + c.slice(0, 3).map((v) => v.toString(16).padStart(2, '0').toUpperCase()).join('');
|
|
699
|
+
const lum = (c) => 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
|
|
700
|
+
const vp = (n) => (dens > 0 ? ` (${(n / dens).toFixed(2)}vp)` : '');
|
|
701
|
+
|
|
702
|
+
if (mode === 'colors') {
|
|
703
|
+
let dark = [255, 255, 255];
|
|
704
|
+
const hist = new Map();
|
|
705
|
+
for (let y = y1; y <= y2; y++) {
|
|
706
|
+
for (let x = x1; x <= x2; x++) {
|
|
707
|
+
const c = px(im, x, y);
|
|
708
|
+
if (lum(c) < lum(dark)) { dark = c; }
|
|
709
|
+
const k = hex(c);
|
|
710
|
+
hist.set(k, (hist.get(k) || 0) + 1);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const top = [...hist.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4)
|
|
714
|
+
.map((e) => `${e[0]}×${e[1]}`);
|
|
715
|
+
console.log(`rect [${x1},${y1}][${x2},${y2}]`);
|
|
716
|
+
console.log(` darkest : ${hex(dark)}`);
|
|
717
|
+
console.log(` top : ${top.join(' ')}`);
|
|
718
|
+
console.log(' 注:文字/描边取 darkest,底色取 top 里占比最大的那个');
|
|
719
|
+
} else if (mode === 'runs') {
|
|
720
|
+
console.log(`rect [${x1},${y1}][${x2},${y2}] 暗像素逐行区间 (lum<${170})`);
|
|
721
|
+
for (let y = y1; y <= y2; y++) {
|
|
722
|
+
const runs = [];
|
|
723
|
+
let s = -1;
|
|
724
|
+
for (let x = x1; x <= x2 + 1; x++) {
|
|
725
|
+
const d = x <= x2 && lum(px(im, x, y)) < 170;
|
|
726
|
+
if (d && s < 0) { s = x; }
|
|
727
|
+
if (!d && s >= 0) { runs.push(`${s}-${x - 1}`); s = -1; }
|
|
728
|
+
}
|
|
729
|
+
if (runs.length > 0) { console.log(` y=${y} ${runs.join(' ')}`); }
|
|
730
|
+
}
|
|
731
|
+
console.log(' 注:折线/条形图标的顶点坐标从区间端点读出,ink bbox 只给外接矩形');
|
|
732
|
+
} else if (mode === 'ascii') {
|
|
733
|
+
const ramp = ' .:-=+*#%@';
|
|
734
|
+
for (let y = y1; y <= y2; y++) {
|
|
735
|
+
let line = '';
|
|
736
|
+
for (let x = x1; x <= x2; x++) {
|
|
737
|
+
const l = lum(px(im, x, y)) / 255;
|
|
738
|
+
line += ramp[Math.min(9, Math.max(0, Math.round((1 - l) * 9)))];
|
|
739
|
+
}
|
|
740
|
+
console.log(String(y).padStart(5) + ' ' + line);
|
|
741
|
+
}
|
|
742
|
+
} else if (mode === 'filter') {
|
|
743
|
+
const want = arg('--color');
|
|
744
|
+
if (!want) {
|
|
745
|
+
console.error('--mode filter 需要 --color RRGGBB');
|
|
746
|
+
process.exit(2);
|
|
747
|
+
}
|
|
748
|
+
const t = (want).replace('#', '');
|
|
749
|
+
const tgt = [0, 2, 4].map((i) => parseInt(t.slice(i, i + 2), 16));
|
|
750
|
+
let mnx = 1e9, mxx = -1, mny = 1e9, mxy = -1, n = 0;
|
|
751
|
+
for (let y = y1; y <= y2; y++) {
|
|
752
|
+
for (let x = x1; x <= x2; x++) {
|
|
753
|
+
const c = px(im, x, y);
|
|
754
|
+
if (Math.abs(c[0] - tgt[0]) <= tol && Math.abs(c[1] - tgt[1]) <= tol
|
|
755
|
+
&& Math.abs(c[2] - tgt[2]) <= tol) {
|
|
756
|
+
n++;
|
|
757
|
+
if (x < mnx) { mnx = x; } if (x > mxx) { mxx = x; }
|
|
758
|
+
if (y < mny) { mny = y; } if (y > mxy) { mxy = y; }
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (n === 0) {
|
|
763
|
+
console.log(`未命中颜色 #${t.toUpperCase()} (tol=${tol}) —— 换 --mode colors 先看真实色值`);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const w = mxx - mnx + 1;
|
|
767
|
+
const h = mxy - mny + 1;
|
|
768
|
+
console.log(`颜色 #${t.toUpperCase()} (tol=${tol}) 命中 ${n}px`);
|
|
769
|
+
console.log(` bbox [${mnx},${mny}][${mxx},${mxy}]`);
|
|
770
|
+
console.log(` 尺寸 w=${w}${vp(w)} h=${h}${vp(h)}`);
|
|
771
|
+
console.log(' 注:本模式按颜色过滤,不受 bgSpread/非均匀底影响');
|
|
772
|
+
} else {
|
|
773
|
+
console.error(`未知 --mode: ${mode}(可选 colors|runs|ascii|filter)`);
|
|
774
|
+
process.exit(2);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function main() {
|
|
779
|
+
const argv = process.argv.slice(2);
|
|
780
|
+
const arg = (k) => {
|
|
781
|
+
const i = argv.indexOf(k);
|
|
782
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
783
|
+
};
|
|
784
|
+
if (argv.includes('--probe')) { runProbe(argv, arg); return; }
|
|
785
|
+
const dir = arg('--ui-info');
|
|
786
|
+
const quiet = argv.includes('--quiet');
|
|
787
|
+
if (!dir) {
|
|
788
|
+
console.error('用法: node measure_pack.js --ui-info <page_NNNN_X 目录> [--out f.json] [--quiet] [--density N]');
|
|
789
|
+
process.exit(2);
|
|
790
|
+
}
|
|
791
|
+
// 按 meta.json 声明的确切路径取截图/view tree(不靠 Glob 猜)
|
|
792
|
+
let shot = path.join(dir, 'screenshot.png');
|
|
793
|
+
let vx = path.join(dir, 'view.xml');
|
|
794
|
+
const metaP = path.join(dir, 'meta.json');
|
|
795
|
+
let pageId = path.basename(dir);
|
|
796
|
+
let metaDensity = null;
|
|
797
|
+
let metaScreen = null;
|
|
798
|
+
const cliDensity = parseFloat(arg('--density') || '0');
|
|
799
|
+
if (fs.existsSync(metaP)) {
|
|
800
|
+
try {
|
|
801
|
+
const meta = JSON.parse(fs.readFileSync(metaP, 'utf8'))
|
|
802
|
+
// page_id?: string; screenshot?: string; view_xml?: string;
|
|
803
|
+
// device?: { density?: number | null; width_px?: number | null; height_px?: number | null };
|
|
804
|
+
;
|
|
805
|
+
if (meta.page_id) { pageId = meta.page_id; }
|
|
806
|
+
const root = path.dirname(dir);
|
|
807
|
+
if (meta.screenshot) { shot = path.resolve(root, meta.screenshot); }
|
|
808
|
+
if (meta.view_xml) { vx = path.resolve(root, meta.view_xml); }
|
|
809
|
+
// 采集期用 `wm density` 记下的真值。有它就不必反解 —— 反解不唯一,解错一次
|
|
810
|
+
// 整页 vp 差 2~3 倍,且编译与引用检查全部通过,极难归因。
|
|
811
|
+
if (typeof meta.device?.density === 'number' && meta.device.density > 0) {
|
|
812
|
+
metaDensity = meta.device.density;
|
|
813
|
+
}
|
|
814
|
+
// 整屏尺寸真值:uiautomator 只 dump 获焦窗口,对话框页面的节点包围盒不是整屏,
|
|
815
|
+
// 拿它去过屏幕尺寸闸门会误判。有 `wm size` 真值就用真值。
|
|
816
|
+
if (typeof meta.device?.width_px === 'number' && typeof meta.device?.height_px === 'number'
|
|
817
|
+
&& meta.device.width_px > 0 && meta.device.height_px > 0) {
|
|
818
|
+
metaScreen = { widthPx: meta.device.width_px, heightPx: meta.device.height_px };
|
|
819
|
+
}
|
|
820
|
+
} catch (e) {
|
|
821
|
+
console.error('meta.json 解析失败,回退到默认文件名: ' + String(e));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (!fs.existsSync(vx)) {
|
|
825
|
+
console.error('view tree 不存在: ' + vx);
|
|
826
|
+
process.exit(2);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const nodes = parseNodes(fs.readFileSync(vx, 'utf8'));
|
|
830
|
+
const root = nodes[0];
|
|
831
|
+
// 优先级:--density > meta.json 的 device.density > 节点反解
|
|
832
|
+
const known =
|
|
833
|
+
cliDensity > 0 ? { value: cliDensity, source: 'cli' }
|
|
834
|
+
: metaDensity !== null ? { value: metaDensity, source: 'device' }
|
|
835
|
+
: null;
|
|
836
|
+
const den = deriveDensity(nodes, known, metaScreen);
|
|
837
|
+
const d = den.density;
|
|
838
|
+
const toVp = (v) => (d ? Math.round((v / d) * 100) / 100 : null);
|
|
839
|
+
|
|
840
|
+
let im = null;
|
|
841
|
+
let imErr = '';
|
|
842
|
+
if (fs.existsSync(shot)) {
|
|
843
|
+
try {
|
|
844
|
+
im = decodePng(shot);
|
|
845
|
+
} catch (e) {
|
|
846
|
+
imErr = String(e);
|
|
847
|
+
}
|
|
848
|
+
} else {
|
|
849
|
+
imErr = '截图不存在: ' + shot;
|
|
850
|
+
}
|
|
851
|
+
// 解码失败必须**显式刺眼**,不能只躺在 JSON 字段里:那样的症状是密度算得出、
|
|
852
|
+
// 而 ink/edge_colors 全空,看起来像「这页没有可量的东西」,而不是「量测器坏了」。
|
|
853
|
+
if (imErr) {
|
|
854
|
+
console.error('!! 像素量测不可用 —— ' + imErr);
|
|
855
|
+
console.error('!! 本包仅含 view tree 量测;ink bbox 与取色缺失,勿据此断言实绘尺寸。');
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// 主要区域 = 可交互 / 有文字或描述 / 可滚动 / 已知的控件类。
|
|
859
|
+
//
|
|
860
|
+
// 最后一项不可省:实测 `android.widget.SeekBar` 的 clickable=false、text 与
|
|
861
|
+
// content-desc 均为空、scrollable=false —— 只按前三项过滤会把进度条整条漏掉,
|
|
862
|
+
// 而它恰是本页需要精确量测的区域之一(声明框 42px 高、实绘轨道 26px)。
|
|
863
|
+
// 凡「靠交互属性判断重要性」的过滤都有这个盲区,故补一张控件类白名单。
|
|
864
|
+
const WIDGET = /\.(SeekBar|ProgressBar|Switch|CheckBox|RadioButton|EditText|ImageView|ImageButton|RatingBar|Spinner|ToggleButton|Button|TextView)$/;
|
|
865
|
+
const picked = nodes.filter((n) =>
|
|
866
|
+
n.clickable || n.scrollable || n.checkable ||
|
|
867
|
+
n.text.length > 0 || n.desc.length > 0 || WIDGET.test(n.cls));
|
|
868
|
+
|
|
869
|
+
const regions = picked.map((n) => {
|
|
870
|
+
const r = {
|
|
871
|
+
node: n.idx,
|
|
872
|
+
role: n.cls.replace(/^android\.(widget|view)\./, ''),
|
|
873
|
+
boundsPx: `[${n.x1},${n.y1}][${n.x2},${n.y2}]`,
|
|
874
|
+
wPx: n.w,
|
|
875
|
+
hPx: n.h,
|
|
876
|
+
wVp: toVp(n.w),
|
|
877
|
+
hVp: toVp(n.h),
|
|
878
|
+
xRatio: Math.round(((n.x1 - root.x1) / root.w) * 1e4) / 1e4,
|
|
879
|
+
yRatio: Math.round(((n.y1 - root.y1) / root.h) * 1e4) / 1e4,
|
|
880
|
+
wRatio: Math.round((n.w / root.w) * 1e4) / 1e4,
|
|
881
|
+
hRatio: Math.round((n.h / root.h) * 1e4) / 1e4,
|
|
882
|
+
clickable: n.clickable,
|
|
883
|
+
text: n.text,
|
|
884
|
+
desc: n.desc,
|
|
885
|
+
ink: null
|
|
886
|
+
};
|
|
887
|
+
if (im && n.w > 1 && n.h > 1 && n.w <= im.width && n.h <= im.height) {
|
|
888
|
+
const cx2 = Math.min(n.x2, im.width);
|
|
889
|
+
const cy2 = Math.min(n.y2, im.height);
|
|
890
|
+
r.ink = inkBox(im, n.x1, n.y1, cx2, cy2);
|
|
891
|
+
if (r.ink && (r.ink.w !== n.w || r.ink.h !== n.h)) {
|
|
892
|
+
r.inkNote = '实绘图形小于声明框 —— 声明框可能是语义/触控框,勿直接当绘制尺寸';
|
|
893
|
+
}
|
|
894
|
+
const c = regionColors(im, n.x1, n.y1, cx2, cy2);
|
|
895
|
+
r.inkColor = c.ink;
|
|
896
|
+
r.bgColor = c.bg;
|
|
897
|
+
}
|
|
898
|
+
return r;
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
// 四边取色:用于判断底色/scrim 合成,以及沉浸式下的边缘着色(仅提供数值,不做判断)
|
|
902
|
+
const edges = {};
|
|
903
|
+
if (im) {
|
|
904
|
+
const mid = Math.floor(im.height / 2);
|
|
905
|
+
const midx = Math.floor(im.width / 2);
|
|
906
|
+
edges.top = px(im, midx, 0);
|
|
907
|
+
edges.bottom = px(im, midx, im.height - 1);
|
|
908
|
+
edges.left = px(im, 0, mid);
|
|
909
|
+
edges.right = px(im, im.width - 1, mid);
|
|
910
|
+
edges.center = px(im, midx, mid);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const out = {
|
|
914
|
+
page_id: pageId,
|
|
915
|
+
generated_by: 'measure_pack.js',
|
|
916
|
+
inputs: {
|
|
917
|
+
view_xml: vx,
|
|
918
|
+
screenshot: fs.existsSync(shot) ? shot : null,
|
|
919
|
+
screenshot_error: imErr || null
|
|
920
|
+
},
|
|
921
|
+
image: im ? { width: im.width, height: im.height } : null,
|
|
922
|
+
density: {
|
|
923
|
+
value: d,
|
|
924
|
+
source: den.source,
|
|
925
|
+
source_note: den.source === 'device'
|
|
926
|
+
? '采集期 `wm density` 真值(meta.json 的 device 字段)'
|
|
927
|
+
: den.source === 'cli'
|
|
928
|
+
? '调用方通过 --density 显式给出'
|
|
929
|
+
: '由正方形节点边长反解常见 dp 档得出,下列为独立证据',
|
|
930
|
+
// confident=false 时**不要**拿本页 vp 当硬性实测值:反解有歧义,
|
|
931
|
+
// 请用 `adb shell wm density` 核实,或重跑带 device 字段的采集。
|
|
932
|
+
confident: den.confident,
|
|
933
|
+
rival: den.rival,
|
|
934
|
+
crosscheck: den.crosscheck,
|
|
935
|
+
reason: den.reason,
|
|
936
|
+
evidence: den.evidence.slice(0, 8)
|
|
937
|
+
},
|
|
938
|
+
app_content_bounds: {
|
|
939
|
+
boundsPx: `[${root.x1},${root.y1}][${root.x2},${root.y2}]`,
|
|
940
|
+
wPx: root.w,
|
|
941
|
+
hPx: root.h,
|
|
942
|
+
wVp: toVp(root.w),
|
|
943
|
+
hVp: toVp(root.h)
|
|
944
|
+
},
|
|
945
|
+
edge_colors: edges,
|
|
946
|
+
node_count: nodes.length,
|
|
947
|
+
regions
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
const outFile = arg('--out') || path.join(dir, 'measure_pack.json');
|
|
951
|
+
fs.writeFileSync(outFile, JSON.stringify(out, null, 2), 'utf8');
|
|
952
|
+
|
|
953
|
+
if (!quiet) {
|
|
954
|
+
console.log(`measure_pack —— ${pageId}`);
|
|
955
|
+
console.log(` view tree : ${vx}`);
|
|
956
|
+
console.log(` screenshot: ${fs.existsSync(shot) ? shot : '(缺失) ' + imErr}`);
|
|
957
|
+
if (im) { console.log(` image : ${im.width}x${im.height}`); }
|
|
958
|
+
const dsrc = den.source === 'device' ? '设备真值' : den.source === 'cli' ? '--density' : '节点反解';
|
|
959
|
+
console.log(` density : ${d === null ? '(无法反解)' : d} [${dsrc}]`);
|
|
960
|
+
for (const e of den.evidence.slice(0, 4)) {
|
|
961
|
+
console.log(` 证据: node ${e.node} 边长 ${e.sidePx}px / ${e.dp}dp = ${e.density}`);
|
|
962
|
+
}
|
|
963
|
+
// 低置信必须**吵出来**:安静地印一个数字,下游会把它当实测值写进代码。
|
|
964
|
+
if (d !== null && !den.confident) {
|
|
965
|
+
console.log(' !! density 低置信 —— 本页 vp 换算不可作为硬性实测依据' +
|
|
966
|
+
(den.rival ? `;次优候选 ${den.rival.density}(${den.rival.hits} 命中)` : '') +
|
|
967
|
+
'。请用 `adb shell wm density` 核实,或重跑采集以写入 meta.json 的 device 字段');
|
|
968
|
+
}
|
|
969
|
+
if (den.reason) {
|
|
970
|
+
console.log(' 原因: ' + den.reason);
|
|
971
|
+
}
|
|
972
|
+
if (den.crosscheck) {
|
|
973
|
+
console.log(' !! density 交叉校验: ' + den.crosscheck);
|
|
974
|
+
}
|
|
975
|
+
console.log(` app bounds: ${out.app_content_bounds.boundsPx}` +
|
|
976
|
+
` = ${out.app_content_bounds.wVp}x${out.app_content_bounds.hVp} vp`);
|
|
977
|
+
if (im) {
|
|
978
|
+
console.log(' 边缘取色 : ' + Object.keys(edges)
|
|
979
|
+
.map((k) => `${k}=(${edges[k].slice(0, 3).join(',')})`).join(' '));
|
|
980
|
+
}
|
|
981
|
+
console.log(` 区域数 : ${regions.length} / 全节点 ${nodes.length}`);
|
|
982
|
+
// 带文字区域的取色必须进**打印摘要**:父流程贴进 prompt 的是这段摘要,
|
|
983
|
+
// 只落在 JSON 里等于没省 —— 子 agent 仍会另起一轮去探色。
|
|
984
|
+
// 只列有 text/desc 的区域,避免把 37 个区域全刷出来。
|
|
985
|
+
const painted = regions.filter(
|
|
986
|
+
(r) => r.inkColor !== undefined && (r.text.length > 0 || r.desc.length > 0));
|
|
987
|
+
if (painted.length > 0) {
|
|
988
|
+
console.log(' 文本取色 : (前景=最深像素, 底=最高频像素; M3 各 on* 角色仅差几十灰阶,勿肉眼判)');
|
|
989
|
+
for (const r of painted.slice(0, 14)) {
|
|
990
|
+
const label = (r.text || r.desc).replace(/\s+/g, ' ').slice(0, 30);
|
|
991
|
+
console.log(` node ${String(r.node).padStart(3)} ${r.inkColor} on ${r.bgColor} ${r.role} "${label}"`);
|
|
992
|
+
}
|
|
993
|
+
if (painted.length > 14) {
|
|
994
|
+
console.log(` ... 另 ${painted.length - 14} 个见 measure_pack.json 的 inkColor/bgColor`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
console.log(` 已写出 : ${outFile}`);
|
|
998
|
+
console.log('');
|
|
999
|
+
console.log('注意:本包只给**量测值**。沉浸式与否、锚点属固定偏移还是由父尺寸推导、');
|
|
1000
|
+
console.log('图标 L1/L2/L3 分级,都必须回读 Android 源码判定(Phase 4.2 / 4.3),不在本包范围。');
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
main();
|
|
1005
|
+
|