夜雨聆风学习资料网

ARTICLE · 1317589

用 Cloudflare 免费版做一个 PDF 工具箱:合并、拆分、加水印,文件不出浏览器

用 Cloudflare 免费版做一个 PDF 工具箱:合并、拆分、加水印,文件不出浏览器

先看成品。这是我用 Cloudflare 免费版做的 PDF 工具箱,挂在我自己的子域名上,一共四个功能:合并 PDF、提取页面、图片转 PDF、加水印。最关键的一点是:文件全程只在你自己的浏览器里处理,不会发到任何服务器,断网都能用。

起因是每到交实习、报名材料的时候,大家都会遇到同一件事:简历、成绩单、获奖证书要合成一个 PDF,实习证明是手机拍的照片,得先转成 PDF,最后还要每页加一行「仅供入职审核使用」的水印,防止被拿去干别的。很多人第一反应是搜个在线 PDF 网站,把文件全传上去。可是上面有学号、成绩,还有证书照片,传到别人的服务器上,存多久、给谁看,你说了不算哈哈。

那问聊天机器人行不行?它能告诉你步骤,但它没法替你把三个 PDF 真的合成一个文件,更没法保证你的文件不离开电脑。这个工具箱是一个真能用的网页:打开就用,不注册、不装软件,手机上也能开。

这是《Cloudflare 从零到上线》第 23 集,全程免费版(Workers 静态资源),完整源码放在我博客的「项目开源」页了。

原理:Cloudflare 只负责送来页面和工具,真正干活的是你的浏览器

原理一句话:网页只负责把「工具」送到你的浏览器,活在你的浏览器里干。Cloudflare 上只放 5 个静态文件:页面、样式、app.js,还有开源的 PDF 库 pdf-lib。你选文件时,浏览器只是在本地读它;合并、提取、加水印都在内存里完成;最后生成一个本地下载链接,存到你自己的下载目录。整个项目没有任何后端代码,也就没有地方能接收你的文件。

光说「不上传」不算数,后面第六步我会用三种办法验证:断网测试、故意往外发请求看会不会被拦、再把全程的网络请求列出来数一数。

开始前准备

  • 一个已经接入 Cloudflare 的域名(免费套餐就行),工具箱挂在它的子域名上;
  • 电脑装好 Node.js 20,运行一次 npx wrangler login;
  • 新建一个文件夹 tangent-pdf,不需要数据库,也不用绑卡。

第一步:确认版本,装 pdf-lib

①Node 20 ②wrangler 4.86,版本对了再往下做

node -v; npx wrangler --version

我这里是 Node 20 和 wrangler 4.86。然后装 pdf-lib,它是开源的(MIT 协议),能在浏览器里读写 PDF。

①装好 pdf-lib ②把打包好的 pdf-lib.min.js 复制到 public/vendor

npm init -y
npm i pdf-lib@1.17.1
mkdir -p public/vendor && cp node_modules/pdf-lib/dist/pdf-lib.min.js public/vendor/ && ls -l public/vendor

注意这里没用网上的公共 CDN,而是把 pdf-lib 复制进自己的 public/vendor。一是国内访问更稳,二是后面安全策略可以只允许加载「自己域名下的脚本」,这点很重要。

第二步:写页面和 app.js

①整个项目就这 5 个文件 ②pdf-lib 放在 vendor 里

页面 index.html 就是四个标签,每个标签一块:选文件、文件列表、一个按钮。

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PDF 工具箱</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
  <h1 data-t="title">PDF 工具箱</h1>
  <p class="sub" data-t="sub">合并、提取页面、图片转 PDF、加水印 · 文件只在你的浏览器里处理,不上传</p>
</header>
<nav id="tabs">
  <button data-tab="merge" class="on" data-t="t_merge">合并 PDF</button>
  <button data-tab="pick" data-t="t_pick">提取页面</button>
  <button data-tab="img" data-t="t_img">图片转 PDF</button>
  <button data-tab="wm" data-t="t_wm">加水印</button>
