""" 测量 board-finance H5 原型「经营一览」板块内所有关键元素的 实际渲染位置(getBoundingClientRect),计算相邻元素间的真实垂直间距。 配置与 screenshot_h5_pages.py 一致:iPhone 15 Pro Max (430×932, DPR:3) """ import asyncio import json from pathlib import Path from playwright.async_api import async_playwright BASE_URL = "http://127.0.0.1:5500/docs/h5_ui/pages" OUT_DIR = Path(__file__).resolve().parents[2] / "export" / "h5_style_extract" OUT_DIR.mkdir(parents=True, exist_ok=True) # 用 getBoundingClientRect 测量每个关键元素的精确位置 MEASURE_JS = """ () => { const section = document.getElementById('section-overview'); if (!section) return { error: 'not found' }; const sRect = section.getBoundingClientRect(); function measure(el, label) { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return { label, top: Math.round(r.top * 100) / 100, bottom: Math.round(r.bottom * 100) / 100, height: Math.round(r.height * 100) / 100, left: Math.round(r.left * 100) / 100, right: Math.round(r.right * 100) / 100, width: Math.round(r.width * 100) / 100, // 相对于板块顶部的偏移 relTop: Math.round((r.top - sRect.top) * 100) / 100, relBottom: Math.round((r.bottom - sRect.top) * 100) / 100, fontSize: cs.fontSize, lineHeight: cs.lineHeight, paddingTop: cs.paddingTop, paddingBottom: cs.paddingBottom, text: el.textContent?.trim()?.substring(0, 30) || '', }; } const items = []; // 板块根 items.push(measure(section, 'SECTION_ROOT')); // 板块头 const header = section.querySelector('.summary-header'); if (header) { items.push(measure(header, 'HEADER_BAR')); const h3 = header.querySelector('h3'); if (h3) items.push(measure(h3, 'HEADER_TITLE')); const p = header.querySelector('p'); if (p) items.push(measure(p, 'HEADER_DESC')); const emoji = header.querySelector('span'); if (emoji) items.push(measure(emoji, 'HEADER_EMOJI')); } // 板块正文 const content = section.querySelector('.summary-content'); if (content) { items.push(measure(content, 'CONTENT_BODY')); } // 收入概览子标题行 const subRows = content?.querySelectorAll(':scope > div > .flex.items-center.gap-2'); if (subRows) { subRows.forEach((el, i) => { items.push(measure(el, `SUB_LABEL_${i}`)); el.querySelectorAll('span').forEach((s, j) => { items.push(measure(s, `SUB_LABEL_${i}_SPAN_${j}`)); }); }); } // 3列网格 const grid3 = section.querySelector('.grid.grid-cols-3'); if (grid3) { items.push(measure(grid3, 'GRID_3COL')); grid3.querySelectorAll(':scope > div').forEach((cell, i) => { items.push(measure(cell, `GRID3_CELL_${i}`)); // 标签文字 const labelP = cell.querySelector('p'); if (labelP) items.push(measure(labelP, `GRID3_CELL_${i}_LABEL`)); // 数值文字(第二个 p) const allP = cell.querySelectorAll('p'); if (allP[1]) items.push(measure(allP[1], `GRID3_CELL_${i}_VALUE`)); // 环比 const compare = cell.querySelector('.compare-data'); if (compare) items.push(measure(compare, `GRID3_CELL_${i}_COMPARE`)); }); } // 确认收入行(bg-white/10 rounded-xl 内的第一个) const confirmedRow = content?.querySelector('.rounded-xl'); if (confirmedRow) { items.push(measure(confirmedRow, 'CONFIRMED_ROW')); confirmedRow.querySelectorAll('p').forEach((p, i) => { items.push(measure(p, `CONFIRMED_P_${i}`)); }); } // 分割线 const divider = content?.querySelector('.border-t'); if (divider) { items.push(measure(divider, 'DIVIDER')); } // 现金流水子标题行 // 它是 divider 后面的 div 内的 flex 行 const cashSection = divider?.nextElementSibling; if (cashSection) { const cashSubLabel = cashSection.querySelector('.flex.items-center.gap-2'); if (cashSubLabel) { items.push(measure(cashSubLabel, 'CASH_SUB_LABEL')); } } // 2列网格 const grid2 = section.querySelector('.grid.grid-cols-2'); if (grid2) { items.push(measure(grid2, 'GRID_2COL')); grid2.querySelectorAll(':scope > div').forEach((cell, i) => { items.push(measure(cell, `GRID2_CELL_${i}`)); const allP = cell.querySelectorAll('p'); if (allP[0]) items.push(measure(allP[0], `GRID2_CELL_${i}_LABEL`)); if (allP[1]) items.push(measure(allP[1], `GRID2_CELL_${i}_VALUE`)); const compare = cell.querySelector('.compare-data'); if (compare) items.push(measure(compare, `GRID2_CELL_${i}_COMPARE`)); }); } // AI 洞察 const aiSection = section.querySelector('.ai-insight-section'); if (aiSection) { items.push(measure(aiSection, 'AI_SECTION')); const aiHeader = aiSection.querySelector('.ai-insight-header'); if (aiHeader) items.push(measure(aiHeader, 'AI_HEADER')); const aiTitle = aiSection.querySelector('.ai-insight-title'); if (aiTitle) items.push(measure(aiTitle, 'AI_TITLE')); aiSection.querySelectorAll('p').forEach((p, i) => { items.push(measure(p, `AI_LINE_${i}`)); }); } return items; } """ async def main(): print("=" * 70) print("H5 垂直间距精确测量 — getBoundingClientRect") print("iPhone 15 Pro Max (430×932, DPR:3)") print("=" * 70) async with async_playwright() as p: browser = await p.chromium.launch( headless=False, args=["--hide-scrollbars"], ) context = await browser.new_context( viewport={"width": 430, "height": 932}, device_scale_factor=3, ) page = await context.new_page() dpr = await page.evaluate("() => window.devicePixelRatio") assert dpr == 3 url = f"{BASE_URL}/board-finance.html" await page.goto(url, wait_until="load", timeout=15000) await page.wait_for_timeout(2500) items = await page.evaluate(MEASURE_JS) # 保存原始数据 out_path = OUT_DIR / "board-finance-vertical-gaps.json" with open(out_path, "w", encoding="utf-8") as f: json.dump(items, f, ensure_ascii=False, indent=2) # 计算相邻元素间的真实垂直间距 print(f"\n共测量 {len(items)} 个元素\n") # 按 relTop 排序,打印每个元素的位置 sorted_items = sorted(items, key=lambda x: x['relTop']) print(f"{'元素':<30} {'relTop':>8} {'relBot':>8} {'height':>8} {'fontSize':>10} {'lineH':>10}") print("-" * 100) for it in sorted_items: print(f"{it['label']:<30} {it['relTop']:>8.1f} {it['relBottom']:>8.1f} {it['height']:>8.1f} {it['fontSize']:>10} {it['lineHeight']:>10}") # 计算关键间距对 print("\n" + "=" * 70) print("关键垂直间距(相邻元素 bottom→top 的实际 px 距离)") print("公式:gap_rpx = gap_px × 2 × 0.875") print("=" * 70) gap_pairs = [ ("HEADER_BAR", "CONTENT_BODY", "头部栏底 → 正文顶"), ("HEADER_TITLE", "HEADER_DESC", "标题底 → 副标题顶"), ("SUB_LABEL_0", "GRID_3COL", "收入概览标签底 → 3列网格顶"), ("GRID3_CELL_0_LABEL", "GRID3_CELL_0_VALUE", "标签底 → 数值顶(cell0)"), ("GRID3_CELL_0_VALUE", "GRID3_CELL_0_COMPARE", "数值底 → 环比顶(cell0)"), ("GRID_3COL", "CONFIRMED_ROW", "3列网格底 → 确认收入行顶"), ("CONFIRMED_ROW", "DIVIDER", "确认收入行底 → 分割线顶"), ("DIVIDER", "CASH_SUB_LABEL", "分割线底 → 现金流水标签顶"), ("CASH_SUB_LABEL", "GRID_2COL", "现金流水标签底 → 2列网格顶"), ("GRID2_CELL_0_LABEL", "GRID2_CELL_0_VALUE", "标签底 → 数值顶(2col cell0)"), ("GRID2_CELL_0_VALUE", "GRID2_CELL_0_COMPARE", "数值底 → 环比顶(2col cell0)"), ("GRID_2COL", "AI_SECTION", "2列网格底 → AI洞察顶"), ("AI_HEADER", "AI_LINE_0", "AI标题底 → 第一行文字顶"), ("AI_LINE_0", "AI_LINE_1", "AI第1行底 → 第2行顶"), ] item_map = {it['label']: it for it in items} for from_label, to_label, desc in gap_pairs: fr = item_map.get(from_label) to = item_map.get(to_label) if fr and to: gap_px = to['relTop'] - fr['relBottom'] gap_rpx = round(gap_px * 2 * 0.875, 1) print(f" {desc}") print(f" {from_label}.bottom={fr['relBottom']:.1f} → {to_label}.top={to['relTop']:.1f}") print(f" 间距: {gap_px:.1f}px → {gap_rpx:.1f}rpx") print() else: missing = from_label if not fr else to_label print(f" {desc}: ⚠️ 未找到 {missing}") print() await browser.close() print(f"原始数据: {out_path}") if __name__ == "__main__": asyncio.run(main())