基于Unicode编码的信息隐藏Misc
导入
曾几何时,微博热搜出现了两个话题:
没有看出区别?我们不妨比较一下URI编码。
- [#易烊千玺决定放弃入职国话#](https://s.weibo.com/weibo?q=%23%E6%98%93%E7%83%8A%E5%8D%83%E7%8E%BA%E5%86%B3%E5%AE%9A%E6%94%BE%E5%BC%83%E5%85%A5%E8%81%8C%E5%9B%BD%E8%AF%9D%23)
- [#易烊千玺决定放弃⼊职国话#](https://s.weibo.com/weibo?q=%23%E6%98%93%E7%83%8A%E5%8D%83%E7%8E%BA%E5%86%B3%E5%AE%9A%E6%94%BE%E5%BC%83%E2%BC%8A%E8%81%8C%E5%9B%BD%E8%AF%9D%23)
区别在于“入”(%E5%85%A5
)和“⼊”(%E2%BC%8A
)。参见encodeURI()
,URI编码使用UTF-8编码。
由此可知:
- “入”,UTF-8编码
%E5%85%A5
,Unicode编号U+5165
,中日韩统一表意文字区块
- “⼊”,UTF-8编码
%E2%BC%8A
,Unicode编号U+2F0A
,康熙部首区块
这样的字符显然并不是能简单输入的。那么有什么办法可以生成呢?
规范化形式
在开始这一节之前,不妨打开Unicode® Standard Annex #15 UNICODE NORMALIZATION FORMS。
需要重点关注的是,存在四种规范化形式:
- Normalization Form D (NFD)
- Normalization Form C (NFC)
- Normalization Form KD (NFKD)
- Normalization Form KC (NFKC)
其中,有无“K”的区别是是否使用兼容分解,有“K”则使用兼容分解,无“K”则使用标准分解。“C”或“D”的区别是是否进行复合,“C”则进行复合,“D”则不进行复合。
复合
对于我们的日常语言来说,较少出现复合的问题,因此我们主要讨论分解。
在这里举一个简单例子带过:A+^→Â
。由于论坛对Unicode及样式支持有限,为了避免出现错误复合,这里的“^”只是打了一个类似的符号,实际Unicode编号U+0302
,下同。
兼容分解和标准分解
我们需要关注的是分解。
标准分解可视为兼容分解的子集,兼容分解可视为标准分解的超集。在兼容分解中格式信息可能丢失,而标准分解中格式信息将会保留。这里就涉及到相等和等价的问题。相等,顾名思义就是一模一样。之前的两个话题从编码角度看并非完全一样,所以被认为是不相等的话题。但是,对于用户来说,实际上是难以区分的。在Unicode设计中,考虑了这些因素,就有了兼容分解等价和标准分解等价。如果兼容分解后相等,则称为兼容分解等价。如果标准分解后相等,则称为标准分解等价。类似的,标准分解等价是兼容分解等价的子集,兼容分解等价是标准分解等价的超集。如果微博需要修复“阴阳话题”这一问题,可以使用分解等价替代相等。
标准分解包括:
- 逆复合,如
Â→A+^
- 复合序列顺序规范化
- 拆解,如
가→ᄀ+ᅡ
- 单体等价
在标准分解基础上,兼容分解还包括:
- 字母变体,如
ℌ→H
- 断行控制
- 位置变体
- 圆圈变体,如
㊐→日
- 宽度变体,如
カ→カ
- 旋转变体,如
︷︸→{}
- 角标变体,如
⁹₉→99
- 特殊字符,如
㌀ →アパート
- 分数,如
¼→1/4
- 其他,如
dž→dž
因此,只要利用好分解等价,就能解决怎样生成的问题。
代码和实战
编写C#代码如下。代码目标框架为.NET 6.0。
NormalizationForm normalizationForm = NormalizationForm.FormKD;
int ans = 1;
string rawStr = @"易烊千玺决定放弃入职国话";
for (int j = 0; j < rawStr.Length; j++)
{
string oldStr = rawStr.Substring(j, 1).Normalize(normalizationForm);
var list = new List<char>();
for (int i = '\u0000'; i <= '\uffff'; i++)
{
if ('\ud800' <= i && i <= '\udfff')
continue;
if ('\ufdd0' <= i && i <= '\ufdef')
continue;
if (i == '\ufffe' || i == '\uffff')
continue;
string newStr = new string((char)i, 1).Normalize(normalizationForm);
if (newStr == oldStr)
{
list.Add((char)i);
}
}
foreach (var i in list)
{
bool isSame = i == rawStr[j];
if (isSame)
Console.Write("[");
Console.Write(i);
if (isSame)
Console.Write("]");
}
Console.WriteLine($" Count = {list.Count}");
ans *= list.Count;
}
Console.WriteLine($"Ans = {ans}, ILogB = {Math.ILogB(ans)}");
运行程序结果如下。注:我们的程序中使用了兼容分解,使用标准分解只能得到“易”(中日韩兼容表意文字区块),并不能得到“⼊”(康熙部首区块)。
[易]易 Count = 2
[烊] Count = 1
[千] Count = 1
[玺] Count = 1
[决] Count = 1
[定] Count = 1
[放] Count = 1
[弃] Count = 1
⼊[入] Count = 2
[职] Count = 1
[国] Count = 1
[话] Count = 1
Ans = 4, ILogB = 2
可以得知,实际上存在4种组合(Ans = 4
)。按大小排序,依次是:
易烊千玺决定放弃⼊职国话
易烊千玺决定放弃入职国话
易烊千玺决定放弃⼊职国话
易烊千玺决定放弃入职国话
微博只出现阴阳两种热搜还是保守了一些。(难道微博做了标准分解?)回到我们信息隐藏的标题,请问这和信息隐藏有什么关系呢?大家不难发现,上面的句子可以编码两位信息(ILogB = 2
)。实际上,随着文本的增长,可编码信息位数也将成倍增加。我们便可以利用特定排列组合在按大小排序的所有排列组合中的顺序作为编码信息的方式。
譬如:正常的“入”的话题,所有排列组合中的顺序为1(从0开始计数,下同),编码信息1;部首的“⼊”的话题,所有排列组合中的顺序为0,编码信息0。
后记
实际上,有关Unicode和规范化的题目早在两年前论坛就出现过(如:【2021春节】解题领红包之番外篇分析),但是时至今日还是不少会被编码和规范化的问题拦住(如:【编码问题】字符串中混有奇怪的文字,UTF-8可以显示,ANSI就?了)。希望这篇随笔可以节约大家的时间,遇到类似问题不再迷茫。