字体实验室的右侧是一排轴拖动字重从 100 滑到 900标题加粗、正文也跟着变。一切正常。麻烦的是宽度轴。把字宽从 100 拉到 75字变窄了可整段文本的行数从 4 行变成了 3 行卡片高度跟着缩了一截下面的元素全部往上跳。再拉回 100又跳回去。调字体调成了一场地震。排版工具不该有这种反应。宽度是设计者要观察的对象布局不该跟着它变。把字体的度量拉出来单独看可变字体的轴决定字形字形决定每个字占多宽、整体多高。排版要稳定就得把字形和布局这两件事切开。先要把当前轴值下的度量算清楚exportinterfaceFontAxis{tag:string// wght 字重、wdth 字宽、slnt 倾斜unit:string// 单位通常是百分比min:number// 轴的下限max:number// 轴的上限default:number// 字体作者的推荐值current:number// 设计者当前拖到的位置}exportinterfaceFontMetrics{ascent:number// 基线以上高度em 为单位descent:number// 基线以下高度lineGap:number// 行间额外间隙averageAdvanceRatio:number// 平均字宽相对标称值的比例}exportfunctiondefaultMetrics(metrics:FontMetrics,axis:FontAxis):FontMetrics{// 布局永远用 default 轴下的度量与 current 无关return{ascent:metrics.ascent,descent:metrics.descent,lineGap:metrics.lineGap,averageAdvanceRatio:1}}FontAxis的六个字段照字体文件fvar表的结构来各管一件事认下来后面少踩坑字段含义取值示例tag轴的四字母标识‘wght’、‘wdth’、‘slnt’unit轴的单位百分比、角度min / max作者声明的合法范围75 ~ 125default作者推荐值布局度量以它为准100current设计者拖到的位置只影响字形87.5defaultMetrics这个函数看着什么也没做它的意义在于把约定写进代码布局读的是default轴下的度量跟current没关系。如果没有这个函数界面层很容易顺手用当前值去算高度跳动的根子就埋下了。轴值钳制轴值进到界面之前还要过一道钳制。快速拖动滑块时事件会合并偶发的 NaN 和越界值都从这条路上来不拦的话字形渲染和宽度指示线都会读到脏数据exportfunctionclampAxisValue(axis:FontAxis,value:number):number{if(Number.isNaN(value)){returnaxis.default// 脏数据回落到作者推荐值}returnMath.min(Math.max(value,axis.min),axis.max)}钳制只管current的取值布局度量走default那条路两条路互不干扰。NaN 落到界面上最多让滑块抖一下不会顺着度量算到卡片高度里去。行高必须锁死行高是最容易跳动的一处。第一版直接用ascent descent lineGap乘以字号字重从 400 推到 700 时某些字体的 ascent 会增加大约 3%行高跟着长整块文本往下膨胀。改成从字体默认轴读出度量后固定下来exportfunctionlineHeightOf(metrics:FontMetrics,fontSizePx:number):number{// 度量取自默认轴因此在调整轴值时保持恒定constraw:number(metrics.ascentmetrics.descentmetrics.lineGap)*fontSizePxreturnMath.round(raw)}四舍五入不能省。行高如果是 27.4 像素三行就是 82.2五行就是 137连续排布时会出现某两行间距比别处多一个像素。中文字体的行数一多这些零头会累积成肉眼可见的疏密不均。行数为什么不能跟着轴值变行数变化是跳动里最刺眼的一种。设计者把字宽调窄每行能容纳的字多了断行位置全变文本框高度直接换档。做法是断行用固定的标称宽度不看当前字宽exportinterfaceLineBox{text:stringadvancePx:number// 这一行在当前轴值下的实际宽度}constNOMINAL_ADVANCE_RATIO:number1exportfunctionbreakLines(text:string,fontSizePx:number,maxWidthPx:number,metrics:FontMetrics):LineBox[]{constlines:LineBox[][]letcursor:string// 逐字累加用标称宽度判断是否超行与当前字宽轴无关for(constchoftext){constnominalWidth:numberfontSizePx*NOMINAL_ADVANCE_RATIOconsttestWidth:number(cursor.length1)*nominalWidthif(testWidthmaxWidthPxcursor.length0){lines.push({text:cursor,advancePx:cursor.length*fontSizePx*metrics.averageAdvanceRatio})cursorch}else{cursorch}}if(cursor.length0){lines.push({text:cursor,advancePx:cursor.length*fontSizePx*metrics.averageAdvanceRatio})}returnlines}断行的判据用标称宽度返回的advancePx才用当前轴下的真实宽度。这样调窄字宽时每行装的字不变行数不变变化只体现在每一行实际占的横向空间上——这正是设计者想看到的。advancePx用来画一条宽度指示线让设计者知道调窄之后还剩多少余量。宽度要留预算不能正好卡住即使断行锁死还有个溢出问题。字宽轴拉到上限时同样的行内文字实际宽度会比标称宽出一截。布局空间按预留倍数给不按实际宽度给constWIDTH_BUDGET_RATIO:number1.18exportfunctionbudgetWidth(nominalWidthPx:number):number{returnMath.ceil(nominalWidthPx*WIDTH_BUDGET_RATIO)}1.18 是量出来的。把字宽轴从 75 推到 125 的整个范围内同一段文本的实际宽度相对标称的偏差最大到 16% 左右留 18% 有少量余量。这个数字与具体字体有关换字体要重新量一次所以它在代码里是一个常量而不是写死的算式。留预算会让文本块整体偏宽一点视觉上左侧对齐、右侧留白略多。用固定留白换取调轴时不跳动这笔账划算。字形覆盖要单独统计界面下方那两行中文字符 27,586和拉丁字符 1,204跟轴值没有关系它统计的是当前字体文件里有多少个字符有字形exportinterfaceCoverageReport{chinese:numberlatin:numbertotal:numbermissingSamples:string[]}exportfunctionanalyzeCoverage(fontChars:string[],sampleText:string):CoverageReport{constcharset:SetstringnewSet(fontChars)constmissing:string[][]for(constchofsampleText){if(ch\n||ch ){continue}if(!charset.has(ch)!missing.includes(ch)){missing.push(ch)}}letchinese:number0letlatin:number0charset.forEach((ch:string){constcode:numberch.charCodeAt(0)if(code0x4e00code0x9fff){chinese1}elseif(code0code128){latin1}})return{chinese,latin,total:charset.size,missingSamples:missing.slice(0,20)}}missingSamples只留前 20 个。缺字列表在字体不完整的情况下可能有几百条全铺在界面上没有意义给几个样本让设计者判断是整体缺失还是个别符号就够了。字形总数会随轴值变化。斜体轴打开时字体可能切换到另一组字形覆盖数会变。所以这个统计在轴值改变后要重新跑不能只在加载时算一次。拖到极端轴值这几处会翻车轴值推到两端或者快速拖动时翻车集中在四个地方逐个说下现象和处理字宽顶到 125单行实际宽度超出 18% 的预算右侧留白被吃光。宽度指示线改成按剩余预算上色超预算的部分画成红色虚线设计者一眼看得出再宽就要溢出。滑块跳跃事件合并让 current 从 100 直接跳到 118。断行用标称宽度行数不动但advancePx要按新值重算晚一帧刷新就能看到指示线抖一下所以它挂在每帧开头现算不缓存。字体文件缺字形missingSamples给出前 20 个样本界面据此提示部分字符将回退系统字体同时把回退字体的度量算出来做对比免得回退之后行高又变。slnt 轴打开部分字体会切换到另一组字形覆盖数和平均字宽比都会变之前缓存的度量直接作废轴值改变后整套统计重跑一遍。这四种情况有个共同点动的都是看的部分量的部分纹丝不动。布局该多高加载那会儿就按 default 轴定死了设计者再怎么拖卡片不会跳。目录结构VariableTypeLab/ ├─ entry/src/main/ets/ │ ├─ pages/Index.ets # 轴控制、预设与度量面板 │ ├─ features/ │ │ ├─ AxisModel.ets # 轴定义与取值范围 │ │ ├─ MetricsResolver.ets # 默认轴度量与行高 │ │ ├─ LineBreaker.ets # 与轴无关的断行 │ │ └─ CoverageAnalyzer.ets # 字形覆盖统计 │ ├─ entryability/EntryAbility.ets │ └─ adapters/ │ └─ FontLoaderAdapter.ets # 字体文件加载与轴读取 ├─ entry/src/main/resources/ └─ build-profile.json5MetricsResolver里集中了所有与布局稳定性有关的常量行高取整、宽度预算、标称宽度比例。设计系统同步时这一组值跟着走字体文件换了只要重新测一遍预算比例。轴值本身不进持久化存储。它属于设计过程中的临时状态每次打开实验室都从字体作者的默认值开始避免上次调到一个极端值下次进来满屏都是溢出的文本。