批量加水印还能一键去水印?我写了一个PDF处理工具一键搞定
今天就把这个工具的实现过程写出来。分两大块:后端Python(干活的引擎)和前端HTML页面(点来点去的界面)。代码都贴出来了,复制回去改改就能跑。
先说说后端怎么搞的
Python里能处理PDF的库好几个,最常被提起的是 PyPDF2 和 pikepdf。我把它们放一起比了一下:
PyPDF2
是纯Python写的, pip install完事,最简单。缺点是碰到AES-256加密的PDF会报错,大文件处理起来内存飙得厉害。pikepdf
底层是C++的QPDF库,性能稳,加密文件也照啃不误。装起来稍微绕一点,但 pip install pikepdf也基本能搞定。
日常办公用,文件不大、没加密,PyPDF2 足够了。如果处理的是重要合同、带密码的文件,或者追求一个稳字,换 pikepdf。我两个版本的代码都贴出来,按需取用。
加水印的思路
给PDF加水印,代码做的事其实挺“笨”的——它不是真的在页面上画东西,而是把水印PDF的每一页,和原始PDF的每一页,像两张透明胶片一样叠在一起。
所以你事先得准备一个水印PDF。怎么准备?打开Word或PPT,打上“公司机密”几个字,调好字体、透明度、倾斜角度,导出成PDF就行。这个PDF有多少页不重要,代码只取第一页当模板,然后往目标文件上反复贴。
去水印的思路
去水印比加水印麻烦不少,因为它要跟PDF的底层结构打交道。PDF文件里,水印通常是两种形式:要么是文本对象(比如“机密”两个字),要么是一张图片。
去文本水印的思路是:找到页面内容流里负责“显示文字”的那条指令,检查文字里有没有你指定的关键词(比如“机密”),有的话就把那段文字替换成空字符串。图片水印的处理更复杂一些,这里先不展开。
有个事得先说明白:去水印的效果,完全取决于水印当初是怎么加上去的。如果水印是通过专门的工具“烧”进页面里的,或者跟正文糊在同一个图层上,那大概率去不干净,甚至可能伤到正文。本文的方法,对付那种“叠加型”的文本水印比较有效。
安装库
打开命令行:
# PyPDF2 版pip install PyPDF2# pikepdf 版(二选一)pip install pikepdf
后端完整代码(PyPDF2版)
新建文件 pdf_tool_backend.py,把下面这些贴进去:
import osimport zipfilefrom PyPDF2 import PdfReader, PdfWriterfrom PyPDF2.generic import ContentStream, NameObject, TextStringObjectfrom PyPDF2.utils import b_# ----- 加水印 -----defadd_watermark_single(input_pdf_path, watermark_pdf_path, output_pdf_path): watermark_reader = PdfReader(watermark_pdf_path) watermark_page = watermark_reader.pages[0] input_reader = PdfReader(input_pdf_path) writer = PdfWriter()for page in input_reader.pages: page.merge_page(watermark_page) writer.add_page(page)withopen(output_pdf_path, "wb") as f: writer.write(f)returnTruedefbatch_add_watermark(input_folder, watermark_pdf, output_folder): os.makedirs(output_folder, exist_ok=True) pdf_files = [f for f in os.listdir(input_folder) if f.lower().endswith('.pdf')]ifnot pdf_files:print("📭 文件夹里没有PDF文件")return [] results = []print(f"📂 共发现 {len(pdf_files)} 个PDF文件")for idx, filename inenumerate(pdf_files, start=1): input_path = os.path.join(input_folder, filename) output_path = os.path.join(output_folder, f"水印_{filename}") add_watermark_single(input_path, watermark_pdf, output_path) results.append(output_path)print(f" [加水印 {idx}/{len(pdf_files)}] {filename}")return results# ----- 去水印 -----defremove_watermark_single(input_pdf_path, watermark_text, output_pdf_path): reader = PdfReader(input_pdf_path) writer = PdfWriter()for page in reader.pages: content_object = page["/Contents"] content = ContentStream(content_object, reader)for operands, operator in content.operations:if operator == b_("Tj") or operator == b_("TJ"):if operands: text_item = operands[0] if operator == b_("Tj") else operands[0][0]ifisinstance(text_item, TextStringObject):if watermark_text in text_item:if operator == b_("Tj"): operands[0] = TextStringObject("")else: operands[0] = [TextStringObject("")] page[NameObject("/Contents")] = content writer.add_page(page)withopen(output_pdf_path, "wb") as f: writer.write(f)returnTruedefbatch_remove_watermark(input_folder, watermark_text, output_folder): os.makedirs(output_folder, exist_ok=True) pdf_files = [f for f in os.listdir(input_folder) if f.lower().endswith('.pdf')]ifnot pdf_files:print("📭 文件夹里没有PDF文件")return [] results = []print(f"📂 共发现 {len(pdf_files)} 个PDF文件")for idx, filename inenumerate(pdf_files, start=1): input_path = os.path.join(input_folder, filename) output_path = os.path.join(output_folder, f"无水印_{filename}")try: remove_watermark_single(input_path, watermark_text, output_path) results.append(output_path)print(f" [去水印 {idx}/{len(pdf_files)}] {filename}")except Exception as e:print(f" ⚠️ [跳过] {filename} - 处理失败,可能水印格式不兼容")return results# ----- 打包下载 -----defzip_folder(folder_path, zip_path):with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:for root, dirs, files in os.walk(folder_path):for file in files: file_path = os.path.join(root, file) arcname = os.path.relpath(file_path, folder_path) zipf.write(file_path, arcname)return zip_path# ----- 本地测试入口 -----if __name__ == "__main__": input_dir = r"C:\Users\你的用户名\Desktop\原始PDF" watermark_file = r"C:\Users\你的用户名\Desktop\水印.pdf" output_dir = r"C:\Users\你的用户名\Desktop\处理后"print("=" * 50)print(" PDF批量处理工具(后端)")print("=" * 50)print("\n🔖 正在添加水印...") batch_add_watermark(input_dir, watermark_file, output_dir)# 去水印的话把这行注释去掉# batch_remove_watermark(input_dir, "机密", output_dir)print(f"\n✅ 处理完成!文件保存在:{output_dir}")
代码里干了什么
加水印部分:PdfReader 打开水印文件,取第一页。然后打开要处理的PDF,一页一页调用 merge_page 把水印贴上去。最后 PdfWriter 把处理过的页面攒成一个新文件。
去水印部分:拿到每一页的“内容流”(/Contents),这个流里面是一串操作指令。遍历这些指令,找到文本显示操作(Tj 或 TJ),检查显示的文字里有没有你指定的关键词。有就替换成空字符串。最后把修改后的内容流塞回页面。
换 pikepdf 的话怎么写
pikepdf 的代码确实更干净:
import pikepdf# 加水印defadd_watermark_pike(input_pdf, watermark_pdf, output_pdf):with pikepdf.open(input_pdf) as target, pikepdf.open(watermark_pdf) as watermark: wm_page = watermark.pages[0]for page in target.pages: page.add_overlay(wm_page) target.save(output_pdf)# 去水印(文本水印需要更深层操作,这里给个加水印的例子)# 去水印的逻辑类似,也是操作内容流,但 pikepdf 的 API 更直观一些
pikepdf 的好处是稳定,大文件不虚,加密文件也能搞。代价是安装包大一点(它依赖 QPDF 的 C++ 库)。
再说前端界面怎么搭的
光有后端脚本,每次都得改路径,用起来还是有点烦。我顺手用 TailwindCSS 搭了个网页界面。打开就是一个卡片,能上传文件,能切“加水印”还是“去水印”,能输入水印关键词,点一下等着收文件就行。
前端完整代码
新建 index.html:
<!DOCTYPE html><htmllang="zh-CN"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>PDF批量处理 - 加水印/去水印</title><scriptsrc="https://cdn.tailwindcss.com"></script></head><bodyclass="bg-gray-50 min-h-screen flex items-center justify-center p-4"><divclass="max-w-4xl w-full bg-white rounded-2xl shadow-lg p-6 md:p-8"><h1class="text-2xl font-bold text-gray-800 mb-1">📄 PDF批量处理工具</h1><pclass="text-gray-500 text-sm mb-6">上传PDF,加水印或去水印,一键下载</p><!-- 上传区 --><divclass="border-2 border-dashed border-gray-300 rounded-xl p-6 mb-6 text-center hover:border-blue-400 transition-colors cursor-pointer"id="uploadArea"><divclass="text-4xl mb-2">📂</div><pclass="text-gray-600 font-medium mb-1">点击或拖拽上传PDF文件</p><pclass="text-gray-400 text-xs">支持批量,单文件不超过50MB</p><inputtype="file"id="fileInput"multipleaccept=".pdf"class="hidden"></div><!-- 文件列表 --><divclass="mb-6"><divclass="flex items-center justify-between mb-3"><h2class="text-sm font-semibold text-gray-700">待处理文件</h2><spanclass="text-xs text-gray-400"id="fileCount">0 个文件</span></div><divid="fileList"class="space-y-2 max-h-64 overflow-y-auto"><divclass="bg-gray-50 rounded-lg p-8 text-center text-gray-400 text-sm"> 暂无文件,请先上传</div></div></div><!-- 功能切换 --><divclass="grid grid-cols-2 gap-4 mb-6"><!-- 加水印卡片 --><divclass="border rounded-xl p-4 cursor-pointer transition-all"id="watermarkCard"><divclass="flex items-center gap-2 mb-2"><spanclass="text-2xl">🔖</span><h3class="font-semibold text-gray-700">加水印</h3></div><pclass="text-xs text-gray-500 mb-3">为PDF添加文字水印</p><divclass="bg-blue-50 rounded-lg p-3"><labelclass="text-xs text-gray-600 block mb-1">水印文字</label><inputtype="text"id="watermarkText"value="公司机密"placeholder="例如:公司机密"class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"><pclass="text-xs text-gray-400 mt-1">需提前准备水印PDF模板</p></div></div><!-- 去水印卡片 --><divclass="border rounded-xl p-4 cursor-pointer transition-all"id="removeCard"><divclass="flex items-center gap-2 mb-2"><spanclass="text-2xl">🧹</span><h3class="font-semibold text-gray-700">去水印</h3></div><pclass="text-xs text-gray-500 mb-3">去除文本水印</p><divclass="bg-orange-50 rounded-lg p-3"><labelclass="text-xs text-gray-600 block mb-1">要删除的水印文字</label><inputtype="text"id="removeWatermarkText"value="机密"placeholder="例如:机密"class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-orange-400"><pclass="text-xs text-gray-400 mt-1">输入水印中包含的关键词</p></div></div></div><!-- 当前模式 --><divclass="bg-gray-50 rounded-lg p-3 mb-6 flex items-center justify-between"><spanclass="text-sm text-gray-600">当前模式:</span><spanclass="text-sm font-medium px-3 py-1 bg-white rounded-full shadow-sm"id="currentMode">加水印</span></div><!-- 操作按钮 --><divclass="flex gap-3"><buttonid="processBtn"class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-4 rounded-xl transition-colors shadow-sm disabled:bg-gray-300 disabled:cursor-not-allowed"> 开始处理</button><buttonid="downloadBtn"class="bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium py-3 px-4 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed"disabled> 下载全部</button></div><divid="statusBar"class="mt-4 text-center text-sm text-gray-500 min-h-6"></div></div><script>let selectedFiles = [];let currentMode = 'watermark';const fileList = document.getElementById('fileList');const fileCount = document.getElementById('fileCount');const processBtn = document.getElementById('processBtn');const downloadBtn = document.getElementById('downloadBtn');const statusBar = document.getElementById('statusBar');const currentModeSpan = document.getElementById('currentMode');const watermarkCard = document.getElementById('watermarkCard');const removeCard = document.getElementById('removeCard'); watermarkCard.addEventListener('click', () => { currentMode = 'watermark'; currentModeSpan.textContent = '加水印'; watermarkCard.classList.add('border-blue-400', 'bg-blue-50/30'); removeCard.classList.remove('border-orange-400', 'bg-orange-50/30'); processBtn.classList.remove('bg-orange-600', 'hover:bg-orange-700'); processBtn.classList.add('bg-blue-600', 'hover:bg-blue-700'); }); removeCard.addEventListener('click', () => { currentMode = 'remove'; currentModeSpan.textContent = '去水印'; removeCard.classList.add('border-orange-400', 'bg-orange-50/30'); watermarkCard.classList.remove('border-blue-400', 'bg-blue-50/30'); processBtn.classList.remove('bg-blue-600', 'hover:bg-blue-700'); processBtn.classList.add('bg-orange-600', 'hover:bg-orange-700'); });const uploadArea = document.getElementById('uploadArea');const fileInput = document.getElementById('fileInput'); uploadArea.addEventListener('click', () => fileInput.click()); uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('border-blue-400', 'bg-blue-50'); }); uploadArea.addEventListener('dragleave', () => { uploadArea.classList.remove('border-blue-400', 'bg-blue-50'); }); uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.classList.remove('border-blue-400', 'bg-blue-50');const files = Array.from(e.dataTransfer.files).filter(f => f.type === 'application/pdf');addFiles(files); }); fileInput.addEventListener('change', (e) => {const files = Array.from(e.target.files);addFiles(files); fileInput.value = ''; });functionaddFiles(files) { selectedFiles = [...selectedFiles, ...files];renderFileList(); }functionrenderFileList() {if (selectedFiles.length === 0) { fileList.innerHTML = '<div class="bg-gray-50 rounded-lg p-8 text-center text-gray-400 text-sm">暂无文件,请先上传</div>'; fileCount.textContent = '0 个文件'; processBtn.disabled = true;return; } processBtn.disabled = false; fileCount.textContent = `${selectedFiles.length} 个文件`; fileList.innerHTML = selectedFiles.map((file, index) =>` <div class="bg-gray-50 rounded-lg p-3 flex items-center justify-between"> <div class="flex items-center gap-3"> <span class="text-red-500">📄</span> <div> <p class="text-sm text-gray-700 truncate max-w-xs">${file.name}</p> <p class="text-xs text-gray-400">${(file.size / 1024).toFixed(1)} KB</p> </div> </div> <button onclick="removeFile(${index})" class="text-gray-400 hover:text-red-500 text-lg">×</button> </div> `).join(''); }window.removeFile = function(index) { selectedFiles.splice(index, 1);renderFileList(); }; processBtn.addEventListener('click', () => {if (selectedFiles.length === 0) return; processBtn.disabled = true;const action = currentMode === 'watermark' ? '加水印' : '去水印';const watermarkValue = currentMode === 'watermark' ? document.getElementById('watermarkText').value : document.getElementById('removeWatermarkText').value; statusBar.textContent = `⏳ 正在${action},处理 ${selectedFiles.length} 个文件...`;// 模拟处理(真实场景替换成 fetch 调用后端)setTimeout(() => { statusBar.textContent = `✅ ${action}完成!共处理 ${selectedFiles.length} 个文件`; downloadBtn.disabled = false; processBtn.disabled = false; }, 2000); }); downloadBtn.addEventListener('click', () => { statusBar.textContent = '📥 正在打包下载...';setTimeout(() => { statusBar.textContent = '✅ 下载完成!'; }, 1500); }); watermarkCard.classList.add('border-blue-400', 'bg-blue-50/30');</script></body></html>
界面长什么样
打开后就是一个大白卡片。顶上是一个拖拽上传区,文件扔进去就出现在下面列表里。中间两个卡片,一个加水印一个去水印,点了哪个按钮颜色就跟着变。每个卡片里都有对应的输入框——加水印填水印文字,去水印填要删的关键词。底部状态条会告诉你处理到哪一步了。
整个页面没用一行自定义CSS,全是Tailwind的原子类拼出来的。bg-white rounded-2xl shadow-lg p-6 就是白色背景、圆角、阴影、内边距。改样式直接在HTML里加类名就行,不用切来切去改CSS文件。
前后端怎么接起来
现在前端里的处理按钮只是装装样子(setTimeout 模拟了一下)。真要跑起来,得把那个点击事件换成真实的API请求:
const formData = newFormData();selectedFiles.forEach(file => formData.append('files', file));formData.append('mode', currentMode);formData.append('watermark_text', document.getElementById( currentMode === 'watermark' ? 'watermarkText' : 'removeWatermarkText').value);fetch('http://localhost:8000/process', {method: 'POST',body: formData}).then(res => res.blob()).then(blob => {const url = window.URL.createObjectURL(blob);const a = document.createElement('a'); a.href = url; a.download = 'processed_pdfs.zip'; a.click(); statusBar.textContent = '✅ 下载已开始!';});
后端用 FastAPI 或 Flask 起一个服务,接收文件,根据 mode 参数调用 batch_add_watermark 或 batch_remove_watermark,然后把输出文件夹打成zip包返回。具体怎么搭这个服务,要是大家感兴趣我后面再写一篇干货。
写在最后
这篇文章算是把前后端分离的小项目完整跑通了一遍:
-
后端用 PyPDF2(或pikepdf)当PDF处理引擎,加水印靠merge_page,去水印靠解析内容流替换文本。 -
前端用TailwindCSS画界面,上传、列表、功能切换、状态反馈一条龙。
把这个东西跑起来之后,你可以把水印PDF换成自己公司的模板,部署到部门内网服务器上,或者继续往上加功能——加页码、压缩文件、设置密码,骨架已经有了,往上添肉就行。
写到这里。如果你身边也有被PDF反复折磨的同事,可以把这个工具分享给他。或者你有啥其他想自动化的办公场景,评论区唠唠,我看看能不能研究下用代码写个工具。
夜雨聆风