Python实现文本隐形水印:利用零宽度字符隐藏信息
1. 项目概述:当文本也能“藏”信息
你可能听说过图片水印、视频水印,但你有没有想过,一段看似普通的文字,也能在不改变其视觉呈现的前提下,悄悄地“藏”进另一段信息?比如,在一份发给多人的合同草稿里,嵌入一个唯一的接收者标识;或者在一段公开分享的代码片段中,暗藏版权或作者信息。这听起来有点像谍战片里的密文,但实现它的技术却出奇地简单,核心就是一种叫做“零宽度字符”的特殊符号。
零宽度字符,顾名思义,就是宽度为零的字符。它们在绝大多数文本编辑器、网页和聊天软件中是不可见的,不会占据任何视觉空间,也不会打断文本的阅读流。然而,它们和普通字符一样,可以被复制、粘贴,并存储在字符串中。我们的项目,就是利用这个特性,将一段需要隐藏的信息(比如一个ID、一句话),编码成一系列零宽度字符,然后像撒胡椒粉一样,均匀地“插入”到宿主文本的字符之间。对于阅读者来说,原文一字未变;但对于知道解码规则的程序来说,就能从中提取出隐藏的秘密。
这有什么用呢?想象几个场景:作为内容创作者,你可以在发布的文章里嵌入自己的签名,如果文章被未署名转载,你可以通过提取水印来证明原创;在团队协作中,可以将文档版本或审阅人信息嵌入文本,方便溯源;甚至可以用来做简单的文本防伪或追踪。当然,它并非坚不可摧的安全方案,更像是一种轻量级的、优雅的信息附加手段。
今天,我就手把手带你用Python实现这套文本隐形水印系统。整个过程不依赖任何复杂的图像处理库,核心就是字符串操作和二进制思维,即使你是Python新手,跟着步骤也能轻松搞定。我们会从原理拆解开始,到编码、嵌入、提取的完整代码实现,最后还会分享几个实际应用中的“避坑”心得。
2. 核心原理与方案设计
2.1 零宽度字符:看不见的“墨水”
首先,我们得认识一下这次要用到的“特殊墨水”。Unicode字符集中,有几种字符被设计为不可见且不占排版宽度,最常用的有三个:
-
零宽度非连接符
:Unicode
U+200C -
零宽度连接符
:Unicode
U+200D -
零宽度空格
:Unicode
U+200B
此外,还有从左至右标记(
U+200E
)和从右至左标记(
U+200F
)等,它们有方向性,有时也会被使用。在我们的方案里,为了简单和通用,通常会选用前两到三种。你可以把它们理解为三种不同的“状态”或“信号”。比如,我们可以定义:
-
U+200C代表二进制0 -
U+200D代表二进制1 -
U+200B作为分隔符或用于混淆(可选)
这样,任何一段文本信息(比如“user123”),都可以先转换成二进制流(例如ASCII或UTF-8编码的二进制),然后再把二进制流中的每一个
0
和
1
,映射成对应的零宽度字符。最终,我们得到的就是一串完全不可见的“隐形字符串”。
注意 :虽然这些字符在大多数现代环境中不可见,但并非所有地方都完全兼容。一些极老的终端、或者对Unicode处理不完善的系统,可能会将其显示为乱码或方框。在主要面向Web和现代桌面应用的环境中使用是可靠的。
2.2 水印嵌入策略:如何“藏”得自然
得到了隐形字符序列后,下一个问题是如何把它插入到宿主文本中。最简单的办法是直接追加在原文开头或结尾。但这样做有个缺点:如果水印文本被部分截取(比如只复制了前半段),水印就丢失了。更健壮的做法是 均匀插入 。
我们的策略是:遍历宿主文本的每一个字符间隙(包括开头前和结尾后),将隐形字符序列中的字符,依次插入到这些间隙中。如果隐形字符序列比间隙数长,就循环使用;如果短,就插入完为止。这种做法的好处是,水印信息被“打散”并分布到了全文,即使文本被截取了一部分,只要剩下的部分足够长且包含水印片段,我们仍有概率提取出完整或部分信息。为了提高提取成功率,我们还可以在编码信息时加入一些冗余(例如,重复编码两次)。
2.3 整体工作流程设计
整个系统可以分为两个对称的过程: 编码嵌入 和 解码提取 。
编码嵌入流程:
- 输入 :明文信息(要隐藏的文本) + 宿主文本(用来承载水印的公开文本)。
- 信息编码 :将明文信息转换为二进制字符串。
-
字符映射
:将二进制字符串中的
0和1,根据预定规则,替换为零宽度字符,生成“隐形水印串”。 - 水印嵌入 :将“隐形水印串”中的字符,均匀插入到宿主文本的各个字符间隙中,生成最终的、含隐藏水印的文本。
- 输出 :含隐藏水印的文本。
解码提取流程:
- 输入 :含隐藏水印的文本。
- 水印提取 :遍历输入文本,过滤出所有零宽度字符,按顺序拼接,得到“隐形水印串”。
-
字符逆映射
:将“隐形水印串”中的零宽度字符,根据预定规则,逆向映射回二进制字符串
0和1。 - 信息解码 :将二进制字符串转换回原始的明文信息。
- 输出 :提取出的隐藏信息。
这个流程清晰且对称,接下来我们就用Python代码将其一一实现。
3. 核心代码实现与分步解析
我们将创建一个Python类
ZeroWidthWatermark
来封装所有功能。确保你的Python环境在3.6以上即可,无需安装任何第三方库。
3.1 基础设置与常量定义
首先,我们定义要用到的零宽度字符和映射规则。
class ZeroWidthWatermark:
"""零宽度字符隐形水印处理器"""
# 定义使用的零宽度字符
ZERO_WIDTH_SPACE = '\u200b' # 零宽度空格
ZERO_WIDTH_NON_JOINER = '\u200c' # 零宽度非连接符
ZERO_WIDTH_JOINER = '\u200d' # 零宽度连接符
# 可以额外添加方向标记字符以增加容量或混淆,但提取时需一致
# LEFT_TO_RIGHT_MARK = '\u200e'
# RIGHT_TO_LEFT_MARK = '\u200f'
def __init__(self, use_zwsp=True):
"""
初始化水印处理器。
:param use_zwsp: 是否使用零宽度空格(U+200B)作为分隔符。若为False,则仅使用200C和200D。
"""
# 定义二进制位到零宽度字符的映射
self.bit_to_char = {
'0': self.ZERO_WIDTH_NON_JOINER,
'1': self.ZERO_WIDTH_JOINER,
}
# 定义零宽度字符到二进制位的反向映射
self.char_to_bit = {v: k for k, v in self.bit_to_char.items()}
self.separator = self.ZERO_WIDTH_SPACE if use_zwsp else ''
# 如果使用ZWSP作为分隔符,将其从映射中排除,避免解码混淆
if self.separator:
# 确保分隔符不在解码字典中
if self.separator in self.char_to_bit:
del self.char_to_bit[self.separator]
这里我做了个设计选择:将
ZERO_WIDTH_SPACE (U+200B)
作为可选的
分隔符
,而不是用来表示二进制位。为什么?主要是为了
提高解码的鲁棒性
。如果我们只用
200C
和
200D
表示0和1,那么提取水印时,只需要在文本中找出这两种字符并按顺序拼接即可。如果
200B
也用来表示数据,一旦它在宿主文本中因为某些原因(比如系统自动格式化)被插入或删除,就会导致二进制位错乱,解码失败。而把它作为分隔符(比如在每个编码后的“字符”或“字节”后插入一个),可以在解码时帮助我们划分边界,但这不是必须的。在基础版本中,我们先不用分隔符,保持逻辑最简单。
3.2 信息编码:从文本到隐形字符串
这一步负责把要隐藏的字符串(如
"secret"
)转换成由零宽度字符组成的序列。
def _text_to_bits(self, text, encoding='utf-8'):
"""
将文本字符串转换为二进制位字符串。
:param text: 要编码的文本
:param encoding: 文本编码方式,默认为utf-8
:return: 二进制位字符串,如 '0110100001100101011011000110110001101111'
"""
# 先将文本按指定编码转为字节(bytes)
bytes_data = text.encode(encoding)
# 将每个字节转换为8位的二进制字符串,并拼接起来
bits = ''.join(format(byte, '08b') for byte in bytes_data)
return bits
def _bits_to_text(self, bits, encoding='utf-8'):
"""
将二进制位字符串转换回文本。
:param bits: 二进制位字符串,长度必须是8的倍数
:param encoding: 文本编码方式,需与编码时一致
:return: 解码后的文本字符串
"""
# 检查位数是否是8的倍数
if len(bits) % 8 != 0:
raise ValueError(f"二进制位字符串长度({len(bits)})不是8的倍数,无法正确解码。")
# 每8位一组,转换为整数,再转换为字节
byte_array = bytearray()
for i in range(0, len(bits), 8):
byte_bits = bits[i:i+8]
byte_val = int(byte_bits, 2)
byte_array.append(byte_val)
# 将字节数组按指定编码解码为字符串
return byte_array.decode(encoding)
def _encode_message(self, message):
"""
核心编码函数:将消息文本转换为零宽度字符序列。
:param message: 要隐藏的消息
:return: 由零宽度字符组成的字符串
"""
# 1. 文本转二进制位
bits = self._text_to_bits(message)
# 2. 将每个二进制位映射为零宽度字符
zw_string = ''.join(self.bit_to_char[bit] for bit in bits)
return zw_string
_text_to_bits
函数是关键。它使用
format(byte, '08b')
将每个字节(8位)格式化为一个8字符长的二进制字符串,例如字节
104
(对应字母‘h’的ASCII)会变成
'01101000'
。
_encode_message
函数则遍历这个长长的二进制字符串,把每一个
‘0’
换成
\u200c
,每一个
‘1’
换成
\u200d
。
实操心得 :这里选择
utf-8编码是因为它通用,能处理中文等非ASCII字符。如果你确定隐藏的信息永远是英文数字,用ascii编码也可以。但用utf-8更稳妥。另外,format(byte, '08b')中的08确保了即使字节值很小(比如1),也会输出00000001,保持了固定的8位长度,这对后续解码时正确分割字节至关重要。
3.3 水印嵌入:将隐形字符“织”入文本
现在有了隐形字符序列
zw_string
,我们需要把它巧妙地插入到宿主文本中。
def embed(self, host_text, secret_message):
"""
将秘密信息嵌入到宿主文本中。
:param host_text: 宿主文本,即公开的、可见的文本
:param secret_message: 需要隐藏的秘密信息
:return: 嵌入了水印的文本
"""
# 1. 将秘密信息编码为零宽度字符序列
watermark_sequence = self._encode_message(secret_message)
if not watermark_sequence:
return host_text
# 2. 将宿主文本转换为字符列表,便于插入操作
host_chars = list(host_text)
watermark_len = len(watermark_sequence)
host_len = len(host_chars)
# 3. 计算插入位置(字符之间的间隙,包括开头前和结尾后)
# 间隙数量比字符数多一个
gaps = host_len + 1
# 4. 均匀插入策略
embedded_chars = []
watermark_index = 0
for i in range(host_len + 1): # i 代表第i个间隙
# 先插入宿主字符(除了最后一个间隙后的位置)
if i < host_len:
embedded_chars.append(host_chars[i])
# 在当前间隙插入水印字符(如果还有水印字符)
if watermark_index < watermark_len:
# 选择插入哪个水印字符
char_to_insert = watermark_sequence[watermark_index]
embedded_chars.append(char_to_insert)
watermark_index += 1
# 如果水印序列比间隙短,插入完即停止
# 如果水印序列比间隙长,这里逻辑需要循环使用水印序列,但为了解码简单,我们通常确保水印序列不会过长。
# 更复杂的策略可以循环插入,但解码时需要知道循环次数或使用终止符。
# 5. 将字符列表合并为字符串并返回
watermarked_text = ''.join(embedded_chars)
return watermarked_text
这段代码实现了最基本的均匀插入。它遍历宿主文本的每一个“间隙”(包括第一个字符前和最后一个字符后),在插入宿主字符本身的同时,按顺序插入一个水印字符。这种方法的优点是解码极其简单:只需要把所有零宽度字符按出现顺序捡出来就行。但它有个潜在问题:如果水印信息很长,而宿主文本很短,水印字符会密集地插在文本周围,虽然看不见,但可能会被某些文本分析工具检测到异常(比如字符数激增)。因此, 在实际应用中,宿主文本的长度最好远大于水印信息编码后的零宽度字符长度 。
3.4 水印提取与解码:从“无形”到“有形”
提取是嵌入的逆过程,相对更简单。
def extract(self, watermarked_text):
"""
从已嵌入水印的文本中提取秘密信息。
:param watermarked_text: 可能含有水印的文本
:return: 提取出的秘密信息,如果未找到水印则返回空字符串
"""
# 1. 过滤出所有属于我们映射字典的零宽度字符
extracted_zw_chars = []
for char in watermarked_text:
if char in self.char_to_bit: # 只收集表示0和1的字符
extracted_zw_chars.append(char)
if not extracted_zw_chars:
return "" # 没有找到任何水印字符
# 2. 将零宽度字符序列转换回二进制位字符串
bits = ''.join(self.char_to_bit[zw_char] for zw_char in extracted_zw_chars)
# 3. 将二进制位字符串解码为文本
try:
secret_message = self._bits_to_text(bits)
return secret_message
except (ValueError, UnicodeDecodeError) as e:
# 解码失败,可能因为位数不对或编码问题
print(f"解码失败: {e}")
# 可以尝试返回原始比特流,或者进行错误恢复尝试
# 这里简单返回空字符串
return ""
提取逻辑非常直接:遍历输入文本的每一个字符,如果这个字符在我们预先定义的
char_to_bit
字典里(即它是
\u200c
或
\u200d
),就把它收集起来。收集到的字符序列,再通过字典反向映射成
0
和
1
组成的二进制字符串。最后,调用
_bits_to_text
函数将二进制字符串还原成我们最初隐藏的信息。
注意事项 :这里的提取逻辑非常“贪婪”,它会提取文本中所有符合规则的零宽度字符。这意味着,如果宿主文本原本就偶然包含了这些零宽度字符(虽然概率极低),或者水印被插入后文本又被编辑,混入了其他零宽度字符,都会导致提取失败或得到乱码。因此,这个方案更适合对文本内容有控制权的场景,或者通过添加校验码(如下文会讲)来提高容错。
3.5 完整代码与基础使用示例
将以上所有部分组合起来,我们就得到了一个完整的、可运行的
ZeroWidthWatermark
类。
# 零宽度字符隐形水印完整实现
class ZeroWidthWatermark:
ZERO_WIDTH_SPACE = '\u200b'
ZERO_WIDTH_NON_JOINER = '\u200c'
ZERO_WIDTH_JOINER = '\u200d'
def __init__(self, use_zwsp=False):
self.bit_to_char = {'0': self.ZERO_WIDTH_NON_JOINER, '1': self.ZERO_WIDTH_JOINER}
self.char_to_bit = {v: k for k, v in self.bit_to_char.items()}
self.separator = self.ZERO_WIDTH_SPACE if use_zwsp else ''
if self.separator and self.separator in self.char_to_bit:
del self.char_to_bit[self.separator]
def _text_to_bits(self, text, encoding='utf-8'):
bytes_data = text.encode(encoding)
return ''.join(format(byte, '08b') for byte in bytes_data)
def _bits_to_text(self, bits, encoding='utf-8'):
if len(bits) % 8 != 0:
raise ValueError(f"二进制位字符串长度({len(bits)})不是8的倍数。")
byte_array = bytearray()
for i in range(0, len(bits), 8):
byte_array.append(int(bits[i:i+8], 2))
return byte_array.decode(encoding)
def _encode_message(self, message):
bits = self._text_to_bits(message)
return ''.join(self.bit_to_char[bit] for bit in bits)
def embed(self, host_text, secret_message):
watermark_sequence = self._encode_message(secret_message)
if not watermark_sequence:
return host_text
host_chars = list(host_text)
watermark_len = len(watermark_sequence)
host_len = len(host_chars)
gaps = host_len + 1
embedded_chars = []
wm_index = 0
for i in range(host_len + 1):
if i < host_len:
embedded_chars.append(host_chars[i])
if wm_index < watermark_len:
embedded_chars.append(watermark_sequence[wm_index])
wm_index += 1
return ''.join(embedded_chars)
def extract(self, watermarked_text):
extracted_zw_chars = [c for c in watermarked_text if c in self.char_to_bit]
if not extracted_zw_chars:
return ""
bits = ''.join(self.char_to_bit[c] for c in extracted_zw_chars)
try:
return self._bits_to_text(bits)
except (ValueError, UnicodeDecodeError):
return ""
# 使用示例
if __name__ == "__main__":
watermark = ZeroWidthWatermark()
# 原始文本和秘密信息
original_text = "这是一段用于测试的公开文本,内容是关于Python编程的分享。"
secret = "版权归属:张三 2023"
print("原始文本:", original_text)
print("秘密信息:", secret)
print("-" * 50)
# 嵌入水印
watermarked = watermark.embed(original_text, secret)
print("含水印的文本 (视觉无变化):")
print(repr(watermarked)) # 使用repr可以看到隐藏字符
print("长度对比 - 原始:", len(original_text), "含水印:", len(watermarked))
print("-" * 50)
# 提取水印
extracted = watermark.extract(watermarked)
print("提取出的信息:", extracted)
print("提取是否成功?", extracted == secret)
运行这段代码,你会发现
original_text
和
watermarked
打印出来看起来一模一样,但
repr(watermarked)
会显示其中夹杂了大量的
\u200c
和
\u200d
。
len(watermarked)
也会比原文长很多,这正是水印字符占用的“空间”。提取出的信息与原始秘密完全一致。
4. 高级优化与实战技巧
基础版本已经能工作,但在实际应用中还很脆弱。下面分享几个我踩过坑后总结的优化技巧。
4.1 添加校验码与容错处理
基础版本最大的问题是“脆弱”。任何对含水印文本的修改(比如增删一个普通字符,甚至某些编辑器自动格式化零宽度字符),都可能导致提取时二进制位序列错位,整个解码失败。解决方法是在编码信息时加入 校验和 或 前导码 。
一个简单有效的办法是:在隐藏的真实信息前,添加一个固定的、已知的 起始标记 。提取时,先找到这个标记,再从标记之后开始解析真实数据。这样即使文本开头被截掉了一些,只要标记还在,我们就能找到正确起点。
def embed_robust(self, host_text, secret_message, start_marker="ZWWM"):
"""
更健壮的嵌入方法:添加起始标记和长度信息。
:param start_marker: 起始标记文本,用于解码时定位
:return: 含水印的文本
"""
# 1. 构建待编码的数据包:起始标记 + 信息长度 + 信息本身
# 将长度固定为4字节(32位)的二进制表示,足够表示很长的信息了
length_info = len(secret_message)
# 将长度转换为4个字节的二进制字符串
length_bits = format(length_info, '032b') # 32位,前面补零
# 编码起始标记、长度和秘密信息
marker_bits = self._text_to_bits(start_marker)
message_bits = self._text_to_bits(secret_message)
# 组合:标记 + 长度 + 信息
full_bits = marker_bits + length_bits + message_bits
# 2. 将组合后的二进制转换为零宽度字符序列
watermark_sequence = ''.join(self.bit_to_char[bit] for bit in full_bits)
# 3. 嵌入(使用之前的均匀插入方法,这里复用embed的逻辑,但传入我们自建的序列)
# 为了复用,我们可以稍微修改embed函数,或者这里重写插入逻辑。我们选择简单重写。
host_chars = list(host_text)
watermarked_chars = []
wm_index = 0
for i in range(len(host_chars) + 1):
if i < len(host_chars):
watermarked_chars.append(host_chars[i])
if wm_index < len(watermark_sequence):
watermarked_chars.append(watermark_sequence[wm_index])
wm_index += 1
return ''.join(watermarked_chars)
def extract_robust(self, watermarked_text, start_marker="ZWWM"):
"""
从可能不完整或受损的文本中提取信息。
"""
# 1. 提取所有零宽度字符并转换为比特流
all_zw_chars = [c for c in watermarked_text if c in self.char_to_bit]
if not all_zw_chars:
return None
bit_stream = ''.join(self.char_to_bit[c] for c in all_zw_chars)
# 2. 在比特流中搜索起始标记
marker_bits = self._text_to_bits(start_marker)
marker_pos = bit_stream.find(marker_bits)
if marker_pos == -1:
print("错误:未找到起始标记。")
return None
# 3. 找到标记后,接下来32位是长度信息
length_start = marker_pos + len(marker_bits)
length_bits = bit_stream[length_start: length_start + 32]
if len(length_bits) < 32:
print("错误:比特流长度不足,无法读取长度信息。")
return None
message_length = int(length_bits, 2)
# 4. 根据长度读取信息比特
message_start = length_start + 32
message_bits = bit_stream[message_start: message_start + message_length * 8] # 长度是字符数,乘以8得比特数
if len(message_bits) < message_length * 8:
print(f"警告:比特流不完整,期望{message_length*8}位,实际{len(message_bits)}位。尝试解码已有部分。")
# 可以尝试解码,但可能出错
# 5. 解码信息
try:
# 将比特流按8位一组分割,可能最后一组不完整
bytes_list = []
for i in range(0, len(message_bits), 8):
byte_bits = message_bits[i:i+8]
if len(byte_bits) == 8:
bytes_list.append(int(byte_bits, 2))
secret_message = bytes(bytes_list).decode('utf-8', errors='ignore') # 使用ignore忽略解码错误
return secret_message
except Exception as e:
print(f"解码过程出错: {e}")
return None
这个健壮版本做了几件事:
- 起始标记 :编码一段固定的、独特的比特序列(如“ZWWM”的二进制),放在最前面。提取时,先在比特流中搜索这个标记,找到了就说明水印数据的开始位置。这解决了因文本开头被删导致水印头部丢失的问题。
- 长度信息 :在标记后,用固定位数(如32位)编码秘密信息的 字节长度 。这样,提取时就知道该读取多少位的数据,即使后面混入了无关的零宽度字符(比如原文自带的),也能在读取指定长度后停止。
-
错误处理
:解码时使用
errors='ignore'参数,即使部分数据损坏,也能尽量还原出可读的部分。
实操心得 :起始标记不能太短,否则可能在比特流中偶然匹配到(尽管概率低)。像“ZWWM”这样4-5个字符的标记比较合适。长度信息固定为32位,意味着最大能表示约4GB的信息长度,对于文本水印绰绰有余。在实际使用中,你甚至可以在信息尾部再加一个结束标记,形成“数据帧”的概念,进一步提高可靠性。
4.2 水印容量与宿主文本长度的权衡
水印的容量(能隐藏多少信息)直接取决于宿主文本的长度。我们的均匀插入算法,最多能在长度为N的宿主文本中插入N+1个零宽度字符。每个零宽度字符代表1比特信息。所以,最大隐藏信息量大约是
(N+1)/8
个字节。
例如,宿主文本有100个字符,最多能隐藏约12.6字节的信息(约12个英文字母)。如果想隐藏一句中文(UTF-8下可能占10-30字节),宿主文本就需要有几百字符才比较稳妥。
公式估算 :
最大隐藏字节数 ≈ (宿主文本字符数 + 1) / 8
所需宿主文本最小字符数 ≈ (隐藏信息字节数 * 8) - 1
如果你的秘密信息很长,宿主文本却很短,你有几个选择:
-
压缩秘密信息
:在编码前,用
zlib或gzip库对字符串进行压缩,可以显著减少字节数。 - 使用更高效的编码 :比如用Base64编码二进制数据,虽然会膨胀约33%,但有时比直接存二进制更兼容。或者,如果你隐藏的信息只是数字ID,可以直接用十进制或十六进制转二进制,比用UTF-8编码字符串更省位。
- 增加宿主文本长度 :这是最直接的方法。可以在不影响主旨的情况下,适当增加一些描述性文字。
4.3 对抗检测与混淆策略
虽然零宽度字符人眼不可见,但简单的程序检测很容易。例如,计算文本中零宽度字符的比例,如果异常高,就可能暴露。为了增强隐蔽性,可以引入 混淆 :
-
随机插入
:不按顺序均匀插入,而是随机选择间隙插入水印字符。同时,在剩余间隙随机插入一些“假”的零宽度字符(比如用
U+200B,但解码时忽略它)。这样,即使有人检测到零宽度字符,也无法轻易区分哪些是有效载荷,哪些是噪声。 -
使用多种字符
:除了
200C和200D,还可以把200B、200E、200F也纳入编码表,用2-3位二进制表示一个零宽度字符,提高信息密度,或者用它们来做混淆。 -
结合文本样式
:在某些支持富文本的场景(如HTML),可以将零宽度字符与特定的、不改变显示的CSS样式(如
font-size:0、color:transparent)结合,增加检测难度。但这超出了纯文本范畴。
这里提供一个简单的随机插入与混淆的示例:
import random
def embed_stealthy(self, host_text, secret_message, noise_ratio=0.5):
"""
随机插入水印并添加噪声字符,增强隐蔽性。
:param noise_ratio: 噪声字符数与水印字符数的比例
"""
watermark_sequence = self._encode_message(secret_message)
host_chars = list(host_text)
gaps = len(host_chars) + 1
# 生成噪声字符序列(使用ZWSP)
noise_chars = [self.ZERO_WIDTH_SPACE] * int(len(watermark_sequence) * noise_ratio)
# 合并有效水印和噪声
all_stealth_chars = watermark_sequence + ''.join(noise_chars)
# 打乱顺序?不行,解码需要顺序。改为在插入时随机选择是插入水印字符还是噪声字符。
# 更优策略:预先决定每个间隙插入什么。
stealth_list = list(all_stealth_chars)
random.shuffle(stealth_list) # 打乱噪声和水印字符的相对顺序?这会破坏水印。
# 正确做法:创建一个插入计划列表,标记每个位置插什么。
# 简化:先插入所有水印字符到随机位置,再在剩余位置插入噪声。
# 创建一个长度等于间隙数的列表,初始为None
insertion_plan = [None] * gaps
# 为水印字符随机选择不重复的位置
wm_positions = random.sample(range(gaps), len(watermark_sequence))
for pos, wm_char in zip(wm_positions, watermark_sequence):
insertion_plan[pos] = wm_char
# 为噪声字符选择剩余的空位置
empty_positions = [i for i, val in enumerate(insertion_plan) if val is None]
noise_positions = random.sample(empty_positions, min(len(noise_chars), len(empty_positions)))
for pos, nc in zip(noise_positions, noise_chars):
insertion_plan[pos] = nc
# 根据计划构建文本
result_chars = []
plan_index = 0
for i in range(len(host_chars) + 1):
if i < len(host_chars):
result_chars.append(host_chars[i])
if plan_index < gaps and insertion_plan[plan_index] is not None:
result_chars.append(insertion_plan[plan_index])
plan_index += 1
return ''.join(result_chars)
这个增强版的
embed_stealthy
做了两件事:一是将水印字符随机插入到各个间隙,而不是顺序插入;二是在剩余间隙中随机插入零宽度空格(
U+200B
)作为噪声。在解码时,我们的
extract
函数只会识别
200C
和
200D
,所以噪声会被自动过滤掉,不影响正确解码。随机插入使得水印字符的分布没有规律,更难被统计分析检测出来。
5. 常见问题、排查与实战场景
5.1 水印提取失败?一步步排查
即使代码正确,在实际操作中也可能遇到提取不出水印或提取乱码的情况。别慌,按以下步骤排查:
-
检查零宽度字符是否被“吃掉” :
- 现象 :在某个平台(如某些社交媒体、旧版记事本)复制粘贴后,水印丢失。
- 原因 :该平台可能出于安全或兼容性考虑,自动过滤或删除了不可见字符。
-
验证
:用Python输出含水印文本的长度
len(text),并与原始宿主文本长度对比。如果长度没变或变短,说明字符被过滤。如果变长了,但提取失败,进入下一步。 - 解决 :避免在不支持Unicode特殊字符的环境中使用。优先在Web应用、Markdown文档、现代代码编辑器、支持富文本的聊天工具(如Telegram、Discord的部分客户端)中传递。
-
检查编码/解码一致性 :
- 现象 :提取出的信息是乱码。
-
原因
:嵌入和提取时使用的编码方式不一致。比如嵌入用
utf-8,提取误用ascii解码中文。 -
验证
:在提取代码中,打印出提取到的二进制比特流
bits,看看长度是否是8的倍数。如果不是,说明在传输过程中零宽度字符序列可能被增删了。 -
解决
:确保
ZeroWidthWatermark类在嵌入和提取时使用相同的编码参数(默认utf-8即可)。如果比特流长度不对,尝试使用上文提到的“健壮版”提取方法,它能容忍一定程度的比特丢失。
-
检查文本编辑操作 :
- 现象 :对含水印文本进行编辑(如删除一个字、调整空格)后,水印提取失败。
- 原因 :编辑操作改变了零宽度字符的相对位置或数量。我们的基础提取方法是按顺序收集所有零宽度字符,删除一个宿主字符可能会连带删除其后的零宽度字符(取决于光标位置和删除方式)。
-
验证
:比较编辑前后文本的长度变化,以及用
repr()查看零宽度字符的分布。 - 解决 :这是文本水印的固有弱点。对于需要对抗编辑的场景,要么使用更健壮的编码(如添加纠错码),要么确保水印信息非常短(如一个短ID),并嵌入多份副本,只要有一份完整即可提取。
-
确认起始标记(如果使用健壮版) :
- 现象 :使用健壮版嵌入,但提取时提示“未找到起始标记”。
- 原因 :起始标记对应的比特流在传输中受损,或者提取时使用的标记字符串与嵌入时不一致。
-
解决
:确保嵌入和提取使用相同的
start_marker参数。可以考虑使用更独特、比特模式不易偶然出现的标记。
5.2 不同场景下的应用建议
根据你的需求,选择不同的策略:
-
场景一:版权标识与轻度防伪
- 需求 :在发布的文章、代码片段中嵌入作者名、发布日期。
-
建议
:使用
基础版
即可。水印信息短(如
"Author:Alice-2023"),宿主文本长。即使部分丢失,只要能提取出部分信息(如“Alice”),也能起到标识作用。可以同时在文章开头、中间、结尾各嵌入一次,增加存活概率。
-
场景二:内部文档溯源
- 需求 :在公司内部分发的文档草稿中,嵌入唯一分发ID,追踪泄露源。
-
建议
:使用
健壮版(带起始标记和长度)
。水印信息是唯一的ID(如
"DocID:XYZ789-Rev1")。分发时,为每个接收者生成一个带其ID的水印版本。一旦泄露,提取水印即可定位源头。
-
场景三:聊天软件中的隐蔽通信(娱乐用途)
- 需求 :在支持的聊天工具中,发送看起来正常但内含隐藏信息的消息。
- 建议 :使用 随机插入+混淆版 。因为聊天消息通常较短,需要最大化隐蔽性。信息内容可以是一个提示词或短链接。注意,部分聊天软件(如微信)可能会过滤零宽度字符,需提前测试。
-
场景四:数据库字段或日志的隐形标记
- 需求 :在需要存储额外元数据但又不想新增字段时,将信息嵌入到现有文本字段中。
-
建议
:使用
健壮版
,并考虑将信息进行
压缩
(如用
zlib.compress)后再编码,以节省空间。务必确保数据库和应用程序的字符编码(如UTF-8)能完整保存这些特殊字符。
5.3 性能与安全边界认知
- 性能 :对于几千字以内的文本,上述Python代码的嵌入和提取都是毫秒级的,完全无感。对于超长文本(如整本书),主要开销在字符串的遍历和拼接,但依然很快。
- 安全边界 :必须清醒认识到,这 不是 加密,也不是强安全方案。它只是一种 隐写术 。它的安全性基于“攻击者不会注意到这些不可见字符”。一个有心人只要检查文本的Unicode码点,或者简单计算一下非打印字符的比例,就能轻易发现异常并提取出水印(如果他猜到了编码规则)。因此,它适用于低安全需求的场景,如版权声明、内部追踪、趣味游戏等,绝不能用于传递真正敏感的秘密。
最后,分享一个我自己的使用习惯:在嵌入重要水印后,我总会用一个简单的函数验证一下“往返”是否成功,即
extract(embed(text, secret)) == secret
。并且,我会把含水印的文本粘贴到一个最简化的环境(比如Windows自带的记事本)里看看是否还能正确提取,以此来测试其兼容性。
更多推荐
所有评论(0)