""" measure_gaps.py - 通用元素间距测量工具 功能: 1. 对任意 H5 页面任意 CSS 选择器,通过 getBoundingClientRect 测量 元素位置、大小、computed style,输出 px 和 rpx 换算表 2. 对一组选择器对,计算元素间的边界间距(bottom-to-top 或 right-to-left) 3. 支持 scrollTop 偏移(当元素在页面中下方时先滚动到目标屏) 4. 输出 JSON + 终端表格,可直接填入 audit.md 用法: uv run python scripts/ops/measure_gaps.py [options] 示例: # 测量 task-list 页面中所有 .task-card 元素的间距 uv run python scripts/ops/measure_gaps.py task-list --selectors ".task-card" # 测量 board-finance 页面应将 inner 到 section 的 padding uv run python scripts/ops/measure_gaps.py board-finance --selectors "#section-overview" ".summary-content" # 测量并输出比较表格(用于 audit.md F 项) uv run python scripts/ops/measure_gaps.py task-list --pairs ".filter-bar" ".task-card:first-child" """ import argparse import asyncio import io import json import math import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] H5_PAGES = ROOT / "docs" / "h5_ui" / "pages" OUT_DIR = ROOT / "docs" / "h5_ui" / "measure" OUT_DIR.mkdir(parents=True, exist_ok=True) VIEWPORT_W = 430 VIEWPORT_H = 752 DPR = 1.5 RPX_FACTOR = 750 / VIEWPORT_W # = 1.7442... H5_BASE = "file:///" + str(ROOT).replace("\\", "/") + "/docs/h5_ui/pages" def px_to_rpx(px: float) -> int: """H5 px -> 小程序 rpx,取偶数""" raw = px * RPX_FACTOR return int(math.ceil(raw / 2) * 2) def px_val(s: str) -> float: """'16px' -> 16.0""" s = s.strip() if s.endswith("px"): return float(s[:-2]) if s in ("", "none", "normal", "auto"): return 0.0 try: return float(s) except ValueError: return 0.0 MEASURE_JS = """ (selectors, scrollTop) => { // scroll to position first window.scrollTo(0, scrollTop || 0); const results = []; for (const sel of selectors) { const els = document.querySelectorAll(sel); els.forEach((el, idx) => { const r = el.getBoundingClientRect(); const cs = window.getComputedStyle(el); results.push({ selector: sel, index: idx, // position relative to viewport top: r.top, bottom: r.bottom, left: r.left, right: r.right, width: r.width, height: r.height, // absolute position on page pageTop: r.top + (scrollTop || 0), pageBottom: r.bottom + (scrollTop || 0), // computed style key spacing props paddingTop: cs.paddingTop, paddingBottom: cs.paddingBottom, paddingLeft: cs.paddingLeft, paddingRight: cs.paddingRight, marginTop: cs.marginTop, marginBottom: cs.marginBottom, marginLeft: cs.marginLeft, marginRight: cs.marginRight, gap: cs.gap, rowGap: cs.rowGap, columnGap: cs.columnGap, fontSize: cs.fontSize, lineHeight: cs.lineHeight, fontWeight: cs.fontWeight, borderTopWidth: cs.borderTopWidth, borderBottomWidth: cs.borderBottomWidth, borderRadius: cs.borderRadius, text: (el.textContent || '').trim().substring(0, 40), tagName: el.tagName, className: (el.className || '').substring(0, 100), }); }); } return results; } """ async def measure_page(page_name: str, selectors: list[str], scroll_top: int = 0, pair_selectors: list[str] | None = None) -> dict: try: from playwright.async_api import async_playwright except ImportError: print("ERROR: playwright not installed. Run: uv add playwright && playwright install chromium") sys.exit(1) url = f"{H5_BASE}/{page_name}.html" results = {} async with async_playwright() as pw: browser = await pw.chromium.launch(headless=True) ctx = await browser.new_context( viewport={"width": VIEWPORT_W, "height": VIEWPORT_H}, device_scale_factor=DPR, ) page = await ctx.new_page() await page.goto(url, wait_until="load", timeout=20000) await page.wait_for_timeout(3000) # wait for Tailwind JIT items = await page.evaluate(MEASURE_JS, selectors, scroll_top) results["items"] = items # compute gaps between consecutive items (pageBottom[i] -> pageTop[i+1]) if len(items) > 1: gaps = [] for i in range(len(items) - 1): a, b = items[i], items[i + 1] gap_px = b["pageTop"] - a["pageBottom"] gaps.append({ "from": f"{a['selector']}[{a['index']}]", "to": f"{b['selector']}[{b['index']}]", "gap_px": round(gap_px, 2), "gap_rpx": px_to_rpx(gap_px), "from_bottom_px": round(a["pageBottom"], 2), "to_top_px": round(b["pageTop"], 2), }) results["consecutive_gaps"] = gaps # compute pair gaps if specified if pair_selectors and len(pair_selectors) >= 2: pair_items = await page.evaluate(MEASURE_JS, pair_selectors, scroll_top) by_sel = {} for it in pair_items: by_sel.setdefault(it["selector"], []).append(it) pair_a = by_sel.get(pair_selectors[0], [{}])[0] pair_b = by_sel.get(pair_selectors[1], [{}])[0] if pair_a and pair_b: gap_px = pair_b.get("pageTop", 0) - pair_a.get("pageBottom", 0) results["pair_gap"] = { "a": pair_selectors[0], "b": pair_selectors[1], "gap_px": round(gap_px, 2), "gap_rpx": px_to_rpx(gap_px), } await browser.close() return results def print_table(items: list[dict]): print(f"\n{'selector':<35} {'idx':>3} {'top_px':>8} {'h_px':>8} {'pt':>8} {'pb':>8} {'mt':>8} {'mb':>8} {'gap':>8} {'fs':>8} {'lh':>8} {'h_rpx':>8}") print("-" * 130) for it in items: print( f"{it['selector']:<35} {it['index']:>3} " f"{it['pageTop']:>8.1f} {it['height']:>8.1f} " f"{px_val(it['paddingTop']):>8.1f} {px_val(it['paddingBottom']):>8.1f} " f"{px_val(it['marginTop']):>8.1f} {px_val(it['marginBottom']):>8.1f} " f"{px_val(it.get('gap','0px')):>8.1f} " f"{px_val(it['fontSize']):>8.1f} " f"{px_val(it['lineHeight']) if it['lineHeight'] not in ('normal','') else 0:>8.1f} " f"{px_to_rpx(it['height']):>8}" ) def print_gaps(gaps: list[dict]): if not gaps: return print(f"\n{'from':<40} {'to':<40} {'gap_px':>8} {'gap_rpx':>8}") print("-" * 100) for g in gaps: print(f"{g['from']:<40} {g['to']:<40} {g['gap_px']:>8.1f} {g['gap_rpx']:>8}") def spacing_audit_table(items: list[dict]) -> str: """Generate markdown table for audit.md spacing section""" lines = [ "| 元素选择器 | 页面Top(px) | 高度(px) | 高度(rpx) | paddingT | paddingB | marginT | marginB | gap | fontSize | lineHeight |", "|---|---|---|---|---|---|---|---|---|---|---|", ] for it in items: h_rpx = px_to_rpx(it['height']) lh = px_val(it['lineHeight']) if it['lineHeight'] not in ('normal', '') else 0 lines.append( f"| {it['selector']}[{it['index']}] " f"| {it['pageTop']:.1f} | {it['height']:.1f} | {h_rpx} " f"| {it['paddingTop']} | {it['paddingBottom']} " f"| {it['marginTop']} | {it['marginBottom']} " f"| {it.get('gap','0px')} | {it['fontSize']} | {it['lineHeight']} |" ) return "\n".join(lines) def main(): if sys.platform == 'win32': sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') parser = argparse.ArgumentParser(description='测量 H5 页面元素间距,输出 px 和 rpx') parser.add_argument('page', help='页面名,如 task-list') parser.add_argument('--selectors', nargs='+', default=[], help='CSS 选择器列表') parser.add_argument('--scroll', type=int, default=0, help='scrollTop 位置(默认 0)') parser.add_argument('--pairs', nargs=2, metavar=('A', 'B'), help='计算两个选择器间的间距') parser.add_argument('--out', help='输出 JSON 路径(默认自动命名)') args = parser.parse_args() if not args.selectors and not args.pairs: print('请指定 --selectors 或 --pairs') parser.print_help() sys.exit(1) selectors = args.selectors or list(args.pairs) results = asyncio.run(measure_page(args.page, selectors, args.scroll, args.pairs)) # save JSON out_path = Path(args.out) if args.out else OUT_DIR / f"{args.page}-gaps.json" with open(out_path, 'w', encoding='utf-8') as f: json.dump(results, f, ensure_ascii=False, indent=2) # print table items = results.get('items', []) if items: print(f'\n页面: {args.page} scrollTop={args.scroll} 元素数: {len(items)}') print_table(items) # spacing props summary print('\n关键间距汇总(px → rpx):') for it in items: pt = px_val(it['paddingTop']) pb = px_val(it['paddingBottom']) mt = px_val(it['marginTop']) mb = px_val(it['marginBottom']) g = px_val(it.get('gap', '0px')) fs = px_val(it['fontSize']) lh_raw = it['lineHeight'] lh = px_val(lh_raw) if lh_raw not in ('normal', '') else 0 parts = [] if pt: parts.append(f'paddingTop={pt:.1f}px→{px_to_rpx(pt)}rpx') if pb: parts.append(f'paddingBot={pb:.1f}px→{px_to_rpx(pb)}rpx') if mt: parts.append(f'marginTop={mt:.1f}px→{px_to_rpx(mt)}rpx') if mb: parts.append(f'marginBot={mb:.1f}px→{px_to_rpx(mb)}rpx') if g: parts.append(f'gap={g:.1f}px→{px_to_rpx(g)}rpx') if fs: parts.append(f'fontSize={fs:.1f}px→{px_to_rpx(fs)}rpx') if lh: parts.append(f'lineHeight={lh:.1f}px→{px_to_rpx(lh)}rpx') if parts: print(f' {it["selector"]}[{it["index"]}]: {" ".join(parts)}') # consecutive gaps gaps = results.get('consecutive_gaps', []) if gaps: print('\n\u76f8邻元素垂直间距:') print_gaps(gaps) # pair gap pg = results.get('pair_gap') if pg: print(f'\n指定对间距: {pg["a"]} → {pg["b"]}: {pg["gap_px"]:.1f}px = {pg["gap_rpx"]}rpx') # markdown table print('\n--- audit.md 间距表格 ---') print(spacing_audit_table(items)) print(f'\n详细数据已保存: {out_path}') if __name__ == '__main__': main()