乐于分享
好东西不私藏

AI帮我写了个PDF水印工具,打开浏览器就能用,效果完全超出了预期

AI帮我写了个PDF水印工具,打开浏览器就能用,效果完全超出了预期

昨天我让AI帮我写了一个PDF水印工作台,效果完全超出了我的预期,感觉还不错。

它只有一个HTML文件,全程在浏览器里处理PDF,文件也不会上传到哪里哪里的服务器。

直接可以操作水印的文字调整、透明度、角度等等,还可以一键下载。

来看看效果图:

实现的功能:

  • • 上传PDF(支持拖拽)
  • • 自定义水印文字、字体、颜色、透明度、旋转角度
  • • 选择平铺(重复水印)或单个水印模式
  • • 精准控制间距、边距、起始位置
  • • 指定页码范围(全部、首页、自定义如1,3,5-8
  • • 实时预览,一键下载带水印的PDF

接下来看看AI实现的技术思路是怎样的。

一、技术栈

整个工具只依赖一个外部库:pdf-lib.js。它是一个强大的纯JavaScript PDF处理库,可以在浏览器中创建、修改PDF文档。

另一个核心是浏览器自带的 Canvas,它负责把水印文字渲染成带透明度的PNG图片,然后再塞进PDF。

这里我在实现过程中遇到一个问题,pdf-lib对中文字体的内嵌支持比较麻烦,需要加载字体文件,但我怎么反复调整都还是无法添加中文水印。用Canvas直接调用系统字体,画图再嵌入,可以解决这个问题。

整体流程可以概括为:

上传PDF → 读取为二进制数据 → 用pdf-lib解析页数   ↓用户调整参数 → 防抖等待450ms → Canvas绘制水印PNG   ↓遍历每一页PDF → 将PNG按坐标铺入 → 生成新PDF二进制   ↓转为Blob URL → 更新iframe预览 → 更新下载链接

二、核心实现

1. 文件读取与解析

上传的PDF文件通过File.arrayBuffer()拿到原始字节流,然后交给pdf-lib加载。

const arrayBuffer = await file.arrayBuffer();const pdfDoc = await PDFDocument.load(arrayBuffer);const pages = pdfDoc.getPages().length;originalPdfBytes = arrayBuffer; // 保存起来,后续每次生成水印都要用

originalPdfBytes会一直保留,因为每次调整参数都要基于原始PDF重新生成水印(避免叠加)。

2. 水印图片生成

这是整个工具里最巧妙的环节。我们把水印文字画在一个离屏Canvas上,然后导出为PNG格式的Uint8Array,后续pdf-lib可以直接嵌入。

关键代码:

function createWatermarkImage(settings) {  // 1. 考虑高分屏,放大绘制保证清晰度  const scale = Math.max(2, Math.ceil(window.devicePixelRatio || 1));  // 2. 创建Canvas,设置尺寸(根据文字旋转后占用的宽高计算)  const canvas = document.createElement("canvas");  const ctx = canvas.getContext("2d");  // 3. 设置字体、旋转、颜色、透明度  ctx.font = `${settings.fontWeight} ${settings.fontSize * scale}px ${settings.fontFamily}`;  ctx.translate(canvas.width/2, canvas.height/2);  ctx.rotate(settings.rotationDeg * Math.PI / 180);  ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${settings.opacity})`;  ctx.textAlign = "center";  ctx.textBaseline = "middle";  ctx.fillText(settings.text, 0, 0);  // 4. 导出为PNG字节流  return {    bytes: dataUrlToUint8Array(canvas.toDataURL("image/png")),    width: canvas.width / scale,    height: canvas.height / scale  };}

为什么要把Canvas放大再缩小?为了在高分辨率屏幕(如Retina屏)上水印文字依然清晰,我们用devicePixelRatio的倍数绘制,然后告诉PDF以原始尺寸放置,这样图片精度就足够高。

3. PDF水印嵌入:循环铺放或单点定位

拿到PNG图片后,用pdfDoc.embedPng()将其嵌入PDF文档,然后遍历需要加水印的页面,逐页绘制。

平铺模式(Tile)

从设定的起始偏移(offsetX, offsetY)开始,以水平间距hSpacing和垂直间距vSpacing为步长,在页面范围内循环绘制,直到超出边界。

for (let x = startX; x < width + imageWidth; x += stepX) {  for (let y = startY; y < height + imageHeight; y += stepY) {    page.drawImage(watermarkImage, { x, y, width: imgWidth, height: imgHeight });  }}

单水印模式

根据九宫格位置(如centertop-leftbottom-right)和边距参数,计算出唯一的坐标。

const positions = {  "center": [pageWidth/2 - imgWidth/2, pageHeight/2 - imgHeight/2],  "top-left": [marginX, pageHeight - imgHeight - marginY],  // ...其他位置};const [x, y] = positions[settings.singlePosition];page.drawImage(watermarkImage, { x, y, width: imgWidth, height: imgHeight });

4. 页码范围解析:灵活的语法支持

为了让用户可以指定“第1、3、5到8页”,我们实现了一个简单的页码范围解析器。

function parsePageSet(mode, rangeText, totalPages) {  if (mode === "all") return new Set([...Array(totalPages).keys()]);  if (mode === "first") return new Set([0]);  const pageSet = new Set();  const chunks = rangeText.split(",").map(s => s.trim());  for (const chunk of chunks) {    const match = chunk.match(/^(\d+)(?:-(\d+))?$/);    if (!match) continue;    let start = parseInt(match[1], 10);    let end = match[2] ? parseInt(match[2], 10) : start;    // 修正边界、转为0-based索引    for (let p = Math.min(start, end); p <= Math.max(start, end); p++) {      if (p >= 1 && p <= totalPages) pageSet.add(p - 1);    }  }  return pageSet;}

5. 实时预览与防抖:体验的关键

用户拖拽滑块时,我们不希望每个微小变化都触发一次昂贵的PDF生成操作。因此引入了**防抖(Debounce)**机制:用户停止操作450毫秒后才开始渲染。

function schedulePreview() {  clearTimeout(previewTimer);  previewTimer = setTimeout(() => renderPreview(), 450);}

同时,为了避免过时的渲染结果覆盖最新的请求,每次渲染前会分配一个递增的renderId,渲染完成后检查ID是否一致。

const renderId = ++latestRenderId;// ... 异步生成PDF后if (renderId !== latestRenderId) return; // 丢弃过时结果

生成后的PDF字节被包装成Blob,创建一个blob:协议的URL赋给iframe.src,同时更新下载按钮的链接。

const blob = new Blob([pdfBytes], { type: "application/pdf" });const blobUrl = URL.createObjectURL(blob);pdfPreview.src = blobUrl;downloadLink.href = blobUrl;

6. 内存管理:及时清理Blob URL

每次生成新预览前,都要撤销上一次创建的blob:链接,避免浏览器内存泄漏。

if (currentPdfBlobUrl) {  URL.revokeObjectURL(currentPdfBlobUrl);}

这个PDF水印工作台的完整HTML文件,我已经放在了公众号后台。关注后回复「PDF水印」就可以获取全部完整代码了