site logo

Marico's space

在 Cloudflare Worker 中填充 PDF 表单 —— 无需 Chromium、无需 Lambda

前端技术 2026-07-01 11:28:07 6

最近折腾了 Cloudflare Workers 的 PDF 表单填充,踩了几个坑,这篇把问题说清楚。你在 Workers 上要给发票、合同、政府表格这类 PDF 填数据时,习惯性会想到无头浏览器或者绑个 PDF 库到 Lambda 上。在 Workers 上这两条路都走不通——Chromium 跑不了,单独起个 Lambda 来渲染 PDF 又把事情搞复杂了。其实填充 PDF 表单就是一次 HTTP 调用的事,下面说清楚怎么做,以及为什么关键在于托管平台而不是 PDF 代码本身。

为什么无头浏览器不适合 Workers

过去十年用 JavaScript "生成 PDF" 的标准做法是 Puppeteer 驱动无头 Chrome:渲染 HTML,调用 page.pdf(),搞定。这个方案在普通 Node 服务器或者带 Chromium 层的胖 Lambda 上没问题,但在 Cloudflare Workers 上行不通,而且这不是缺个参数的问题,是架构层面的原因。Worker 运行在 V8 隔离环境(Isolate)里——和 Chrome 用的是同一个引擎,但底下没有操作系统。没有文件系统,没有能力启动子进程,每个请求还有严格的内存和 CPU 时间限制。无头 Chrome 是完整浏览器二进制文件,这些它全都要。你没法把一个约 150MB 的浏览器塞进 Worker,就算塞进去了,也没有 exec() 可以跑它。

AcroForm 填充——给支持表单的 PDF 已有字段写入值——根本不需要浏览器。字段已经在 PDF 里定义好了,你要做的只是设值然后扁平化,这本质上是纯字节操作。这种活儿配渲染引擎是杀鸡用牛刀,配无状态函数才是恰到好处。唯一的问题是这个函数跑在哪儿

为什么不用 Lambda

常见的退路是"把 PDF 相关的活儿放在 AWS Lambda 上,Worker 调用它"。这能跑,但你算算代价:为了一个功能跑两套运行时——Worker 负责接收请求,Lambda 就为了装个 PDF 库。你继承 Lambda 的冷启动问题,偏偏你的边缘应用就是要快的那条路径。每次文档处理都要走边缘 → us-east-1 → 边缘,意味着悉尼的用户填个表要跨太平洋打个来回。而且部署也分裂了——两个日志流、两套 IAM 权限、两套要同步的东西。这些都跟 PDF 没关系,全是平台层面的拖累,硬生生塞进一个选 Workers 就是为了避免这类问题的应用里。

更好的思路是把填充当作它本来的样子——一个无状态转换——然后调用一个跑在和你 Worker 同样分布式边缘平台上的托管端点。Worker 是你唯一需要部署的东西。

Worker 实现

完整代码如下。接收字段值的 JSON 请求体,从 R2 拉取 AcroForm 模板,调用 /api/fill-form,返回填充好的 PDF。全程无浏览器,无第二套运行时,大约 35 行代码。

// src/index.ts — Cloudflare Worker (module syntax)
export interface Env { TEMPLATES: R2Bucket; // bucket holding your blank AcroForm PDFs
} export default { async fetch(req: Request, env: Env): Promise { if (req.method !== 'POST') { return new Response('POST a JSON body of field values', { status: 405 }); } // 1. The values to write into the form's named fields. const fields = await req.json<Record<string, string>>(); // 2. Pull the blank template from R2 (cached at the edge after first read). const obj = await env.TEMPLATES.get('invoice-template.pdf'); if (!obj) return new Response('template missing', { status: 500 }); const templatePdf = await obj.arrayBuffer(); // 3. One call to PDFops. Field keys must match the PDF's AcroForm // field names — use /tools/inspect to list them if you're unsure. const fd = new FormData(); fd.append('pdf', new Blob([templatePdf], { type: 'application/pdf' }), 'template.pdf'); fd.append('fields', JSON.stringify(fields)); const resp = await fetch('https://pdfops.dev/api/fill-form', { method: 'POST', body: fd }); if (!resp.ok) { return new Response(`fill failed: ${await resp.text()}`, { status: 502 }); } // 4. Stream the filled PDF straight back to the caller. return new Response(resp.body, { headers: { 'Content-Type': 'application/pdf' }, }); },
};

这就是生产可用的形态。在 wrangler.toml 里绑定一个叫 TEMPLATES 的 R2 存储桶,放入一个支持表单的 PDF,wrangler deploy,然后 POST 一个字段值的 JSON 对象,就能拿到填充好的 PDF,自始至终没有第二个服务参与。resp.body 流式返回意味着 Worker 永远不会把整个文档缓冲到内存里——它把 PDFops 的响应直接管道传出去,就算大表单也能稳稳待在隔离环境的内存预算内。

两点需要注意。字段键名必须和 PDF 里 AcroForm 字段的名称一致——如果不确定有哪些字段,免费的 Form-Field Inspector 工具可以列出任意 PDF 的所有字段名。另外 R2 的 get() 首次读取后会在边缘缓存,所以后续请求不会每次都重新拉模板——稳态下就是一次到 fill-form 的 fetch 调用。

在真实应用里的位置

上面这个裸 Worker 是基础单元。实际场景里触发方式通常是 Webhook 或消息队列,输出会到存储再加一封邮件。这些本质上都是同一个模式,只是在填充步骤外面多绕了几圈:

无论哪种场景,填充都是同一次 fetch。变的是触发方式和目的地——PDF 处理的底层平台永远不变。

什么时候这个方案不适用

动手试试

端点是现成的,对任何 AcroForm PDF 都有效。在写 Worker 之前,先在终端里验证填充逻辑:

curl -X POST https://pdfops.dev/api/fill-form \ -F "pdf=@invoice-template.pdf" \ -F 'fields={"customer_name":"Acme Corp","invoice_no":"INV-1042","amount_due":"$2,400.00"}' \ -o filled-invoice.pdf

填充好的 PDF 直接返回。从这里开始,上面那个 Worker 就是同一套调用包上一层 fetch 处理器。Beta 阶段每个 IP 每月 100 次请求免费,无需注册。

有 Workers 相关的具体问题、遇到绑定的坑、或者想要的功能端点,waitlist 表单里留言,消息框是最快影响下个版本的方式。