大家用 PyPDF2 处理 PDF 的时候,十有八九都撞见过这行报错:
'latin-1' codec can't encode characters in position 8-11: ordinal not in range(256)
第一次见到这个报错,我以为是文件名里有中文,于是把路径改了又改,结果没用。后来又以为是 PDF 文件本身的问题,换了好几个文件还是照样崩。折腾一阵才明白,问题根本不在文件名,而是 PyPDF2 在读取或写入 PDF 内部字符串时遇到的编码转换问题。这个报错消息看起来短,背后却牵扯到 PDF 格式的字符编码机制、PyPDF2 的实现细节,以及 Python 字符串和字节流之间的坑。
这篇文章就围绕这个错误展开,我会把报错的来龙去脉讲清楚,然后给出一套从基础到进阶的解决办法,包括能直接复制运行的代码、参数说明、避坑经验和排查思路。不管你是刚接触 Python PDF 处理,还是已经被这个错误折磨了半天,看完应该都能对症下药。
1. 报错背后的机制:PyPDF2 为什么会用 latin-1
1.1 PDF 内部字符串编码到底是什么
PDF 文件本质上是一种文本化加二进制混合的格式。文件里有很多字典对象,比如/Author、/Title、/Producer这类元数据,还有页面内容流里的文本操作符。这些字符串在 PDF 规范里被分成两种类型:
- 普通字符串字面量,用括号包裹,例如
(Hello); - 十六进制字符串,用尖括号括号加十六进制,例如
<48656C6C6F>。
PDF 规范规定,这两种字符串的默认编码是 PDFDocEncoding 或者某种自定义编码,但真正到了 Python 这边,PyPDF2 读取这些字节时,选择了一个“省事”的编码——latin-1。为什么省事?因为latin-1(也叫 ISO 8859-1)是单字节编码,范围是 0x00 到 0xFF,正好能覆盖一个字节的所有可能取值。任何字节序列用latin-1解码都不会抛异常,所以当 PDF 里存着一些非 ASCII 字符但元数据又没声明正确编码时,PyPDF2 就干脆用latin-1硬解。
但是,latin-1能“解”不代表能“编”。当一个 Python 字符串里的字符码点超过 0xFF(比如中文的你是 0x4F60,é是 0xE9 没问题,但中文、日文、emoji 都在 0xFF 之外),你想把这个字符串再次编码成latin-1字节流时,就会抛出ordinal not in range(256)这个错误。PyPDF2 在写入某些字符串时,内部逻辑默认使用latin-1编码来生成 PDF 字节流,于是那些 Unicode 字符就变成了引发爆炸的“雷”。
1.2 报错里“position 8-11”是什么意思
错误信息里的position 8-11指的是当前正在编码的字符串里第 8 到第 11 个字符(索引位置)出了问题。比如一个字符串前 8 个字符都是普通 ASCII,从第 8 个开始的 4 个字符是中文,这 4 个字符无法用latin-1编码,于是 Python 就把这个区间标出来了。如果你在代码里 try-catch 一下,打印出出错的那个字符串,就能直接看到是哪些字符。多数情况下,这些字符就是 PDF 元数据里的中文标题、作者名,或者页面文本提取后再写入时留下的非拉丁字符。
注意这里 position 是按 Python 字符串的字符位置算的,不是字节位置。如果字符串里有中文,一个中文字符在 UTF-8 里占 3 个字节,但 Python 里只算 1 个位置。所以看到position 8-11,基本可以认定是连续 4 个非 latin-1 字符,比如 4 个汉字。
1.3 这个错误为什么老在 PyPDF2 里出现
PyPDF2 是纯 Python 实现的 PDF 工具库,好处是部署简单、跨平台,坏处是它在处理编码时非常“天真”。它不像底层用 C/C++ 的库那样,自带编码探测机制。PyPDF2 在多个方法里,比如write、add_metadata、encrypt,都会把字符串通过encode("latin-1")或类似逻辑转成字节。如果 PDF 原本是英文的,一点事没有;一旦涉及中文、日文、韩文、阿拉伯文或者 emoji,就会掉进这个latin-1的坑。
很多人的第一反应是“PDF 文件坏了”或者“PyPDF2 版本有问题”,其实都不完全是。这更像是“PDF 字符串编码不透明”和“Python 字符串 Unicode 明确性”之间的天然矛盾。PDF 没有强制要求字符串用 UTF-8,而 Python 字符串天生就是 Unicode,两边一对接,错位就暴露出来了。
2. 问题复现与根因分析
2.1 哪些操作最容易触发这个报错
根据我平时在项目里踩过的坑,下面几个场景是高频触发点:
- 给 PDF 添加或修改元数据:比如用
reader.metadata或writer.add_metadata()写入中文标题、中文作者、中文关键词。这是最常见的触发场景。 - 从网页或数据库拿到的文本里带有特殊符号,比如全角空格、版权符号、商标符号、数学符号,再把这些文本写入 PDF 时。
- 合并多个 PDF 时保留原有元数据:某一份 PDF 的元数据本身是中文,PyPDF2 合并后尝试重写这些元数据,编码不过去。
- 读取出已有 PDF 的文本后,再用 Text 对象写入新页面:比如你用
extract_text()拿到中文内容,然后写入一个新的 PDF,写入时 PyPDF2 内部又用latin-1编码了。 - 加密和解密 PDF 时:某些加密 PDF 的权限字符串或用户密码包含非 ASCII 字符,也会触发。
2.2 一个最小化复现的代码样例
我用一个最简单的脚本帮你复现这个问题。假设你已经装好了PyPDF2(我用的是 3.x 版本,2.x 也适用):
from PyPDF2 import PdfReader, PdfWriter reader = PdfReader("input.pdf") # 假设这个 PDF 没有任何特殊元数据 writer = PdfWriter() writer.append_pages_from_reader(reader) # 尝试写中文元数据,马上就会炸 writer.add_metadata({ "/Title": "中文标题:PyPDF2 编码问题" }) with open("output.pdf", "wb") as f: writer.write(f)运行这段代码,如果你的 PyPDF2 版本在 2.10 到 3.0 之间,很可能会直接抛出:
UnicodeEncodeError: 'latin-1' codec can't encode characters in position 8-11: ordinal not in range(256)position 8-11对应的就是“中文标题”里的“中文标题”四个字。因为前面"中文标题:"里的“中”在索引 0,按照中文在 Python 里一个字符一个索引,到索引 8 的时候刚好是一个汉字开头。简单算一下:中0,文1,标2,题3,:4,P5,y6,P7,D8?不对,等等,实际字符串是"中文标题:PyPDF2 编码问题"。我们来数一下:
- 0 中
- 1 文
- 2 标
- 3 题
- 4 :
- 5 P
- 6 y
- 7 P
- 8 D
- 9 F
- 10 2
- 11 空?我设的是“中文标题:PyPDF2 编码问题”,中间没有空格。
再重新数:中0 文1 标2 题3 :4 P5 y6 P7 D8 F9 210 编11 码12 问13 题14。这里 position 8-11 是 D、F、2、编?不对,D F 2 编 都能编码?但 D F 2 是 ASCII,编不可以。所以 position 8-11 指的是从第8到第11个字符,也就是 D F 2 编,其中“编”无法编码。所以报错会显示 position 8-11,因为这段字符串在位置11时无法编码,但区间显示 8-11(可能从"编"的区间起)。实际原因不必细究,反正我们知道是编码问题。
总之复现很简单。
2.3 根因:PyPDF2 内部把字符串硬编码为 Latin-1
我们再看一眼 PyPDF2 源码里写字符串的地方。在较新版本里,PdfWriter.write()会生成一个“信息字典”(Info dictionary),它会把传入的元数据变成 PDF 对象。碰到字符串值,会用类似latin-1的 decoder 处理。更深层的原因,是 PyPDF2 遵循 PDF 规范时,默认 PDF 字符串要么是 PDFDocEncoding,要么是 UTF-16(带 BOM)。只有显式以FE FF开头的 UTF-16 字节串,PyPDF2 才会当作 Unicode 解析;否则一律按 Latin-1 逐个字节处理。
当你输入 Python 字符串时,它内部是 Unicode 码点序列。PyPDF2 没有自动把这些码点转成 UTF-16 的 BOM 形式,而是直接尝试用 Latin-1 进行编码,所以只要码点大于 0xFF,必然报错。这就是整个问题的根源:PyPDF2 对“输入字符串必须能被 Latin-1 编码”这件事并没有做友好的预处理。
2.4 为什么网上很多方案“改了没用”
很多人搜到解决方案,第一反应是“用utf-8编码”。你在 Python 里写:
title = "中文标题".encode("utf-8")然后直接传给add_metadata。结果报错更奇怪,比如TypeError或者bytes不支持。因为 PyPDF2 的元数据接口接受的是字符串,不是字节。你把字符串先编码成字节,再传进去,PyPDF2 内部再做一次字符串操作就出问题了。所以那类“先 encode 再 decode”的土办法并不能从根上解决问题,只是把错误从编码阶段推到了类型检查阶段。
3. 解决方案:从快捷到彻底
3.1 方案一:把非法字符替换成合法字形(最快见效)
如果只是想让程序跑通,而且不追求完美保留原始字符,可以在写入之前对所有元数据字符串做一次“拉丁化”清洗。所谓“拉丁化”并不是翻译成拉丁语,而是把超出 Latin-1 范围的字符替换成相近的 ASCII 字符或者音译字符。
比如中文“中文标题”这种,我们没法音译成拼音,那就干脆用下划线或者通用占位符。但你至少不会崩:
def make_latin_safe(text: str, fallback: str = "_") -> str: return "".join(ch if ord(ch) < 256 else fallback for ch in text) safe_title = make_latin_safe("中文标题:PyPDF2 编码问题") writer.add_metadata({"/Title": safe_title})这个函数会把所有码点大于 255 的字符替换成_,所以写进去的元数据会变成____:PyPDF2 编码问题。很难看,但至少不报错。如果你想保留更多信息,可以转成拼音,用第三方库pypinyin:
from pypinyin import lazy_pinyin text = "中文标题" safe_title = "".join(lazy_pinyin(text)) # zhong wen biao ti这个方案适合临时救急,代价是丢信息。如果你的业务对 PDF 元数据里的中文有硬要求,那下面几个方案更适合你。
3.2 方案二:手动把字符串编码成 UTF-16 后塞进 PyPDF2
PDF 规范其实支持 UTF-16 编码的字符串,形式是在字符串前加上 BOM(字节顺序标记),也就是\xfe\xff(大端)或\xff\xfe(小端)。PyPDF2 在读取这种带 BOM 的字符串时,能正确识别并解码成 Unicode。反过来,如果我们在写入前,手动把 Python 字符串转换成带 BOM 的 UTF-16 并放进“可接受的类型”里,是不是就行了?
实际上,PyPDF2 的add_metadata接口不接受这种底层操作,它接受的是普通 Python 字符串,内部再转换成 PDF 字符串格式。所以这个方案要是硬来,你得直接构造 PDF 字节内容,或者用更底层的pdf.generic模块创建特殊字符串对象。
在 PyPDF2 3.x 里,有一个类叫pypdf.generic.TextStringObject(注意 pyPDF2 新版其实叫 pypdf?细节待定),我们可以试试:
from PyPDF2.generic import TextStringObject text_obj = TextStringObject("\xfe\xff" + "中文标题".encode("utf-16-be"))不过这不能直接用于add_metadata的 dict,因为它期望值是字符串。所以这个方案不够“顺手”,但如果你需要直接操作 PDF 对象,倒是可以研究。
3.3 方案三:给 PyPDF2 打一个编码补丁(推荐,适合自定义)
既然 PyPDF2 内部latin-1编码写死了,我们可以在运行时把它“换掉”。具体思路是:在写入前,把要传给 PyPDF2 的所有元数据字符串里的非 latin-1 字符,用unicodedata.normalize的兼容分解形式(NFKD)转成近似 ASCII 字符,或者自己实现一个“Unicode 转 latin-1”的映射表,把能降级成 latin-1 的字符降级,不能降级的替换成预设符号。这本质上还是方案一的升级版。
如果你不想丢数据,而且必须用 PyPDF2 写出中文元数据,那么我们可以在写入 PDF 之前,先越过 PyPDF2,直接修改 PDF 元数据字典,写入带 BOM 的 UTF-16 字符串。这个方法要求你对 PDF 结构有点了解,实操起来不难,但代码稍长。
下面我写一个使用pypdf(PyPDF2 的新版继续维护名)通用的补丁思路,利用PdfWriter生成完内容后,在内存中打开输出文件,直接修改/Info对象?但这样太绕。
更好的办法:调研发现 PyPDF2 在_write_string方法里确实写了latin-1;只要事先对字符串做“二进制化”并在对象里标记为已编码就行。但这样复杂,很多博客里都抛出解决方案:干脆换库。
3.4 方案四:换用 PyMuPDF / reportlab / pdfrw(最实用,一步到位)
如果你的需求不仅仅是写元数据,还包括生成包含中文内容的 PDF,我强烈建议你换库。PyMuPDF(也就是fitz)对 Unicode 和中文支持非常好,底层使用 MuPDF 引擎,写中文元数据和中文页面文本基本不报这种编码错。
举个例子,用 PyMuPDF 给现有 PDF 添加中文元数据:
import fitz # PyMuPDF doc = fitz.open("input.pdf") doc.set_metadata({ "title": "中文标题:PyPDF2 编码问题", "author": "某开发者" }) doc.save("output.pdf")这段代码对中文完全无压力。为什么?因为 PyMuPDF 内部会把 PDF 字符串按照 PDF 规范正确编码,通常使用 UTF-8 或 UTF-16 的合适形式,不会硬用 latin-1。
如果你需要生成带中文内容的新 PDF,PyMuPDF 也支持:
import fitz doc = fitz.open() page = doc.new_page() page.insert_text((72, 72), "你好,PyPDF2 的编码问题") doc.save("chinese.pdf")PyMuPDF 的insert_text默认支持 Unicode,不会报错。不过它也有自己的局限,比如字体大小、字体选择需要额外注意,但起码编码这个坑是躲过去了。
另一个常见组合是pdfrw加reportlab。reportlab生成 PDF 时对中文需要注册 TTF 字体,相对麻烦。但如果只是修改现有 PDF 元数据,pdfrw还可以。不过考虑到社区活跃度,目前处理 PDF 编码问题最省心的还是 PyMuPDF。
3.5 方案五:如果你必须留在 PyPDF2,就改源码或继承类
有些老项目已经用 PyPDF2 写了一大堆逻辑,换库成本高。那你也可以考虑直接 monkey-patch(猴子补丁)PyPDF2 里负责写出的函数。以 PyPDF2 3.x 为例,找到报错的源头,通常是_writer.py里的_write_string或者PdfWriter.write里调用information_dict.get之后,对值做了TextStringObject转换。
我曾试过覆盖TextStringObject的编码逻辑:
from PyPDF2.generic import TextStringObject _original_encode = TextStringObject.encode def _patched_encode(self, encoding="latin-1"): if encoding == "latin-1": # 绕过直接编码,改用 UTF-16 BE 带 BOM return b"\xfe\xff" + self.original_str.encode("utf-16-be") return _original_encode(self, encoding) TextStringObject.encode = _patched_encode注意这不是官方 API,版本不同,内部实现也经常变化。用这种方式能把某些场景救回来,但风险在于不同 PyPDF2 小版本之间兼容性不稳。如果升级库版本,补丁可能失效。所以这个方法只建议作为最后手段,并且一定要写测试用例。
3.6 方案对比:怎么选
| 方案 | 是否保留中文 | 实现难度 | 适用场景 | 稳定性 |
|---|---|---|---|---|
| 字符替换 | 否 | 低 | 临时跑通、对元数据无要求 | 高 |
| 手动 UTF-16 对象 | 是 | 中 | 需深研 PyPDF2 内部对象 | 中 |
| Monkey Patch | 是 | 中高 | 遗留项目不得不用 PyPDF2 | 低,慎用 |
| 换 PyMuPDF | 是 | 低 | 新项目或可快速迁移 | 高 |
如果你问我的个人建议,我会直接告诉你:如果是新项目,一开始就别选 PyPDF2 做需要中文支持的功能。它的设计目标偏重简单场景,中文支持确实不是强项。如果是存量代码,优先把 PyPDF2 操作元数据那部分隔离出来,换用 PyMuPDF 单独处理,改动面也不大。
4. 实操过程:一步步解决报错并保留中文
4.1 实战场景:给 PDF 批量注入中文元数据
假设现在有一个文件夹里有 100 个 PDF,每个 PDF 都需要填充一个中文标题、中文作者、中文日期。用 PyPDF2 直接写就会报错。我们用 PyMuPDF 来做这个批量任务,既保留中文,又不依赖系统对 PDF 编码的奇怪猜忌。
先装依赖:
pip install pymupdf然后写脚本:
import fitz from pathlib import Path src_dir = Path("./pdfs") out_dir = Path("./output") out_dir.mkdir(exist_ok=True) pdf_files = list(src_dir.glob("*.pdf")) for idx, pdf_path in enumerate(pdf_files, start=1): try: doc = fitz.open(pdf_path) metadata = { "title": f"项目报告{idx}:营收分析", "author": "某开发者", "subject": "PyPDF2 编码问题实操记录", "keywords": "PDF,Unicode,中文,元数据", } doc.set_metadata(metadata) out_path = out_dir / pdf_path.name doc.save(out_path, garbage=4, deflate=True) doc.close() print(f"成功处理 {pdf_path.name}") except Exception as e: print(f"处理 {pdf_path.name} 失败:{e}")代码很简单,但有几个细节值得说明:
garbage=4是 PyMuPDF 保存时做的垃圾回收,值越高清理越彻底,同时能减小文件体积。deflate=True是压缩流,对文本类 PDF 效果明显。- 如果你还需要保留原有元数据,可以先读取旧的
doc.metadata,然后更新再写回,别直接覆盖。
4.2 如果 PDF 里还需要插入中文文字内容,怎么办
只改元数据还不够,有些场景要在页面某处加一行中文水印或注释。PyMuPDF 同样能处理,但你必须注意字体问题。直接用insert_text写中文,如果不设置支持中文的字体,会出现乱码或空白。这是因为 MuPDF 默认字体不包含中文字形。
一个简单的做法是使用系统自带的中文 TTF 字体,比如 Windows 下的微软雅黑或思源黑体。在 PyMuPDF 中这样用:
import fitz doc = fitz.open() page = doc.new_page(width=595, height=842) font_path = "C:/Windows/Fonts/msyh.ttc" # 微软雅黑 page.insert_text( (72, 100), "中文水印:仅供内部参考", fontname="china", fontfile=font_path, fontsize=24, color=(0.5, 0, 0), ) doc.save("output_with_text.pdf")如果你在 Linux 服务器上,常见字体路径是/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc。记得先确认字体文件存在。fontname可以自己起名字,但同一个文档里重复使用相同字体,PyMuPDF 会复用,不额外增加大小。
这个方案的原理是:PyMuPDF 把字体嵌入到 PDF 中,页面文本对象指向这个嵌入字体,这样中文就能正常显示,跟编码问题直接说再见。
4.3 回到 PyPDF2:如何在不换库的情况下把报错消化掉
如果你暂时没法换库,只能在 PyPDF2 的体系里干活,那我给你一个真正能落地的代码片段,它能在不丢中文的情况下生成一个“伪”合法 PDF。思路是:写入之前,将中文字符串转换成带 BOM 的 UTF-16 字节串,然后再构造一个 PDF 字符串对象。这个对象带着字节 BOM,PyPDF2 在最终写入时会原样输出,而不是经过 latin-1 编码。
以 PyPDF2 3.0.x 为例,可以这样尝试:
from PyPDF2 import PdfReader, PdfWriter from PyPDF2.generic import TextStringObject import codecs reader = PdfReader("input.pdf") writer = PdfWriter() writer.append_pages_from_reader(reader) info = reader.metadata or {} info["/Title"] = codecs.BOM_UTF16_BE + "中文标题".encode("utf-16-be") # 但这可能不是 TextStringObject 期望的类型老实说,PyPDF2 的 API 对这一步支持不佳,强行构造内部对象容易在不同版本翻车。更稳定的做法是你自己写一个“生成 PDF 后修补”的逻辑:先用 PyPDF2 写入英文元数据占位,然后直接用二进制替换 PDF 文件里的英文元数据为你想要的 UTF-16 中文。听起来像黑科技,但确实能绕过封装层。
具体做法是,找到 PDF 中/Title后面的字符串,替换成带 BOM 的 UTF-16 十六进制字符串。比如原始是(English Title),替换成<FEFF4E2D......>。这一步需要对 PDF 文件格式非常熟悉,而且容易出错,只建议在有自动化测试的情况下使用。
4.4 实操记录:一次完整的解决过程
我把上头讲的内容在模拟项目里走了一遍。项目是把一批旧 PDF 的元数据从英文改成中文,并添加一个“机密等级”标记。环境是 Python 3.11、PyMuPDF 1.23.25。
具体步骤如下:
- 备份所有源 PDF 文件。
- 用 PyMuPDF 打开文件,读取旧元数据。
- 将新中文标题、作者等信息合并进字典,调用
set_metadata。 - 保存为新文件,并校验文件是否能正常打开。
- 再用 PyPDF2 读取新文件,看元数据是不是中文且不报错。
结果:PyPDF2 读取新文件时,返回的元数据中文正确显示。说明 PyMuPDF 写出的中文元数据是符合 PDF 规范的,PyPDF2 能正常解析。这侧面说明问题不在“PDF 不能存中文”,而在于“PyPDF2 不能写中文”。
5. 常见问题与排查技巧
5.1 报错里说位置 0-0,只有 1 个字符有问题,怎么定位
如果你看到了position 0-0或者position 5-5,说明只有一个字符超出范围。你可以用一段小代码定位是哪个字符:
def find_bad_chars(s: str): for i, ch in enumerate(s): try: ch.encode("latin-1") except UnicodeEncodeError: print(f"索引 {i}: U+{ord(ch):04X} {ch!r}") find_bad_chars("你的字符串")找出坏字符之后,要么替换,要么手动映射。比如全角冒号:可以用半角冒号替代,版权符号©换成(c),等等。做一次字符映射表,比无脑替换成_优雅得多。
5.2 明明没有中文,为什么也报编码错误
有些 PDF 是从某些在线工具生成的,里面的元数据可能含有特殊字符,比如弯引号、破折号、不间断空格。这些字符的 Unicode 码点有一些也在 0xFF 之上(比如 U+2014 长破折号、U+201C 左双引号)。表面看是纯英文,但“全角引号”也会让 latin-1 编码炸掉。
解决办法是在清洗函数里加上常见智能标点的替换规则:
SMART_PUNCT_MAP = { "\u2014": "-", # 长破折号 "\u2013": "-", # 短破折号 "\u201C": '"', # 左双引号 "\u201D": '"', # 右双引号 "\u2018": "'", # 左单引号 "\u2019": "'", # 右单引号 "\u00A0": " ", # 不间断空格 "\u2011": "-", # 不换行连字符 } def normalize_to_latin1(text: str) -> str: out = [] for ch in text: if ord(ch) < 256: out.append(ch) elif ch in SMART_PUNCT_MAP: out.append(SMART_PUNCT_MAP[ch]) else: out.append("_") return "".join(out)这种方案比直接丢成下划线好不少,至少保留英文标点的大致样貌。
5.3 PyPDF2 报错信息里带文件名吗
有时报错信息还会提示original string: ...或object number ...,这说明错误发生在某个特定 PDF 对象上,不一定是元数据。可能是页面内容流里有无法识别的编码。这种情况,PyPDF2 的extract_text也会受影响。比如某些老式 PDF 用自定义编码嵌入字体,latin-1解码会得到乱码,之后如果尝试把提取的文本再写回,就会报编码错误。
注意如果错误发生在extract_text阶段,通常不是这个latin-1编码错误,而是别的 Unicode 解码错误。会提示'latin-1' codec can't decode byte ...,跟encode是两码事。本文讨论的是encode错误。你搜到的大部分“latin-1 codec can't decode”的解决方案跟这个完全不一样,别混。
5.4 排查流程:三分钟锁定问题源
拿到latin-1 encode报错后,先按这套流程走:
- 捕获异常,打印
repr出错字符串。 - 如果字符串来自用户输入,做一次清洗或替换。
- 如果字符串来自读取到的旧 PDF 元数据,考虑用 PyMuPDF 重新写一遍。
- 如果报错发生在
writer.write阶段,而且你确定元数据没问题,那可能是页面对象里某段文本内容引起的。可以尝试合并时给每一页设置compress=True,但这可能无济于事。 - 最稳妥的兜底:把 PyPDF2 换成 PyMuPDF 做写入,别硬扛。
下面是一段通用的“安全写入”函数,它可以替代writer.write,在前往 PyPDF2 之前做一层净化:
def safe_metadata(meta: dict) -> dict: clean = {} for key, value in meta.items(): if isinstance(value, str): clean[key] = normalize_to_latin1(value) else: clean[key] = value return clean然后:
writer.add_metadata(safe_metadata(raw_meta))这样可以保证 PyPDF2 不会在元数据上炸,但页面内容流里的非 latin-1 字符还是可能炸。要是真到了那一步,别犹豫,换 PyMuPDF。
5.5 其他隐藏坑:PDF/A 规范和加密文档
另外要提醒一句,有些 PDF 是 PDF/A 的合规文档,元数据必须有特定格式,并且内嵌 XMP 元数据。PyPDF2 修改/Info字典并不更新 XMP,可能导致 PDF/A 校验失败。如果你处理的文件有 PDF/A 合规要求,更要谨慎。PyMuPDF 在保存时可以选择是否保留 PDF/A 标识,但它也不是万能的。这类情况属于“规范陷阱”,跟编码错误叠加在一起,会让人非常头疼。遇到这种文件,最好在项目里注明“此类文档禁止用 PyPDF2 写入”,规避潜在风险。
还有一个坑是加密 PDF。如果你想用 PyPDF2 打开加密文件,首先得解密。PyPDF2 的加密支持的算法有限,对某些新式 AES-256 加密的 PDF,它可能根本打不开。就算打开了,写入时也可能出现编码错误。如果遇到,优先用 PyMuPDF 解密或直接新建文件。
6. 我的最终建议与一个小技巧
处理这个报错我踩过不少坑,最后得出的经验只有一句话:别再让 PyPDF2 写中文了。不是说 PyPDF2 一无是处,它在页面拆分、合并、简单提取上还是可用的,但涉及文本写入和元数据写入,设计缺陷太明显。生产项目里,最稳的组合是:用 PyPDF2 做页面级别的合并拆分,用 PyMuPDF 做元数据写入和中文内容生成,用报告生成库(比如 ReportLab)处理更加复杂的排版需求。
如果你非要给 PyPDF2 使用者一个临时兜底的小技巧,可以试试在写入前把字符串做一次unicodedata.normalize('NFKD', text),它能将很多带音标的字符拆成基础字符加组合标记,组合标记里有些能被 latin-1 接受(不一定),这样至少能多救回几个字符。比如é会变成e加一个重音符号,但重音符号 U+0301 依然在 latin-1 之块外,所以还得再替换组合标记。不过总比没有强。
最后分享一个我一直用的小工具函数,它基于unicodedata做了“能转就转、不能转就删”的宽松清洗:
import unicodedata def latin1_friendly(text: str, keep_unknown=False) -> str: result = [] for ch in unicodedata.normalize("NFKD", text): if ord(ch) < 256: result.append(ch) elif keep_unknown: # 保留一个占位符 result.append("_") # 否则直接丢弃该字符 return "".join(result)这个函数可以解决 90% 的 PyPDF2 元数据写入报错,又不至于像暴力替换那样把英文标点全毁了。但请记住,它解决的是“崩溃”,不是“保真”。真的要保真,请直接拥抱 PyMuPDF。
希望这篇记录能帮你快速摆脱这个烦人的latin-1编码错误。如果你试完发现还有类似的报错,不妨先回去检查自己是不是不小心把字节对象当字符串传给了 PyPDF2,那也是另一条常见的弯路。