1.10【清印 ClearMark 专栏 10】规则库:JSON DSL、关键词匹配与视觉特征加权
这是「清印 ClearMark 智能文档去水印工作台」系列的第十篇。
前面讲矢量 PDF 检测时提过一句「品牌签名水印」靠规则库识别。本篇就把它拆开讲清楚------
清印 ClearMark 内置了 8 套规则(扫描全能王、WPS、福昕、Adobe、飞书、企业微信、钉钉、通用保密),怎么用 JSON 描述水印特征,怎么按关键词 + 位置 + 字号 + 颜色 + 旋转做加权打分。
一、为什么需要规则库
回看第 4 篇讲的矢量 PDF 七大检测策略:跨页重复、同页平铺、旋转大字、品牌签名、共享 XObject、OCG 图层、注释水印。前六个都是启发式------基于「水印在结构上的特征」识别。但有一种水印这种启发式搞不定:
品牌签名水印------单页只出现一次,文字内容是「扫描全能王」「由 CamScanner 创建」「WPS Office」等品牌词。
它没有重复(只有一处),没有旋转(轴对齐),字号不大(10pt 左右),颜色可能灰色也可能解析成黑色,没有独立 XObject。所有结构特征都正常------除了文本内容。
识别它的唯一办法就是看文本内容。但「创建」这种词太通用了,正文里也可能出现「创建于 2024 年」。直接匹配关键词会误伤。
于是规则库的设计目标变成:
- 关键词 + 多维过滤:不能只看关键词,必须同时满足位置、字号、颜色等结构约束。
- 强词 + 弱词分级:「扫描全能王」是强品牌词,单独命中即可;「创建」「保密」是通用词,必须同页已有强签名才采纳。
- 置信度加权:满足关键词只是基线分 0.6,加上「灰色 +0.10」「半透明 +0.10」「旋转 +0.15」等多重特征才能升到 HIGH。
- 可扩展 :用户能往
~/.clearmark/rules/丢自己的 JSON 规则,不用改代码。
二、规则 DSL:JSON 描述水印特征
2.1 字段定义
每条规则是一个 JSON 文件,字段在 rule_loader.py(file:///home/ylt/项目/pdf_watermark_remover/core/rules/rule_loader.py) 的开头有完整文档:
python
"""规则文件格式(JSON):
{
"name": "规则显示名",
"description": "可选,规则说明",
"text_keywords": ["关键词1", "关键词2"],
"position": "bottom|center|top|any",
"color_hint": "gray|any",
"max_font_size": 24,
"min_font_size": 6
}
"""
| 字段 | 类型 | 含义 |
|---|---|---|
name |
str | 规则显示名,用于 UI 下拉框和报告 |
description |
str | 规则说明(可选) |
text_keywords |
Liststr | 关键词列表,任意一个命中即触发 |
position |
str | 水印位置约束:top / bottom / center / any |
color_hint |
str | 颜色约束:gray(必须灰色)/ any(不限) |
max_font_size |
float | 最大字号(pt),0 = 不限 |
min_font_size |
float | 最小字号(pt),0 = 不限 |
2.2 八条内置规则对比
我们写了 8 条规则覆盖主流软件水印:
| 规则文件 | 软件 | 关键词数 | position | color_hint | max_font | min_font |
|---|---|---|---|---|---|---|
| camscanner.json | 扫描全能王 | 6 | bottom | gray | 24 | 6 |
| wps.json | WPS / 金山 | 6 | bottom | gray | 36 | 6 |
| foxit.json | 福昕 | 6 | any | any | 36 | 6 |
| adobe.json | Adobe Acrobat | 6 | center | any | 96 | 8 |
| feishu.json | 飞书 / Lark | 7 | any | gray | 24 | 6 |
| wecom.json | 企业微信 | 6 | any | gray | 24 | 6 |
| dingtalk.json | 钉钉 | 5 | bottom | gray | 24 | 6 |
| confidential.json | 通用保密 | 14 | center | gray | 96 | 8 |
看 camscanner.json 完整内容:
json
{
"name": "CamScanner扫描全能王",
"description": "扫描全能王 / CamScanner 应用添加的水印,常出现在扫描生成的PDF页底,含「扫描全能王」「CamScanner」「创建」等关键词,灰色半透明。",
"text_keywords": [
"扫描全能王",
"CamScanner",
"创建",
"CS扫描",
"由CamScanner",
"Intsig"
],
"position": "bottom",
"color_hint": "gray",
"max_font_size": 24,
"min_font_size": 6
}
注意它把「创建」也列进去了------「创建」是扫描全能王水印里经常出现的词(如「由 CamScanner 创建」),但「创建」本身是通用词。这就是上面说的「强词 + 弱词」问题,下面详细讲。
2.3 通用保密规则的关键词设计
json
{
"name": "通用保密水印",
"description": "通用保密 / 机密 / 内部资料 / 版权 等通用文字水印,含「仅供」「机密」「绝密」「内部资料」「禁止复制」「版权所有」等关键词,常见于斜铺或居中。",
"text_keywords": [
"仅供",
"机密",
"绝密",
"内部资料",
"禁止复制",
"版权所有",
"保密",
"严禁复制",
"Confidential",
"INTERNAL",
"TOP SECRET",
"All Rights Reserved",
"禁止外传",
"禁止传播"
],
"position": "center",
"color_hint": "gray",
"max_font_size": 96,
"min_font_size": 8
}
max_font_size: 96 是因为保密水印常是整页斜铺大字(60-90pt)。position: center 也是它的典型位置------斜铺大字通常居中。
关键词里中英文都列,因为一些外企内部文档用 Confidential / TOP SECRET。
三、规则加载器:内置 + 用户自定义两层
3.1 加载位置
rule_loader.py(file:///home/ylt/项目/pdf_watermark_remover/core/rules/rule_loader.py) 的加载策略:
python
_RULES_DIR = os.path.dirname(os.path.abspath(__file__)) # 内置规则目录
_USER_RULES_DIR = os.path.expanduser('~/.clearmark/rules') # 用户自定义规则目录
- 内置规则 :
core/rules/*.json,随源码/打包文件一起分发,不可改。 - 用户自定义规则 :
~/.clearmark/rules/*.json,用户可以自己加。同名时用户规则覆盖内置------这样用户可以「修正」内置规则的关键词列表而不动源码。
3.2 _load_json_file:容错的 JSON 解析
python
def _load_json_file(path: str) -> dict:
"""读取并解析单个 JSON 规则文件"""
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
return {}
# 规范化字段,保证后续使用稳定
data.setdefault('name', os.path.splitext(os.path.basename(path))[0])
data.setdefault('description', '')
data.setdefault('text_keywords', [])
data.setdefault('position', 'any')
data.setdefault('color_hint', 'any')
data.setdefault('max_font_size', 0)
data.setdefault('min_font_size', 0)
return data
except (OSError, json.JSONDecodeError, ValueError):
return {}
设计要点:
setdefault兜底所有字段 :用户写的 JSON 缺哪个字段都不会 KeyError。这是 DSL 解析的基本素养------永远别假设用户提供完整数据。name默认取文件名 :用户写了个空 JSON 也能跑,规则名就是camscanner(去掉 .json 后缀)。- 整体 try/except 返回空 dict :JSON 语法错误、文件不可读、编码不对,统统返回
{},让上层load_rules跳过这条规则。坏规则不能影响其他规则加载。
3.3 load_rules:合并内置 + 用户
python
def load_rules() -> dict:
"""加载所有规则
合并内置规则与用户自定义规则(同名时自定义覆盖内置)。
"""
rules: Dict[str, dict] = {}
# 内置规则
for path in sorted(glob(os.path.join(_RULES_DIR, '*.json'))):
rule = _load_json_file(path)
if not rule:
continue
key = os.path.splitext(os.path.basename(path))[0]
rule['source'] = 'builtin'
rule['rule_name'] = key
rules[key] = rule
# 用户自定义规则(覆盖同名内置)
if os.path.isdir(_USER_RULES_DIR):
for path in sorted(glob(os.path.join(_USER_RULES_DIR, '*.json'))):
rule = _load_json_file(path)
if not rule:
continue
key = os.path.splitext(os.path.basename(path))[0]
rule['source'] = 'user'
rule['rule_name'] = key
rules[key] = rule
return rules
合并策略:先加载内置到 rules 字典,再加载用户,用户覆盖同名 key。所以同名时用户规则完全替代内置,不是合并关键词。
每条规则加载后注入两个字段:
source:'builtin'或'user',UI 显示来源。rule_name: 文件名(去 .json),用于代码内引用。
四、智能检测里的规则库整合:_detect_signature_watermarks
4.1 总体流程
智能检测(detect_auto)会调 _detect_signature_watermarks,它整合所有规则的关键词做一次扫描。这里有个反直觉的设计:
不按规则逐个扫描,而是按关键词建反向索引,一次扫描所有文本块。
为什么?因为 PDF 解析是 IO + CPU 密集的,每条规则跑一遍就要重新解析一遍内容流。8 条规则 × N 页 = 8N 次解析。改成「按关键词反向索引 + 一次扫描」后只解析 N 次,复杂度从 O(规则数 × 页数) 降到 O(页数)。
代码骨架:
python
def _detect_signature_watermarks(
self,
parsed_pages: List[Tuple[fitz.Page, ParsedContent]]
) -> List[WatermarkCandidate]:
"""基于内置/自定义规则库的品牌签名检测"""
out: List[WatermarkCandidate] = []
try:
rules = load_rules()
except Exception:
return out
# 关键词 → 声明它的规则列表
kw_rules: Dict[str, List[dict]] = defaultdict(list)
for rule in rules.values():
for kw in (rule.get('text_keywords') or []):
kw = str(kw).strip()
if kw:
kw_rules[kw].append(rule)
for page_idx, (page, parsed) in enumerate(parsed_pages):
# (tb, 页面bbox, rule, 是否通用词)
strong_hits: List[Tuple[TextBlock, tuple, dict]] = []
weak_hits: List[Tuple[TextBlock, tuple, dict]] = []
for tb in parsed.text_blocks:
text = tb.text.strip()
if not text:
continue
text_lower = text.lower()
for kw, rlist in kw_rules.items():
if kw.lower() not in text_lower:
continue
for rule in rlist:
if not self._tb_passes_rule_filters(tb, page.rect, rule):
continue
bbox = _text_to_page_bbox(tb, page.rect)
item = (tb, bbox, rule)
if kw.lower() in _GENERIC_KEYWORDS:
weak_hits.append(item)
else:
strong_hits.append(item)
break
else:
continue
break # 该文字块已归到某条规则,不再匹配其它词
accepted = list(strong_hits)
# 通用词仅在同页存在强品牌签名时才采纳
if strong_hits:
accepted.extend(weak_hits)
...
4.2 强词 vs 弱词:_GENERIC_KEYWORDS
python
_GENERIC_KEYWORDS = {'创建', '仅供', '保密', '机密', '绝密', 'lark'}
这 6 个词被标为「通用词」------它们在正文里也可能出现,不能单独命中。比如:
- 「创建」:「本文创建于 2024 年」→ 正文,不是水印。
- 「机密」:「机密等级:公开」→ 正文标题,不是水印。
- 「lark」:飞书员工姓名里可能有「Lark Wang」。
规则是:通用词必须同页已有强品牌签名才采纳。逻辑:
python
accepted = list(strong_hits)
# 通用词仅在同页存在强品牌签名时才采纳
if strong_hits:
accepted.extend(weak_hits)
这样,「由 CamScanner 创建」一页里,「CamScanner」是强词命中,「创建」是弱词也命中------两个一起被采纳。但单独一页只有「创建于 2024 年」时,没有强词,弱词被丢弃。
注意 _GENERIC_KEYWORDS 是硬编码的,不写在 JSON 里。这是有意为之------通用词的判定需要全局知识,用户加规则时不该自己定义「这是通用词」。如果用户要加新通用词,得改源码。
4.3 规则过滤:_tb_passes_rule_filters
python
@staticmethod
def _tb_passes_rule_filters(tb: TextBlock, page_rect,
rule: dict) -> bool:
"""文字块是否满足规则的位置/字号结构过滤
颜色约束刻意不参与:很多实际文件里灰色签名在内容流中
被解析为黑色,硬过滤会漏检;颜色仅用于置信度加权。
"""
max_fs = float(rule.get('max_font_size') or 0)
min_fs = float(rule.get('min_font_size') or 0)
if max_fs > 0 and tb.font_size > max_fs:
return False
if min_fs > 0 and tb.font_size < min_fs:
return False
pos = (rule.get('position') or 'any').lower()
if pos != 'any':
bbox = _text_to_page_bbox(tb, page_rect)
if _determine_position(bbox, page_rect) != pos:
return False
return True
这里有个反直觉但很重要的决策:
颜色约束(
color_hint)不参与硬过滤,只用于置信度加权。
为什么?因为很多 PDF 里灰色水印在内容流里被解析成黑色(PDF 颜色空间转换有损,扫描全能王早期版本尤其严重)。如果硬过滤「必须灰色」,这些水印会被漏检。
正确做法是:位置和字号做硬过滤(这俩解析稳定),颜色只做软加权。这样宁可多检一些「黑色签名」让用户自己勾选,也不能漏检。
4.4 位置判定:_determine_position
python
def _determine_position(bbox: Tuple[float, float, float, float],
page_rect) -> str:
"""根据 bbox 在页面位置返回 top/bottom/center"""
cy = (bbox[1] + bbox[3]) / 2
if cy < page_rect.height / 3:
return 'top'
if cy > page_rect.height * 2 / 3:
return 'bottom'
return 'center'
简单粗暴三分法:上 1/3 是 top,下 1/3 是 bottom,中间 1/3 是 center。够用了------水印通常在页眉页脚或正中,不会在「页眉下方 5%」这种边界位置。
为什么不更精细?因为精细的位置分类会引入边界判定问题------「cy = page_rect.height / 3 + 1px」算 top 还是 center?三分法让边界尽量窄,减少误判。
4.5 置信度加权
python
for tb, bbox, rule in accepted:
is_strong = any(tb is t for t, _, _ in strong_hits)
conf = 0.75 if is_strong else 0.6
if _is_gray_rgb(tb.color_rgb):
conf += 0.10
if 0 < tb.alpha < 1:
conf += 0.10
...
基线分:
- 强品牌词命中:0.75(直接采纳)
- 弱通用词命中(在同页有强词时):0.60
加权:
- 灰色 +0.10:水印通常灰色,正文通常黑色。
- 半透明 (0 < alpha < 1)+0.10:水印经常用
gs设置 alpha。 - 旋转非轴对齐 +0.15:斜铺大字水印是水印的强特征。
- 字号 30~200 +0.05:水印大字常见。
这套加权让置信度从 0.6 升到 0.85+,进入 HIGH 等级,UI 自动勾选。
4.6 _is_gray_rgb 与 _is_non_axis_rotation
python
def _is_gray_rgb(rgb: Tuple[float, float, float]) -> bool:
"""判断 RGB 是否为灰色(低饱和、非纯黑白)"""
if rgb is None:
return False
r, g, b = rgb
return (abs(r - g) < 0.1 and abs(g - b) < 0.1 and 0.3 < r < 0.9)
def _is_non_axis_rotation(rotation: float) -> bool:
"""判断旋转角是否非轴对齐(不是 0/90/180/270 附近)"""
norm = abs(rotation) % 180
# 接近 0 或 90 视为轴对齐
return not (norm < 5 or norm > 175 or 85 < norm < 95)
- 灰色判定:R/G/B 三通道差值 < 0.1,且 r 在 0.3-0.9 之间。0.3 以下太黑(正文),0.9 以上太白(背景),都不是水印。
- 旋转判定 :
norm % 180后接近 0 或 90 算轴对齐。45° 斜铺是水印典型特征,加权 +0.15。
五、规则库检测入口:detect_preset
除了在智能检测里整合所有规则,用户也可以选某一条规则单独跑 。这就是 detect_preset 方法:
python
def detect_preset(self, rule_name: str) -> DetectionResult:
"""规则库检测
Args:
rule_name: 规则名(不含 .json 后缀),如 'camscanner'
"""
if not self.doc:
return DetectionResult(...)
rule = load_rule(rule_name)
if not rule:
return DetectionResult(
mode=DetectMode.PRESET, format_type=self.format_type,
summary=f'未知规则: {rule_name}',
)
keywords = rule.get('text_keywords', []) or []
pos_rule = (rule.get('position') or 'any').lower()
color_hint = (rule.get('color_hint') or 'any').lower()
max_fs = float(rule.get('max_font_size') or 0)
min_fs = float(rule.get('min_font_size') or 0)
candidates: List[WatermarkCandidate] = []
page_count = self.doc.page_count
for i in range(page_count):
...
# 文字水印:关键词匹配 + 规则过滤
for tb in parsed.text_blocks:
if not tb.text.strip():
continue
text_lower = tb.text.lower()
hit_kw = next((kw for kw in keywords
if kw and kw.lower() in text_lower), None)
if not hit_kw:
continue
# 字号过滤
if max_fs > 0 and tb.font_size > max_fs:
continue
if min_fs > 0 and tb.font_size < min_fs:
continue
# 颜色过滤
if color_hint == 'gray' and not _is_gray_rgb(tb.color_rgb):
continue
# 位置过滤
bbox = _text_to_page_bbox(tb, page.rect)
if pos_rule != 'any' and \
_determine_position(bbox, page.rect) != pos_rule:
continue
# 命中特征加权
conf = 0.6
if _is_gray_rgb(tb.color_rgb):
conf += 0.15
if 0 < tb.alpha < 1:
conf += 0.10
if _is_non_axis_rotation(tb.rotation):
conf += 0.10
c = WatermarkCandidate(...)
candidates.append(c)
...
注意 detect_preset 和 _detect_signature_watermarks 的区别:
| 维度 | detect_preset |
_detect_signature_watermarks |
|---|---|---|
| 触发方式 | 用户主动选规则 | 智能检测自动跑 |
| 规则范围 | 单条规则 | 所有规则 |
| 颜色过滤 | 硬过滤 (color_hint == 'gray' 直接 continue) |
软加权(不参与过滤) |
| 通用词处理 | 不区分强弱词 | 区分,弱词需强词同页 |
为什么 detect_preset 用硬过滤?因为用户主动选了「扫描全能王」规则,他相信这条规则的特征,宁可漏检也不要误检------硬过滤更精准。
智能检测用软加权?因为智能检测要兼容所有可能的水印,宁可多检让用户自己勾,也不能漏。
同一条规则,在不同模式下过滤策略不同------这是清印 ClearMark 一个微妙但重要的设计点。
六、报告:detail 字段标注规则来源
每条命中的候选都会在 detail 字段里标注规则来源:
python
c = WatermarkCandidate(
...
detail=f'[{rule.get("name", rule_name)}] 文字水印: "{tb.text}"',
...
)
UI 显示时:
✓ [CamScanner扫描全能王] 文字水印: "由CamScanner创建"
✓ [WPS水印] 文字水印: "WPS Office"
✓ [通用保密水印] 文字水印: "机密"
用户一眼看出「这是哪条规则命中的」,方便他判断要不要勾选。如果误检了,他可以取消勾选并考虑修改规则。
七、踩坑实录
坑 1:智能检测漏检扫描全能王签名水印
最早 _detect_signature_watermarks 没有,智能检测只靠「重复」启发式。扫描全能王水印单页只出现一次,没有重复,直接漏检。用户反馈「检测不到水印」,我们查代码发现根本没有处理「单次品牌签名」这个场景。
修正 :加 _detect_signature_watermarks,整合规则库关键词做单次签名检测。
坑 2:颜色硬过滤导致漏检
最早 color_hint == 'gray' 是硬过滤。结果某些扫描全能王早期版本生成的 PDF 里,灰色水印在内容流里被解析成黑色 RGB(0,0,0),硬过滤直接 continue,漏检。
修正 :智能检测里 _tb_passes_rule_filters 不参与颜色过滤,颜色只做置信度加权。detect_preset 保留硬过滤(用户主动选规则时更精准)。
坑 3:通用词误伤正文
最早没有 _GENERIC_KEYWORDS 分级,所有关键词一视同仁。「创建」单独命中就采纳,结果一篇文档里「本文创建于 2024 年」被标成水印。
修正 :引入 _GENERIC_KEYWORDS 集合,通用词必须同页有强品牌签名才采纳。
坑 4:规则文件 JSON 语法错误,整个规则库加载失败
最早 _load_json_file 没有 try/except,用户自己写规则 JSON 时多了个逗号,load_rules 直接抛 JSONDecodeError,整个智能检测崩。
修正 :_load_json_file 包 try/except,单条规则解析失败返回 {},load_rules 跳过空规则。坏规则不影响其他规则。
坑 5:位置三分法边界判定
最早用「< 1/3 是 top」「> 1/3 是 bottom」「否则 center」,结果 cy 正好等于 1/3 时来回横跳。
修正:上 1/3 是 top,下 1/3 是 bottom,中间 1/3 是 center。三条线互不重叠,边界稳定。
八、本篇小结
| 关注点 | 解决方案 |
|---|---|
| 规则 DSL | JSON 七字段:name/description/text_keywords/position/color_hint/max_font_size/min_font_size |
| 规则来源 | 内置 core/rules/*.json + 用户 ~/.clearmark/rules/*.json,同名用户覆盖内置 |
| 加载容错 | _load_json_file 整体 try/except + setdefault 兜底所有字段 |
| 智能检测整合 | 按关键词反向索引 + 一次扫描,O(规则数×页数) → O(页数) |
| 强弱词分级 | _GENERIC_KEYWORDS 集合,通用词需同页有强品牌签名才采纳 |
| 规则过滤策略 | 位置 + 字号硬过滤;颜色软加权(智能检测)/ 硬过滤(preset) |
| 置信度加权 | 基线 0.6-0.75 + 灰色 +0.10 + 半透明 +0.10 + 旋转 +0.15 + 字号 +0.05 |
| 报告来源标注 | detail 字段前缀 [规则显示名],UI 一眼看出哪条规则命中 |
规则库的核心思想是:让程序认识水印的「品牌身份」。启发式告诉你「这有水印」,规则库告诉你「这是扫描全能王的水印」。
后者对用户更有用------他看到 [CamScanner扫描全能王] 文字水印: "由CamScanner创建" 就知道这是软件自动加的、可以放心移除;看到 [通用保密水印] 文字水印: "机密" 就会犹豫一下,因为这可能是文档作者主动加的保密标识。
让用户做知情决策,是规则库的真正价值。
下一篇我们讲跨平台兼容------清印 ClearMark 怎么一套代码同时跑在银河麒麟 V10 和 Windows 11 上。涉及字体策略、目录打开命令、.desktop 文件生成、PyInstaller 打包差异。这是把项目从「在我电脑上能跑」变成「能让用户用」的最后一公里。
📥 完整源码与可执行程序已上传 CSDN 资源,搜索「清印 ClearMark 智能文档去水印工作台」即可下载。