别让客户收到乱码账单:vibe 项目的 PDF/报表导出实战
账单发到客户手里却变成乱码?本篇教你用 Puppeteer Page.pdf 做中文排版、pdf-lib 做合并盖章,覆盖字体子集化、表格跨页、页眉页码、大文件分片生成与沙箱安全,一次搞定 vibe 项目的 PDF 成品交付。

划界说明 · 先读这段,避免走错片场。本系列有三篇相邻指南,分工如下:《数据导入导出指南》讲的是数据交换——CSV / Excel 的导入导出,给"系统之间传数据"用;《文件存储与 CDN 分发指南》讲的是存与送——对象存储、签名 URL、CDN 加速,给"文件存哪、怎么发给用户"用。本篇只讲"生成":服务端如何把业务数据变成一份排版精美、中文不乱码、可以直接发给客户的 PDF(账单、发票、合同、报表)。三篇的交接点在本篇最后一节说清楚。
为什么 PDF 是 vibe 项目的"交付刚需"
vibe 项目最容易翻车的时刻,往往不是功能做不出来,而是第一次把东西交到客户手里。你在浏览器里调得漂漂亮亮的账单页,客户一打印、一转发,中文变成方框、表格被拦腰截断、页码全无——信任当场清零。PDF 之所以是"成品交付"的标准格式,恰恰因为它所见即所得、离线可读、难以篡改:排版被冻结在文件里,不随设备和网络变化。
对 vibe 开发者来说,PDF 导出还有一层特殊意义:它是你和"草台班子感"之间的一道墙。一个能稳定输出专业账单的系统,客户会默认它"背后是正经工程"。本篇就教你砌这道墙,技术路线只有两条主流:
- Puppeteer Page.pdf(HTML → PDF):用 headless Chrome 把你写好的 HTML 模板渲染成 PDF。排版能力 = 浏览器排版能力,CSS 怎么写,PDF 就长什么样。官方 API 见
Page.pdf()。 - pdf-lib(程序化绘制 / 后处理):纯 JS 库,在 Node 里直接创建、修改、合并 PDF:填表单、盖章、加页码、拼多个文件、写元数据。
一句话选型:排版用 Puppeteer,加工用 pdf-lib。多数线上系统是两者混用——先渲染、再加工。下面这张对照图把决策逻辑一次讲透:
图 1:两种技术路线的决策对照——排版还原用 Puppeteer,程序化加工用 pdf-lib,多数线上系统两者混用。
实战一:用 Puppeteer 把 HTML 账单变成 PDF
page.pdf(options) 的官方签名是 pdf(options?: PDFOptions): Promise<Uint8Array>,默认使用 print 媒体类型渲染页面(想用 screen 样式,先调 page.emulateMediaType('screen'))。返回值是字节数组,不传 path 就只在内存里给你,想落盘就传 path。
最小可运行示例
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox', // 容器/云端 root 用户必需
'--disable-setuid-sandbox',
'--disable-dev-shm-usage', // /dev/shm 太小会崩溃,改用 /tmp
'--font-render-hinting=none',
],
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
// 等字体就绪:中文 PDF 乱码的一大元凶是字体还没加载完就打印了
await page.evaluateHandle('document.fonts.ready');
const pdfBytes = await page.pdf({
format: 'A4',
printBackground: true, // 背景色/条纹表格需要它
displayHeaderFooter: true,
headerTemplate: '<span></span>', // 留空则不占页眉空间
footerTemplate: `
<div style="width:100%;font-size:9px;color:#666;
display:flex;justify-content:space-between;
padding:0 40px;">
<span>XX 公司 · 对账单</span>
<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>`,
margin: { top: '60px', bottom: '60px', left: '40px', right: '40px' },
timeout: 30_000,
});
// pdfBytes 是 Uint8Array:可直接 res.send(Buffer.from(pdfBytes)),
// 也可 fs.writeFile 落盘后走上传链路(见第七节)
} finally {
await browser.close();
}
几个官方文档口径的关键点,逐个核对过:
format默认是letter,国内账单请显式传'A4';它优先级高于width/height,而 CSS 里写了@page { size: ... }时,把preferCSSPageSize: true才会生效。- 页眉页脚模板里只有五个特殊 class 会被注入值:
date(打印日期)、title(文档标题)、url、pageNumber(当前页)、totalPages(总页数)。页码只能在这里做,HTML 正文里拿不到"第几页"。 - 默认打印会"优化"颜色(省墨模式);品牌色必须原样呈现时,在 CSS 里加
-webkit-print-color-adjust: exact。 waitForFonts选项默认 true,会等document.fonts.ready;上面示例里手动 evaluate 属于双保险。
中文排版:字体、换行、子集化
中文乱码只有三个根因,记住这个排查顺序能省你一下午:
- 服务器上没有中文字体。headless Chrome 不会自己变出汉字。Docker 镜像里装
fonts-noto-cjk(Debian/Ubuntu:apt-get install fonts-noto-cjk),或在模板里用@font-face引用随包发布的 woff2。 - 字体没加载完就打印了。见上例的
document.fonts.ready+waitForFonts。 - PDF 阅读器缺字体且文件里没嵌入。Chromium 打印时默认会把用到的字形嵌入 PDF,一般没问题;但如果你用了系统字体做"伪装",换台机器打开就可能回退成方框。稳妥做法:模板里只用
@font-face声明的 Web 字体。
/* 账单模板的字体与分页 CSS(print 媒体类型下生效) */
@font-face {
font-family: 'Noto Sans SC';
src: url('file:///app/assets/fonts/NotoSansSC-Regular.woff2') format('woff2');
font-display: block;
}
body { font-family: 'Noto Sans SC', sans-serif; }
/* 中文换行:允许在任意 CJK 字符间断行,英文单词不断碎 */
.bill-table td { overflow-wrap: anywhere; word-break: normal; }
/* 表格跨页三件套 */
thead { display: table-header-group; } /* 每页重复表头 */
tfoot { display: table-footer-group; } /* 每页重复表尾(合计行) */
tr { page-break-inside: avoid; } /* 行内不截断 */
/* 想让"合计"区块永远跟表格尾部在一起 */
.summary { page-break-inside: avoid; }
关于字体子集化:完整版 Noto Sans SC 动辄十几 MB,全量嵌入会让每份账单 PDF 肿到几 MB。Chromium 在 page.pdf() 时只嵌入实际用到的字形(自动子集化),所以走 Puppeteer 路线你基本不用操心;但如果你自己用 @font-face 挂了一个 20MB 的 ttc,渲染进程加载会变慢。生产建议:构建期用 fonttools 的 pyftsubset 按"常用汉字 + 数字 + 货币符号"预切一个 2–3MB 的 woff2,模板只引用它。
页眉页脚与页码的正确姿势
新手最常犯的错误是在 HTML 正文里手写"第 X 页",然后发现总页数永远算不对。记住铁律:页眉页脚只用 displayHeaderFooter + headerTemplate/footerTemplate 做,正文里不碰页码。另外注意两点:
- 模板的 HTML 必须是一整段合法 HTML 字符串,且 Puppeteer 不会给你加载外部 CSS——样式全部写内联
style。 - 页眉页脚区域的高度由
margin.top / margin.bottom决定:模板内容如果超出 margin,会被裁掉而不是自动撑高。先定 margin,再写模板。
一份能直接用的账单模板骨架
把上面的 CSS 和页脚模板拼起来,一份最小可用的中文账单 HTML 长这样。注意三个细节:lang="zh-CN" 让断行按 CJK 规则走;金额列用等宽数字;备注里的用户输入在服务端先转义再插值。
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8">
<style>
@font-face { font-family:'Noto Sans SC';
src:url('file:///app/assets/fonts/NotoSansSC-Regular.woff2') format('woff2'); }
@font-face { font-family:'Noto Sans SC'; font-weight:700;
src:url('file:///app/assets/fonts/NotoSansSC-Bold.woff2') format('woff2'); }
body { font-family:'Noto Sans SC',sans-serif; color:#1a1a1a; }
.amount { font-variant-numeric: tabular-nums; text-align:right; }
thead { display:table-header-group; }
tr { page-break-inside:avoid; }
</style></head>
<body>
<h1>对账单</h1>
<p>单号 INV-202610-088 · 开票日期 2026-10-17</p>
<p>客户:杭州星尘科技有限公司</p>
<table class="bill-table">
<thead><tr><th>项目</th><th>数量</th><th>单价</th><th>金额</th></tr></thead>
<tbody>
<tr><td>云端渲染时长</td><td class="amount">320</td>
<td class="amount">¥ 12.50</td><td class="amount">¥ 4,000.00</td></tr>
</tbody>
<tfoot><tr><td colspan="3">合计</td><td class="amount">¥ 128,400.00</td></tr></tfoot>
</table>
<div class="summary">备注:请于 15 日内付款,逾期按日加收万分之五。</div>
</body></html>
实战二:用 pdf-lib 做后处理(合并 / 盖章 / 元数据)
Puppeteer 负责"长得好看",pdf-lib 负责"手续齐全"。它的官方定位就是创建与修改 PDF:PDFDocument.create() 新建、PDFDocument.load(bytes) 读取现有文件,save() 序列化回 Uint8Array。下面是账单场景最常用的三段代码,API 签名均按官方 README 核对过。
合并:把封面、账单明细、条款拼成一份
import { PDFDocument } from 'pdf-lib';
async function mergePdfs(buffers /* Uint8Array[] */) {
const out = await PDFDocument.create();
for (const bytes of buffers) {
const src = await PDFDocument.load(bytes);
// copyPages(pdf, indices):把源文档指定页复制到目标文档
const pages = await out.copyPages(src, src.getPageIndices());
pages.forEach((p) => out.addPage(p));
}
out.setTitle('2026-10 对账单');
out.setAuthor('XX 公司财务系统');
out.setSubject('月度对账单');
return out.save(); // Promise<Uint8Array>
}
setTitle / setAuthor / setSubject 不是摆设:客户在文件管理器里预览、归档检索时,靠的就是这些元数据。vibe 项目常年忽略它们,专业度就是从这些细节漏掉的。
盖章:在每页右下角打"已审核"水印
import { PDFDocument, rgb, degrees, StandardFonts } from 'pdf-lib';
async function stamp(pdfBytes, text = '已审核') {
const doc = await PDFDocument.load(pdfBytes);
// 注意:StandardFonts 只含拉丁字体;中文印章文字见下一段嵌入自定义字体
const font = await doc.embedFont(StandardFonts.HelveticaBold);
for (const page of doc.getPages()) {
const { width, height } = page.getSize();
page.drawText(text, {
x: width - 140, y: 90,
size: 28, font,
color: rgb(0.8, 0.15, 0.15),
rotate: degrees(-18),
opacity: 0.55,
});
// 顺手画个方框描边
page.drawRectangle({
x: width - 160, y: 70, width: 120, height: 44,
borderColor: rgb(0.8, 0.15, 0.15), borderWidth: 2, opacity: 0.55,
});
}
return doc.save();
}
中文表单填充:嵌入自定义字体是必经之路
这是 pdf-lib 中文场景的最大坑:StandardFonts 里的 14 种标准字体一个汉字都不认识,直接 drawText('张三') 会抛错或输出乱码。正确做法是嵌入 TTF/OTF,并注册 fontkit 做子集化(否则中文字体全量嵌入,文件体积爆炸):
import { PDFDocument } from 'pdf-lib';
import * as fontkit from '@napi-rs/fontkit';
import fs from 'node:fs';
const doc = await PDFDocument.create();
doc.registerFontkit(fontkit); // 没有这行,embedFont(中文ttf) 会失败
const fontBytes = fs.readFileSync('./assets/NotoSansSC-Regular.ttf');
const cnFont = await doc.embedFont(fontBytes /* 自动子集化,只打包用到的字 */);
const page = doc.addPage([595.28, 841.89]); // A4,单位 point
page.drawText('客户名称:杭州星尘科技有限公司', {
x: 50, y: 780, size: 14, font: cnFont,
});
// 测量文字宽度做右对齐(pdf-lib 原生支持)
const amount = '¥ 128,400.00';
const w = cnFont.widthOfTextAtSize(amount, 14);
page.drawText(amount, { x: 545.28 - 50 - w, y: 740, size: 14, font: cnFont });
const bytes = await doc.save();
registerFontkit + embedFont 自动子集化,是 pdf-lib 官方 README "Font Subsetting" 一节明确支持的行为:只把用到的字形打包进 PDF,一份纯中文单页账单通常只有几十 KB。
表单填充与压平:让模板 PDF 变成正式文件
很多公司的合同/申请表是设计好的 PDF 模板(带 AcroForm 字段),vibe 项目要做的是程序化填值。注意中文这一行:填完必须调 updateAppearances 刷新字段外观,否则阅读器里看到的还是空白;发给客户前再 flatten() 压平,字段变成不可编辑的页面内容,防篡改:
import { PDFDocument } from 'pdf-lib';
import * as fontkit from '@napi-rs/fontkit';
import fs from 'node:fs';
const doc = await PDFDocument.load(fs.readFileSync('./assets/contract-template.pdf'));
doc.registerFontkit(fontkit);
const cnFont = await doc.embedFont(fs.readFileSync('./assets/NotoSansSC-Regular.ttf'));
const form = doc.getForm();
form.getTextField('customerName').setText('杭州星尘科技有限公司');
form.getTextField('amount').setText('¥ 128,400.00');
form.updateAppearances(cnFont); // 用中文字体重绘所有字段外观,关键一步
form.flatten(); // 压平:字段变为静态内容,不可再编辑
const bytes = await doc.save();
图 2:服务端 PDF 生成流水线——前四步是本篇的"生成",绿色阶段是存储指南的"存与送"。
为什么必须是服务端生成?先回答 window.print()
几乎每个 vibe 开发者都问过:"浏览器里直接 window.print() 让用户自己存 PDF,不就行了吗?"行,但只行在 demo 阶段。生产环境有四个理由必须走服务端:
- 一致性不可控。用户用什么浏览器、装了什么字体、打印对话框里选了什么页边距,你一概管不了。同一份账单,客户 A 存出来是 A4、客户 B 存出来是 Letter,排版各漂移一点,客服解释成本全是你的。
- 没有程序化后处理。前端 print 拿不到文件字节,盖章、合并、写元数据、上传对象存储全都做不了——而这些恰恰是"正式文件"和"网页打印"的区别。
- 审计与留痕。发票、合同这类文件,系统必须留存"当时发出去的那一份"。服务端生成 = 天然归档;前端打印 = 发出去什么样,服务器永远不知道,出纠纷时拿不出证据。
- 大文件直接卡死标签页。几百页的报表在用户浏览器里渲染打印,轻则风扇狂转,重则标签页崩溃。把重活搬到服务端队列里,前端只管轮询进度。
所以分工很清楚:window.print() 留给"用户想随手存一份"的辅助入口;凡是要发给客户、要归档、要盖章的 PDF,一律走服务端流水线。这也是本篇通篇只讲服务端的原因。
大文件:流式生成,避免内存爆炸
上面的例子都是"整份 PDF 在内存里拼好再返回"。当账单明细有 5 万行、PDF 300 页时,两个地方会爆:
- Chromium 渲染进程内存:一个 300 页的 DOM,headless Chrome 吃掉 2–4GB 内存很常见,容器直接被 OOM killer 干掉。
- Node 进程内存:
page.pdf()返回的Uint8Array+ pdf-lib 合并时的多次拷贝,峰值可能是文件体积的 3–5 倍。
工程上的标准解法是分片生成 + 流式落盘:
// 策略:按客户/月份分片并行渲染,落盘为临时文件,最后用 pdf-lib 合并
import { PDFDocument } from 'pdf-lib';
import fs from 'node:fs/promises';
async function buildHugeReport(chunks) {
const tmpFiles = [];
const pool = new WorkerPool(2); // 并发 2 个 Chromium,视内存调整
try {
for (const chunk of chunks) {
const file = `/tmp/report-${chunk.id}.pdf`;
await pool.run(renderChunkPdf, chunk, {
path: file, // page.pdf 的 path 选项:直接写盘,不经过 Node 内存
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
footerTemplate: footerWithPageOffset(chunk.pageOffset),
// 页码连续:每片知道自己的起始页码,模板里直接写死数字
});
tmpFiles.push(file);
}
const out = await PDFDocument.create();
for (const f of tmpFiles) {
const src = await PDFDocument.load(await fs.readFile(f));
(await out.copyPages(src, src.getPageIndices()))
.forEach((p) => out.addPage(p));
}
const final = await out.save();
await fs.writeFile('/tmp/report-final.pdf', final);
return '/tmp/report-final.pdf';
} finally {
await pool.close();
await Promise.allSettled(tmpFiles.map((f) => fs.unlink(f)));
}
}
要点说明:
page.pdf({ path })让 Chromium 直接写盘,是官方支持的内存优化——大文件场景永远传path,别拿Uint8Array在内存里倒手。- 分片后"总页数"对不上了:做法是渲染前先算好每片的
pageOffset(需要预估页数,或先快速渲染一遍只计数),页脚模板里写死"第 N 页 / 共 M 页"。 - 这种任务必须丢进队列异步做(BullMQ /pg-boss),HTTP 请求里同步等 300 页 PDF 是自杀行为。生成完通过站内信/邮件把下载链接推给客户。
沙箱安全:headless Chrome 不是随便跑的
Puppeteer 启动的是一个完整的 Chromium,模板 HTML 里可能混入用户输入(客户名称、商品备注)。这意味着 PDF 渲染服务 = 一个"渲染不可信 HTML"的服务,安全基线必须拉满:
- 资源限制:容器层面设 memory limit;Chromium 加
--disable-dev-shm-usage(/dev/shm 默认 64MB,大页面直接崩溃)。并发数按"每实例预留 512MB–1GB"来估,别无脑起 10 个 browser。 - 超时熔断:
page.pdf({ timeout: 30_000 })+ 外层AbortSignal.timeout()双保险;单个任务卡死不能拖住整个队列。 - 模板注入:所有插值先转义(
<>&),模板引擎开自动转义;渲染前可跑一遍 DOMPurify。攻击者能控制 HTML,就能在你的内网发起请求(SSRF)——Chromium 的网络访问要收敛:--disable-features=...不够的话,用page.setRequestInterception(true)把非白名单请求全部 abort。 - 文件协议收敛:模板里用
file://引用本地字体时,确保 Chromium 的工作目录里没有敏感文件可被file://读到;更稳的是把字体做成 base64 内嵌进@font-face,彻底不碰文件协议。
成本账:一次 PDF 渲染到底花多少钱
vibe 项目多是独立开发者在扛服务器账单,这笔账得算清楚。一个常驻 headless Chromium 实例(2 核 4G 容器)一个月约几十元;真正的成本在大并发:
- 内存是定价锚。单实例同时渲染 2–3 个中等账单是舒适区;超过就加实例或排队。按这个并发数反推你需要的容器规格,别按"峰值请求数"去买机器。
- 去重是最便宜的优化。同一客户重复点击"导出",用"模板版本 + 数据指纹"做缓存键,命中直接返回上次的文件,Chromium 连启动都不用。账单类文件天然适合这个策略——数据不变,文件就不变。
- Serverless 要算冷启动。把 Puppeteer 塞进 Lambda/云函数时,Chrome 二进制约 120MB+,冷启动数秒起,且函数超时上限(通常 15 分钟)卡着大文件任务。结论:小文件、突发流量适合 Serverless;稳定的批量对账单,老老实实跑常驻容器 + 队列。
- 字体子集化也是在省钱。文件从 20MB 压到 200KB,对象存储的存储费和下行流量费跟着降两个数量级,还附带打开更快。
交接:生成之后,怎么"存"和"送"(衔接存储指南)
PDF 生成只是流水线的前半段,后半段属于《文件存储与 CDN 分发指南》的地盘。这里只给交接契约,不抢戏:
- 生成服务把最终 PDF 上传到对象存储(S3 / R2 / OSS),key 按
invoices/{yyyy-mm}/{customerId}/{invoiceId}.pdf组织; - 给客户的不是文件直链,而是带过期时间的签名 URL(如 15 分钟有效),账单类敏感文件绝不做公开桶;
- 高频访问的报表模板/静态附件走 CDN 缓存,但每个客户的账单 URL 必须带身份参数、禁用 CDN 缓存,防止串号;
- 上传与"生成完成"状态更新放在同一个事务/消息里,避免"文件传了但订单状态没更新"的幽灵账单。
反过来,存储指南里"大文件分片上传"那一节,用的正是本篇分片生成的临时文件——两篇在这个 /tmp/report-*.pdf 上握手。
中文排版深水区:五个隐形坑
字体搞定之后,还有五个"不报错、但客户觉得你不专业"的隐形坑:
- 字体回退链写反了。
font-family: 'Noto Sans SC', 'Inter', sans-serif这种写法会让英文数字也走中文字体的西文部分,又宽又丑。正确顺序是西文在前、中文在后:'Inter', 'Noto Sans SC', sans-serif,浏览器按字符逐个回退,数字用 Inter、汉字用 Noto。 - 标点出现在行首。中文排版有"禁则":句号、逗号、右引号不能顶格出现在行首。CSS 用
line-break: strict让浏览器遵守 CJK 禁则;另外中文引号请用直角引号「」而不是英文 "",细节决定质感。 - 数字列对不齐。账单金额列必须右对齐且小数点对齐,秘诀是
font-variant-numeric: tabular-nums(等宽数字)。¥ 符号统一用半角 ¥,别混进全角 ¥。 - 伪粗体发虚。中文字体通常只有 Regular / Bold 两档文件;CSS 写
font-weight: 600时浏览器会"合成粗体"(把字形加粗描边),打印出来发虚。做法是像上例那样显式声明@font-face font-weight: 700的 Bold 文件,让<strong>有真字重可用。 - 中西文基线打架。一行里混着汉字和英文时,基线高度不一致会导致文字上下跳动。起步
line-height: 1.6,表头和金额这类短文本可用 flex 垂直居中,别依赖默认基线。
乱码排查手册:按顺序查这七步
客户截图里出现方框时,别凭感觉改配置,按这个顺序二分排查,每一步都能排除一类根因:
- 看 PDF 里到底嵌了什么字体。用 Chrome 直接打开 PDF,地址栏输
chrome://pdf看文档属性的字体列表(或 Adobe Reader 的"文件 > 属性 > 字体")。如果中文字体显示"未嵌入",问题在生成侧;如果嵌入了还乱码,问题在字体文件本身。 - 查服务器字体。
fc-list | grep -i "noto\|cjk"有输出才算数。"本地 Mac 正常、Docker 里乱码"是最高频的案发现场——Mac 自带苹方,容器里什么都没有。 - 查字体加载时序。在模板里临时加一段
<script>document.fonts.ready.then(() => document.title = 'fonts-ok')</script>,生成后检查page.title()是不是fonts-ok。 - 查
@font-face路径。file:///app/assets/...的路径在容器里真实存在吗?权限对吗?改用 base64 内嵌能立刻验证是不是路径问题。 - 查转义。PDF 里出现
&说明被双重转义;出现<table>明文说明没转义。去模板引擎的输出里对一眼原文。 - 查 print 媒体查询。有些 CSS 框架的
@media print会把背景全关、字体全换。用page.emulateMediaType('print')后截图看一眼,确认和屏幕版一致。 - 最小复现。一张只写"你好世界"的单字测试页,能 10 秒内区分"环境问题"还是"模板问题"。
FAQ:五个最高频的灵魂拷问
Q:本地 Mac 好好的,一上 Docker 就乱码? A:Mac 自带苹方/冬青黑体,容器里没有。排查手册第 2 步,fc-list 见分晓。根治:构建期 apt-get install fonts-noto-cjk 或模板自带 woff2。
Q:一份账单 PDF 干到 20MB,怎么瘦身?
A:三大元凶——全量嵌入的 ttc 字体、未压缩的 PNG 截图、Chromium 默认的图片质量。字体走子集化(本篇两节都讲了),图片在模板里先用构建脚本压成 JPEG 80%,通常能压到 200KB 以内。
Q:页眉页脚的中文也乱码?
A:header/footer 模板是独立的小文档,不继承正文的 @font-face。样式全部内联,字体声明在模板里再写一遍。
Q:并发一高 Chromium 就 OOM,怎么限流?
A:不要每次请求 launch 一个 browser。用一个常驻 browser + browser.createBrowserContext() 隔离任务(或直接复用 page 池),队列并发数按内存预算卡死,超出的排队而不是硬起。
Q:客户说"文件打不开"?
A:先确认不是传输截断(对比文件大小与 Content-Length);再确认没有用实验性加密选项;最后让客户换个阅读器试——某些老旧打印机驱动只认 PDF 1.4,page.pdf() 默认输出较新版本时会有兼容问题,必要时用 pdf-lib 重存一遍降版本。
上线前检查清单
- ☐ 服务器装了 CJK 字体,或模板自带
@font-facewoff2;staging 环境实际打开 PDF 抽查中文 - ☐
document.fonts.ready/waitForFonts已落实,而非"本地能跑就行" - ☐ 页眉页脚用
displayHeaderFooter模板实现,margin 预留了足够高度 - ☐ 长表格:
thead重复表头、tr不截断,已用 200 行以上数据实测跨页 - ☐ pdf-lib 中文场景注册了 fontkit 并嵌入了子集化字体;元数据(标题/作者)已写
- ☐ 大文件走分片 +
path落盘 + 队列异步,不在请求线程里同步生成 - ☐ Chromium 有内存上限、超时熔断、请求拦截;模板插值全部转义
- ☐ 账单 URL 是签名短效链接,不可被 CDN 缓存、不被搜索引擎收录(
X-Robots-Tag: noindex)
一句话总结
别让客户收到乱码账单,本质上是三件事:渲染前把字体搞定(CJK 字体 + 等加载完 + 子集化)、排版交给浏览器、手续交给 pdf-lib(页码模板、合并盖章、元数据)、大文件分片落盘、渲染服务按沙箱标准隔离。这三件做到了,你的 vibe 项目在客户眼里就是"正经系统";生成之后的文件怎么存、怎么安全地送到客户手上,接下去看《文件存储与 CDN 分发指南》,而如果你要处理的是 CSV/Excel 的批量导入导出,回看《数据导入导出指南》。
评论 (0)
相关文章

功能开关把部署和发布解耦:代码先上,功能后开。四种开关类型与选型矩阵、一张表的最小实现、兜底铁律(新功能默认关、止血开关冻结)、开关腐烂治理,以及开关与灰度发布如何配合。

prompt 是建议,不是约束。AI 结构化输出可靠性的四层防线:模型原生结构化输出(OpenAI vs Anthropic,官方文档核实)、zod 校验的严格/宽松取舍、把报错原文喂回的修复重试、按成本选的降级兜底。

两个人同时改同一行是"一致性"问题,不是"实时"问题——"WebSocket 广播 JSON"的天真方案撑不过三个人(同行冲突、光标打架、断线丢数据)。本指南先与站内 realtime-features(单向推送)、offline-sync(单用户)两篇划界,再深入:OT vs CRDT 一句话讲透+选型对照表(结论先行:一人团队直接选 Yjs)、Y.Doc/Awareness/Provider 三件套(30 行客户端+40 行带鉴权的 y-websocket 服务端)、房间级 ACL 与快照兜底防恶意客户端、生产坑(大文档快照/分片、Y.UndoManager 语义、按席位计费),最后附 5 项上线验收清单。