</nav>
<main>
  <section id="merge" class="pane on">
    <label class="drop"><input type="file" id="mergeIn" accept="application/pdf" multiple><span data-t="pick_pdfs">点这里选择多个 PDF(可多选)</span></label>
    <ol id="mergeList" class="files"></ol>
    <button class="go" id="mergeGo" data-t="merge_go">合并并下载</button>
  </section>
  <section id="pick" class="pane">
    <label class="drop"><input type="file" id="pickIn" accept="application/pdf"><span data-t="pick_one">点这里选择一个 PDF</span></label>
    <ol id="pickList" class="files"></ol>
    <div class="row"><label for="pages" data-t="pages_lbl">要哪几页</label><input id="pages" placeholder="例如 1-3,5"></div>
    <button class="go" id="pickGo" data-t="pick_go">提取并下载</button>
  </section>
  <section id="img" class="pane">
    <label class="drop"><input type="file" id="imgIn" accept="image/jpeg,image/png" multiple><span data-t="pick_imgs">点这里选择 JPG / PNG 图片(可多选)</span></label>
    <ol id="imgList" class="files"></ol>
    <button class="go" id="imgGo" data-t="img_go">生成 A4 PDF 并下载</button>
  </section>
  <section id="wm" class="pane">
    <label class="drop"><input type="file" id="wmIn" accept="application/pdf"><span data-t="pick_one">点这里选择一个 PDF</span></label>
    <ol id="wmList" class="files"></ol>
    <div class="row"><label for="wmText" data-t="wm_lbl">水印文字</label><input id="wmText"></div>
    <div class="row"><label for="wmOp" data-t="wm_op">浓淡</label><input type="range" id="wmOp" min="10" max="60" value="25"><b id="wmOpV">25%</b></div>
    <button class="go" id="wmGo" data-t="wm_go">加水印并下载</button>
  </section>
  <p id="msg" role="status"></p>
</main>
<footer data-t="foot">没有服务器参与处理:断网后这个页面照样能用</footer>
<script src="/vendor/pdf-lib.min.js"></script>
<script src="/app.js"></script>
</body>
</html>

真正干活的是 app.js。我分了 8 段,每段前面都有注释,下面挑重点讲,完整代码贴在这一节最后。

app.js ①:选文件时只在本地读成 ArrayBuffer,交给 pdf-lib

第一个重点是「读文件」。file.arrayBuffer() 只是把你选的文件读进浏览器内存,然后交给 PDFDocument.load,能拿到页数。文件列表可以点 ↑ 调顺序、点 ✕ 删掉。

app.js ②:合并就是把每个文件的页面复制到一个新 PDF,再用 Blob 本地下载

第二个是「合并」。新建一个空的 PDF,用 copyPages 把每个文件的所有页依次复制进去。下载用的是 Blob:把生成的字节包成一个本地链接,点一下就保存,这一步同样不经过网络。

app.js ③:「1-3,6」这种写法解析成页码,输错了直接提示

第三个是「提取页面」。你输入「1-3,6」,代码把它拆成第 1、2、3、6 页,超出页数或者格式不对就直接提示,不会生成一个错的文件。中文逗号也认。

app.js ④:每张照片一页 A4,等比缩小、居中,不会被拉变形

第四个是「图片转 PDF」。JPG 和 PNG 各用对应的方法嵌进去,每张图一页 A4,按比例缩小、居中,四周留一点白边。

app.js ⑤:pdf-lib 的标准字体写不了中文,先用 canvas 画成 PNG 再铺满每页

第五个是「加水印」,这里有个坑。pdf-lib 自带的标准字体只能写英文,直接写中文会报错。我的办法是:先用浏览器的 canvas 把水印文字画成一张透明 PNG,再把这张图斜着、半透明地铺满每一页。好处是不用往 PDF 里塞几 MB 的中文字体,文件也不会变大多少。

完整的 app.js:

// PDF 工具箱:全部在浏览器里用 pdf-lib 处理,没有任何上传请求
const { PDFDocument, degrees } = PDFLib;
const $ = (s) => document.querySelector(s);

// ---------- 1. 中英文界面(按浏览器语言) ----------
const EN = !navigator.language.startsWith("zh");
const T = {
  title: "PDF Toolbox", sub: "Merge, pick pages, images to PDF, watermark · files never leave your browser",
  t_merge: "Merge PDFs", t_pick: "Pick pages", t_img: "Images to PDF", t_wm: "Watermark",
  pick_pdfs: "Click to choose PDFs (multiple)", pick_one: "Click to choose a PDF", pick_imgs: "Click to choose JPG / PNG images (multiple)",
  merge_go: "Merge and download", pages_lbl: "Pages", pick_go: "Extract and download", img_go: "Make A4 PDF and download",
  wm_lbl: "Text", wm_op: "Opacity", wm_go: "Watermark and download", foot: "No server touches your files: this page keeps working offline",
};
const M = EN
  ? { pages: (n) => `${n} pages`, need2: "Choose at least 2 PDFs", need1: "Choose a file first", badRange: "Page range is not valid, e.g. 1-3,5",
      out: (n, p) => `Done: ${n}, ${p} pages, downloaded`, wmNeed: "Type the watermark text", locked: "This PDF is encrypted, unlock it first", busy: "Working…" }
  : { pages: (n) => `${n} 页`, need2: "至少选 2 个 PDF", need1: "先选文件", badRange: "页码格式不对,例如 1-3,5",
      out: (n, p) => `完成:${n},共 ${p} 页,已下载`, wmNeed: "先填水印文字", locked: "这个 PDF 加了密码,先解密再处理", busy: "处理中…" };
