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:
python
@dataclass
class WatermarkCandidate:
"""检测到的水印候选
统一表达所有格式的水印检测结果,UI 层只消费此结构。
"""
kind: WatermarkKind # 水印种类
page_index: int = 0
bbox: Tuple[float, float, float, float] = (0, 0, 0, 0)
origin: Tuple[float, float] = (0, 0)
rotation: float = 0.0
detail: str = ''
confidence: float = 0.0
confidence_level: ConfidenceLevel = ConfidenceLevel.NONE
# 内容流相关(PDF 矢量用)
start: int = -1 # 内容流字节偏移
end: int = -1
xref: int = -1 # XObject xref
text: str = ''
color: Optional[Tuple[float, float, float]] = None
font_size: float = 0.0
alpha: float = 1.0
# 用户控制
selected: bool = True
# 网格信息(平铺水印用)
grid_rows: int = 0
grid_cols: int = 0
grid_angle: float = 0.0
# 运行时像素掩膜(扫描图/图片处理器用)
mask: Optional[object] = None
它的精妙之处在于:所有格式的所有检测结果,都被统一表达成这一个结构 。PDF 矢量文字水印用 start/end 存内容流偏移;扫描件浅色斜铺水印用 mask 存像素掩膜;规则库命中用 text 存关键词;旋转大字水印用 rotation 存角度。UI 层(ResultPanel)只需要遍历 candidates 列表就能渲染所有候选,完全不需要知道背后是 PDF 还是图片。
这个数据结构是整个系统的"通用货币",后续 11 篇文章都会反复提到它。
处理器层:策略模式 + 工厂路由
core/format_probe.py 是入口:
python
def probe_format(path: str) -> FormatType:
"""探测文件格式类型
对于 PDF,进一步判断是矢量型、扫描型还是混合型。
"""
ext = get_extension(path)
if ext in PDF_EXTS:
return _probe_pdf_type(path)
elif ext in IMAGE_EXTS:
return FormatType.IMAGE
...
def _probe_pdf_type(path: str) -> FormatType:
"""判断 PDF 类型:矢量 / 扫描 / 混合"""
doc = fitz.open(path)
has_text = False
has_full_page_image = False
has_vector = False
check_pages = min(doc.page_count, 5)
for i in range(check_pages):
page = doc[i]
text = page.get_text("text").strip()
if text:
has_text = True
images = page.get_images(full=True)
for img in images:
xref = img[0]
pix = fitz.Pixmap(doc, xref)
# 图片面积接近页面面积 → 扫描件
if pix.width * pix.height > page.rect.width * page.rect.height * 0.8:
has_full_page_image = True
doc.close()
if has_full_page_image and not has_text:
return FormatType.PDF_SCANNED
elif has_full_page_image and has_text:
return FormatType.PDF_MIXED
else:
return FormatType.PDF_VECTOR
注意这里有一个 三条腿走路 的策略:
- 矢量 PDF (有文字指令、无大图)→
PdfVectorProcessor:走内容流解析路线,可无损擦除 - 扫描 PDF (无文字指令、有整页大图)→
PdfScannedProcessor:走像素级修复路线 - 混合 PDF(既有文字又有大图)→ 走矢量处理器的混合策略
路由器在 get_processor(format_type) 里完成实例化:
python
def get_processor(format_type: FormatType):
if format_type in (FormatType.PDF_VECTOR, FormatType.PDF_MIXED):
from .processor.pdf_vector import PdfVectorProcessor
return PdfVectorProcessor()
elif format_type == FormatType.PDF_SCANNED:
from .processor.pdf_scanned import PdfScannedProcessor
return PdfScannedProcessor()
elif format_type == FormatType.IMAGE:
from .processor.image_inpaint import ImageInpaintProcessor
return ImageInpaintProcessor()
...
所有处理器继承同一个抽象基类 IProcessor:
python
class IProcessor(ABC):
@abstractmethod
def open(self, path: str): ...
@abstractmethod
def detect_auto(self) -> DetectionResult: ...
@abstractmethod
def detect_region(self, marks: List[UserMark]) -> DetectionResult: ...
@abstractmethod
def detect_preset(self, rule_name: str) -> DetectionResult: ...
@abstractmethod
def remove(self, candidates, output_path, progress_callback=None) -> int: ...
@abstractmethod
def render_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:
python
def candidate_page_bbox(self, cand) -> tuple:
"""内嵌图像素候选 bbox → 页面点坐标(供 UI 在渲染图上定位)"""
info = next((i for i in infos if i['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 并发处理:
python
def detect_all(self, progress_callback=None, cancel_check=None) -> None:
self._cancelled = False
pending = [t for t in self._tasks
if t.status in (TaskStatus.PENDING, TaskStatus.FAILED)]
total = len(pending)
if total == 0:
return
done = 0
with ThreadPoolExecutor(max_workers=self._max_workers) as executor:
future_map = {executor.submit(self._detect_one, t): t for t in pending}
for future in as_completed(future_map):
if (cancel_check and cancel_check()) or self._cancelled:
self._cancelled = True
for f in future_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 层不感知格式差异。
作者注:本系列基于真实项目开发过程,所有代码、调试故事、坑点均为一手记录。文中代码片段均为项目实际实现,对应文件路径会在文中标注。如果你希望提前拿到完整源码,可以从文末下载链接获取。