skip to content
Logo Zero's Blog

零宽水印实现原理与实践

/ 9 min read

什么是零宽水印

提到水印,很多人第一反应是图片水印、音视频水印。但实际上,文本同样可以加水印

零宽水印(Zero-Width Watermark)是一种面向文本的隐藏信息技术,它的核心思路是:

利用 Unicode 中“看不见”的零宽字符,在文本中嵌入额外信息。

这些字符通常不会显示出来,也不会影响正常阅读,但它们在底层编码中是真实存在的,因此可以被程序识别和提取。

常见的零宽字符如下:

字符Unicode含义
\u200BU+200B零宽空格
\u200CU+200C零宽非连接符
\u200DU+200D零宽连接符
\uFEFFU+FEFF零宽无断空格

简单来说,零宽水印就是把一段看不见的数据藏进文本里。


零宽水印能解决什么问题

零宽水印最常见的价值在于追踪和溯源

典型场景包括:

  • 文本分发追踪:同一份内容下发给不同用户时,嵌入不同标识
  • 泄露源定位:发现外泄文本后,通过提取水印定位来源
  • 渠道追踪:不同渠道投放的文本内容携带不同隐藏标记
  • 身份标识:在通知、报告、导出文档中嵌入用户 ID 或 traceId

它更像是一种轻量级文本标记方案,非常适合需要“低成本接入”的系统。


实现原理

零宽水印的实现可以拆成两个过程:编码解码

1. 编码过程

把待嵌入的信息:

  1. 转成字符串
  2. 编码成二进制
  3. 将二进制映射为零宽字符
  4. 插入到原文本中

2. 解码过程

从文本中:

  1. 提取零宽字符
  2. 还原成二进制
  3. 再解码回原始信息

整体上可以理解为:

原始信息 -> 二进制 -> 零宽字符 -> 插入文本

提取时则反向执行。

一个最简单的编码方案

为了便于理解,这里采用最基础的映射方式:

  • 0 -> \u200B
  • 1 -> \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);