从二进制文件中读取 UTF-8 字符的简单方法?

Easy way to read UTF-8 characters from a binary file?

这是我的问题:我必须读取“二进制”文件,即具有不同“记录”大小并且可能包含二进制数据以及 UTF-8 编码文本字段的文件。

从输入文件中读取给定数量的字节是微不足道的,但我想知道是否有函数可以轻松地从文件中读取给定数量的字符(而不是字节)?比如,如果我知道我需要读取一个 10 个字符的字段(以 UTF-8 编码,它将 至少 10 个字节长,但可以达到 40 个或更多,如果我们说的是“高”代码点)。

我强调我正在读取一个“混合”文件,也就是说,我无法将其整个处理为 UTF-8,因为二进制字段必须在不被解释为 UTF-8 字符的情况下读取。

因此,虽然手工操作非常简单(逐字节、天真的方法不难实现 - 尽管我对效率持怀疑态度),但我想知道是否有是更好的选择。如果可能,在标准库中,但我也对第 3 方代码开放 - 如果我的组织验证其使用。

这里有两种可能性:

(1) 如果(但通常仅当)您的语言环境设置为处理 UTF-8,则 getwc 函数应准确读取一个 UTF 编码的 Unicode 字符,即使它有多个字节长。所以你可以做类似

的事情
setlocale(LC_CTYPE, "UTF-8");
wint_t c;

for(i = 0; i < 10; i++) {
    c = getwc(ifp);
    /* do something with c */
}

现在,c 这里将是一个包含 Unicode 代码点的整数,不是 UTF-8 多字节序列。如果(很可能)您想在内存数据结构中存储 UTF-8 字符串,则必须转换回 UTF-8,可能使用 wctomb.

(2) 您可以从输入中读取 N 个字节,然后使用 mbstowcs 将它们转换为宽字符流。这也不完美,因为很难知道 N 应该是什么,而且 mbstowcs 给你的宽字符串可能也不是你想要的。

但在探索这两种方法之前,真正的问题是,您的输入格式是什么?那些 UTF 编码的文本片段,它们是固定大小的,还是文件格式包含明确的计数说明它们有多大?在任何一种情况下,它们的大小是以字节还是以字符为单位指定的?希望它是以字节为单位指定的,在这种情况下你不需要做任何转换 to/from UTF-8,你可以使用 fread 读取 N 个字符。如果计数是根据字符指定的(根据我的经验,这有点奇怪),您可能必须使用类似我上面的方法 (1) 的方法。

除了上面 (1) 中的循环之外,我不知道有什么简单的封装方法可以执行相当于“读取 N 个 UTF-8 字符,无论需要多少字节”的操作。

你也可以这样使用:

static unsigned char num_most_significant_ones[] = {
    /* 80 */   1, 1, 1, 1, 1, 1, 1, 1,   1, 1, 1, 1, 1, 1, 1, 1,
    /* 90 */   1, 1, 1, 1, 1, 1, 1, 1,   1, 1, 1, 1, 1, 1, 1, 1,
    /* A0 */   1, 1, 1, 1, 1, 1, 1, 1,   1, 1, 1, 1, 1, 1, 1, 1,
    /* B0 */   1, 1, 1, 1, 1, 1, 1, 1,   1, 1, 1, 1, 1, 1, 1, 1,
    /* C0 */   2, 2, 2, 2, 2, 2, 2, 2,   2, 2, 2, 2, 2, 2, 2, 2,
    /* D0 */   2, 2, 2, 2, 2, 2, 2, 2,   2, 2, 2, 2, 2, 2, 2, 2,
    /* E0 */   3, 3, 3, 3, 3, 3, 3, 3,   3, 3, 3, 3, 3, 3, 3, 3,
    /* F0 */   4, 4, 4, 4, 4, 4, 4, 4,   5, 5, 5, 5, 6, 6, 7, 8
};

static unsigned char lead_byte_data_mask[] = {
   0x7F, 0, 0x1F, 0x0F, 0x07, 0x03, 0x01
};

static int32_t min_by_len[] = {
   -1, 0x00, 0x80, 0x800, 0x10000ULL
}

// buf must be capable of accommodating at least 4 bytes.
// Returns 0 on EOF or read error.
size_t read_one_utf8_char(FILE* stream, char* buf) {
   int lead = getc(stream);
   if (lead == EOF)
      return 0;

   buf[0] = lead;
   if (lead < 0x80)
      return 1;

   unsigned len = num_most_significant_ones[ lead - 0x80 ];
   if (len == 1 || len > 6)
      goto ERROR;

   unsigned char mask = lead_byte_data_mask[len];
   uint32_t cp = lead & mask;
   for (int i=1; i<len; ++i) {
      int ch = getc(stream);  // Premature EOF or error.
      if (ch == EOF)
         goto ERROR;
      if ((ch & 0xC0) != 0x80) {  // Premature end of character.
         ungetc(ch, stream);
         goto ERROR;
      }
      cp = (cp << 6) | (ch & 0x3F);
      if (i < 4)
         buf[i] = ch;
   }

   if (len > 4 || cp < min_by_len[len] || ( cp >= 0xD800 && cp < 0xE000 ) || cp >= 0x110000)
      goto ERROR;

   return len;

ERROR:
   // Return U+FFFD.
   buf[0] = 0xEF;
   buf[1] = 0xBF;
   buf[2] = 0xBD;
   return 3;
}

getwc 不同,这个 returns UTF-8。

此外,它进行验证,用 U+FFFD 替换非法序列。 (它不会替换非字符。[1][2])我不知道 getwc 是否会那样做。

未测试。

嗯,现在,我已经决定创建一个分配大小为 4 * numberOfCharactersToRead + 1 的缓冲区的函数(因为 UTF-8 字符最多编码为 4 个字节)。

然后我就那么害怕(或者尽可能多,如果我打到 EOF)。然后我只测试高位以了解我是否命中了 1 字节、2 字节、3 字节或 4 字节的字符。我根据需要检查以下字节,并记下它把我放在哪里。

在我读取了所需数量的字符后,我记下了它实际占用的字节数,如果我读取的字符数超过需要,我就会调整回文件指针。我还重新分配()缓冲区以将其缩小到所需的大小。

我很确定它比在将 wchar_t 转换回 UTF-8 之前重复调用 getwc() 更有效(因为,最后,我需要将其保留为 UTF-8 序列,因为我将该数据存储在 Perl 标量中,这就是 Perl 内部执行此操作的方式)。

我以 0 结束读取的 UTF-8“字符串”(因此多了一个字节),以便能够使用标准 C 函数打印它,仅此而已。

此外,为了将“原始二进制文件”与 UTF-8 编码文本一起存储,当我连接它们时,我仅将二进制字节编码为 UTF-8 代码点。这样,在 Perl 下,我可以像对待 UTF-8 字符一样对待字符或“原始字节”。当我需要处理伪装成字符的原始字节时,我只需要取回“代码点”值即可。

我知道我没有在标签中提到 Perl,但这对问题来说无关紧要,所以我提到它只是为了提供一些关于我为什么这样做的背景信息。

感谢所有发布有用建议的人:)