1.1 清印 ClearMark — 一款本地文档去水印工作台的完整设计与实现
系列第 1 篇 · 共 12 篇
这不是一篇产品软文,而是一名一线开发者对自己做过的一个工具系统的复盘。从产品定位、架构选型,到 PDF 内容流解析、扫描件像素级水印检测、OpenCV 图像修复、PySide6 桌面 UI、跨平台适配、批量任务编排——我会把整套设计拆成 12 篇,逐步讲清楚。
如果你只是想要个能用的工具,可以直接拉到文末下载链接;如果你想知道"水印到底是怎么被检测和擦除的",欢迎跟着系列一路读下去。
一、为什么我们要再做一款去水印工具
市面上并不缺去水印工具。在线版的有 ILovePDF、SmallPDF,桌面端的有 WPS 自带的去水印、Adobe Acrobat 的编辑功能,还有一堆 PDF 转换类网站顺手提供。但真到了实际生产环境,你会发现它们都卡在三类痛点上:
痛点 1:在线工具过不了合规审查
金融、政企、科研单位里,文档大多含合同条款、客户数据、内部资料。把这些文件传到一个境外服务器去做"水印识别",合规这一关根本过不去。哪怕服务方承诺"处理完即删",审计也写不出来。
我自己就遇到过:一份扫描件采购合同,上面盖了"仅供内部使用"的灰色斜铺水印,业务方想拿去做汇报。问了一圈在线工具,没有一个敢传。最后只能截图——截图又把 OCR 识别率拉低了 30%。
痛点 2:通用工具识别不全
WPS 的去水印只认页眉页脚里的几个固定模板;Acrobat 的"编辑 PDF"可以删文字,但你得手动一个个点;Photoshop 能修图,但不能直接处理 PDF 内容流,而且对扫描件烤进去的浅色斜字根本无能为力——那是像素,不是图层。
最让人头疼的是扫描件。很多公司用扫描全能王、福昕、WPS 扫描后导出 PDF,水印是烤进扫描图里的——文字被光栅化成像素,浅灰色、半透明、45° 斜铺,整页覆盖。这种水印既不是 PDF 注释,也不是 XObject,是图片像素本身。绝大多数工具遇到它只能放弃。
痛点 3:批量处理无从下手
实际场景里没人会一次处理一份。法务一周要清理几十份合同,研究院要批量脱敏几百页报告。这类需求需要:
- 一次拖入整个文件夹
- 并发检测,不要逐个等
- 每份文件单独列出候选,可以人工勾选
- 失败的不影响其他文件
- 输出有迹可循,不覆盖原文件
市面上 99% 的工具只支持单文件、单水印、单点删除。批量?不存在的。
二、清印 ClearMark 是什么
基于上述痛点,我们做了清印 ClearMark 智能文档去水印工作台 V2.0。一句话定位:
一款 100% 本地运行、支持 PDF 与图片、覆盖矢量/扫描/混合三类水印、可批量的桌面去水印工具。
它的核心约束有三条:
- 不联网。所有解析、检测、修复、保存都在本地完成。源文件不上传任何服务器,连个 ping 都没有。
- 不动源文件。输出永远是新文件,存到源目录的
output/子文件夹;不可写时回退到~/.clearmark/output/;同时源文件打开时自动备份到backup/。 - 可解释。每条检测到的水印都会列出候选,标注置信度、坐标、来源(重复/旋转/签名/聚类/OCR),用户勾选才移除——不做"黑盒一键清除"。
它能处理什么水印
经过两轮迭代,V2.0 覆盖的水印类型如下:
| 大类 | 子类 | 检测方式 | 移除策略 |
|---|---|---|---|
| 矢量 PDF 文字水印 | 跨页重复、同页平铺网格、旋转大字、品牌签名 | 内容流解析 + 几何特征 + 规则库关键词 | 内容流区间擦除 |
| 矢量 PDF 图片水印 | Logo、二维码、共享 XObject | XObject xref 跨页统计 | 整对象清空(空流 + GC) |
| 矢量 PDF 图层水印 | OCG 可选内容图层 | doc.get_ocgs() | 写入/D/OFF |
| 矢量 PDF 注释水印 | Watermark/Stamp/FreeText 注释 | page.annots()遍历 | page.delete_annot() |
| 扫描件浅色斜铺文字 | 烤进扫描图的半透明斜字 | 像素级笔画分割 + 形态学分组 + 整页旋转 OCR 验证 | 邻域最大亮度填充 + 像素掩膜修复 |
| 扫描件颜色聚类层 | 灰色半透明覆盖 | K-Means 颜色聚类 | Alpha 反演修复 |
| 图片水印 | 文字、Logo、灰色覆盖层 | OCR + 颜色聚类 + 用户涂抹 | Telea 修复 + Alpha 反演 |
| 用户手动标记 | 矩形框选、画笔涂抹、套索 | 用户交互 | 矩形掩膜 + 修复 |
这张表是整个系列后续 10 篇文章的索引。每一行都对应一类检测器和一个移除算法,背后都有真实调试故事。
三、技术栈为什么这么选
工欲善其事,必先利其器。但选器这件事,比做事本身更费心。我们最终的技术栈是:
| 维度 | 选型 | 选它的理由 |
|---|---|---|
| 语言 | Python 3.10.11 | PDF/图像生态最完整,开发效率高,便于交付源码 |
| GUI 框架 | PySide6(Qt6 官方 Python 绑定) | 跨平台一致、QPainter 渲染能力强、信号槽机制天然适配后台线程 |
| PDF 解析 | PyMuPDF(fitz) | 同时支持内容流读写、XObject 操作、注释操作、页面渲染,API 比 pypdf 完整得多 |
| 图像处理 | OpenCV + NumPy | K-Means 聚类、形态学操作、Telea/NS 修复算法都是 OpenCV 原生支持 |
| OCR 引擎 | RapidOCR(PaddleOCR ONNX 版) | 离线包可独立分发,不依赖 PaddlePaddle 大包,识别中文稳定 |
| 内容流解析 | 自研content_stream.py | PyMuPDF 提供的get_texttrace不暴露字节偏移,无法做区间擦除,必须自己写 |
| 打包 | PyInstaller onedir 模式 | 绿色免安装,整个文件夹拷到目标机器即可运行 |
| 目标平台 | 银河麒麟 V10 / Windows 11 | 国产化适配 + 主流桌面 |
为什么不上 PaddleOCR 全量包?因为它体积大(700MB+),而 RapidOCR 只用 ONNX runtime 推理,模型 30MB,识别中文精度足够。这套组合在麒麟 V10 ARM64 上也能跑起来。
为什么不用 pdfplumber / pypdf?因为它们不支持修改。去水印的本质是写操作——你要从内容流里删一段、要清空 XObject、要改 OCG 状态,这些都需要写权限。PyMuPDF 是少数能稳定支持 PDF 写操作的库。
为什么不用 PyQt 而用 PySide6?许可证。PySide6 是 LGPL,商用更友好;API 与 PyQt 几乎一致,迁移成本为零。
四、系统总览:三层架构
整个系统分为三层,每层职责清晰、互不耦合:
┌─────────────────────────────────────────────────────────┐ │ UI 层(ui/) │ │ ┌──────────────┬──────────────┬────────────────────┐ │ │ │ MainWindow │ PreviewWidget│ ResultPanel │ │ │ │ 三栏布局 │ QPainter渲染 │ 候选列表+勾选 │ │ │ ├──────────────┼──────────────┼────────────────────┤ │ │ │ Worker线程 │ Icons多分辨率│ Styles 主题 │ │ │ └──────────────┴──────────────┴────────────────────┘ │ └────────────────────────┬────────────────────────────────┘ │ 统一数据模型 ┌────────────────────────┴────────────────────────────────┐ │ 核心层(core/) │ │ ┌──────────┬──────────┬──────────┬─────────────────┐ │ │ │ Session │TaskOrch │FormatProb│ ContentStream │ │ │ │单文件会话 │批量编排 │格式路由 │ 内容流解析器 │ │ │ ├──────────┼──────────┼──────────┼─────────────────┤ │ │ │ Processor│ Detect/ │ Inpaint/ │ Rules/ │ │ │ │三类处理器 │ 5个检测器 │ CV修复引擎│ 规则库 │ │ │ └──────────┴──────────┴──────────┴─────────────────┘ │ └────────────────────────┬────────────────────────────────┘ │ ┌────────────────────────┴────────────────────────────────┐ │ 数据层 │ │ WatermarkCandidate · DetectionResult · TaskItem │ │ OutputStrategy · UserMark │ └─────────────────────────────────────────────────────────┘数据层:统一数据模型是关键
整个系统最关键的一个设计是core/model.py里的WatermarkCandidate:
@dataclassclassWatermarkCandidate:"""检测到的水印候选 统一表达所有格式的水印检测结果,UI 层只消费此结构。 """kind:WatermarkKind# 水印种类page_index:int=0bbox:Tuple[float,float,float,float]=(0,0,0,0)origin:Tuple[float,float]=(0,0)rotation:float=0.0detail:str=''confidence:float=0.0confidence_level:ConfidenceLevel=ConfidenceLevel.NONE# 内容流相关(PDF 矢量用)start:int=-1# 内容流字节偏移end:int=-1xref:int=-1# XObject xreftext:str=''color:Optional[Tuple[float,float,float]]=Nonefont_size:float=0.0alpha:float=1.0# 用户控制selected:bool=True# 网格信息(平铺水印用)grid_rows:int=0grid_cols:int=0grid_angle:float=0.0# 运行时像素掩膜(扫描图/图片处理器用)mask:Optional[object]=None它的精妙之处在于:所有格式的所有检测结果,都被统一表达成这一个结构。PDF 矢量文字水印用start/end存内容流偏移;扫描件浅色斜铺水印用mask存像素掩膜;规则库命中用text存关键词;旋转大字水印用rotation存角度。UI 层(ResultPanel)只需要遍历candidates列表就能渲染所有候选,完全不需要知道背后是 PDF 还是图片。
这个数据结构是整个系统的"通用货币",后续 11 篇文章都会反复提到它。
处理器层:策略模式 + 工厂路由
core/format_probe.py是入口:
defprobe_format(path:str)->FormatType:"""探测文件格式类型 对于 PDF,进一步判断是矢量型、扫描型还是混合型。 """ext=get_extension(path)ifextinPDF_EXTS:return_probe_pdf_type(path)elifextinIMAGE_EXTS:returnFormatType.IMAGE...def_probe_pdf_type(path:str)->FormatType:"""判断 PDF 类型:矢量 / 扫描 / 混合"""doc=fitz.open(path)has_text=Falsehas_full_page_image=Falsehas_vector=Falsecheck_pages=min(doc.page_count,5)foriinrange(check_pages):page=doc[i]text=page.get_text("text").strip()iftext:has_text=Trueimages=page.get_images(full=True)forimginimages:xref=img[0]pix=fitz.Pixmap(doc,xref)# 图片面积接近页面面积 → 扫描件ifpix.width*pix.height>page.rect.width*page.rect.height*0.8:has_full_page_image=Truedoc.close()ifhas_full_page_imageandnothas_text:returnFormatType.PDF_SCANNEDelifhas_full_page_imageandhas_text:returnFormatType.PDF_MIXEDelse:returnFormatType.PDF_VECTOR注意这里有一个三条腿走路的策略:
- 矢量 PDF(有文字指令、无大图)→
PdfVectorProcessor:走内容流解析路线,可无损擦除 - 扫描 PDF(无文字指令、有整页大图)→
PdfScannedProcessor:走像素级修复路线 - 混合 PDF(既有文字又有大图)→ 走矢量处理器的混合策略
路由器在get_processor(format_type)里完成实例化:
defget_processor(format_type:FormatType):ifformat_typein(FormatType.PDF_VECTOR,FormatType.PDF_MIXED):from.processor.pdf_vectorimportPdfVectorProcessorreturnPdfVectorProcessor()elifformat_type==FormatType.PDF_SCANNED:from.processor.pdf_scannedimportPdfScannedProcessorreturnPdfScannedProcessor()elifformat_type==FormatType.IMAGE:from.processor.image_inpaintimportImageInpaintProcessorreturnImageInpaintProcessor()...所有处理器继承同一个抽象基类IProcessor:
classIProcessor(ABC):@abstractmethoddefopen(self,path:str):...@abstractmethoddefdetect_auto(self)->DetectionResult:...@abstractmethoddefdetect_region(self,marks:List[UserMark])->DetectionResult:...@abstractmethoddefdetect_preset(self,rule_name:str)->DetectionResult:...@abstractmethoddefremove(self,candidates,output_path,progress_callback=None)->int:...@abstractmethoddefrender_page(self,page_index:int,zoom:float=1.0)->bytes:...UI 层和会话管理器只需要调processor.detect_auto()/processor.remove(...),根本不需要知道背后是哪种格式。这套设计让后续增加新格式(比如 Word、PPT)只需要新增一个IProcessor子类,不改 UI 层一行代码。
五、三类处理器,三套坐标系
整个系列最绕的地方是坐标契约。三类处理器用三套不同的坐标系:
| 处理器 | 坐标系 | bbox 含义 |
|---|---|---|
PdfVectorProcessor | PDF 页面点(page.rect) | 文字块/图片块在页面上的位置 |
PdfScannedProcessor | 内嵌图像素 | 候选在水印所在 XObject 内嵌图中的像素位置 |
ImageInpaintProcessor | 原图像素 | 候选在原图上的像素位置 |
为什么扫描件处理器要用"内嵌图像素"而不是"页面点"?因为扫描件的水印是烤进图片像素的,检测和修复都必须在像素空间进行。但 UI 上要显示红框,又得换算回页面点。所以PdfScannedProcessor重写了基类的candidate_page_bbox:
defcandidate_page_bbox(self,cand)->tuple:"""内嵌图像素候选 bbox → 页面点坐标(供 UI 在渲染图上定位)"""info=next((iforiininfosifi['xref']==cand.xref),None)img_w,img_h=info['width'],info['height']px0,py0,px1,py1=info['bbox']place_w=px1-px0 place_h=py1-py0 x0=px0+cand.bbox[0]/img_w*place_w y0=py0+cand.bbox[1]/img_h*place_h...这套换算在 UI 渲染时被频繁调用。坐标用错一个量级,红框就会跑到页面外。我们在第 7 篇 UI 篇会专门讲这个坑。
六、UI 层:三栏工作台
界面布局遵循"左导航 + 中工作区 + 右详情"的经典三栏:
┌─────────────────────────────────────────────────────────┐ │ 工具栏:打开 检测 移除 工具切换 笔刷 高亮 对比 设置 手册 │ ├──────────┬──────────────────────────────┬──────────────┤ │ 左栏 │ 中栏 PreviewWidget │ 右栏 │ │ 文件/ │ ┌──────────────────────────┐ │ ResultPanel │ │ 任务列表 │ │ QPainter 渲染页面 │ │ 候选列表 │ │ │ │ 红框标记水印 │ │ 勾选/取消 │ │ 文件A │ │ 鼠标框选/画笔涂抹 │ │ 置信度 │ │ 文件B │ │ 前后对比双图并排 │ │ 来源标签 │ │ 文件C │ └──────────────────────────┘ │ │ │ │ │ 全选/全不选 │ │ │ │ 移除并保存 │ ├──────────┴──────────────────────────────┴──────────────┤ │ 状态栏:当前操作 页码 文件路径 │ └─────────────────────────────────────────────────────────┘操作按钮(全选/全不选/移除并保存)固定在右栏底部,永不随滚动条消失,这是 V2.0 改了一版才确定的交互——第一版把按钮放在列表头部,结果列表一长按钮就找不到了,被同事吐槽过。
预览区用QPainter手绘——不用QLabel+QPixmap的简单方案,因为我们要在 pixmap 上叠加红框、用户框选轨迹、画笔轨迹、对比双图,这些都需要 painter 灵活控制 z-order。具体实现见第 7 篇。
七、批量处理:状态机 + 运行守卫
批量是 V2.0 的重点。core/task_orchestrator.py用ThreadPoolExecutor并发处理:
defdetect_all(self,progress_callback=None,cancel_check=None)->None:self._cancelled=Falsepending=[tfortinself._tasksift.statusin(TaskStatus.PENDING,TaskStatus.FAILED)]total=len(pending)iftotal==0:returndone=0withThreadPoolExecutor(max_workers=self._max_workers)asexecutor:future_map={executor.submit(self._detect_one,t):tfortinpending}forfutureinas_completed(future_map):if(cancel_checkandcancel_check())orself._cancelled:self._cancelled=Trueforfinfuture_map:f.cancel()break...每个TaskItem都有自己的状态机:PENDING → DETECTING → DETECTED → REMOVING → COMPLETED,失败转到FAILED,无水印转到SKIPPED。UI 上的列表项按状态显示不同后缀,比如"3 候选/1 移除/失败:权限不足"。
UI 线程守卫是核心坑:批量按钮必须在执行期间禁用,否则用户连点会触发多个 worker 同时操作同一个文件,结果就是 core dump。关闭窗口时也要wait()后台线程结束才能退出,不然 Qt 会在析构时崩溃。这套坑我们在第 9 篇详细讲。
八、跨平台:一套代码跑麒麟 V10 + Windows 11
最后是工程化部分。我们的目标是同一份源码,在 Windows 11 和银河麒麟 V10 上都能直接跑,不需要任何条件编译。关键策略:
| 维度 | 实现 |
|---|---|
| 字体 | main._pick_default_font()启动时按平台选:Win=Microsoft YaHei UI;macOS=PingFang SC;Linux=Noto Sans CJK SC→WenQuanYi→系统默认 |
| 打开目录 | Windows 调explorer,macOS 调open,Linux 调xdg-open |
| Linux 任务栏分组 | icons.ensure_linux_desktop_file用sys.executable写.desktop文件 |
| 路径分隔符 | 全部用os.path.join/os.pathsep,不硬编码 |
| 文件编码 | 所有open()显式encoding='utf-8',避免 Windows 默认 GBK |
| PyInstaller 打包 | build.py用os.pathsep自动适配--add-data分隔符;Windows 附.ico;Linux 附.desktop |
这一套适配在第 11 篇专门讲,踩过的坑够写一篇长文了。
九、本系列后续文章索引
为了让读者按需阅读,本系列共 12 篇,按以下顺序更新:
- 【开篇】为什么我们要做一款本地文档去水印工具(本文)
- 【架构】多格式文档处理系统分层架构与处理器路由
- 【PDF 矢量 · 上】手写 PDF 内容流解析器:从字节流到结构化水印候选
- 【PDF 矢量 · 下】矢量水印多策略检测与无损移除
- 【扫描件】烤入扫描图的浅色斜铺文字水印:像素级 OCR 验证与掩膜修复
- 【图片】通用图像去水印:颜色聚类分离 + Alpha 反演修复
- 【UI · 上】PySide6 三栏工作台:QPainter 渲染、坐标契约与框选/涂抹交互
- 【UI · 下】前后对比模式与多分辨率图标系统
- 【批量】多文件并发任务编排:ThreadPoolExecutor + 运行守卫 + 状态机
- 【规则库】扫描全能王/WPS/福昕水印规则库:JSON DSL + 关键词匹配 + 视觉特征加权
- 【跨平台】一套代码适配银河麒麟 V10 与 Windows 11
- 【避坑实录】那些深夜调试的坑:drawPixmap 崩溃、update_stream 黑块、多次移除白板、高 DPI 警告
十、下载与资源
清印 ClearMark V2.0 提供源码与可执行程序两种发行方式:
- 源码版本:包含全部 30 个 Python 文件、规则库、图标资源、打包脚本,可直接
python main.py运行,适合二次开发与学习。 - 绿色免安装版:基于 PyInstaller onedir 模式打包,整个文件夹拷到目标机器即可运行,已包含 Python 运行时与全部依赖,适合直接使用。
适用平台:
- Windows 10 / 11 (x64)
- 银河麒麟 V10 桌面版(x86_64 / aarch64)
- macOS(实验性支持)
📥下载地址:下载链接将在系列文章全部发布后统一更新。
如果你正在做以下事情,这套源码会对你有帮助:
- 想了解 PDF 内容流(content stream)的结构与解析方式
- 想学习 PyMuPDF 的高级 API(XObject、OCG、注释、
replace_image) - 想实践 PySide6 + QPainter 的桌面应用开发
- 想研究 OpenCV 图像修复(Telea/NS 算法、K-Means 聚类、形态学操作)
- 想做跨平台(Linux + Windows)桌面工具的工程化
- 想了解批量并发任务编排与 Qt 线程守卫的最佳实践
写在最后
做这个工具的过程里,最让我感慨的是:水印这件事看起来是个产品功能,本质上是一组 PDF 与图像算法的工程化整合。从内容流解析、几何变换、形态学、聚类、OCR、图像修复,到线程模型、坐标契约、跨平台——每一块单独都能写一本书。把它们整合成一个能用的桌面工具,靠的是工程取舍:什么时候用启发式、什么时候上 OCR、什么时候交给用户手动框选、什么时候放弃自动化只做半自动。
这个系列我想做的不是营销,而是把每一块的设计取舍讲清楚。如果你也在做类似的工具,或者只是好奇 PDF 内部到底长什么样,欢迎跟着读下去。下一篇文章我们会聊整体架构与处理器路由——为什么是三层、为什么是策略模式、IProcessor接口怎么设计才让 UI 层不感知格式差异。
作者注:本系列基于真实项目开发过程,所有代码、调试故事、坑点均为一手记录。文中代码片段均为项目实际实现,对应文件路径会在文中标注。如果你希望提前拿到完整源码,可以从文末下载链接获取。