Tuta 的 PDF/A 生成器:客户端命令式 PDF 生成架构与源码解析

原创2026-09-26 23:59:18153 阅读
文章标签:协同办公密码学

Tuta 的 PDF/A 生成器:客户端命令式 PDF 生成架构与源码解析

导读

本文围绕 Tuta(tutanota)仓库中 src/applications/common/api/worker/pdf 目录下的 PDF/A 生成器展开。它是一套完全在浏览器/客户端侧运行、以"命令式"风格逐条累积绘制指令、最终组装出**归档级 PDF(PDF/A-1b)**的 TypeScript 实现,当前主要用于在客户端生成 Tuta 发票(详见 ../invoicegen)以及账号恢复文档(Recovery Kit)。读完本文,你将掌握该生成器的双层架构(高层 PdfDocument 绘制 API + 底层 PdfWriter 对象组装)、字体与图像资源嵌入机制、表格自动分页、二维码与多字节文本(地址字段)的降级渲染方案,以及如何在业务代码中实例化并生成 PDF。

目录定位:一个自洽的 PDF 运行时

src/applications/common/api/worker/pdf/
├── README.md              # 本文主体文档
├── PdfConstants.ts        # 枚举、字形宽度表、PDF 默认对象、XMP 元数据
├── PdfObject.ts           # PDF 对象(字典 + 序列化)
├── PdfStreamObject.ts     # 带流(stream)的 PDF 对象
├── PdfWriter.ts           # 底层:对象管理、xref/trailer、资源加载、写文件
├── PdfDocument.ts         # 高层:命令式绘制 API、分页、表格、地址字段
├── Deflater.ts            # 基于 CompressionStream 的 deflate 封装
└── qrSvg.ts               # 解析 rect-only SVG(QR 码)为绘制矩形

原文档的定位非常明确:"Classes related to generating archival grade PDFs with in an imperative style. Currently used to generate Tuta Invoices client-side (see ../invoicegen)." 也就是说,这是一套**命令式(imperative)**的 PDF 生成方案——调用方不必理解 PDF 对象图,只需按顺序调用 addText()、addTable()、addImage() 等绘制指令,生成器内部会把这些指令累积成 PDF 内容流并最终序列化为合法文件。

两个消费方都位于 src/applications/common/api/worker 下:

两者共用同一套 PDF 运行时,这正体现了该模块"工具库"的定位。

双层架构:PdfWriter 与 PdfDocument 的分工

生成器将"PDF 文件的物理结构"与"文档的绘制逻辑"严格分离:

  • PdfWriter(底层):只关心 PDF 文件的物理组装——对象编号、对象字典、流编码、xref 交叉引用表、trailer、文件头,以及外部资源的嵌入。它的职责注释明确写着 "manages the low-level building of a PDF document by managing objects and their relation to each other"(见 PdfWriter.ts)。
  • PdfDocument(高层):把用户的操作指令翻译成 PDF 内容流操作符(content stream operators)。它持有 PdfWriter 实例,构造函数中调用 setupDefaultObjects() 建立文档骨架;对外暴露 add...() / change...() 系列方法;create() 负责提交所有流并让 PdfWriter 写出最终文件(见 PdfDocument.ts)。

典型的实例化方式(各 Worker Locator 中一致,如 mail-app/workerUtils/worker/WorkerLocator.ts):

new PdfWriter(new TextEncoder())

构造后即可交给业务生成器:

const pdfWriter = new PdfWriter(new TextEncoder())
const generator = new PdfInvoiceGenerator(pdfWriter, invoice, invoiceNumber, customerId)
const pdfBytes: Uint8Array<ArrayBuffer> = await generator.generate()

PdfWriter 构造函数还接受可选的 customFetch,用于在非浏览器环境(如测试)中替换全局 fetch 来加载字体等资源。

关键常量与数据结构:PdfConstants 里的"PDF 骨架"

流编码与对象引用

PdfConstants.ts 定义了三种流编码:

枚举值 PDF 过滤器 用途
NONE 无 元数据等不需压缩的流
FLATE /FlateDecode 文本/图形内容流、字体、CMap、ICC 色彩配置
DCT /DCTDecode JPEG 图像(Logo、图标、地址图片)

