在Perl中构建UTF-8编码器

这周我编写了一个UTF-8编码器/解码器。Perl已经内置了UTF-8编码功能,所以这不是必需的,但有时了解事情是如何工作的也是很不错的。UTF-8方案定义在RFC 3629中。
UTF-8编码器做什么?
UTF-8是一种将Unicode码点序列编码为字节/八位的方案。码点只是一个数字,用于标识Unicode条目(例如,0x24是一个美元符号)。
Unicode定义的码点范围是0x0000..0x10FFFF,因此编码器必须将码点转换为字节,根据UTF-8方案,如下所示
Char. number range | UTF-8 bytes/octets sequence
(hexadecimal) | (binary)
--------------------+------------------------------------
0000 0000-0000 007F | 0xxxxxxx
0000 0080-0000 07FF | 110xxxxx 10xxxxxx
0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
这有一些有趣的特性。首先,范围在0x00..0x7F(0-127)的码点将与ASCII编码具有相同的字节,这很方便。其次,它是一种可变宽度编码,这意味着单个码点可以是1-4个字节长。
解码只是反向过程:将一系列字节转换回码点。
编码UTF-8
为了编码UTF-8,我需要将码点(仅是一个数字)转换为一系列字节。由于UTF-8表中定义了四种不同的字节序列,因此有四种情况需要处理
sub codepoint_to_bytes {
my $codepoint = shift;
if ($codepoint < 0x80) {
return pack 'C', $codepoint;
}
elsif ($codepoint < 0x800) {
return pack 'CC',
$codepoint >> 6 | 0b11000000,
$codepoint & 0b00111111 | 0b10000000;
}
elsif ($codepoint < 0x10000) {
return pack 'CCC',
$codepoint >> 12 | 0b11100000,
$codepoint >> 6 & 0b00111111 | 0b10000000,
$codepoint & 0b00111111 | 0b10000000;
}
else {
return pack 'CCCC',
$codepoint >> 18 | 0b11110000,
$codepoint >> 12 & 0b00111111 | 0b10000000,
$codepoint >> 6 & 0b00111111 | 0b10000000,
$codepoint & 0b00111111 | 0b10000000;
}
}
第一种情况是最简单的:如果码点在0x00和0x7F之间,不需要转换,所以我只需以原样打包码点。字符的字节值与码点相同(例如,'U' == 56 == 0x38 == 00111000)。
对于第二种情况,我必须将码点填充到掩码110xxxxx 10xxxxxx
中,这意味着我需要返回两个字节。这是我的做法
- 对于第一个字节,将码点右移6位(因为第二个字节将获取这6位)。
- 使用按位或设置两个最高有效位为1(
xxxxxxxx | 11000000 == 11xxxxxx
)。我使用Perl的行内二进制表示法(0b...
),这使得比较二进制数与掩码变得容易。 - 对于第二个字节,使用按位与设置两个最高有效位为零(
xxxxxxxx & 00111111 == 00xxxxxx
)。 - 使用按位或设置最高有效位为1(
xxxxxxxx | 10000000 == 1xxxxxxx
)。 - 使用打包将字节组合成标量并返回它。
三字节和四字节编码的过程遵循相同的方法,但根据UTF-8方案更新了规则。
如果我想获取电视码点(U+1F4FA)的UTF-8编码字节,我可以使用如下代码
my $bytes = codepoint_to_bytes(0x1F4FA);
解码UTF-8
为了解码UTF-8字节,我们需要反向编码过程以回到原始的Unicode码点数字。解码器必须检查它接收了多少字节,提取适当的位并将它们加在一起。
正如俗话所说,Perl“试图使简单的事情变得简单,使困难的事情变得可能”,但有时它使简单的事情比在像C这样的简单语言中更困难。二进制数据就是这样:Perl需要在您安全地处理数据之前通知它关闭字符功能。
有两种方法可以实现。旧的不推荐的方法是使用bytes祈使句。新的方法是使用Encode模块将标量编码为字节并移除其UTF-8标志。之后,Perl的函数将把标量当作字节序列来处理,而不是字符。
use Encode 'encode';
sub bytes_to_codepoint {
# treat the scalar as bytes/octets
my $input = encode('UTF-8', shift);
# length returns number of bytes
my $len = length $input;
my $template = 'C' x $len;
my @bytes = unpack $template, $input;
...
}
在子例程bytes_to_codepoint
中,我使用encode()
将传入的字节填充到$input
中。接下来,我使用length
函数来返回$input
中的字节数——这与它通常的行为不同,它返回的是字符数;这是使用encode()
将标量转换为字节的效果。最后,我使用unpack从$input
中提取字节。
现在我知道了传递给bytes_to_codepoint
的字节数,只需反转编码过程中的二进制操作即可。
if ($len == 1) {
return $bytes[0];
}
elsif ($len == 2) {
return (($bytes[0] & 0b00011111) << 6) +
($bytes[1] & 0b00111111);
}
elsif ($len == 3) {
return (($bytes[0] & 0b00001111) << 12) +
(($bytes[1] & 0b00111111) << 6) +
( $bytes[2] & 0b00111111);
}
else {
return (($bytes[0] & 0b00000111) << 18) +
(($bytes[1] & 0b00111111) << 12) +
(($bytes[2] & 0b00111111) << 6) +
($bytes[3] & 0b00111111);
}
如果只有一个字节,就原样返回它,因为代码点数字与字节值相同。与编码一样,当涉及到两个字节时,事情变得有趣起来。
- 使用位与操作移除第一个字节的掩码。
- 将得到的数字左移6位以获取原始值。所以
00000010
会变成10000000
。 - 使用位与操作移除第二个字节的掩码。
- 将数字相加。
同样的逻辑适用于三字节和四字节序列,我只是更新了位操作以匹配UTF-8方案。最终的代码如下所示
use Encode 'encode';
sub bytes_to_codepoint {
# treat the scalar as bytes/octets
my $input = encode('UTF-8', shift);
# length returns number of bytes
my $len = length $input;
my $template = 'C' x $len;
my @bytes = unpack $template, $input;
# reverse encoding
if ($len == 1) {
return $bytes[0];
}
elsif ($len == 2) {
return (($bytes[0] & 0b00011111) << 6) +
($bytes[1] & 0b00111111);
}
elsif ($len == 3) {
return (($bytes[0] & 0b00001111) << 12) +
(($bytes[1] & 0b00111111) << 6) +
( $bytes[2] & 0b00111111);
}
else {
return (($bytes[0] & 0b00000111) << 18) +
(($bytes[1] & 0b00111111) << 12) +
(($bytes[2] & 0b00111111) << 6) +
($bytes[3] & 0b00111111);
}
}
假设我想获取东京塔的代码点,可以这样调用代码
use utf8;
my $codepoint = bytes_to_codepoint('🗼');
注意
- 这是一个简单的实现——它不处理UTF-16保留字符(U+D800..U+DFFF)、非字符,并且一次只编码/解码一个代码点。
- 如果你需要一个快速的UTF-8编码器且不想使用Perl的内置工具,请查看Unicode::UTF8。
- UTF-8是最受欢迎的Unicode编码。它由Ken Thompson和Rob Pike在短短几天内创建。
- 如果你正在构建自己的UTF-8编码器,请查看Markus Kuhn的解码器测试文件,它包含几个困难的或边缘情况的UTF-8解码测试。Markus还编写了一份全面的UTF-8和Unicode FAQ for Unix/Linux。
这篇文章最初发布在PerlTricks.com上。
标签
反馈
这篇文章有什么问题吗?请通过在GitHub上打开问题或拉取请求来帮助我们。