@typeset/typeset
@typeset/typeset 是 CJK paragraph composition 的 core engine。它负责把 source text 转换为结构化排版事实,并提供两种执行路径:带 measurement 和 available width 的完整 paragraph composition,以及不依赖宽度的 Static Flow 编译。它不依赖 DOM、Canvas、Figma 或其他 renderer。
目标架构与功能归属见 ARCHITECTURE.md。
当前 pipeline
调用方必须显式完成共享分析阶段,再选择一种执行路径。
完整的 Measured Composition:
const story = createStory(input);
const layoutResults = story.paragraphs.map((paragraph) => {
const graphemes = segmentGraphemes(paragraph.text, {
sourceOffset: paragraph.start,
});
const resolvedFont = resolveCompositeFont({
graphemes,
characterClassProfile,
compositeFontProfile,
});
const tokens = tokenizeGraphemes(graphemes);
const measurements = measureTokens(tokens, measureToken, {
fontRuns: resolvedFont.runs,
});
const boundaries = resolveInlineBoundaries({
tokens,
paragraphRange: paragraph,
});
const spacingOpportunities = resolveInlineSpacingOpportunities({
tokens,
boundaries,
characterClassProfile,
mojikumiProfile,
});
const solvedParagraph = solveParagraph({
measurements,
boundaries,
spacingOpportunities,
characterClassProfile,
lineKeepProfile,
mojikumiProfile,
lineEndHangingProfile,
availableWidth,
alignment: "justify",
lastLinePolicy: {
effectiveUnitCount: { mode: "enabled", minimum: 2 },
fillRatio: { mode: "enabled", minimum: minimumFillRatio },
},
});
return createLayoutResult({
paragraph,
measurements,
solvedParagraph,
});
});
不需要 measurement 和 available width 的 Static Flow:
const staticFlow = compileStaticFlow({
paragraph,
tokens,
fontRuns: resolvedFont.runs,
boundaries,
spacingOpportunities,
characterClassProfile,
lineKeepProfile,
mojikumiProfile,
lastLinePolicy: {
effectiveUnitCount: { mode: "enabled", minimum: 2 },
fillRatio: { mode: "disabled" },
},
});
core package 暂不提供隐藏共享分析阶段的 convenience entry。调用方必须明确生成 tokens、boundaries 和 spacing opportunities;只有 Measured Composition 额外要求 measurement 与 available width。
模块
story/
把 source text 分成 paragraphs,并保留 UTF-16 source ranges。hard paragraph break 不会进入 paragraph tokens。
grapheme/
使用 Intl.Segmenter 生成 Unicode grapheme clusters。每个 Grapheme 保存 text、paragraph-local index 和 story-level source range。
character/
定义可复用的 character classes 和匹配函数。Kinsoku 与 Mojikumi profiles 引用这里的 class ids,但各自保留独立的规则语义。
composite-font/
按有序 character-class rules 为 graphemes 选择字体意图。CompositeFontProfile 包含一个默认 CompositeFontStack 和零至多条覆盖规则;规则按数组顺序 first-match,未命中时回退到默认配置。每个 stack 由有序 CompositeFontCandidate[] 构成,每个候选分别保存 family 与 style;stack 还可以设置 relativeSize 和 baselineShift。Core 保留候选顺序,但不判断字体可用性、glyph coverage 或最终命中的宿主 fallback face。
const resolvedFont = resolveCompositeFont({
graphemes,
characterClassProfile: defaultCharacterClassProfile,
compositeFontProfile: {
id: "article",
name: "Article composite font",
defaultFont: {
candidates: [
{ family: "Source Han Serif", style: "Regular" },
{ family: "serif", style: "Regular" },
],
},
rules: [
{
characterClassId: defaultCharacterClassIds.latin,
font: {
candidates: [
{ family: "Source Serif", style: "Italic" },
{ family: "serif", style: "Regular" },
],
relativeSize: 0.9,
baselineShift: 0.04,
},
},
{
characterClassId: defaultCharacterClassIds.halfwidthNumber,
font: {
candidates: [
{ family: "Source Code", style: "Medium" },
{ family: "monospace", style: "Regular" },
],
},
},
],
},
});
解析结果同时包含逐 grapheme assignment 和按相同候选、相对字号及基线偏移合并的 source-ordered runs。这里的 resolved 表示字符类规则已解析,不表示 adapter 已选出实际 face。多个 character classes 重叠时,先出现的已配置规则优先;character class profile 本身的顺序不构成字体优先级。
areCompositeFontStacksEqual 提供 Core 统一的 stack 语义比较:候选的 family、style 与顺序必须相同,省略的 relativeSize、baselineShift 分别按 1、0 比较。Adapter 不应自行复制这套相等规则。
relativeSize 是相对于 composition 基准字号的无单位比例,1 表示 100%。baselineShift 使用 composition em,正值移向 line-over(横排即向上)。两者跟随 character-class stack,而不是某个实际命中的 fallback candidate;省略时分别等价于 1 和 0。Baseline shift 只改变最终视觉位置,不改变 advance、断行或 leading。
tokenizer/
把 graphemes 分组为 tokens。token 是当前 measurement 和普通换行的基本单元,但 token 内部仍可以拥有 spacing opportunities。ASCII space run 保持为独立 token;其在软换行处是否可见由后续 boundary 与 solver 处理,不由 tokenizer 删除。U+2028 会成为零宽度 forced-line-break token,并在后续 boundary / solver 中强制换行。
关于当前 TokenKind 的待审查事项见 tokenizer/DRAFT.md。
measurement/
定义最小 measurement contract:
type MeasureToken = (token: Token, context?: MeasureTokenContext) => TokenMeasurement;
type MeasureTokenContext = {
fontRuns: readonly TokenFontRun[];
};
type TokenMeasurement = {
token: Token;
advance: number;
fontRuns?: readonly TokenFontRun[];
};
具体字体测量由调用方或 adapter 提供。forced-line-break 由 measureTokens 直接赋予零宽度,不传给外部 provider。measureTokenByTextLength 是 core 暴露的 fake/debug implementation,只用于让 pipeline 在没有真实 adapter 时闭合。
传入 measureTokens(..., { fontRuns: resolvedFont.runs }) 后,measurement provider 会在第二个参数中收到当前 token 的局部 font runs;最终 TokenMeasurement 与 LayoutToken 会保留这些 runs,LayoutGrapheme.font 记录字符类解析后的候选栈。没有传入 font runs 时保持原有单字体行为。
resolveTokenFontRuns(tokens, resolvedFont.runs) 使用 source order 一次扫描,将 paragraph font runs 分配给各 token;measureTokens 内部使用同一函数。需要在正式 measurement 前构造 platform measurement requests 的 adapter 也应复用它,避免重复实现 range intersection。
Core 中的 advance、spacing、available width 与 protrusion 都使用同一个 composition em。Adapter 测量相对字号 run 时必须使用 baseFontSize * relativeSize 作为实际字号,但仍以 base composition font size 归一化 pixel advance;不能除以相对字号后的 local em,否则缩放会被抵消。Mojikumi spacing 也始终使用 base composition em,不能因为承载文字的相对字号而再次缩放。
Playground 的复合字体面板将每个 candidate 显示为可增删、可排序的 family/style select 对。支持 Local Font Access API 的浏览器可在用户点击后读取本地 font faces,再按 family 展示准确的 style;不支持、拒绝权限或读取失败时使用内置预设。serif、sans-serif、monospace、system-ui 等 CSS generic families 不属于本地字体枚举,始终单独显示在字体 select 的前部,且作为不可确定具体 face 的最终宿主 fallback。
Local Font Access 返回的是独立 face 记录,稳定字段是 family 与 style,并不提供可与 family 分离的标准数值 weight。这与 Figma 的 FontName { family, style } 模型一致,因此 V1 保留原始 style 名称,不把它强制转换成数值字重。Web 预览把每个具体 face 注册为一个只暴露 normal / 400 的合成 family,再把这些 aliases 作为 CSS family fallback 列表同时交给 Pretext 与 DOM;真实 style 已固化在 alias 的字体数据中。generic family 无法绑定到一个具体 face,不参与这种精确 aliasing。
@typeset/adapter-web/pretext 使用 Pretext 整体测量每个 token 的 natural advance。fontSizePx 表示当前 run 的实际字号,compositionEmPx 表示 Core 宽度单位对应的基准 pixel size;后者省略时默认等于前者,保持单字号调用兼容。它尚不提供 token 内部的 grapheme advances,也未处理异步 Web Font 加载或 font fallback identity。
inline-boundary/
生成 paragraph start、token boundaries 和 paragraph end,并记录:
- break permission:
allowed、forbidden、mandatory - Kinsoku reasons
InlineBoundary 不包含 spacing。
defaultKinsokuRuleTable生成当前严格中文禁则。noKinsokuRuleTable关闭可选的行首、行尾与不可分禁则,但仍保留 paragraph end 和 forced line break 等结构性 mandatory boundary。- ASCII
spacetoken 前方是结构性 forbidden boundary,后方仍允许换行;后方 boundary 的 line-end / next-line source positions 位于整段空格两侧。采用该断点时空格保留为上一行的语义 source,但折叠为零排版宽度,不成为下一行开头或独立空行。这个行为不属于Kinsoku或Mojikumi。
inline-spacing/
根据 Mojikumi grapheme-pair rules 生成静态的 InlineGraphemeSpacingOpportunity[]。每个 opportunity 表示一个相邻 grapheme gap,并包含:
before/aftergrapheme address- 可选的
boundaryIndex - source position
- style columns:minimum、desired 和 maximum
- policy columns:compression priority 与 expansion priority
token 内和 token 间 spacing 使用同一个 grapheme-gap model。
InlineSpacingConstraint 保持适合表格行消费的扁平数据形状,但在类型上由 InlineSpacingStyle 与 InlineSpacingPolicy 组成。二者是同一条 Mojikumi rule 的不同列组,不要求维护两张通过 key 关联的规则表。
Mojikumi profile 也可以声明 paragraph-start、line-start 和 line-end rules。它们在本阶段不展开;solver 确定候选行的首尾 grapheme 后才查询并物化对应 spacing。
defaultInlineMojikumiProfile由default-mojikumi.ts中的完整表格生成。源表保留 grapheme pairs、line start、paragraph start 和 line end 四类单元。simpleChinesePrettySpacingMojikumiProfile以默认表为基础,收紧相邻开闭括号、冒号、顿号、日文句点、逗号和句尾标点与开括号、以及行边缘括号,并为 CJK 正文与引号之间提供 12.5% / 25% / 50% 间距。未覆盖的单元与优先级均继承默认 profile。- 表格中的
0%, 0%, 0%单元编译为显式零规则,用于在 character classes 重叠时终止匹配;resolver 命中后不生成无调整能力的 spacing opportunity,也不会继续回退到后续 class rule。 InlineMojikumiProfile.rules使用 first-match 语义;需要覆盖默认规则时,调用方必须把更具体的规则放在默认规则之前。noInlineMojikumiProfile不生成 spacing opportunities。
line-keep/
resolveLineKeepConstraints 把 width-independent 的末行 effective-unit-count policy 解析为独立 boundary constraints。它不修改 Kinsoku facts,也不把策略原因写回 InlineBoundary.rules;Measured solver 与 Static Flow 分别消费同一解析器的结果。
LineKeepProfile.effectiveUnitClassIds 引用同一次求解传入的 CharacterClassProfile。token 内任一 grapheme 命中其中任一 class 时,该 token 贡献一个 effective unit;重叠 class 不重复计数。默认 profile 以现有 Han、Hiragana、Katakana、全角/半角数字和 Latin class 为正文单位,因此单个 CJK grapheme 计一个单位,Latin word 与数字 run 各计一个单位,标点不计数但仍保留在末尾不可分范围内。约束不跨越 forced line break;最后一个 hard segment 少于最低数量时只保持现有有效内容。
当前默认 Character classes 尚无 Hangul、Bopomofo 与 Emoji class,因此这些字符不会贡献末行 effective unit。这是已知覆盖缺口;当前实现不为 LineKeep 另建字符判断,待 Character classes 补齐后由 profile 引用对应 class。
static-flow/
compileStaticFlow 消费 Stage 6 已经生成的 tokens、boundaries、spacing opportunities 和 last-line policy,不接收 measurement 或 available width,可直接在 Node.js 等编译期环境中运行。
它输出:
- 由 paragraph edge 与
U+2028forced line break 确定的 hard segments。 - paragraph start / hard line start / hard line end / paragraph end 的 virtual edge roles 和已知 edge spacing。
- 由 Kinsoku forbidden boundary 与末行 effective-unit-count constraints 合并得到的
unbreakableRuns,供 HTML adapter 编译成不可换行标记。 fixedspacing:token 内部或禁止断行 boundary 上可以无条件应用的 desired spacing。conditionalspacing:普通可换行 boundary 上只有同行时才能应用的 desired spacing。softLineStartSpacings:普通可换行 boundary 后方命中 line-start rule 时,记录只有该 boundary 实际换行后才应出现的非零 desired spacing。- 无宽度时仍不能完成的 soft line-end spacing 与末行 fill-ratio diagnostics。
Static Flow 可以把“末行至少 N 个 effective units”编译成尾部不可分关系。有效单位由 LineKeepProfile 引用当前 Character classes 定义;约束只作用于最后一个 forced line break 之后的内容,不跨越 mandatory boundary。它不决定浏览器或其他 renderer 最终在哪里软换行,也不执行 justification、line adjustment、末行比例、标点悬挂或段落最优断行。它的输出是 adapter-neutral 的静态排版关系,不是 HTML 字符串。
当前 paragraph start 只具有 paragraph-start role,不应用 line-start rule;paragraph end 同时具有 line-end 与 paragraph-end role;forced line break 两侧分别形成 hard line-end 和 hard line-start。普通 soft line edge 的实际位置在无宽度模式下未知;Static Flow 只在已有 allowed boundary 上预先记录可由 adapter 条件表达的 line-start spacing,不把它伪装成已经发生的换行。
line-adjustment/
把候选行的 spacing constraints 编译为 base amount、compression capacity 和 expansion capacity,并通过纯函数生成 LineAdjustmentPlan。
LineAdjustmentPlan 记录 base width、target width、final width、请求与实际调整量、每个 priority 的 capacity / applied amount / ratio、每个 gap 的 actual spacing 和未满足量。压缩与扩张使用独立 priority;处理时从较低 priority 开始,同一 priority 内按各 gap 的容量比例分配。
plan 只描述发生了什么,不包含好坏判断。独立 scorer 将 plan 转换为 LineAdjustmentScore,分别保留 unresolved amount 和各 priority 的 ratio² distortion。
这个模块属于 Stage 7 内部职责,不决定断点,也不执行 Kinsoku recovery、hyphenation 或其他 fallback。
line-end-hanging/
行尾标点悬挂使用独立的 LineEndHangingProfile,不复用 Mojikumi 的负 line-end spacing,也不修改 Kinsoku boundary。
noLineEndHangingProfile显式禁用。defaultLineEndHangingProfile使用allow模式,当前只包含、、,、。,三条规则的maximumProtrusion均为1。该值与 measurement advance 使用同一宽度单位;Playground 和 Figma 当前都将其解释为 em。allow只在普通候选无法容纳时尝试悬挂;force对符合规则的候选行尾始终启用。- 当前仅当候选行最后一个可见 token 恰好只含一个 grapheme 时才能处理;protrusion 不超过
maximumProtrusion和该 token 的 measured advance。
resolver 识别候选行末资格并计算受上限约束的 protrusion。最终是否采用、如何参与 fit 与路径比较属于 solver。
solver/
solveParagraph 消费 measurements、boundaries、静态 spacing opportunities、Mojikumi / character class / line-end hanging profiles 和 available width,输出 solved lines。
alignment 是必传输入,不提供隐式默认值:
start保留 desired spacing;候选超宽时可以按 compression priority 压缩,但不会为了填满一行而扩展 spacing。justify为每个候选建立LineAdjustmentPlan;未悬挂时occupiedWidth等于 plan 的 final width,悬挂时则扣除 protrusion,最终统一根据occupiedWidth判断 fit。
lastLinePolicy 也是必传输入,包含两个可独立开关的策略:
effectiveUnitCount要求末行至少保留指定数量的有效单位。它是由 Character classes 驱动的 hard keep constraint,不依赖 available width。fillRatio要求末行达到指定宽度占比。这个比例由调用方根据 available width、版式和产品规则计算,core 不内置宽度到比例的换算公式。
两项策略独立且可以共存:effective-unit count 先限制合法断点,fill ratio 再作为 width-dependent 软偏好比较剩余完整路径。Static Flow 只能执行前者,并把启用的 fill ratio 记录为 unsupported effect。
当前 solver 使用按需候选和 dynamic programming:
- 根据
alignment,为 token range 建立 base width 和请求调整量,再由LineAdjustmentPlan判断能否放入一行 - 第一行创建
paragraph-startentity,后续行创建line-startentity,每个候选行末创建line-endentity;对应 edge spacing 在候选行上下文中查询并参与 fit paragraph-start与line-start当前只匹配各自同名规则,不继承、不回退也不叠加;两者与首行缩进等规则的关系尚待设计- 从每个可达 boundary 向前扫描局部合法断点,不创建持久的全量 edge graph;每个 boundary 最多按
tight、natural、loose保留三条路径 - 根据实际 adjustment direction 和已消费 capacity ratio 得到 line fitness;超过一半容量的压缩为
tight,扩张为loose,其余为natural - 累计每行的 structured score:先比较 unresolved amount,再从较后的 priority 向较早的 priority 比较 distortion,然后比较相邻行的 fitness transition demerit
- 直接从
tight跳到loose或反向跳变会产生 demerit;start的未扩张留白不会被误判为loose - 不在 Kinsoku forbidden boundary 或 line-keep constrained boundary 普通换行
- 采用 ASCII space 后方的断点时消费空格 token,并从该行 content width 与普通 inline spacing 中排除它,同时保留 source identity;行尾文字组和悬挂资格仍以该空格作为匹配对象
- 严格 Kinsoku 下的基础 push-in / push-out 由统一求解自然产生:压缩后可容纳时保留标点,容量不足时选择更早的合法断点;它们不是独立的 spacing 模块
LineAdjustmentPlan按方向和 priority 分层消费容量,同一 priority 内按各 opportunity 的容量比例分配start只压缩超宽候选,不扩展欠宽行;justify的非末行尝试扩展到 available width;两种模式的末行在无需压缩时都保留 desired spacing- 无法在 available width 内找到合法 boundary 时输出 overfull line
- 悬挂候选在剪枝前参与 fit;其 adjustment target 为
availableWidth + protrusion advanceWidth保留 spacing 调整后的完整逻辑 advance,occupiedWidth扣除显式 protrusion;fit、overfull、末行比例与 start raggedness 均使用后者- token-internal spacing 参与宽度,但不产生换行点
- 启用 effective-unit count 后,末尾有效单位先限制合法断点;同时启用 fill ratio 时,再从合法完整路径中优先选择满足
occupiedWidth / availableWidth的结果 SolvedLine保留完整adjustmentPlan和最终fitnessSolvedParagraph.diagnostics当前包含line-overfull、spacing-adjustment-capacity-exhausted和last-line-minimum-fill-unmet;每项引用最终行索引,并保留实际值、目标值或未完成的调整量- 空 paragraph 输出一条 empty line
当前 allow 只是普通候选 overfull 时的 fallback,不会把普通候选与悬挂候选作为两条并行路径评分,也没有统一协调行尾 Mojikumi compression 与 hanging 的优先关系。
当前 effective-unit count 已能表达基础的“一个 Han 字加尾随标点仍只算一个单位”,但默认 Character classes 尚未覆盖全部 CJK script 与 Emoji,也尚未提供按语言区分的 LineKeep profiles、lonely word、跨 frame widow/orphan、hyphenation 或分级 fallback search。solver 也尚未实现可配置的 fitness 阈值与 transition demerit、完整 adjustment kinds 和更完整的 justification。
layout/
createLayoutResult({ paragraph, measurements, solvedParagraph }) 把 solver result 物化为 adapter-facing logical layout:
- 按 solved token ranges 生成有序 lines 和 tokens。
LayoutLine.sourceRange和text保留折叠的行尾 ASCII space,contentRange表示实际参与排版的内容;对应 token 标为collapsed-at-line-end。U+2028仍作为 control token 不进入可渲染 tokens。- 把 actual spacing 映射为每个 grapheme 的
spacingAfter。 - 返回
advanceWidth、occupiedWidth、availableWidth、显式lineEndHanging、source ranges,以及每行实际的 start / end virtual entities。 - actual spacing 使用统一 anchors 关联 grapheme 或 virtual entity;
spacingAfter只物化 grapheme-pair spacing。 - 原样保留 solver diagnostics,供 adapter 选择展示或记录。
这个阶段不重新求解,也不产生 DOM、CSS 或 Figma instructions。当前 measurement 仍只有 token advance,因此 Stage 8 尚不提供 glyph positions、baseline 或 ink bounds。
渲染边界
core output 不包含 DOM nodes、CSS properties、Canvas calls 或 Figma objects。Measured Composition 已经决定的断行和 spacing,以及 Static Flow 已经决定的 hard breaks、fixed spacing 和不可分关系,renderer / adapter 都不能重新解释。Static Flow 没有决定的 soft breaks 则由目标排版环境完成。
@typeset/adapter-web 是当前 Web adapter。它把每个 LayoutResult 或 StaticFlowResult 序列化为经过转义的可信 HTML,并由包内 CSS Module 提供排版语义;框架调用方把单个 paragraph 的内部 HTML 当作不透明结果整体替换。Solid Playground 已改为消费这个输出,继续承担配置与可视化验证 UI。
Measured renderer 使用 @typeset/adapter-web/pretext 基于 Canvas 测量 token natural advance,把 CSS pixel 归一化为 em 后传入 core。adapter 返回不带额外 paragraph wrapper 的 HTML fragment,每个 solved line 是占满挂载容器宽度的 inline-block span;调用方负责把容器宽度设为 LayoutResult.availableWidth。这种布局保留视觉断行,但不会在各行之间建立 block formatting boundary,因此复制结果仍是一个 paragraph。每行默认禁止浏览器再次断行,Playground 的 Debug 选项可临时允许二次断行以观察测量差异。连续 ASCII space 组成的 token 按一个普通空格测量,行尾 ASCII space 仍完整写入 HTML 并由浏览器折叠。Static renderer 不调用测量或 solver;它把 hard segments、不可分 runs、fixed spacing 和 conditional spacing 呈现为 HTML,并继续使用浏览器 whitespace flow。Static 的 start / justify alignment 由 Web adapter 的段落 CSS 表达,其中两端对齐采用浏览器实际 soft wrapping,末行不拉伸。Hard segment 的 sourceRange 保留已知行尾空格。正的 conditional spacing 使用可折叠空白表达;负值使用带负 inline margin 的零宽元素表达,并用相邻 soft break 保留换行机会。
已知限制:@chenglou/pretext 的 Canvas 测量与浏览器 DOM inline layout 并不保证得到完全相同的 advance。字体 shaping、空白折叠、CSS spacing 和浏览器内部取整都可能使两者产生细小差异;如果允许浏览器再次换行,它可能在 core 已决定的 solved line 内产生新的断行。Measured renderer 因此必须把 solved line 当作确定结果并禁止行内再次换行。当前 Debug 选项只用于暴露和比较这一差异,不代表可依赖的渲染模式。
Web adapter 把 Measured line-end spacing 与 Static Flow 已知 hard line-end spacing 叠加到规则所引用 grapheme 的 spacingAfter,并以 letter-spacing 渲染,不在行末追加空元素。Inline parts 会在 spacing 值变化处切分,因此 line-end amount 只作用于被引用 grapheme 所在的 part;这种 CSS 技术验证可能在切分处改变 kerning、ligature 或 contextual shaping,尚不等同于基于 glyph positions 的正式 renderer。
Web adapter 当前只翻译负值 softLineStartSpacings:它在已有 allowed boundary 上生成可折叠空白补偿和后一个 run 的 inline-start margin,同行时两者抵消;浏览器在该处换行时,行尾空白被折叠,后一个 run 保留行首 spacing。若后一个 run 同时属于不可分组,补偿载体位于该组外侧,行首 margin 由原子 no-wrap box 承载;forbidden boundary 不会获得这种条件关系。正值需要另一种 Web 条件补偿策略,adapter 保留 diagnostic 但不渲染该效果。
负 conditional spacing 的当前 Web 表达存在已知缺陷:发生软换行时,它不会偏移下一行行首,但负 margin 仍会影响上一行的 inline geometry,因此尚未完全实现“只在 boundary 同行时存在”的语义。Core 输出仍将该 spacing 标记为 conditional;Web adapter 通过 approximate-negative-conditional-spacing diagnostic 明确报告这一降级。
Web adapter 会跨 token 合并最终渲染配置相同的连续 grapheme,只为配置变化、conditional spacing、soft line-start compensation 或 Static Flow 结构边界保留独立 inline run。默认输出不会只为 line-end hanging 创建 span;调用方显式请求 hanging annotation 时,adapter 才为对应 grapheme 保留可标注的边界。跨 token 合并可能让浏览器执行 measurement token 之间的 kerning、ligature 或 contextual shaping;独立 run 也可能切断整 token 测量时存在的 shaping,两者都是当前仅有 token advance、尚无 glyph positions 时的已知近似。
Playground 可以切换 Measured / Static 两种执行模式,独立关闭 Kinsoku 或 inline spacing,并在 default、SimpleChinesePrettySpacing、priority 和 custom 四种 Mojikumi profile 之间切换。Facts / Rules / Style / Policy 是排版配置区的一级 tab,而不是 Mojikumi 的子类别;成组配置使用默认展开的原生 details。Facts 只读展示当前 CharacterClassProfile 的 class id、matcher、定义与备注,不提供自定义、覆盖或重叠校验;Rules 展示 Kinsoku 的行首禁止、行尾禁止与同类字符不可分规则。Rules 与 Facts 引用同一份字符类数据,不重复维护字符归属。Style / Policy 复用同一张 Mojikumi 规则表,并都提供功能开关与 profile 选择;Style 包含 alignment,并开放 minimum、desired、maximum;Policy 开放 compression priority、expansion priority,并允许独立配置末行最低 effective unit 数与最低宽度占比,后者在 Static 模式不可用;Measured 模式还提供 none / allow / force 行尾悬挂模式。不属于当前视图的数值列保留为只读上下文。priority 是仅用于验证方向性 priority 的 Playground 本地 profile;custom 从默认 profile 克隆规则,不改变 core 内置 profile,也不实现通用 profile 持久化或导入。Debug 集中提供执行模式、预览宽度、CSS 对比、resolved data 和仅影响展示的 em 网格等诊断选项;Static 模式还可以额外显示由 Solid Static Component 在构建期生成的固定默认样例,用于验证 Static Flow、Web adapter 与 renderWrapper 的完整链路。
验证
pnpm --filter @typeset/typeset test
pnpm --filter @typeset/typeset typecheck
pnpm --filter @typeset/typeset benchmark:solver
solver benchmark 只计量 solveParagraph;grapheme segmentation、tokenization、measurement、boundary 与 spacing resolution 均在计时前完成。