暗色模式

Document Builder 文档生成器 alpha 版本功能演示

技术教程 前端 JavaScript 资源分享 源码 网页源码
2026-07-02
17
0

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 党政机关公文标准

Screenshot 2026-07-02 at 12-17-29 Document Builder — Demo.png

  • 公司内部标准文档格式

Screenshot 2026-07-02 at 12-20-06 Document Builder — Demo.png

  • 组件演示

Screenshot 2026-07-02 at 12-30-20 Document Builder — Demo.png

插件

  • 纸张(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) 开始,大致分为以下几个步骤:

  1. 解析配置:读取 JSON 中的 themepaperorientation 等字段,确定纸张大小、方向和边距
  2. 创建离屏测量容器:在 Shadow DOM 中创建 .db-measure 容器,用于文本截断时的尺寸测量
  3. 遍历 content 数组:对每个 block 调用 render() 生成 DOM 元素,追加到当前页
  4. 分页检测:每次追加后检查页面是否溢出:

    • 若组件支持跨页截断(如段落),则用 TextSplitter 将文本拆为"留在本页"和"移到下页"两部分
    • 若不支持截断,将整个块移动到下一页
    • 若组件标记了 forcePageBreak,渲染后强制分页
  5. 应用页眉页脚:根据主题配置和分页段设置,为每页渲染页眉和页脚
  6. 销毁测量容器,触发 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:渲染上下文,包含 pageWidthpageHeight(像素值)、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 在离屏容器中做二分查找:

  1. 将待截断元素的克隆放入测量容器
  2. 用 TreeWalker 遍历所有文本节点(跳过 atomicSelector 标记的原子元素,如行内公式)
  3. 从最后一个文本节点开始,逐字符移除,每移除一个字符检查高度
  4. 找到"刚好放下"的分界点后,将文本拆为 fits 和 overflow 两部分
  5. 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

如果你对这类工具感兴趣,欢迎关注或参与贡献。

发表评论

暂无评论,快来抢沙发吧!