if (EN) {
  document.documentElement.lang = "en"; document.title = T.title;
  document.querySelectorAll("[data-t]").forEach((e) => (e.textContent = T[e.dataset.t]));
  $("#pages").placeholder = "e.g. 1-3,5";
}
$("#wmText").value = EN ? "For job application only · 2026-11-03" : "仅供入职审核使用 · 2026-11-03";

// ---------- 2. 切换标签、提示 ----------
document.querySelectorAll("#tabs button").forEach((b) => (b.onclick = () => {
  document.querySelectorAll("#tabs button, .pane").forEach((e) => e.classList.remove("on"));
  b.classList.add("on"); $("#" + b.dataset.tab).classList.add("on"); say("");
}));
function say(t, cls = "") { const m = $("#msg"); m.textContent = t; m.className = cls; }
const kb = (n) => n < 1048576 ? (n / 1024).toFixed(0) + " KB" : (n / 1048576).toFixed(1) + " MB";

// ---------- 3. 文件列表:读页数,可上移 / 删除 ----------
async function load(file) {
  const doc = await PDFDocument.load(await file.arrayBuffer()); // 加密的 PDF 这里会报错
  return { file, doc, pages: doc.getPageCount() };
}
function list(el, arr, render) {
  el.innerHTML = "";
  arr.forEach((it, i) => {
    const li = document.createElement("li");
    li.innerHTML = `<span class="n"></span><span class="meta"></span><button title="up">↑</button><button title="remove">✕</button>`;
    li.querySelector(".n").textContent = `${i + 1}. ${it.file.name}`;
    li.querySelector(".meta").textContent = render(it);
    const [up, rm] = li.querySelectorAll("button");
    up.onclick = () => { if (i) { [arr[i - 1], arr[i]] = [arr[i], arr[i - 1]]; list(el, arr, render); } };
    rm.onclick = () => { arr.splice(i, 1); list(el, arr, render); };
    el.appendChild(li);
  });
}
function pdfInput(input, el, arr, single) {
  input.onchange = async () => {
    say(M.busy);
    try {
      if (single) arr.length = 0;
      for (const f of input.files) arr.push(await load(f));
      list(el, arr, (it) => `${M.pages(it.pages)} · ${kb(it.file.size)}`); say("");
    } catch (e) { say(String(e).includes("encrypted") ? M.locked : String(e), "err"); }
    input.value = "";
  };
}
const merged = [], picked = [], marked = [], imgs = [];
pdfInput($("#mergeIn"), $("#mergeList"), merged, false);
pdfInput($("#pickIn"), $("#pickList"), picked, true);
pdfInput($("#wmIn"), $("#wmList"), marked, true);

// ---------- 4. 下载:Blob 生成本地链接,不经过网络 ----------
async function save(doc, name) {
  doc.setProducer("PDF Toolbox (browser only)"); doc.setCreator("pdf-lib");
  const bytes = await doc.save();
  const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
  const a = document.createElement("a"); a.href = url; a.download = name; a.click();
  setTimeout(() => URL.revokeObjectURL(url), 10000);
  say(M.out(name, doc.getPageCount()), "ok");
}

// ---------- 5. 合并 ----------
$("#mergeGo").onclick = async () => {
  if (merged.length < 2) return say(M.need2, "err");
  const out = await PDFDocument.create();
  for (const it of merged) {
    const pages = await out.copyPages(it.doc, it.doc.getPageIndices());
    pages.forEach((p) => out.addPage(p));
  }
  await save(out, EN ? "merged.pdf" : "合并.pdf");
};

// ---------- 6. 提取页面:「1-3,5」→ [0,1,2,4] ----------
function parseRange(s, max) {
  const out = [];
  for (const part of s.replace(/,/g, ",").split(",").map((x) => x.trim()).filter(Boolean)) {
    const m = part.match(/^(\d+)(?:\s*-\s*(\d+))?$/);
    if (!m) return null;
    const a = +m[1], b = m[2] ? +m[2] : a;
    if (a < 1 || b > max || a > b) return null;
    for (let i = a; i <= b; i++) out.push(i - 1);
  }
  return out.length ? out : null;
}
$("#pickGo").onclick = async () => {
  if (!picked.length) return say(M.need1, "err");
  const src = picked[0], idx = parseRange($("#pages").value, src.pages);
  if (!idx) return say(M.badRange, "err");
  const out = await PDFDocument.create();
  (await out.copyPages(src.doc, idx)).forEach((p) => out.addPage(p));
  await save(out, src.file.name.replace(/\.pdf$/i, "") + (EN ? "-pages.pdf" : "-提取.pdf"));
};

