20260920173439

This commit is contained in:
oneao committed 2026-09-20 17:34:40 +08:00
1 parent f4bfc1ddfc
commit 6f03d92425
174 files changed
+23490 -157

No files matched your search

@@ -0,0 +1,192 @@
# @report/layout
元素测量、文本换行、动态高度、明细表展开与分页,产出所有 Renderer 共用的 `LayoutDocument`。
> 对应设计文档 §4.3、§9、§3.3。
## 定位
Layout 负责**尺寸计算、文本测量、换行、表格展开、动态高度、分页**;
Renderer 只负责把 `LayoutDocument` 画成 HTML / SVG / PDF,**绝不重新计算分页**(§3.3)。
Layout 通过 `TextMeasurer`、`FontResolver` 接口获得环境能力,**不直接依赖 DOM**(§4.3)。
因此同一套分页逻辑可以在浏览器、Node 和服务端复用。
## 安装与使用
```ts
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 内置模块。
```ts
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 是唯一的分页真相
```ts
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` 取字体)。
## 开发
```bash
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)。