【问题标题】:Using awk to detect UTF-8 multibyte character使用 awk 检测 UTF-8 多字节字符
【发布时间】:2020-08-08 05:02:26
【问题描述】:

我正在使用 awk(符号链接到我的机器上的 gawk)来读取文件并获取每行的字符数来测试文件是否是固定宽度的。然后我可以通过-b --characters-as-bytes 选项重新使用以下脚本来查看文件是否按字节固定宽度。

#!/usr/bin/awk -f

BEGIN {
    width = -1;
}

{
    len = length($0);

    if (width == -1) {
        width = len;
    } else if (len != 0 && len != width) {
        exit 1;
    }
}

我想做类似的事情来测试文件中的每一行是否具有相同数量的字节和字符,以假设所有字符都是一个字节(我确实意识到这是主题误报)。挑战是我想一次性浏览文件并在第一次不匹配时突破。有没有办法在 awk 脚本中设置 -b 选项,类似于如何调整 FS。如果这不可能,我愿意接受 awk 之外的选项。如果必须的话,我总是可以在C 写这个,但我想确保没有可用的东西。

效率是我的目标。拥有这些信息将帮助我跳过一个代价高昂的过程,因此我认为这本身并不昂贵。我正在处理可能超过 1 亿行的文件。

澄清

我想要类似上面的东西。像这样的

#!/usr/bin/awk -f
{
    if (length($0) != bytelength($0))
        exit 1;
}

我不需要任何输出。我将触发返回码(bash 中的$?)。因此,如果失败,请退出 1。显然 bytelength 不是一个函数。我只是在寻找一种无需运行两次 awk 即可实现此目的的方法。

更新

sundeep 的解决方案适用于我上面描述的内容:

awk -F '' -l ordchr '{for(i=1;i<=NF;i++) if(ord($i)<0) {exit 1;}}'

我的操作是假设awk 会将具有高于 0x7F 的 Windows 单字节编码的高端字符计算为单个字符,但实际上它根本不计算它。所以字节长度仍然与长度不同。我想我需要用 C 来写一些特定的东西。

结论

所以我认为我在解释我的问题方面做得很差。我收到以 UTF-8 或 Windows 风格的单字节编码(如 CP1252)编码的数据。我想检查文件中是否有任何多字节字符,如果找到则退出。我最初想在 awk 中执行此操作,但我使用可能具有不同编码的文件已被证明是困难的。

所以简而言之,如果我们假设一个文件中只有一个字符:

CHARACTER  FILE_ENCODING     ALL_SINGLE_BYTE   IN_HEX
á          UTF-8             false             0xC3 0xA1
á          CP1252            true              0xE1
a          ANY               true              0x61

