# @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)。