用Python读取带BOM字符的Unicode文件数据

70

我正在使用Python读取一系列源代码文件,但遇到了Unicode BOM错误。这是我的代码:

bytes = min(32, os.path.getsize(filename))
raw = open(filename, 'rb').read(bytes)
result = chardet.detect(raw)
encoding = result['encoding']

infile = open(filename, mode, encoding=encoding)
data = infile.read()
infile.close()

print(data)

如你所见,我正在使用chardet来检测编码,然后将文件读入内存并尝试打印。当Unicode文件包含BOM时,打印语句会失败,并出现以下错误:

UnicodeEncodeError: 'charmap' 编解码器无法在位置0-2编码字符:
字符映射到<undefined>

我猜测它正试图使用默认字符集对BOM进行解码,但失败了。如何从字符串中删除BOM以防止出现此问题?


只是想知道,当数据以UTF-8 BOM开头时,chardet返回的编码是什么?看起来这将是一个相当大的提示,表明编码是UTF-8 :^) - Mark Tolonen
2
@MarkTolonen:这是一个bug,现在已经修复了 - jfs
8个回答

107

无需检查BOM是否存在,utf-8-sig为您管理,并且如果BOM不存在,则与utf-8的行为完全相同:

# Standard UTF-8 without BOM
>>> b'hello'.decode('utf-8')
'hello'
>>> b'hello'.decode('utf-8-sig')
'hello'

# BOM encoded UTF-8
>>> b'\xef\xbb\xbfhello'.decode('utf-8')
'\ufeffhello'
>>> b'\xef\xbb\xbfhello'.decode('utf-8-sig')
'hello'
在上面的示例中,您可以看到utf-8-sig正确解码给定字符串,而不管BOM是否存在。如果您认为读取的文件中可能存在BOM字符的微小机会,只需使用utf-8-sig即可放心使用。

2
直到现在我才了解utf-8-sig - 谢谢!有没有什么好的理由不将BOM去除作为更明显的编码值的默认行为?我的意思是,是否有人真的想从文本文件中读取带有BOM的字符串呢? - AdamF
3
@AdamF,我认为utf-8utf-8-sig之间的区别在于避免出现意外行为或魔法。我很高兴Python使用utf-8将文件解码为原始格式,因为BOM是文件中的一个字符,所以保留它是有意义的。我也很高兴有utf-8-sig,它可以自动处理去掉BOM的情况。虽然我不知道有人希望保留BOM的情况,但我相信存在这样的用例。有了这两种编码方式,我们可以决定自己的预期行为。 - lightswitch05

67

当解码 UTF-16 时应自动剥离 BOM 字符,但不适用于 UTF-8,除非您明确使用 utf-8-sig 编码。您可以尝试以下内容:

import io
import chardet
import codecs

bytes = min(32, os.path.getsize(filename))
raw = open(filename, 'rb').read(bytes)

if raw.startswith(codecs.BOM_UTF8):
    encoding = 'utf-8-sig'
else:
    result = chardet.detect(raw)
    encoding = result['encoding']

infile = io.open(filename, mode, encoding=encoding)
data = infile.read()
infile.close()

print(data)

13
有趣的是,chardet 不会自动执行这个操作。 - Mark Ransom
1
+1 对@MarkRansom的评论:有人知道为什么chardet不能自动完成吗? - Ronan Jouchet
我猜这是因为这实质上从输入流中删除了一个字符。通用编码检测无法知道您是否需要BOM标记。 - abesto
1
@StephenJ.Fuhry,你说它不会影响输入流?它是输入流的一部分。如果解析输入流为UTF-*的应用程序不理解BOM标记,则会产生奇怪的字符。如果解析输入流为UTF-*的应用程序理解BOM标记,则会发生你所描述的情况。在这种情况下,问题正是应用程序不理解BOM。因此,你所描述的情况并不会发生。这正是问题所在。 - abesto
chardetBOM_UTF8错误已经修复。虽然答案两种方式都不一致:utf-16le BOM会导致encoding='UTF-16LE',这会使BOM留在流中(与utf-8-sig剥离流中的BOM不一致)。 - jfs
显示剩余6条评论