// ---------- 7. 图片转 PDF:每张图一页 A4,等比缩放居中 ----------
$("#imgIn").onchange = () => {
  for (const f of $("#imgIn").files) imgs.push({ file: f });
  list($("#imgList"), imgs, (it) => kb(it.file.size)); $("#imgIn").value = "";
};
$("#imgGo").onclick = async () => {
  if (!imgs.length) return say(M.need1, "err");
  const out = await PDFDocument.create(), A4 = [595.28, 841.89], pad = 28;
  for (const it of imgs) {
    const bytes = await it.file.arrayBuffer();
    const img = it.file.type === "image/png" ? await out.embedPng(bytes) : await out.embedJpg(bytes);
    const page = out.addPage(A4);
    const s = Math.min((A4[0] - 2 * pad) / img.width, (A4[1] - 2 * pad) / img.height, 1);
    const w = img.width * s, h = img.height * s;
    page.drawImage(img, { x: (A4[0] - w) / 2, y: (A4[1] - h) / 2, width: w, height: h });
  }
  await save(out, EN ? "images.pdf" : "图片合集.pdf");
};

// ---------- 8. 加水印:中文先画到 canvas 变成 PNG,再铺满每一页 ----------
$("#wmOp").oninput = () => ($("#wmOpV").textContent = $("#wmOp").value + "%");
function stamp(text) {
  const c = document.createElement("canvas"), g = c.getContext("2d"), fs = 44;
  g.font = `bold ${fs}px sans-serif`;
  c.width = Math.ceil(g.measureText(text).width) + 20; c.height = fs + 20;
  g.font = `bold ${fs}px sans-serif`; g.fillStyle = "#b91c1c"; g.textBaseline = "middle";
  g.fillText(text, 10, c.height / 2);
  return c.toDataURL("image/png"); // 系统自带字体画中文,PDF 里不用嵌入字体
}
$("#wmGo").onclick = async () => {
  if (!marked.length) return say(M.need1, "err");
  const text = $("#wmText").value.trim(); if (!text) return say(M.wmNeed, "err");
  const src = marked[0], out = await PDFDocument.load(await src.file.arrayBuffer());
  const png = await out.embedPng(stamp(text)), op = $("#wmOp").value / 100;
  for (const page of out.getPages()) {
    const { width, height } = page.getSize(), w = width * 0.55, h = (png.height * w) / png.width;
    for (let y = -h; y < height + h; y += height / 3.2)      // 斜着铺 3~4 行
      for (let x = -w / 2; x < width; x += w * 1.1)
        page.drawImage(png, { x, y, width: w, height: h, opacity: op, rotate: degrees(30) });
  }
  await save(out, src.file.name.replace(/\.pdf$/i, "") + (EN ? "-watermarked.pdf" : "-水印.pdf"));
};

style.css 只是配色和手机适配,和功能无关,就不贴了,在「项目开源」页里能直接下载。

第三步:写配置和安全响应头

wrangler.toml:只有静态文件,挂到自己的子域名

name = "tangent-pdf"
compatibility_date = "2026-11-01"
workers_dev = false                 # 不用 workers.dev,国内经常打不开
routes = [
  { pattern = "pdf.你的域名", custom_domain = true }
]

[assets]
directory = "./public"              # 只放静态文件:没有后端代码,也就没有地方接收你的文件
not_found_handling = "single-page-application"

[assets] 告诉 Cloudflare:把 public 目录当静态网站发出去,没有 Worker 代码。workers_dev = false 是不用 workers.dev 的默认地址,国内经常打不开,我们只用自己的子域名。按官方文档,静态文件的请求是免费、不限量的,免费版单个文件最大 25 MiB,pdf-lib 才 500 多 KB,完全够。

_headers:①只准加载自己域名的脚本 ②connect-src 'none':页面不准往外发任何请求

