零宽水印实现原理与实践
/ 9 min read
什么是零宽水印
提到水印,很多人第一反应是图片水印、音视频水印。但实际上,文本同样可以加水印。
零宽水印(Zero-Width Watermark)是一种面向文本的隐藏信息技术,它的核心思路是:
利用 Unicode 中“看不见”的零宽字符,在文本中嵌入额外信息。
这些字符通常不会显示出来,也不会影响正常阅读,但它们在底层编码中是真实存在的,因此可以被程序识别和提取。
常见的零宽字符如下:
| 字符 | Unicode | 含义 |
|---|---|---|
\u200B | U+200B | 零宽空格 |
\u200C | U+200C | 零宽非连接符 |
\u200D | U+200D | 零宽连接符 |
\uFEFF | U+FEFF | 零宽无断空格 |
简单来说,零宽水印就是把一段看不见的数据藏进文本里。
零宽水印能解决什么问题
零宽水印最常见的价值在于追踪和溯源。
典型场景包括:
- 文本分发追踪:同一份内容下发给不同用户时,嵌入不同标识
- 泄露源定位:发现外泄文本后,通过提取水印定位来源
- 渠道追踪:不同渠道投放的文本内容携带不同隐藏标记
- 身份标识:在通知、报告、导出文档中嵌入用户 ID 或 traceId
它更像是一种轻量级文本标记方案,非常适合需要“低成本接入”的系统。
实现原理
零宽水印的实现可以拆成两个过程:编码 和 解码。
1. 编码过程
把待嵌入的信息:
- 转成字符串
- 编码成二进制
- 将二进制映射为零宽字符
- 插入到原文本中
2. 解码过程
从文本中:
- 提取零宽字符
- 还原成二进制
- 再解码回原始信息
整体上可以理解为:
原始信息 -> 二进制 -> 零宽字符 -> 插入文本提取时则反向执行。
一个最简单的编码方案
为了便于理解,这里采用最基础的映射方式:
0->\u200B1->\u200C
比如二进制字符串:
0101映射后就是:
\u200B\u200C\u200B\u200C再把这些零宽字符插入到文本中,就完成了最简单的水印嵌入。
这种方案不复杂,但足够说明核心思想。
实现示例
下面给出一个基础版实现,适合作为原理演示。
1)定义零宽字符
const ZERO = '\u200B';const ONE = '\u200C';2)字符串转二进制
function stringToBinary(str) {const encoder = new TextEncoder();const bytes = encoder.encode(str);return Array.from(bytes) .map(byte => byte.toString(2).padStart(8, '0')) .join('');}3)二进制转字符串
function binaryToString(binary) {const bytes = binary.match(/.{1,8}/g) || [];const uint8Array = new Uint8Array( bytes.map(byte => parseInt(byte, 2)));const decoder = new TextDecoder();return decoder.decode(uint8Array);}4)二进制与零宽字符互转
function binaryToZeroWidth(binary) {return binary .split('') .map(bit => (bit === '0' ? ZERO : ONE)) .join('');}
function zeroWidthToBinary(zeroWidthStr) {return zeroWidthStr .split('') .map(char => { if (char === ZERO) return '0'; if (char === ONE) return '1'; return ''; }) .join('');}5)嵌入水印
这里使用最直观的方式:将零宽字符依次插入到原始文本字符之间。
function embedWatermark(text, watermark) {const binary = stringToBinary(watermark);const zwc = binaryToZeroWidth(binary);
const chars = text.split('');let result = '';
for (let i = 0; i < chars.length; i++) { result += chars[i]; if (i < zwc.length) { result += zwc[i]; }}
if (zwc.length > chars.length) { result += zwc.slice(chars.length);}
return result;}6)提取水印
function extractWatermark(text) {const zeroWidthChars = text .split('') .filter(char => char === ZERO || char === ONE) .join('');
const binary = zeroWidthToBinary(zeroWidthChars);return binaryToString(binary);}7)使用示例
const original = '这是一段需要嵌入水印的文本内容。';const watermark = 'user_10086';
const watermarkedText = embedWatermark(original, watermark);console.log('嵌入后文本:', watermarkedText);
const extracted = extractWatermark(watermarkedText);console.log('提取出的水印:', extracted);从显示效果上看,插入零宽字符后的文本和原文几乎没有区别,因此用户通常察觉不到变化。
例如下面两段文本,肉眼看起来可能完全一致:
这是一段文本这是一段文本但其中一段内部可能已经夹带了零宽字符,程序依然可以把它们提取出来。
也就是说:
- 人眼看不见
- 程序读得到
这正是零宽水印能成立的基础。
零宽水印的优缺点
优点
- 实现简单,开发成本低
- 对用户几乎无感知
- 适合文本内容场景
- 便于做轻量级溯源和追踪
缺点
- 鲁棒性较差
- 容易被清洗或移除
- 承载数据量有限
- 不适合高强度攻击场景
适合哪些场景
零宽水印适合:
- 内部文档分发追踪
- 用户专属文本下发
- 通知消息、报告导出标记
- 渠道内容识别与回溯
不太适合:
- 强版权保护
- OCR、截图后仍要求恢复
- 复杂跨平台传播场景
- 对抗性较强的公开环境
换句话说,它更偏向工程实用型方案,而不是“万能水印方案”。
总结
零宽水印是一种非常轻量的文本隐藏方案,它利用不可见的 Unicode 字符,在不影响阅读体验的前提下完成信息嵌入。
它的优势很明显:
- 简单
- 隐蔽
- 易接入
但局限也同样明显:
- 容易丢失
- 容易被清理
- 不适合强对抗
因此,更适合把零宽水印理解为一种低成本文本追踪技术。如果你的目标是内部文档溯源、个性化文本分发、渠道标记等,这类方案很实用;如果目标是强鲁棒性版权保护,则需要进一步考虑更复杂的水印设计。
附录:完整示例代码
const ZERO = '\u200B';const ONE = '\u200C';
function stringToBinary(str) {const encoder = new TextEncoder();const bytes = encoder.encode(str);return Array.from(bytes) .map(byte => byte.toString(2).padStart(8, '0')) .join('');}
function binaryToString(binary) {const bytes = binary.match(/.{1,8}/g) || [];const uint8Array = new Uint8Array( bytes.map(byte => parseInt(byte, 2)));const decoder = new TextDecoder();return decoder.decode(uint8Array);}
function binaryToZeroWidth(binary) {return binary .split('') .map(bit => (bit === '0' ? ZERO : ONE)) .join('');}
function zeroWidthToBinary(zeroWidthStr) {return zeroWidthStr .split('') .map(char => { if (char === ZERO) return '0'; if (char === ONE) return '1'; return ''; }) .join('');}
function embedWatermark(text, watermark) {const binary = stringToBinary(watermark);const zwc = binaryToZeroWidth(binary);
const chars = text.split('');let result = '';
for (let i = 0; i < chars.length; i++) { result += chars[i]; if (i < zwc.length) { result += zwc[i]; }}
if (zwc.length > chars.length) { result += zwc.slice(chars.length);}
return result;}
function extractWatermark(text) {const zeroWidthChars = text .split('') .filter(char => char === ZERO || char === ONE) .join('');
const binary = zeroWidthToBinary(zeroWidthChars);return binaryToString(binary);}
const original = '这是一段需要嵌入水印的文本内容。';const watermark = 'user_10086';
const watermarkedText = embedWatermark(original, watermark);console.log('原文本:', original);console.log('嵌入水印后:', watermarkedText);
const extracted = extractWatermark(watermarkedText);console.log('提取出的水印:', extracted);