对象引用通过 PdfObjectRef { refId } 以字符串 ID 互联,最终在写文件阶段统一解析为 objNumber 0 R 语法。字典值类型 PdfDictValue 支持字符串、引用、数组与嵌套字典四类。

PDF 默认对象(PDF_DEFAULT_OBJECTS)

任何可用文档都必须包含一组基础对象,它们被定义在 PDF_DEFAULT_OBJECTS 常量中(Object.freeze 冻结,见 PdfConstants.ts):

  • CATALOG:文档入口,/Type /Catalog,引用 PAGES、/SinglePage 页面布局、METADATA 与 OUTPUT_INTENT;
  • OUTPUT_INTENT:/S /GTS_PDFA1,配合 DestOutputProfile(指向 sRGB ICC)——这是满足 PDF/A 规范的关键对象之一;
  • RESOURCES:统一登记 4 个 XObject 图像(Im1~Im4)与 4 个字体(F1~F4);
  • FONT_REGULAR / FONT_BOLD / FONT_MONO_BOLD:三个内嵌 TrueType 字体(SourceSans3-Regular、SourceSans3-Bold、NotoSansMono-Bold),均采用 /WinAnsiEncoding,FirstChar 32、LastChar 255,并带显式 Widths 字形宽度数组;
  • FONT_INVISIBLE_CID 系:一个特殊的 Type0 字体(Helvetica + /Identity-H CMap + ToUnicode),用于"不可见但可选中复制"的文本层(详见下文地址字段一节)。

Widths 数组来自 regularFontWidths、boldFontWidths 与 monoFontWidths,单位是 1/1000 PostScript 点,按 Unicode 顺序从 0x20(空格)排到 0xff。这些数组由 Tuta 文档中的脚本预先生成,是 getWordLengthInPoints() 做文本测宽、右对齐/自适应缩小的基础。

元数据与 PDF/A 符合性

PDF_METADATA 是 XMP 元数据模板,含 {slotCreateDate}、{slotModifyDate} 两个占位符,在 PdfWriter.setupResourceObjects() 中被替换为当前 ISO 时间戳,并写入:

<pdfaid:conformance>B</pdfaid:conformance>
<pdfaid:part>1</pdfaid:part>
<pdf:Producer>Tuta PDF Generator</pdf:Producer>

即 PDF/A-1b(part 1, conformance B)。配合 OUTPUT_INTENT(sRGB 输出意图 + sRGB2014.icc 内嵌 ICC 配置),使生成文件满足归档级要求。

高层绘制 API:PdfDocument 的命令式指令集

PdfDocument 将所有绘制操作累积到两个"流缓冲":textStream(文本操作符)与 graphicsStream(图形操作符)。坐标单位是毫米,内部通过 mmToPSPoint()(乘以 2.834645688,即 72/25.4)换算为 PostScript 点;并通过变换矩阵 1 0 0 -1 0 ${mmToPSPoint(PAPER_HEIGHT)} 将 PDF 默认的左下角原点翻转为左上角原点、Y 轴向下(第四象限坐标系),与页面排版直觉一致。页面固定为 A4:PAPER_WIDTH = 210、PAPER_HEIGHT = 297(见 PdfDocument.ts)。

字体、字号与文本着色

  • PDF_FONTS 枚举:REGULAR=1、BOLD=2、INVISIBLE_CID=3、MONO_BOLD=4,分别对应资源字典里的 F1~F4。
  • changeFont(font, points):切换字体并设置字号,同时写入 Tf 与行距 TL(行距 = 字号 + 2);
  • changeFontSize(points):只改字号;changeTextGrayscale(grayScale):设置 0~1 的灰度(1 白、0 黑),实现灰阶文本;
  • changeTextRenderingMode(mode):对应 PDF 的 Tr 操作符,TEXT_RENDERING_MODE 提供 NORMAL=0 与 INVISIBLE=3。

文本放置:addText 与三种对齐

