返回探索
指南VibeFix 编辑部更新于 2026年10月17日

别让客户收到乱码账单:vibe 项目的 PDF/报表导出实战

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

办公桌上摊开的纸质账单与笔记本电脑,象征 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。多数线上系统是两者混用——先渲染、再加工。下面这张对照图把决策逻辑一次讲透:

两种 PDF 生成技术路线的决策对照条形图:Puppeteer Page.pdf 与 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 属于双保险。

中文排版:字体、换行、子集化

中文乱码只有三个根因,记住这个排查顺序能省你一下午:

  1. 服务器上没有中文字体。headless Chrome 不会自己变出汉字。Docker 镜像里装 fonts-noto-cjk(Debian/Ubuntu:apt-get install fonts-noto-cjk),或在模板里用 @font-face 引用随包发布的 woff2。
  2. 字体没加载完就打印了。见上例的 document.fonts.ready + waitForFonts。
  3. 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();
服务端 PDF 生成流水线架构图:业务数据与 HTML 模板经服务端渲染、Puppeteer Page.pdf 生成、pdf-lib 后处理,最后上传对象存储并经 CDN 分发给客户图 2:服务端 PDF 生成流水线——前四步是本篇的"生成",绿色阶段是存储指南的"存与送"。

为什么必须是服务端生成?先回答 window.print()

几乎每个 vibe 开发者都问过:"浏览器里直接 window.print() 让用户自己存 PDF,不就行了吗?"行,但只行在 demo 阶段。生产环境有四个理由必须走服务端:

  1. 一致性不可控。用户用什么浏览器、装了什么字体、打印对话框里选了什么页边距,你一概管不了。同一份账单,客户 A 存出来是 A4、客户 B 存出来是 Letter,排版各漂移一点,客服解释成本全是你的。
  2. 没有程序化后处理。前端 print 拿不到文件字节,盖章、合并、写元数据、上传对象存储全都做不了——而这些恰恰是"正式文件"和"网页打印"的区别。
  3. 审计与留痕。发票、合同这类文件,系统必须留存"当时发出去的那一份"。服务端生成 = 天然归档;前端打印 = 发出去什么样,服务器永远不知道,出纠纷时拿不出证据。
  4. 大文件直接卡死标签页。几百页的报表在用户浏览器里渲染打印,轻则风扇狂转,重则标签页崩溃。把重活搬到服务端队列里,前端只管轮询进度。

所以分工很清楚:window.print() 留给"用户想随手存一份"的辅助入口;凡是要发给客户、要归档、要盖章的 PDF,一律走服务端流水线。这也是本篇通篇只讲服务端的原因。

大文件:流式生成,避免内存爆炸

上面的例子都是"整份 PDF 在内存里拼好再返回"。当账单明细有 5 万行、PDF 300 页时,两个地方会爆:

  1. Chromium 渲染进程内存:一个 300 页的 DOM,headless Chrome 吃掉 2–4GB 内存很常见,容器直接被 OOM killer 干掉。
  2. 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 分发指南》的地盘。这里只给交接契约,不抢戏:

  1. 生成服务把最终 PDF 上传到对象存储(S3 / R2 / OSS),key 按 invoices/{yyyy-mm}/{customerId}/{invoiceId}.pdf 组织;
  2. 给客户的不是文件直链,而是带过期时间的签名 URL(如 15 分钟有效),账单类敏感文件绝不做公开桶;
  3. 高频访问的报表模板/静态附件走 CDN 缓存,但每个客户的账单 URL 必须带身份参数、禁用 CDN 缓存,防止串号;
  4. 上传与"生成完成"状态更新放在同一个事务/消息里,避免"文件传了但订单状态没更新"的幽灵账单。

反过来,存储指南里"大文件分片上传"那一节,用的正是本篇分片生成的临时文件——两篇在这个 /tmp/report-*.pdf 上握手。

中文排版深水区:五个隐形坑

字体搞定之后,还有五个"不报错、但客户觉得你不专业"的隐形坑:

  1. 字体回退链写反了。font-family: 'Noto Sans SC', 'Inter', sans-serif 这种写法会让英文数字也走中文字体的西文部分,又宽又丑。正确顺序是西文在前、中文在后:'Inter', 'Noto Sans SC', sans-serif,浏览器按字符逐个回退,数字用 Inter、汉字用 Noto。
  2. 标点出现在行首。中文排版有"禁则":句号、逗号、右引号不能顶格出现在行首。CSS 用 line-break: strict 让浏览器遵守 CJK 禁则;另外中文引号请用直角引号「」而不是英文 "",细节决定质感。
  3. 数字列对不齐。账单金额列必须右对齐且小数点对齐,秘诀是 font-variant-numeric: tabular-nums(等宽数字)。¥ 符号统一用半角 ¥,别混进全角 ¥。
  4. 伪粗体发虚。中文字体通常只有 Regular / Bold 两档文件;CSS 写 font-weight: 600 时浏览器会"合成粗体"(把字形加粗描边),打印出来发虚。做法是像上例那样显式声明 @font-face font-weight: 700 的 Bold 文件,让 <strong> 有真字重可用。
  5. 中西文基线打架。一行里混着汉字和英文时,基线高度不一致会导致文字上下跳动。起步 line-height: 1.6,表头和金额这类短文本可用 flex 垂直居中,别依赖默认基线。

乱码排查手册:按顺序查这七步

客户截图里出现方框时,别凭感觉改配置,按这个顺序二分排查,每一步都能排除一类根因:

  1. 看 PDF 里到底嵌了什么字体。用 Chrome 直接打开 PDF,地址栏输 chrome://pdf 看文档属性的字体列表(或 Adobe Reader 的"文件 > 属性 > 字体")。如果中文字体显示"未嵌入",问题在生成侧;如果嵌入了还乱码,问题在字体文件本身。
  2. 查服务器字体。fc-list | grep -i "noto\|cjk" 有输出才算数。"本地 Mac 正常、Docker 里乱码"是最高频的案发现场——Mac 自带苹方,容器里什么都没有。
  3. 查字体加载时序。在模板里临时加一段 <script>document.fonts.ready.then(() => document.title = 'fonts-ok')</script>,生成后检查 page.title() 是不是 fonts-ok。
  4. 查 @font-face 路径。file:///app/assets/... 的路径在容器里真实存在吗?权限对吗?改用 base64 内嵌能立刻验证是不是路径问题。
  5. 查转义。PDF 里出现 &amp; 说明被双重转义;出现 <table> 明文说明没转义。去模板引擎的输出里对一眼原文。
  6. 查 print 媒体查询。有些 CSS 框架的 @media print 会把背景全关、字体全换。用 page.emulateMediaType('print') 后截图看一眼,确认和屏幕版一致。
  7. 最小复现。一张只写"你好世界"的单字测试页,能 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-face woff2;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评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

实拍:工业控制面板上的红色急停按钮
指南
上线不靠祈祷:vibe 项目的功能开关与应急关停实战

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

部署上线后端工程项目创作
实拍:程序员紧盯显示器上的代码逐行检查
指南
别再 JSON.parse 裸奔:AI 结构化输出的校验与修复实战

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

后端工程项目创作独立开发
协同编辑器示意图:同一文档上多个带名字标签(Sophia、Charlie)的彩色光标同时编辑
指南
两个人同时改同一行:vibe 项目的多人实时协同编辑实战

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

开发工作流数据工作流团队协作