/** * measure_pack.ts —— 把「每页都要重算一遍」的参考量测,前移到父流程一次算完。 * * 用法(Node >= 22.18 / 23.6 原生剥类型,无需编译、无外部依赖): * node measure_pack.ts --ui-info [--out ] [--quiet] * * ── 为什么存在 ──────────────────────────────────────────────────────────── * 实测一次单页转换(96 次工具调用 / 61.4 min):**工具真实执行合计 17.9 秒,占 0.5%**, * 而「拿到工具结果之后的模型思考+生成」占 71.3%(96 轮 × 均值 27.4s)。 * 也就是说成本 ≈ 轮次数 × 单轮延迟,压轮次是唯一有意义的加速方向。 * * 那次运行里子 agent 为了测密度、ink bbox、取色,跑了 11 轮 PIL(约 5 min)。 * 这些量测有两个特点:(a) 同一采集设备上**每页同值**,(b) 判据纯算术、可复算。 * 于是它们属于「父侧一次算完、批内摊薄」的活,不该每页各花若干轮重算。 * * ── 职责边界(重要) ────────────────────────────────────────────────────── * 本脚本**只做量测与算术,不做判断**。它不推断沉浸式、不推断锚定方向、不给图标分级 —— * 那些需要回读 Android 源码与约束声明,是 Phase 4/5 的事。 * 每个输出值都带 `source` 字段标注来源,便于子 agent 引用与父侧复核。 * * 参考:conversion-procedure.md Phase 4.1「LAYOUT_EVIDENCE」与 Phase 4.2「坐标与窗口模式规则」。 */ import * as fs from 'fs'; import * as path from 'path'; // 静态 import,不能用 require:Node 原生剥类型时按 ESM 处理本文件,require 未定义。 // 用 require 会让 PNG 解码整个失败,而失败点在 try/catch 里 —— 症状是密度算得出、 // 而 image/edge_colors/ink 全为空,极易被当成「截图有问题」。 import * as zlib from 'zlib'; // ---------------------------------------------------------------- 类型 interface Node { idx: number; cls: string; text: string; rid: string; desc: string; clickable: boolean; scrollable: boolean; enabled: boolean; checkable: boolean; checked: boolean; x1: number; y1: number; x2: number; y2: number; w: number; h: number; } interface Png { width: number; height: number; /** RGBA,每像素 4 字节 */ px: Uint8Array; } interface InkBox { x1: number; y1: number; x2: number; y2: number; w: number; h: number; cx: number; cy: number; /** * 背景不均匀(渐变/图片底)时置位。此时本 ink bbox **不可信** —— * 「与背景有差异」的判据会把渐变本身也算成墨迹,量出来的往往就是整个区域。 */ unreliable?: boolean; reason?: string; /** 四角在最差通道上的色值跨度。0 表示纯色底,越大越说明底是渐变/图片。 */ bgSpread?: number; } // ---------------------------------------------------------------- view.xml /** * 解析 uiautomator dump。用正则而非 XML 解析器:该格式是扁平的 与嵌套 * 混用,属性集固定,正则足够且不引依赖。属性顺序不可假定,逐个按名取。 */ function parseNodes(xml: string): Node[] { const out: Node[] = []; const re = /]*?)\/?>/g; let m: RegExpExecArray | null; let i = 0; while ((m = re.exec(xml)) !== null) { const a: string = m[1]; const get = (k: string): string => { const r = new RegExp(k + '="([^"]*)"').exec(a); return r ? r[1] : ''; }; const b = /bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(a); if (!b) { i++; continue; } const x1: number = parseInt(b[1], 10); const y1: number = parseInt(b[2], 10); const x2: number = parseInt(b[3], 10); const y2: number = parseInt(b[4], 10); out.push({ idx: i++, cls: get('class'), text: get('text'), rid: get('resource-id'), desc: get('content-desc'), clickable: get('clickable') === 'true', scrollable: get('scrollable') === 'true', enabled: get('enabled') === 'true', checkable: get('checkable') === 'true', checked: get('checked') === 'true', x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 }); } return out; } // ---------------------------------------------------------------- 密度反解 interface DensityEvidence { node: number; sidePx: number; dp: number; density: number; } interface DensityResult { density: number | null; evidence: DensityEvidence[]; } /** * 由 bounds 反解屏幕密度。 * * 判据:Android 的固有尺寸几乎总是整数 dp,且常见值集中在 Material/AndroidX 的 * 24/32/36/40/48/56/64/72dp。因此拿**正方形**节点的边长去试各档密度, * 命中最多者即为该设备密度。只看正方形:矩形的宽高多由约束求解得出,不是声明值。 * * 输出 evidence 列表而非单一数字 —— Phase 5 要求每个尺寸可溯源, * 两条独立命中(不同节点、不同 dp 档)才足以支撑一个密度结论。 */ function deriveDensity(nodes: Node[]): DensityResult { const CANDIDATES: number[] = [1, 1.5, 2, 2.25, 2.5, 2.625, 2.75, 3, 3.5, 4]; const COMMON_DP: number[] = [16, 20, 24, 28, 32, 36, 40, 44, 48, 56, 64, 72, 80, 96]; let best: number | null = null; let bestHits: DensityEvidence[] = []; for (const d of CANDIDATES) { const hits: DensityEvidence[] = []; for (const n of nodes) { if (n.w <= 0 || n.w !== n.h) { continue; } const dp: number = n.w / d; for (const c of COMMON_DP) { if (Math.abs(dp - c) < 0.02) { hits.push({ node: n.idx, sidePx: n.w, dp: c, density: d }); break; } } } if (hits.length > bestHits.length) { bestHits = hits; best = d; } } // 去重:同一边长只留一条证据,避免 N 个同尺寸图标刷满列表 const seen: Set = new Set(); const uniq: DensityEvidence[] = []; for (const h of bestHits) { if (!seen.has(h.sidePx)) { seen.add(h.sidePx); uniq.push(h); } } return { density: best, evidence: uniq }; } // ---------------------------------------------------------------- PNG 解码 /** * 最小 PNG 解码器:zlib inflate + 逐行反滤波。仅支持 8bit 非隔行的 * 灰度/RGB/调色板/灰度+A/RGBA(colorType 0/2/3/4/6)。 * * 不支持的变体**显式抛错并给出 PIL 回退命令**,绝不静默降级 —— * 一个悄悄返回错像素的量测器,比没有量测器更危险。 */ function decodePng(file: string): Png { const buf: Buffer = fs.readFileSync(file); const SIG: number[] = [137, 80, 78, 71, 13, 10, 26, 10]; for (let i = 0; i < 8; i++) { if (buf[i] !== SIG[i]) { throw new Error(`不是 PNG 文件: ${file}`); } } let off: number = 8; let w: number = 0; let h: number = 0; let depth: number = 0; let ctype: number = 0; let interlace: number = 0; const idat: Buffer[] = []; let plte: Buffer | null = null; let trns: Buffer | null = null; while (off < buf.length) { const len: number = buf.readUInt32BE(off); const type: string = buf.toString('ascii', off + 4, off + 8); const data: Buffer = buf.subarray(off + 8, off + 8 + len); if (type === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); depth = data[8]; ctype = data[9]; interlace = data[12]; } else if (type === 'IDAT') { idat.push(data); } else if (type === 'PLTE') { plte = Buffer.from(data); } else if (type === 'tRNS') { trns = Buffer.from(data); } else if (type === 'IEND') { break; } off += 12 + len; } if (depth !== 8 || interlace !== 0 || ![0, 2, 3, 4, 6].includes(ctype)) { throw new Error( `不支持的 PNG 变体 (bitDepth=${depth}, colorType=${ctype}, interlace=${interlace})。\n` + '本解码器只支持 8bit 非隔行的 colorType 0/2/3/4/6。\n' + '回退方案(需 Pillow):\n' + ` python -c "from PIL import Image; im=Image.open(r'${file}').convert('RGBA'); ` + `im.save(r'${file}.rgba8.png')"\n` + '然后对转换后的文件重跑本脚本。' ); } const CH: Record = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }; const ch: number = CH[ctype]; const raw: Buffer = zlib.inflateSync(Buffer.concat(idat)); const stride: number = w * ch; const px: Uint8Array = new Uint8Array(w * h * 4); const prev: Uint8Array = new Uint8Array(stride); const cur: Uint8Array = new Uint8Array(stride); let rp: number = 0; for (let y = 0; y < h; y++) { const filter: number = raw[rp++]; for (let i = 0; i < stride; i++) { cur[i] = raw[rp + i]; } rp += stride; // 反滤波(PNG spec 9.2) for (let i = 0; i < stride; i++) { const a: number = i >= ch ? cur[i - ch] : 0; const b: number = prev[i]; const c: number = i >= ch ? prev[i - ch] : 0; let v: number = cur[i]; if (filter === 1) { v = v + a; } else if (filter === 2) { v = v + b; } else if (filter === 3) { v = v + ((a + b) >> 1); } else if (filter === 4) { const p: number = a + b - c; const pa: number = Math.abs(p - a); const pb: number = Math.abs(p - b); const pc: number = Math.abs(p - c); v = v + (pa <= pb && pa <= pc ? a : (pb <= pc ? b : c)); } cur[i] = v & 0xff; } // 转 RGBA for (let x = 0; x < w; x++) { const o: number = (y * w + x) * 4; const s: number = x * ch; if (ctype === 3) { const pi: number = cur[s]; if (plte) { px[o] = plte[pi * 3]; px[o + 1] = plte[pi * 3 + 1]; px[o + 2] = plte[pi * 3 + 2]; } px[o + 3] = trns && pi < trns.length ? trns[pi] : 255; } else if (ctype === 0 || ctype === 4) { px[o] = px[o + 1] = px[o + 2] = cur[s]; px[o + 3] = ctype === 4 ? cur[s + 1] : 255; } else { px[o] = cur[s]; px[o + 1] = cur[s + 1]; px[o + 2] = cur[s + 2]; px[o + 3] = ctype === 6 ? cur[s + 3] : 255; } } prev.set(cur); } return { width: w, height: h, px }; } // ---------------------------------------------------------------- 像素量测 function px(im: Png, x: number, y: number): number[] { const o: number = (y * im.width + x) * 4; return [im.px[o], im.px[o + 1], im.px[o + 2], im.px[o + 3]]; } /** * 在给定矩形内求「墨迹」包围盒 —— 即与背景色差异超过 tol 的像素的外接矩形。 * * 用途:参考截图里一个元素的**声明框**(view.xml 的 bounds / 语义框)与它**实际画出来的 * 图形**往往不同尺寸。区分二者是硬需求:本项目实测中,SeekBar 的语义框左内缩 58px(22.1dp), * 而实际绘制的轨道(含圆角端帽)起于 75px —— 若把 58px 当成声明 padding,会得出 22dp 的 * 错误结论,而源码写的是 32dp。ink bbox 是唯一能把两者分开的量测。 * * 背景色默认取该矩形四角的中位数,避免取到元素自身。 */ function inkBox(im: Png, x1: number, y1: number, x2: number, y2: number, tol: number = 12, bg?: number[]): InkBox | null { const corners: number[][] = [ px(im, x1, y1), px(im, x2 - 1, y1), px(im, x1, y2 - 1), px(im, x2 - 1, y2 - 1) ]; const base: number[] = bg || [ median(corners.map((c) => c[0])), median(corners.map((c) => c[1])), median(corners.map((c) => c[2])) ]; // 背景均匀性自检:四角在任一通道上的跨度若接近/超过容差,说明底不是纯色 // (渐变、图片、模糊底),此时「与背景有差异」这个判据本身失效。 // 实测:本项目唱片区底为粉色渐变,四角绿通道 248→237(跨度 11,容差 12), // 通用 ink bbox 量出 861x818(几乎整块),而针对图形颜色筛选量出的是 655x724。 // 与其把容差调到刚好凑上某个已知答案,不如让工具**自报不可信** —— // 前者是把工具拟合到一个样本,后者才在别的页面上仍然成立。 let spread: number = 0; for (let c = 0; c < 3; c++) { const vals: number[] = corners.map((q) => q[c]); spread = Math.max(spread, Math.max(...vals) - Math.min(...vals)); } const shaky: boolean = spread >= tol * 0.8; let mnx: number = Number.MAX_SAFE_INTEGER; let mny: number = Number.MAX_SAFE_INTEGER; let mxx: number = -1; let mxy: number = -1; for (let y = y1; y < y2; y++) { for (let x = x1; x < x2; x++) { const p: number[] = px(im, x, y); if (Math.abs(p[0] - base[0]) > tol || Math.abs(p[1] - base[1]) > tol || Math.abs(p[2] - base[2]) > tol) { if (x < mnx) { mnx = x; } if (x > mxx) { mxx = x; } if (y < mny) { mny = y; } if (y > mxy) { mxy = y; } } } } if (mxx < 0) { return null; } const box: InkBox = { x1: mnx, y1: mny, x2: mxx, y2: mxy, w: mxx - mnx + 1, h: mxy - mny + 1, cx: (mnx + mxx) / 2, cy: (mny + mxy) / 2 }; // 底不均匀即判不可信。**不要**试图再用「ink 是否小于声明框」去细分可信与否 —— // 本项目实测证伪过这条思路:SeekBar 区域的 ink 量出 928x42,宽度 x 75..1002 恰好与 // 按颜色筛选的结果逐点吻合,看起来「可信」;但高度 42 正是声明框全高,而真实绘制的 // 轨道只有 26px —— 宽度对是因为轨道本就近乎满宽,**纯属碰巧**。 // 即:同一个 ink bbox 可以一维正确、另一维被渐变污染,不存在能把两者分开的整体判据。 // // 于是整页铺渐变时,大多数区域被标记是**事实而非噪声**。如实报出,并附实测跨度, // 由使用者按需对具体元素做颜色筛选 —— 这比给一个看起来精确的错数字诚实得多。 box.bgSpread = spread; if (shaky) { box.unreliable = true; box.reason = `区域背景不均匀(四角通道跨度 ${spread},容差 ${tol}),疑为渐变/图片/模糊底。` + 'ink bbox 会把底色差异也算成墨迹,**任一维都可能被污染**(实测出现过「宽对高错」),' + '故不可据此断言实绘尺寸。需按图形自身颜色单独筛选后再量。'; } return box; } function median(a: number[]): number { const s: number[] = a.slice().sort((p, q) => p - q); return s[Math.floor(s.length / 2)]; } // ---------------------------------------------------------------- main interface RegionOut { node: number; role: string; boundsPx: string; wPx: number; hPx: number; wVp: number | null; hVp: number | null; xRatio: number; yRatio: number; wRatio: number; hRatio: number; clickable: boolean; text: string; desc: string; ink: InkBox | null; inkNote?: string; /** 该区域的前景色(最深像素)与底色(占比最大的像素),十六进制。 */ inkColor?: string; bgColor?: string; } /** * 区域取色 —— 前景取**最深像素**,底色取**频次最高**的像素。 * * 为什么不能沿用 inkBox 的四角中位数:四角必然落在**底**上,所以它能给底色、 * 永远给不出文字/描边的颜色。而"这段文字是什么颜色"是每个 Text 都要回答的问题 * (M3 的 onSurface / onSurfaceVariant / primary / tertiary 在截图里只差几十个灰阶, * 靠肉眼看截图分不出来),于是不内置就等于每页都要另起一轮像素探测。 * * 成本几乎为零:inkBox 本来就把这些像素走了一遍,这里只是在同一趟里多记两个值。 */ function regionColors(im: Png, x1: number, y1: number, x2: number, y2: number): { ink: string; bg: string } { const hex = (r: number, g: number, b: number): string => '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0').toUpperCase()).join(''); let dr: number = 255, dg: number = 255, db: number = 255, dl: number = 1e9; const hist: Map = new Map(); for (let y = y1; y < y2; y++) { for (let x = x1; x < x2; x++) { const o: number = (y * im.width + x) * 4; const r: number = im.px[o], g: number = im.px[o + 1], b: number = im.px[o + 2]; const l: number = 0.299 * r + 0.587 * g + 0.114 * b; if (l < dl) { dl = l; dr = r; dg = g; db = b; } const k: number = (r << 16) | (g << 8) | b; hist.set(k, (hist.get(k) || 0) + 1); } } let bk: number = 0, bn: number = -1; for (const [k, n] of hist) { if (n > bn) { bn = n; bk = k; } } return { ink: hex(dr, dg, db), bg: hex((bk >> 16) & 255, (bk >> 8) & 255, bk & 255) }; } // ---------------------------------------------------------------- 探针模式 /** * `--probe` —— 对一个矩形做**逐像素追问**,回答默认输出的 ink bbox 答不了的问题。 * * 为什么必须内置:本包已经有 PNG 解码器(decodePng),但只把它用在 ink bbox 上。 * 于是每当需要「这个图标的实际几何是什么」「这个色值是多少」「这条圆角胶囊多大」, * 子 agent 就得自己在 /tmp 里重写一遍解码器 —— 实测**同一次单页转换里重写了 7 遍** * 逐字节相同的 inflate + 反滤波样板,每遍还各占一轮工具往返。 * 而这些量测本身是纯算术、对同一台采集设备恒定,正是本包存在的理由。 * * 四种输出各对应一类回答不了的问题: * colors 某几个点/整块区域的真实色值(取最深像素 + 频次直方图, * 而不是四角取样 —— 文字/描边的"主色"永远不在四角) * runs 逐行的暗像素区间。线条图标(勾、条、箭头)的顶点坐标只能从这里读出; * ink bbox 只给外接矩形,拿它复原不出折线。 * ascii 把区域渲染成字符画,用于**先看清形状**再决定量什么。 * filter 按**指定颜色**求包围盒,而非"与背景不同"。渐变/图片底上 * bgSpread 会把 ink 判为不可靠(见 InkBox.unreliable),此时只有 * 按颜色过滤能量出真实尺寸 —— 例如 M3 选中态胶囊。 * * 用法: * node measure_pack.ts --probe --rect x1,y1,x2,y2 --mode colors|runs|ascii|filter * [--color RRGGBB] [--tol 14] [--density 2.625] */ function runProbe(argv: string[], arg: (k: string) => string | null): void { const file: string | null = arg('--probe'); const rectS: string | null = arg('--rect'); const mode: string = arg('--mode') || 'colors'; if (!file || !fs.existsSync(file)) { console.error('--probe 需要一个存在的 png 路径'); process.exit(2); } const im: Png = decodePng(file as string); const r: number[] = (rectS || `0,0,${im.width},${im.height}`).split(',').map((v) => parseInt(v, 10)); const x1: number = Math.max(0, r[0]); const y1: number = Math.max(0, r[1]); const x2: number = Math.min(im.width - 1, r[2]); const y2: number = Math.min(im.height - 1, r[3]); const dens: number = parseFloat(arg('--density') || '0'); const tol: number = parseInt(arg('--tol') || '14', 10); const hex = (c: number[]): string => '#' + c.slice(0, 3).map((v) => v.toString(16).padStart(2, '0').toUpperCase()).join(''); const lum = (c: number[]): number => 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; const vp = (n: number): string => (dens > 0 ? ` (${(n / dens).toFixed(2)}vp)` : ''); if (mode === 'colors') { let dark: number[] = [255, 255, 255]; const hist: Map = new Map(); for (let y = y1; y <= y2; y++) { for (let x = x1; x <= x2; x++) { const c: number[] = px(im, x, y); if (lum(c) < lum(dark)) { dark = c; } const k: string = hex(c); hist.set(k, (hist.get(k) || 0) + 1); } } const top: string[] = [...hist.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4) .map((e) => `${e[0]}×${e[1]}`); console.log(`rect [${x1},${y1}][${x2},${y2}]`); console.log(` darkest : ${hex(dark)}`); console.log(` top : ${top.join(' ')}`); console.log(' 注:文字/描边取 darkest,底色取 top 里占比最大的那个'); } else if (mode === 'runs') { console.log(`rect [${x1},${y1}][${x2},${y2}] 暗像素逐行区间 (lum<${170})`); for (let y = y1; y <= y2; y++) { const runs: string[] = []; let s: number = -1; for (let x = x1; x <= x2 + 1; x++) { const d: boolean = x <= x2 && lum(px(im, x, y)) < 170; if (d && s < 0) { s = x; } if (!d && s >= 0) { runs.push(`${s}-${x - 1}`); s = -1; } } if (runs.length > 0) { console.log(` y=${y} ${runs.join(' ')}`); } } console.log(' 注:折线/条形图标的顶点坐标从区间端点读出,ink bbox 只给外接矩形'); } else if (mode === 'ascii') { const ramp: string = ' .:-=+*#%@'; for (let y = y1; y <= y2; y++) { let line: string = ''; for (let x = x1; x <= x2; x++) { const l: number = lum(px(im, x, y)) / 255; line += ramp[Math.min(9, Math.max(0, Math.round((1 - l) * 9)))]; } console.log(String(y).padStart(5) + ' ' + line); } } else if (mode === 'filter') { const want: string | null = arg('--color'); if (!want) { console.error('--mode filter 需要 --color RRGGBB'); process.exit(2); } const t: string = (want as string).replace('#', ''); const tgt: number[] = [0, 2, 4].map((i) => parseInt(t.slice(i, i + 2), 16)); let mnx: number = 1e9, mxx: number = -1, mny: number = 1e9, mxy: number = -1, n: number = 0; for (let y = y1; y <= y2; y++) { for (let x = x1; x <= x2; x++) { const c: number[] = px(im, x, y); if (Math.abs(c[0] - tgt[0]) <= tol && Math.abs(c[1] - tgt[1]) <= tol && Math.abs(c[2] - tgt[2]) <= tol) { n++; if (x < mnx) { mnx = x; } if (x > mxx) { mxx = x; } if (y < mny) { mny = y; } if (y > mxy) { mxy = y; } } } } if (n === 0) { console.log(`未命中颜色 #${t.toUpperCase()} (tol=${tol}) —— 换 --mode colors 先看真实色值`); return; } const w: number = mxx - mnx + 1; const h: number = mxy - mny + 1; console.log(`颜色 #${t.toUpperCase()} (tol=${tol}) 命中 ${n}px`); console.log(` bbox [${mnx},${mny}][${mxx},${mxy}]`); console.log(` 尺寸 w=${w}${vp(w)} h=${h}${vp(h)}`); console.log(' 注:本模式按颜色过滤,不受 bgSpread/非均匀底影响'); } else { console.error(`未知 --mode: ${mode}(可选 colors|runs|ascii|filter)`); process.exit(2); } } function main(): void { const argv: string[] = process.argv.slice(2); const arg = (k: string): string | null => { const i: number = argv.indexOf(k); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; }; if (argv.includes('--probe')) { runProbe(argv, arg); return; } const dir: string | null = arg('--ui-info'); const quiet: boolean = argv.includes('--quiet'); if (!dir) { console.error('用法: node measure_pack.ts --ui-info [--out f.json] [--quiet]'); process.exit(2); } // 按 meta.json 声明的确切路径取截图/view tree(不靠 Glob 猜) let shot: string = path.join(dir, 'screenshot.png'); let vx: string = path.join(dir, 'view.xml'); const metaP: string = path.join(dir, 'meta.json'); let pageId: string = path.basename(dir); if (fs.existsSync(metaP)) { try { const meta = JSON.parse(fs.readFileSync(metaP, 'utf8')) as Record; if (meta.page_id) { pageId = meta.page_id; } const root: string = path.dirname(dir); if (meta.screenshot) { shot = path.resolve(root, meta.screenshot); } if (meta.view_xml) { vx = path.resolve(root, meta.view_xml); } } catch (e) { console.error('meta.json 解析失败,回退到默认文件名: ' + String(e)); } } if (!fs.existsSync(vx)) { console.error('view tree 不存在: ' + vx); process.exit(2); } const nodes: Node[] = parseNodes(fs.readFileSync(vx, 'utf8')); const root: Node = nodes[0]; const den: DensityResult = deriveDensity(nodes); const d: number | null = den.density; const toVp = (v: number): number | null => (d ? Math.round((v / d) * 100) / 100 : null); let im: Png | null = null; let imErr: string = ''; if (fs.existsSync(shot)) { try { im = decodePng(shot); } catch (e) { imErr = String(e); } } else { imErr = '截图不存在: ' + shot; } // 解码失败必须**显式刺眼**,不能只躺在 JSON 字段里:那样的症状是密度算得出、 // 而 ink/edge_colors 全空,看起来像「这页没有可量的东西」,而不是「量测器坏了」。 if (imErr) { console.error('!! 像素量测不可用 —— ' + imErr); console.error('!! 本包仅含 view tree 量测;ink bbox 与取色缺失,勿据此断言实绘尺寸。'); } // 主要区域 = 可交互 / 有文字或描述 / 可滚动 / 已知的控件类。 // // 最后一项不可省:实测 `android.widget.SeekBar` 的 clickable=false、text 与 // content-desc 均为空、scrollable=false —— 只按前三项过滤会把进度条整条漏掉, // 而它恰是本页需要精确量测的区域之一(声明框 42px 高、实绘轨道 26px)。 // 凡「靠交互属性判断重要性」的过滤都有这个盲区,故补一张控件类白名单。 const WIDGET = /\.(SeekBar|ProgressBar|Switch|CheckBox|RadioButton|EditText|ImageView|ImageButton|RatingBar|Spinner|ToggleButton|Button|TextView)$/; const picked: Node[] = nodes.filter((n) => n.clickable || n.scrollable || n.checkable || n.text.length > 0 || n.desc.length > 0 || WIDGET.test(n.cls)); const regions: RegionOut[] = picked.map((n) => { const r: RegionOut = { node: n.idx, role: n.cls.replace(/^android\.(widget|view)\./, ''), boundsPx: `[${n.x1},${n.y1}][${n.x2},${n.y2}]`, wPx: n.w, hPx: n.h, wVp: toVp(n.w), hVp: toVp(n.h), xRatio: Math.round(((n.x1 - root.x1) / root.w) * 1e4) / 1e4, yRatio: Math.round(((n.y1 - root.y1) / root.h) * 1e4) / 1e4, wRatio: Math.round((n.w / root.w) * 1e4) / 1e4, hRatio: Math.round((n.h / root.h) * 1e4) / 1e4, clickable: n.clickable, text: n.text, desc: n.desc, ink: null }; if (im && n.w > 1 && n.h > 1 && n.w <= im.width && n.h <= im.height) { const cx2: number = Math.min(n.x2, im.width); const cy2: number = Math.min(n.y2, im.height); r.ink = inkBox(im, n.x1, n.y1, cx2, cy2); if (r.ink && (r.ink.w !== n.w || r.ink.h !== n.h)) { r.inkNote = '实绘图形小于声明框 —— 声明框可能是语义/触控框,勿直接当绘制尺寸'; } const c = regionColors(im, n.x1, n.y1, cx2, cy2); r.inkColor = c.ink; r.bgColor = c.bg; } return r; }); // 四边取色:用于判断底色/scrim 合成,以及沉浸式下的边缘着色(仅提供数值,不做判断) const edges: Record = {}; if (im) { const mid: number = Math.floor(im.height / 2); const midx: number = Math.floor(im.width / 2); edges.top = px(im, midx, 0); edges.bottom = px(im, midx, im.height - 1); edges.left = px(im, 0, mid); edges.right = px(im, im.width - 1, mid); edges.center = px(im, midx, mid); } const out = { page_id: pageId, generated_by: 'measure_pack.ts', inputs: { view_xml: vx, screenshot: fs.existsSync(shot) ? shot : null, screenshot_error: imErr || null }, image: im ? { width: im.width, height: im.height } : null, density: { value: d, source: '由正方形节点边长反解常见 dp 档得出,下列为独立证据', evidence: den.evidence.slice(0, 8) }, app_content_bounds: { boundsPx: `[${root.x1},${root.y1}][${root.x2},${root.y2}]`, wPx: root.w, hPx: root.h, wVp: toVp(root.w), hVp: toVp(root.h) }, edge_colors: edges, node_count: nodes.length, regions }; const outFile: string = arg('--out') || path.join(dir, 'measure_pack.json'); fs.writeFileSync(outFile, JSON.stringify(out, null, 2), 'utf8'); if (!quiet) { console.log(`measure_pack —— ${pageId}`); console.log(` view tree : ${vx}`); console.log(` screenshot: ${fs.existsSync(shot) ? shot : '(缺失) ' + imErr}`); if (im) { console.log(` image : ${im.width}x${im.height}`); } console.log(` density : ${d === null ? '(无法反解)' : d}`); for (const e of den.evidence.slice(0, 4)) { console.log(` 证据: node ${e.node} 边长 ${e.sidePx}px / ${e.dp}dp = ${e.density}`); } console.log(` app bounds: ${out.app_content_bounds.boundsPx}` + ` = ${out.app_content_bounds.wVp}x${out.app_content_bounds.hVp} vp`); if (im) { console.log(' 边缘取色 : ' + Object.keys(edges) .map((k) => `${k}=(${edges[k].slice(0, 3).join(',')})`).join(' ')); } console.log(` 区域数 : ${regions.length} / 全节点 ${nodes.length}`); // 带文字区域的取色必须进**打印摘要**:父流程贴进 prompt 的是这段摘要, // 只落在 JSON 里等于没省 —— 子 agent 仍会另起一轮去探色。 // 只列有 text/desc 的区域,避免把 37 个区域全刷出来。 const painted: RegionOut[] = regions.filter( (r) => r.inkColor !== undefined && (r.text.length > 0 || r.desc.length > 0)); if (painted.length > 0) { console.log(' 文本取色 : (前景=最深像素, 底=最高频像素; M3 各 on* 角色仅差几十灰阶,勿肉眼判)'); for (const r of painted.slice(0, 14)) { const label: string = (r.text || r.desc).replace(/\s+/g, ' ').slice(0, 30); console.log(` node ${String(r.node).padStart(3)} ${r.inkColor} on ${r.bgColor} ${r.role} "${label}"`); } if (painted.length > 14) { console.log(` ... 另 ${painted.length - 14} 个见 measure_pack.json 的 inkColor/bgColor`); } } console.log(` 已写出 : ${outFile}`); console.log(''); console.log('注意:本包只给**量测值**。沉浸式与否、锚点属固定偏移还是由父尺寸推导、'); console.log('图标 L1/L2/L3 分级,都必须回读 Android 源码判定(Phase 4.2 / 4.3),不在本包范围。'); } } main();