在日常办公中,给 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() 写入中文,会得到一堆乱码或空白。
常见的解决方案有两种:
-
嵌入中文字体文件(如思源黑体、微软雅黑)------ 但字体文件动辄几 MB,加载慢,且需要处理字体子集化
-
将文字渲染为图片,再嵌入 PDF ------ 这就是我们采用的方案
3.2 实现方案:Canvas → PNG → PDF
核心思路:用 HTML5 Canvas 把文字画成一张透明背景的 PNG 图片,然后用 pdf-lib 的 embedPng() 将这张图片嵌入到每一页 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 时更容易计算位置 -
返回的
widthPt和heightPt直接以 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)">‹</button>
<span id="pageInfo">第 1 页 / 共 1 页</span>
<button id="nextPage" onclick="changePage(1)">›</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°</label>
<label class="radio-item"><input type="radio" name="rotate" value="0" onchange="updatePreview()"> 0°</label>
<label class="radio-item"><input type="radio" name="rotate" value="45" checked onchange="updatePreview()"> 45°</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>↔</span><input type="number" class="form-input" id="spaceX" value="400" onchange="updatePreview()">
<span>↕</span><input type="number" class="form-input" id="spaceY" value="400" onchange="updatePreview()">
</div>
<p class="hint">单位:磅(pt),1pt ≈ 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°</label>
<label class="radio-item"><input type="radio" name="imgRotate" value="0"> 0°</label>
<label class="radio-item"><input type="radio" name="imgRotate" value="45" checked> 45°</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>↔</span><input type="number" class="form-input" id="imgSpaceX" value="400">
<span>↕</span><input type="number" class="form-input" id="imgSpaceY" value="400">
</div>
<p class="hint">单位:磅(pt),1pt ≈ 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()">×</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 动画提升体验。
