6.5 KiB
@report/layout
元素测量、文本换行、动态高度、明细表展开与分页,产出所有 Renderer 共用的 LayoutDocument。
对应设计文档 §4.3、§9、§3.3。
定位
Layout 负责尺寸计算、文本测量、换行、表格展开、动态高度、分页;
Renderer 只负责把 LayoutDocument 画成 HTML / SVG / PDF,绝不重新计算分页(§3.3)。
Layout 通过 TextMeasurer、FontResolver 接口获得环境能力,不直接依赖 DOM(§4.3)。
因此同一套分页逻辑可以在浏览器、Node 和服务端复用。
安装与使用
import { createDocument } from "@report/core"
import { layoutDocument, createEstimateMeasurer, createPassthroughFontResolver } from "@report/layout"
const doc = createDocument({ name: "销售单" })
const result = await layoutDocument(
doc,
{ data: { ds1: rows }, parameters: { year: 2024 } },
{
textMeasurer: createEstimateMeasurer(),
fontResolver: createPassthroughFontResolver(),
},
)
console.log(result.pageCount)
for (const page of result.pages) {
for (const node of page.nodes) {
console.log(node.id, node.type, node.frame)
}
}
Node 侧使用真实字体度量
@report/layout/fonts 依赖 node:fs,因此单独入口,避免浏览器打包被迫引入 Node 内置模块。
import { FontLibrary, createFontMeasurer, findSystemFontSources } from "@report/layout/fonts"
import { layoutDocument } from "@report/layout"
const library = new FontLibrary()
library.loadAll(findSystemFontSources())
const measurer = createFontMeasurer({
library,
fallbackFamilies: ["SimSun", "SimHei", "Microsoft YaHei"],
})
const result = await layoutDocument(doc, context, {
textMeasurer: measurer,
fontResolver: measurer.resolver,
})
核心概念
LayoutDocument 是唯一的分页真相
interface LayoutDocument {
pages: LayoutPage[]
diagnostics: LayoutDiagnostic[]
pageCount: number
}
interface LayoutPage {
width: number // mm
height: number // mm
pageNumber: number // 从 1 开始
nodes: LayoutNode[]
sections: LayoutPageSection[]
}
所有节点使用绝对坐标(mm,原点在页面左上角),Renderer 不需要再做任何层级累加。
单位约定
文档内部统一 mm(§7)。units.ts 集中提供换算:
| 函数 | 说明 |
|---|---|
mmToPx / pxToMm |
与 Designer 显示层换算(1in = 96px) |
mmToPt / ptToMm |
与 PDF 输出边界换算(1in = 72pt) |
fontSizeToMm |
字号 pt → 几何 mm,测量时使用 |
文本换行
- 西文按词断行,不拆开单词(超长词强制断开并标记
brokeWords)。 - 中文按字断行,无需空格分词。
- 支持避头点(
,。)】不出现在行首)与避尾点((【不出现在行尾)。 - 显式换行符保留为硬换行。
换行结果完全由注入的 TextMeasurer 决定,因此与具体字体环境解耦。
动态高度
text 元素内容高于 frame.height 时,布局阶段会自动增高(§3.3)。
这是表格 rowHeightMode: "auto" 的基础。
中英混排与字体回退
拉丁字体通常不含汉字。度量器按字符查找覆盖它的字体:
"中" 在 Arial 中 → glyphId 0(.notdef)→ 回退到 SimHei → 1.0em
"A" 在 Arial 中 → glyphId 36 → 直接用 Arial → 0.667em
关键正确性细节:字体缺失字符时返回 .notdef 字形,而它本身带有 advance 宽度。
若直接返回该宽度,缺字会被静默当成正常字符排版。本实现对此显式返回 null,
由调用方按回退字体或兜底宽度处理,并在 TextMetrics.fallback 中标记。
分页
- 页眉 / 页脚每页重复,其高度先从可用高度扣除。
- 明细表产出「表头块 + 每行一个块」的序列,因此行与行之间可跨页断开,单行不会被拆散。
repeatHeaderOnEachPage控制表头是否每页重复。- 页眉 + 页脚高度达到或超过内容区时返回明确错误诊断而不是静默产出重叠页面。
- 单个内容块高于整页时产生
layout.page-overflow警告并让其独占一页(允许溢出)。
页脚页码占位符
页脚文本中的 {pageNumber} 与 {pageCount} 会在分页完成后替换为实际值,
用于「第 X 页 / 共 Y 页」。
目录结构
src/
├── units.ts 单位换算
├── measurer.ts TextMeasurer / FontResolver 接口 + 估算度量器
├── font-parser.ts TTF/OTF/TTC 字节解析(无文件系统依赖)
├── load-font.ts 字体加载与 FontLibrary(Node)
├── font-measurer.ts 基于真实字体度量的 TextMeasurer
├── system-fonts.ts 系统字体探测
├── text-wrap.ts 分段、换行、避头尾、动态高度
├── layout-document.ts LayoutDocument 模型
├── measure.ts 样式解析、绑定求值、元素测量
├── table.ts 明细表展开为分页块
├── paginate.ts 分页引擎
├── engine.ts Layout Engine 主入口
├── index.ts 主入口(纯逻辑,无 node:fs)
└── fonts.ts 字体入口(Node)
字体解析说明
font-parser.ts 自行解析字体二进制,不依赖 canvas 等原生模块:
| 表 | 用途 |
|---|---|
head |
unitsPerEm(度量归一化基准) |
hhea |
numberOfHMetrics、ascender、descender、lineGap |
hmtx |
每个字形的 advanceWidth |
cmap |
字符 → 字形映射(format 4 与 format 12) |
maxp |
numGlyphs |
name |
字体族名(用于匹配与诊断) |
同时支持 .ttf / .otf 与 .ttc 字体集合(按 fontIndex 取字体)。
开发
pnpm build # tsup 打包
pnpm test # vitest
pnpm typecheck # tsc --noEmit
当前测试覆盖
| 测试文件 | 用例数 | 覆盖内容 |
|---|---|---|
font.test.ts |
29 | TTF/TTC 解析、真实字宽、中英回退、缺字检测 |
text-wrap.test.ts |
30 | 分词、按词/按字换行、避头尾、动态高度 |
pagination.test.ts |
28 | 页面几何、单页/多页、页眉页脚重复、Y 分层 |
合计 87 个用例。字体相关用例在缺少系统字体的环境中自动跳过。
尚未实现
- 表达式驱动的分组(
group-header/group-footer)与跨页合计。 - 容器内嵌套表格的多级拆分。
- 表格列自动宽度(当前按声明宽度 + 等比压缩)。
- PDF 输出(属
report-renderer,阶段 4)。