addText(text, position?, byteLength?) 是最基础的文本指令(PdfDocument.ts):

  • position 为 [x, y] 毫米坐标,省略时保持当前光标位置继续追加;
  • 每次放置都通过 Tm 操作符设置文本矩阵,并用十六进制编码的 <...> Tj 输出字形;
  • byteLength 参数默认 1(即 WinAnsi 单字节,如 "20" 代表空格)。注释明确警告:"Do not change it to more than 1 byte unless you can verify any text printed this way will be displayed correctly"——多字节字符在简单 PDF 字体下可能显示异常。

toUnicodePoint() 负责把字符串逐字符转为十六进制 Unicode 码点:单字节模式下,遇到 codePoint >= 256 的字符会打印警告并跳过(因为 WinAnsi 无法表达);双字节模式则按 charCodeAt(0).toString(16).padStart(4, "0") 输出 4 位十六进制。

围绕它提供了三种对齐:

  • addTextRightAlign(text, position, containerWidth):依据 getWordLengthInPoints() 测宽后,把文本右对齐到指定容器宽度内(发票表格右侧金额列即用此方法);
  • addTextCenterAlignAutoScaled(text, position, maxWidthMM):水平居中;当文本宽度超过 maxWidthMM(默认整页宽 210mm)时自动等比缩小字号直至能放下,随后恢复原字号;
  • addLineBreak():追加 T* 换行操作符。

getWordLengthInPoints() 用字形宽度表累加字符宽度:total += 1 / (1000 / widthsArray[index]),即每个字符的宽度(1/1000 点)累加后再乘以字号,得出整段文本的宽度(点)。这是所有对齐与自适应缩放的计算基础。

图形:圆角矩形、直线、图片与二维码

  • addRoundedRectangle({ topLeftMM, widthMM, heightMM, cornerRadiusMM, fillColor?, borderColor?, borderWidthMM? }):以 kappa 常数 0.55342925736 做三次贝塞尔曲线逼近圆角,顺时针构造路径,支持纯填充(f)、纯描边(s)或填充+描边(b)。要求 fillColor 与 borderColor 至少设置一个,否则抛出 ProgrammingError。颜色以 [r, g, b] 0~1 浮点形式给出(恢复文档中即用主题色 [143/255, 74/255, 78/255] 等渲染卡片);
  • addDrawnLine(fromPos, toPos):黑色直线(m ... l s);
  • addImage(image: PDF_IMAGES, position, dimensions):放置位图 XObject。PDF_IMAGES 枚举提供 TUTA_LOGO、ADDRESS、EDIT_ICON、CLOUD_ICON 四个预置图像。由于图像需要两次矩阵变换,方法会单独开/关图形状态(Q q ... Do Q q),避免影响其他图形元素;
  • addQrSvg(svgString, topLeftMm):把 rect-only 风格的 QR 码 SVG 解析为一系列黑色矩形,逐条输出 re 路径后统一 f 填充。解析逻辑在 qrSvg.ts:提取 <svg> 的 width/height、遍历所有 <rect>,跳过与画布等大的"背景矩形",其余矩形转成毫米坐标绘制。

表格渲染与自动分页

addTable(position, tableWidth, columns, data, rowsOnFirstPage = 4) 是本生成器最有工程含量的 API(PdfDocument.ts):

  • columns: TableColumn[]:每列声明 headerName 与 columnWidth(占表格总宽的比例);
  • data:二维字符串数组,每行长度必须与列数一致(不一致会输出错误日志);
  • 分页规则:1 个 InvoiceItem = 2 行表格(首行条目信息、次行日期)。首页最多容纳 ROWS_FIRST_PAGE_MULTIPLE = 24 行(12 个条目),后续每页 ROWS_N_PAGE = 50 行。若数据总量不超过 rowsOnFirstPage,则首页只渲染实际行数;否则首页直接排满 24 行;
  • 渲染流程:先 addTableHeader()(粗体 11pt 表头 + 表头下方分隔线),再逐页渲染行块;当剩余数据需要续页时调用 addPage() 并把新页的起始 Y 重置回 MARGIN_TOP;最后一页若空间不足(剩余行数 ≤ 24 或首页恰好排满 24 行)会自动追加一页,最后在表格底部画一条收尾直线,并返回表格在末页的高度,方便调用方在表格后继续排版其他内容。

addTableRow() 内部对第 0、1 列(如"数量"、"条目说明")用左对齐 addText,从第 2 列起用 addTextRightAlign 右对齐,这正是发票表格金额列靠右的由来。

