#!/usr/bin/env node /** * scripts/verify-textbox.mjs —— v11b 一、P0-a:text-box 行盒修剪实测。 * * 上一轮写成 `text-box: trim-both cap alphabetic` 简写,Chrome 把 trim-both 丢了: * computed text-box-trim = none、行盒 = 32px(font-size × line-height 1)。 * 本轮全部换成两个独立长属性 `text-box-trim: trim-both; text-box-edge: cap alphabetic;`。 * * 量三档字号 × 加 / 不加 trim 的行盒高度(同一个浏览器、同一字体、同一个元素高度算法: * inline-block 元素 getBoundingClientRect().height,就是它的行盒高度), * 再量页面上真实元素: * 1. `.b`(主数值 / 阶段徽标)——任务书点名的那个类:fontSize / lineHeight / computed text-box-trim / boxHeight * 2. 32px 主数值:`.kpi b`(工作台)、`.kpi b`(报告类型页) * 3. 达标线:32px 档 boxHeight ≤ 26px * * 用法:node scripts/verify-textbox.mjs [--url http://127.0.0.1:4173/] * 退出码:全部达标 0;任何一项不达标 1(并逐条打印失败项)。 */ import { startServer, launch, evalIn, openView, table } from './v11b-lib.mjs'; const argv = process.argv.slice(2); const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d); const srv = arg('--url', '') ? null : await startServer(4196); const base = arg('--url', '') || srv.base; const browser = await launch(); const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 }); const cdp = await page.context().newCDPSession(page); await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); const fails = []; const rec = []; const row = (item, measured, line, pass) => { rec.push({ item, measured, line, pass }); if (!pass) fails.push(item); }; console.log(`\n# 浏览器 ${browser.version()} ${base}\n`); /* ============================================================ * A. 三档字号 × 加 / 不加 trim 的对照实验 * 元素:display:inline-block; white-space:nowrap; line-height:1;文本「1284」 * 不加 trim = 浏览器默认行盒;加 trim = 两条长属性(与页面同写法)。 * ============================================================ */ await page.goto(new URL('/', base).href, { waitUntil: 'networkidle' }); const probe = await page.evaluate(async () => { // 等页面自己的字体(Inter latin subset)生效,三档对照用同一套字体 try { await document.fonts.ready; } catch { /* ignore */ } const host = document.createElement('div'); host.style.cssText = 'position:fixed;left:-9999px;top:0;font-family:var(--kf-font, system-ui);'; document.body.appendChild(host); const size = (css) => { const el = document.createElement('span'); el.textContent = '1284'; el.style.cssText = `display:inline-block;white-space:nowrap;line-height:1;${css}`; host.appendChild(el); const h = el.getBoundingClientRect().height; const cs = getComputedStyle(el); const out = { h, trim: cs.textBoxTrim, edge: cs.textBoxEdge, lh: parseFloat(cs.lineHeight) }; el.remove(); return out; }; const TRIM = 'text-box-trim:trim-both;text-box-edge:cap alphabetic;'; /* 现场「不生效的写法」对照组:display:flex 的值容器 —— text-box 计算值是 trim-both,但行盒量起来还是 32px */ const sizeFlex = (css) => { const el = document.createElement('span'); el.style.cssText = `display:flex;font-size:32px;line-height:1;${css}`; el.innerHTML = '1284'; host.appendChild(el); const h = el.getBoundingClientRect().height; const cs = getComputedStyle(el); const out = { h, trim: cs.textBoxTrim, edge: cs.textBoxEdge, lh: parseFloat(cs.lineHeight) }; el.remove(); return out; }; const sizes = [12, 20, 32]; const res = {}; for (const px of sizes) { const fs = `font-size:${px}px;`; res[px] = { raw: size(fs), trim: size(fs + TRIM), shorthand: size(fs + 'text-box:trim-both cap alphabetic;') }; } res.flex = sizeFlex(TRIM); host.remove(); return { v: (navigator.userAgent.match(/Chrome\/[\d.]+/) || [''])[0], sizes, res, supports: CSS.supports('text-box-trim', 'trim-both') && CSS.supports('text-box-edge', 'cap alphabetic') }; }); /* ============================================================ * 2. 页面真实元素:.b / 32px 主数值 * ============================================================ */ // 工作台:预置「我是谁」让「我的推进」也渲染出来;.b 与 .kpi b 都量 await openView(page, base, '/', { me: '赵磊', wait: 1000 }); const wb = await evalIn(page, ` const pick = (sel, n = 6) => { const out = []; deepAll((el) => { if (out.length < n && el.matches?.(sel) && visible(el)) out.push(el); }); return out.map((el) => { const cs = getComputedStyle(el); const r = el.getBoundingClientRect(); return { text: el.textContent.trim().slice(0, 12), font: parseFloat(cs.fontSize), lh: parseFloat(cs.lineHeight), trim: cs.textBoxTrim, edge: cs.textBoxEdge, h: Math.round(r.height * 100) / 100 }; }); }; return { b: pick('.b'), kpi: pick('.kpi b', 4) }; `); // 报告类型页也有一排 32px KPI await openView(page, base, '/reports/voc', { wait: 900 }); const typeKpi = await evalIn(page, ` const out = []; deepAll((el) => { if (out.length < 4 && el.matches?.('.kpi b') && visible(el)) { const cs = getComputedStyle(el); const r = el.getBoundingClientRect(); out.push({ text: el.textContent.trim().slice(0, 12), font: parseFloat(cs.fontSize), lh: parseFloat(cs.lineHeight), trim: cs.textBoxTrim, edge: cs.textBoxEdge, h: Math.round(r.height * 100) / 100 }); } }); return out; `); /* ============================================================ * 输出:对照表 * ============================================================ */ console.log('## 三档字号:加 / 不加 trim 的行盒高度(inline-block,line-height 1,文本「1284」)\n'); const ctable = probe.sizes.map((px) => { const r = probe.res[px]; const pct = (a, b) => `${(((b - a) / a) * 100).toFixed(1)}%`; return { size: `${px}px`, raw: `${r.raw.h.toFixed(2)}px`, trim: `${r.trim.h.toFixed(2)}px (${r.trim.trim})`, delta: pct(r.raw.h, r.trim.h), css: `${(r.raw.h / px).toFixed(2)}em → ${(r.trim.h / px).toFixed(2)}em`, sh: `${r.shorthand.h.toFixed(2)}px`, }; }); table(ctable, [['字号', 'size'], ['不加 trim', 'raw'], ['加独立属性', 'trim'], ['变化', 'delta'], ['行盒 / 字号', 'css'], ['对照:简写写法', 'sh']]); const fx = probe.res.flex; console.log(`\n浏览器(${probe.v})支持独立长属性:${probe.supports ? '是' : '否'};` + `简写 text-box: trim-both cap alphabetic 在本浏览器 computed 是 text-box-trim=${probe.res[32].shorthand.trim}(上一轮实测它被丢弃 → 行盒 32px)。`); console.log(`对照组:32px + trim-both 但容器 display:flex → computed text-box-trim=${fx.trim},行盒实测 ${fx.h.toFixed(2)}px(修剪对 flex 容器不生效;本轮把值容器改成 block + height:max-content 后才有上面 32px→23.28px 的效果)。`); console.log('\n## 页面真实元素\n'); const fmtEl = (e) => `font ${e.font}px / line-height ${e.lh}px / text-box-trim ${e.trim} / text-box-edge ${e.edge} / 行盒 ${e.h}px`; for (const e of wb.kpi) row(`工作台 32px KPI「${e.text}」`, `${e.h}px`, fmtEl(e), e.font === 32 && e.trim === 'trim-both' && e.h <= 26); for (const e of typeKpi) row(`报告类型页 32px KPI「${e.text}」`, `${e.h}px`, fmtEl(e), e.font === 32 && e.trim === 'trim-both' && e.h <= 26); for (const e of wb.b) { const big = e.font >= 28; const pass = e.trim === 'trim-both' && e.edge === 'cap alphabetic' && (!big || e.h <= 26); row(`.b「${e.text}」${big ? '(主数值)' : '(徽标)'}`, `${e.h}px`, fmtEl(e), pass); } /* ============================================================ * 判分 * ============================================================ */ console.log('\n## 达标判定(32px 档:trim 后行盒 ≤ 26px)\n'); table(rec.map((r) => ({ item: r.item, m: r.measured, ok: r.pass ? '✓' : '✗' })), [['项', 'item'], ['实测行盒', 'm'], ['通过', 'ok']]); // 对照实验本身也要落进判定:32px 加 trim 必须 ≤ 26,且明显低于不加 trim const p32 = probe.res[32]; const probeOk = p32.trim.h <= 26 && p32.trim.h < p32.raw.h * 0.75; console.log(`\n对照实验 32px:不加 trim ${p32.raw.h.toFixed(2)}px → 加 trim ${p32.trim.h.toFixed(2)}px(达标线 ≤ 26px)${probeOk ? ' ✓' : ' ✗'}`); await browser.close(); srv?.kill(); const bad = fails.length + (probeOk ? 0 : 1); if (bad) { console.log(`\n失败项 ${bad} 个:`); for (const f of fails) console.log(` ✗ ${f}`); if (!probeOk) console.log(' ✗ 对照实验 32px 行盒未压到 ≤ 26px'); process.exit(1); } console.log('\n全部达标(exit 0)');