28

我根据 Chewie 的回答,编写了一个巧妙的基于 BOM 的检测器。

它可以自动检测编码,在常见情况下,数据可能是已知本地编码或带有 BOM 的 Unicode(这通常是文本编辑器产生的情况)。更重要的是,与 chardet 不同,它不会进行任意猜测,因此可以给出可预测的结果:

def detect_by_bom(path, default):
    with open(path, 'rb') as f:
        raw = f.read(4)    # will read less if the file is smaller
    # BOM_UTF32_LE's start is equal to BOM_UTF16_LE so need to try the former first
    for enc, boms in \
            ('utf-8-sig', (codecs.BOM_UTF8,)), \
            ('utf-32', (codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)), \
            ('utf-16', (codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
        if any(raw.startswith(bom) for bom in boms):
            return enc
    return default

2
此代码片段应该以相反的顺序检查BOM,因为UTF-16的BOM是UTF-32的子集(因此这将错误地将UTF-32编码的字符串识别为UTF-16)。即,参见https://github.com/umit-ozturk/Ansible-Example-AB2018/blob/1a238a27a61fb2c076c00cdeb9037e131e874f8a/lib/python3.6/site-packages/pip/_vendor/html5lib/_inputstream.py#L535-L567。 - Jérémie
1
@ikamen CC-BY-SA 4.0 https://stackoverflow.com/legal/terms-of-service#licensing - ivan_pozdeev

11

chardet2014年10月7日发布的2.3.0版本起可以自动检测BOM_UTF8:

#!/usr/bin/env python
import chardet # $ pip install chardet

# detect file encoding
with open(filename, 'rb') as file:
    raw = file.read(32) # at most 32 bytes are returned
    encoding = chardet.detect(raw)['encoding']

with open(filename, encoding=encoding) as file:
    text = file.read()
print(text)
注意: chardet 可能会返回保留 BOM 的编码 'UTF-XXLE', 'UTF-XXBE'。应该去掉 'LE', 'BE' 以避免问题-- 不过此时检测 BOM 自己也很容易,例如 @ivan_pozdeev 的回答
为了避免在将 Unicode 文本输出到 Windows 控制台时出现 UnicodeEncodeError,请参见 Python、Unicode 和 Windows 控制台

我是否错了,open 实际上没有 encoding 关键字参数? - Yan Foto
1
@YanFoto:它适用于Python 3。在旧版本中,请使用io.open - jfs

9
我发现其他答案过于复杂。有一种更简单的方法,不需要降到二进制文件I/O的较低层次,不依赖于字符集启发式算法(chardet),该算法不是Python标准库的一部分,并且不需要一个很少见的备用编码签名(utf-8-sig与常见的utf-8相比),后者在UTF-16系列中似乎没有类似物。
我发现最简单的方法是处理Unicode中的BOM字符,并让编解码器来完成繁重的工作。只有一个Unicode 字节顺序标记,因此一旦数据转换为Unicode字符,确定是否存在BOM并添加/删除它就很容易了。要读取可能带有BOM的文件:
BOM = '\ufeff'
with open(filepath, mode='r', encoding='utf-8') as f:
    text = f.read()
    if text.startswith(BOM):
        text = text[1:]

这适用于所有有趣的 UTF 编码(例如 utf-8utf-16leutf-16be 等),不需要额外的模块,并且不需要降级到二进制文件处理或特定的 codec 常量。
要写入 BOM:
text_with_BOM = text if text.startswith(BOM) else BOM + text
with open(filepath, mode='w', encoding='utf-16be') as f:
    f.write(text_with_BOM)

这适用于任何编码。UTF-16大端只是一个例子。
顺便说一下,这并不是为了排除chardet。当您不知道文件使用哪种编码时,它可以帮助您。但是添加/删除BOM时不需要它。

当文本文件使用utf-16 LE时,这对我不起作用。读取文件时,我会收到“UnicodeDecodeError:'utf-8'编解码器无法解码位置0中的字节0xff:无效的起始字节”。 - criddell
@criddell 你是否明确使用了上面的代码?如果是这样,你可能尝试使用utf-8编解码器读取一个utf-16be编码的文件。上面的示例展示了两种编码方式,以展示广度。在实践中,如果你使用utf-16be编码写入文件,你必须使用相同的编码方式读取该文件。这种技术经过测试并且有效。示例在此 - Jonathan Eunice
这个解决方案手动复制了utf-8-sig所做的工作。它相当于open(<...>,encoding='utf-8-sig') - ivan_pozdeev
@ivan_pozdeev 当基本编码为utf-8时,是的。但据我所知,UTF-16或UTF-32编码中没有类似的-sig变体。这种技术在所有编码中都可以统一使用,而不仅仅是针对某一个编码。 - Jonathan Eunice
utf-16utf-32是“sig”变体。 - ivan_pozdeev
显示剩余3条评论

2

如果您想编辑该文件,您需要知道使用了哪种BOM。@ivan_pozdeev的这个版本会返回编码和可选的BOM:

def encoding_by_bom(path, default='utf-8') -> Tuple[str, Optional[bytes]]:
    """Adapted from https://dev59.com/ZWYr5IYBdhLWcg3wi6zd#24370596 """

    with open(path, 'rb') as f:
        raw = f.read(4)    # will read less if the file is smaller
    # BOM_UTF32_LE's start is equal to BOM_UTF16_LE so need to try the former first
    for enc, boms in \
            ('utf-8-sig', (codecs.BOM_UTF8,)), \
            ('utf-32', (codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)), \
            ('utf-16', (codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
        for bom in boms:
            if raw.startswith(bom):
                return enc, bom
    return default, None


0
@ivan_pozdeev的答案的变体,适用于字符串/异常(而不是文件)。我正在处理被塞入Python异常中的Unicode HTML内容(请参见http://bugs.python.org/issue2517)。
def detect_encoding(bytes_str):
  for enc, boms in \
      ('utf-8-sig',(codecs.BOM_UTF8,)),\
      ('utf-16',(codecs.BOM_UTF16_LE,codecs.BOM_UTF16_BE)),\
      ('utf-32',(codecs.BOM_UTF32_LE,codecs.BOM_UTF32_BE)):
    if (any(bytes_str.startswith(bom) for bom in boms): return enc
  return 'utf-8' # default

def safe_exc_to_str(exc):
  try:
    return str(exc)
  except UnicodeEncodeError:
    return unicode(exc).encode(detect_encoding(exc.content))

或者,这个更简单的代码可以轻松删除非ASCII字符:

def just_ascii(str):
  return unicode(str).encode('ascii', 'ignore')

1
在内存中的字节串中不应该看到BOM(它应该在解码文件的代码中被剥离)。您的默认值(utf-8)可能会在解码期间引发异常。BOM不能保证编码将成功。改用errors='backslashreplace'。无关的:(1)不要使用裸的except:,它捕获太多,甚至是KeyboardInterrupt。(2)不要使用\和括号,而是使用for enc, boms in [...]: - jfs
@J.F.Sebastian - 我已经改用“except Exception”了。我不确定我理解你的第二条反馈意见。顺便说一下,我从HTML中看到了BOM字符,这些字符随后被塞入了Python异常中。 - Dave Dopson
(1) 如果您期望输入字符混乱,则更有可能在异常处理程序内引发encode()异常。(2) 不要丢失异常信息:使用“backslashreplace”而不是“ignore”错误处理程序。(3) 我的意思是,您可以使用[]括号而不是反斜杠将for循环中的表达式分成多行。 - jfs

0
我在处理BOM标记时更喜欢这个解决方案。
with open(filename, "r", encoding='utf-8-sig') as f:
    text = f.read()

关于文档的资料


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接