【问题讨论】:

    标签: awk


    【解决方案1】:

    您似乎专门针对 UTF-8。事实上,UTF-8 编码中的第一个多字节字符以 0b11xxxxxx 开头,下一个字节必须是 0b10xxxxxx,其中 x 代表任何值(来自 wikipedia)。

    因此,您可以通过匹配十六进制范围来使用sed 检测此类序列,并在找到时以非零退出状态退出:

    LC_ALL=C sed -n '/[\xC0-\xFF][\x80-\xBF]/q1'
    

    即。匹配范围[0b11000000-0b11111111][0b10000000-0b10111111]中的字节。

    我认为\x??q 都是sed 的GNU 扩展。

    【讨论】:

    【解决方案2】:

    最好的答案实际上是 Sundeep 在评论中提供的带有grep 的答案。你应该试着让它发挥作用。下面的答案以类似的方式使用 sed。我可能会删除它,因为它确实没有为grep 的解决方案添加任何内容。

    这个呢?

    [[ -z "$(LANG=C sed -z '/[\x80-\xFF]/d' <(echo -e 'one\ntwo\nth⌫ree'))" ]]
    echo $?
    
    • &lt;(echo -e 'one\ntwo\nth⌫ree') 只是一个包含多字节字符的示例文件
    • 整个 sed 命令执行以下两种操作之一:
      • 如果文件包含多字节字符,则输出空字符串
      • 如果没有,则输出完整文件
    • 如果字符串长度为零,[[ -z string ]] 返回 0 或 1。

    【讨论】:

    • 请参阅问题中的“结论”。这对于单字节的 CP1252 编码文件中的 á 不起作用。 grep 解决方案实际上因错误而窒息。所以你的解决方案是迄今为止最好的。我不知道这是否可能。
    【解决方案3】:

    引自上面同一个维基百科页面:

    回退和自动检测:只有一小部分可能的字节 字符串是有效的 UTF-8 字符串:字节 C0、C1 和 F5 到 FF 不能出现,并且设置高位的字节必须成对出现,并且 其他要求。

    八进制代码表示 xC0 = \300、xC1 = \301 和 xF5 = \365 -> xFF = \377 是无效的 UTF-8。

    知道这个空间不是有效的 UTF-8 非常有用,因为它可以让人们在任何字符串中插入自定义分隔符:

    选择这些字节中的任何一个,例如 \373,一旦使用快速 if 语句来验证该行不存在它,您现在可以执行您选择的自定义文本操作技巧, 使用单字节分隔符,即使涉及将它们插入到单个代码点的 UTF8 字节之间,也不会破坏 unicode .完成逻辑块后,只需使用快速 gsub() 删除它的所有痕迹。

    如果存在该字节(\373\xFB),那么您很可能会遇到二进制文件或部分损坏的 UTF8 文本数据。

    一个用例,例如在我自己的模块中,是一个 UTF-8 代码点级别安全* substr( ) 函数。因此,不要一次手动计算点 1,而是首先使用正则表达式计算任何代码点的最大字节数。假设是 3 字节(因为 4 字节在实践中仍然很少见)。

    然后我在 2 字节的 旁边应用 1 个 \373 填充(我将它填充到 [\302-\337] 的左侧),以及它的 2 个填充,即\373\373,紧挨着ASCII码,瞧,现在所有的UTF8代码点都有一个固定的宽度,所以substr( )变成了一个单纯的乘法练习。

    在这些起点和终点运行字节级substr( ),应用gsub(/[\373]+/, "", s) 丢弃所有填充字节,现在您拥有适用于所有变体的可用* UTF-8-safe substr( ) 函数不支持 unicode 的 awk。这种方式也适用于多行记录,绝对不会影响 FS 和 RS 与记录的交互方式。

    (如果您需要 4 字节,只需填充更多)

    *我没有合并任何奇特的逻辑来解释代码点,这些代码点是分解后的组件,据说为了字符串操作目的而组合在一起作为单个逻辑单元。

    【讨论】:

      【解决方案4】:

      对于不支持 unicode 的 awk 版本,

      gawk -b/ LC_ALL=C /mawk/mawk2 'BEGIN { 
      
         reUTF8="([\\000-\\177]|" \
                "[\\302-\\337][\\200-\\277]|" \
                "\\340[\\240-\\277][\\200-\\277]|" \
                "\\355[\\200-\\237][\\200-\\277]|" \
                "[\\341-\\354\\356-\\357][\\200-\\277]" \
                "[\\200-\\277]|\\360[\\220-\\277]" \
                "[\\200-\\277][\\200-\\277]|" \
                "[\\361-\\363][\\200-\\277][\\200-\\277]" \
                "[\\200-\\277]|\\364[\\200-\\217]" \
                "[\\200-\\277][\\200-\\277])" }'
      

      设置这个正则表达式。您应该能够获得由gnu-wc -lcm 计算的符合 UTF8 的字符总数,即使对于像 mp3s 或 mp4s 或压缩的 gz/xz/zip 这样的纯二进制文件也是如此。只要您的数据本身符合 UTF8 标准,就会按照 Unicode 13 的规定计算它。

      您的区域设置在这里无关紧要,您的平台、操作系统版本、awk 版本或 awk 变体也无关紧要。

      $ echo; time pvE0 < MV84/*BLITZE*webm | gwc -lcm
      
            in0:  449MiB 0:00:10 [44.4MiB/s] [44.4MiB/s] [================================================>] 100%            
      1827289 250914815 471643928
      
      real    0m10.188s
      user    0m10.075s
      sys 0m0.352s
      $ echo; time pvE0 < MV84/*BLITZE*webm | mawk2x 'BEGIN { FS = "^$"} { bytes += lengthB0(); chars += lengthC0(); } END { print --NR, chars+NR, bytes+NR }'
      
            in0:  449MiB 0:00:16 [27.0MiB/s] [27.0MiB/s] [================================================>] 100%            
      1827289=250914815=471643928
      
      real    0m16.756s
      user    0m16.621s
      sys 0m0.449s
      

      正在测试的文件是来自 youtube 的 449 MB .webm 音乐视频剪辑,它是 3840x2160 VP9 + Opus 编解码器。解释性脚本语言与已编译的 C 二进制文件如此接近,这不算太破旧。

      而且它只是 this 对于长得可怕的正则表达式来解释无效字节的速度很慢。如果您非常确定您的数据是完全符合 UTF8 的文本,您可以进一步优化该正则表达式,以便 mawk2 可以比 gnu-wc 和 bsd-wc 运行得更快:

      $  brc; time pvE0 < "${m3t}" | awkwc4m
            in0: 1.85GiB 0:00:14 [ 128MiB/s] [ 128MiB/s] [================================================>] 100%            
        12,494,275 lines     1,285,316,715 utf8 (349,725,658 uc)     1,891.656 MB (  1983544693)  /dev/stdin
      
      real    0m14.753s <—- Custom Bash function that's entirely AWK
      
      $  brc; time pvE0 < "${m3t}" |gwc -lcm
            in0: 1.85GiB 0:00:28 [67.3MiB/s] [67.3MiB/s] [================================================>] 100%            
      12494275 1285316715 1983544693
      
      real    0m28.165s <—— GNU WC
      
      $  brc; time pvE0 < "${m3t}" |wc -lcm
            in0: 1.85GiB 0:00:22 [85.5MiB/s] [85.5MiB/s] [================================================>] 100%            
       12494275 1285316715
      
      real    0m22.181s  <——  BSD WC
      

      ps:“${m3t}”是一个 1.85GB 的平面 .txt 文件,有 1250 万行,每个 13 个字段,用多字节 unicode 字符(其中 3.497 亿)填充到边缘。

      gawk -e(在 unicode 模式下)会抱怨那个正则表达式。为了避免这种烦恼,请使用与上述相同的正则表达式,但扩展为使 gawk -e 快乐

      1. ([\000-\177]|((\302|\303|\304|\305|\306|\307|\310|\311|\312|\313|\314|\315| \316|\317|\320|\321|\322|\323|\324|\325|\326|\327|\330|\331|\332|\333|\334|\335|\336 |\337)|(\340)(\240|\241|\242|\243|\244|\245|\246|\247|\250|\251|\252|\253|\254|\ 255|\256|\257|\260|\261|\262|\263|\264|\265|\266|\267|\270|\271|\272|\273|\274|\275| \276|\277)|(\355)(\200|\201|\202|\203|\204|\205|\206|\207|\210|\211|\212|\213|\214 |\215|\216|\217|\220|\221|\222|\223|\224|\225|\226|\227|\230|\231|\232|\233|\234|\ 235|\236|\237))(\200|\201|\202|\203|\204|\205|\206|\207|\210|\211|\212|\213|\214|\ 215|\216|\217|\220|\221|\222|\223|\224|\225|\226|\227|\230|\231|\232|\233|\234|\235| \236|\237|\240|\241|\242|\243|\244|\245|\246|\247|\250|\251|\252|\253|\254|\255|\256 |\257|\260|\261|\262|\263|\264|\265|\266|\267|\270|\271|\272|\273|\274|\275|\276|\ 277)|((\341|\342|\343|\344|\345|\346|\347|\350|\351|\352|\353|\354|\356|\357)|(\ 360)(\220|\221|\222|\223|\224|\225|\226|\227|\230|\231|\232|\233|\234|\235|\236|\237 |\240|\2 41|\242|\243|\244|\245|\246|\247|\250|\251|\252|\253|\254|\255|\256|\257|\260|\261| \262|\263|\264|\265|\266|\267|\270|\271|\272|\273|\274|\275|\276|\277)|(\361|\362| \363)(\200|\201|\202|\203|\204|\205|\206|\207|\210|\211|\212|\213|\214|\215|\216|\ 217|\220|\221|\222|\223|\224|\225|\226|\227|\230|\231|\232|\233|\234|\235|\236|\237| \240|\241|\242|\243|\244|\245|\246|\247|\250|\251|\252|\253|\254|\255|\256|\257|\260 |\261|\262|\263|\264|\265|\266|\267|\270|\271|\272|\273|\274|\275|\276|\277)|(\364 )(\200|\201|\202|\203|\204|\205|\206|\207|\210|\211|\212|\213|\214|\215|\216|\217) )(\200|\201|\202|\203|\204|\205|\206|\207|\210|\211|\212|\213|\214|\215|\216|\217| \220|\221|\222|\223|\224|\225|\226|\227|\230|\231|\232|\233|\234|\235|\236|\237|\240 |\241|\242|\243|\244|\245|\246|\247|\250|\251|\252|\253|\254|\255|\256|\257|\260|\ 261|\262|\263|\264|\265|\266|\267|\270|\271|\272|\273|\274|\275|\276|\277){2})李>

      【讨论】:

        【解决方案5】:

        注意:此答案中的代码可用于检测有效的 UTF-8 多字节字符。如果存在无效的 UTF-8 字节序列,它也会失败。但是,它保证您的文件是 UTF-8。所有有效的 UTF-8 代码也是有效的 CP1252,但并非所有的 CP1252 都是有效的 UTF-8。

        所以看起来这可能是一个小众问题。对我来说,这意味着有时间求助于 C。这应该可行,但本着问题的精神,我不会接受它,以防有人能提出 awk 解决方案。

        这是我称为 hasmultibyte 的 C 解决方案:

        #include <stdio.h>
        #include <stdlib.h>
        
        void check_for_multibyte(FILE* in) 
        {
                int c = 0;
                while ((c = getc(in)) != EOF) {
                        /* Floating continuation byte */
                        if ((c & 0xC0) == 0x80)
                                exit(5);
        
                        /* utf8 multi-byte start */
                        if ((c & 0xC0) == 0xC0) {
                                int continuations = 1;
                                switch (c & 0xF0) {
                                case 0xF0:
                                        continuations = 3;
                                        break;
                                case 0xE0:
                                        continuations = 2;
                                }   
                                int i = 0;
                                for (; i < continuations; ++i)
                                        if ((getc(in) & 0xC0) != 0x80)
                                                exit(5);
        
                                exit(0);
                        }   
                }   
        }
        
        int main (int argc, char** argv)
        {
                FILE* in = stdin;
                int i = 1;
                do {
                        if (i != argc) {
                                in = fopen(argv[i], "r");
                                if (!in) {
                                        perror(argv[i]);
                                        exit(EXIT_FAILURE);
                                }   
                        }   
        
                        check_for_multibyte(in);
        
                        if (in != stdin)
                                fclose(in);
                } while (++i < argc);
        
                return 5;
        }
        

        在 shell 环境中,你可以这样使用它:

        if hasmultibyte file.txt; then
            ...
        fi
        

        如果你想在管道末端使用它,它也会从标准输入读取文件:

        if cat file.txt | hasmultibyte; then
            ...
        fi
        

        测试

        这是程序的测试。我在其中创建了 3 个名为 Hernández 的文件:

        name_ascii.txt  - Uses a instead of á.
        name_cp1252.txt - Encoded in CP1252
        name_utf-8.txt  - Encoded in UTF-8 (default)
        

        您看到的�是由于终端期望的无效 UTF-8 造成的。实际上就是 CP1252 中的字符 á。

        > file name_*
        name_ascii.txt:  ASCII text
        name_cp1252.txt: ISO-8859 text
        name_utf-8.txt:  UTF-8 Unicode text
        > cat name_*
        Hernandez
        Hern�ndez
        Hernández
        > hasmultibyte name_ascii.txt && echo multibyte
        > hasmultibyte name_cp1252.txt && echo multibyte
        > hasmultibyte name_utf-8.txt && echo multibyte
        multibyte
        

        更新

        此代码已从原始代码更新。它已更改为读取多字节字符的第一个字节并读取该字符应该是多少字节。这可以确定如下。

        first byte    number of bytes
        110xxxxx      2
        1110xxxx      3
        11110xxx      4
        

        这种方法更可靠,并且会减少不准确之处。原始方法搜索11xxxxxx 形式的字节并检查下一个字节是否有连续字节(10xxxxxx)。鉴于 CP1252 文件中的 â„x 之类的内容,这将产生误报。在二进制中,这是11100010 10000100 01111000。第一个字节声称一个 3 个字节的字符,第二个是一个连续字节,但第三个不是。这不是一个有效的 UTF-8 序列。

        额外测试

        > # create files
        > echo "â„¢" | iconv -f UTF-8 -t CP1252 > 3byte.txt
        > echo "Ââ„¢" | iconv -f UTF-8 -t CP1252 > 3byte_fail.txt
        > echo "â„x" | iconv -f UTF-8 -t CP1252 > 3byte_fail2.txt
        
        > hasmultibyte 3byte.txt; echo $? 
        0
        > hasmultibyte 3byte_fail.txt; echo $? 
        5
        > hasmultibyte 3byte_fail2.txt; echo $? 
        5
        

        【讨论】:

        • 如果我错了,请纠正我,但我认为对于 4 个字节,0 也以相同的方式向下滑动:1111 0xxx ?
        • @RAREKpopManifesto 你是对的。代码确实这样做了,所以我修复了描述
        【解决方案6】:

        == 更新 = 9-20-21 ========

        事实证明,甚至根本不需要预切片。

        gawk -e 'BEGIN { ORS = ":";
        
           a0 = a = "\354\236\274"; 
           n = 1;                  # this # is for how many bytes 
                                   # you'd like to see                
           b1 = b = \
               sprintf("%.*s",n + 1,a = "\301" a); 
        
           sub("^"b,   "", a) 
           sub(/^\301/,"", b) 
           sub("\236|\270|\271|\272|\273|\274|\275|\276|\277",":&", a)
        
               # for that string, 
               # chain up every byte in \x80-\xBF range, 
               # but make sure not to tag on "( )" at the 2 ends.
               # that will make the regex a lot slower,
               # for reasons unclear to me 
        
           printf(":" a0 "|" b1 "|"  b ORS a  "|") } ' | odview
        

        产生这个输出

             :  잼  **  **    | 301 354  | 354   :  236   : 274  |        
           072 354 236 274 174 301 354 174 354 072  236 072 274 174        
             :   ?  9e   ?   |   ?   ?  |   ?    :  9e   :   ?   |        
             58 236 158 188 124 193 236 124 236 58  158  58 188 124        
             3a  ec  9e  bc  7c  c1  ec 7c  ec  3a   9e  3a  bc  7c  
        

        瞧 ~~ 只使用 sprintf() 和 [g]sub(),每个单独的字节都触手可及,即使是在 unicode 代码中,根本不需要使用数组。

        ============================

        由于我们在讨论 awk 和 UTF8 的主题,所以快速分享一下技巧(仅在多字节部分):

        如果您处于 gawk unicode-aware 模式,并且想要访问几个 utf8 字符的单个字节(例如执行 URL encoding,单独分析它们,或者像 packing a DWORD32),但不想使用gsub(//,"&amp;"SUBSEP) 的高成本方法,然后 拆分 成一个 数组,一个 quick-n-dirty 方法只是

           gsub(/\302|\303|\304|\305|\306|\307|\310|\311|\312\ 
                |\313|\314|\315|\316|\317|\320|\321|\322|\323|\324
                |\325|\326|\327|\330|\331|\332|\333|\334|\335|\336
                |\337|\340|\341|\342|\343|\344|\345|\346|\347|\350
                |\351|\352|\353|\354|\355|\356|\357|\360|\361|\362
                |\363|\364/, "&\300")
        
        
        
          잼  **  **    =    354 *300*<---236 274                                 
          354 236 274  075  354 300    236 274                                
           ?  9e   ?    =    ?   ?      9e   ?                                
          236 158 188  61   236 192    158 188                                
           ec  9e  bc  3d    ec  c0     9e  bc  
                                  
        

        基本上,“slicing”在前导字节和尾随字节之间正确编码了 UTF8 字符。在我个人的反复试验中,我发现 UTF8 (xC0 xC1 xF5-xFF) 中非法的 13 个字节最适合这项任务。

        说原来的 var 叫做 b3。然后使用

        b2 = sprintf("%.3s",b3) 提取出\354 \300 \236

        sub(b2,"",b3) 所以现在 b3 将只有 \274

        b1 = sprintf("%.1s", b2) b1 现在只是 \354

        sub(b1"\300","",b2) 最后,b2 实际上只是 \236

        的第二个字节

        这个痛苦乏味的过程的原因是 1 gsub 将每个字节加倍,然后另一个完整的数组 split() 加上另外 3 个数组条目查找可能会稍微慢一些。如果你想先计算字节数,

           lenBytes = match($0, /$/) - 1; 
                                       
            # i only recently discovered 
            # this trick that works decently well
        

        匹配一个甚至适用于与 Unicode 没有相似之处的随机字节集合,gawk 很高兴为您提供确切的结果。这是对随机字节运行 match() 并且不会从 gawk 收到错误消息的唯一有意义的方法。 (另一个是 match($0,/^/) 但这很有用。尝试做 .* / . / .+ all 最终会在语言环境中出现错误字符。

        ** 不要使用索引()。如果您需要确切的位置,则只需拆分为数组。

        如果你需要做字节级子串

        不要在 gawk unicode-mode 中直接使用 substr() 作为随机字节。

        请改用sprintf("%.53s",b3)。 在切片之前,该语法为您提供 53 个 unicode 字符。 切片后,距离字符串开头 53 个字节。

        我什至把它们自己锁起来,好像它们是 gensub() 即使它是好的 ole' sub() :

        if (sub(reANY340357,"&\301",z)||3==b) {
            sub((x=sprintf("%.1s",(y=sprintf("%.3s",z))sub(y,"",z)))"\301","",y)
        

        一旦你完成了你需要的一切,一个快速的 gsub(/\300|\301/, "") 将恢复你正确的 UTF8 字符串。

        希望这有用 =)

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2012-01-20
          • 2011-07-25
          • 1970-01-01
          • 1970-01-01
          • 2013-03-09
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多