193 lines
6.5 KiB
Markdown
193 lines
6.5 KiB
Markdown
# @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)。
|