1
This commit is contained in:
293
scripts/ops/measure_gaps.py
Normal file
293
scripts/ops/measure_gaps.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user