纯前端实现 PDF 加水印工具 —— 零后端、支持中文、实时预览

在日常办公中,给 PDF 添加水印是一个高频需求。本文介绍一个完全基于浏览器端实现的 PDF 加水印工具,无需上传文件到服务器,所有处理均在本地完成,既保护隐私又节省带宽。


一、项目效果

先看一下最终效果:

1、拖动pdf文件到这里,或者点击选择文件

2、输入水印内容,配置合适的样式,点击确定

3、点击下载加水印文件

4、生成的水印文件效果

核心功能:

  • ✅ 支持文字水印图片水印两种模式

  • ✅ 实时预览,所见即所得

  • ✅ 可自定义字号、颜色、透明度、旋转角度

  • ✅ 支持单排/多排(平铺)两种排列方式

  • ✅ 可设置平铺间距,调节水印密度

  • ✅ 支持指定页面范围(如只给第 1~10 页加水印)

  • 完美支持中文(这是重点!)


二、技术栈

表格

版本 用途
pdf-lib 1.17.1 PDF 文档的读取、编辑、保存
pdf.js 3.11.174 PDF 页面渲染为 Canvas,用于实时预览
原生 Canvas API - 文字水印渲染、图片处理

为什么选 pdf-lib

pdf-lib 是一个纯 JavaScript 的 PDF 操作库,不需要任何后端服务,直接在浏览器中就能完成 PDF 的创建和修改。相比其他方案,它的优势是:

  • 零依赖,一个 <script> 标签即可引入

  • API 设计简洁,支持嵌入图片、绘制图形等

  • 完全在客户端运行,文件不上传服务器


三、核心难点:中文字体支持

这是整个项目中最棘手的问题。

3.1 问题背景

pdf-lib 默认不支持中文字体 。如果你直接用 page.drawText() 写入中文,会得到一堆乱码或空白。

常见的解决方案有两种:

  1. 嵌入中文字体文件(如思源黑体、微软雅黑)------ 但字体文件动辄几 MB,加载慢,且需要处理字体子集化

  2. 将文字渲染为图片,再嵌入 PDF ------ 这就是我们采用的方案

3.2 实现方案:Canvas → PNG → PDF

核心思路:用 HTML5 Canvas 把文字画成一张透明背景的 PNG 图片,然后用 pdf-libembedPng() 将这张图片嵌入到每一页 PDF 中。

JavaScript

javascript 复制代码
async function renderTextToPng(text, fontSizePt, colorHex, opacity) {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  const dpr = 3; // 高DPI,保证清晰度

  // 1. 先测量文字尺寸
  ctx.font = `bold ${fontSizePt}px "Microsoft YaHei", sans-serif`;
  const metrics = ctx.measureText(text);
  
  const pad = 4;
  const w = Math.ceil(metrics.width) + pad * 2;
  const h = Math.ceil(fontSizePt * 1.3);

  // 2. 按高DPI渲染,避免模糊
  canvas.width  = w * dpr;
  canvas.height = h * dpr;
  ctx.scale(dpr, dpr);

  ctx.font = `bold ${fontSizePt}px "Microsoft YaHei", sans-serif`;
  ctx.fillStyle = colorHex;
  ctx.globalAlpha = opacity;
  ctx.textBaseline = 'middle';
  ctx.fillText(text, pad, h / 2);

  // 3. 导出为 PNG 字节数组
  const dataUrl = canvas.toDataURL('image/png');
  const res = await fetch(dataUrl);
  const bytes = new Uint8Array(await res.arrayBuffer());

  return { bytes, widthPt: w, heightPt: h };
}

关键点:

  • dpr = 3:三倍像素密度,确保导出的 PNG 在 PDF 中显示清晰,不会出现锯齿

  • textBaseline = 'middle':文字垂直居中,后续贴到 PDF 时更容易计算位置

  • 返回的 widthPtheightPt 直接以 pt(磅) 为单位,与 PDF 坐标系一致

3.3 将 PNG 贴到 PDF 页面

JavaScript

javascript 复制代码
const textImg = await pdfDoc.embedPng(bytes);

for (let i = start - 1; i < end; i++) {
  const page = pdfDoc.getPage(i);
  const { width, height } = page.getSize();

  if (layout === 'single') {
    // 单排:居中显示
    page.drawImage(textImg, {
      x: width / 2 - widthPt / 2,
      y: height / 2 - heightPt / 2,
      width: widthPt,
      height: heightPt,
      opacity: opacity,
      rotate: PDFLib.degrees(rotate),
    });
  } else {
    // 多排(平铺):覆盖整个页面
    const startX = -width,  endX = width * 2;
    const startY = -height, endY = height * 2;
    for (let x = startX; x < endX; x += sx) {
      for (let y = startY; y < endY; y += sy) {
        page.drawImage(textImg, {
          x: x, y: y,
          width: widthPt, height: heightPt,
          opacity: opacity,
          rotate: PDFLib.degrees(rotate),
        });
      }
    }
  }
}

平铺算法说明:

