Files
Neo-ZQYY/scripts/ops/measure_gaps.py
2026-03-15 10:15:02 +08:00

294 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 <page_name> [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()