地址字段:多字节文本的降级渲染

addAddressField(position, address) 解决一个现实难题:发票收件人地址可能包含中文、西里尔文等超过单字节(>255)的字符,而常规 TrueType 字体只支持 WinAnsi 单字节。

其处理策略(PdfDocument.ts):

  1. 用 areStringPartsOneByteLength() 检测地址各行是否全部为单字节;
  2. 若含多字节字符且浏览器支持 OffscreenCanvas:在 800×320 的画布上用 36px serif 逐行绘制地址(36px 是刻意与 PDF 12pt 字号对齐的经验值),convertToBlob({ type: "image/jpeg" }) 转为 JPEG,以 PDF_IMAGES.ADDRESS 图像(/Im2,DCT 编码)嵌入 PDF,图像尺寸取画布除以 8,保证 JPEG 分辨率适中;
  3. 与此同时,地址文本仍以不可见方式叠加渲染:切换到 TEXT_RENDERING_MODE.INVISIBLE 与 FONT_INVISIBLE_CID(Helvetica Type0 + Identity-H CMap),让文字"看不见但存在于 PDF 文本层、可被选择和复制"——这是 PDF/A 合规文本检索的常见做法;
  4. 若 OffscreenCanvas 不可用(如旧浏览器),则捕获异常并回退为普通文本渲染。

方法注释强调:无论走哪条路径,都必须先创建 IMG_ADDRESS 流对象,否则 RESOURCES 里的 Im2 引用无法解析。

页面生命周期

addPage() 每调用一次就新增一个 /Page 对象:首次调用时内容流为空直接建立;再次调用时先把上一页的 textStream/graphicsStream 渲染(deflate 压缩)成 TEXT_n、GRAPHICS_n 两个流对象,再建立新页并把两个流注册进该页的 Contents 数组,同时把新页挂到页树 PAGES 的 Kids 列表。

create() 收尾时提交未渲染的流、创建 PAGES 对象(含 Count),最后调用 pdfWriter.writePdfFile() 返回完整 PDF 的 Uint8Array。注意 PdfDocument 注释的约束:一份文档实例只能 create() 一次,不能复用。

底层文件组装:PdfWriter 如何写出合法 PDF

PdfWriter 承担了从"对象图"到"物理字节流"的全部转换(PdfWriter.ts):

  1. setupDefaultObjects():把 PDF_DEFAULT_OBJECTS 全部注册进引用表(createObject(dictionary, refId),重复 refId 会抛 ProgrammingError);
  2. createStreamObject():为带流对象设置 Filter(NONE 除外)与 Length 字典项;PdfStreamObject 序列化时按 ... obj <<...>> stream\n <字节> \nendstream endobj 结构写出,流字节原样拼接(见 PdfStreamObject.ts);
  3. setupResourceObjects():真正的高成本环节。通过 fetch(或注入的 customFetch)加载 8 个外部资源并做一次内存缓存(cachedResources):
/pdf/SourceSans3-Regular.ttf   /pdf/SourceSans3-Bold.ttf
/pdf/NotoSansMono-Bold.ttf     /pdf/sRGB2014.icc
/pdf/identity_h.cmap           /pdf/tutanota_logo_en.jpg
/pdf/edit.jpg                  /pdf/cloud.jpg

字体文件与 ICC、CMap 经 Deflater deflate 后以 FLATE 编码嵌入;Logo/图标以 DCT(JPEG)编码;PDF_METADATA 的时间戳占位符也在此替换; 4. writePdfFile():按 PDF 规范拼装四段——二进制文件头(%PDF-1.4 + 二进制注释行,十六进制 255044462d312e340a25e2e3cfd30a)、对象体(先统一 resolveReferences() 把 refId 解析为 对象号 0 R,再逐对象编码并计算字节偏移)、xref 交叉引用表(每个对象 20 字节条目 + 对象 0 的 0000000000 65535 f 哨兵项)、trailer(/Size、/Root 指向 CATALOG、/ID 唯一标识符——由 "FACEBEEF" + Date.now() + Date.now() 生成)。最终以 %%EOF 结尾。

