Document Builder 文档生成器 alpha 版本功能演示
引言
经过若干个周末的编写,终于完成了 DocumentBuilder.js 1.0-alpha 版本的开发。现在先来简单介绍一下何为 DocumentBuilder.js。
简介
DocumentBuilder.js, 顾名思义,就是一个基于 JavaScript 的原生文档排版框架。原生 JS 排版引擎,将 JSON AST 渲染为分页文档。无框架依赖,纯 Shadow DOM 隔离。
主要由钩子(Hook)、主题(Theme)、插件(Plugin)三大系统驱动。主程序只提供最基本的功能,样式与组件都由主题和插件提供,具有高可拓展性。程序可以在指定容器上生成文档的 DOM 结构,并支持实时预览与修改。打印通过浏览器原生功能实现,也可以搭配 html2pdf.js 等库导出为 PDF。
本程序的功能基本都不在主程序,而是在插件中。这里我也写了几个基本的插件与主题,覆盖了从党政公文到公司文档的常见场景。
主题
- GB/T 9704 党政机关公文标准

- 公司内部标准文档格式

- 组件演示

插件
- 纸张(Paper):定义了 ISO A1—A5、US Letter 等标准纸张的尺寸与边距
- 常用组件:paragraph(段落)、image(图片)、signature(签章)、red-header(红头文件头)、hr(分割线)、footer(公文版记)、cover(封面)、math(基于 KaTeX 的公式渲染)、heading(标题)、bullets-list(列表)
- 水印(Watermark):支持平铺、斜铺、居中三种布局,可自定义文字、Logo、字号、透明度
- PaperProfiles(信函格式):手写信函纸,支持发文机构 + 双横线边框,以及空白纸(仅保留顶部底部双横线)
有了这些插件与主题后,DocumentBuilder.js 的功能已经基本完整,能够覆盖日常文档排版的绝大多数需求。
本程序的开发主要是为了解决懒得使用 Word 受挫文档的问题。方便一键生成公文(虽然这个功能我们可能根本用不到)。
程序还有很多组件功能,比如双面打印、水印等插件功能没有演示,需要可以自行探索。
瞎 BB
我们的演示文件使用了《流浪地球》里的地球联合政府,Belike 最近球瘾犯了,比较期待《流浪地球3》.
具体的实现方法
这个章节可能会比较无聊,都是在写 DocumentBuilder.js 的具体实现细节。
本章节由 Claude Code 根据项目具体上下文编写, 人工参与润色。
整体架构
DocumentBuilder.js 的整体架构可以分为三层:
┌─────────────────────────────────────┐
│ 插件 / 主题层 │
│ plugins/*.js themes/*.js │
├─────────────────────────────────────┤
│ 核心引擎 │
│ document-builder.js │
│ ├─ ComponentRegistry 组件注册表 │
│ ├─ ThemeRegistry 主题注册表 │
│ ├─ TextSplitter 文本截断器 │
│ ├─ Paginator 分页引擎 │
│ ├─ HookManager 钩子管理器 │
│ └─ StyleManager 样式管理器 │
├─────────────────────────────────────┤
│ Shadow DOM 容器 │
│ #shadow-root > .db-workspace │
└─────────────────────────────────────┘所有渲染结果都存放在 Shadow DOM 中,与页面主文档完全隔离。这意味着页面上的全局 CSS 不会影响文档排版,文档内部的样式也不会泄漏到页面中。
渲染管线
整个渲染流程从 setDocument(jsonAST) 开始,大致分为以下几个步骤:
- 解析配置:读取 JSON 中的
theme、paper、orientation等字段,确定纸张大小、方向和边距 - 创建离屏测量容器:在 Shadow DOM 中创建
.db-measure容器,用于文本截断时的尺寸测量 - 遍历 content 数组:对每个 block 调用
render()生成 DOM 元素,追加到当前页 分页检测:每次追加后检查页面是否溢出:
- 若组件支持跨页截断(如段落),则用 TextSplitter 将文本拆为"留在本页"和"移到下页"两部分
- 若不支持截断,将整个块移动到下一页
- 若组件标记了
forcePageBreak,渲染后强制分页
- 应用页眉页脚:根据主题配置和分页段设置,为每页渲染页眉和页脚
- 销毁测量容器,触发
afterRender钩子
这个管线是纯同步的、单遍扫描——不需要二次回流或重排。
插件系统
插件通过 documentBuilder.registerComponent(name, definition) 注册。主程序对插件的存在一无所知——插件不引入时主程序正常运行,遇到未知 type 时仅 console.warn 并跳过。
组件定义需要包含以下接口:
documentBuilder.registerComponent('paragraph', {
shouldSplit: true,
textContentSelector: '.db-block-paragraph',
atomicSelector: '.db-atomic-inline',
defaultAttrs: { fontSize: 14, lineHeight: 1.6 },
styles: '.db-block-paragraph { ... }',
render: function (attrs, content, ctx) {
// attrs: 合并了 defaultAttrs 和用户传入的属性
// content: 子节点数组(文本节点或内联元素)
// ctx: { pageWidth, pageHeight, paper, margins }
// 返回 DOM 元素
},
measure: function (el) {
return { height: el.offsetHeight };
}
});render 函数接收三个参数:
- attrs:合并了
defaultAttrs和 JSON 中传入的block.attrs,包含对齐方式、字体大小、间距等 - content:子节点数组,可能包含
{ type: 'text', text: '...', marks: [...] }文本节点,或{ type: 'inline-math', text: '...' }行内公式 - ctx:渲染上下文,包含
pageWidth、pageHeight(像素值)、paper(纸张毫米尺寸)、margins(边距毫米值)
主程序对组件的工作方式相当"粗暴":调用 render → 获取 DOM → append 到页面 → 测量 offsetHeight → 判断是否需要分页。
主题系统
主题通过 documentBuilder.registerTheme(name, config) 注册,可以设定:
- 默认纸张与边距
- 字体定义(自动注入 @font-face)
- 全局样式
- 页眉页脚模板
页眉页脚是比较复杂的一部分。主题中可以定义多个模板,并支持奇偶页不同、首页单独设置:
footer: {
defaultTemplate: 'default',
oddEven: true,
oddTemplate: 'odd-style',
evenTemplate: 'even-style',
templates: {
'default': { render: function(ctx) { ... }, styles: '...' },
'odd-style': { render: function(ctx) { ... }, styles: '...' },
'even-style': { render: function(ctx) { ... }, styles: '...' }
}
}页脚模板的 ctx 额外包含 formatPageNumber() 函数,支持阿拉伯数字、罗马数字、中文数字、字母序号等多种页码格式。配合 headerFooterPages.segments 配置,可以实现"第 1 页无页码,第 2 页起显示 '— 2 —'"这样的段式页码逻辑。
分页与文字截断
文档排版中最核心的问题是"文字太多,放不下了怎么办"。
对于不支持跨页的组件(图片、表格、封面等),做法很简单:整个块搬到下一页。
对于段落这类长文本,实现 TextSplitter 在离屏容器中做二分查找:
- 将待截断元素的克隆放入测量容器
- 用 TreeWalker 遍历所有文本节点(跳过 atomicSelector 标记的原子元素,如行内公式)
- 从最后一个文本节点开始,逐字符移除,每移除一个字符检查高度
- 找到"刚好放下"的分界点后,将文本拆为 fits 和 overflow 两部分
- fits 留在本页,overflow 放到下一页
为什么是倒序逐字符?因为大多数场景下需要截断的文字并不多(可能只多了一两行),从末尾开始删效率最高。极端情况(整块文字几乎都不够放)下性能的确会差一些,但考虑到每页截断只发生一次,且文本长度通常不超过几千字,实测影响不大。
原子元素(atomicSelector)——比如行内 KaTeX 公式——在截断时不会被拆开。它们会作为一个整体被移到 fits 或 overflow 中,保证公式的完整性。
文字缩放
在公文排版中,发文机关名称需要以醒目的大号红色字体显示在版心顶部。这里遇到一个问题:机关名称的长度变化很大,短则三四个字,长达十几个字,而版心宽度是固定的。
解决思路来自活字排版中的"均匀"与"缩放":
模式 A:文字过短——分散对齐
当文字宽度小于版心宽度时,用 letter-spacing 将文字均匀撑开:
var totalPadding = containerWidth - textWidth;
var ls = totalPadding / (charCount - 1);
el.style.letterSpacing = (ls / MM_TO_PX) + 'mm';每个字之间的空白均匀分布,视觉上填满一行,类似报纸标题的"疏排"效果。
模式 B:文字过长——横向缩放
当文字宽度超出版心时,计算缩放比例后用 CSS transform 压缩:
var scale = containerWidth / textWidth;
el.style.transform = 'scaleX(' + scale + ')';
el.style.transformOrigin = 'center center';这里有个坑:如果文字所在的元素默认 stretch 撑满父容器,文字溢出只会往右侧延伸,缩放后就会偏右。解决方法是让容器 display: flex; align-items: center;,文字元素的宽度自动等于内容宽度,过长时向两侧均匀溢出,scaleX 后自然居中。
钩子系统
钩子是插件与主程序交互的另一种方式,适用于"不需要渲染具体内容,但需要在某个时机执行操作"的场景——比如水印插件在渲染完成后叠加水印图层。
支持以下生命周期钩子:
| 钩子 | 触发时机 | 用途举例 |
|---|---|---|
beforeRender | 开始渲染前 | 修改 JSON AST |
beforeBlockRender | 每个 block 渲染前 | 动态调整属性 |
afterBlockRender | 每个 block 渲染后 | 追加额外样式 |
beforePageBreak | 分页前 | 记录分页位置 |
afterPageBreak | 分页后 | 更新目录结构 |
afterRender | 全部渲染完成 | 叠加水印、更新页码 |
onComponentMount | 元素挂载到页面 | 触发动画、懒加载 |
钩子的注册与移除通过 on() / off() 方法操作,支持注册多个回调。
写在最后
DocumentBuilder.js 目前还是一个 alpha 版本,代码大约 1200 行,加上十几个插件总共不到 3000 行。功能上还有很多不完善的地方,
- [x] 基础排版与分页
- [x] 插件与主题系统
- [x] 公文标准(GB/T 9704)
- [x] 水印与封面
- [x] 信函格式
- [ ] 表格组件
- [ ] 目录生成
- [ ] 批量文档生成
(PS: 待办组件博客还没写,懒得写这个其实 ╭( ̄▽ ̄)")
后续会根据实际使用场景逐步完善。项目的完整源码和在线 Demo 可以访问 GitHub 仓库 document-builder。
如果你对这类工具感兴趣,欢迎关注或参与贡献。
评论 (0)
暂无评论,快来抢沙发吧!