# 整站加安全响应头:connect-src 'none' = 页面里的脚本不能向任何地方发请求
/*
  Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'
  X-Content-Type-Options: nosniff
  Referrer-Policy: no-referrer
  Permissions-Policy: camera=(), microphone=(), geolocation=()

/vendor/*
  Cache-Control: public, max-age=31536000, immutable

这个文件是整个项目的「保险」。在 public 里放一个叫 _headers 的文件,Cloudflare 会把里面的规则加到每个响应上(官方文档说明它本身不会被当成网页发出去)。重点看两条:script-src 'self' 是只准运行我自己域名下的脚本;connect-src 'none' 是页面里的脚本不准向任何地方发请求。就算哪天代码里混进了一行偷偷上传的逻辑,浏览器也会直接拦下。

第四步:部署

部署:①读到 public 里的静态文件 ②挂到自己的子域名(版本 ID 已打码)

npx wrangler deploy

一条命令就上线了,几秒钟。然后用 curl 看一下安全响应头有没有生效:

curl 确认 ①CSP 里的 connect-src 'none' 已经生效

curl -sI https://pdf.你的域名/ | grep -iE '^(content-security|x-content|referrer|permissions)'

能看到这几行就说明生效了。改了 _headers 以后要重新 deploy 一次。

第五步:四个功能实测

下面用的简历、成绩单、证书、照片都是我编的示例数据,名字也是编的。

合并:①按顺序选三个文件 ②点 ↑ 可以调顺序 ③合并并下载

合并:选简历(2 页)、成绩单、获奖证书,顺序不对点 ↑ 调,点「合并并下载」,马上得到一个 4 页的 PDF。

提取页面:①故意写 1-9(文件只有 8 页)②直接提示格式不对,不会生成错文件

提取页面:一份 8 页的课程资料,我故意写「1-9」,它直接提示不对;改成「1-3,6」就能下载只有这 4 页的新文件。

图片转 PDF:①两张照片 ②生成 A4 PDF,一张一页

图片转 PDF:选两张证明的照片,一张 JPG 一张 PNG,生成一个 2 页的 A4 PDF。

加水印:选刚才合并好的文件,水印写「仅供入职审核使用」加上日期,浓淡拉到 35% 左右,既看得清,又不挡正文。以后交材料都可以这样加上水印,被人挪作他用也有据可查。

下载下来的 4 个文件:合并顺序对,提取的是 1、2、3、6 页,照片一张一页,水印铺满每页

pdfinfo 核对:①四个文件的页数都对得上

最后用 pdfinfo 核对一遍页数:合并 4 页、提取 4 页、图片 2 页、加水印 4 页,全对。

第六步:验证文件真的没上传

断网后再合并一次:①照样完成、照样下载

第一个验证:页面打开以后把网断了(我是用浏览器自动化把网络切成离线),再选两个文件合并,照样完成、照样下载。说明处理过程根本用不到网络。

①在页面里故意往外 POST 被拦下 ②控制台写明违反了 connect-src 'none'

第二个验证:我在页面里故意执行一段往外发 POST 的代码,模拟「偷偷上传」,结果直接失败,控制台写得很清楚:违反了 connect-src 'none',已拦截。还有个意外收获:Cloudflare 会自动往页面里插一个访问统计脚本,它也被这条安全策略拦下了,没加载。

全程请求:①只有 GET 静态文件,POST / PUT 一个都没有

第三个验证:把录屏全程浏览器发出的所有请求列出来,只有打开页面时加载的那几个静态文件,POST 和 PUT 一个都没有。

手机浏览器也能用:同样四个功能,文件同样只在手机里处理

手机上直接打开子域名也能用,从相册选照片转 PDF 特别方便。

踩坑和注意

踩坑和注意,做之前先看一遍

1. pdf-lib 的标准字体写不了中文,水印要先用 canvas 画成 PNG 再贴,别直接 drawText。

2. 加了密码的 PDF 读不了,页面会提示,先用自己的阅读器解密再处理。

3. 所有文件都在内存里处理,几百 MB 的大文件,手机可能会卡或者直接崩,大文件用电脑处理。

4. pdf-lib 一定放在自己域名下,CSP 才能只写 'self';如果引用公共 CDN,就得在 script-src 里放行它。

5. 改了 _headers 要重新 deploy,不然线上还是旧规则。

小结

一共六步:确认版本装 pdf-lib、写页面和 app.js、写配置和安全响应头、部署、四个功能实测、验证不上传。做完以后,你就有了一个只属于自己的 PDF 工具箱:交实习材料、报名材料、报销单据都能用,敏感文件再也不用往别人的服务器上传。

如果觉得有用,欢迎关注「正切AI实验室」,这个系列每一集都是能直接用起来的成品。完整源码在我博客的「项目开源」页:blog.tangentlab.kdns.fr/projects/cf-pdf/

好啦,就写到这里,喜欢的朋友点个赞,谢谢。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料