1.10【清印 ClearMark 专栏 10】规则库:JSON DSL、关键词匹配与视觉特征加权

1.10【清印 ClearMark 专栏 10】规则库:JSON DSL、关键词匹配与视觉特征加权

这是「清印 ClearMark 智能文档去水印工作台」系列的第十篇。

前面讲矢量 PDF 检测时提过一句「品牌签名水印」靠规则库识别。本篇就把它拆开讲清楚------

清印 ClearMark 内置了 8 套规则(扫描全能王、WPS、福昕、Adobe、飞书、企业微信、钉钉、通用保密),怎么用 JSON 描述水印特征,怎么按关键词 + 位置 + 字号 + 颜色 + 旋转做加权打分。


一、为什么需要规则库

回看第 4 篇讲的矢量 PDF 七大检测策略:跨页重复、同页平铺、旋转大字、品牌签名、共享 XObject、OCG 图层、注释水印。前六个都是启发式------基于「水印在结构上的特征」识别。但有一种水印这种启发式搞不定:

品牌签名水印------单页只出现一次,文字内容是「扫描全能王」「由 CamScanner 创建」「WPS Office」等品牌词。

它没有重复(只有一处),没有旋转(轴对齐),字号不大(10pt 左右),颜色可能灰色也可能解析成黑色,没有独立 XObject。所有结构特征都正常------除了文本内容。

识别它的唯一办法就是看文本内容。但「创建」这种词太通用了,正文里也可能出现「创建于 2024 年」。直接匹配关键词会误伤。

于是规则库的设计目标变成:

  1. 关键词 + 多维过滤:不能只看关键词,必须同时满足位置、字号、颜色等结构约束。
  2. 强词 + 弱词分级:「扫描全能王」是强品牌词,单独命中即可;「创建」「保密」是通用词,必须同页已有强签名才采纳。
  3. 置信度加权:满足关键词只是基线分 0.6,加上「灰色 +0.10」「半透明 +0.10」「旋转 +0.15」等多重特征才能升到 HIGH。
  4. 可扩展 :用户能往 ~/.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 {}

设计要点:

  1. setdefault 兜底所有字段 :用户写的 JSON 缺哪个字段都不会 KeyError。这是 DSL 解析的基本素养------永远别假设用户提供完整数据。
  2. name 默认取文件名 :用户写了个空 JSON 也能跑,规则名就是 camscanner(去掉 .json 后缀)。
  3. 整体 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 智能文档去水印工作台」即可下载。

相关推荐
xiangji3 个月前
开源完美模块组件化可扩展的Xml解析器Hand.ParseXml
xml·模块化·组件化·可扩展
vivo互联网技术1 年前
vivo 浏览器福利体系架构演进之路
后端·数据一致性·可扩展·大流量·可复制
犀思云1 年前
如何构建灵活、可控、可扩展的多云网络底座
可扩展·多云网络·可控
xiangji1 年前
ShadowSql.net之正确使用方式
orm·dapper·可扩展·sqlbuilder·面向接口
Amd7942 年前
Nuxt.js 应用中的 imports:dirs 事件钩子详解
nuxt·目录·模块化·导入·钩子·灵活·可扩展