为了让水印覆盖整页(包括边缘),我们从 (-width, -height) 开始绘制,到 (2*width, 2*height) 结束,这样无论旋转角度是多少,页面中心区域都能被完全覆盖。


四、实时预览的实现

预览功能使用 pdf.js 将 PDF 页面渲染到 Canvas 上,然后在同一个 Canvas 上叠加绘制水印

JavaScript

javascript 复制代码
async function renderPage(pageNum) {
  const page = await pdfJsDoc.getPage(pageNum);
  const canvas = document.getElementById('previewCanvas');
  const ctx = canvas.getContext('2d');
  
  const scale = 1.5; // 预览缩放比例
  const viewport = page.getViewport({ scale });
  canvas.width = viewport.width;
  canvas.height = viewport.height;
  
  // 1. 先渲染 PDF 页面
  await page.render({ canvasContext: ctx, viewport }).promise;
  
  // 2. 再叠加水印预览
  drawWatermarkPreview(ctx, canvas.width, canvas.height, scale);
}

坐标统一:

预览和实际生成使用同一套逻辑,所有尺寸都以 pt 为单位,预览时乘以 scale 映射到像素。这样用户调整参数后,预览效果与最终 PDF 完全一致。


五、图片水印

图片水印的处理相对简单:

JavaScript

javascript 复制代码
let embedImg;
try { 
  embedImg = await pdfDoc.embedPng(watermarkImageBytes); 
} catch { 
  embedImg = await pdfDoc.embedJpg(watermarkImageBytes); 
}

const imgW = embedImg.width * scalePct;
const imgH = embedImg.height * scalePct;
  • 自动识别 PNG/JPG 格式

  • 支持缩放比例调节

  • 其余逻辑(单排/多排、旋转、透明度)与文字水印完全一致


六、页面范围控制

用户可指定水印应用的页面范围,通过简单的循环控制即可:

JavaScript

javascript 复制代码
const start = Math.max(1, parseInt(document.getElementById('pageStart').value) || 1);
const end   = Math.min(totalPages, parseInt(document.getElementById('pageEnd').value) || totalPages);

for (let i = start - 1; i < end; i++) {
  const page = pdfDoc.getPage(i);
  // ... 绘制水印
}

七、完整代码

整个工具就是一个单文件 HTML,无需构建工具,直接双击即可运行:

