@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[] 构成,每个候选分别保存 familystyle;stack 还可以设置 relativeSizebaselineShift。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 与顺序必须相同,省略的 relativeSizebaselineShift 分别按 10 比较。Adapter 不应自行复制这套相等规则。

relativeSize 是相对于 composition 基准字号的无单位比例,1 表示 100%。baselineShift 使用 composition em,正值移向 line-over(横排即向上)。两者跟随 character-class stack,而不是某个实际命中的 fallback candidate;省略时分别等价于 10。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-breakmeasureTokens 直接赋予零宽度,不传给外部 provider。measureTokenByTextLength 是 core 暴露的 fake/debug implementation,只用于让 pipeline 在没有真实 adapter 时闭合。

传入 measureTokens(..., { fontRuns: resolvedFont.runs }) 后,measurement provider 会在第二个参数中收到当前 token 的局部 font runs;最终 TokenMeasurementLayoutToken 会保留这些 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;不支持、拒绝权限或读取失败时使用内置预设。serifsans-serifmonospacesystem-ui 等 CSS generic families 不属于本地字体枚举,始终单独显示在字体 select 的前部,且作为不可确定具体 face 的最终宿主 fallback。

Local Font Access 返回的是独立 face 记录,稳定字段是 familystyle,并不提供可与 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,并记录:

InlineBoundary 不包含 spacing。

inline-spacing/

根据 Mojikumi grapheme-pair rules 生成静态的 InlineGraphemeSpacingOpportunity[]。每个 opportunity 表示一个相邻 grapheme gap,并包含:

token 内和 token 间 spacing 使用同一个 grapheme-gap model。

InlineSpacingConstraint 保持适合表格行消费的扁平数据形状,但在类型上由 InlineSpacingStyleInlineSpacingPolicy 组成。二者是同一条 Mojikumi rule 的不同列组,不要求维护两张通过 key 关联的规则表。

Mojikumi profile 也可以声明 paragraph-startline-startline-end rules。它们在本阶段不展开;solver 确定候选行的首尾 grapheme 后才查询并物化对应 spacing。

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 等编译期环境中运行。

它输出:

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。

resolver 识别候选行末资格并计算受上限约束的 protrusion。最终是否采用、如何参与 fit 与路径比较属于 solver。

solver/

solveParagraph 消费 measurements、boundaries、静态 spacing opportunities、Mojikumi / character class / line-end hanging profiles 和 available width,输出 solved lines。

alignment 是必传输入,不提供隐式默认值:

lastLinePolicy 也是必传输入,包含两个可独立开关的策略:

两项策略独立且可以共存:effective-unit count 先限制合法断点,fill ratio 再作为 width-dependent 软偏好比较剩余完整路径。Static Flow 只能执行前者,并把启用的 fill ratio 记录为 unsupported effect。

当前 solver 使用按需候选和 dynamic programming:

当前 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:

这个阶段不重新求解,也不产生 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。它把每个 LayoutResultStaticFlowResult 序列化为经过转义的可信 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,并在 defaultSimpleChinesePrettySpacingprioritycustom 四种 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 均在计时前完成。