乐于分享
好东西不私藏

批量加水印还能一键去水印?我写了一个PDF处理工具一键搞定

批量加水印还能一键去水印?我写了一个PDF处理工具一键搞定

市面上不缺加水印的工具,但要么收费,要么需要把文件上传到陌生网站,要么界面复杂得像飞机驾驶舱。往往真正需要的东西其实特别简单:一个网页,上传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[0if 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">&times;</button>                </div>            `).join('');        }window.removeFile = function(index) {            selectedFiles.splice(index, 1);renderFileList();        };        processBtn.addEventListener('click'() => {if (selectedFiles.length === 0return;            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反复折磨的同事,可以把这个工具分享给他。或者你有啥其他想自动化的办公场景,评论区唠唠,我看看能不能研究下用代码写个工具。