pdf_watermark.html:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>免费在线PDF加水印</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf-lib/1.17.1/pdf-lib.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<style>
  *{margin:0;padding:0;box-sizing:border-box}
  body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#f0f5ff;min-height:100vh;color:#333}
  .header{text-align:center;padding:30px 20px 10px}
  .header-icon{display:inline-flex;align-items:center;gap:10px;margin-bottom:10px}
  .header-icon svg{width:32px;height:32px}
  .header h1{font-size:26px;color:#1a1a2e;display:inline}
  .header p{color:#666;font-size:14px;margin-top:8px}
  .header-links a{color:#4a90e2;text-decoration:none;font-size:13px;margin:0 8px}
  .header-links a:hover{text-decoration:underline}
  .steps{display:flex;justify-content:center;align-items:center;gap:0;margin:25px 0;padding:0 20px}
  .step-item{display:flex;flex-direction:column;align-items:center;position:relative;min-width:120px}
  .step-circle{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;background:#e0e0e0;color:#999;transition:all .3s;z-index:2}
  .step-item.active .step-circle{background:#4a90e2;color:#fff}
  .step-item.done .step-circle{background:#4a90e2;color:#fff}
  .step-label{margin-top:8px;font-size:13px;color:#999;transition:all .3s}
  .step-item.active .step-label{color:#4a90e2;font-weight:500}
  .step-item.done .step-label{color:#4a90e2}
  .step-line{width:100px;height:2px;background:#e0e0e0;margin:0 -10px;position:relative;top:-22px;z-index:1;transition:all .3s}
  .step-line.active{background:#4a90e2}
  .main-container{max-width:1200px;margin:0 auto 40px;padding:0 20px}
  .card{background:#fff;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.06);padding:30px;min-height:400px}
  .upload-area{border:2px dashed #d0d7de;border-radius:8px;padding:80px 40px;text-align:center;cursor:pointer;transition:all .3s;position:relative}
  .upload-area:hover,.upload-area.dragover{border-color:#4a90e2;background:#f8fbff}
  .upload-area svg{width:60px;height:60px;color:#c0c7ce;margin-bottom:16px}
  .upload-area p{color:#999;font-size:14px}
  .upload-area input{position:absolute;inset:0;opacity:0;cursor:pointer}
  .file-list{margin-top:20px}
  .file-item{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#f8f9fa;border-radius:6px;margin-bottom:8px}
  .file-name{font-size:14px;color:#333;flex:1}
  .file-status{font-size:12px;color:#4a90e2;margin-right:12px}
  .file-remove{cursor:pointer;color:#999;font-size:18px;line-height:1}
  .file-remove:hover{color:#e74c3c}
  .editor-wrap{display:flex;gap:20px}
  .preview-panel{flex:1;min-height:500px;border:1px solid #e8e8e8;border-radius:6px;overflow:hidden;position:relative;background:#f5f5f5;display:flex;align-items:center;justify-content:center}
  .preview-panel canvas{max-width:100%;max-height:500px;box-shadow:0 2px 8px rgba(0,0,0,.1)}
  .preview-nav{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:10px;background:rgba(255,255,255,.9);padding:6px 14px;border-radius:20px;box-shadow:0 2px 8px rgba(0,0,0,.1);font-size:13px}
  .preview-nav button{background:#4a90e2;color:#fff;border:none;width:26px;height:26px;border-radius:50%;cursor:pointer;font-size:14px;line-height:1}
  .preview-nav button:disabled{background:#ccc;cursor:not-allowed}
  .preview-nav select{border:1px solid #ddd;border-radius:4px;padding:2px 6px;font-size:12px}
  .config-panel{width:320px;flex-shrink:0}
  .tabs{display:flex;border-bottom:1px solid #e8e8e8;margin-bottom:20px}
  .tab{flex:1;text-align:center;padding:10px 0;cursor:pointer;font-size:14px;color:#666;border-bottom:2px solid transparent;transition:all .2s}
  .tab.active{color:#4a90e2;border-bottom-color:#4a90e2;font-weight:500}
  .form-group{margin-bottom:16px}
  .form-label{display:block;font-size:13px;color:#666;margin-bottom:6px}
  .form-input,.form-select{width:100%;padding:8px 12px;border:1px solid #d0d7de;border-radius:4px;font-size:13px;outline:none;transition:border .2s}
  .form-input:focus,.form-select:focus{border-color:#4a90e2}
  .form-input[type="range"]{padding:0;height:6px}
  .range-wrap{display:flex;align-items:center;gap:10px}
  .range-val{min-width:36px;font-size:13px;color:#666;text-align:right}
  .radio-group{display:flex;gap:16px}
  .radio-item{display:flex;align-items:center;gap:4px;font-size:13px;color:#333;cursor:pointer}
  .radio-item input{cursor:pointer}
  .spacing-row{display:flex;align-items:center;gap:8px}
  .spacing-row .form-input{width:70px;text-align:center}
  .spacing-row span{font-size:12px;color:#999}
  .page-range{display:flex;align-items:center;gap:8px}
  .page-range .form-input{width:60px;text-align:center}
  .page-range span{font-size:12px;color:#999}
  .hint{font-size:12px;color:#999;margin-top:4px}
  .btn-primary{width:100%;padding:12px;background:#4a90e2;color:#fff;border:none;border-radius:4px;font-size:15px;cursor:pointer;transition:background .2s;margin-top:10px}
  .btn-primary:hover{background:#3a7bc8}
  .btn-primary:disabled{background:#a0c4f0;cursor:not-allowed}
  .success-wrap{text-align:center;padding:60px 20px}
  .success-icon{width:60px;height:60px;background:#52c41a;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;margin-bottom:16px}
  .success-icon svg{width:32px;height:32px;color:#fff}
  .success-title{color:#52c41a;font-size:18px;font-weight:500;margin-bottom:20px}
  .success-file{display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:24px;font-size:14px;color:#333}
  .success-file svg{width:20px;height:20px;color:#e74c3c}
  .success-btns{display:flex;justify-content:center;gap:12px}
  .success-btns button{padding:10px 24px;border-radius:4px;font-size:14px;cursor:pointer;display:flex;align-items:center;gap:6px}
  .btn-back{background:#fff;color:#4a90e2;border:1px solid #4a90e2}
  .btn-back:hover{background:#f0f7ff}
  .btn-download{background:#4a90e2;color:#fff;border:none}
  .btn-download:hover{background:#3a7bc8}
  .success-note{margin-top:20px;font-size:12px;color:#999}
  .hidden{display:none!important}
  .img-upload{border:1px dashed #d0d7de;border-radius:4px;padding:20px;text-align:center;cursor:pointer;transition:all .2s;position:relative}
  .img-upload:hover{border-color:#4a90e2;background:#f8fbff}
  .img-upload input{position:absolute;inset:0;opacity:0;cursor:pointer}
  .img-upload img{max-width:100%;max-height:120px;display:block;margin:0 auto}
  .img-upload-text{font-size:12px;color:#999}
  .loading-overlay{position:fixed;inset:0;background:rgba(255,255,255,.8);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:100;font-size:14px;color:#666}
  .spinner{width:40px;height:40px;border:3px solid #e0e0e0;border-top-color:#4a90e2;border-radius:50%;animation:spin .8s linear infinite;margin-bottom:12px}
  @keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>

<div class="header">
  <div class="header-icon">
    <svg viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/></svg>
    <h1>免费在线PDF加水印</h1>
  </div>
  <p>本功能用于向PDF文件添加文字或图片水印,可自定义水印内容大小应用页面范围等</p>
</div>

<div class="steps">
  <div class="step-item active" id="step1-item"><div class="step-circle">1</div><div class="step-label">上传PDF文件</div></div>
  <div class="step-line" id="line1"></div>
  <div class="step-item" id="step2-item"><div class="step-circle">2</div><div class="step-label">添加PDF水印</div></div>
  <div class="step-line" id="line2"></div>
  <div class="step-item" id="step3-item"><div class="step-circle">3</div><div class="step-label">下载PDF文件</div></div>
</div>

<div class="main-container">
  <div class="card">

    <div id="step1">
      <div class="upload-area" id="uploadArea">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
        <p>点击或拖动选择要加水印的PDF文件</p>
        <input type="file" id="fileInput" accept=".pdf">
      </div>
      <div class="file-list" id="fileList"></div>
    </div>

    <div id="step2" class="hidden">
      <div class="editor-wrap">
        <div class="preview-panel" id="previewPanel">
          <canvas id="previewCanvas"></canvas>
          <div class="preview-nav">
            <button id="prevPage" onclick="changePage(-1)">&#8249;</button>
            <span id="pageInfo">第 1 页 / 共 1 页</span>
            <button id="nextPage" onclick="changePage(1)">&#8250;</button>
            <select id="previewPageSelect" onchange="jumpPage(this.value)"></select>
          </div>
        </div>
        <div class="config-panel">
          <div class="tabs">
            <div class="tab active" onclick="switchTab('text')" id="tab-text">文字水印</div>
            <div class="tab" onclick="switchTab('image')" id="tab-image">图片水印</div>
          </div>

          <div id="config-text">
            <div class="form-group">
              <label class="form-label">水印文字:</label>
              <input type="text" class="form-input" id="wmText" value="" placeholder="请输入水印文字" oninput="updatePreview()">
            </div>
            <div class="form-group">
              <label class="form-label">字号大小(pt):</label>
              <select class="form-select" id="wmSize" onchange="updatePreview()">
                <option value="12">12</option><option value="14">14</option><option value="16">16</option>
                <option value="18">18</option><option value="20">20</option><option value="24">24</option>
                <option value="30" selected>30</option><option value="36">36</option><option value="48">48</option>
                <option value="60">60</option><option value="72">72</option>
              </select>
            </div>
            <div class="form-group">
              <label class="form-label">字体颜色:</label>
              <select class="form-select" id="wmColor" onchange="updatePreview()">
                <option value="#000000" selected>黑色</option>
                <option value="#ff0000">红色</option>
                <option value="#0000ff">蓝色</option>
                <option value="#008000">绿色</option>
                <option value="#808080">灰色</option>
                <option value="#ffffff">白色</option>
              </select>
            </div>
            <div class="form-group">
              <label class="form-label">透明程度:</label>
              <div class="range-wrap">
                <input type="range" class="form-input" id="wmOpacity" min="0" max="100" value="25" oninput="document.getElementById('opacityVal').textContent=this.value+'%'; updatePreview()">
                <span class="range-val" id="opacityVal">25%</span>
              </div>
            </div>
            <div class="form-group">
              <label class="form-label">旋转角度:</label>
              <div class="radio-group">
                <label class="radio-item"><input type="radio" name="rotate" value="-45" onchange="updatePreview()"> -45&deg;</label>
                <label class="radio-item"><input type="radio" name="rotate" value="0" onchange="updatePreview()"> 0&deg;</label>
                <label class="radio-item"><input type="radio" name="rotate" value="45" checked onchange="updatePreview()"> 45&deg;</label>
              </div>
            </div>
            <div class="form-group">
              <label class="form-label">排列方式:</label>
              <div class="radio-group">
                <label class="radio-item"><input type="radio" name="layout" value="single" onchange="updatePreview()"> 单排</label>
                <label class="radio-item"><input type="radio" name="layout" value="multi" checked onchange="updatePreview()"> 多排</label>
              </div>
            </div>
            <div class="form-group">
              <label class="form-label">平铺间距(pt):</label>
              <div class="spacing-row">
                <span>&#8596;</span><input type="number" class="form-input" id="spaceX" value="400" onchange="updatePreview()">
                <span>&#8597;</span><input type="number" class="form-input" id="spaceY" value="400" onchange="updatePreview()">
              </div>
              <p class="hint">单位:磅(pt),1pt &asymp; 0.35mm</p>
            </div>
          </div>

          <div id="config-image" class="hidden">
            <div class="form-group">
              <label class="form-label">上传水印图片:</label>
              <div class="img-upload" id="imgUploadArea">
                <div id="imgUploadText">
                  <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
                  <p class="img-upload-text">点击上传图片</p>
                </div>
                <img id="wmImgPreview" class="hidden">
                <input type="file" id="imgInput" accept="image/*" onchange="handleImageUpload(this)">
              </div>
            </div>
            <div class="form-group">
              <label class="form-label">透明程度:</label>
              <div class="range-wrap">
                <input type="range" class="form-input" id="imgOpacity" min="0" max="100" value="25" oninput="document.getElementById('imgOpacityVal').textContent=this.value+'%'">
                <span class="range-val" id="imgOpacityVal">25%</span>
              </div>
            </div>
            <div class="form-group">
              <label class="form-label">旋转角度:</label>
              <div class="radio-group">
                <label class="radio-item"><input type="radio" name="imgRotate" value="-45"> -45&deg;</label>
                <label class="radio-item"><input type="radio" name="imgRotate" value="0"> 0&deg;</label>
                <label class="radio-item"><input type="radio" name="imgRotate" value="45" checked> 45&deg;</label>
              </div>
            </div>
            <div class="form-group">
              <label class="form-label">排列方式:</label>
              <div class="radio-group">
                <label class="radio-item"><input type="radio" name="imgLayout" value="single"> 单排</label>
                <label class="radio-item"><input type="radio" name="imgLayout" value="multi" checked> 多排</label>
              </div>
            </div>
            <div class="form-group">
              <label class="form-label">平铺间距(pt):</label>
              <div class="spacing-row">
                <span>&#8596;</span><input type="number" class="form-input" id="imgSpaceX" value="400">
                <span>&#8597;</span><input type="number" class="form-input" id="imgSpaceY" value="400">
              </div>
              <p class="hint">单位:磅(pt),1pt &asymp; 0.35mm</p>
            </div>
            <div class="form-group">
              <label class="form-label">图片缩放:</label>
              <div class="range-wrap">
                <input type="range" class="form-input" id="imgScale" min="10" max="200" value="100" oninput="document.getElementById('imgScaleVal').textContent=this.value+'%'">
                <span class="range-val" id="imgScaleVal">100%</span>
              </div>
            </div>
          </div>

          <div class="form-group">
            <label class="form-label">页面范围:</label>
            <div class="page-range">
              <input type="number" class="form-input" id="pageStart" value="1" min="1">
              <span>至</span>
              <input type="number" class="form-input" id="pageEnd" value="45" min="1">
              <span>页</span>
            </div>
            <p class="hint" id="pageHint">注:该PDF共 0 页,预览第 1 页</p>
          </div>

          <button class="btn-primary" id="confirmBtn" onclick="applyWatermark()">确定</button>
        </div>
      </div>
    </div>

    <div id="step3" class="hidden">
      <div class="success-wrap">
        <div class="success-icon">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
        </div>
        <div class="success-title">文件加水印成功</div>
        <div class="success-file">
          <svg viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
          <span id="outputFileName">output.pdf</span>
        </div>
        <div class="success-btns">
          <button class="btn-back" onclick="goBack()">
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 14L4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>
            返回继续加水印
          </button>
          <button class="btn-download" onclick="downloadFile()">
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
            下载加水印文件
          </button>
        </div>
        <p class="success-note">注:您上传的文件在半小时后会自动删除,您也可以在文件下载成功后手动删除文件!<br><a href="#" style="color:#4a90e2">[手动删除]</a></p>
      </div>
    </div>

  </div>
</div>

<div class="loading-overlay hidden" id="loading">
  <div class="spinner"></div>
  <span id="loadingText">正在处理...</span>
</div>

<script>
let pdfDoc = null, pdfJsDoc = null, currentPage = 1, totalPages = 0;
let viewportScale = 1.5, watermarkImageBytes = null, watermarkImageObj = null;
let outputPdfBytes = null, currentFileName = '', currentTab = 'text';

pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';

function setStep(n) {
  for (let i = 1; i <= 3; i++) {
    const item = document.getElementById('step'+i+'-item');
    const line = document.getElementById('line'+(i-1));
    item.classList.remove('active','done');
    if (line) line.classList.remove('active');
    if (i < n) item.classList.add('done');
    if (i === n) item.classList.add('active');
    if (line && i <= n) line.classList.add('active');
  }
  document.getElementById('step1').classList.toggle('hidden', n !== 1);
  document.getElementById('step2').classList.toggle('hidden', n !== 2);
  document.getElementById('step3').classList.toggle('hidden', n !== 3);
}

const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');

uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
uploadArea.addEventListener('drop', e => { e.preventDefault(); uploadArea.classList.remove('dragover'); if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', e => { if (e.target.files.length) handleFile(e.target.files[0]); });

async function handleFile(file) {
  if (!file.name.toLowerCase().endsWith('.pdf')) { alert('请选择 PDF 文件'); return; }
  currentFileName = file.name;
  showLoading('正在读取PDF文件...');
  const arrayBuffer = await file.arrayBuffer();
  try { pdfDoc = await PDFLib.PDFDocument.load(arrayBuffer); totalPages = pdfDoc.getPageCount(); }
  catch(e) { hideLoading(); alert('无法读取PDF文件'); return; }
  const typedArray = new Uint8Array(arrayBuffer);
  pdfJsDoc = await pdfjsLib.getDocument({ data: typedArray }).promise;
  document.getElementById('pageEnd').value = totalPages;
  document.getElementById('pageEnd').max = totalPages;
  document.getElementById('pageHint').textContent = '注:该PDF共 ' + totalPages + ' 页,预览第 1 页';
  const sel = document.getElementById('previewPageSelect');
  sel.innerHTML = '';
  for (let i = 1; i <= totalPages; i++) { const opt = document.createElement('option'); opt.value = i; opt.textContent = '第 ' + i + ' 页'; sel.appendChild(opt); }
  document.getElementById('fileList').innerHTML = '<div class="file-item"><span class="file-name">' + file.name + '</span><span class="file-status">已上传</span><span class="file-remove" onclick="removeFile()">&times;</span></div>';
  hideLoading();
  setStep(2);
  currentPage = 1;
  await renderPage(1);
}

function removeFile() {
  pdfDoc = null; pdfJsDoc = null; currentPage = 1; totalPages = 0;
  document.getElementById('fileList').innerHTML = '';
  document.getElementById('fileInput').value = '';
  setStep(1);
}

/* ==================== 预览渲染(单位统一:pt * scale = px) ==================== */
async function renderPage(pageNum) {
  if (!pdfJsDoc) return;
  showLoading('正在渲染预览...');
  const page = await pdfJsDoc.getPage(pageNum);
  const canvas = document.getElementById('previewCanvas');
  const ctx = canvas.getContext('2d');
  const scale = viewportScale;
  const viewport = page.getViewport({ scale });
  canvas.width = viewport.width;
  canvas.height = viewport.height;
  await page.render({ canvasContext: ctx, viewport }).promise;
  drawWatermarkPreview(ctx, canvas.width, canvas.height, scale);
  document.getElementById('pageInfo').textContent = '第 ' + pageNum + ' 页 / 共 ' + totalPages + ' 页';
  document.getElementById('prevPage').disabled = pageNum <= 1;
  document.getElementById('nextPage').disabled = pageNum >= totalPages;
  document.getElementById('previewPageSelect').value = pageNum;
  document.getElementById('pageHint').textContent = '注:该PDF共 ' + totalPages + ' 页,预览第 ' + pageNum + ' 页';
  hideLoading();
}

/* 绘制水印预览 ------ 所有参数均为 PDF pt 单位,通过 scale 映射到 Canvas px */
function drawWatermarkPreview(ctx, canvasW, canvasH, scale) {
  ctx.save();

  if (currentTab === 'text') {
    const text = document.getElementById('wmText').value.trim();
    if (!text) { ctx.restore(); return; }

    const fontSize = parseInt(document.getElementById('wmSize').value);
    const colorHex = document.getElementById('wmColor').value;
    const opacity  = parseInt(document.getElementById('wmOpacity').value) / 100;
    const rotate   = -parseFloat(document.querySelector('input[name="rotate"]:checked').value);  // 取反:Canvas Y轴向下,PDF Y轴向上
    const layout   = document.querySelector('input[name="layout"]:checked').value;
    const sx = parseInt(document.getElementById('spaceX').value) || 400;
    const sy = parseInt(document.getElementById('spaceY').value) || 600;

    // 字体大小:pt -> canvas px(直接乘以 pdf.js viewport scale)
    const pxSize = fontSize * scale;
    ctx.font = 'bold ' + pxSize + 'px "Microsoft YaHei", "PingFang SC", "SimSun", sans-serif';
    ctx.fillStyle = colorHex;
    ctx.globalAlpha = opacity;

    const metrics = ctx.measureText(text);
    const tw = metrics.width;
    const th = pxSize;

    // PDF 页面尺寸(pt)
    const pdfW = canvasW / scale;
    const pdfH = canvasH / scale;

    if (layout === 'single') {
      ctx.save();
      ctx.translate(canvasW / 2, canvasH / 2);
      ctx.rotate(rotate * Math.PI / 180);
      ctx.fillText(text, -tw / 2, th / 3);
      ctx.restore();
    } else {
      const startX = -pdfW, endX = pdfW * 2;
      const startY = -pdfH, endY = pdfH * 2;
      for (let x = startX; x < endX; x += sx) {
        for (let y = startY; y < endY; y += sy) {
          ctx.save();
          ctx.translate(x * scale, y * scale);
          ctx.rotate(rotate * Math.PI / 180);
          ctx.fillText(text, -tw / 2, th / 3);
          ctx.restore();
        }
      }
    }
  }
  else if (currentTab === 'image' && watermarkImageObj) {
    const opacity  = parseInt(document.getElementById('imgOpacity').value) / 100;
    const rotate   = -parseFloat(document.querySelector('input[name="imgRotate"]:checked').value);  // 取反:Canvas Y轴向下,PDF Y轴向上
    const layout   = document.querySelector('input[name="imgLayout"]:checked').value;
    const sx = parseInt(document.getElementById('imgSpaceX').value) || 400;
    const sy = parseInt(document.getElementById('imgSpaceY').value) || 600;
    const scalePct = parseInt(document.getElementById('imgScale').value) / 100;

    // 图片在 PDF 中的尺寸(pt)= 像素 * scalePct
    const pdfImgW = watermarkImageObj.width * scalePct;
    const pdfImgH = watermarkImageObj.height * scalePct;
    const pxImgW  = pdfImgW * scale;
    const pxImgH  = pdfImgH * scale;

    const pdfW = canvasW / scale;
    const pdfH = canvasH / scale;

    ctx.globalAlpha = opacity;
    if (layout === 'single') {
      ctx.save();
      ctx.translate(canvasW / 2, canvasH / 2);
      ctx.rotate(rotate * Math.PI / 180);
      ctx.drawImage(watermarkImageObj, -pxImgW / 2, -pxImgH / 2, pxImgW, pxImgH);
      ctx.restore();
    } else {
      const startX = -pdfW, endX = pdfW * 2;
      const startY = -pdfH, endY = pdfH * 2;
      for (let x = startX; x < endX; x += sx) {
        for (let y = startY; y < endY; y += sy) {
          ctx.save();
          ctx.translate(x * scale, y * scale);
          ctx.rotate(rotate * Math.PI / 180);
          ctx.drawImage(watermarkImageObj, -pxImgW / 2, -pxImgH / 2, pxImgW, pxImgH);
          ctx.restore();
        }
      }
    }
  }

  ctx.restore();
}

function updatePreview() { if (pdfJsDoc) renderPage(currentPage); }
function changePage(delta) { const n = currentPage + delta; if (n >= 1 && n <= totalPages) { currentPage = n; renderPage(n); } }
function jumpPage(v) { currentPage = parseInt(v); renderPage(currentPage); }

function switchTab(tab) {
  currentTab = tab;
  document.getElementById('tab-text').classList.toggle('active', tab === 'text');
  document.getElementById('tab-image').classList.toggle('active', tab === 'image');
  document.getElementById('config-text').classList.toggle('hidden', tab !== 'text');
  document.getElementById('config-image').classList.toggle('hidden', tab !== 'image');
  updatePreview();
}

function handleImageUpload(input) {
  const file = input.files[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = e => {
    const img = new Image();
    img.onload = () => {
      watermarkImageObj = img;
      document.getElementById('wmImgPreview').src = img.src;
      document.getElementById('wmImgPreview').classList.remove('hidden');
      document.getElementById('imgUploadText').classList.add('hidden');
      updatePreview();
    };
    img.src = e.target.result;
  };
  reader.readAsDataURL(file);
}

document.getElementById('imgInput').addEventListener('change', async function(e) {
  const file = e.target.files[0];
  if (!file) return;
  watermarkImageBytes = new Uint8Array(await file.arrayBuffer());
});

/* ==================== 应用水印 ==================== */
async function applyWatermark() {
  if (!pdfDoc) return;
  const s = Math.max(1, parseInt(document.getElementById('pageStart').value) || 1);
  const e = Math.min(totalPages, parseInt(document.getElementById('pageEnd').value) || totalPages);

  if (currentTab === 'text') {
    const text = document.getElementById('wmText').value.trim();
    if (!text) { alert('请输入水印文字'); return; }
  } else {
    if (!watermarkImageBytes) { alert('请先上传水印图片'); return; }
  }

  showLoading('正在生成水印PDF,请稍候...');
  try {
    if (currentTab === 'text') await applyTextWatermark(s, e);
    else await applyImageWatermark(s, e);
    outputPdfBytes = await pdfDoc.save();
    document.getElementById('outputFileName').textContent = '(加水印) ' + currentFileName;
    hideLoading();
    setStep(3);
  } catch (err) {
    hideLoading();
    console.error(err);
    alert('处理失败: ' + err.message);
  }
}

/* 文字水印生成 ------ Canvas 转 PNG(完美支持中文) */
async function applyTextWatermark(start, end) {
  const text     = document.getElementById('wmText').value.trim();
  const fontSize = parseInt(document.getElementById('wmSize').value);
  const colorHex = document.getElementById('wmColor').value;
  const opacity  = parseInt(document.getElementById('wmOpacity').value) / 100;
  const rotate   = parseFloat(document.querySelector('input[name="rotate"]:checked').value);
  const layout   = document.querySelector('input[name="layout"]:checked').value;
  const sx = parseInt(document.getElementById('spaceX').value) || 400;
  const sy = parseInt(document.getElementById('spaceY').value) || 600;

  const { bytes, widthPt, heightPt } = await renderTextToPng(text, fontSize, colorHex, opacity);
  const textImg = await pdfDoc.embedPng(bytes);

  for (let i = start - 1; i < end; i++) {
    const page = pdfDoc.getPage(i);
    const { width, height } = page.getSize();

    if (layout === 'single') {
      page.drawImage(textImg, {
        x: width/2  - widthPt/2,
        y: height/2 - heightPt/2,
        width: widthPt,
        height: heightPt,
        opacity: opacity,
        rotate: PDFLib.degrees(rotate),
      });
    } else {
      const startX = -width,  endX = width * 2;
      const startY = -height, endY = height * 2;
      for (let x = startX; x < endX; x += sx) {
        for (let y = startY; y < endY; y += sy) {
          page.drawImage(textImg, {
            x: x,
            y: y,
            width: widthPt,
            height: heightPt,
            opacity: opacity,
            rotate: PDFLib.degrees(rotate),
          });
        }
      }
    }
  }
}

/* 将文字渲染成高DPI PNG ------ 在 pt=px 坐标系下测量,与 PDF 单位完全一致 */
async function renderTextToPng(text, fontSizePt, colorHex, opacity) {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  const dpr = 3;

  ctx.font = 'bold ' + fontSizePt + 'px "Microsoft YaHei", "PingFang SC", "SimSun", sans-serif';
  const metrics = ctx.measureText(text);

  const pad = 4;
  const w = Math.ceil(metrics.width) + pad * 2;
  const h = Math.ceil(fontSizePt * 1.3);

  canvas.width  = w * dpr;
  canvas.height = h * dpr;
  ctx.scale(dpr, dpr);

  ctx.font = 'bold ' + fontSizePt + 'px "Microsoft YaHei", "PingFang SC", "SimSun", sans-serif';
  ctx.fillStyle = colorHex;
  ctx.globalAlpha = opacity;
  ctx.textBaseline = 'middle';
  ctx.fillText(text, pad, h / 2);

  const dataUrl = canvas.toDataURL('image/png');
  const res = await fetch(dataUrl);
  const bytes = new Uint8Array(await res.arrayBuffer());

  return { bytes, widthPt: w, heightPt: h };
}

/* 图片水印生成 */
async function applyImageWatermark(start, end) {
  const opacity  = parseInt(document.getElementById('imgOpacity').value) / 100;
  const rotate   = parseFloat(document.querySelector('input[name="imgRotate"]:checked').value);
  const layout   = document.querySelector('input[name="imgLayout"]:checked').value;
  const sx = parseInt(document.getElementById('imgSpaceX').value) || 400;
  const sy = parseInt(document.getElementById('imgSpaceY').value) || 600;
  const scalePct = parseInt(document.getElementById('imgScale').value) / 100;

  let embedImg;
  try { embedImg = await pdfDoc.embedPng(watermarkImageBytes); }
  catch { embedImg = await pdfDoc.embedJpg(watermarkImageBytes); }

  const imgW = embedImg.width * scalePct;
  const imgH = embedImg.height * scalePct;

  for (let i = start - 1; i < end; i++) {
    const page = pdfDoc.getPage(i);
    const { width, height } = page.getSize();

    if (layout === 'single') {
      page.drawImage(embedImg, {
        x: width/2 - imgW/2,
        y: height/2 - imgH/2,
        width: imgW,
        height: imgH,
        opacity: opacity,
        rotate: PDFLib.degrees(rotate),
      });
    } else {
      const startX = -width,  endX = width * 2;
      const startY = -height, endY = height * 2;
      for (let x = startX; x < endX; x += sx) {
        for (let y = startY; y < endY; y += sy) {
          page.drawImage(embedImg, {
            x: x,
            y: y,
            width: imgW,
            height: imgH,
            opacity: opacity,
            rotate: PDFLib.degrees(rotate),
          });
        }
      }
    }
  }
}

function downloadFile() {
  if (!outputPdfBytes) return;
  const blob = new Blob([outputPdfBytes], { type: 'application/pdf' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = document.getElementById('outputFileName').textContent;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

function goBack() { removeFile(); setStep(1); }

function showLoading(text) {
  document.getElementById('loadingText').textContent = text || '正在处理...';
  document.getElementById('loading').classList.remove('hidden');
}
function hideLoading() {
  document.getElementById('loading').classList.add('hidden');
}
</script>

</body>
</html>

八、总结

表格

特性 实现方式
纯前端运行 pdf-lib + pdf.js,无后端
中文支持 Canvas 渲染文字为 PNG 后嵌入
实时预览 pdf.js 渲染 + Canvas 叠加绘制
高清晰度 dpr=3 高倍渲染 + PNG 无损嵌入
灵活配置 单排/多排、间距、角度、透明度、页面范围

这个项目非常适合集成到内部工具站或作为独立页面使用。如果你有类似需求,欢迎参考实现思路,也欢迎在评论区交流讨论!


参考链接:


💡 小提示 :由于 pdf-lib 直接嵌入图片会增加 PDF 体积,建议水印文字不要太长,图片水印尽量压缩后再上传。如果 PDF 页数较多,生成时可能需要等待几秒,建议加 Loading 动画提升体验。

相关推荐
石小石Orz1 小时前
TRAE SOLO实战:实现一个桌面3D助手
前端·人工智能
蜡台1 小时前
使用 uni-popup 实现数据选择器Data-Picker
前端·javascript·html·uniapp·uni-popup·data-picker
拆房老料1 小时前
BaseMetas FileView 1.2.0 发布:Office/WPS 大文件预览与内存安全优化实测
前端·产品运营·开源软件
blns_yxl1 小时前
Promise封装Fetch + 重试机制(HTML+JS)
前端·javascript·html
Android洋芋1 小时前
PrintFriendly网页文章转PDF插件工具及技术分析
运维·服务器·pdf·网页转pdf
Elastic 中国社区官方博客1 小时前
使用重新设计的 AutoOps 更快地进行 Elasticsearch 问题排查
大数据·运维·前端·人工智能·elasticsearch·搜索引擎·全文检索
无巧不成书02181 小时前
Vue+Vue-CLI全平台零基础搭建教程
前端·javascript·vue.js·vue·前端开发·vue-cli·前端环境搭建
何时梦醒1 小时前
⚛️ React 19 组件化实战 —— 从零搭建 Todo List 并吃透组件通信
前端·人工智能·react.js