Deflater 基于浏览器原生 CompressionStream("deflate") 实现(见 Deflater.ts),因此该生成器依赖支持 Compression API 的运行时(现代浏览器 / Worker)。

两个真实消费场景

1. 发票生成器:PdfInvoiceGenerator

PdfInvoiceGenerator.ts 完整演示了高层 API 的编排。其 generate() 流程:

await this.doc.addPage()
this.doc.addImage(PDF_IMAGES.TUTA_LOGO, [25, MARGIN_TOP], [45, 15.7])
this.renderSideBarInfo()
await this.renderAddressField()
this.renderInvoiceInfo()
await this.renderInvoiceTable()
this.renderAdditional()
this.renderLegalDisclaimer()
return await this.doc.create()

值得注意的实现细节:

  • 构造函数根据 countryUsesGerman(this.invoice.country) 决定使用德语或英语文案(InvoiceTexts[languageCode]),字段从 InvoiceTexts.ts 读取;
  • 表格列定义:数量(宽 19.8)、条目(95.7)、单价(24.75)、总价(24.75)四列,列宽为毫米比例;
  • 表头、发票编号、客户 ID、"As agreed" 金额说明、法律声明等均通过 changeFont/addText/addLineBreak 组合渲染;
  • 该类注释明确了职责边界:"ONLY responsible for rendering the data it gets... If adjustments to the data must be made prior to rendering, then these should take place within the RenderInvoice service."——渲染与数据处理解耦。

2. 恢复套件:RecoveryDocumentGenerator

RecoveryDocumentGenerator.ts 展示了图形 API 的完整用法:

  • 用 qrcode-svg 库把 {mailAddress, recoveryCode} JSON 生成 QR 码 SVG,再经 doc.addQrSvg() 嵌入;
  • 用 addRoundedRectangle() 绘制主题色卡片(填充 + 描边 + 圆角);
  • 用 addTextCenterAlignAutoScaled() 居中放置邮箱地址与页脚提示;
  • 64 字符恢复码每 4 位一组、分 4 行以 MONO_BOLD 等宽粗体输出;
  • 通过 PDF_IMAGES.EDIT_ICON / CLOUD_ICON 点缀图标;
  • 坐标换算使用 pxToMm(px) = px * (210 / 595)(按 A4 595pt 宽度推算)。

该生成器同样以 new PdfDocument(pdfWriter) 起步、以 await this.doc.create() 结束,完整印证了"命令式指令 → 内部累积 → 一次性成文"的使用模型。

使用前提与限制(来自源码的客观约束)

  • 目标格式:A4 页面、PDF/A-1b 归档级(XMP pdfaid:part 1 / conformance B + sRGB OutputIntent),PDF 版本 1.4;
  • 运行时依赖:需要 CompressionStream(deflate)、fetch(可注入替换)与 OffscreenCanvas(多字节地址渲染才需要);TextEncoder 由调用方传入;
  • 字符集限制:常规字体仅支持 WinAnsi(单字节,≤255 码点),多字节文本只能走"Canvas 图片 + 不可见 CID 文本层"的降级路径;
  • 实例生命周期:PdfDocument 不可复用,一次 create() 后应废弃;
  • 资源路径:字体/ICC/CMap/图片固定从 /pdf/ 前缀加载(baseUrl 由 location 推导,非浏览器环境下需自行保证资源可达);
  • 使用场景边界:目前仅用于发票与恢复文档两类内部文档生成,其 API 设计以这两种文档的排版需求(表格、地址、二维码、圆角卡片)为驱动。

小结

Tuta 的 PDF 生成器是一个教科书式的"从零写 PDF"工程:PdfConstants 固化 PDF 规范骨架,PdfWriter 负责物理文件结构,PdfDocument 提供符合直觉的命令式排版 API,再以发票与恢复套件两个真实业务验证闭环。对于需要脱离重型第三方渲染器、在浏览器 Worker 中生成归档级 PDF 的场景,这份代码是极具参考价值的实现范本。深入阅读可从 PdfConstants.ts(对象骨架)→ PdfWriter.ts(文件组装)→ PdfDocument.ts(绘制指令)→ PdfInvoiceGenerator.ts(业务编排)的顺序展开。

登录后查看全文
tutanota