Python-自动化指南-繁琐工作自动化-第三版-十一-

Python 自动化指南(繁琐工作自动化)第三版(十一)

原文:automatetheboringstuff.com/

译者:飞龙

协议:CC BY-NC-SA 4.0

17 个 PDF 和 Word 文档

原文:automatetheboringstuff.com/3e/chapter17.html

图片

虽然你可能认为 PDF 和 Word 是存储文本的格式,但这些文档是包含字体、颜色和布局信息的二进制文件,这使得它们比简单的纯文本文件复杂得多。如果你想让你的程序读取或写入 PDF 或 Word 文档,你需要做的不只是简单地传递它们的文件名给 open()函数。幸运的是,有几个 Python 包使这些交互变得简单。本章将介绍其中的两个:PyPDF 和 Python-Docx。

PDF 文档

PDF代表可移植文档格式,使用.pdf文件扩展名。尽管 PDF 支持许多功能,但本节将重点介绍三个常见任务:提取文档的文本内容、提取其图像以及从现有文档中创建新的 PDF 文件。

PyPDF 是一个用于创建和修改 PDF 文件的 Python 包。按照附录 A 中的说明安装该包。如果包安装正确,在交互式 shell 中运行import pypdf不应显示任何错误。

虽然 PDF 文件非常适合以易于打印和阅读的方式布局文本,但它们不容易解析为纯文本。因此,PyPDF 在从 PDF 中提取文本时可能会出错,甚至可能无法打开某些 PDF 文件。遗憾的是,对此你无能为力。PyPDF 可能根本无法处理你的一些特定文件。尽管如此,我个人还没有遇到过 PyPDF 无法打开的 PDF 文件。

提取文本

要开始使用 PyPDF,让我们使用我关于递归算法的书籍《递归的书》(No Starch Press,2022 年)中的一个示例章节的 PDF 文件,如图 17-1 所示。

展示“什么是递归?”这一章节开头的 PDF 文件

图 17-1:我们将从中提取文本的 PDF 文件

nostarch.com/automate-boring-stuff-python-3rd-edition在线资源中下载此Recursion_Chapter1.pdf文件,然后输入以下内容到交互式 shell 中:

>>> import pypdf
>>> reader = pypdf.PdfReader('Recursion_Chapter1.pdf') # ❶
>>> len(reader.pages) # ❷
18 

导入pypdf模块,然后使用 PDF 的文件名调用pypdf.PdfReader()以获取一个代表 PDF 的PdfReader对象❶。将此对象存储在名为reader的变量中。

PdfReader对象的pages属性是一个类似于列表的数据结构,包含代表 PDF 中单个页面的Page对象。像实际的 Python 列表一样,你可以将此数据结构传递给len()函数❷。此示例 PDF 有 18 页。

要从该 PDF 中提取文本并将其输出到文本文件,请打开一个新的文件编辑标签,并将以下代码保存到 extractpdftext.py

import pypdf
import pdfminer.high_level

PDF_FILENAME = 'Recursion_Chapter1.pdf'
TEXT_FILENAME = 'recursion.txt'

text = ''
try:
    reader = pypdf.PdfReader(PDF_FILENAME)
    for page in reader.pages: # ❶
        for page in reader.pages: # ❶
except Exception:
    for page in reader.pages: # ❶
with open(TEXT_FILENAME, 'w', encoding='utf-8') as file_obj:
    for page in reader.pages: # ❶

我们使用pypdf模块提取文本,但如果对于特定的 PDF 文件失败并引发异常,我们将回退到pdfminer模块。在try块中,我们使用for循环❶遍历 PDF 文件PdfReader对象中的每个Page对象。调用Page对象的extract_text()方法❷返回一个字符串,我们可以将其连接到text变量。当循环结束时,text将包含整个 PDF 文本的单个字符串。

如果 PDF 文件具有 PyPDF 无法理解的非常规格式,我们可以尝试使用这本书第三方包中包含的较旧的模块pdfminer.high_level。该模块的extract_text()函数将 PDF 的内容作为一个单独的字符串获取,而不是一次处理一页❸。

最后,我们可以使用第十章中介绍的open()函数和write()方法将字符串写入文本文件❹。

AI 后的后处理

我们刚刚执行的文字提取并不完美。PDF 文件格式臭名昭著地复杂,最初是为打印文档而设计的,而不是为了使其机器可读。即使提取过程中没有问题,文本布局也是固定的:字符串将在每行文本之后包含换行符,并且在行尾会有连字符分隔的单词。例如,从我们的示例 PDF 中提取的文本看起来像这样:

1
WHAT IS RECURSION?
Recursion has an intimidating reputation.
It's considered hard to understand, but
at its core, it depends on only two things:
 function calls and stack data structures.
Most new programmers trace through what a program does by follow -
ing the execution. It's an easy way to read code: you just put your finger
# --snip-- 

正如你所见,有许多主观的决定需要做出:

  • PDF 中的段落应该在何处结束和开始?

  • 应该在提取的文本中包含页码、页眉和页脚吗?

  • 如何将 PDF 中的数据表转换为纯文本?

  • 应该在提取的文本中包含多少空白?

清理此文本很无聊,并且无法通过代码轻松自动化。然而,像 ChatGPT 这样的大型语言模型(LLM)AI 能够很好地理解文本的上下文,从而自动生成清理后的版本。在复制和粘贴提取的文本之前,使用以下提示:

以下是从关于递归算法的书籍 PDF 的几页中提取的文本。清理此文本。我的意思是,将段落放在单独的一行上。还要从每一页中删除页脚和页眉文本。还要去掉跨行单词末尾的连字符。不要进行任何拼写、语法纠正或改写。以下是文本 ...

在一次试验中,这个提示生成了以下文本:

WHAT IS RECURSION?

Recursion has an intimidating reputation. It's considered hard
to understand, but at its core, it depends on only two things:
function calls and stack data structures. Most new programmers
trace through what a program does by following the execution.
It's an easy way to read code: you just put your finger... 

人类必须始终审查任何 AI 系统的输出。例如,LLM 从文本开头移除了章节编号1,这并不是我的意图。你可能需要调整提示来纠正任何误解。

如果你没有访问 LLM 的权限,PyPDF 文档在pypdf.readthedocs.io/en/latest/user/post-processing-in-text-extraction.html中有一个包含代码片段的后处理技巧列表。

提取图像

PyPDF 还可以从 PDF 文档中提取图像。每个Page对象都有一个包含Image对象类似数据结构的images属性。我们可以将这些Image对象的字节写入以'wb'(写二进制)模式打开的图像文件。Image对象还有一个包含图像名称字符串的name属性。以下是从示例章节 PDF 的所有页面中提取图像的代码。打开一个新的文件编辑标签,并将以下代码保存为extractpdfimages.py

import pypdf
PDF_FILENAME = 'Recursion_Chapter1.pdf'

reader = pypdf.PdfReader(PDF_FILENAME)
image_num = 0 # ❶
for i, page in enumerate(reader.pages): # ❷
    print(f'Reading page {i+1} - {len(page.images)} images found...')
    try:
        for page in reader.pages: # ❶
            for page in reader.pages: # ❶
                for page in reader.pages: # ❶
            print(f'Wrote {image_num}_page{i+1}_{image.name}...')
            for page in reader.pages: # ❶
    except Exception as exc:
        for page in reader.pages: # ❶

该程序的输出将如下所示:

Reading page 1 - 7 images found...
Wrote 0_page1_Im0.jpg...
Wrote 1_page1_Im1.png...
# --snip--
Reading page 7 - 1 images found...
Skipped page 7 due to error: not enough image data
# --snip--
Reading page 17 - 0 images found...
Reading page 18 - 0 images found... 

PDF 文档中的图像通常具有通用名称,如Im0.jpgIm1.png,因此我们使用一个名为image_num的变量计数器❶,结合页面号来分配它们唯一的名称。首先,我们遍历PdfReader对象的pages属性中的每个Page对象。回想一下,Python 的enumerate()函数❷返回整数索引和传递给它的类似列表对象的列表项。每个Page对象都有一个我们将遍历的images属性❸。

在遍历PdfReader对象的pages属性中的每个Page对象时,我们调用open()并使用 f-string 提供文件名❹。这个文件名由image_num计数器中的整数、页面号和Image对象的name属性中的字符串组成。因为i0开始,而 PDF 页面号从1开始,所以我们使用i+1来存储页面号。这个名称将包括文件扩展名,如.png.jpg。我们还必须将'wb'传递给open()函数调用,以便以写二进制模式打开文件。图像文件的字节存储在Image对象的data属性中,我们将其传递给write()方法❺。写入图像后,代码将image_num增加1❻。

如果 PDF 文件与 PyPDF 之间存在某些不兼容性,导致Page对象的images属性引发异常,我们的tryexcept语句可以捕获它并打印一条简短的错误消息❼。这样,一个页面的问题不会导致整个程序崩溃。

与文本提取一样,图像提取可能不完美。例如,PyPDF 未能检测到示例章节 PDF 中的许多图像,并显示错误消息。同时,你可能会惊讶地发现 PyPDF 提取了用作背景或间隔的小而空的图像。当处理 PDF 时,你通常会需要人工审查以确保输出是可接受的。

从其他页面创建 PDF

PyPDF 的PdfReader对应的是PdfWriter,它可以创建新的 PDF 文件。但是 PyPDF 不能像 Python 处理纯文本文件那样将任意文本写入 PDF。相反,PyPDF 的 PDF 写入功能仅限于从其他 PDF 文件复制、合并、裁剪和转换页面到新文件中。这个交互式 shell 示例中的代码仅复制了样本章节 PDF 的前五页:

>>> import pypdf
>>> writer = pypdf.PdfWriter() # ❶
>>> writer.append('Recursion_Chapter1.pdf', (0, 5)) # ❷
>>> with open('first_five_pages.pdf', 'wb') as file:
...     writer.write(file) # ❸
...
(False, <_io.BufferedWriter name='first_five_pages.pdf'>) 

首先,我们通过调用pypdf.PdfWriter()创建一个PdfWriter对象 ❶。变量writer中的PdfWriter对象代表一个空白 PDF 文档,包含零页。然后,PdfWriter对象的append()方法从样本章节 PDF 中复制前五页,我们通过'Recursion_Chapter1.pdf'文件名来识别它 ❷。(尽管名称相同,但PdfWriter对象的append()方法与列表的append()方法不同。)

此方法的第二个参数是元组(0, 5),它告诉PdfWriter对象从索引0PdfWriter对象中的第一页)开始复制页面,直到但不包括索引5。PyPDF 将索引0视为第一页,即使 PDF 应用程序将其称为第 1 页。

最后,要将PdfWriter对象的内容写入 PDF 文件,请使用文件名和'wb'模式调用open()方法,然后将File对象传递给PdfWriter对象的write()方法 ❸。这应该会生成一个新的 PDF 文件。

传递给append()的元组可以包含两个或三个整数。如果提供了第三个整数,则方法会跳过相应数量的页面。因为这种行为与range()函数相匹配,所以可以将两个或三个整数传递给list(range())以查看代码会复制哪些页面:

>>> list(range(0, 5))  # Passing (0, 5) makes append() copy these pages:
[0, 1, 2, 3, 4]
>>> list(range(0, 5, 2))  # Passing (0, 5, 2) makes append() copy these pages:
[0, 2, 4] 

append()方法还可以接受一个包含每个页面编号整数的列表参数。例如,假设我们用以下代码替换之前的交互式 shell 示例中的代码:

>>> writer.append('Recursion_Chapter1.pdf', [0, 1, 2, 3, 4])

这段代码也会将 PDF 文档的前五页复制到PdfWriter对象中。请注意,append()对元组和列表参数的解释不同;元组(0, 5)告诉append()复制索引0到但不包括索引5的页面,但列表[0, 5]会告诉append()分别复制索引0然后复制索引5。元组和列表之间这种含义的差异是非传统的,你不会在其他 Python 库中看到它,但它却是 PyPDF 设计的一部分。

append()方法将复制的页面添加到PdfWriter对象的末尾。要插入复制的页面到末尾之前,请调用merge()方法。merge()方法有一个额外的整数参数,用于指定插入页面的位置。例如,看看这段代码:

>>> writer.merge(2, 'Recursion_Chapter1.pdf', (0, 5))

此代码将索引 05(不包括索引 5)的页面复制,并将它们插入到 writer 对象中页码 2(第三页)的位置。索引 2 的原始页面以及所有其他页面在插入的页面集之后都会向后移动。

旋转页面

我们还可以使用 Page 对象的 rotate() 方法以 90 度的增量旋转 PDF 的页面。将 90180270 作为参数传递给此方法以顺时针旋转页面,将 -90-180-270 传递给此方法以逆时针旋转页面。旋转页面在您有许多 PDF 文件,无论出于何种原因,已经错误旋转并且需要将它们旋转回来,或者只需要旋转 PDF 文档中的几个选定页面时非常有用。PDF 应用程序通常具有旋转功能,您可以使用它手动纠正 PDF 文件,但 Python 允许您快速将旋转应用于许多 PDF 文件以自动化这项无聊的任务。

例如,在交互式外壳中输入以下内容以旋转样本章节 PDF 的页面:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> for i in range(len(writer.pages)): # ❶
...     for page in reader.pages: # ❶
...
{'/ArtBox': [21, 21, 525, 687], '/BleedBox': [12, 12, 534, 696],
# --snip--

>>> with open('rotated.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='rotated.pdf'>) 

我们创建一个新的 PdfWriter 对象,并将样本章节 PDF 的页面复制到其中。然后,我们使用一个 for 循环遍历每一页的页码。调用 len(writer.pages) 返回页数 ❶ 作为整数。表达式 writer.pages[i]for 循环的每次迭代中访问每个 Page 对象,并且 rotate(90) 方法调用 ❷ 在 PdfWriter 对象中旋转该页面。

生成的 PDF 应该包含所有页面顺时针旋转 90 度,如图 17-2 所示。

“什么是递归?” PDF 水平放置,因此文本是侧面的。

图 17-2:旋转了 90 度顺时针的 rotated.pdf 文件

PyPDF 不能以 90 度以外的增量旋转文档。

插入空白页

您可以使用 insert _blank_page()add_blank_page() 方法将空白页插入到 PdfWriter 对象中。新页面的尺寸将与前一页相同。例如,让我们创建一个样本章节 PDF 的副本,在末尾和第 3 页添加空白页:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> writer.add_blank_page() # ❶
{'/Type': '/Page', '/Resources': {}, '/MediaBox': [0.0, 0.0,
546, 708], '/Parent': IndirectObject(1, 0, 2629126028624)}
>>> writer.insert_blank_page(index=2) # ❷
{'/Type': '/Page', '/Parent': NullObject, '/Resources': {},
'/MediaBox': RectangleObject([0.0, 0.0, 546, 708])}
>>> with open('with_blanks.pdf', 'wb') as file:
...     writer.write(file)  # Save the writer object to a PDF file.
...
(False, <_io.BufferedWriter name='with_blanks.pdf'>) 

在将样本章节 PDF 的所有页面复制到 PdfWriter 对象后,add_blank_page() 方法将空白页添加到文档末尾。insert_blank_page() 方法在页码 2(即第三页,因为页码 0 是第一页)处插入空白页。此方法需要您指定 index 参数名称。

您可以选择保留这些页面为空白,或者稍后添加内容,例如叠加和水印,正如下一节所解释的。

添加水印和叠加

PyPDF 还可以将一个页面的内容叠加到另一个页面上,这对于在页面上添加徽标、时间戳或水印非常有用。在 PyPDF 中,戳记叠加 是放置在页面现有内容之上的内容,而 水印底纹 是放置在页面现有内容之下的内容。

从书籍的在线资源下载 watermark.pdf,并将 PDF 放置在当前工作目录中,与示例章节 PDF 一起。然后,在交互式外壳中输入以下内容:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> watermark_page = pypdf.PdfReader('watermark.pdf').pages[0] # ❶
>>> for page in writer.pages:
...     page.merge_page(watermark_page, over=False) # ❷
...
>>> with open('with_watermark.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='with_watermark.pdf'>) 

此示例创建了一个样本章节 PDF 的副本,并将其保存在新的 PdfWriter 对象中,保存在 writer 变量中。我们还获取了水印 PDF 的第一页的 Page 对象,并将其存储在 watermark_page 变量中。然后 for 循环遍历 PdfWriter 对象中的所有 Page 对象,并通过将其传递给 merge_page() 方法来应用水印。(不要将本章前面讨论的 Page 对象的 merge_page() 方法与 PdfWriter 对象的 merge() 方法混淆。)

merge_page() 方法也接受一个名为 over 的关键字参数。将此参数传递为 True 以创建一个戳记或叠加,或者传递 False 以创建水印或底图。

在循环中修改 PdfWriter 对象的页面后,代码将其保存为 with_watermark.pdf。图 17-3 显示了原始水印 PDF 和应用了水印的样本章节 PDF 的两页。

三份 PDF。第一份包含一个带有“绝密”文字的对角灰色框,第二份显示了“绝密”框叠加在“什么是递归?”章节页面上,第三份显示了“绝密”框叠加在另一页文本上。

图 17-3:水印 PDF(左)和添加了水印的页面(中间,右)

merge_page() 方法对于对 PDF 文档进行广泛更改很有用,例如合并两页的内容。

加密和解密 PDF

PDF 允许您加密其内容,使其不可读。加密的强度取决于您选择的密码,因此请创建一个使用不同字符类型的密码,不是词典中的单词,并且大约有 14 到 16 个字符。请记住,PDF 没有密码重置机制;如果您忘记了密码,除非您能猜到它,否则 PDF 将永远无法阅读。

PdfWriter 对象的 encrypt() 方法接受一个密码字符串和一个选择加密算法的字符串。'AES-256' 参数实现了一个推荐的现代加密算法,因此我们将始终使用它。在交互式外壳中输入以下内容以创建样本章节 PDF 的加密副本:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> writer.encrypt('swordfish', algorithm='AES-256')
>>> with open('encrypted.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='encrypted.pdf'>) 

PdfWriter 对象上调用 encrypt('swordfish', algorithm='AES-256') 方法会加密 PDF 的内容。我们将这个加密的 PDF 写入到 encrypted.pdf 文件后,没有任何 PDF 应用程序,包括 PyPDF,能够在不输入密码 swordfish 的情况下打开它。(这是一个糟糕的密码,因为它是一个在词典中出现的单词,因此很容易猜到。)除非您应用正确的解密密钥或密码,否则加密数据看起来是随机的,使用错误的密码解密文档会导致垃圾数据。PDF 应用程序会检测到这一点,然后提示您再次尝试密码。

PyPDF 可以为加密的 PDF 应用密码以解密它。在交互式 shell 中输入以下内容以检测具有is_encrypted属性的加密 PDF 并使用decrypt()解密:

>>> import pypdf
>>> reader = pypdf.PdfReader('encrypted.pdf') # ❶
>>> writer = pypdf.PdfWriter()
>>> reader.is_encrypted # ❷
True
>>> reader.pages[0] # ❸
Traceback (most recent call last):
# --snip--
pypdf.errors.FileNotDecryptedError: File has not been decrypted
>>> reader.decrypt('an incorrect password').name # ❹
'NOT_DECRYPTED'
>>> reader.decrypt('swordfish').name # ❺
'OWNER_PASSWORD'
>>> writer.append(reader) # ❻
>>> with open('decrypted.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='decrypted.pdf'>) 

我们就像加载任何其他 PDF 一样将加密的 PDF 加载到PdfReader对象中❶。PdfReader对象有一个is_encrypted属性❷,它设置为TrueFalse。如果你尝试通过例如访问pages属性❸来读取 PDF 内容,PyPDF 会抛出一个FileNotDecryptedError异常,因为它无法读取它。

PDF 可以有用户密码,允许你查看 PDF,以及所有者密码,允许你设置打印、注释、提取文本和其他功能的权限。用户密码和所有者密码分别是encrypt()函数的第一个和第二个参数。如果只传递一个字符串参数给encrypt(),PyPDF 将使用它作为两个密码。

要解密PdfReader对象,调用decrypt()方法并传递密码字符串。这个方法调用返回一个PasswordType对象;我们只对对象的name属性感兴趣。如果name设置为'NOT_DECRYPTED'❹,我们提供了错误的密码。如果name设置为'OWNER_PASSWORD''USER_PASSWORD'❺,我们已输入正确的所有者或用户密码。

现在,我们可以将PdfReader对象中的页面追加到PdfWriter对象中❻,并将解密后的 PDF 保存到文件中。

项目 12:从多个 PDF 中合并选定页面

假设你有一个无聊的工作,那就是将几十个 PDF 文档合并成一个单一的 PDF 文件。每个文档的第一页是一个封面页,但你不想在最终结果中重复这些封面页。尽管有很多免费的 PDF 合并程序,但其中许多只是简单地合并整个文件。让我们编写一个 Python 程序来自定义要包含在合并 PDF 中的页面。

从高层次来看,程序将执行以下操作:

  • 在当前工作目录中查找所有 PDF 文件并按字母顺序排序。

  • 对于每个 PDF,将第一页之后的全部页面复制到输出 PDF 中。

  • 将输出 PDF 保存到文件。

在实现方面,你的代码需要执行以下操作:

  • 调用os.listdir()以查找工作目录中的所有文件并删除任何非 PDF 文件。(我们在第十一章中介绍了这个函数。)

  • 调用 Python 的sort()列表方法对文件名进行字母排序。

  • 为输出 PDF 创建一个PdfWriter对象。

  • 遍历每个 PDF 文件,为它创建一个PdfReader对象。

  • PdfReader对象中,将第一页之后的全部页面复制到输出 PDF 中。

  • 将输出 PDF 写入文件。

为此项目打开一个新的文件编辑标签并保存为combine_pdfs.py

第 1 步:查找所有 PDF 文件

首先,你的程序需要获取当前工作目录中所有*.pdf 扩展名的文件列表并对其进行排序。让你的代码看起来像以下这样:

# combine_pdfs.py - Combines all the PDFs in the current working directory
# into a single PDF

import pypdf, os # ❶

# Get all the PDF filenames.
pdf_filenames = []
for filename in os.listdir('.'):
    if filename.endswith('.pdf'):
        for page in reader.pages: # ❶
pdf_filenames.sort(key=str.lower) # ❸

writer = pypdf.PdfWriter() # ❹

# TODO: Loop through all the PDF files.

# TODO: Copy all pages after the first page.

# TODO: Save the resulting PDF to a file. 

此代码导入 pypdfos 模块❶。os.listdir('.') 调用将返回当前工作目录中每个文件的列表。然后代码遍历此列表,将具有 .pdf 扩展名的文件添加到 pdf_filenames 变量中的列表中❷。接下来,我们使用 sort() 方法的 key=str.lower 关键字参数按字母顺序排序此列表❸。由于技术原因,sort() 方法将像 Z 这样的大写字母放在像 a 这样的小写字母之前;我们提供的关键字参数通过比较字符串的小写形式来防止这种情况。我们创建一个 PdfWriter 对象来保存合并的 PDF 页面❹。最后,一些注释概述了程序的其余部分。

第 2 步:打开每个 PDF

现在,程序必须读取 pdf_filenames 中的每个 PDF 文件。将以下内容添加到您的程序中:

# combine_pdfs.py - Combines all the PDFs in the current working directory
# into a single PDF

import pypdf, os

# --snip--

# Loop through all the PDF files:
for pdf_filename in pdf_filenames:
 reader = pypdf.PdfReader(pdf_filename)
 # Copy all pages after the first page:
 writer.append(pdf_filename, (1, len(reader.pages)))

# TODO: Save the resulting PDF to a file. 

对于每个 PDF 文件名,循环创建一个 PdfReader 对象并将其存储在名为 reader 的变量中。现在循环内的代码可以调用 len(reader.pages) 来找出 PDF 有多少页。它使用这个信息在 append() 方法调用中复制从 1(第二页,因为 PyPDF 使用 0 作为第一页索引)开始的页面,直到 PDF 的末尾。然后,它将内容追加到 writer 中的同一个 PdfWriter 对象。

第 3 步:保存结果

一旦这些 for 循环完成循环,writer 变量应该包含一个包含所有 PDF 页面的 PdfWriter 对象。最后一步是将这些内容写入硬盘上的文件。将以下代码添加到您的程序中:

# combine_pdfs.py - Combines all the PDFs in the current working directory
# into a single PDF

import pypdf, os

# --snip--

# Save the resulting PDF to a file:
with open('combined.pdf', 'wb') as file:
 writer.write(file) 

'wb' 传递给 open() 函数将输出 PDF 文件,combined.pdf,以二进制写入模式打开。然后,将生成的 File 对象传递给 write() 方法创建实际的 PDF 文件。(请注意 File 对象和 PdfWriter 对象中同名 write() 方法。)程序结束时,一个单独的 PDF 包含文件夹中每个 PDF 的所有页面(除了第一个),按文件名字母顺序排序。

类似程序的思路

能够从其他 PDF 的页面创建 PDF 将让您制作能够执行以下操作的程序:

  • 从 PDF 中剪切特定的页面。

  • 反转或重新排序 PDF 中的页面。

  • 从其他 PDF 的页面中创建仅包含某些特定文本的 PDF,该文本由 Page 对象的 extract_text() 方法识别。

Word 文档

Python 可以使用 Python-Docx 包创建和修改 Microsoft Word 文档,该包具有 .docx 文件扩展名,您可以通过遵循附录 A 中的说明来安装它。

警告

请确保安装 Python-Docx,而不是 Docx,后者属于不同的包,本书没有涉及。然而,从 Python-Docx 包导入模块时,您需要运行 import docx,而不是 import python-docx*。

如果您没有 Word,可以使用免费的 LibreOffice Writer 应用程序(适用于 Windows、macOS 和 Linux)打开 .docx 文件。从 www.libreoffice.org 下载它。尽管 Word 可以在 macOS 上运行,但本章将专注于 Windows 上的 Word。此外,请注意,虽然基于浏览器的 Office 365 和 Google Docs 网络应用程序是流行的文字处理器,但它们也可以导入和导出 .docx 文件。

与纯文本文件相比,.docx 文件具有许多结构元素,Python-Docx 使用三种不同的数据类型来表示这些元素。在最高级别,一个 Document 对象代表整个文档。Document 对象包含文档中段落的 Paragraph 对象列表。(在 Word 文档中,每当用户在输入时按下 ENTER 或 RETURN 键时,就会开始一个新段落。)这些 Paragraph 对象中的每一个都包含一个或多个 Run 对象的列表。图 17-4 中的单句段落有四个运行。

句子“一个带有一些粗体和斜体的普通段落。”单词“粗体”是粗体的,而单词“斜体”是斜体的。每个格式相同的文本块被标记为“运行。”

图 17-4:在 Paragraph 对象中识别出的 Run 对象

Word 文档中的文本不仅仅是字符串。它还与字体、大小、颜色和其他与它相关的样式信息相关联。Word 中的 样式 是这些属性的集合。Run 对象是具有相同样式的连续文本运行。每当文本样式发生变化时,您就需要一个新的 Run 对象。

阅读 Word 文档

让我们用 docx 模块进行实验。从本书的在线资源下载 demo.docx 并将文档保存到工作目录中。然后,在交互式壳中输入以下内容:

>>> import docx
>>> doc = docx.Document('demo.docx')
>>> len(doc.paragraphs)
7
>>> doc.paragraphs[0].text
'Document Title'
>>> doc.paragraphs[1].text
'A plain paragraph with some bold text and some italic'
>>> len(doc.paragraphs[1].runs)
4
>>> doc.paragraphs[1].runs[0].text
'A plain paragraph with some '
>>> doc.paragraphs[1].runs[1].text
'bold'
>>> doc.paragraphs[1].runs[2].text
' and some '
>>> doc.paragraphs[1].runs[3].text
'italic' 

我们在 Python 中打开 .docx 文件,调用 docx.Document(),并将文件名 demo.docx 传递给它。这将返回一个 Document 对象,它有一个 paragraphs 属性,该属性是一个 Paragraph 对象的列表。当我们对这个属性调用 len() 时,它返回 7,这告诉我们在这个文档中有七个 Paragraph 对象。这些 Paragraph 对象中的每一个都有一个 text 属性,该属性包含该段落中的文本字符串(不包含样式信息)。在这里,第一个 text 属性包含 'DocumentTitle',第二个包含 '一个带有一些粗体文本和一些斜体文本的普通段落'

每个 Paragraph 对象都有一个 runs 属性,它是一个 Run 对象的列表。Run 对象也有一个 text 属性,它只包含该特定运行中的文本。让我们看看第二个 Paragraph 对象中的 text 属性。对这个对象调用 len() 告诉我们,这里有四个 Run 对象。第一个 Run 对象包含 '一个普通的段落,包含一些 '。然后,文本变为粗体样式,所以 'bold' 开始一个新的 Run 对象。之后,文本回到非粗体样式,这导致第三个 Run 对象,' text and some '。最后,第四个也是最后一个 Run 对象包含以斜体样式显示的 'italic'

使用 Python-Docx,您的 Python 程序现在可以读取 .docx 文件中的文本,并像使用任何其他字符串值一样使用它。

从 .docx 文件中获取全文

如果您只关心 Word 文档的文本而不关心其样式信息,您可以使用此 get_text() 函数。它接受一个 .docx 文件的文件名,并返回其文本的单个字符串值。打开一个新的文件编辑标签,并输入以下代码,将其保存为 readDocx.py

import docx

def get_text(filename):
    doc = docx.Document(filename)
    full_text = []
    for para in doc.paragraphs:
        full_text.append(para.text)
    return '\n'.join(full_text) 

这个 get_text() 函数打开 Word 文档,遍历 paragraphs 列表中的所有 Paragraph 对象,然后将它们的文本追加到 full_text 列表中。循环之后,代码使用换行符将 full_text 中的字符串连接起来。

您可以像导入其他模块一样导入 readDocx.py 程序。现在,如果您只需要 Word 文档的文本,您可以输入以下内容:

>>> import readDocx
>>> print(readDocx.get_text('demo.docx'))
Document Title
A plain paragraph with some bold text and some italic
Heading, level 1
Intense quote
first item in unordered list
first item in ordered list 

您还可以调整 get_text() 来修改返回之前字符串。例如,要缩进每个段落,将 readDocx.py 中的 append() 调用替换为以下内容:

full_text.append('  ' + para.text)

要在段落之间添加双空格,请将 join() 调用代码更改为以下内容:

return '\n\n'.join(full_text)

如您所见,只需几行代码就可以编写函数,这些函数可以读取 .docx 文件并将您喜欢的其内容字符串返回。

设置段落和运行对象样式

Word 和其他文字处理器使用样式来保持文本的可视呈现一致且易于更改。例如,也许您希望所有正文段落都是 11 点的 Times New Roman,左对齐,右边缘参差不齐的文本。您可以创建一个具有这些设置的样式并将其分配给所有正文段落。如果您以后想更改文档中所有正文段落的呈现方式,您可以更改样式以自动更新这些段落。

要在基于浏览器的 Office 365 Word 应用程序中查看样式,请单击 主页 菜单项,然后单击 标题和其他样式 下拉菜单,它可能会显示“正常”或其他样式名称。单击 查看更多样式 以打开更多样式窗口。在 Windows 的 Microsoft Word 桌面应用程序中,您可以通过按 CTRL-ALT-SHIFT-S 显示样式面板,它看起来像图 17-5。在 LibreOffice Writer 中,您可以通过单击 查看样式 菜单项来查看样式面板。

在左侧,Word 样式面板的截图,其中包含样式列表,并突出显示“查看更多样式”选项。在右侧,“更多样式”面板打开,显示“样式名称”搜索栏和样式列表。

图 17-5:样式面板

Word 文档包含三种类型的样式:段落样式应用于 Paragraph 对象,字符样式应用于 Run 对象,链接样式应用于这两种对象。要样式化 ParagraphRun 对象,将它们的 style 属性设置为样式名称的字符串。如果 style 设置为 None,则不会与 ParagraphRun 对象关联任何样式。默认 Word 样式具有以下字符串值:

'Normal'   'Heading 5' 'List Bullet'       'List Paragraph'
'Body Text' 'Heading 6' 'List Bullet 2'     'MacroText'
'Body Text 2'   'Heading 7' 'List Bullet 3'     'No Spacing'
'Body Text 3'   'Heading 8' 'List Continue'     'Quote'
'Caption'   'Heading 9' 'List Continue 2'   'Subtitle'
'Heading 1' 'Intense Quote' 'List Continue 3'   'TOC Heading'
'Heading 2' 'List'      'List Number '      'Title'
'Heading 3' 'List 2'    'List Number 2' 
'Heading 4' 'List 3'    'List Number 3' 

当为一个 Run 对象使用链接样式时,需要在名称末尾添加 ' Char'。例如,要设置 Paragraph 对象的引用链接样式,你会使用 paragraphObj.style = 'Quote',但对于 Run 对象,你会使用 runObj.style = 'Quote Char'

要创建自定义样式,请使用 Word 应用程序定义它们,然后从 ParagraphRun 对象的 style 属性中读取它们。

应用运行属性

我们可以使用 text 属性进一步样式化文本。每个属性可以设置为三个值之一:True(表示无论应用了什么其他样式,该属性始终启用),False(表示该属性始终禁用),或 None(默认为运行样式设置)。表 17-1 列出了可以在 Run 对象上设置的 text 属性。

表 17-1:Run 对象 text 属性

属性 描述
bold 文本为粗体。
italic 文本为斜体。
underline 文本带有下划线。
strike 文本带有删除线。
double_strike 文本带有双删除线。
all_caps 文本全部为大写字母。
small_caps 文本以大写字母出现,小写字母小两点。
shadow 文本带有阴影。
outline 文本以轮廓形式出现,而不是实心。
rtl 文本从右到左书写。
imprint 文本看起来被压入页面。
emboss 文本以浮雕形式从页面上抬起。

例如,要更改 demo.docx 的样式,请在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document('demo.docx')
>>> doc.paragraphs[0].text
'Document Title'
>>> doc.paragraphs[0].style  # The exact id may be different.
_ParagraphStyle('Title') id: 3095631007984
>>> doc.paragraphs[0].style = 'Normal'
>>> doc.paragraphs[1].text
'A plain paragraph with some bold text and some italic'
>>> (doc.paragraphs[1].runs[0].text, doc.paragraphs[1].runs[1].text, 
doc.paragraphs[1].runs[2].text, doc.paragraphs[1].runs[3].text)
('A plain paragraph with some ', 'bold', ' and some ', 'italic')
>>> doc.paragraphs[1].runs[0].style = 'Quote Char'
>>> doc.paragraphs[1].runs[1].underline = True
>>> doc.paragraphs[1].runs[3].underline = True
>>> doc.save('restyled.docx') 

我们使用 textstyle 属性轻松查看文档中的段落。正如你所看到的,很容易将段落分成运行,并单独访问每个运行。我们获取第二段中的第一个、第二个和第四个运行,样式化每个运行,并将结果保存到新文档中。

现在,restyled.docx顶部的Document Title应使用 Normal 样式而不是 Title 样式,文本A plain paragraph with someRun对象应具有Quote Char样式,而单词bolditalic的两个Run对象应将其underline属性设置为True。图 17-6 显示了restyled.docx中段落和运行的样式。

包含文本“一个包含一些粗体和斜体的普通段落。”的 Word 文档。一个箭头指向包含“引用”样式的样式栏。另一个箭头指向应用了该样式的文本。

图 17-6:restyled.docx 文件

您可以在python-docx.readthedocs.io上找到关于 Python-Docx 使用样式的完整文档。

编写 Word 文档

要创建自己的.docx文件,调用docx.Document()以返回一个新的、空的 Word Document对象。例如,在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document()
>>> doc.add_paragraph('Hello, world!')
<docx.text.paragraph.Paragraph object at 0x0000000003B56F60>
>>> doc.save('helloworld.docx') 

add_paragraph()文档方法向文档添加一个新的文本段落,并返回所添加的Paragraph对象的引用。当您完成添加文本后,将文件名字符串传递给save()文档方法以将Document对象保存到文件。

此代码将在当前工作目录中创建一个名为helloworld.docx的文件。打开后,它应该看起来像图 17-7 所示。您可以将此.docx文件上传到 Office 365 或 Google Docs,或在 Word 或 LibreOffice 中打开它。

包含文本“Hello, world!”的 Word 文档。

图 17-7:使用 add_paragraph('Hello, world!')创建的 Word 文档

您可以通过再次调用add_paragraph()方法并传递新段落的文本来向文档添加段落。要将文本添加到现有段落的末尾,调用段落的add_run()方法并传递一个字符串。在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document()
>>> doc.add_paragraph('Hello world!')
<docx.text.paragraph.Paragraph object at 0x000000000366AD30>
>>> para_obj_1 = doc.add_paragraph('This is a second paragraph.')
>>> para_obj_2 = doc.add_paragraph('This is a yet another paragraph.')
>>> para_obj_1.add_run(' This text is being added to the second paragraph.')
<docx.text.run.Run object at 0x0000000003A2C860>
>>> doc.save('multipleParagraphs.docx') 

生成的文档应看起来像图 17-8 所示。请注意,文本This text is being added to the second paragraph.被添加到para_obj_1中的Paragraph对象中,它是添加到doc中的第二个段落。add_paragraph()add_run()函数分别返回ParagraphRun对象,以避免您作为单独的步骤提取它们。

再次调用save()方法以保存您所做的额外更改。

包含三行文本的 Word 文档。第一行说“Hello world!”,第二行说“这是一个第二段落。此文本正在添加到第二段落中。”,第三行说“这是另一个段落。”

图 17-8:添加了多个 Paragraph 和 Run 对象的文档

add_paragraph()add_run()方法都接受一个可选的第二个参数,该参数是ParagraphRun对象样式的字符串。以下是一个示例:

>>> doc.add_paragraph('Hello, world!', 'Title')
<docx.text.paragraph.Paragraph object at 0x00000213E6FA9190> 

这行代码添加了一个包含文本 Hello, world! 的段落,并使用标题样式。

添加标题

调用 add_heading() 添加一个包含标题样式的段落。在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document()
>>> doc.add_heading('Header 0', 0)
<docx.text.paragraph.Paragraph object at 0x00000000036CB3C8>
>>> doc.add_heading('Header 1', 1)
<docx.text.paragraph.Paragraph object at 0x00000000036CB630>
>>> doc.add_heading('Header 2', 2)
<docx.text.paragraph.Paragraph object at 0x00000000036CB828>
>>> doc.add_heading('Header 3', 3)
<docx.text.paragraph.Paragraph object at 0x00000000036CB2E8>
>>> doc.add_heading('Header 4', 4)
<docx.text.paragraph.Paragraph object at 0x00000000036CB3C8>
>>> doc.save('headings.docx') 

生成的 headings.docx 文件应如图 17-9 所示。

包含“标题 0”、“标题 1”、“标题 2”、“标题 3”和“标题 4”文本的 Word 文档,字体大小逐渐减小。

图 17-9:包含标题 0 到 4 的 headings.docx 文档

add_heading() 的参数是一个包含标题文本的字符串和一个介于 04 之间的整数。整数 0 使标题成为标题样式,我们将其用于文档的顶部。整数 19 用于各种标题级别,其中 1 是主要标题,9 是最低的子标题。add_heading() 函数返回一个 Paragraph 对象,以节省您从 Document 对象中提取它的步骤。

添加行和分页

要添加换行符(而不是开始一个全新的段落),您可以在想要出现换行符的 Run 对象上调用 add_break() 方法。如果您想添加分页符,则需要将值 docx.enum.text.WD_BREAK.PAGE 作为 add_break() 的唯一参数传递,就像在以下示例的中间所做的那样:

>>> doc = docx.Document()
>>> doc.add_paragraph('This is on the first page!')
<docx.text.paragraph.Paragraph object at 0x0000000003785518>
>>> doc.paragraphs[0].runs[0].add_break(docx.enum.text.WD_BREAK.PAGE) # ❶
>>> doc.add_paragraph('This is on the second page!')
<docx.text.paragraph.Paragraph object at 0x00000000037855F8>
>>> doc.save('twoPage.docx') 

此代码创建了一个两页的 Word 文档,第一页上写着 This is on the first page!,第二页上写着 This is on the second page!。尽管在第一页的 This is on the first page! 文本之后还有足够的空间,但我们通过在第一段的第一次运行后插入分页符,强制下一段在新页上开始 ❶。

添加图片

您可以使用 Document 对象的 add_picture() 方法将图像添加到文档的末尾。假设您当前工作目录中有一个名为 zophie.png 的文件。您可以通过输入以下内容将 zophie.png 添加到文档末尾,宽度为 1 英寸,高度为 4 厘米(Word 可以使用英制和公制单位):

>>> doc.add_picture('zophie.png', width=docx.shared.Inches(1), height=docx.shared.Cm(4))
<docx.shape.InlineShape object at 0x00000000036C7D30> 

第一个参数是图像文件名的字符串。可选的 widthheight 关键字参数将设置文档中图像的宽度和高度。如果省略,宽度和高度将默认为图像的正常大小。

您可能更喜欢使用熟悉的单位,如英寸和厘米来指定图像的高度和宽度,因此当您指定 widthheight 关键字参数时,可以使用 docx.shared.Inches()docx.shared.Cm() 函数。

摘要

文本信息不仅仅是用于纯文本文件;实际上,你很可能更频繁地处理 PDF 和 Word 文档。你可以使用 PyPDF 包来读取和写入 PDF 文档,但许多其他 Python 库也可以读取和写入 PDF 文件。如果你想超越本章讨论的内容,我建议在 PyPI 网站上搜索 pdfplumber、ReportLab、pdfrw、PyMuPDF、pdfkit 和 borb。

很不幸,从 PDF 文档中读取文本并不总是能完美地转换为字符串,因为文件格式复杂,有些 PDF 文件可能根本无法读取。pdfminer.six 包是已不再维护的 pdfminer 包的一个分支,专注于从 PDF 中提取文本。本章使用了 pdfminer.six 作为后备机制,以防无法从特定的 PDF 文件中提取文本。

Word 文档更可靠,你可以使用 python-docx 包的docx模块来读取它们。你可以通过ParagraphRun对象来操作 Word 文档中的文本。这些对象也可以赋予样式,尽管它们必须来自默认样式集或文档中已有的样式。你可以在文档的末尾添加新的段落、标题、换行符和图片。

许多与处理 PDF 和 Word 文档相关的限制都源于这些格式旨在为人类读者提供良好的显示效果,而不是便于软件解析。下一章将探讨一些其他常见的信息存储格式:CSV、JSON 和 XML 文件。这些格式是为计算机使用而设计的,你会发现 Python 与它们的工作会更加容易。

练习问题

  1. PdfWriter对象的File对象需要以什么模式打开才能保存 PDF 文件?

  2. 如何从PdfReaderPdfWriter对象获取第 5 页的Page对象?

  3. 如果PdfReader对象的 PDF 文件使用密码swordfish加密,在能够从其中获取Page对象之前,你必须做什么?

  4. 如果rotate()方法按顺时针方向旋转页面,如何逆时针旋转页面?

  5. 哪个方法返回名为demo.docx的文件的Document对象?

  6. Paragraph对象和Run对象之间有什么区别?

  7. 如何获取存储在名为doc的变量中的Document对象的Paragraph对象列表?

  8. 哪种类型的对象具有boldunderlineitalicstrikeoutline变量?

  9. bold变量设置为TrueFalseNone之间有什么区别?

  10. 如何创建一个新的 Word 文档的Document对象?

  11. 如何向名为doc的变量中存储的Document对象添加包含文本'Hello, there!'的段落?

  12. 哪些整数代表 Word 文档中可用的标题级别?

练习程序

为了练习,编写程序来完成以下任务。

PDF 焦虑

使用第十一章中的 os.walk() 函数编写一个脚本,该脚本将遍历文件夹(及其子文件夹)中的每个 PDF 文件,并使用命令行中提供的密码加密 PDF 文件。将每个加密的 PDF 文件以 encrypted.pdf 后缀添加到原始文件名中。在删除原始文件之前,程序应尝试读取和解析新文件,以确保它已正确加密。

然后,编写一个程序,该程序在文件夹(及其子文件夹)中查找所有加密的 PDF 文件,并使用提供的密码创建 PDF 的解密副本。如果密码不正确,程序应向用户打印一条消息,并继续下一个 PDF。

自定义邀请

假设你有一个包含宾客名单的文本文件。这个名为 guests.txt 的文件每行有一个名字,如下所示:

Prof. Plum
Miss Scarlet
Col. Mustard
Al Sweigart
RoboCop 

编写一个程序,生成一个看起来像图 17-10 的自定义邀请的 Word 文档。

由于 Python-Docx 只能使用 Word 文档中已经存在的样式,因此你首先需要将这些样式添加到一个空白 Word 文件中,然后使用 Python-Docx 打开该文件。结果 Word 文档中每页应该有一个邀请,因此调用 add_break() 在每个邀请的最后一段之后添加一个分页符。这样,你只需要打开一个 Word 文档就可以一次性打印所有邀请。

仅包含第 1、3 和 5 行值的 Excel 电子表格

图 17-10:由你的自定义邀请脚本生成的 Word 文档描述

你可以从本书的在线资源下载一个示例 guests.txt 文件。

PDF 密码破丨解丨器

假设你有一个加密的 PDF,你忘记了密码,但记得它是一个英文单词。尝试猜测你忘记的密码是一项相当无聊的任务。相反,你可以编写一个程序,通过尝试所有可能的英文单词直到找到一个有效的单词来解密 PDF。这被称为 暴力破解密码攻击。从本书的在线资源下载文本文件 dictionary.txt。这个字典文件包含超过 44,000 个英文单词,每个单词一行。

使用你在第十章中学到的文件读取技能,通过读取此文件创建一个单词字符串列表。然后,遍历此列表中的每个单词,将其传递给 decrypt() 方法。你应该尝试每个单词的大写和小写形式。(在我的笔记本电脑上,遍历字典文件中的所有 88,000 个大写和小写单词需要几分钟时间。这就是为什么你不应该使用简单的英文单词作为你的密码。)

PDF 文档

PDF 代表 便携式文档格式,并使用 .pdf 文件扩展名。尽管 PDF 支持许多功能,但本节将重点介绍三个常见任务:提取文档的文本内容、提取其图像以及从现有文档创建新的 PDF。

PyPDF 是一个用于创建和修改 PDF 文件的 Python 包。按照附录 A 中的说明安装该包。如果包安装正确,在交互式 shell 中运行import pypdf不应显示任何错误。

虽然 PDF 文件非常适合以易于打印和阅读的方式布局文本,但它们不容易解析为纯文本。因此,PyPDF 在从 PDF 中提取文本时可能会出错,甚至可能无法打开某些 PDF 文件。遗憾的是,对此你无能为力。PyPDF 可能根本无法处理你的一些特定文件。尽管如此,我个人还没有遇到过 PyPDF 无法打开的 PDF 文件。

提取文本

要开始使用 PyPDF,让我们使用我关于递归算法的书籍《递归书》(No Starch Press,2022 年)中示例章节的 PDF,如图 17-1 所示。

显示名为“什么是递归?”的章节开头的 PDF

图 17-1:我们将从中提取文本的 PDF 文件

nostarch.com/automate-boring-stuff-python-3rd-edition在线资源下载此Recursion_Chapter1.pdf文件,然后在交互式 shell 中输入以下内容:

>>> import pypdf
>>> reader = pypdf.PdfReader('Recursion_Chapter1.pdf') # ❶
>>> len(reader.pages) # ❷
18 

导入pypdf模块,然后使用 PDF 的文件名调用pypdf.PdfReader()以获取表示 PDF 的PdfReader对象❶。将此对象存储在名为reader的变量中。

PdfReader对象的pages属性是一个类似于列表的数据结构,包含代表 PDF 中单个页面的Page对象。像实际的 Python 列表一样,你可以将此数据结构传递给len()函数❷。此示例 PDF 有 18 页。

要从该 PDF 中提取文本并将其输出到文本文件,打开一个新的文件编辑标签,并将以下代码保存到extractpdftext.py

import pypdf
import pdfminer.high_level

PDF_FILENAME = 'Recursion_Chapter1.pdf'
TEXT_FILENAME = 'recursion.txt'

text = ''
try:
    reader = pypdf.PdfReader(PDF_FILENAME)
    for page in reader.pages: # ❶
        for page in reader.pages: # ❶
except Exception:
    for page in reader.pages: # ❶
with open(TEXT_FILENAME, 'w', encoding='utf-8') as file_obj:
    for page in reader.pages: # ❶

我们使用pypdf模块提取文本,但如果它对某个 PDF 文件失败并引发异常,我们将回退到pdfminer模块。在try块中,我们使用for循环❶遍历 PDF 文件PdfReader对象中的每个Page对象。调用Page对象的extract_text()方法❷返回一个字符串,我们可以将其连接到text变量。当循环结束时,text将包含整个 PDF 文本的单个字符串。

如果 PDF 文件具有 PyPDF 无法理解的非常规格式,我们可以尝试使用本书第三方包中包含的较旧模块pdfminer.high_level。该模块的extract_text()函数将 PDF 内容作为单个字符串获取,而不是逐页操作❸。

最后,我们可以使用第十章中介绍的open()函数和write()方法将字符串写入文本文件❹。

使用 AI 进行后处理

我们刚刚执行的文字提取并不完美。PDF 文件格式臭名昭著地复杂,最初是为打印文档而设计的,而不是为了使其机器可读。即使提取过程中没有问题,文本布局也是固定的:字符串将在每行文本之后包含换行符,并且在行尾会有连字符分隔的单词。例如,从我们的示例 PDF 中提取的文本看起来像这样:

1
WHAT IS RECURSION?
Recursion has an intimidating reputation.
It's considered hard to understand, but
at its core, it depends on only two things:
 function calls and stack data structures.
Most new programmers trace through what a program does by follow -
ing the execution. It's an easy way to read code: you just put your finger
# --snip-- 

如您所见,有许多主观的决定需要做出:

  • PDF 中的段落应该在何处结束和开始?

  • 提取的文本中是否应该包含页码、页眉和页脚?

  • 如何将 PDF 中的数据表格转换为纯文本?

  • 应该包含多少空白字符在提取的文本中?

清理这些文本很无聊,并且无法通过代码轻松自动化。然而,像 ChatGPT 这样的大型语言模型(LLM)AI 可以很好地理解文本的上下文,从而自动生成清理后的版本。在复制和粘贴提取的文本之前,使用以下提示:

以下是从关于递归算法的书籍 PDF 的几页中提取的文本。清理此文本。我的意思是,将段落放在单独的一行上。还要从每一页中删除页脚和页眉文本。还要去掉跨行分割的单词末尾的连字符。不要进行任何拼写、语法纠正或改写。以下是文本 ...

在一个试验中,这个提示生成了以下文本:

WHAT IS RECURSION?

Recursion has an intimidating reputation. It's considered hard
to understand, but at its core, it depends on only two things:
function calls and stack data structures. Most new programmers
trace through what a program does by following the execution.
It's an easy way to read code: you just put your finger... 

人类必须始终审查任何 AI 系统的输出。例如,LLM 从文本开头移除了章节编号1,这并不是我的意图。你可能需要细化提示来纠正任何误解。

如果你没有访问 LLM,PyPDF 文档中有一个包含代码片段的后期处理技巧列表,网址为pypdf.readthedocs.io/en/latest/user/post-processing-in-text-extraction.html

提取图像

PyPDF 还可以从 PDF 文档中提取图像。每个Page对象都有一个包含Image对象列表样式的数据结构的images属性。我们可以将这些Image对象的字节写入以'wb'(写二进制)模式打开的图像文件。Image对象还有一个包含图像名称字符串的name属性。以下是从示例章节 PDF 的所有页面中提取图像的代码。打开一个新的文件编辑标签,并将以下代码保存为extractpdfimages.py

import pypdf
PDF_FILENAME = 'Recursion_Chapter1.pdf'

reader = pypdf.PdfReader(PDF_FILENAME)
image_num = 0 # ❶
for i, page in enumerate(reader.pages): # ❷
    print(f'Reading page {i+1} - {len(page.images)} images found...')
    try:
        for page in reader.pages: # ❶
            for page in reader.pages: # ❶
                for page in reader.pages: # ❶
            print(f'Wrote {image_num}_page{i+1}_{image.name}...')
            for page in reader.pages: # ❶
    except Exception as exc:
        for page in reader.pages: # ❶

该程序的输出将如下所示:

Reading page 1 - 7 images found...
Wrote 0_page1_Im0.jpg...
Wrote 1_page1_Im1.png...
# --snip--
Reading page 7 - 1 images found...
Skipped page 7 due to error: not enough image data
# --snip--
Reading page 17 - 0 images found...
Reading page 18 - 0 images found... 

PDF 文档中的图片通常具有通用名称,例如 Im0.jpgIm1.png,因此我们使用一个名为 image_num 的变量计数器 ❶ 与页码一起为它们分配唯一的名称。首先,我们在 PdfReader 对象的 pages 属性中循环遍历每个 Page 对象。回想一下,Python 的 enumerate() 函数 ❷ 返回整数索引和传递给它的类似列表的对象的列表项。每个 Page 对象都有一个 images 属性,我们也会遍历它 ❸。

在那个嵌套的 for 循环内部,该循环遍历 images 属性中的 Image 对象,我们调用 open() 并使用 f-string 提供文件名 ❹。这个文件名由 image_num 计数器中的整数、页码以及 Image 对象的 name 属性中的字符串组成。因为 i0 开始,而 PDF 页码从 1 开始,所以我们使用 i+1 来存储页码。这个名称将包括文件扩展名,例如 .png.jpg。我们还必须将 'wb' 传递给 open() 函数调用,以便以写二进制模式打开文件。图像文件的字节存储在 Image 对象的 data 属性中,我们将其传递给 write() 方法 ❺。写入图像后,代码将 image_num 增加 1 ❻。

如果 PDF 文件与 PyPDF 之间存在某些不兼容性,导致 Page 对象的 images 属性引发异常,我们的 tryexcept 语句可以捕获它并打印一条简短的错误消息 ❼。这样,一个页面的问题不会导致整个程序崩溃。

与文本提取一样,图像提取可能并不完美。例如,PyPDF 未能检测到样本章节 PDF 中的许多图片,并显示错误消息。同时,你可能会惊讶地发现 PyPDF 提取了用作背景或间隔的小而空的图片。当处理 PDF 时,你通常会需要人工审查以确保输出是可接受的。

从其他页面创建 PDF

PyPDF 的 PdfReader 对应的是 PdfWriter,它可以创建新的 PDF 文件。但是 PyPDF 不能像 Python 处理纯文本文件那样将任意文本写入 PDF。相反,PyPDF 的 PDF 写入功能仅限于从其他 PDF 中复制、合并、裁剪和转换页面到新的 PDF 中。这个交互式 shell 示例中的代码创建了一个包含样本章节 PDF 前五页的副本:

>>> import pypdf
>>> writer = pypdf.PdfWriter() # ❶
>>> writer.append('Recursion_Chapter1.pdf', (0, 5)) # ❷
>>> with open('first_five_pages.pdf', 'wb') as file:
...     writer.write(file) # ❸
...
(False, <_io.BufferedWriter name='first_five_pages.pdf'>) 

首先,我们通过调用 pypdf.PdfWriter() ❶ 创建一个 PdfWriter 对象。变量 writer 中的 PdfWriter 对象代表一个空白 PDF 文档,其中包含零页。然后,PdfWriter 对象的 append() 方法从样本章节 PDF 中复制前五页,我们通过 'Recursion_Chapter1.pdf' 文件名来识别它。(尽管名称相同,但 PdfWriter 对象的 append() 方法与列表的 append() 方法不同。)

此方法第二个参数是元组 (0, 5),它告诉 PdfWriter 对象从页面索引 0PdfWriter 对象中的第一页)开始复制页面,到但不包括页面索引 5。PyPDF 将索引 0 视为第一页,即使 PDF 应用程序将其称为第 1 页。

最后,要将 PdfWriter 对象的内容写入 PDF 文件,使用带有文件名和 'wb' 模式的 open() 函数调用,然后将 File 对象传递给 PdfWriter 对象的 write() 方法 ❸。这应该会生成一个新的 PDF 文件。

传递给 append() 的元组可以包含两个或三个整数。如果提供了第三个整数,该方法会跳过相应数量的页面。因为这种行为与 range() 函数相匹配,你可以将两个或三个整数传递给 list(range()) 来查看代码会复制哪些页面:

>>> list(range(0, 5))  # Passing (0, 5) makes append() copy these pages:
[0, 1, 2, 3, 4]
>>> list(range(0, 5, 2))  # Passing (0, 5, 2) makes append() copy these pages:
[0, 2, 4] 

append() 方法还可以接受一个包含每个页面索引整数的列表参数。例如,假设我们将上一个交互式 shell 示例中的代码替换为以下代码:

>>> writer.append('Recursion_Chapter1.pdf', [0, 1, 2, 3, 4])

此代码还会将 PDF 文档的前五页复制到 PdfWriter 对象中。请注意,append() 方法对元组和列表参数的解释不同;元组 (0, 5) 告诉 append() 复制索引为 0 的页面到但不包括页面索引 5 的页面,但列表 [0, 5] 会告诉 append() 分别复制页面索引 0 和页面索引 5。元组和列表之间这种含义上的差异是不寻常的,你不会在其他 Python 库中看到它,但它却是 PyPDF 设计的一部分。

append() 方法将复制的页面添加到 PdfWriter 对象的末尾。要插入复制的页面到末尾之前,请调用 merge() 方法。merge() 方法有一个额外的整数参数,用于指定插入页面的位置。例如,看看以下代码:

>>> writer.merge(2, 'Recursion_Chapter1.pdf', (0, 5))

此代码复制索引为 0 的页面到但不包括索引 5 的页面,并将它们插入到 writerPdfWriter 对象的页面索引 2(第三页)的位置。原始索引为 2 的页面以及所有其他页面在插入的页面集之后都会被向后移动。

旋转页面

我们还可以使用 Page 对象的 rotate() 方法以 90 度的增量旋转 PDF 页面。将 90180270 作为此方法的参数以顺时针旋转页面,将 -90-180-270 作为参数以逆时针旋转页面。旋转页面在有许多 PDF 已经由于某种原因错误旋转且需要旋转回来,或者只需要旋转 PDF 文档中的几个选定页面时非常有用。PDF 应用程序通常具有旋转功能,你可以使用它手动纠正 PDF,但 Python 允许你快速将旋转应用于许多 PDF,以自动化这项无聊的任务。

例如,将以下内容输入到交互式 shell 中以旋转示例章节 PDF 的页面:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> for i in range(len(writer.pages)): # ❶
...     for page in reader.pages: # ❶
...
{'/ArtBox': [21, 21, 525, 687], '/BleedBox': [12, 12, 534, 696],
# --snip--

>>> with open('rotated.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='rotated.pdf'>) 

我们创建一个新的PdfWriter对象,并将样本章节 PDF 的页面复制到其中。然后,我们使用for循环遍历每个页面编号。调用len(writer.pages)返回页数❶作为一个整数。表达式writer.pages[i]for循环的每次迭代中访问每个Page对象,并且rotate(90)方法调用❷将此页面在PdfWriter对象中旋转。

生成的 PDF 应包含所有页面顺时针旋转 90 度,如图 17-2 所示。

“什么是递归?”的 PDF 水平排列,因此文本是侧放的

图 17-2:旋转后的.pdf 文件,页面顺时针旋转了 90 度

PyPDF 不能以 90 度以外的增量旋转文档。

插入空白页

您可以使用insert_blank_page()add_blank_page()方法向PdfWriter对象插入或附加空白页。新页面的尺寸将与前一页相同。例如,让我们创建一个副本的样本章节 PDF,在末尾和第 3 页处添加空白页:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> writer.add_blank_page() # ❶
{'/Type': '/Page', '/Resources': {}, '/MediaBox': [0.0, 0.0,
546, 708], '/Parent': IndirectObject(1, 0, 2629126028624)}
>>> writer.insert_blank_page(index=2) # ❷
{'/Type': '/Page', '/Parent': NullObject, '/Resources': {},
'/MediaBox': RectangleObject([0.0, 0.0, 546, 708])}
>>> with open('with_blanks.pdf', 'wb') as file:
...     writer.write(file)  # Save the writer object to a PDF file.
...
(False, <_io.BufferedWriter name='with_blanks.pdf'>) 

在将样本章节 PDF 的所有页面复制到PdfWriter对象之后,add_blank_page()方法将空白页添加到文档的末尾。insert_blank_page()方法在页面索引2(即第三页,因为页面索引0是第一页)处插入空白页。此方法要求您指定index参数名称。

您可以选择保留这些页面为空白,或者稍后向它们添加内容,例如叠加和水印,如下一节所述。

添加水印和叠加

PyPDF 还可以将一个页面的内容叠加到另一个页面上,这对于在页面上添加徽标、时间戳或水印非常有用。在 PyPDF 中,戳记叠加是放置在页面现有内容之上的内容,而水印底图是放置在页面现有内容之下的内容。

从书籍的在线资源下载watermark.pdf,并将其与样本章节 PDF 一起放置在当前工作目录中。然后,在交互式 shell 中输入以下内容:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> watermark_page = pypdf.PdfReader('watermark.pdf').pages[0] # ❶
>>> for page in writer.pages:
...     page.merge_page(watermark_page, over=False) # ❷
...
>>> with open('with_watermark.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='with_watermark.pdf'>) 

此示例在新的PdfWriter对象中创建样本章节 PDF 的副本,并保存在writer变量中。我们还获取了水印 PDF 的第一页的Page对象,并将其存储在watermark_page变量中。然后,for循环遍历PdfWriter对象中的所有Page对象,并通过传递给merge_page()方法来应用水印。(不要将本章前面讨论过的Page对象的merge_page()方法与PdfWriter对象的merge()方法混淆。)

merge_page()方法还有一个over关键字参数。将此参数传递为True以创建戳记或叠加,或传递False以创建水印或底图。

在循环中修改 PdfWriter 对象的页面后,代码将其保存为 with_watermark.pdf。图 17-3 显示了原始水印 PDF 和两个应用了水印的样本章节 PDF 页面。

三份 PDF。第一份包含一个对角灰色框,框内有“绝密”文字,第二份显示了“绝密”框叠加在“什么是递归?”章节页面上,第三份显示了“绝密”框叠加在另一页文字上。

图 17-3:水印 PDF(左)和添加了水印的页面(中间,右)

merge_page() 方法对于对 PDF 文档进行广泛更改很有用,例如合并两页的内容。

加密和解密 PDFs

PDFs 允许您加密其内容,使其不可读。加密的强度取决于您选择的密码,因此请创建一个使用不同字符类型的密码,不是字典中的单词,并且大约有 14 到 16 个字符。请记住,PDFs 没有密码重置机制;如果您忘记了密码,除非您能猜到它,否则 PDF 将永远无法阅读。

PdfWriter 对象的 encrypt() 方法接受一个密码字符串和一个选择加密算法的字符串。'AES-256' 参数实现了一个推荐的现代加密算法,因此我们将始终使用它。在交互式外壳中输入以下内容以创建样本章节 PDF 的加密副本:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> writer.encrypt('swordfish', algorithm='AES-256')
>>> with open('encrypted.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='encrypted.pdf'>) 

PdfWriter 对象上调用 encrypt('swordfish', algorithm='AES-256') 方法会加密 PDF 的内容。在我们将这个加密的 PDF 写入 encrypted.pdf 文件后,没有任何 PDF 应用程序,包括 PyPDF,能够在不输入密码 swordfish 的情况下打开它。(这是一个糟糕的密码,因为它是一个在字典中出现的单词,因此很容易猜到。)除非您应用正确的解密密钥或密码,否则加密数据看起来是随机的,使用错误的密码解密文档会导致垃圾数据。PDF 应用程序会检测到这一点,然后提示您再次尝试密码。

PyPDF 可以为加密的 PDF 应用密码以解密它。在交互式外壳中输入以下内容以检测具有 is_encrypted 属性的加密 PDF 并使用 decrypt() 解密:

>>> import pypdf
>>> reader = pypdf.PdfReader('encrypted.pdf') # ❶
>>> writer = pypdf.PdfWriter()
>>> reader.is_encrypted # ❷
True
>>> reader.pages[0] # ❸
Traceback (most recent call last):
# --snip--
pypdf.errors.FileNotDecryptedError: File has not been decrypted
>>> reader.decrypt('an incorrect password').name # ❹
'NOT_DECRYPTED'
>>> reader.decrypt('swordfish').name # ❺
'OWNER_PASSWORD'
>>> writer.append(reader) # ❻
>>> with open('decrypted.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='decrypted.pdf'>) 

我们就像加载任何其他 PDF 一样将加密的 PDF 加载到 PdfReader 对象中❶。PdfReader 对象有一个 is_encrypted 属性❷,设置为 TrueFalse。如果您尝试通过例如访问 pages 属性❸来读取 PDF 内容,PyPDF 会引发 FileNotDecryptedError,因为它无法读取它。

PDF 可以有 用户密码,允许你查看 PDF,以及一个 所有者密码,允许你设置打印、注释、提取文本和其他功能的权限。用户密码和所有者密码分别是 encrypt() 函数的第一个和第二个参数。如果只传递一个字符串参数给 encrypt(),PyPDF 将使用它作为两个密码。

要解密 PdfReader 对象,调用 decrypt() 方法并传递密码字符串。这个方法调用返回一个 PasswordType 对象;我们只对对象的 name 属性感兴趣。如果 name 设置为 'NOT_DECRYPTED' ❹,我们提供了错误的密码。如果 name 设置为 'OWNER_PASSWORD''USER_PASSWORD' ❺,我们已输入正确的所有者或用户密码。

我们现在可以将 PdfReader 对象的页面追加到 PdfWriter 对象 ❻,并将解密后的 PDF 保存到文件中。

项目 12:从多个 PDF 中合并选定页面

假设你有一个无聊的工作,就是将几十个 PDF 文档合并成一个单一的 PDF 文件。每个文档的第一页是封面,但你不想在最终结果中重复封面。尽管有很多免费的 PDF 合并程序,但其中许多只是简单地合并整个文件。让我们编写一个 Python 程序来自定义要包含在合并 PDF 中的页面。

从高层次来看,程序将执行以下操作:

  • 在当前工作目录中查找所有 PDF 文件,并按字母顺序排序。

  • 对于每个 PDF,将第一页之后的所有页面复制到输出 PDF 中。

  • 将输出 PDF 保存到文件中。

在实现方面,你的代码需要执行以下操作:

  • 调用 os.listdir() 来查找工作目录中的所有文件,并删除任何非 PDF 文件。(我们在第十一章中介绍了这个函数。)

  • 调用 Python 的 sort() 列表方法来对文件名进行字母排序。

  • 为输出 PDF 创建一个 PdfWriter 对象。

  • 遍历每个 PDF 文件,为它创建一个 PdfReader 对象。

  • PdfReader 对象中,将第一页之后的所有页面复制到输出 PDF 中。

  • 将输出 PDF 写入文件。

为此项目打开一个新的文件编辑标签页,并将其保存为 combine_pdfs.py

第 1 步:查找所有 PDF 文件

首先,你的程序需要获取当前工作目录中所有具有 .pdf 扩展名的文件列表,并对其进行排序。让你的代码看起来像以下这样:

# combine_pdfs.py - Combines all the PDFs in the current working directory
# into a single PDF

import pypdf, os # ❶

# Get all the PDF filenames.
pdf_filenames = []
for filename in os.listdir('.'):
    if filename.endswith('.pdf'):
        for page in reader.pages: # ❶
pdf_filenames.sort(key=str.lower) # ❸

writer = pypdf.PdfWriter() # ❹

# TODO: Loop through all the PDF files.

# TODO: Copy all pages after the first page.

# TODO: Save the resulting PDF to a file. 

此代码导入pypdfos模块❶。os.listdir('.')调用将返回当前工作目录中每个文件的列表。然后,代码遍历此列表,将具有.pdf扩展名的文件添加到pdf_filenames变量中的列表。接下来,我们使用sort()方法的key=str.lower关键字参数按字母顺序排序此列表❷。由于技术原因,sort()方法将像Z这样的大写字母放在像a这样的小写字母之前;我们提供的关键字参数通过比较字符串的小写形式来防止这种情况。我们创建一个PdfWriter对象来保存组合的 PDF 页面❸。最后,一些注释概述了程序的其余部分。

第 2 步:打开每个 PDF

现在程序必须读取pdf_filenames中的每个 PDF 文件。将以下内容添加到您的程序中:

# combine_pdfs.py - Combines all the PDFs in the current working directory
# into a single PDF

import pypdf, os

# --snip--

# Loop through all the PDF files:
for pdf_filename in pdf_filenames:
 reader = pypdf.PdfReader(pdf_filename)
 # Copy all pages after the first page:
 writer.append(pdf_filename, (1, len(reader.pages)))

# TODO: Save the resulting PDF to a file. 

对于每个 PDF 文件名,循环创建一个PdfReader对象,并将其存储在名为reader的变量中。现在循环内的代码可以调用len(reader.pages)来找出 PDF 有多少页。它使用这个信息在append()方法调用中复制从1(第二页,因为 PyPDF 使用0作为第一页索引)开始的页面,直到 PDF 的末尾。然后,它将内容追加到writer中的同一个PdfWriter对象。

第 3 步:保存结果

一旦这些for循环完成循环,writer变量应包含一个包含所有 PDF 页面组合的PdfWriter对象。最后一步是将此内容写入硬盘上的文件。将以下代码添加到您的程序中:

# combine_pdfs.py - Combines all the PDFs in the current working directory
# into a single PDF

import pypdf, os

# --snip--

# Save the resulting PDF to a file:
with open('combined.pdf', 'wb') as file:
 writer.write(file) 

'wb'传递给open()以以写二进制模式打开输出 PDF 文件,combined.pdf。然后,将生成的File对象传递给write()方法以创建实际的 PDF 文件。(注意File对象和PdfWriter对象中同名的方法。)程序结束时,一个单独的 PDF 包含文件夹中每个 PDF 的所有页面(除了第一页),按文件名字母顺序排序。

类似程序的思路

能够从其他 PDF 的页面创建 PDF 将使您能够创建能够执行以下操作的程序:

  • 从 PDF 中裁剪特定页面。

  • 反转或重新排序 PDF 中的页面。

  • 通过Page对象的extract_text()方法,从其他 PDF 中仅创建包含某些特定文本的 PDF 页面。

提取文本

要开始使用 PyPDF,让我们使用我关于递归算法的书籍的样本章节的 PDF,即递归的书(No Starch Press,2022),如图 17-1 所示。

显示名为“什么是递归?”的书籍章节开头的 PDF

图 17-1:我们将从中提取文本的 PDF 文件

从在线资源nostarch.com/automate-boring-stuff-python-3rd-edition下载此Recursion_Chapter1.pdf文件,然后在交互式 shell 中输入以下内容:

>>> import pypdf
>>> reader = pypdf.PdfReader('Recursion_Chapter1.pdf') # ❶
>>> len(reader.pages) # ❷
18 

导入pypdf模块,然后使用 PDF 的文件名调用pypdf.PdfReader()以获取表示 PDF 的PdfReader对象❶。将此对象存储在名为reader的变量中。

PdfReader对象的pages属性是一个类似于列表的数据结构,包含表示 PDF 中单个页面的Page对象。像实际的 Python 列表一样,你可以将此数据结构传递给len()函数❷。此示例 PDF 有 18 页。

要从此 PDF 中提取文本并将其输出到文本文件,请打开一个新的文件编辑标签,并将以下代码保存到extractpdftext.py

import pypdf
import pdfminer.high_level

PDF_FILENAME = 'Recursion_Chapter1.pdf'
TEXT_FILENAME = 'recursion.txt'

text = ''
try:
    reader = pypdf.PdfReader(PDF_FILENAME)
    for page in reader.pages: # ❶
        for page in reader.pages: # ❶
except Exception:
    for page in reader.pages: # ❶
with open(TEXT_FILENAME, 'w', encoding='utf-8') as file_obj:
    for page in reader.pages: # ❶

我们使用pypdf模块来提取文本,但如果它对某个 PDF 文件失败并引发异常,我们将回退到pdfminer模块。在try块中,我们使用for循环❶遍历 PDF 文件的PdfReader对象中的每个Page对象。调用Page对象的extract_text()方法❷返回一个字符串,我们可以将其连接到text变量。当循环结束时,text将包含整个 PDF 文本的单个字符串。

如果 PDF 文件具有 PyPDF 无法理解的非常规格式,我们可以尝试使用本书第三方包中包含的较旧的模块pdfminer.high_level。该模块的extract_text()函数将 PDF 内容作为单个字符串获取,而不是逐页操作❸。

最后,我们可以使用第十章中介绍的open()函数和write()方法将字符串写入文本文件❹。

使用 AI 进行后处理

我们刚刚执行的文本提取并不完美。PDF 文件格式臭名昭著地复杂,最初是为打印文档而设计的,而不是为使其机器可读而设计的。即使提取没有问题,文本布局是固定的:字符串将在每行文本后包含换行符,并且在行末的连字符分隔的单词。例如,从我们的示例 PDF 中提取的文本看起来像这样:

1
WHAT IS RECURSION?
Recursion has an intimidating reputation.
It's considered hard to understand, but
at its core, it depends on only two things:
 function calls and stack data structures.
Most new programmers trace through what a program does by follow -
ing the execution. It's an easy way to read code: you just put your finger
# --snip-- 

如您所见,有许多主观的决定需要做出:

  • 在 PDF 中,段落应该在哪里结束和开始?

  • 应该在提取的文本中包含页码、页眉和页脚吗?

  • 如何将 PDF 中的数据表转换为纯文本?

  • 应该在提取文本中包含多少空白?

清理此文本很无聊,并且无法通过代码轻松自动化。然而,大型语言模型(LLM)AI,如 ChatGPT,可以很好地理解文本的上下文,从而自动生成清理后的版本。在复制和粘贴提取的文本之前,使用以下提示:

以下是从关于递归算法的书籍 PDF 的几页中提取的文本。清理此文本。这意味着将段落放在单独的一行上。还要从每一页中删除页脚和页眉文本。还要删除跨行单词末尾的连字符。不要进行任何拼写、语法纠正或改写。以下是文本 ...

在一个试验中,这个提示生成了以下文本:

WHAT IS RECURSION?

Recursion has an intimidating reputation. It's considered hard
to understand, but at its core, it depends on only two things:
function calls and stack data structures. Most new programmers
trace through what a program does by following the execution.
It's an easy way to read code: you just put your finger... 

人类必须始终审查任何 AI 系统的输出。例如,LLM 从文本开头移除了章节编号1,这不是我的意图。你可能需要细化提示以纠正任何误解。

如果你没有访问 LLM(大型语言模型)的权限,PyPDF 文档中有一个包含代码片段的后期处理技巧列表,网址为pypdf.readthedocs.io/en/latest/user/post-processing-in-text-extraction.html

提取图像

PyPDF 还可以从 PDF 文档中提取图像。每个Page对象都有一个包含Image对象类似数据结构的images属性。我们可以将这些Image对象的字节写入以'wb'(写二进制)模式打开的图像文件。Image对象还有一个包含图像名称字符串的name属性。以下是提取样本章节 PDF 所有页面的图像的代码。打开一个新的文件编辑标签,并将以下代码保存为extractpdfimages.py

import pypdf
PDF_FILENAME = 'Recursion_Chapter1.pdf'

reader = pypdf.PdfReader(PDF_FILENAME)
image_num = 0 # ❶
for i, page in enumerate(reader.pages): # ❷
    print(f'Reading page {i+1} - {len(page.images)} images found...')
    try:
        for page in reader.pages: # ❶
            for page in reader.pages: # ❶
                for page in reader.pages: # ❶
            print(f'Wrote {image_num}_page{i+1}_{image.name}...')
            for page in reader.pages: # ❶
    except Exception as exc:
        for page in reader.pages: # ❶

这个程序的输出将看起来像这样:

Reading page 1 - 7 images found...
Wrote 0_page1_Im0.jpg...
Wrote 1_page1_Im1.png...
# --snip--
Reading page 7 - 1 images found...
Skipped page 7 due to error: not enough image data
# --snip--
Reading page 17 - 0 images found...
Reading page 18 - 0 images found... 

PDF 文档中的图片通常具有通用名称,例如Im0.jpgIm1.png,因此我们使用一个名为image_num的变量计数器❶,结合页码来分配它们唯一的名称。首先,我们遍历PdfReader对象的pages属性中的每个Page对象。回想一下,Python 的enumerate()函数❷返回整数索引和传递给它的类似列表的对象的列表项。每个Page对象都有一个images属性,我们也会遍历它❸。

在遍历images属性中Image对象的第二个嵌套for循环中,我们调用open()并使用 f-string 提供文件名❹。这个文件名由image_num计数器中的整数、页码和Image对象的name属性中的字符串组成。因为i0开始,而 PDF 页码从1开始,所以我们使用i+1来存储页码。这个名称将包括文件扩展名,例如.png.jpg。我们还必须将'wb'传递给open()函数调用,以便以写二进制模式打开文件。图像文件的字节存储在Image对象的data属性中,我们将其传递给write()方法❺。写入图像后,代码将image_num增加1❻。

如果 PDF 文件与 PyPDF 之间存在不兼容性,导致Page对象的images属性引发异常,我们的tryexcept语句可以捕获它并打印一条简短的错误消息❼。这样,一个页面的问题不会导致整个程序崩溃。

与文本提取一样,图像提取可能不完美。例如,PyPDF 无法检测样本章节 PDF 中的许多图像,并显示错误消息。同时,你可能会惊讶地发现 PyPDF 提取了用作背景或间隔的小而空的图像。当处理 PDF 时,你通常需要人工审查以确保输出是可接受的。

从其他页面创建 PDF

PyPDF 的 PdfReader 对应的是 PdfWriter,它可以创建新的 PDF 文件。但是 PyPDF 不能像 Python 对文本文件那样写入任意文本到 PDF 中。相反,PyPDF 的 PDF 写入功能仅限于从其他 PDF 中复制、合并、裁剪和转换页面到新的 PDF 中。此交互式外壳示例中的代码创建了包含仅前五页的样本章节 PDF 的副本:

>>> import pypdf
>>> writer = pypdf.PdfWriter() # ❶
>>> writer.append('Recursion_Chapter1.pdf', (0, 5)) # ❷
>>> with open('first_five_pages.pdf', 'wb') as file:
...     writer.write(file) # ❸
...
(False, <_io.BufferedWriter name='first_five_pages.pdf'>) 

首先,我们通过调用 pypdf.PdfWriter() 创建一个 PdfWriter 对象 ❶。变量 writer 中的 PdfWriter 对象代表一个空白 PDF 文档,其中包含零页。然后,PdfWriter 对象的 append() 方法从样本章节 PDF 中复制前五页,我们通过 'Recursion_Chapter1.pdf' 文件名来识别它 ❷。(尽管名称相同,但 PdfWriter 对象的 append() 方法与 append() 列表方法不同。)

此方法的第二个参数是元组 (0, 5),它告诉 PdfWriter 对象从页面索引 0PdfWriter 对象中的第一页)开始复制页面,直到但不包括页面索引 5。PyPDF 将索引 0 视为第一页,即使 PDF 应用程序将其称为第 1 页。

最后,要将 PdfWriter 对象的内容写入 PDF 文件,使用带有文件名和 'wb' 模式的 open() 调用,然后将 File 对象传递给 PdfWriter 对象的 write() 方法 ❸。这应该会生成一个新的 PDF 文件。

传递给 append() 的元组可以包含两个或三个整数。如果提供了第三个整数,则方法会跳过那么多页。因为这种行为与 range() 函数匹配,你可以将两个或三个整数传递给 list(range()) 来查看代码会复制哪些页面:

>>> list(range(0, 5))  # Passing (0, 5) makes append() copy these pages:
[0, 1, 2, 3, 4]
>>> list(range(0, 5, 2))  # Passing (0, 5, 2) makes append() copy these pages:
[0, 2, 4] 

append() 方法还可以接受一个列表参数,其中包含要附加的每个页面的整数。例如,如果我们用以下代码替换上一个交互式外壳示例中的代码:

>>> writer.append('Recursion_Chapter1.pdf', [0, 1, 2, 3, 4])

此代码还会将 PDF 文档的前五页复制到 PdfWriter 对象中。请注意,append() 方法对元组和列表参数的解释不同;元组 (0, 5) 告诉 append() 复制索引为 0 的页面到但不包括索引 5 的页面,但列表 [0, 5] 会告诉 append() 分别复制索引 0 和索引 5 的页面。元组和列表之间这种含义上的差异是不寻常的,你不会在其他 Python 库中看到它,但它是 PyPDF 设计的一部分。

append() 方法将复制的页面添加到 PdfWriter 对象的末尾。要在末尾之前插入复制的页面,请调用 merge() 方法。merge() 方法有一个额外的整数参数,用于指定插入页面的位置。例如,查看以下代码:

>>> writer.merge(2, 'Recursion_Chapter1.pdf', (0, 5))

此代码复制索引 05 之间的页面(但不包括索引 5),并将它们插入到 writer 中的页面索引 2(即第三页)的位置。索引 2 的原始页面以及所有其他页面在插入的页面集之后被移位。

旋转页面

我们也可以使用 Page 对象的 rotate() 方法以 90 度的增量旋转 PDF 的页面。将 90180270 作为参数传递给此方法,以顺时针旋转页面,将 -90-180-270 传递给此方法以逆时针旋转页面。旋转页面在您有许多 PDF 文档,无论出于何种原因,已经错误旋转且需要旋转回来,或者只需要旋转 PDF 文档中的几个选定页面时非常有用。PDF 应用程序通常具有旋转功能,您可以使用它手动纠正 PDF,但 Python 允许您快速将旋转应用于许多 PDF,以自动化这项无聊的任务。

例如,将以下内容输入到交互式外壳中,以旋转示例章节 PDF 的页面:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> for i in range(len(writer.pages)): # ❶
...     for page in reader.pages: # ❶
...
{'/ArtBox': [21, 21, 525, 687], '/BleedBox': [12, 12, 534, 696],
# --snip--

>>> with open('rotated.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='rotated.pdf'>) 

我们创建一个新的 PdfWriter 对象,并将示例章节 PDF 的页面复制到其中。然后,我们使用 for 循环遍历每个页面编号。调用 len(writer.pages) 返回页面的数量 ❶ 作为整数。表达式 writer.pages[i]for 循环的每次迭代中访问每个 Page 对象,并且 rotate(90) 方法调用 ❷ 在 PdfWriter 对象中旋转此页面。

生成的 PDF 应该包含所有页面顺时针旋转 90 度,如图 17-2 所示。

“什么是递归?”PDF 水平方向定位,因此文本是侧面的。

图 17-2:旋转了 90 度顺时针的 rotated.pdf 文件

PyPDF 不能以 90 度以外的增量旋转文档。

插入空白页面

您可以使用 insert_blank_page()add_blank_page() 方法向 PdfWriter 对象插入或附加空白页面。新页面的尺寸将与前一页相同。例如,让我们创建一个带有空白页在末尾和第 3 页的示例章节 PDF 的副本:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> writer.add_blank_page() # ❶
{'/Type': '/Page', '/Resources': {}, '/MediaBox': [0.0, 0.0,
546, 708], '/Parent': IndirectObject(1, 0, 2629126028624)}
>>> writer.insert_blank_page(index=2) # ❷
{'/Type': '/Page', '/Parent': NullObject, '/Resources': {},
'/MediaBox': RectangleObject([0.0, 0.0, 546, 708])}
>>> with open('with_blanks.pdf', 'wb') as file:
...     writer.write(file)  # Save the writer object to a PDF file.
...
(False, <_io.BufferedWriter name='with_blanks.pdf'>) 

在将示例章节 PDF 的所有页面复制到 PdfWriter 对象之后,add_blank_page() 方法在文档末尾添加一个空白页面。insert_blank_page() 方法在页面索引 2(即第三页,因为页面索引 0 是第一页)处插入一个空白页面。此方法要求您指定 index 参数名称。

您可以选择将这些页面留空,或者稍后添加内容,例如叠加和水印,下一节将解释这些内容。

添加水印和叠加

PyPDF 可以将一页的内容叠加到另一页上,这对于在页面上添加徽标、时间戳或水印非常有用。在 PyPDF 中,戳记叠加 是放置在页面现有内容之上的内容,而 水印底图 是放置在页面现有内容之下的内容。

从本书的在线资源下载 watermark.pdf,并将其与样本章节 PDF 一起放置在当前工作目录中。然后,在交互式外壳中输入以下内容:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> watermark_page = pypdf.PdfReader('watermark.pdf').pages[0] # ❶
>>> for page in writer.pages:
...     page.merge_page(watermark_page, over=False) # ❷
...
>>> with open('with_watermark.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='with_watermark.pdf'>) 

此示例创建了一个新的 PdfWriter 对象中的样本章节 PDF 副本,保存在 writer 变量中。我们还获取了水印 PDF 的第一页的 Page 对象,并将其存储在 watermark_page 变量中。然后 for 循环遍历 PdfWriter 对象中的所有 Page 对象,并通过将其传递给 merge_page() 方法来应用水印。(不要将本章前面讨论过的 Page 对象的 merge_page() 方法与 PdfWriter 对象的 merge() 方法混淆。)

merge_page() 方法也接受一个名为 over 的关键字参数。将此参数传递为 True 以创建一个戳记或叠加,或者传递 False 以创建水印或底图。

在循环中修改 PdfWriter 对象的页面后,代码将其保存为 with_watermark.pdf。图 17-3 显示了原始水印 PDF 和应用了水印的样本章节 PDF 的两页。

三份 PDF。第一份包含一个带有“绝密”文字的对角灰色框,第二份显示了“绝密”框叠加在“什么是递归?”章节页面上,第三份显示了“绝密”框叠加在另一页文本上。

图 17-3:水印 PDF(左)和添加了水印的页面(中间,右)

merge_page() 方法对于对 PDF 文档进行广泛更改非常有用,例如合并两页的内容。

加密和解密 PDF

PDF 允许您加密其内容,使其不可读。加密的强度取决于您选择的密码,因此请创建一个使用不同字符类型的密码,不是词典中的单词,并且大约有 14 到 16 个字符。请记住,PDF 没有密码重置机制;如果您忘记了密码,除非您能猜到它,否则 PDF 将永远无法阅读。

PdfWriter 对象的 encrypt() 方法接受一个密码字符串和一个选择加密算法的字符串。'AES-256' 参数实现了一个推荐的现代加密算法,因此我们始终使用它。在交互式外壳中输入以下内容以创建样本章节 PDF 的加密副本:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> writer.encrypt('swordfish', algorithm='AES-256')
>>> with open('encrypted.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='encrypted.pdf'>) 

PdfWriter对象上调用encrypt('swordfish', algorithm='AES-256')方法将加密 PDF 的内容。在我们将加密的 PDF 写入encrypted.pdf文件后,没有任何 PDF 应用程序,包括 PyPDF,能够不输入密码swordfish而打开它。(这是一个糟糕的密码,因为它是一个在字典中出现的单词,因此很容易猜测。)除非您应用正确的解密密钥或密码,否则加密数据看起来是随机的,使用错误的密码解密文档会导致垃圾数据。PDF 应用程序会检测到这一点,然后提示您再次尝试密码。

PyPDF 可以为加密的 PDF 应用密码以解密它。在交互式 shell 中输入以下内容以检测具有is_encrypted属性的加密 PDF 并使用decrypt()解密:

>>> import pypdf
>>> reader = pypdf.PdfReader('encrypted.pdf') # ❶
>>> writer = pypdf.PdfWriter()
>>> reader.is_encrypted # ❷
True
>>> reader.pages[0] # ❸
Traceback (most recent call last):
# --snip--
pypdf.errors.FileNotDecryptedError: File has not been decrypted
>>> reader.decrypt('an incorrect password').name # ❹
'NOT_DECRYPTED'
>>> reader.decrypt('swordfish').name # ❺
'OWNER_PASSWORD'
>>> writer.append(reader) # ❻
>>> with open('decrypted.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='decrypted.pdf'>) 

我们就像加载任何其他 PDF 一样将加密的 PDF 加载到PdfReader对象中❶。PdfReader对象有一个is_encrypted属性❷,设置为TrueFalse。如果您尝试通过例如访问pages属性❸来读取 PDF 内容,PyPDF 会引发FileNotDecryptedError,因为它无法读取它。

PDF 文件可以有一个用户密码,允许您查看 PDF,以及一个所有者密码,允许您设置打印、注释、提取文本和其他功能的权限。用户密码和所有者密码分别是encrypt()函数的第一个和第二个参数。如果向encrypt()函数传递一个字符串参数,PyPDF 将使用它作为两个密码。

要解密PdfReader对象,调用decrypt()方法并传递密码字符串。此方法调用返回一个PasswordType对象;我们只对对象的name属性感兴趣。如果name设置为'NOT_DECRYPTED'❹,我们提供了错误的密码。如果name设置为'OWNER_PASSWORD''USER_PASSWORD'❺,我们已输入正确的所有者或用户密码。

我们现在可以将PdfReader对象中的页面追加到PdfWriter对象❻,并将解密后的 PDF 保存到文件中。

项目 12:从多个 PDF 中组合选择页面

假设您有一个合并几十个 PDF 文档到单个 PDF 文件的无聊工作。每个文档的第一页是封面,但您不希望在最终结果中重复封面。尽管有很多免费的 PDF 合并程序,但其中许多只是将整个文件合并在一起。让我们编写一个 Python 程序来自定义要包含在合并 PDF 中的页面。

从高层次来看,程序将执行以下操作:

  • 在当前工作目录中查找所有 PDF 文件,并按字母顺序排序。

  • 对于每个 PDF,将第一页之后的全部页面复制到输出 PDF 中。

  • 将输出 PDF 保存到文件中。

在实现方面,您的代码需要执行以下操作:

  • 调用os.listdir()以查找工作目录中的所有文件,并删除任何非 PDF 文件。(我们在第十一章中介绍了这个函数。)

  • 调用 Python 的 sort() 列表方法对文件名进行字母排序。

  • 为输出 PDF 创建一个 PdfWriter 对象。

  • 遍历每个 PDF 文件,为它创建一个 PdfReader 对象。

  • PdfReader 对象中,复制第一页之后的所有页面到输出 PDF。

  • 将输出 PDF 写入文件。

为此项目打开一个新的文件编辑标签,并将其保存为 combine_pdfs.py

旋转页面

我们还可以使用 Page 对象的 rotate() 方法以 90 度的增量旋转 PDF 的页面。将 90180270 作为参数传递给此方法以顺时针旋转页面,将 -90-180-270 传递给此方法以逆时针旋转页面。旋转页面在您有许多 PDF 文件,无论出于何种原因,已经错误旋转并且需要将它们旋转回来,或者只需要旋转 PDF 文档中的几个选定页面时非常有用。PDF 应用程序通常具有旋转功能,您可以使用它手动纠正 PDF,但 Python 允许您快速将旋转应用于许多 PDF,以自动化这项无聊的任务。

例如,在交互式外壳中输入以下内容以旋转样本章节 PDF 的页面:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> for i in range(len(writer.pages)): # ❶
...     for page in reader.pages: # ❶
...
{'/ArtBox': [21, 21, 525, 687], '/BleedBox': [12, 12, 534, 696],
# --snip--

>>> with open('rotated.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='rotated.pdf'>) 

我们创建一个新的 PdfWriter 对象,并将样本章节 PDF 的页面复制到其中。然后,我们使用 for 循环遍历每个页面编号。调用 len(writer.pages) 返回页数 ❶ 作为整数。表达式 writer.pages[i]for 循环的每次迭代中访问每个 Page 对象,并且 rotate(90) 方法调用 ❷ 在 PdfWriter 对象中旋转此页面。

生成的 PDF 应该包含所有页面顺时针旋转 90 度,如图 17-2 所示。

“什么是递归?”的 PDF 水平放置,因此文本是侧面的。

图 17-2:旋转后的 rotated.pdf 文件,页面已顺时针旋转 90 度

PyPDF 不能以 90 度以外的增量旋转文档。

插入空白页

您可以使用 insert_blank_page()add_blank_page() 方法向 PdfWriter 对象插入或附加一个空白页。新页面的尺寸将与前一页相同。例如,让我们创建一个样本章节 PDF 的副本,在末尾和第 3 页添加空白页:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> writer.add_blank_page() # ❶
{'/Type': '/Page', '/Resources': {}, '/MediaBox': [0.0, 0.0,
546, 708], '/Parent': IndirectObject(1, 0, 2629126028624)}
>>> writer.insert_blank_page(index=2) # ❷
{'/Type': '/Page', '/Parent': NullObject, '/Resources': {},
'/MediaBox': RectangleObject([0.0, 0.0, 546, 708])}
>>> with open('with_blanks.pdf', 'wb') as file:
...     writer.write(file)  # Save the writer object to a PDF file.
...
(False, <_io.BufferedWriter name='with_blanks.pdf'>) 

在将样本章节 PDF 的所有页面复制到 PdfWriter 对象之后,add_blank_page() 方法会在文档末尾添加一个空白页。insert_blank_page() 方法会在页面索引 2(即第三页,因为页面索引 0 是第一页)处插入一个空白页。此方法需要您指定 index 参数名称。

您可以选择保留这些页面为空白,或者稍后向它们添加内容,例如叠加和水印,如下一节所述。

添加水印和叠加

PyPDF 也可以将一页的内容叠加到另一页上,这对于在页面上添加徽标、时间戳或水印很有用。在 PyPDF 中,戳记叠加是放置在页面现有内容之上的内容,而水印底图是放置在页面现有内容之下的内容。

从书籍的在线资源下载 watermark.pdf,并将 PDF 文件放置在当前工作目录中,与示例章节 PDF 文件一起。然后,在交互式 shell 中输入以下内容:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> watermark_page = pypdf.PdfReader('watermark.pdf').pages[0] # ❶
>>> for page in writer.pages:
...     page.merge_page(watermark_page, over=False) # ❷
...
>>> with open('with_watermark.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='with_watermark.pdf'>) 

此示例在新的 PdfWriter 对象中创建样本章节 PDF 的副本,并保存在 writer 变量中。我们还获取水印 PDF 的第一页的 Page 对象,并将其存储在 watermark_page 变量中。然后 for 循环遍历 PdfWriter 对象中的所有 Page 对象,并通过传递给 merge_page() 来应用水印。(不要将本章前面讨论的 Page 对象的 merge_page() 方法与 PdfWriter 对象的 merge() 方法混淆。)

merge_page() 方法还有一个 over 关键字参数。传递 True 给此参数以创建一个戳记或叠加,或者传递 False 以创建一个水印或底图。

在循环中修改 PdfWriter 对象的页面后,代码将其保存为 with_watermark.pdf。图 17-3 显示了原始水印 PDF 和两个应用了水印的样本章节 PDF 页面。

三份 PDF 文件。第一份包含一个带有“绝密”文字的对角灰色框,第二份显示了“绝密”框叠加在“什么是递归?”章节页面上,第三份显示了“绝密”框叠加在另一页文本上。

图 17-3:水印 PDF(左)和添加了水印的页面(中间,右)

merge_page() 方法对于对 PDF 文档进行广泛更改很有用,例如合并两页的内容。

加密和解密 PDF 文件

PDF 文件允许您加密其内容,使其不可读。加密的强度取决于您选择的密码,因此请创建一个使用不同字符类型的密码,不是字典中的单词,并且大约有 14 到 16 个字符。请记住,PDF 没有密码重置机制;如果您忘记了密码,除非您能猜到它,否则 PDF 将永远不可读。

PdfWriter 对象的 encrypt() 方法接受一个密码字符串和一个选择加密算法的字符串。'AES-256' 参数实现了一个推荐的现代加密算法,因此我们始终使用它。在交互式 shell 中输入以下内容以创建样本章节 PDF 的加密副本:

>>> import pypdf
>>> writer = pypdf.PdfWriter()
>>> writer.append('Recursion_Chapter1.pdf')
>>> writer.encrypt('swordfish', algorithm='AES-256')
>>> with open('encrypted.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='encrypted.pdf'>) 

PdfWriter 对象上调用 encrypt('swordfish', algorithm='AES-256') 方法将 PDF 的内容加密。在我们将加密的 PDF 写入 encrypted.pdf 文件后,没有任何 PDF 应用程序,包括 PyPDF,能够在不输入密码 swordfish 的情况下打开它。(这是一个很差的密码,因为它是一个在字典中出现的单词,因此很容易猜测。)除非应用正确的解密密钥或密码,否则加密数据看起来是随机的,使用错误的密码解密文档会导致垃圾数据。PDF 应用程序会检测到这一点,然后提示你再次尝试密码。

PyPDF 可以给加密的 PDF 应用密码以解密它。在交互式外壳中输入以下内容以检测具有 is_encrypted 属性的加密 PDF 并使用 decrypt() 解密:

>>> import pypdf
>>> reader = pypdf.PdfReader('encrypted.pdf') # ❶
>>> writer = pypdf.PdfWriter()
>>> reader.is_encrypted # ❷
True
>>> reader.pages[0] # ❸
Traceback (most recent call last):
# --snip--
pypdf.errors.FileNotDecryptedError: File has not been decrypted
>>> reader.decrypt('an incorrect password').name # ❹
'NOT_DECRYPTED'
>>> reader.decrypt('swordfish').name # ❺
'OWNER_PASSWORD'
>>> writer.append(reader) # ❻
>>> with open('decrypted.pdf', 'wb') as file:
...     writer.write(file)
...
(False, <_io.BufferedWriter name='decrypted.pdf'>) 

我们就像加载任何其他 PDF 一样将加密的 PDF 加载到 PdfReader 对象中 ❶。PdfReader 对象有一个 is_encrypted 属性 ❷,它被设置为 TrueFalse。如果你尝试通过例如访问 pages 属性 ❸ 来读取 PDF 内容,PyPDF 会抛出一个 FileNotDecryptedError,因为它无法读取它。

PDF 可以有一个 用户密码,允许你查看 PDF,以及一个 所有者密码,允许你设置打印、注释、提取文本和其他功能的权限。用户密码和所有者密码分别是 encrypt() 的第一个和第二个参数。如果只传递一个字符串参数给 encrypt(),PyPDF 将使用它作为两个密码。

要解密 PdfReader 对象,调用 decrypt() 方法并传递密码字符串。这个方法调用返回一个 PasswordType 对象;我们只对对象的 name 属性感兴趣。如果 name 设置为 'NOT_DECRYPTED' ❹,我们提供了错误的密码。如果 name 设置为 'OWNER_PASSWORD''USER_PASSWORD' ❺,我们已输入正确的所有者或用户密码。

现在我们可以将 PdfReader 对象的页面追加到 PdfWriter 对象中 ❻ 并将解密后的 PDF 保存到文件。

项目 12:从多个 PDF 中合并选择页面

假设你有一个无聊的工作,就是将几十个 PDF 文档合并成一个单一的 PDF 文件。每个文档的第一页是封面,但你不想在最终结果中重复封面。尽管有很多免费的 PDF 合并程序,但其中许多只是简单地合并整个文件。让我们编写一个 Python 程序来自定义要包含在合并 PDF 中的页面。

从高层次来看,程序将执行以下操作:

  • 在当前工作目录中查找所有 PDF 文件,并按字母顺序排序。

  • 对于每个 PDF,将第一页之后的全部页面复制到输出 PDF 中。

  • 将输出 PDF 保存到文件。

在实现方面,你的代码需要执行以下操作:

  • 调用 os.listdir() 来查找工作目录中的所有文件,并删除任何非 PDF 文件。(我们在第十一章中介绍了这个函数。)

  • 调用 Python 的 sort() 列表方法来对文件名进行字母排序。

  • 为输出 PDF 创建一个 PdfWriter 对象。

  • 遍历每个 PDF 文件,为它创建一个 PdfReader 对象。

  • PdfReader 对象中,将第一页之后的所有页面复制到输出 PDF 中。

  • 将输出 PDF 写入文件。

为此项目打开一个新的文件编辑标签,并将其保存为 combine_pdfs.py

第 1 步:查找所有 PDF 文件

首先,您的程序需要获取当前工作目录中所有具有 .pdf 扩展名的文件的列表并对其进行排序。使您的代码看起来如下所示:

# combine_pdfs.py - Combines all the PDFs in the current working directory
# into a single PDF

import pypdf, os # ❶

# Get all the PDF filenames.
pdf_filenames = []
for filename in os.listdir('.'):
    if filename.endswith('.pdf'):
        for page in reader.pages: # ❶
pdf_filenames.sort(key=str.lower) # ❸

writer = pypdf.PdfWriter() # ❹

# TODO: Loop through all the PDF files.

# TODO: Copy all pages after the first page.

# TODO: Save the resulting PDF to a file. 

此代码导入 pypdfos 模块 ❶。os.listdir('.') 调用将返回当前工作目录中每个文件的列表。然后代码遍历此列表,将具有 .pdf 扩展名的文件添加到 pdf_filenames 变量中的列表中 ❷。接下来,我们使用 sort() 方法的 key=str.lower 关键字参数按字母顺序排序此列表 ❸。由于技术原因,sort() 方法将大写字母如 Z 放在小写字母如 a 之前;我们提供的关键字参数通过比较字符串的小写形式来防止这种情况。我们创建一个 PdfWriter 对象来保存合并的 PDF 页面 ❹。最后,一些注释概述了程序的其余部分。

第 2 步:打开每个 PDF

现在,程序必须读取 pdf_filenames 中的每个 PDF 文件。将以下内容添加到您的程序中:

# combine_pdfs.py - Combines all the PDFs in the current working directory
# into a single PDF

import pypdf, os

# --snip--

# Loop through all the PDF files:
for pdf_filename in pdf_filenames:
 reader = pypdf.PdfReader(pdf_filename)
 # Copy all pages after the first page:
 writer.append(pdf_filename, (1, len(reader.pages)))

# TODO: Save the resulting PDF to a file. 

对于每个 PDF 文件名,循环创建一个 PdfReader 对象,并将其存储在名为 reader 的变量中。现在循环内的代码可以调用 len(reader.pages) 来找出 PDF 有多少页。它使用此信息在 append() 方法调用中复制从 1(因为 PyPDF 使用 0 作为第一页索引)开始的页面,直到 PDF 的末尾。然后,它将内容追加到 writer 中的同一个 PdfWriter 对象。

第 3 步:保存结果

一旦这些 for 循环完成循环,writer 变量应该包含一个包含所有 PDF 页面的 PdfWriter 对象。最后一步是将此内容写入硬盘上的文件。将以下代码添加到您的程序中:

# combine_pdfs.py - Combines all the PDFs in the current working directory
# into a single PDF

import pypdf, os

# --snip--

# Save the resulting PDF to a file:
with open('combined.pdf', 'wb') as file:
 writer.write(file) 

'wb' 传递给 open() 打开输出 PDF 文件,combined.pdf,在写二进制模式下。然后,将生成的 File 对象传递给 write() 方法创建实际的 PDF 文件。(注意 File 对象和 PdfWriter 对象中同名 write() 方法。)程序结束时,一个单独的 PDF 包含文件夹中每个 PDF 的所有页面(除了第一页),按文件名字母顺序排序。

类似程序的思路

能够从其他 PDF 的页面创建 PDF 将使您能够编写能够执行以下操作的程序:

  • 从 PDF 中裁剪特定页面。

  • 反转或重新排序 PDF 中的页面。

  • 从其他 PDF 的具有特定文本的页面创建 PDF,该文本由 Page 对象的 extract_text() 方法识别。

Word 文档

Python 可以使用 Python-Docx 包创建和修改具有 .docx 扩展名的 Microsoft Word 文档,你可以通过附录 A 中的说明进行安装。

警告

务必安装 Python-Docx,而不是 Docx,后者属于一个不同的包,本书没有涉及。然而,当你从 Python-Docx 包中导入模块时,你需要运行 import docx,而不是 import python-docx*。

如果你没有 Word,你可以使用免费的 LibreOffice Writer 应用程序(适用于 Windows、macOS 和 Linux)来打开 .docx 文件。从 www.libreoffice.org 下载它。尽管 Word 可以在 macOS 上运行,但本章将重点介绍 Windows 上的 Word。此外,请注意,虽然基于浏览器的 Office 365 和 Google Docs 网络应用程序很受欢迎,但它们也可以导入和导出 .docx 文件。

与纯文本文件相比,.docx 文件具有许多结构元素,Python-Docx 使用三种不同的数据类型来表示这些元素。在最高级别,一个 Document 对象代表整个文档。Document 对象包含一个文档中段落的 Paragraph 对象列表。(在 Word 文档中,用户在键入时按下 ENTER 或 RETURN 键时,就会开始一个新段落。)这些 Paragraph 对象中的每一个都包含一个或多个 Run 对象的列表。图 17-4 中的单句段落有四个运行。

句子“一个包含一些粗体和斜体文本的普通段落。”单词“粗体”是粗体的,单词“斜体”是斜体的。格式相同的每个文本块都被标记为“Run。”

图 17-4:在 Paragraph 对象中识别出的 Run 对象

Word 文档中的文本不仅仅是字符串。它还与字体、大小、颜色和其他与它关联的样式信息相关。Word 中的 样式 是这些属性的集合。Run 对象是具有相同样式的连续文本运行。每当文本样式发生变化时,你都需要一个新的 Run 对象。

阅读 Word 文档

让我们实验一下 docx 模块。从本书的在线资源下载 demo.docx 并将文档保存到工作目录中。然后,在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document('demo.docx')
>>> len(doc.paragraphs)
7
>>> doc.paragraphs[0].text
'Document Title'
>>> doc.paragraphs[1].text
'A plain paragraph with some bold text and some italic'
>>> len(doc.paragraphs[1].runs)
4
>>> doc.paragraphs[1].runs[0].text
'A plain paragraph with some '
>>> doc.paragraphs[1].runs[1].text
'bold'
>>> doc.paragraphs[1].runs[2].text
' and some '
>>> doc.paragraphs[1].runs[3].text
'italic' 

我们在 Python 中打开 .docx 文件,调用 docx.Document(),并传递文件名 demo.docx。这将返回一个 Document 对象,它有一个 paragraphs 属性,这是一个 Paragraph 对象的列表。当我们对这个属性调用 len() 时,它返回 7,这告诉我们在这个文档中有七个 Paragraph 对象。这些 Paragraph 对象中的每一个都有一个 text 属性,它包含该段落中的文本字符串(不包含样式信息)。在这里,第一个 text 属性包含 'DocumentTitle',第二个包含 'A plain paragraph with some bold text and some italic'

每个 Paragraph 对象都有一个 runs 属性,它是一个 Run 对象的列表。Run 对象也有一个 text 属性,包含该特定运行中的文本。让我们看看第二个 Paragraph 对象中的 text 属性。对这个对象调用 len() 告诉我们,这里有四个 Run 对象。第一个 Run 对象包含 '一个普通的段落,包含一些 '。然后,文本变为粗体样式,所以 'bold' 开始一个新的 Run 对象。之后,文本又回到非粗体样式,这导致第三个 Run 对象,' text and some '。最后,第四个也是最后一个 Run 对象包含 'italic' 并以斜体显示。

使用 Python-Docx,您的 Python 程序现在可以读取 .docx 文件中的文本,并像使用任何其他字符串值一样使用它。

从 .docx 文件中获取全文

如果您只关心 Word 文档的文本而不关心其样式信息,您可以使用此 get_text() 函数。它接受一个 .docx 文件的文件名,并返回其文本的单个字符串值。打开一个新的文件编辑标签,并输入以下代码,将其保存为 readDocx.py

import docx

def get_text(filename):
    doc = docx.Document(filename)
    full_text = []
    for para in doc.paragraphs:
        full_text.append(para.text)
    return '\n'.join(full_text) 

这个 get_text() 函数打开 Word 文档,遍历 paragraphs 列表中的所有 Paragraph 对象,然后将它们的文本追加到 full_text 列表中。循环结束后,代码使用换行符将 full_text 中的字符串连接起来。

您可以像导入其他模块一样导入 readDocx.py 程序。现在,如果您只需要 Word 文档的文本,您可以输入以下内容:

>>> import readDocx
>>> print(readDocx.get_text('demo.docx'))
Document Title
A plain paragraph with some bold text and some italic
Heading, level 1
Intense quote
first item in unordered list
first item in ordered list 

您还可以调整 get_text() 以修改返回之前字符串。例如,要缩进每个段落,将 readDocx.py 中的 append() 调用替换为以下内容:

full_text.append('  ' + para.text)

要在段落之间添加双倍间距,将 join() 调用代码更改为以下内容:

return '\n\n'.join(full_text)

如您所见,编写函数来读取 .docx 文件并将其内容作为字符串返回,只需几行代码即可。

段落和运行对象的样式

Word 和其他文字处理程序使用样式来保持文本的可视呈现一致且易于更改。例如,也许您希望所有正文段落都是 11 点、Times New Roman、左对齐、右边缘参差不齐的文本。您可以创建一个具有这些设置的样式并将其分配给所有正文段落。如果您以后想更改文档中所有正文段落的呈现方式,您可以更改样式以自动更新这些段落。

要在基于浏览器的 Office 365 Word 应用程序中查看样式,请单击 主页 菜单项,然后选择 标题和其他样式 下拉菜单,它可能会显示“正常”或其他样式名称。单击 查看更多样式 以打开更多样式窗口。在 Windows 的 Microsoft Word 桌面应用程序中,您可以通过按 CTRL-ALT-SHIFT-S 显示样式面板,它看起来像图 17-5。在 LibreOffice Writer 中,您可以通过单击 视图样式 菜单项来查看样式面板。

在左侧,是包含样式列表的 Word 样式面板的截图,其中“查看更多样式”选项被突出显示。在右侧,打开的“更多样式”面板显示了一个“样式名称”搜索栏和样式列表。

图 17-5:样式面板

Word 文档包含三种类型的样式:段落样式应用于 Paragraph 对象,字符样式应用于 Run 对象,链接样式应用于这两种类型的对象。要设置 ParagraphRun 对象的样式,将它们的 style 属性设置为样式的名称字符串。如果 style 设置为 None,则不会与 ParagraphRun 对象关联任何样式。默认 Word 样式具有以下字符串值:

'Normal'   'Heading 5' 'List Bullet'       'List Paragraph'
'Body Text' 'Heading 6' 'List Bullet 2'     'MacroText'
'Body Text 2'   'Heading 7' 'List Bullet 3'     'No Spacing'
'Body Text 3'   'Heading 8' 'List Continue'     'Quote'
'Caption'   'Heading 9' 'List Continue 2'   'Subtitle'
'Heading 1' 'Intense Quote' 'List Continue 3'   'TOC Heading'
'Heading 2' 'List'      'List Number '      'Title'
'Heading 3' 'List 2'    'List Number 2' 
'Heading 4' 'List 3'    'List Number 3' 

当为 Run 对象使用链接样式时,您需要在名称末尾添加 ' Char'。例如,要设置 Paragraph 对象的引用链接样式,您将使用 paragraphObj.style = 'Quote',但对于 Run 对象,您将使用 runObj.style = 'Quote Char'

要创建自定义样式,请使用 Word 应用程序定义它们,然后从 ParagraphRun 对象的 style 属性中读取它们。

应用运行属性

我们可以使用 text 属性进一步设置运行的样式。每个属性可以设置为三个值之一:True(表示无论对运行应用了什么其他样式,该属性始终启用),False(表示该属性始终禁用),或 None(默认为运行设置的样式)。表 17-1 列出了可以在 Run 对象上设置的 text 属性。

表 17-1:Run 对象 text 属性

属性 描述
bold 文本呈现粗体。
italic 文本以斜体显示。
underline 文本被下划线标注。
strike 文本带有删除线。
double_strike 文本带有双删除线。
all_caps 文本以大写字母显示。
small_caps 文本以大写字母显示,小写字母比大写字母小两点。
shadow 文本带有阴影。
outline 文本以轮廓形式呈现,而不是实心。
rtl 文本从右到左书写。
imprint 文本看起来被压入页面。
emboss 文本在页面上呈现凸起效果。

例如,要更改 demo.docx 的样式,请在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document('demo.docx')
>>> doc.paragraphs[0].text
'Document Title'
>>> doc.paragraphs[0].style  # The exact id may be different.
_ParagraphStyle('Title') id: 3095631007984
>>> doc.paragraphs[0].style = 'Normal'
>>> doc.paragraphs[1].text
'A plain paragraph with some bold text and some italic'
>>> (doc.paragraphs[1].runs[0].text, doc.paragraphs[1].runs[1].text, 
doc.paragraphs[1].runs[2].text, doc.paragraphs[1].runs[3].text)
('A plain paragraph with some ', 'bold', ' and some ', 'italic')
>>> doc.paragraphs[1].runs[0].style = 'Quote Char'
>>> doc.paragraphs[1].runs[1].underline = True
>>> doc.paragraphs[1].runs[3].underline = True
>>> doc.save('restyled.docx') 

我们使用 textstyle 属性来轻松查看文档中的段落。正如您所看到的,将段落划分为运行并单独访问每个运行非常容易。我们在第二段中获取了第一个、第二个和第四个运行,为每个运行设置样式,并将结果保存到新文档中。

现在,restyled.docx 文件顶部的 Document Title 应该使用 Normal 风格而不是 Title 风格,文本 A plain paragraph with someRun 对象应使用 Quote Char 风格,而单词 bolditalic 的两个 Run 对象应将它们的 underline 属性设置为 True。图 17-6 显示了 restyled.docx 中的段落和运行样式的外观。

包含文本“A plain paragraph with some bold and some italic.”的 Word 文档。一个箭头指向包含“Quote”样式的样式栏。另一个箭头指向应用了此样式的文本。

图 17-6:restyled.docx 文件

您可以在 python-docx.readthedocs.io 上找到有关 Python-Docx 使用样式的完整文档。

编写 Word 文档

要创建自己的 .docx 文件,请调用 docx.Document() 以返回一个新的、空白的 Word Document 对象。例如,在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document()
>>> doc.add_paragraph('Hello, world!')
<docx.text.paragraph.Paragraph object at 0x0000000003B56F60>
>>> doc.save('helloworld.docx') 

add_paragraph() 文档方法向文档添加新的文本段落,并返回添加的 Paragraph 对象的引用。当您完成添加文本后,将文件名字符串传递给 save() 文档方法以将 Document 对象保存到文件。

此代码将在当前工作目录中创建一个名为 helloworld.docx 的文件。打开后,它应该看起来像图 17-7 所示。您可以将此 .docx 文件上传到 Office 365 或 Google Docs,或者在 Word 或 LibreOffice 中打开它。

包含文本“Hello, world!”的 Word 文档

图 17-7:使用 add_paragraph('Hello, world!') 创建的 Word 文档

您可以通过再次调用 add_paragraph() 方法并传递新段落的文本来向文档中添加段落。要将文本添加到现有段落的末尾,请调用该段落的 add_run() 方法并传递一个字符串。在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document()
>>> doc.add_paragraph('Hello world!')
<docx.text.paragraph.Paragraph object at 0x000000000366AD30>
>>> para_obj_1 = doc.add_paragraph('This is a second paragraph.')
>>> para_obj_2 = doc.add_paragraph('This is a yet another paragraph.')
>>> para_obj_1.add_run(' This text is being added to the second paragraph.')
<docx.text.run.Run object at 0x0000000003A2C860>
>>> doc.save('multipleParagraphs.docx') 

生成的文档应该看起来像图 17-8 所示。请注意,文本 This text is being added to the second paragraph. 已添加到 para_obj_1 中的 Paragraph 对象,它是添加到 doc 中的第二个段落。add_paragraph()add_run() 函数分别返回 ParagraphRun 对象,以避免您在单独的步骤中提取它们。

再次调用 save() 方法以保存您所做的额外更改。

包含三行文本的 Word 文档。第一行说“Hello world!”,第二行说“这是第二个段落。此文本被添加到第二个段落中。”,第三行说“这是另一个段落。”

图 17-8:添加了多个 Paragraph 和 Run 对象的文档

add_paragraph()add_run() 都接受一个可选的第二个参数,该参数是 ParagraphRun 对象样式的字符串。以下是一个示例:

>>> doc.add_paragraph('Hello, world!', 'Title')
<docx.text.paragraph.Paragraph object at 0x00000213E6FA9190> 

这行代码添加了一个包含文本 Hello, world! 的标题样式段落。

添加标题

调用 add_heading() 会添加一个带有标题样式的段落。在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document()
>>> doc.add_heading('Header 0', 0)
<docx.text.paragraph.Paragraph object at 0x00000000036CB3C8>
>>> doc.add_heading('Header 1', 1)
<docx.text.paragraph.Paragraph object at 0x00000000036CB630>
>>> doc.add_heading('Header 2', 2)
<docx.text.paragraph.Paragraph object at 0x00000000036CB828>
>>> doc.add_heading('Header 3', 3)
<docx.text.paragraph.Paragraph object at 0x00000000036CB2E8>
>>> doc.add_heading('Header 4', 4)
<docx.text.paragraph.Paragraph object at 0x00000000036CB3C8>
>>> doc.save('headings.docx') 

生成的 headings.docx 文件应类似于图 17-9。

包含“标题 0”、“标题 1”、“标题 2”、“标题 3”和“标题 4”文本,字体大小逐渐变小的 Word 文档

图 17-9:包含标题 0 到 4 的 headings.docx 文档

add_heading() 的参数是一个包含标题文本的字符串和一个从 04 的整数。整数 0 使标题成为标题样式,我们将其用于文档的顶部。整数 19 用于各种标题级别,其中 1 是主要标题,9 是最低的子标题。add_heading() 函数返回一个 Paragraph 对象,以避免从 Document 对象中作为单独步骤提取它。

添加行和分页符

要添加换行符(而不是开始一个全新的段落),可以在你希望换行符出现之后的 Run 对象上调用 add_break() 方法。如果你想添加分页符,需要将值 docx.enum.text.WD_BREAK.PAGE 作为单独的参数传递给 add_break(),就像在以下示例的中间所做的那样:

>>> doc = docx.Document()
>>> doc.add_paragraph('This is on the first page!')
<docx.text.paragraph.Paragraph object at 0x0000000003785518>
>>> doc.paragraphs[0].runs[0].add_break(docx.enum.text.WD_BREAK.PAGE) # ❶
>>> doc.add_paragraph('This is on the second page!')
<docx.text.paragraph.Paragraph object at 0x00000000037855F8>
>>> doc.save('twoPage.docx') 

此代码创建了一个两页的 Word 文档,第一页上写着 This is on the first page!,第二页上写着 This is on the second page!。尽管在 This is on the first page! 文本之后还有足够的空间在第一页上,但我们通过在第一段的第一行之后插入分页符,强制下一个段落在新页上开始 ❶。

添加图片

你可以使用 Document 对象的 add_picture() 方法在文档末尾添加一个图像。假设你当前工作目录中有一个名为 zophie.png 的文件。你可以通过输入以下内容将 zophie.png 添加到文档末尾,宽度为 1 英寸,高度为 4 厘米(Word 可以使用英制和公制单位):

>>> doc.add_picture('zophie.png', width=docx.shared.Inches(1), height=docx.shared.Cm(4))
<docx.shape.InlineShape object at 0x00000000036C7D30> 

第一个参数是图像文件名的字符串。可选的 widthheight 关键字参数将设置文档中图像的宽度和高度。如果省略,宽度和高度将默认为图像的正常大小。

你可能更喜欢使用熟悉的单位,如英寸和厘米来指定图像的高度和宽度,因此当你指定 widthheight 关键字参数时,可以使用 docx.shared.Inches()docx.shared.Cm() 函数。

读取 Word 文档

让我们实验一下 docx 模块。从本书的在线资源下载 demo.docx 并将文档保存到工作目录中。然后,在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document('demo.docx')
>>> len(doc.paragraphs)
7
>>> doc.paragraphs[0].text
'Document Title'
>>> doc.paragraphs[1].text
'A plain paragraph with some bold text and some italic'
>>> len(doc.paragraphs[1].runs)
4
>>> doc.paragraphs[1].runs[0].text
'A plain paragraph with some '
>>> doc.paragraphs[1].runs[1].text
'bold'
>>> doc.paragraphs[1].runs[2].text
' and some '
>>> doc.paragraphs[1].runs[3].text
'italic' 

在 Python 中打开一个 .docx 文件,调用 docx.Document(),并传入文件名 demo.docx。这将返回一个 Document 对象,它有一个 paragraphs 属性,该属性是一个 Paragraph 对象的列表。当我们对这个属性调用 len() 时,它返回 7,这告诉我们在这个文档中有七个 Paragraph 对象。这些 Paragraph 对象中的每一个都有一个 text 属性,它包含该段落中的文本字符串(不包含样式信息)。在这里,第一个 text 属性包含 'DocumentTitle',第二个包含 '一个普通的段落,包含一些粗体文本和一些斜体文本'

每个 Paragraph 对象还有一个 runs 属性,它是一个 Run 对象的列表。Run 对象也有一个 text 属性,它只包含该特定运行中的文本。让我们看看第二个 Paragraph 对象中的 text 属性。对这个对象调用 len() 告诉我们,这里有四个 Run 对象。第一个 Run 对象包含 '一个普通的段落,包含一些 '。然后,文本变为粗体样式,所以 'bold' 开始一个新的 Run 对象。文本在之后又回到了非粗体样式,这导致第三个 Run 对象,' text and some '。最后,第四个也是最后一个 Run 对象包含以斜体样式显示的 'italic'

使用 Python-Docx,你的 Python 程序现在可以读取 .docx 文件中的文本,并像使用任何其他字符串值一样使用它。

从 .docx 文件中获取全文

如果你只关心 Word 文档的文本而不关心其样式信息,你可以在下面使用这个 get_text() 函数。它接受一个 .docx 文件的文件名,并返回一个包含其文本的单个字符串值。打开一个新的文件编辑标签,并输入以下代码,将其保存为 readDocx.py

import docx

def get_text(filename):
    doc = docx.Document(filename)
    full_text = []
    for para in doc.paragraphs:
        full_text.append(para.text)
    return '\n'.join(full_text) 

这个 get_text() 函数打开 Word 文档,遍历 paragraphs 列表中的所有 Paragraph 对象,然后将它们的文本追加到 full_text 列表中。循环结束后,代码使用换行符将 full_text 中的字符串连接起来。

你可以像导入其他模块一样导入 readDocx.py 程序。现在,如果你只需要 Word 文档的文本,你可以输入以下内容:

>>> import readDocx
>>> print(readDocx.get_text('demo.docx'))
Document Title
A plain paragraph with some bold text and some italic
Heading, level 1
Intense quote
first item in unordered list
first item in ordered list 

你也可以调整 get_text() 来修改返回之前字符串。例如,要缩进每个段落,将 readDocx.py 中的 append() 调用替换为以下内容:

full_text.append('  ' + para.text)

要在段落之间添加双空格,将 join() 调用代码更改为以下内容:

return '\n\n'.join(full_text)

如你所见,编写函数来读取 .docx 文件并将内容作为字符串返回到你的程序中,只需要几行代码。

段落和运行对象的样式

Word 和其他文字处理器使用样式来保持文本的可视呈现一致且易于更改。例如,你可能希望所有正文段落都是 11 点 Times New Roman,左对齐,右边缘参差不齐的文本。你可以创建具有这些设置的样式并将其分配给所有正文段落。如果你以后想更改文档中所有正文段落的呈现方式,你可以更改样式以自动更新这些段落。

要在基于浏览器的 Office 365 Word 应用程序中查看样式,请单击 主页 菜单项,然后单击 标题和其他样式 下拉菜单,它可能会显示“正常”或其他样式名称。单击 查看更多样式 以打开更多样式窗口。在 Windows 的 Microsoft Word 桌面应用程序中,你可以通过按 CTRL-ALT-SHIFT-S 显示样式面板来查看样式,它看起来像图 17-5。在 LibreOffice Writer 中,你可以通过单击 查看样式 菜单项来查看样式面板。

在左侧,是包含样式列表的 Word 样式面板的截图,其中“查看更多样式”选项突出显示。在右侧,打开的“更多样式”面板显示“样式名称”搜索栏和样式列表。

图 17-5:样式面板

Word 文档包含三种类型的样式:段落样式应用于 Paragraph 对象,字符样式应用于 Run 对象,链接样式应用于这两种类型的对象。要样式化 ParagraphRun 对象,将它们的 style 属性设置为样式的名称字符串。如果 style 设置为 None,则不会与 ParagraphRun 对象关联任何样式。默认 Word 样式具有以下字符串值:

'Normal'   'Heading 5' 'List Bullet'       'List Paragraph'
'Body Text' 'Heading 6' 'List Bullet 2'     'MacroText'
'Body Text 2'   'Heading 7' 'List Bullet 3'     'No Spacing'
'Body Text 3'   'Heading 8' 'List Continue'     'Quote'
'Caption'   'Heading 9' 'List Continue 2'   'Subtitle'
'Heading 1' 'Intense Quote' 'List Continue 3'   'TOC Heading'
'Heading 2' 'List'      'List Number '      'Title'
'Heading 3' 'List 2'    'List Number 2' 
'Heading 4' 'List 3'    'List Number 3' 

当为 Run 对象使用链接样式时,你需要在名称末尾添加 ' Char'。例如,要设置 Paragraph 对象的引用链接样式,你会使用 paragraphObj.style = 'Quote',但对于 Run 对象,你会使用 runObj.style = 'Quote Char'

要创建自定义样式,请使用 Word 应用程序定义它们,然后从 ParagraphRun 对象的 style 属性中读取它们。

应用运行属性

我们可以使用 text 属性进一步样式化运行。每个属性可以设置为三个值之一:True(表示无论应用了什么其他样式,该属性始终启用),False(表示该属性始终禁用),或 None(默认为运行设置的样式)。表 17-1 列出了可以在 Run 对象上设置的 text 属性。

表 17-1:Run 对象 text 属性

属性 描述
bold 文本以粗体显示。
italic 文本以斜体显示。
underline 文本带有下划线。
strike 文本带有删除线。
double_strike 文本以双删除线显示。
all_caps 文本以大写字母显示。
small_caps 文本以大写字母出现,小写字母小两点。
shadow 文本带有阴影。
outline 文本以轮廓形式出现,而不是实心。
rtl 文本是从右到左书写的。
imprint 文本看起来像是压印在页面上的。
emboss 文本以凸起的形式从页面上抬起。

例如,要更改 demo.docx 的样式,请在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document('demo.docx')
>>> doc.paragraphs[0].text
'Document Title'
>>> doc.paragraphs[0].style  # The exact id may be different.
_ParagraphStyle('Title') id: 3095631007984
>>> doc.paragraphs[0].style = 'Normal'
>>> doc.paragraphs[1].text
'A plain paragraph with some bold text and some italic'
>>> (doc.paragraphs[1].runs[0].text, doc.paragraphs[1].runs[1].text, 
doc.paragraphs[1].runs[2].text, doc.paragraphs[1].runs[3].text)
('A plain paragraph with some ', 'bold', ' and some ', 'italic')
>>> doc.paragraphs[1].runs[0].style = 'Quote Char'
>>> doc.paragraphs[1].runs[1].underline = True
>>> doc.paragraphs[1].runs[3].underline = True
>>> doc.save('restyled.docx') 

我们使用 textstyle 属性来轻松查看文档中的段落。如您所见,很容易将段落分成运行,并单独访问每个运行。我们在第二段中获取第一个、第二个和第四个运行,为每个运行设置样式,并将结果保存到新文档中。

现在,restyled.docx 顶部的 Document Title 应该使用 Normal 样式而不是 Title 样式,文本 A plain paragraph with someRun 对象应使用 Quote Char 样式,而两个用于单词 bolditalicRun 对象应将它们的 underline 属性设置为 True。图 17-6 显示了 restyled.docx 中的段落和运行样式。

包含文本“一个包含一些粗体和斜体的普通段落。”的 Word 文档。一个箭头指向包含“引用”样式的样式栏。另一个箭头指向应用了此样式的文本。

图 17-6:restyled.docx 文件

您可以在 python-docx.readthedocs.io 找到关于 Python-Docx 使用样式的完整文档。

编写 Word 文档

要创建自己的 .docx 文件,请调用 docx.Document() 以返回一个新的、空白的 Word Document 对象。例如,在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document()
>>> doc.add_paragraph('Hello, world!')
<docx.text.paragraph.Paragraph object at 0x0000000003B56F60>
>>> doc.save('helloworld.docx') 

add_paragraph() 文档方法向文档添加新的文本段落,并返回添加的 Paragraph 对象的引用。当您完成添加文本后,将文件名字符串传递给 save() 文档方法以将 Document 对象保存到文件。

此代码将在当前工作目录中创建一个名为 helloworld.docx 的文件。打开时,它应该看起来像图 17-7。您可以将此 .docx 文件上传到 Office 365 或 Google Docs,或在 Word 或 LibreOffice 中打开它。

包含文本“Hello, world!”的 Word 文档

图 17-7:使用 add_paragraph('Hello, world!') 创建的 Word 文档

您可以通过再次调用 add_paragraph() 方法并传递新段落的文本来向文档添加段落。要向现有段落的末尾添加文本,请调用段落的 add_run() 方法并传递一个字符串。在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document()
>>> doc.add_paragraph('Hello world!')
<docx.text.paragraph.Paragraph object at 0x000000000366AD30>
>>> para_obj_1 = doc.add_paragraph('This is a second paragraph.')
>>> para_obj_2 = doc.add_paragraph('This is a yet another paragraph.')
>>> para_obj_1.add_run(' This text is being added to the second paragraph.')
<docx.text.run.Run object at 0x0000000003A2C860>
>>> doc.save('multipleParagraphs.docx') 

生成的文档应类似于图 17-8。请注意,文本This text is being added to the second paragraph.被添加到para_obj_1中的Paragraph对象中,这是添加到doc中的第二个段落。add_paragraph()add_run()函数分别返回ParagraphRun对象,以避免您单独提取它们的麻烦。

再次调用save()方法以保存您所做的额外更改。

包含三行文本的 Word 文档。第一行写着“Hello world!”第二行写着“这是第二段。这段文本被添加到第二段中。”第三行写着“这是另一个段落。”

图 17-8:添加了多个 Paragraph 和 Run 对象的文档

add_paragraph()add_run()都接受一个可选的第二个参数,该参数是ParagraphRun对象样式的字符串。以下是一个示例:

>>> doc.add_paragraph('Hello, world!', 'Title')
<docx.text.paragraph.Paragraph object at 0x00000213E6FA9190> 

这行添加了一个以标题样式显示文本Hello, world!的段落。

添加标题

调用add_heading()添加一个具有标题样式的段落。在交互式外壳中输入以下内容:

>>> import docx
>>> doc = docx.Document()
>>> doc.add_heading('Header 0', 0)
<docx.text.paragraph.Paragraph object at 0x00000000036CB3C8>
>>> doc.add_heading('Header 1', 1)
<docx.text.paragraph.Paragraph object at 0x00000000036CB630>
>>> doc.add_heading('Header 2', 2)
<docx.text.paragraph.Paragraph object at 0x00000000036CB828>
>>> doc.add_heading('Header 3', 3)
<docx.text.paragraph.Paragraph object at 0x00000000036CB2E8>
>>> doc.add_heading('Header 4', 4)
<docx.text.paragraph.Paragraph object at 0x00000000036CB3C8>
>>> doc.save('headings.docx') 

生成的headings.docx文件应类似于图 17-9。

包含“标题 0”、“标题 1”、“标题 2”、“标题 3”和“标题 4”的文本,字体大小逐渐减小。的 Word 文档

图 17-9:包含标题 0 到 4 的 headings.docx 文档

add_heading()函数的参数是一个包含标题文本的字符串和一个从04的整数。整数0使标题成为标题样式,我们将其用于文档的顶部。整数19用于各种标题级别,其中1是主标题,9是最低的子标题。add_heading()函数返回一个Paragraph对象,以避免您从Document对象中单独提取它作为单独的步骤。

添加行和分页

要添加换行符(而不是开始一个全新的段落),可以在您希望换行符出现之后的Run对象上调用add_break()方法。如果您想添加分页符,则需要将值docx.enum.text.WD_BREAK.PAGE作为单独的参数传递给add_break(),就像以下示例中间所做的那样:

>>> doc = docx.Document()
>>> doc.add_paragraph('This is on the first page!')
<docx.text.paragraph.Paragraph object at 0x0000000003785518>
>>> doc.paragraphs[0].runs[0].add_break(docx.enum.text.WD_BREAK.PAGE) # ❶
>>> doc.add_paragraph('This is on the second page!')
<docx.text.paragraph.Paragraph object at 0x00000000037855F8>
>>> doc.save('twoPage.docx') 

此代码创建了一个两页的 Word 文档,第一页上写着这是第一页!,第二页上写着这是第二页!。尽管在这是 第一页!文本之后第一页上还有足够的空间,但我们通过在第一段的第一次运行后插入分页符,强制下一段在新的一页上开始 ❶。

添加图片

您可以使用 Document 对象的 add_picture() 方法向文档末尾添加图片。假设您当前工作目录中有一个名为 zophie.png 的文件。您可以通过输入以下内容将 zophie.png 添加到文档末尾,宽度为 1 英寸,高度为 4 厘米(Word 可以使用英制和公制单位):

>>> doc.add_picture('zophie.png', width=docx.shared.Inches(1), height=docx.shared.Cm(4))
<docx.shape.InlineShape object at 0x00000000036C7D30> 

第一个参数是图像文件名的一个字符串。可选的 widthheight 关键字参数将设置文档中图像的宽度和高度。如果省略,宽度和高度将默认为图像的正常大小。

您可能更喜欢以熟悉的单位(如英寸和厘米)指定图像的高度和宽度,因此当您指定 widthheight 关键字参数时,可以使用 docx.shared.Inches()docx.shared.Cm() 函数。

摘要

文本信息不仅仅是用于纯文本文件;实际上,您很可能更频繁地处理 PDF 和 Word 文档。您可以使用 PyPDF 包来读取和写入 PDF 文档,但还有许多其他 Python 库可以读取和写入 PDF 文件。如果您想超越本章讨论的内容,我建议在 PyPI 网站上搜索 pdfplumber、ReportLab、pdfrw、PyMuPDF、pdfkit 和 borb。

很遗憾,从 PDF 文档中读取文本可能并不总是完美地转换为字符串,因为文件格式很复杂,有些 PDF 文件可能根本无法读取。pdfminer.six 包是一个不再维护的 pdfminer 包的分支,专注于从 PDF 中提取文本。本章使用了 pdfminer.six 作为后备机制,以防您无法从特定的 PDF 文件中提取文本。

Word 文档更可靠,您可以使用 python-docx 包的 docx 模块来读取它们。您可以通过 ParagraphRun 对象来操作 Word 文档中的文本。这些对象也可以赋予样式,尽管它们必须来自默认样式集或文档中已有的样式。您可以向文档末尾添加新段落、标题、换行符和图片。

与 PDF 文档和 Word 文档一起工作所遇到的许多限制都源于这些格式旨在为人类读者提供良好的显示效果,而不是便于软件解析。下一章将探讨一些其他常见的信息存储格式:CSV、JSON 和 XML 文件。这些格式是为计算机设计的,您将看到 Python 可以更轻松地与它们一起工作。

实践问题

  1. 要保存 PDF 文件,PdfWriter 对象的 File 对象需要以什么模式打开?

  2. 您如何从 PdfReaderPdfWriter 对象获取第 5 页的 Page 对象?

  3. 如果 PdfReader 对象的 PDF 被密码 swordfish 加密,您在从中获取 Page 对象之前必须做什么?

  4. 如果 rotate() 方法按顺时针方向旋转页面,您如何逆时针旋转页面?

  5. 哪个方法返回名为demo.docx的文件的Document对象?

  6. Paragraph对象和Run对象之间的区别是什么?

  7. 如何获取名为doc的变量中存储的Document对象的Paragraph对象列表?

  8. 哪种类型的对象具有boldunderlineitalicstrikeoutline变量?

  9. bold变量设置为TrueFalseNone之间的区别是什么?

  10. 如何为新的 Word 文档创建一个Document对象?

  11. 如何将包含文本'Hello, there!'的段落添加到名为doc的变量中存储的Document对象?

  12. Word 文档中可用的标题级别由哪些整数表示?

练习程序

为了练习,编写程序来完成以下任务。

PDF 偏执狂

使用第十一章中的os.walk()函数,编写一个脚本,该脚本将遍历文件夹(及其子文件夹)中的每个 PDF 文件,并使用命令行提供的密码加密 PDF 文件。将每个加密的 PDF 文件以添加到原始文件名中的_encrypted.pdf后缀保存。在删除原始文件之前,程序应尝试读取和解密新文件,以确保它已正确加密。

然后,编写一个程序,在文件夹(及其子文件夹)中查找所有加密的 PDF 文件,并使用提供的密码创建 PDF 的解密副本。如果密码不正确,程序应向用户打印一条消息,然后继续到下一个 PDF。

自定义邀请函

假设您有一个包含宾客姓名的文本文件。此guests.txt文件每行有一个名字,如下所示:

Prof. Plum
Miss Scarlet
Col. Mustard
Al Sweigart
RoboCop 

编写一个程序,生成一个看起来像图 17-10 的具有自定义邀请函的 Word 文档。

由于 Python-Docx 只能使用 Word 文档中已存在的样式,因此您必须首先将这些样式添加到一个空白 Word 文件中,然后使用 Python-Docx 打开该文件。结果 Word 文档中每页应该有一个邀请函,因此请在每个邀请函的最后一段之后调用add_break()来添加一个分页符。这样,您只需打开一个 Word 文档就可以一次性打印所有邀请函。

只有第 1 行、第 3 行和第 5 行有值的 Excel 电子表格。

图 17-10:由您的自定义邀请脚本生成的 Word 文档描述

您可以从本书的在线资源中下载一个示例guests.txt文件。

PDF 密码破丨解丨器

假设您有一个加密的 PDF 文件,您忘记了密码,但记得它是一个英文单词。尝试猜测您忘记的密码是一项相当无聊的任务。相反,您可以编写一个程序,通过尝试所有可能的英文单词直到找到一个有效的单词来解密 PDF。这被称为暴力密码攻击。从本书的在线资源中下载文本文件dictionary.txt。此字典文件包含超过 44,000 个英文单词,每个单词一行。

使用您在第十章中学到的文件读取技巧,通过读取此文件创建一个单词字符串列表。然后,遍历此列表中的每个单词,将其传递给decrypt()方法。您应该尝试每个单词的大写和小写形式。(在我的笔记本电脑上,遍历字典文件中的所有 88,000 个大写和小写单词需要几分钟时间。这就是为什么您不应该使用简单的英文单词作为密码的原因。)

PDF 偏执狂

使用第十一章中介绍的os.walk()函数,编写一个脚本,该脚本将遍历文件夹(及其子文件夹)中的每个 PDF 文件,并使用命令行中提供的密码加密 PDF 文件。将每个加密的 PDF 文件以_encrypted.pdf后缀添加到原始文件名中。在删除原始文件之前,程序应尝试读取和解密新文件,以确保它已正确加密。

然后,编写一个程序,用于在文件夹(及其子文件夹)中查找所有加密的 PDF 文件,并使用提供的密码创建 PDF 的解密副本。如果密码不正确,程序应向用户打印一条消息,并继续处理下一个 PDF。

自定义邀请

假设您有一个包含宾客名单的文本文件。此guests.txt文件每行有一个名字,如下所示:

Prof. Plum
Miss Scarlet
Col. Mustard
Al Sweigart
RoboCop 

编写一个程序,生成一个看起来像图 17-10 的自定义邀请的 Word 文档。

由于 Python-Docx 只能使用 Word 文档中已存在的样式,您必须首先将这些样式添加到一个空白 Word 文件中,然后使用 Python-Docx 打开该文件。结果 Word 文档中每页应有一个邀请,因此请在每个邀请的最后一段之后调用add_break()来添加一个分页符。这样,您只需打开一个 Word 文档即可一次性打印所有邀请。

仅包含第 1 行、第 3 行和第 5 行值的 Excel 电子表格

图 17-10:由您的自定义邀请脚本生成的 Word 文档描述

您可以从本书的在线资源中下载一个示例guests.txt文件。

PDF 密码破丨解丨器

假设您有一个加密的 PDF 文件,您忘记了密码,但记得它是一个英文单词。尝试猜测您忘记的密码是一项相当无聊的任务。相反,您可以编写一个程序,通过尝试所有可能的英文单词直到找到可以工作的一个来解密 PDF。这被称为暴力破解密码攻击。从本书的在线资源中下载文本文件dictionary.txt。此字典文件包含超过 44,000 个英文单词,每个单词占一行。

使用你在第十章中学到的文件读取技巧,通过读取此文件创建一个单词字符串列表。然后,遍历列表中的每个单词,将其传递给decrypt()方法。你应该尝试每个单词的大写和小写形式。(在我的笔记本电脑上,遍历来自字典文件的 88,000 个大写和小写单词需要几分钟时间。这就是为什么你不应该使用简单的英文单词作为你的密码。)

18 CSV、JSON 和 XML 文件

原文链接

图片

CSV、JSON 和 XML 是用于将数据存储为纯文本文件的数据序列化格式。序列化将数据转换为字符串,以便将程序的工作保存到文本文件中,通过互联网连接传输,或者甚至只是复制粘贴到电子邮件中。Python 自带 csv、json 和 xml 模块,可以帮助您处理这些文件格式。

虽然这些格式的文件本质上是可以使用 Python 的open()函数或其他第十章中的文件 I/O 函数读取和写入的文本文件,但使用 Python 的模块来处理它们更容易,就像我们在第十三章中使用 Beautiful Soup 模块处理 HTML 格式文本一样。每种格式都有自己的用例:

逗号分隔值(CSV,发音为“see-ess-vee”)是一种简化的电子表格格式,最适合存储具有相同列的变量行数的数据。

JavaScript 对象表示法(JSON,发音为“JAY-sawn”或“Jason”)使用与 JavaScript 编程语言中的对象、数组和数据类型相同的语法,尽管它不需要您知道如何用 JavaScript 编程。它被创建为一个比 XML 更简单的替代品。

可扩展标记语言(XML,发音为“ex-em-el”)是一种较老、更成熟的数据序列化格式,在企业软件中得到广泛使用,但如果您不需要其高级功能,则使用起来过于复杂。

本章介绍了这些格式的基本语法以及与之交互的 Python 代码。

CSV 格式

CSV 文件中的每一行(使用.csv文件扩展名)代表电子表格中的一行,逗号分隔行中的单元格。例如,包含在nostarch.com/automate-boring-stuff-python-3rd-edition在线资源中的电子表格example3.xlsx在 CSV 文件中看起来如下所示:

4/5/2035 13:34,Apples,73
4/5/2035 3:41,Cherries,85
4/6/2035 12:46,Pears,14
4/8/2035 8:59,Oranges,52
4/10/2035 2:07,Apples,152
4/10/2035 18:10,Bananas,23
4/10/2035 2:40,Strawberries,98 

我将在本章的 CSV 交互式 shell 示例中使用此文件。下载它或将文本输入到文本编辑器中,并将其保存为example3.csv

您可以将 CSV 文件视为值列表的列表。Python 代码可以将example3.csv内容表示为值[['4/5/2035 13:34', 'Apples', '73'], ['4/5/2035 3:41', 'Cherries', '85'], ... ['4/10/2035 2:40', 'Strawberries', '98']]。CSV 文件简单,缺乏 Excel 电子表格的许多功能。例如,它们:

  • 不要使用多种数据类型;每个值都是一个字符串

  • 没有字体大小或颜色的设置

  • 不要使用多个工作表

  • 不能指定单元格宽度或高度

  • 不能合并单元格

  • 不能嵌入图片或图表

CSV 文件的优势在于其简单性。许多应用程序和编程语言都支持它们,你可以在文本编辑器中查看它们(包括 Mu),并且它们是表示电子表格数据的一种直接方式。

由于 CSV 文件仅仅是文本文件,你可能会想将它们作为字符串读取,然后使用你在第八章中学到的技术处理该字符串。例如,由于 CSV 文件中的每个单元格都由逗号分隔,你可能会尝试对每一行文本调用split(',')以获取以逗号分隔的值作为字符串列表。但 CSV 文件中的每个逗号并不代表两个单元格之间的边界。CSV 文件有一组转义字符,允许你将逗号和其他字符作为值的一部分包含在内。split()方法不处理这些转义字符。由于这些潜在的问题,csv模块提供了一种更可靠的方式来读取和写入 CSV 文件。

读取 CSV 文件

要读取 CSV 文件,你必须创建一个csv.reader对象,这让你可以遍历 CSV 文件中的行。csv模块是 Python 的一部分,因此你可以直接导入它,无需先安装。将*example3.csv*放置在当前工作目录中,然后在交互式 shell 中输入以下内容:

>>> import csv
>>> example_file = open('example3.csv')
>>> example_reader = csv.reader(example_file)
>>> example_data = list(example_reader)
>>> example_data
[['4/5/2035 13:34', 'Apples', '73'], ['4/5/2035 3:41', 'Cherries', '85'],
['4/6/2035 12:46', 'Pears', '14'], ['4/8/2035 8:59', 'Oranges', '52'],
['4/10/2035 2:07', 'Apples', '152'], ['4/10/2035 18:10', 'Bananas', '23'],
['4/10/2035 2:40', 'Strawberries', '98']]
>>> example_file.close() 

要使用csv模块读取 CSV 文件,使用open()函数打开它,就像打开任何其他文本文件一样,但不要在open()返回的File对象上调用read()readlines()方法,而是将其传递给csv.reader()函数。此函数应返回一个reader对象。请注意,你不能直接将文件名字符串传递给csv.reader()函数。

访问reader对象中的值最简单的方法是将它转换为普通的 Python 列表,方法是将它传递给list()函数。对reader对象使用list()会返回一个列表的列表,你可以将其存储在变量中,例如example_data。在 shell 中输入example_data会显示列表的列表。

现在你已经将 CSV 文件作为列表的列表,你可以使用表达式example_data[row][col]访问特定行和列的值,其中rowexample_data中某个列表的索引,col是你想要从该列表中获取的项目索引。在交互式 shell 中输入以下内容:

>>> example_data[0][0]  # First row, first column
'4/5/2035 13:34'
>>> example_data[0][1]  # First row, second column
'Apples'
>>> example_data[0][2]  # First row, third column
'73'
>>> example_data[1][1]  # Second row, second column
'Cherries'
>>> example_data[6][1]  # Seventh row, second column
'Strawberries' 

从输出中可以看出,example_data[0][0]进入第一个列表,给我们第一个字符串,example_data[0][2]进入第一个列表,给我们第三个字符串,依此类推。

for循环中访问数据

对于大型 CSV 文件,你可能希望使用for循环中的reader对象。这种方法可以避免一次性将整个文件加载到内存中。例如,在交互式 shell 中输入以下内容:

>>> import csv
>>> example_file = open('example3.csv')
>>> example_reader = csv.reader(example_file)
>>> for row in example_reader: # ❶
...     print('Row #' + str(example_reader.line_num) + ' ' + str(row)) # ❷
...
Row #1 ['4/5/2035 13:34', 'Apples', '73']
Row #2 ['4/5/2035 3:41', 'Cherries', '85']
Row #3 ['4/6/2035 12:46', 'Pears', '14']
Row #4 ['4/8/2035 8:59', 'Oranges', '52']
Row #5 ['4/10/2035 2:07', 'Apples', '152']
Row #6 ['4/10/2035 18:10', 'Bananas', '23']
Row #7 ['4/10/2035 2:40', 'Strawberries', '98'] 

在导入csv模块并从 CSV 文件创建一个reader对象后,你可以遍历reader对象中的行 ❶。每一行是存储在行变量中的值列表,列表中的每个值代表一个单元格。

print() 函数调用❷ 打印当前行的编号和行内容。要获取行号,请使用 reader 对象的 line_num 属性,它存储一个整数。如果你的 CSV 文件在第一行包含列标题,你可以使用 line_num 来检查是否在第一行,并运行 continue 指令来跳过标题。与 Python 列表索引不同,line_num 中的行号从 1 开始,而不是 0。

你只能遍历 reader 对象一次。要重新读取 CSV 文件,你必须再次调用 open()csv.reader() 以创建另一个 reader 对象。

写入 CSV 文件

一个 csv.writer 对象允许你将数据写入 CSV 文件。要创建一个 writer 对象,请使用 csv.writer() 函数。在交互式 shell 中输入以下内容:

>>> import csv
>>> output_file = open('output.csv', 'w', newline='') # ❶
>>> output_writer = csv.writer(output_file) # ❷
>>> output_writer.writerow(['spam', 'eggs', 'bacon', 'ham'])
21
>>> output_writer.writerow(['Hello, world!', 'eggs', 'bacon', 'ham'])
32
>>> output_writer.writerow([1, 2, 3.141592, 4])
16
>>> output_file.close() 

调用 open() 并传递 'w' 以写入模式打开文件❶。此代码应创建一个你可以传递给 csv.writer()❷ 以生成 writer 对象的对象。

在 Windows 上,你还需要为 open() 函数的 newline 关键字参数传递一个空字符串。由于本书范围之外的技术原因,如果你忘记设置 newline 参数,output.csv 中的行将会是双倍行距,如图 18-1 所示。

Windows 计算机桌面的截图,显示六个重叠的计算器程序

图 18-1:双倍行距的 CSV 文件

writer 对象的 writerow() 方法接受一个列表参数。列表中的每个值将出现在输出 CSV 文件中的单独单元格中。该方法返回值为写入该行的字符数(包括换行符)。例如,以下示例代码生成的 output.csv 文件如下所示:

spam,eggs,bacon,ham
"Hello, world!",eggs,bacon,ham
1,2,3.141592,4 

注意 writer 对象如何自动在 CSV 文件中将值 'Hello, world!' 中的逗号用双引号转义。csv 模块可以让你不必自己处理这些特殊情况。

使用制表符代替逗号

制表符分隔值 (TSV) 文件类似于 CSV 文件,但不出所料,使用制表符而不是逗号。它们的文件扩展名为 .tsv。假设你想使用制表符而不是逗号来分隔单元格,并且希望行是双倍行距。你可以在交互式 shell 中输入以下内容:

>>> import csv
>>> output_file = open('output.tsv', 'w', newline='')
>>> output_writer = csv.writer(output_file, delimiter='\t', lineterminator='\n\n') ❶
>>> output_writer.writerow(['spam', 'eggs', 'bacon', 'ham'])
21
>>> output_writer.writerow(['Hello, world!', 'eggs', 'bacon', 'ham'])
30
>>> output_writer.writerow([1, 2, 3.141592, 4])
16
>>> output_file.close() 

此代码更改了文件中的分隔符和行终止符字符。分隔符 是出现在行中单元格之间的字符。默认情况下,CSV 文件的分隔符是逗号。行终止符 是位于行末的字符。默认情况下,行终止符是换行符。你可以通过使用 csv.writer()delimiterlineterminator 关键字参数来更改这些字符的值。

通过传递 delimiter='\t'lineterminator='\n\n' ❶ 将分隔符更改为制表符,并将行终止符更改为两个换行符。然后代码调用 writerow() 三次以创建三行,生成一个名为 output.tsv 的文件,其内容如下:

spam    eggs    bacon   ham

Hello, world!   eggs    bacon   ham

1   2   3.141592    4 

电子表格中的单元格现在由制表符分隔。

处理表头行

对于包含表头行的 CSV 文件,使用 DictReaderDictWriter 对象通常比使用 readerwriter 对象更方便。虽然 readerwriter 通过使用列表读取和写入 CSV 文件行,但 DictReaderDictWriter 使用字典执行相同的函数,将第一行的值视为键。

从本书的在线资源下载 exampleWithHeader3.csv 以获取下一个示例。此文件与 example3.csv 相同,但第一行包含 TimestampFruitQuantity 作为列标题。要读取文件,请在交互式外壳中输入以下内容:

>>> import csv
>>> example_file = open('exampleWithHeader3.csv')
>>> example_dict_reader = csv.DictReader(example_file)
>>> example_dict_data = list(example_dict_reader) # ❶
>>> example_dict_data
[{'Timestamp': '4/5/2035 3:41', 'Fruit': 'Cherries', 'Quantity': '85'},
{'Timestamp': '4/6/2035 12:46', 'Fruit': 'Pears', 'Quantity': '14'},
{'Timestamp': '4/8/2035 8:59', 'Fruit': 'Oranges', 'Quantity': '52'},
{'Timestamp': '4/10/2035 2:07', 'Fruit': 'Apples', 'Quantity': '152'},
{'Timestamp': '4/10/2035 18:10', 'Fruit': 'Bananas', 'Quantity': '23'},
{'Timestamp': '4/10/2035 2:40', 'Fruit': 'Strawberries', 'Quantity': '98'}]
>>> example_file = open('exampleWithHeader3.csv')
>>> example_dict_reader = csv.DictReader(example_file)
>>> for row in example_dict_reader: # ❷
...     print(row['Timestamp'], row['Fruit'], row['Quantity'])
...
4/5/2035 13:34 Apples 73
4/5/2035 3:41 Cherries 85
4/6/2035 12:46 Pears 14
4/8/2035 8:59 Oranges 52
4/10/2035 2:07 Apples 152
4/10/2035 18:10 Bananas 23
4/10/2035 2:40 Strawberries 98 

通过将 DictReader 对象传递给 list() ❶,你可以将 CSV 数据作为字典列表获取。列表中的每一行对应一个字典。或者,你可以在 for 循环中使用 DictReader 对象 ❷。DictReader 对象将 row 设置为一个字典对象,其键来自第一行的标题。使用 DictReader 对象意味着你不需要额外的代码来跳过第一行的标题信息,因为 DictReader 对象会为你完成这项工作。

如果你尝试使用没有第一行列标题的 example3.csvDictReader 对象一起使用,DictReader 对象将使用 '4/5/2035 13:34''Apples''73' 作为字典键。为了避免这种情况,你可以向 DictReader() 函数提供一个包含虚构标题名称的第二个参数:

>>> import csv
>>> example_file = open('example3.csv')
>>> example_dict_reader = csv.DictReader(example_file, ['time', 'name', 'amount'])
>>> for row in example_dict_reader:
...     print(row['time'], row['name'], row['amount'])
...
4/5/2035 13:34 Apples 73
4/5/2035 3:41 Cherries 85
4/6/2035 12:46 Pears 14
4/8/2035 8:59 Oranges 52
4/10/2035 2:07 Apples 152
4/10/2035 18:10 Bananas 23
4/10/2035 2:40 Strawberries 98 

因为 example3.csv 的第一行不包含列标题,所以我们创建了自定义的列标题:'time''name''amount'DictWriter 对象使用字典来创建 CSV 文件:

>>> import csv
>>> output_file = open('output.csv', 'w', newline='')
>>> output_dict_writer = csv.DictWriter(output_file, ['Name', 'Pet', 'Phone'])
>>> output_dict_writer.writeheader()
16
>>> output_dict_writer.writerow({'Name': 'Alice', 'Pet': 'cat', 'Phone': '555-1234'})
20
>>> output_dict_writer.writerow({'Name': 'Bob', 'Phone': '555-9999'})
15
>>> output_dict_writer.writerow({'Phone': '555-5555', 'Name': 'Carol', 'Pet': 'dog'})
20
>>> output_file.close() 

如果你希望你的文件包含表头行,通过调用 writeheader() 写入该行。否则,跳过调用 writeheader() 以从文件中省略表头行。然后你可以通过调用 writerow() 方法,传递一个使用标题作为键并包含要写入文件的数据的字典来写入 CSV 文件的每一行。

此代码创建的 output.csv 文件看起来如下所示:

Name,Pet,Phone
Alice,cat,555-1234
Bob,,555-9999
Carol,dog,555-5555 

双逗号表示鲍勃的宠物值是空的。请注意,你传递给 writerow() 的字典中键值对的顺序并不重要;它们将按照 DictWriter() 给定的键的顺序写入。例如,即使你在第四行中先传递了 Phone 键和值,然后才是 NamePet 键和值,电话号码仍然在输出中最后出现。

注意,任何缺失的键,如 {'Name': 'Bob', 'Phone': '555-9999'} 中的 'Pet',在 CSV 文件中将成为空单元格。

项目 13:从 CSV 文件中删除表头

假设你有一个无聊的工作,就是从几百个 CSV 文件中删除第一行。也许你将它们输入到一个只需要数据而不需要列顶部的标题的自动化流程中。你 可以 在 Excel 中打开每个文件,删除第一行,然后重新保存文件——但这将花费几个小时。让我们编写一个程序来完成这项工作。

程序需要打开当前工作目录中所有具有 .csv 扩展名的文件,读取 CSV 文件的内容,并将没有第一行的内容重写到同名文件中。这将用新的无头内容替换 CSV 文件中的旧内容。

警告

一如既往,每次编写修改文件的程序时,务必先备份文件,以防程序没有按预期工作。你不想不小心删除原始文件。

从高层次来看,程序必须执行以下操作:

  • 在当前工作目录中找到所有 CSV 文件。

  • 读取每个文件的完整内容。

  • 将内容写入新 CSV 文件,跳过第一行。

在代码层面,这意味着程序需要执行以下操作:

  • 遍历 os.listdir() 返回的文件列表,跳过非 CSV 文件。

  • 创建一个 CSV reader 对象并读取文件内容,使用 line_num 属性来确定要跳过的行。

  • 创建一个 CSV writer 对象并将读取的数据写入新文件。

对于这个项目,打开一个新的文件编辑窗口并将其保存为 removeCsvHeader.py

第一步:遍历每个文件

你的程序首先需要遍历当前工作目录中所有 CSV 文件名的列表。让 removeCsvHeader.py 看起来像这样:

# Removes the header line from csv files
import csv, os

os.makedirs('headerRemoved', exist_ok=True)

# Loop through every file in the current working directory.
for csv_filename in os.listdir('.'):
    if not csv_filename.endswith('.csv'):
        print('Row #' + str(example_reader.line_num) + ' ' + str(row)) # ❷

    print('Removing header from ' + csv_filename + '...')

    # TODO: Read the CSV file (skipping the first row).

    # TODO: Write the CSV file. 

os.makedirs() 调用在 headerRemoved 文件夹中创建一个用于保存无头 CSV 文件的文件夹。在 os.listdir('.') 上的 for 循环让你走了一半的路,但它将遍历工作目录中的 所有 文件,因此你需要在循环开始时添加一些代码来跳过不以 .csv 结尾的文件名。continue 语句 ❶ 使得当 for 循环遇到非 CSV 文件时,会跳到下一个文件名。

要查看程序运行时的输出,请打印一条消息,指示程序正在处理哪个 CSV 文件。然后,添加一些 TODO 注释,说明程序其余部分应该做什么。

第二步:读取文件

程序不会从 CSV 文件中删除第一行。相反,它创建了一个没有第一行的 CSV 文件的新副本。这样,我们就可以在新的文件被错误地修改时使用原始文件。

程序需要一种方法来跟踪它是否正在处理第一行。将以下内容添加到 removeCsvHeader.py

# Removes the header line from csv files
import csv, os

# --snip--

 # Read the CSV file (skipping the first row).
    csv_rows = []
    csv_file_obj = open(csv_filename)
 reader_obj = csv.reader(csv_file_obj)
    for row in reader_obj:
        if reader_obj.line_num == 1:
            continue # Skip the first row.
 csv_rows.append(row)
    csv_file_obj.close()

    # TODO: Write the CSV file. 

reader 对象的 line_num 属性可以用来确定它当前正在读取 CSV 文件的哪一行。另一个 for 循环将遍历从 CSV reader 对象返回的行,除了第一行之外的所有行都将追加到 csv_rows

随着for循环遍历每一行,代码检查reader_obj.line_num是否设置为1。如果是,它执行continue以跳到下一行,而不将其追加到csv_rows中。对于后续的每一行,条件始终为False,代码将行追加到csv_rows中。

第 3 步:写入新的 CSV 文件

现在csv_rows包含了除了第一行之外的所有行,我们需要将列表写入到headerRemoved文件夹中的 CSV 文件。向removeCsvHeader.py添加以下内容:

# Removes the header line from csv files
import csv, os

# --snip--

# Loop through every file in the current working directory.
for csv_filename in os.listdir('.'): # ❶
    if not csv_filename.endswith('.csv'):
        continue    # Skip non-CSV files.

    # --snip--

 # Write the CSV file.
    csv_file_obj = open(os.path.join('headerRemoved', csv_filename), 'w', 
 newline='')
    csv_writer = csv.writer(csv_file_obj)
    for row in csv_rows:
        csv_writer.writerow(row)
    csv_file_obj.close() 

CSV writer对象将使用csv_filename(我们也在 CSV 读取器中使用过)将列表写入到headerRemoved文件夹中的 CSV 文件。在创建writer对象后,我们遍历存储在csv_rows中的子列表,并将每个子列表写入到文件中。

外部for循环❶将遍历到由os.listdir('.')返回的下一个文件名。当这个循环完成后,程序将完成。

为了测试你的程序,从本书的在线资源下载removeCsvHeader.zip,并将其解压缩到一个文件夹中。然后,在该文件夹中运行removeCsvHeader.py程序。输出将如下所示:

Removing header from NAICS_data_1048.csv...
Removing header from NAICS_data_1218.csv...
# --snip--
Removing header from NAICS_data_9834.csv...
Removing header from NAICS_data_9986.csv... 

这个程序应该在每次从 CSV 文件中删除第一行时打印一个文件名。

类似程序的思路

处理 CSV 文件的程序与处理 Excel 文件的程序类似,因为 CSV 和 Excel 都是电子表格文件。例如,你可以编写以下程序:

  • 在 CSV 文件的不同行之间或多个 CSV 文件之间比较数据。

  • 将特定数据从 CSV 文件复制到 Excel 文件,或反之亦然。

  • 检查 CSV 文件中的无效数据或格式错误,并向用户报告这些错误。

  • 将 CSV 文件中的数据作为 Python 程序的输入读取。

多功能纯文本格式

虽然 CSV 文件对于存储具有精确相同列的数据行非常有用,但 JSON 和 XML 格式可以存储各种数据结构。(本书省略了不太受欢迎但仍然有用的 YAML 和 TOML 格式。)这些格式并不特定于 Python;许多编程语言都有用于读取和写入这些格式的函数。

这些格式中的每一个都是使用嵌套 Python 字典和列表的等效方式组织数据。在其他编程语言中,你可能会看到字典被称为mappingshash mapshash tablesassociative arrays(因为它们将一个数据项,即键,映射或关联到另一个数据项,即值)。同样,你可能会在其他语言中看到 Python 的列表被称为arrays。但概念是相同的:它们将数据组织成键值对和列表。

你可以在其他字典和列表内部嵌套字典和列表,以形成复杂的数据结构。但如果你想将这些数据结构保存到文本文件中,你需要选择一个数据序列化格式,例如 JSON 或 XML。本章中的 Python 模块可以解析(即,读取和理解)这些格式中的文本,从而从文本创建 Python 数据结构。

这些人类可读的纯文本格式没有最有效地使用磁盘空间或内存,但它们的优势在于易于在文本编辑器中查看和编辑,并且是语言中立的,因为任何语言的程序都可以读取或写入文本文件。相比之下,第十章中介绍的shelve模块可以将所有 Python 数据类型存储在二进制 shelf 文件中,但其他语言没有模块可以将这些数据加载到它们的程序中。

在本章剩余部分,我将用以下几种格式表示存储名为 Alice 的个人详细信息的 Python 数据结构,以便您进行比较和对比:

{
    "name": "Alice Doe",
    "age": 30,
    "car": None,
    "programmer": True,
    "address": {
        "street": "100 Larkin St.",
        "city": "San Francisco",
        "zip": "94102"
    },
    "phone": [
        {
            "type": "mobile",
            "number": "415-555-7890"
        },
        {
            "type": "work",
            "number": "415-555-1234"
        }
    ]
} 

这些文本格式有自己的历史,并在计算生态系统中占据特定的领域。如果您必须为存储数据选择数据序列化格式,请记住 JSON 比 XML 简单,比 YAML 更广泛采用,而 TOML 主要用作配置文件的格式。最后,提出自己的数据序列化格式可能很有吸引力,但这也是一种重新发明轮子的行为,您将不得不为您的自定义格式编写自己的解析器。最好简单地选择一个现有的格式。

JSON

JSON 以 JavaScript 源代码的形式存储信息,尽管许多非 JavaScript 应用程序也使用它。特别是,网站通常通过 API(如第十三章中提到的 OpenWeather API)将数据以 JSON 格式提供给程序员。我们使用.json文件扩展名将格式化的 JSON 文本保存到纯文本文件中。以下是一个格式化的 JSON 数据结构示例:

{
  "name": "Alice Doe",
  "age": 30,
  "car": null,
  "programmer": true,
  "address": {
    "street": "100 Larkin St.",
    "city": "San Francisco",
 "zip": "94102"
  },
  "phone": [
    {
      "type": "mobile",
      "number": "415-555-7890"
    },
    {
      "type": "work",
      "number": "415-555-1234"
    }
  ]
} 

您首先会注意到 JSON 与 Python 语法相似。Python 的字典和 JSON 的对象都使用花括号,并且包含由逗号分隔的键值对,每个键和值由冒号分隔。Python 的列表和 JSON 的数组都使用方括号,并且包含由逗号分隔的值。在 JSON 中,双引号字符串之外的空间是不重要的,这意味着您可以按您喜欢的任何方式分隔值。然而,最好使用增加的缩进来格式化嵌套的对象和数组,就像缩进的 Python 代码块一样。在我们的示例数据中,电话号码列表缩进两个空格,列表中的每个电话号码字典缩进四个空格。

但 JSON 和 Python 之间也存在差异。JSON 使用 JavaScript 的关键字null代替 Python 的None值。布尔值是 JavaScript 的小写truefalse关键字。JSON 不允许 JavaScript 注释或多行字符串;JSON 中的所有字符串都必须使用双引号。与 Python 列表不同,JSON 数组不能有尾随逗号,因此["spam", "eggs"]是有效的 JSON,而["spam", "eggs",]则不是。

Facebook、Twitter、Yahoo!、Google、Tumblr、Wikipedia、Flickr、Data.gov、Reddit、IMDb、Rotten Tomatoes、LinkedIn 以及许多其他流行的网站提供与 JSON 数据一起工作的 API。其中一些网站需要注册,这几乎总是免费的。你将需要找到文档来了解你的程序需要请求哪些 URL 以获取你想要的数据,以及返回的 JSON 数据结构的通用格式。如果提供 API 的网站有一个开发者页面,请在那里查找文档。

Python 的 json 模块通过 json.loads()json.dumps() 函数处理将格式为 JSON 数据的字符串与相应的 Python 值之间的转换细节。JSON 无法存储每种 Python 值,只能存储以下基本数据类型:字符串、整数、浮点数、布尔值、列表、字典和 NoneType。JSON 无法表示 Python 特定的对象,例如 File 对象、CSV readerwriter 对象,或 Selenium WebElement 对象。json 模块的全文档可以在 docs.python.org/3/library/json.html 找到。

读取 JSON 数据

要将包含 JSON 数据的字符串转换为 Python 值,请将其传递给 json.loads() 函数。(其名称为“加载字符串”,而不是“loads”)在交互式 shell 中输入以下内容:

>>> import json # ❶
>>> json_string = '{"name": "Alice Doe", "age": 30, "car": null, "programmer":
 true, "address": {"street": "100 Larkin St.", "city": "San Francisco", "zip":
 "94102"}, "phone": [{"type": "mobile", "number": "415-555-7890"}, {"type": 
"work", "number": "415-555-1234"}]}'
>>> python_data = json.loads(json_string) # ❷
>>> python_data
{'name': 'Alice Doe', 'age': 30, 'car': None, 'programmer': True, 'address':
{'street': '100 Larkin St.', 'city': 'San Francisco', 'zip': '94102'},
'phone': [{'type': 'mobile', 'number': '415-555-7890'}, {'type': 'work',
'number': '415-555-1234'}]} 

在导入 json 模块 ❶ 之后,你可以调用 loads() ❷ 并传递一个 JSON 数据的字符串。请注意,JSON 字符串始终使用双引号。它应该返回一个 Python 字典。

写入 JSON 数据

json.dumps() 函数(其含义为“导出字符串”,而不是“dumps”)将 Python 数据转换为 JSON 格式的字符串。在交互式 shell 中输入以下内容:

>>> import json
>>> python_data = {'name': 'Alice Doe', 'age': 30, 'car': None, 'programmer': True, 'address':
{'street': '100 Larkin St.', 'city': 'San Francisco', 'zip': '94102'}, 'phone': [{'type':
'mobile', 'number': '415-555-7890'}, {'type': 'work', 'number': '415-555-1234'}]}
>>> json_string = json.dumps(python_data) ❶
>>> print(json_string) ❷
{"name": "Alice Doe", "age": 30, "car": null, "programmer": true, "address": {"street":
"100 Larkin St.", "city": "San Francisco", "zip": "94102"}, "phone": [{"type": "mobile",
"number": "415-555-7890"}, {"type": "work", "number": "415-555-1234"}]}
>>> json_string = json.dumps(python_data, indent=2) ❸
>>> print(json_string)
{
  "name": "Alice Doe",
  "age": 30,
  "car": null,
  "programmer": true,
  "address": {
    "street": "100 Larkin St.",
    "city": "San Francisco",
# --snip--
} 

传递给 json.dumps() 的值只能由以下基本 Python 数据类型组成:字符串、整数、浮点数、布尔值、列表、字典和 NoneType

默认情况下,整个 JSON 文本将写在一行上 ❷。这种压缩格式对于在程序之间读取和写入 JSON 文本来说是不错的,但对于人类阅读来说,多行并缩进的格式会更好。indent=2 关键字参数 ❸ 将 JSON 文本格式化为单独的行,每个嵌套字典或列表缩进两个空格。除非你的 JSON 文件大小达到兆字节级别,否则通过添加空格和换行符来增加大小以提高可读性是值得的。

一旦你将 JSON 文本作为 Python 字符串值拥有,你可以将其写入 .json 文件,传递给一个函数,用于网络请求,或执行任何可以用字符串完成的操作。

XML

XML 文件格式比 JSON 更老,但仍然被广泛使用。其语法类似于 HTML,我们在第十八章中讨论过,它涉及在包含其他内容的尖括号内嵌套开放和闭合标签。这些标签被称为元素。SVG 图像文件由 XML 文本组成。RSS 和 Atom 网络源格式也是用 XML 编写的,而 Microsoft Word 文档只是具有.docx扩展名的 ZIP 文件,其中包含 XML 文件。

我们将格式化的 XML 文本存储在带有.xml扩展名的纯文本文件中。以下是一个以 XML 格式化的示例数据结构:

<person>
    <name>Alice Doe</name>
    <age>30</age>
    <programmer>true</programmer>
    <car xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>
    <address>
        <street>100 Larkin St.</street>
        <city>San Francisco</city>
        <zip>94102</zip>
    </address>
    <phone>
        <phoneEntry>
            <type>mobile</type>
            <number>415-555-7890</number>
        </phoneEntry>
        <phoneEntry>
            <type>work</type>
            <number>415-555-1234</number>
        </phoneEntry>
    </phone>
</person> 

在这个例子中,<person>元素有子元素<name><age>等。<name><age>子元素是子元素,而<person>是它们的父元素。有效的 XML 文档必须有一个单一的根元素,它包含所有其他元素,例如本例中的<person>元素。具有多个根元素的文档,如下所示,是不合法的:

<person><name>Alice Doe</name></person>
<person><name>Bob Smith</name></person>
<person><name>Carol Watanabe</name></person> 

与更现代的序列化格式如 JSON 相比,XML 相当冗长。每个元素都有一个开放和闭合标签,例如<age></age>。XML 元素是一个键值对,键是元素的标签(在这种情况下,<age>),值是开放和闭合标签之间的文本。XML 文本没有数据类型;在开放和闭合标签之间的所有内容都被视为字符串,包括我们示例数据中的94102true文本。数据列表,如<phone>元素,必须使用它们自己的元素来命名各自的项,例如<phoneEntry>。这些子元素的“Entry”后缀只是一个命名约定。

XML 的注释与 HTML 的注释相同:在<!---->之间的任何内容都应被忽略。

开放和闭合标签之外的空间是无关紧要的,你可以按自己的喜好来格式化它。在 XML 中不存在“null”值,但你可以通过向标签添加xsi:nil="true"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"属性来近似它。XML 属性是以 key="value"格式在开放标签内写成的键值对。标签被写成自闭合标签;而不是使用闭合标签,开放标签以/>结束,例如<car xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>

标签和属性名可以写成任何大小写,但按照惯例都是小写。属性值可以用单引号或双引号括起来,但双引号是标准的。

是否使用子元素或属性通常是模糊的。我们的示例数据使用这些元素来处理地址数据:

<address>
    <street>100 Larkin St.</street>
    <city>San Francisco</city>
    <zip>94102</zip>
</address> 

然而,它很容易将子元素数据格式化为自闭合的<address>元素中的属性:

<address street="100 Larkin St." city="San Francisco" zip="94102" />

这类歧义以及标签的冗长性质使得 XML 的使用不如以前那么普遍。XML 在 1990 年代和 2000 年代得到了广泛部署,并且其中很大一部分软件至今仍在使用。但除非你有特定的理由使用 XML,否则使用 JSON 会更好。

通常,XML 软件库有两种读取 XML 文档的方式。文档对象模型 (DOM) 方法一次性将整个 XML 文档读入内存。这使得访问 XML 文档中的任何数据变得容易,但通常只适用于小型或中等大小的 XML 文档。简单 XML API (SAX) 方法将 XML 文档作为元素流读取,因此不需要一次性将整个文档加载到内存中。这种方法对于大小为千兆的 XML 文档非常理想,但不太方便,因为你必须迭代文档中的元素才能与元素一起工作。

Python 的标准库提供了 xml.domxml.saxxml.etree.ElementTree 模块来处理 XML 文本。对于我们的简单示例,我们将使用 Python 的 xml.etree.ElementTree 模块一次性读取整个 XML 文档。

读取 XML 文件

xml.etree 模块使用 Element 对象来表示 XML 元素及其子元素。在交互式外壳中输入以下内容:

>>> import xml.etree.ElementTree as ET # ❶
>>> xml_string = """<person><name>Alice Doe</name><age>30</age> # ❷
<programmer>true</programmer><car xsi:nil="true" xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance”/><address><street>
100 Larkin St.</street><city>San Francisco</city><zip>94102</zip>
</address><phone><phoneEntry><type>mobile</type><number>415-555-
7890</number></phoneEntry><phoneEntry><type>work</type><number>
415-555-1234</number></phoneEntry></phone></person>"""
>>> root = ET.fromstring(xml_string) # ❸
>>> root
<Element 'person' at 0x000001942999BBA0> 

我们使用 as ET 语法导入 xml.etree.ElementTree 模块❶,这样我们就可以输入 ET 而不是长 xml.etree.ElementTree 模块名称。xml_string 变量❷包含我们希望解析的 XML 文本,尽管这段文本也可以很容易地从具有 .xml 扩展名的文本文件中读取。最后,我们将此文本传递给 ET.fromstring() 函数❸,该函数返回一个包含我们想要访问的数据的 Element 对象。我们将此 Element 对象存储在一个名为 root 的变量中。

xml.etree.ElementTree 模块还有一个 parse() 函数。你可以传递一个文件名给它,从中加载 XML,它将返回一个 Element 对象:

>>> import xml.etree.ElementTree as ET
>>> tree = ET.parse('my_data.xml')
>>> root = tree.getroot() 

一旦你有一个 Element 对象,你可以通过访问它的 tagtext Python 属性来查看标签的名称,以及其开闭标签之间的文本。如果你将 Element 对象传递给 list() 函数,它应该返回其直接子元素列表。继续在交互式外壳中输入以下内容:

>>> root.tag
'person'
>>> list(root)
[<Element 'name' at 0x00000150BA4ADDF0>, <Element 'age' at
0x00000150BA4ADF30>, <Element 'programmer' at 0x00000150BA4ADEE0>,
<Element 'car' at 0x00000150BA4ADD00>, <Element 'address' at
0x00000150BA4ADCB0>, <Element 'phone' at 0x00000150BA4ADA30>] 

Element 对象的子 Element 对象可以通过整数索引访问,就像 Python 列表一样。因此,如果 root 包含 <person> 元素,那么 root[0]root[1] 分别包含 <name><age> 元素。你可以访问所有这些 Element 对象的 tagtext 属性。然而,任何自闭合标签,如 <car/>,将使用 None 作为它们的 text 属性。例如,在交互式外壳中输入以下内容:

>>> root[0].tag
'name'
>>> root[0].text
'Alice Doe'
>>> root[3].tag
'car'
>>> root[3].text == None  # <car/> has no text.
True
>>> root[4].tag
'address'
>>> root[4][0].tag
'street'
>>> root[4][0].text
'100 Larkin St.' 

root 元素开始,您可以探索整个 XML 文档中的数据。您还可以通过在 for 循环中放置 Element 对象来迭代直接子元素:

>>> for elem in root:
...     print(elem.tag, '--', elem.text)
...
name -- Alice Doe
age -- 30
programmer -- true
car -- None
address -- None
phone -- None 

如果您想迭代 Element 下的所有子元素,可以在 for 循环中调用 iter() 方法:

>>> for elem in root.iter():
...     print(elem.tag, '--', elem.text)
...
person -- None
name -- Alice Doe
age -- 30
programmer -- true
car -- None
address -- None
street -- 100 Larkin St.
city -- San Francisco
zip -- 94102
phone -- None
phoneEntry -- None
type -- mobile
number -- 415-555-7890
phoneEntry -- None
type -- work
number -- 415-555-1234 

可选地,您可以将字符串传递给 iter() 方法以过滤具有匹配标签的 XML 元素。此示例调用 iter('number') 以仅迭代根元素的 <number> 子元素:

>>> for elem in root.iter('number'):
...     print(elem.tag, '--', elem.text)
...
number -- 415-555-7890
number -- 415-555-1234 

浏览 XML 文档中的数据远不止本节中涵盖的属性和方法。例如,正如第十三章中介绍的 CSS 选择器可以在网页的 HTML 中找到元素一样,一种称为 XPath 的语言可以定位 XML 文档中的元素。这些概念超出了本章的范围,但您可以在 Python 文档中了解它们,网址为 docs.python.org/3/library/xml.etree.elementtree.html

Python 的 XML 模块没有将 XML 文本转换为 Python 数据结构的方法。然而,第三方 xmltodict 模块(pypi.org/project/xmltodict/)可以做到这一点。完整的安装说明见附录 A。以下是其使用示例:

>>> import xmltodict
>>> xml_string = """<person><name>Alice Doe</name><age>30</age>
<programmer>true</programmer><car xsi:nil="true" xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance”/><address><street>
100 Larkin St.</street><city>San Francisco</city><zip>94102
</zip></address><phone><phoneEntry><type>mobile</type><number>
415-555-7890</number></phoneEntry><phoneEntry><type>work</type>
<number>415-555-1234</number></phoneEntry></phone></person>"""
>>> python_data = xmltodict.parse(xml_string)
>>> python_data
{'person': {'name': 'Alice Doe', 'age': '30', 'programmer': 'true',
'car': {'@xsi:nil': 'true', '@xmlns:xsi': 'http://www.w3.org/2001/
XMLSchema-instance'}, 'address': {'street': '100 Larkin St.', 'city':
'San Francisco', 'zip': '94102'}, 'phone': {'phoneEntry': [{'type':
'mobile', 'number': '415-555-7890'}, {'type': 'work', 'number':
'415-555-1234'}]}}} 

与 JSON 等格式相比,XML 标准之所以被边缘化,一个原因是 XML 中表示数据类型更复杂。例如,<programmer> 元素被解析为字符串值 'true' 而不是布尔值 True。而 <car> 元素被解析为尴尬的 'car': {'@xsi:nil': 'true', '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance'} 键值对,而不是值 None。您必须仔细检查任何 XML 模块的输入和输出,以验证它是否按您的意图表示数据。

编写 XML 文件

xml.etree 模块有点难以操作,因此对于小型项目,您可能更愿意调用 open() 函数和 write() 方法来自己创建 XML 文本。但要用 xml.etree 模块从头开始创建 XML 文档,您需要创建一个根 Element 对象(例如我们示例中的 <person> 元素),然后调用 SubElement() 函数为其创建子元素。您可以使用 set() 方法在元素中设置任何 XML 属性。例如,输入以下内容:

>>> import xml.etree.ElementTree as ET
>>> person = ET.Element('person')  # Create the root XML element.
>>> name = ET.SubElement(person, 'name')  # Create <name> and put it under <person>.
>>> name.text = 'Alice Doe'  # Set the text between <name> and </name>.
>>> age = ET.SubElement(person, 'age')
>>> age.text = '30'  # XML content is always a string.
>>> programmer = ET.SubElement(person, 'programmer')
>>> programmer.text = 'true'
>>> car = ET.SubElement(person, 'car')
>>> car.set('xsi:nil', 'true')
>>> car.set('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance')
>>> address = ET.SubElement(person, 'address')
>>> street = ET.SubElement(address, 'street')
>>> street.text = '100 Larkin St.' 

为了简洁,我们将省略 <address><phone> 元素的其他部分。使用根 Element 对象调用 ET.tostring()decode() 函数,以获取 XML 文本的 Python 字符串:

>>> ET.tostring(person, encoding='utf-8').decode('utf-8')
'<person><name>Alice Doe</name><age>30</age><programmer>true</programmer>
<car xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>
<address><street>100 Larkin St.</street></address></person>' 

很不幸,tostring()函数返回一个bytes对象而不是字符串,这需要调用decode()方法来获取实际的字符串。但一旦您有了 XML 文本作为 Python 字符串值,您就可以将其写入.xml文件,传递给一个函数,在 Web 请求中使用它,或者做任何您可以用字符串做的事情。

摘要

CSV、JSON 和 XML 是常见的纯文本数据存储格式。它们易于程序解析,同时仍然易于人类阅读,因此常用于简单的电子表格或 Web 应用程序数据。Python 标准库中的csvjsonxml.etree.ElementTree模块极大地简化了读取和写入这些文件的过程,因此您不需要使用open()函数来这样做。

这些格式并非特定于 Python;许多其他编程语言和软件应用程序也使用这些文件类型。本章可以帮助您编写可以与使用它们的任何应用程序交互的 Python 程序。

练习问题

  1. Excel 电子表格有哪些特性是 CSV 电子表格所不具备的?

  2. 您向csv.reader()csv.writer()传递什么以创建readerwriter对象?

  3. readerwriter对象的File对象需要以什么模式打开?

  4. 哪个方法接受一个列表参数并将其写入 CSV 文件?

  5. delimiterlineterminator关键字参数的作用是什么?

  6. 在 CSV、JSON 和 XML 中,哪些格式可以用文本编辑器应用程序轻松编辑?

  7. 哪个函数接受一个 JSON 数据字符串并返回一个 Python 数据结构?

  8. 哪个函数接受一个 Python 数据结构并返回一个 JSON 数据字符串?

  9. 哪种数据序列化格式类似于 HTML,其中标签被尖括号包围?

  10. JSON 如何写入None值?

  11. 在 JSON 中如何写入布尔值?

练习程序:Excel 到 CSV 转换器

Excel 可以通过几次鼠标点击将电子表格保存为 CSV 文件,但如果您需要将数百个 Excel 文件转换为 CSV,则需要数小时的时间进行点击。使用第十四章中的openpyxl模块编写一个程序,该程序读取当前工作目录中的所有 Excel 文件,并将它们输出为 CSV 文件。

一个 Excel 文件可能包含多个工作表;您需要为每个工作表创建一个 CSV 文件。CSV 文件的文件名应该是<excel filename>_<sheet title>.csv,其中<excel filename>是 Excel 文件的文件名(不带文件扩展名,例如spam_data,而不是spam_data.xlsx),<sheet title>Worksheet对象的title变量的字符串。

这个程序将涉及许多嵌套的for循环。程序的结构应该看起来像这样:

for excel_file in os.listdir('.'):
    # Skip non-xlsx files, load the workbook object.
    for sheet_name in wb.sheetnames:
        # Loop through every sheet in the workbook.
        # Create the CSV filename from the Excel filename and sheet title.
        # Create the csv.writer object for this CSV file.

        # Loop through every row in the sheet.
        for row_num in range(1, sheet.max_row + 1):
            row_data = []    # Append each cell to this list.
            # Loop through each cell in the row.
            for col_num in range(1, sheet.max_column + 1):
                # Append each cell's data to row_data

            # Write the row_data list to the CSV file.

        csv_file.close() 

从本书的在线资源下载 ZIP 文件excelSpreadsheets.zip,并将电子表格解压缩到与您的程序相同的目录中。您可以使用这些文件作为测试程序的文件。

CSV 格式

CSV 文件中的每一行(使用 .csv 文件扩展名)代表电子表格中的一行,行中的单元格由逗号分隔。例如,包含在在线资源中的电子表格 example3.xlsx 在 CSV 文件中看起来如下:

4/5/2035 13:34,Apples,73
4/5/2035 3:41,Cherries,85
4/6/2035 12:46,Pears,14
4/8/2035 8:59,Oranges,52
4/10/2035 2:07,Apples,152
4/10/2035 18:10,Bananas,23
4/10/2035 2:40,Strawberries,98 

我将在本章的 CSV 交互式 shell 示例中使用此文件。下载它或将文本输入到文本编辑器中,并将其保存为 example3.csv

你可以将 CSV 文件视为一系列值列表的列表。Python 代码可以将 example3.csv 的内容表示为值 [['4/5/2035 13:34', 'Apples', '73'], ['4/5/2035 3:41', 'Cherries', '85'], ... ['4/10/2035 2:40', 'Strawberries', '98']]。CSV 文件很简单,缺乏电子表格的许多功能。例如,它们:

  • 没有多个数据类型;每个值都是字符串

  • 没有字体大小或颜色的设置

  • 不要有多个工作表

  • 不能指定单元格宽度和单元格高度

  • 不能合并单元格

  • 不能嵌入图像或图表

CSV 文件的优势在于其简单性。许多应用程序和编程语言都支持它们,你可以在文本编辑器中查看它们(包括 Mu),并且它们是表示电子表格数据的一种直接方式。

由于 CSV 文件仅仅是文本文件,你可能会倾向于将它们读取为字符串,然后使用你在第八章中学到的技术来处理这个字符串。例如,由于 CSV 文件中的每个单元格都是由逗号分隔的,你可能会尝试对每一行文本调用 split(',') 来获取以逗号分隔的值作为字符串列表。但 CSV 文件中的每个逗号并不一定代表两个单元格之间的边界。CSV 文件有一组转义字符,允许你将逗号和其他字符作为值的一部分包含进来。split() 方法无法处理这些转义字符。由于这些潜在的问题,csv 模块提供了一种更可靠的方式来读取和写入 CSV 文件。

读取 CSV 文件

要读取 CSV 文件,你必须创建一个 csv.reader 对象,它允许你遍历 CSV 文件中的行。csv 模块是 Python 内置的,因此你可以直接导入它,无需先安装。将 example3.csv 放置在当前工作目录中,然后在交互式 shell 中输入以下内容:

>>> import csv
>>> example_file = open('example3.csv')
>>> example_reader = csv.reader(example_file)
>>> example_data = list(example_reader)
>>> example_data
[['4/5/2035 13:34', 'Apples', '73'], ['4/5/2035 3:41', 'Cherries', '85'],
['4/6/2035 12:46', 'Pears', '14'], ['4/8/2035 8:59', 'Oranges', '52'],
['4/10/2035 2:07', 'Apples', '152'], ['4/10/2035 18:10', 'Bananas', '23'],
['4/10/2035 2:40', 'Strawberries', '98']]
>>> example_file.close() 

要使用 csv 模块读取 CSV 文件,使用 open() 函数打开它,就像打开任何其他文本文件一样,但不要在 open() 返回的 File 对象上调用 read()readlines() 方法,而是将其传递给 csv.reader() 函数。这个函数应该返回一个 reader 对象。请注意,你不能直接将文件名字符串传递给 csv.reader() 函数。

访问 reader 对象中的值的最简单方法是将它传递给 list() 来将其转换为普通的 Python 列表。使用 list() 对此 reader 对象的操作返回一个列表的列表,您可以将它存储在变量中,例如 example_data。在 shell 中输入 example_data 会显示列表的列表。

现在您已经将 CSV 文件作为列表的列表,您可以使用表达式 example_data[row][col] 访问特定行和列的值,其中 rowexample_data 中列表的索引,col 是您想要从该列表中获取的项目索引。在交互式 shell 中输入以下内容:

>>> example_data[0][0]  # First row, first column
'4/5/2035 13:34'
>>> example_data[0][1]  # First row, second column
'Apples'
>>> example_data[0][2]  # First row, third column
'73'
>>> example_data[1][1]  # Second row, second column
'Cherries'
>>> example_data[6][1]  # Seventh row, second column
'Strawberries' 

从输出中可以看出,example_data[0][0] 进入第一个列表,给我们第一个字符串,example_data[0][2] 进入第一个列表,给我们第三个字符串,依此类推。

在 for 循环中访问数据

对于大型 CSV 文件,您可能希望使用 reader 对象在 for 循环中。这种方法可以避免您一次将整个文件加载到内存中。例如,在交互式 shell 中输入以下内容:

>>> import csv
>>> example_file = open('example3.csv')
>>> example_reader = csv.reader(example_file)
>>> for row in example_reader: # ❶
...     print('Row #' + str(example_reader.line_num) + ' ' + str(row)) # ❷
...
Row #1 ['4/5/2035 13:34', 'Apples', '73']
Row #2 ['4/5/2035 3:41', 'Cherries', '85']
Row #3 ['4/6/2035 12:46', 'Pears', '14']
Row #4 ['4/8/2035 8:59', 'Oranges', '52']
Row #5 ['4/10/2035 2:07', 'Apples', '152']
Row #6 ['4/10/2035 18:10', 'Bananas', '23']
Row #7 ['4/10/2035 2:40', 'Strawberries', '98'] 

在您导入 csv 模块并从 CSV 文件创建 reader 对象后,您可以遍历 reader 对象中的行 ❶。每一行是存储在行变量中的值的列表,列表中的每个值代表一个单元格。

print() 函数调用 ❷ 打印当前行的编号和行内容。要获取行号,请使用 reader 对象的 line_num 属性,它存储一个整数。如果您的 CSV 文件在第一行包含列标题,您可以使用 line_num 来检查您是否在行 1,并运行 continue 指令来跳过标题。与 Python 列表索引不同,line_num 中的行号从 1 开始,而不是 0。

您只能遍历 reader 对象一次。要重新读取 CSV 文件,您必须再次调用 open()csv.reader() 来创建另一个 reader 对象。

编写 CSV 文件

csv.writer 对象允许您将数据写入 CSV 文件。要创建 writer 对象,请使用 csv.writer() 函数。在交互式 shell 中输入以下内容:

>>> import csv
>>> output_file = open('output.csv', 'w', newline='') # ❶
>>> output_writer = csv.writer(output_file) # ❷
>>> output_writer.writerow(['spam', 'eggs', 'bacon', 'ham'])
21
>>> output_writer.writerow(['Hello, world!', 'eggs', 'bacon', 'ham'])
32
>>> output_writer.writerow([1, 2, 3.141592, 4])
16
>>> output_file.close() 

调用 open() 并传递 'w' 以写入模式打开文件 ❶。此代码应创建一个对象,然后您可以将其传递给 csv.writer() ❷ 来生成 writer 对象。

在 Windows 上,您还需要为 open() 函数的 newline 关键字参数传递一个空字符串。由于本书范围之外的技术原因,如果您忘记设置 newline 参数,output.csv 中的行将双倍行距,如图 18-1 所示。

Windows 计算机桌面的截图,显示六个重叠的计算器程序

图 18-1:双倍行距的 CSV 文件

writer对象的writerow()方法接受一个列表参数。列表中的每个值将出现在输出 CSV 文件中的单独单元格中。该方法返回的值是写入该行的字符数(包括换行符)。例如,以下代码在我们的示例中生成一个output.csv文件,其外观如下:

spam,eggs,bacon,ham
"Hello, world!",eggs,bacon,ham
1,2,3.141592,4 

注意writer对象如何自动将 CSV 文件中的值'Hello, world!'中的逗号用双引号转义。csv模块让您免于自己处理这些特殊情况。

使用制表符而不是逗号

制表符分隔值(TSV)文件类似于 CSV 文件,但不出所料,使用制表符而不是逗号。它们的文件具有.tsv文件扩展名。假设您想使用制表符而不是逗号来分隔单元格,并且希望行之间有双倍间距。您可以在交互式 shell 中输入以下内容:

>>> import csv
>>> output_file = open('output.tsv', 'w', newline='')
>>> output_writer = csv.writer(output_file, delimiter='\t', lineterminator='\n\n') ❶
>>> output_writer.writerow(['spam', 'eggs', 'bacon', 'ham'])
21
>>> output_writer.writerow(['Hello, world!', 'eggs', 'bacon', 'ham'])
30
>>> output_writer.writerow([1, 2, 3.141592, 4])
16
>>> output_file.close() 

此代码更改了文件中的分隔符和行终止符字符。分隔符是出现在行中单元格之间的字符。默认情况下,CSV 文件的分隔符是逗号。行终止符是位于行末的字符。默认情况下,行终止符是换行符。您可以通过使用csv.writer()中的delimiterlineterminator关键字参数来更改字符值。

通过传递delimiter='\t'lineterminator='\n\n' ❶,将分隔符更改为制表符,并将行终止符更改为两个换行符。然后代码调用writerow()三次以创建三行,生成一个名为output.tsv的文件,其内容如下:

spam    eggs    bacon   ham

Hello, world!   eggs    bacon   ham

1   2   3.141592    4 

制表符现在分隔电子表格中的单元格。

处理标题行

对于包含标题行的 CSV 文件,使用DictReaderDictWriter对象通常比使用readerwriter对象更方便。虽然readerwriter通过使用列表读取和写入 CSV 文件行,但DictReaderDictWriter使用字典执行相同的函数,将第一行中的值视为键。

从书籍的在线资源中下载exampleWithHeader3.csv以获取下一个示例。此文件与example3.csv相同,但它在第一行包含时间戳水果数量作为列标题。要读取文件,请在交互式 shell 中输入以下内容:

>>> import csv
>>> example_file = open('exampleWithHeader3.csv')
>>> example_dict_reader = csv.DictReader(example_file)
>>> example_dict_data = list(example_dict_reader) # ❶
>>> example_dict_data
[{'Timestamp': '4/5/2035 3:41', 'Fruit': 'Cherries', 'Quantity': '85'},
{'Timestamp': '4/6/2035 12:46', 'Fruit': 'Pears', 'Quantity': '14'},
{'Timestamp': '4/8/2035 8:59', 'Fruit': 'Oranges', 'Quantity': '52'},
{'Timestamp': '4/10/2035 2:07', 'Fruit': 'Apples', 'Quantity': '152'},
{'Timestamp': '4/10/2035 18:10', 'Fruit': 'Bananas', 'Quantity': '23'},
{'Timestamp': '4/10/2035 2:40', 'Fruit': 'Strawberries', 'Quantity': '98'}]
>>> example_file = open('exampleWithHeader3.csv')
>>> example_dict_reader = csv.DictReader(example_file)
>>> for row in example_dict_reader: # ❷
...     print(row['Timestamp'], row['Fruit'], row['Quantity'])
...
4/5/2035 13:34 Apples 73
4/5/2035 3:41 Cherries 85
4/6/2035 12:46 Pears 14
4/8/2035 8:59 Oranges 52
4/10/2035 2:07 Apples 152
4/10/2035 18:10 Bananas 23
4/10/2035 2:40 Strawberries 98 

通过将DictReader对象传递给list() ❶,您可以获取 CSV 数据作为字典列表。列表中的每一行对应一个字典。或者,您可以在for循环中使用DictReader对象 ❷。DictReader对象将row设置为字典对象,其键来自第一行的标题。使用DictReader对象意味着您不需要额外的代码来跳过第一行的标题信息,因为DictReader对象会为您完成这项工作。

如果您尝试使用没有第一行列标题的 example3.csvDictReader 对象,DictReader 对象将使用 '4/5/2035 13:34''Apples''73' 作为字典键。为了避免这种情况,您可以向 DictReader() 函数提供一个包含虚构标题名称的第二个参数:

>>> import csv
>>> example_file = open('example3.csv')
>>> example_dict_reader = csv.DictReader(example_file, ['time', 'name', 'amount'])
>>> for row in example_dict_reader:
...     print(row['time'], row['name'], row['amount'])
...
4/5/2035 13:34 Apples 73
4/5/2035 3:41 Cherries 85
4/6/2035 12:46 Pears 14
4/8/2035 8:59 Oranges 52
4/10/2035 2:07 Apples 152
4/10/2035 18:10 Bananas 23
4/10/2035 2:40 Strawberries 98 

因为 example3.csv 的第一行不包含列标题,我们创建了我们的标题:'time''name''amount'DictWriter 对象使用字典创建 CSV 文件:

>>> import csv
>>> output_file = open('output.csv', 'w', newline='')
>>> output_dict_writer = csv.DictWriter(output_file, ['Name', 'Pet', 'Phone'])
>>> output_dict_writer.writeheader()
16
>>> output_dict_writer.writerow({'Name': 'Alice', 'Pet': 'cat', 'Phone': '555-1234'})
20
>>> output_dict_writer.writerow({'Name': 'Bob', 'Phone': '555-9999'})
15
>>> output_dict_writer.writerow({'Phone': '555-5555', 'Name': 'Carol', 'Pet': 'dog'})
20
>>> output_file.close() 

如果您希望文件包含标题行,可以通过调用 writeheader() 来写入该行。否则,跳过调用 writeheader() 以从文件中省略标题行。然后,您可以通过调用 writerow() 方法并传递一个使用标题作为键并包含要写入文件的数据的字典来写入 CSV 文件的每一行。

该代码创建的 output.csv 文件看起来如下所示:

Name,Pet,Phone
Alice,cat,555-1234
Bob,,555-9999
Carol,dog,555-5555 

双逗号表示 Bob 对于宠物有一个空值。请注意,您传递给 writerow() 的字典中键值对的顺序并不重要;它们将按照 DictWriter() 给定的键的顺序写入。例如,即使您在第四行中先传递了 Phone 键和值,然后才是 NamePet 键和值,电话号码仍然在输出中最后出现。

注意,任何缺失的键,例如 {'Name': 'Bob', 'Phone': '555-9999'} 中的 'Pet',在 CSV 文件中将成为空单元格。

项目 13:从 CSV 文件中移除标题

假设您有一个无聊的工作,就是从几百个 CSV 文件中移除第一行。也许您将它们输入到一个只需要数据而不需要列顶标题的自动化流程中。您 可以 在 Excel 中打开每个文件,删除第一行,然后重新保存文件——但这将花费数小时。让我们编写一个程序来完成这项工作。

程序需要打开当前工作目录中所有具有 .csv 扩展名的文件,读取 CSV 文件的内容,并将没有第一行的新内容重写回同名文件。这将用新的无标题内容替换 CSV 文件中的旧内容。

警告

就像往常一样,无论何时编写修改文件的程序,请务必先备份文件,以防程序没有按预期工作。您不希望意外删除原始文件。

在高层次上,程序必须执行以下操作:

  • 在当前工作目录中找到所有的 CSV 文件。

  • 读取每个文件的全部内容。

  • 跳过第一行将内容写入新的 CSV 文件。

在代码层面,这意味着程序需要执行以下操作:

  • 遍历 os.listdir() 返回的文件列表,跳过非 CSV 文件。

  • 创建一个 CSV reader 对象并读取文件内容,使用 line_num 属性来确定要跳过的行。

  • 创建一个 CSV writer 对象并将读取的数据写入新文件。

对于这个项目,打开一个新的文件编辑窗口并将其保存为removeCsvHeader.py

第 1 步:遍历每个文件

你的程序首先需要遍历当前工作目录中所有 CSV 文件名的列表。让removeCsvHeader.py看起来像这样:

# Removes the header line from csv files
import csv, os

os.makedirs('headerRemoved', exist_ok=True)

# Loop through every file in the current working directory.
for csv_filename in os.listdir('.'):
    if not csv_filename.endswith('.csv'):
        print('Row #' + str(example_reader.line_num) + ' ' + str(row)) # ❷

    print('Removing header from ' + csv_filename + '...')

    # TODO: Read the CSV file (skipping the first row).

    # TODO: Write the CSV file. 

os.makedirs()调用在headerRemoved文件夹中创建一个文件夹,用于保存无头 CSV 文件。在os.listdir('.')上的for循环可以让你做到这一点,但它将遍历工作目录中的所有文件,所以你需要在循环的开始处添加一些代码来跳过不以.csv结尾的文件名。continue语句❶使得当for循环遇到非 CSV 文件时,会跳到下一个文件名。

为了在程序运行时看到输出,打印一条消息,指出程序正在处理哪个 CSV 文件。然后,添加一些TODO注释,指出程序其余部分应该做什么。

第 2 步:读取文件

程序不会从 CSV 文件中删除第一行。相反,它创建了一个没有第一行的 CSV 文件的新副本。这样,如果新文件被错误地修改,我们可以使用原始文件。

程序需要一种方法来跟踪它是否正在循环第一行。将以下内容添加到removeCsvHeader.py中。

# Removes the header line from csv files
import csv, os

# --snip--

 # Read the CSV file (skipping the first row).
    csv_rows = []
    csv_file_obj = open(csv_filename)
 reader_obj = csv.reader(csv_file_obj)
    for row in reader_obj:
        if reader_obj.line_num == 1:
            continue # Skip the first row.
 csv_rows.append(row)
    csv_file_obj.close()

    # TODO: Write the CSV file. 

可以使用reader对象的line_num属性来确定 CSV 文件中正在读取的哪一行。另一个for循环将遍历从 CSVreader对象返回的行,除了第一行之外的所有行都将追加到csv_rows中。

for循环遍历每一行时,代码会检查reader _obj.line_num是否设置为1。如果是,它将执行continue跳到下一行,而不将其追加到csv_rows中。对于后续的每一行,条件始终为False,代码将行追加到csv_rows中。

第 3 步:写入新的 CSV 文件

现在,csv_rows包含了除了第一行之外的所有行,我们需要将这个列表写入headerRemoved文件夹中的 CSV 文件。将以下内容添加到removeCsvHeader.py中:

# Removes the header line from csv files
import csv, os

# --snip--

# Loop through every file in the current working directory.
for csv_filename in os.listdir('.'): # ❶
    if not csv_filename.endswith('.csv'):
        continue    # Skip non-CSV files.

    # --snip--

 # Write the CSV file.
    csv_file_obj = open(os.path.join('headerRemoved', csv_filename), 'w', 
 newline='')
    csv_writer = csv.writer(csv_file_obj)
    for row in csv_rows:
        csv_writer.writerow(row)
    csv_file_obj.close() 

CSVwriter对象将使用csv_filename(我们在 CSV 读取器中也使用了它)将列表写入headerRemoved文件夹中的 CSV 文件。在创建writer对象后,我们遍历存储在csv_rows中的子列表,并将每个子列表写入文件。

外部for循环❶将遍历os.listdir('.')返回的下一个文件名。当这个循环完成后,程序将完成。

为了测试你的程序,从书籍的在线资源中下载removeCsvHeader.zip,并将其解压到一个文件夹中。然后,在那个文件夹中运行removeCsvHeader.py程序。输出将如下所示:

Removing header from NAICS_data_1048.csv...
Removing header from NAICS_data_1218.csv...
# --snip--
Removing header from NAICS_data_9834.csv...
Removing header from NAICS_data_9986.csv... 

这个程序应该在从 CSV 文件中删除第一行时打印每个文件名。

类似程序的思路

与 CSV 文件一起工作的程序与处理 Excel 文件的程序类似,因为 CSV 和 Excel 都是电子表格文件。例如,你可以编写程序来完成以下操作:

  • 比较 CSV 文件中不同行之间的数据,或比较多个 CSV 文件。

  • 将特定数据从 CSV 文件复制到 Excel 文件,或反之亦然。

  • 检查 CSV 文件中的无效数据或格式错误,并向用户报告这些错误。

  • 从 CSV 文件中读取数据作为 Python 程序的输入。

读取 CSV 文件

要读取 CSV 文件,你必须创建一个csv.reader对象,它允许你遍历 CSV 文件中的行。csv模块是 Python 的一部分,因此你可以直接导入它而无需首先安装。将example3.csv放置在当前工作目录中,然后在交互式 shell 中输入以下内容:

>>> import csv
>>> example_file = open('example3.csv')
>>> example_reader = csv.reader(example_file)
>>> example_data = list(example_reader)
>>> example_data
[['4/5/2035 13:34', 'Apples', '73'], ['4/5/2035 3:41', 'Cherries', '85'],
['4/6/2035 12:46', 'Pears', '14'], ['4/8/2035 8:59', 'Oranges', '52'],
['4/10/2035 2:07', 'Apples', '152'], ['4/10/2035 18:10', 'Bananas', '23'],
['4/10/2035 2:40', 'Strawberries', '98']]
>>> example_file.close() 

要使用csv模块读取 CSV 文件,使用open()函数打开它,就像打开任何其他文本文件一样,但不是在open()返回的File对象上调用read()readlines()方法,而是将其传递给csv.reader()函数。此函数应返回一个reader对象。请注意,你不能直接将文件名字符串传递给csv.reader()函数。

访问reader对象中的值最简单的方法是将它通过传递给list()函数转换为普通的 Python 列表。对reader对象使用list()会返回一个列表的列表,你可以将其存储在变量中,例如example_data。在 shell 中输入example_data会显示列表的列表。

现在你已经将 CSV 文件作为列表的列表,你可以使用表达式example_data[row][col]访问特定行和列的值,其中rowexample_data中某个列表的索引,col是你想从该列表中获取的项的索引。在交互式 shell 中输入以下内容:

>>> example_data[0][0]  # First row, first column
'4/5/2035 13:34'
>>> example_data[0][1]  # First row, second column
'Apples'
>>> example_data[0][2]  # First row, third column
'73'
>>> example_data[1][1]  # Second row, second column
'Cherries'
>>> example_data[6][1]  # Seventh row, second column
'Strawberries' 

从输出中可以看出,example_data[0][0]进入第一个列表并给出第一个字符串,example_data[0][2]进入第一个列表并给出第三个字符串,依此类推。

在 for 循环中访问数据

对于大型 CSV 文件,你可能希望使用reader对象在for循环中。这种方法可以避免一次将整个文件加载到内存中。例如,在交互式 shell 中输入以下内容:

>>> import csv
>>> example_file = open('example3.csv')
>>> example_reader = csv.reader(example_file)
>>> for row in example_reader: # ❶
...     print('Row #' + str(example_reader.line_num) + ' ' + str(row)) # ❷
...
Row #1 ['4/5/2035 13:34', 'Apples', '73']
Row #2 ['4/5/2035 3:41', 'Cherries', '85']
Row #3 ['4/6/2035 12:46', 'Pears', '14']
Row #4 ['4/8/2035 8:59', 'Oranges', '52']
Row #5 ['4/10/2035 2:07', 'Apples', '152']
Row #6 ['4/10/2035 18:10', 'Bananas', '23']
Row #7 ['4/10/2035 2:40', 'Strawberries', '98'] 

在导入csv模块并从 CSV 文件创建reader对象之后,你可以遍历reader对象中的行 ❶。每一行是存储在行变量中的值的列表,列表中的每个值代表一个单元格。

print()函数调用 ❷ 打印当前行的编号和行内容。要获取行号,使用reader对象的line_num属性,它存储一个整数。如果你的 CSV 文件在第一行包含列标题,你可以使用line_num来检查你是否在第一行,并运行continue指令来跳过标题。与 Python 列表索引不同,line_num中的行号从 1 开始,而不是 0。

你只能遍历reader对象一次。要重新读取 CSV 文件,你必须再次调用open()csv.reader()来创建另一个reader对象。

写入 CSV 文件

csv.writer 对象让你可以将数据写入 CSV 文件。要创建 writer 对象,请使用 csv.writer() 函数。在交互式 shell 中输入以下内容:

>>> import csv
>>> output_file = open('output.csv', 'w', newline='') # ❶
>>> output_writer = csv.writer(output_file) # ❷
>>> output_writer.writerow(['spam', 'eggs', 'bacon', 'ham'])
21
>>> output_writer.writerow(['Hello, world!', 'eggs', 'bacon', 'ham'])
32
>>> output_writer.writerow([1, 2, 3.141592, 4])
16
>>> output_file.close() 

调用 open() 并传递 'w' 以写入模式打开文件 ❶。此代码应创建一个对象,然后你可以将其传递给 csv.writer() ❷ 以生成一个 writer 对象。

在 Windows 上,你还需要为 open() 函数的 newline 关键字参数传递一个空字符串。由于本书范围之外的技术原因,如果你忘记设置 newline 参数,output.csv 中的行将会是双倍间距,如图 18-1 所示。

Windows 计算机桌面截图,显示六个重叠的计算器程序

图 18-1:双倍间距的 CSV 文件

writer 对象的 writerow() 方法接受一个列表参数。列表中的每个值将在输出 CSV 文件的单独单元格中显示。该方法返回的值是写入该行的字符数(包括换行符)。例如,以下代码在我们的示例中生成一个看起来像这样的 output.csv 文件:

spam,eggs,bacon,ham
"Hello, world!",eggs,bacon,ham
1,2,3.141592,4 

注意 writer 对象如何自动在 CSV 文件中将值 'Hello, world!' 中的逗号用双引号转义。csv 模块让你免于自己处理这些特殊情况。

使用制表符而不是逗号

制表符分隔值 (TSV) 文件与 CSV 文件类似,但不出所料,它们使用制表符而不是逗号。它们的文件扩展名为 .tsv。假设你想使用制表符而不是逗号来分隔单元格,并且希望行是双倍间距。你可以在交互式 shell 中输入以下内容:

>>> import csv
>>> output_file = open('output.tsv', 'w', newline='')
>>> output_writer = csv.writer(output_file, delimiter='\t', lineterminator='\n\n') ❶
>>> output_writer.writerow(['spam', 'eggs', 'bacon', 'ham'])
21
>>> output_writer.writerow(['Hello, world!', 'eggs', 'bacon', 'ham'])
30
>>> output_writer.writerow([1, 2, 3.141592, 4])
16
>>> output_file.close() 

此代码更改了文件中的分隔符和行终止符字符。分隔符 是出现在行中单元格之间的字符。默认情况下,CSV 文件的分隔符是逗号。行终止符 是位于行末的字符。默认情况下,行终止符是换行符。你可以通过使用 csv.writer()delimiterlineterminator 关键字参数来更改这些字符的值。

通过传递 delimiter='\t'lineterminator='\n\n' ❶ 将分隔符更改为制表符,并将行终止符更改为两个换行符。然后代码调用 writerow() 三次以创建三行,生成一个名为 output.tsv 的文件,其内容如下:

spam    eggs    bacon   ham

Hello, world!   eggs    bacon   ham

1   2   3.141592    4 

现在制表符在电子表格中分隔单元格。

处理标题行

对于包含标题行的 CSV 文件,使用 DictReaderDictWriter 对象通常比使用 readerwriter 对象更方便。虽然 readerwriter 通过使用列表读取和写入 CSV 文件行,但 DictReaderDictWriter 使用字典执行相同的函数,将第一行的值视为键。

从本书的在线资源下载exampleWithHeader3.csv,用于下一个示例。此文件与example3.csv相同,除了它包括第一行的时间戳水果数量作为列标题。要读取文件,请在交互式外壳中输入以下内容:

>>> import csv
>>> example_file = open('exampleWithHeader3.csv')
>>> example_dict_reader = csv.DictReader(example_file)
>>> example_dict_data = list(example_dict_reader) # ❶
>>> example_dict_data
[{'Timestamp': '4/5/2035 3:41', 'Fruit': 'Cherries', 'Quantity': '85'},
{'Timestamp': '4/6/2035 12:46', 'Fruit': 'Pears', 'Quantity': '14'},
{'Timestamp': '4/8/2035 8:59', 'Fruit': 'Oranges', 'Quantity': '52'},
{'Timestamp': '4/10/2035 2:07', 'Fruit': 'Apples', 'Quantity': '152'},
{'Timestamp': '4/10/2035 18:10', 'Fruit': 'Bananas', 'Quantity': '23'},
{'Timestamp': '4/10/2035 2:40', 'Fruit': 'Strawberries', 'Quantity': '98'}]
>>> example_file = open('exampleWithHeader3.csv')
>>> example_dict_reader = csv.DictReader(example_file)
>>> for row in example_dict_reader: # ❷
...     print(row['Timestamp'], row['Fruit'], row['Quantity'])
...
4/5/2035 13:34 Apples 73
4/5/2035 3:41 Cherries 85
4/6/2035 12:46 Pears 14
4/8/2035 8:59 Oranges 52
4/10/2035 2:07 Apples 152
4/10/2035 18:10 Bananas 23
4/10/2035 2:40 Strawberries 98 

通过将DictReader对象传递给list()❶,你可以将 CSV 数据作为字典列表获取。列表中的每一行对应一个字典。或者,你可以在for循环中使用DictReader对象❷。DictReader对象将row设置为具有从第一行标题派生的键的字典对象。使用DictReader对象意味着你不需要额外的代码来跳过第一行的标题信息,因为DictReader对象会为你完成这项工作。

如果你尝试使用没有第一行列标题的example3.csvDictReader对象,该对象将使用'4/5/2035 13:34''Apples''73'作为字典键。为了避免这种情况,你可以向DictReader()函数提供一个包含虚构标题名称的第二个参数:

>>> import csv
>>> example_file = open('example3.csv')
>>> example_dict_reader = csv.DictReader(example_file, ['time', 'name', 'amount'])
>>> for row in example_dict_reader:
...     print(row['time'], row['name'], row['amount'])
...
4/5/2035 13:34 Apples 73
4/5/2035 3:41 Cherries 85
4/6/2035 12:46 Pears 14
4/8/2035 8:59 Oranges 52
4/10/2035 2:07 Apples 152
4/10/2035 18:10 Bananas 23
4/10/2035 2:40 Strawberries 98 

因为example3.csv的第一行不包含列标题,我们创建了我们的:'time''name''amount'DictWriter对象使用字典来创建 CSV 文件:

>>> import csv
>>> output_file = open('output.csv', 'w', newline='')
>>> output_dict_writer = csv.DictWriter(output_file, ['Name', 'Pet', 'Phone'])
>>> output_dict_writer.writeheader()
16
>>> output_dict_writer.writerow({'Name': 'Alice', 'Pet': 'cat', 'Phone': '555-1234'})
20
>>> output_dict_writer.writerow({'Name': 'Bob', 'Phone': '555-9999'})
15
>>> output_dict_writer.writerow({'Phone': '555-5555', 'Name': 'Carol', 'Pet': 'dog'})
20
>>> output_file.close() 

如果你想你的文件包含一个标题行,可以通过调用writeheader()来写入那一行。否则,跳过调用writeheader()以从文件中省略标题行。然后,你可以通过调用writerow()方法来写入 CSV 文件的每一行,传递一个使用标题作为键并包含要写入文件的数据的字典。

此代码创建的output.csv文件看起来如下所示:

Name,Pet,Phone
Alice,cat,555-1234
Bob,,555-9999
Carol,dog,555-5555 

双逗号表示 Bob 在宠物栏位有一个空值。请注意,你传递给writerow()的字典中键值对的顺序并不重要;它们将按照DictWriter()提供的键的顺序写入。例如,即使你在第四行中先传递了Phone键和值,然后是NamePet键和值,电话号码仍然在输出中最后出现。

注意,任何缺失的键,例如{'Name': 'Bob', 'Phone': '555-9999'}中的'Pet',在 CSV 文件中将成为空单元格。

项目 13:从 CSV 文件中移除标题

假设你有从几百个 CSV 文件中删除第一行的无聊工作。也许你将它们输入到一个只需要数据,不需要列顶标题的自动化流程中。你可以在 Excel 中打开每个文件,删除第一行,然后重新保存文件——但这将花费数小时。让我们编写一个程序来完成这项工作。

程序需要打开当前工作目录中所有扩展名为.csv的文件,读取 CSV 文件的内容,并将没有第一行标题的内容重写到同名文件中。这将用没有标题的新内容替换 CSV 文件的旧内容。

警告

如往常一样,每次编写修改文件的程序时,请务必先备份文件,以防程序没有按预期工作。你不想意外地删除原始文件。

从高层次来看,程序必须执行以下操作:

  • 在当前工作目录中查找所有 CSV 文件。

  • 读取每个文件的完整内容。

  • 将内容写入新 CSV 文件,跳过第一行。

在代码层面,这意味着程序需要执行以下操作:

  • 遍历 os.listdir() 返回的文件列表,跳过非 CSV 文件。

  • 创建一个 CSV reader 对象并读取文件的全部内容,使用 line_num 属性来确定要跳过的行。

  • 创建一个 CSV writer 对象并将读取的数据写入新文件。

对于这个项目,打开一个新的文件编辑器窗口并将其保存为 removeCsvHeader.py

第 1 步:遍历每个文件

你的程序首先需要遍历当前工作目录中所有 CSV 文件名的列表。使 removeCsvHeader.py 看起来像这样:

# Removes the header line from csv files
import csv, os

os.makedirs('headerRemoved', exist_ok=True)

# Loop through every file in the current working directory.
for csv_filename in os.listdir('.'):
    if not csv_filename.endswith('.csv'):
        print('Row #' + str(example_reader.line_num) + ' ' + str(row)) # ❷

    print('Removing header from ' + csv_filename + '...')

    # TODO: Read the CSV file (skipping the first row).

    # TODO: Write the CSV file. 

os.makedirs() 调用会在保存无头 CSV 文件的 headerRemoved 文件夹中创建一个文件夹。在 os.listdir('.') 上的 for 循环可以让你做到这一点,但它会遍历工作目录中的 所有 文件,所以你需要在循环的开始处添加一些代码来跳过不以 .csv 结尾的文件名。continue 语句 ❶ 使得当 for 循环遇到非 CSV 文件时,会跳到下一个文件名。

为了在程序运行时看到输出,打印一条消息,指出程序正在处理哪个 CSV 文件。然后,添加一些 TODO 注释,指出程序其余部分应该做什么。

第 2 步:读取文件

程序不会从 CSV 文件中删除第一行。相反,它创建了一个没有第一行的 CSV 文件的新副本。这样,我们就可以在新的文件被错误地修改时使用原始文件。

程序需要一种方法来跟踪它是否正在遍历第一行。将以下内容添加到 removeCsvHeader.py 中。

# Removes the header line from csv files
import csv, os

# --snip--

 # Read the CSV file (skipping the first row).
    csv_rows = []
    csv_file_obj = open(csv_filename)
 reader_obj = csv.reader(csv_file_obj)
    for row in reader_obj:
        if reader_obj.line_num == 1:
            continue # Skip the first row.
 csv_rows.append(row)
    csv_file_obj.close()

    # TODO: Write the CSV file. 

reader 对象的 line_num 属性可以用来确定它当前正在读取 CSV 文件的哪一行。另一个 for 循环将遍历从 CSV reader 对象返回的行,除了第一行之外的所有行都将被追加到 csv_rows

随着 for 循环遍历每一行,代码会检查 reader _obj.line_num 是否设置为 1。如果是,它将执行 continue 跳到下一行,而不会将其追加到 csv_rows。对于后续的每一行,条件将始终为 False,代码将行追加到 csv_rows

第 3 步:写入新的 CSV 文件

现在,由于 csv_rows 包含了除了第一行之外的所有行,我们需要将列表写入到 headerRemoved 文件夹中的 CSV 文件。将以下内容添加到 removeCsvHeader.py 中:

# Removes the header line from csv files
import csv, os

# --snip--

# Loop through every file in the current working directory.
for csv_filename in os.listdir('.'): # ❶
    if not csv_filename.endswith('.csv'):
        continue    # Skip non-CSV files.

    # --snip--

 # Write the CSV file.
    csv_file_obj = open(os.path.join('headerRemoved', csv_filename), 'w', 
 newline='')
    csv_writer = csv.writer(csv_file_obj)
    for row in csv_rows:
        csv_writer.writerow(row)
    csv_file_obj.close() 

CSV writer 对象将使用 csv_filename(我们也在 CSV 读取器中使用过)将列表写入 headerRemoved 中的 CSV 文件。在创建 writer 对象后,我们遍历存储在 csv_rows 中的子列表,并将每个子列表写入文件。

外部 for 循环 ❶ 将循环到 os.listdir('.') 返回的下一个文件名。当这个循环完成后,程序将完成。

为了测试你的程序,从本书的在线资源中下载 removeCsvHeader.zip 并将其解压到一个文件夹中。然后,在该文件夹中运行 removeCsvHeader.py 程序。输出将如下所示:

Removing header from NAICS_data_1048.csv...
Removing header from NAICS_data_1218.csv...
# --snip--
Removing header from NAICS_data_9834.csv...
Removing header from NAICS_data_9986.csv... 

此程序应在每次从 CSV 文件中剥离第一行时打印一个文件名。

相似程序的思路

处理 CSV 文件的程序与处理 Excel 文件的程序类似,因为 CSV 和 Excel 都是电子表格文件。例如,你可以编写以下程序:

  • 比较 CSV 文件中不同行之间的数据,或者比较多个 CSV 文件之间的数据。

  • 将特定数据从 CSV 文件复制到 Excel 文件,或反之亦然。

  • 检查 CSV 文件中的无效数据或格式错误,并向用户报告这些错误。

  • 将 CSV 文件中的数据作为 Python 程序的输入读取。

多功能纯文本格式

虽然 CSV 文件适用于存储具有完全相同列的数据行,但 JSON 和 XML 格式可以存储各种数据结构。(本书省略了不太受欢迎但仍然有用的 YAML 和 TOML 格式。)这些格式并非特定于 Python;许多编程语言都有用于读取和写入这些格式的函数。

这些格式中的每一个都使用嵌套 Python 字典和列表的等效方式来组织数据。在其他编程语言中,你可能会看到字典被称为 映射哈希映射哈希表关联数组(因为它们将一个数据项,即键,映射或关联到另一个数据项,即值)。同样,你可能会在其他语言中看到 Python 的列表被称为 数组。但概念是相同的:它们将数据组织成键值对和列表。

你可以在其他字典和列表内部嵌套字典和列表,以形成复杂的数据结构。但如果你想要将这些数据结构保存到文本文件中,你需要选择一个数据序列化格式,如 JSON 或 XML。本章中的 Python 模块可以 解析(即读取和理解)这些格式中编写的文本,以从其文本创建 Python 数据结构。

这些人类可读的纯文本格式在磁盘空间或内存使用效率方面并不是最高的,但它们的优势在于易于在文本编辑器中查看和编辑,并且是语言中立的,因为任何语言的程序都可以读取或写入文本文件。相比之下,第十章中介绍的 shelve 模块可以存储所有 Python 数据类型到二进制存储文件中,但其他语言没有模块可以将其加载到它们的程序中。

在本章的剩余部分,我将使用以下 Python 数据结构,它存储了名为 Alice 的人的个人详细信息,在每个这些格式中,以便你可以比较和对比它们:

{
    "name": "Alice Doe",
    "age": 30,
    "car": None,
    "programmer": True,
    "address": {
        "street": "100 Larkin St.",
        "city": "San Francisco",
        "zip": "94102"
    },
    "phone": [
        {
            "type": "mobile",
            "number": "415-555-7890"
        },
        {
            "type": "work",
            "number": "415-555-1234"
        }
    ]
} 

这些文本格式都有它们自己的历史,并在计算生态系统中占据特定的领域。如果你必须选择一个数据序列化格式来存储你的数据,请记住 JSON 比 XML 简单,比 YAML 更广泛采用,而 TOML 主要用作配置文件的格式。最后,提出自己的数据序列化格式可能很有吸引力,但这也是一种重新发明轮子的行为,你将不得不为你自定义的格式编写自己的解析器。简单地选择一个现有的格式会更好。

JSON

JSON 以 JavaScript 源代码的形式存储信息,尽管许多非 JavaScript 应用程序使用它。特别是,网站通常通过 API(如第十三章中介绍的 OpenWeather API)以 JSON 格式向程序员提供数据。我们以 .json 文件扩展名将 JSON 格式的文本保存到纯文本文件中。以下是作为 JSON 文本格式化的示例数据结构:

{
  "name": "Alice Doe",
  "age": 30,
  "car": null,
  "programmer": true,
  "address": {
    "street": "100 Larkin St.",
    "city": "San Francisco",
 "zip": "94102"
  },
  "phone": [
    {
      "type": "mobile",
      "number": "415-555-7890"
    },
    {
      "type": "work",
      "number": "415-555-1234"
    }
  ]
} 

你首先会注意到 JSON 与 Python 语法相似。Python 的字典和 JSON 的对象都使用花括号,并且通过逗号分隔的键值对,每个键和值之间用冒号分隔。Python 的列表和 JSON 的数组都使用方括号,并且通过逗号分隔值。在 JSON 中,双引号字符串之外的空间是不重要的,这意味着你可以按你喜欢的方式添加空格。然而,最好使用增加缩进来格式化嵌套的对象和数组,就像缩进的 Python 代码块一样。在我们的示例数据中,电话号码列表缩进两个空格,列表中的每个电话号码字典缩进四个空格。

但 JSON 和 Python 之间也存在差异。JSON 使用 JavaScript 的关键字 null 而不是 Python 的 None 值。布尔值是 JavaScript 的小写 truefalse 关键字。JSON 不允许 JavaScript 注释或多行字符串;JSON 中的所有字符串都必须使用双引号。与 Python 列表不同,JSON 数组不能有尾随逗号,因此 ["spam", "eggs"] 是有效的 JSON,而 ["spam", "eggs",] 则不是。

Facebook、Twitter、Yahoo!、Google、Tumblr、Wikipedia、Flickr、Data.gov、Reddit、IMDb、Rotten Tomatoes、LinkedIn 以及许多其他流行的网站提供与 JSON 数据一起工作的 API。其中一些网站需要注册,这几乎总是免费的。你必须找到文档来了解你的程序需要请求哪些 URL 以获取你想要的数据,以及返回的 JSON 数据结构的通用格式。如果提供 API 的网站有一个开发者页面,请在那里查找文档。

Python 的json模块通过json.loads()json.dumps()函数处理将格式为 JSON 数据的字符串与相应的 Python 值之间的转换细节。JSON 不能存储每种 Python 值,只能存储以下基本数据类型:字符串、整数、浮点数、布尔值、列表、字典和NoneType。JSON 不能表示 Python 特定的对象,例如File对象、CSV readerwriter对象,或 Selenium WebElement对象。json模块的完整文档在docs.python.org/3/library/json.html

读取 JSON 数据

要将包含 JSON 数据的字符串转换为 Python 值,请将其传递给json.loads()函数。(该名称的意思是“加载字符串”,而不是“加载”)。在交互式外壳中输入以下内容:

>>> import json # ❶
>>> json_string = '{"name": "Alice Doe", "age": 30, "car": null, "programmer":
 true, "address": {"street": "100 Larkin St.", "city": "San Francisco", "zip":
 "94102"}, "phone": [{"type": "mobile", "number": "415-555-7890"}, {"type": 
"work", "number": "415-555-1234"}]}'
>>> python_data = json.loads(json_string) # ❷
>>> python_data
{'name': 'Alice Doe', 'age': 30, 'car': None, 'programmer': True, 'address':
{'street': '100 Larkin St.', 'city': 'San Francisco', 'zip': '94102'},
'phone': [{'type': 'mobile', 'number': '415-555-7890'}, {'type': 'work',
'number': '415-555-1234'}]} 

在导入json模块 ❶之后,你可以调用loads() ❷并传递一个 JSON 数据的字符串。请注意,JSON 字符串始终使用双引号。它应该返回一个 Python 字典。

写入 JSON 数据

json.dumps()函数(其含义为“导出字符串”,而不是“导出”)将 Python 数据转换为 JSON 格式的字符串。在交互式外壳中输入以下内容:

>>> import json
>>> python_data = {'name': 'Alice Doe', 'age': 30, 'car': None, 'programmer': True, 'address':
{'street': '100 Larkin St.', 'city': 'San Francisco', 'zip': '94102'}, 'phone': [{'type':
'mobile', 'number': '415-555-7890'}, {'type': 'work', 'number': '415-555-1234'}]}
>>> json_string = json.dumps(python_data) ❶
>>> print(json_string) ❷
{"name": "Alice Doe", "age": 30, "car": null, "programmer": true, "address": {"street":
"100 Larkin St.", "city": "San Francisco", "zip": "94102"}, "phone": [{"type": "mobile",
"number": "415-555-7890"}, {"type": "work", "number": "415-555-1234"}]}
>>> json_string = json.dumps(python_data, indent=2) ❸
>>> print(json_string)
{
  "name": "Alice Doe",
  "age": 30,
  "car": null,
  "programmer": true,
  "address": {
    "street": "100 Larkin St.",
    "city": "San Francisco",
# --snip--
} 

传递给json.dumps()的值只能由以下基本 Python 数据类型组成:字符串、整数、浮点数、布尔值、列表、字典和NoneType

默认情况下,整个 JSON 文本将写在一行上 ❷。这种压缩格式对于在程序之间读取和写入 JSON 文本是不错的,但对于人类阅读来说,多行缩进的形式会更好。indent=2关键字参数 ❸将 JSON 文本格式化为单独的行,每个嵌套字典或列表缩进两个空格。除非你的 JSON 文件大小达到兆字节级别,否则通过添加空格和换行符来增加大小对于可读性是值得的。

一旦你有 JSON 文本作为 Python 字符串值,你可以将其写入.json文件,传递给函数,用于网络请求,或执行任何可以用字符串进行的其他操作。

XML

XML 文件格式比 JSON 更老,但仍然被广泛使用。其语法类似于 HTML,我们在第十八章中已经介绍过,它涉及在包含其他内容的尖括号内嵌套打开和关闭标签。这些标签被称为元素。SVG 图像文件由 XML 编写的文本组成。RSS 和 Atom 网络源格式也是用 XML 编写的,而 Microsoft Word 文档只是具有.docx文件扩展名的 ZIP 文件,其中包含 XML 文件。

我们将 XML 格式的文本存储在具有.xml文件扩展名的纯文本文件中。以下是以 XML 格式表示的示例数据结构:

<person>
    <name>Alice Doe</name>
    <age>30</age>
    <programmer>true</programmer>
    <car xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>
    <address>
        <street>100 Larkin St.</street>
        <city>San Francisco</city>
        <zip>94102</zip>
    </address>
    <phone>
        <phoneEntry>
            <type>mobile</type>
            <number>415-555-7890</number>
        </phoneEntry>
        <phoneEntry>
            <type>work</type>
            <number>415-555-1234</number>
        </phoneEntry>
    </phone>
</person> 

在这个例子中,<person>元素有子元素<name><age>等等。<name><age>子元素是子元素,而<person>是它们的父元素。有效的 XML 文档必须有一个单一的根元素,它包含所有其他元素,例如这个例子中的<person>元素。以下具有多个根元素的文档是不合法的:

<person><name>Alice Doe</name></person>
<person><name>Bob Smith</name></person>
<person><name>Carol Watanabe</name></person> 

与更现代的序列化格式如 JSON 相比,XML 相当冗长。每个元素都有一个开放和闭合标签,例如<age></age>。XML 元素是一个键值对,键是元素的标签(在这种情况下,<age>),值是开放和闭合标签之间的文本。XML 文本没有数据类型;开放和闭合标签之间的所有内容都被视为字符串,包括我们示例数据中的94102true文本。数据列表,如<phone>元素,必须使用它们自己的元素来命名单个项目,例如<phoneEntry>。这些子元素的“Entry”后缀只是一个命名约定。

XML 的注释与 HTML 的注释相同:在<!---->之间的任何内容都应被忽略。

开放和闭合标签之外的空间字符是无意义的,你可以按自己的喜好格式化它。XML 中没有“null”值,但你可以通过向标签添加xsi:nil="true"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"属性来近似它。XML 属性是以 key="value"格式在开放标签内写成的键值对。标签被写成自闭合标签;而不是使用闭合标签,开放标签以/>结束,例如<car xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>

标签和属性名可以写成任何大小写,但按照惯例都是小写。属性值可以放在单引号或双引号内,但双引号是标准的。

是否使用子元素或属性通常是模糊的。我们的示例数据使用这些元素来处理地址数据:

<address>
    <street>100 Larkin St.</street>
    <city>San Francisco</city>
    <zip>94102</zip>
</address> 

然而,它很容易将子元素数据格式化为自闭合的<address>元素中的属性:

<address street="100 Larkin St." city="San Francisco" zip="94102" />

这种类型的歧义,以及标签的冗长性质,使得 XML 不如它曾经那么受欢迎。XML 在 1990 年代和 2000 年代得到了广泛的应用,并且其中许多软件至今仍在使用。但除非你有特定的理由使用 XML,否则使用 JSON 会更好。

通常,XML 软件库有两种读取 XML 文档的方式。文档对象模型 (DOM) 方法一次性将整个 XML 文档读入内存。这使得在 XML 文档的任何位置访问数据变得容易,但通常只适用于小型或中等大小的 XML 文档。简单 XML API (SAX) 方法将 XML 文档作为元素流读取,因此不需要一次性将整个文档加载到内存中。这种方法对于大小为千兆的 XML 文档来说很理想,但不太方便,因为你必须先在文档中迭代元素才能与元素一起工作。

Python 的标准库提供了 xml.domxml.saxxml.etree.ElementTree 模块来处理 XML 文本。在我们的简单示例中,我们将使用 Python 的 xml.etree.ElementTree 模块一次性读取整个 XML 文档。

读取 XML 文件

xml.etree 模块使用 Element 对象来表示 XML 元素及其子元素。在交互式 shell 中输入以下内容:

>>> import xml.etree.ElementTree as ET # ❶
>>> xml_string = """<person><name>Alice Doe</name><age>30</age> # ❷
<programmer>true</programmer><car xsi:nil="true" xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance”/><address><street>
100 Larkin St.</street><city>San Francisco</city><zip>94102</zip>
</address><phone><phoneEntry><type>mobile</type><number>415-555-
7890</number></phoneEntry><phoneEntry><type>work</type><number>
415-555-1234</number></phoneEntry></phone></person>"""
>>> root = ET.fromstring(xml_string) # ❸
>>> root
<Element 'person' at 0x000001942999BBA0> 

我们使用 as ET 语法导入 xml.etree.ElementTree 模块 ❶,这样我们就可以输入 ET 而不是长 xml.etree.ElementTree 模块名称。xml_string 变量 ❷ 包含我们希望解析的 XML 文本,尽管这个文本也可以很容易地从具有 .xml 扩展名的文本文件中读取。最后,我们将此文本传递给 ET.fromstring() 函数 ❸,该函数返回一个包含我们想要访问的数据的 Element 对象。我们将这个 Element 对象存储在一个名为 root 的变量中。

xml.etree.ElementTree 模块还有一个 parse() 函数。你可以传递一个文件名给它,从该文件加载 XML,它返回一个 Element 对象:

>>> import xml.etree.ElementTree as ET
>>> tree = ET.parse('my_data.xml')
>>> root = tree.getroot() 

一旦你有一个 Element 对象,你可以通过访问它的 tagtext Python 属性来查看标签的名称,以及其打开和关闭标签之间的文本。如果你将 Element 对象传递给 list() 函数,它应该返回一个包含其直接子元素的列表。继续在交互式 shell 中输入以下内容:

>>> root.tag
'person'
>>> list(root)
[<Element 'name' at 0x00000150BA4ADDF0>, <Element 'age' at
0x00000150BA4ADF30>, <Element 'programmer' at 0x00000150BA4ADEE0>,
<Element 'car' at 0x00000150BA4ADD00>, <Element 'address' at
0x00000150BA4ADCB0>, <Element 'phone' at 0x00000150BA4ADA30>] 

Element 对象的子 Element 对象可以通过整数索引访问,就像 Python 列表一样。所以,如果 root 包含 <person> 元素,那么 root[0]root[1] 分别包含 <name><age> 元素。你可以访问所有这些 Element 对象的 tagtext 属性。然而,任何自闭合标签,如 <car/>,将使用 None 作为它们的 text 属性。例如,在交互式 shell 中输入以下内容:

>>> root[0].tag
'name'
>>> root[0].text
'Alice Doe'
>>> root[3].tag
'car'
>>> root[3].text == None  # <car/> has no text.
True
>>> root[4].tag
'address'
>>> root[4][0].tag
'street'
>>> root[4][0].text
'100 Larkin St.' 

root 元素开始,你可以探索整个 XML 文档中的数据。你还可以通过将 Element 对象放入 for 循环中来迭代直接子元素:

>>> for elem in root:
...     print(elem.tag, '--', elem.text)
...
name -- Alice Doe
age -- 30
programmer -- true
car -- None
address -- None
phone -- None 

如果你想要迭代 Element 下的所有子元素,你可以在 for 循环中调用 iter() 方法:

>>> for elem in root.iter():
...     print(elem.tag, '--', elem.text)
...
person -- None
name -- Alice Doe
age -- 30
programmer -- true
car -- None
address -- None
street -- 100 Larkin St.
city -- San Francisco
zip -- 94102
phone -- None
phoneEntry -- None
type -- mobile
number -- 415-555-7890
phoneEntry -- None
type -- work
number -- 415-555-1234 

可选地,你可以传递一个字符串到 iter() 方法以过滤具有匹配标签的 XML 元素。此示例调用 iter('number') 以遍历根元素的 <number> 子元素:

>>> for elem in root.iter('number'):
...     print(elem.tag, '--', elem.text)
...
number -- 415-555-7890
number -- 415-555-1234 

在 XML 文档中浏览数据的内容远不止本节中涵盖的属性和方法。例如,正如第十三章中介绍的 CSS 选择器可以在网页的 HTML 中找到元素一样,一种叫做 XPath 的语言可以定位 XML 文档中的元素。这些概念超出了本章的范围,但你可以在 Python 文档中了解它们,网址为 docs.python.org/3/library/xml.etree.elementtree.html

Python 的 XML 模块没有将 XML 文本转换为 Python 数据结构的方法。然而,第三方 xmltodict 模块在 pypi.org/project/xmltodict/ 可以做到这一点。完整的安装说明在附录 A 中。以下是其使用示例:

>>> import xmltodict
>>> xml_string = """<person><name>Alice Doe</name><age>30</age>
<programmer>true</programmer><car xsi:nil="true" xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance”/><address><street>
100 Larkin St.</street><city>San Francisco</city><zip>94102
</zip></address><phone><phoneEntry><type>mobile</type><number>
415-555-7890</number></phoneEntry><phoneEntry><type>work</type>
<number>415-555-1234</number></phoneEntry></phone></person>"""
>>> python_data = xmltodict.parse(xml_string)
>>> python_data
{'person': {'name': 'Alice Doe', 'age': '30', 'programmer': 'true',
'car': {'@xsi:nil': 'true', '@xmlns:xsi': 'http://www.w3.org/2001/
XMLSchema-instance'}, 'address': {'street': '100 Larkin St.', 'city':
'San Francisco', 'zip': '94102'}, 'phone': {'phoneEntry': [{'type':
'mobile', 'number': '415-555-7890'}, {'type': 'work', 'number':
'415-555-1234'}]}}} 

XML 标准相对于 JSON 等格式被边缘化的一个原因是,在 XML 中表示数据类型更为复杂。例如,<programmer> 元素被解析为字符串值 'true' 而不是布尔值 True。而 <car> 元素被解析为尴尬的键值对 'car': {'@xsi:nil': 'true', '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance'} 而不是值 None。你必须仔细检查任何 XML 模块的输入和输出,以验证它是否按照你的意图表示数据。

编写 XML 文件

xml.etree 模块有点难以操控,因此对于小型项目,你可能更倾向于调用 open() 函数和 write() 方法来自己创建 XML 文本。但若要使用 xml.etree 模块从头开始创建 XML 文档,你需要创建一个根 Element 对象(例如我们例子中的 <person> 元素),然后调用 SubElement() 函数为其创建子元素。你可以使用 set() 方法在元素中设置任何 XML 属性。例如,输入以下内容:

>>> import xml.etree.ElementTree as ET
>>> person = ET.Element('person')  # Create the root XML element.
>>> name = ET.SubElement(person, 'name')  # Create <name> and put it under <person>.
>>> name.text = 'Alice Doe'  # Set the text between <name> and </name>.
>>> age = ET.SubElement(person, 'age')
>>> age.text = '30'  # XML content is always a string.
>>> programmer = ET.SubElement(person, 'programmer')
>>> programmer.text = 'true'
>>> car = ET.SubElement(person, 'car')
>>> car.set('xsi:nil', 'true')
>>> car.set('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance')
>>> address = ET.SubElement(person, 'address')
>>> street = ET.SubElement(address, 'street')
>>> street.text = '100 Larkin St.' 

为了简洁,我们将省略 <address><phone> 元素的其余部分。使用根 Element 对象调用 ET.tostring()decode() 函数,以获取 XML 文本的 Python 字符串:

>>> ET.tostring(person, encoding='utf-8').decode('utf-8')
'<person><name>Alice Doe</name><age>30</age><programmer>true</programmer>
<car xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>
<address><street>100 Larkin St.</street></address></person>' 

很不幸,tostring() 函数返回的是一个 bytes 对象而不是字符串,这需要调用 decode() 方法来获取实际的字符串。但一旦你有了 XML 文本作为 Python 字符串值,你可以将其写入 .xml 文件,传递给一个函数,用于网络请求,或者做任何你可以用字符串做的事情。

JSON

JSON 以 JavaScript 源代码的形式存储信息,尽管许多非 JavaScript 应用程序也使用它。特别是,网站通常通过 API(如第十三章中提到的 OpenWeather API)将数据以 JSON 格式提供给程序员。我们使用 .json 文件扩展名将格式化的 JSON 文本保存到纯文本文件中。以下是一个格式化的 JSON 文本示例数据结构:

{
  "name": "Alice Doe",
  "age": 30,
  "car": null,
  "programmer": true,
  "address": {
    "street": "100 Larkin St.",
    "city": "San Francisco",
 "zip": "94102"
  },
  "phone": [
    {
      "type": "mobile",
      "number": "415-555-7890"
    },
    {
      "type": "work",
      "number": "415-555-1234"
    }
  ]
} 

你首先会注意到 JSON 的语法与 Python 类似。Python 的字典和 JSON 的对象都使用花括号,并且通过逗号分隔键值对,每个键和值之间用冒号分隔。Python 的列表和 JSON 的数组都使用方括号,并且通过逗号分隔值。在 JSON 中,除了双引号字符串之外,空白字符是无意义的,这意味着你可以按你喜欢的方式添加空格。然而,最好使用增加缩进来格式化嵌套的对象和数组,就像缩进的 Python 代码块一样。在我们的示例数据中,电话号码列表缩进了两个空格,列表中的每个电话号码字典缩进了四个空格。

但 JSON 和 Python 之间也存在一些差异。JSON 使用 JavaScript 的关键字 null 而不是 Python 的 None 值。布尔值是 JavaScript 的小写 truefalse 关键字。JSON 不允许 JavaScript 注释或多行字符串;JSON 中的所有字符串都必须使用双引号。与 Python 列表不同,JSON 数组不能有尾随逗号,因此 ["spam", "eggs"] 是有效的 JSON,而 ["spam", "eggs",] 则不是。

Facebook、Twitter、Yahoo!、Google、Tumblr、Wikipedia、Flickr、Data.gov、Reddit、IMDb、Rotten Tomatoes、LinkedIn 以及许多其他流行的网站都提供了与 JSON 数据一起工作的 API。其中一些网站需要注册,这通常是免费的。你必须找到文档来了解你的程序需要请求哪些 URL 以获取你想要的数据,以及返回的 JSON 数据结构的通用格式。如果提供 API 的网站有一个开发者页面,请在那里查找文档。

Python 的 json 模块使用 json.loads()json.dumps() 函数处理将格式为 JSON 数据的字符串转换为相应的 Python 值的细节。JSON 不能存储所有类型的 Python 值,只能存储以下基本数据类型:字符串、整数、浮点数、布尔值、列表、字典和 NoneType。JSON 不能表示 Python 特定的对象,例如 File 对象、CSV readerwriter 对象,或 Selenium WebElement 对象。json 模块的全文档可以在 docs.python.org/3/library/json.html 找到。

读取 JSON 数据

要将包含 JSON 数据的字符串转换为 Python 值,请将其传递给 json.loads() 函数。(该名称的意思是“加载字符串”,而不是“加载”)。在交互式外壳中输入以下内容:

>>> import json # ❶
>>> json_string = '{"name": "Alice Doe", "age": 30, "car": null, "programmer":
 true, "address": {"street": "100 Larkin St.", "city": "San Francisco", "zip":
 "94102"}, "phone": [{"type": "mobile", "number": "415-555-7890"}, {"type": 
"work", "number": "415-555-1234"}]}'
>>> python_data = json.loads(json_string) # ❷
>>> python_data
{'name': 'Alice Doe', 'age': 30, 'car': None, 'programmer': True, 'address':
{'street': '100 Larkin St.', 'city': 'San Francisco', 'zip': '94102'},
'phone': [{'type': 'mobile', 'number': '415-555-7890'}, {'type': 'work',
'number': '415-555-1234'}]} 

在导入 json 模块 ❶ 之后,你可以调用 loads() ❷ 并传递一个 JSON 数据字符串。请注意,JSON 字符串始终使用双引号。它应该返回一个 Python 字典。

编写 JSON 数据

json.dumps() 函数(其含义为“导出字符串”,而非“dumps”)将 Python 数据转换为 JSON 格式的字符串。在交互式 shell 中输入以下内容:

>>> import json
>>> python_data = {'name': 'Alice Doe', 'age': 30, 'car': None, 'programmer': True, 'address':
{'street': '100 Larkin St.', 'city': 'San Francisco', 'zip': '94102'}, 'phone': [{'type':
'mobile', 'number': '415-555-7890'}, {'type': 'work', 'number': '415-555-1234'}]}
>>> json_string = json.dumps(python_data) ❶
>>> print(json_string) ❷
{"name": "Alice Doe", "age": 30, "car": null, "programmer": true, "address": {"street":
"100 Larkin St.", "city": "San Francisco", "zip": "94102"}, "phone": [{"type": "mobile",
"number": "415-555-7890"}, {"type": "work", "number": "415-555-1234"}]}
>>> json_string = json.dumps(python_data, indent=2) ❸
>>> print(json_string)
{
  "name": "Alice Doe",
  "age": 30,
  "car": null,
  "programmer": true,
  "address": {
    "street": "100 Larkin St.",
    "city": "San Francisco",
# --snip--
} 

传递给 json.dumps() ❶ 的值只能由以下基本 Python 数据类型组成:字符串、整数、浮点数、布尔值、列表、字典和 NoneType

默认情况下,整个 JSON 文本将写在一行上 ❷。这种压缩格式适合在程序之间读取和写入 JSON 文本,但多行、缩进的形式更适合人类阅读。indent=2 关键字参数 ❸ 将 JSON 文本格式化为单独的行,每个嵌套字典或列表缩进两个空格。除非你的 JSON 文件大小达到兆字节级别,否则通过添加空格和换行符来增加大小对于可读性是值得的。

一旦你有了 JSON 文本作为 Python 字符串值,你可以将其写入 .json 文件,传递给一个函数,用于网络请求,或执行任何可以用字符串完成的操作。

读取 JSON 数据

要将包含 JSON 数据的字符串转换为 Python 值,请将其传递给 json.loads() 函数。(其名称的含义为“加载字符串”,而非“loads。”)在交互式 shell 中输入以下内容:

>>> import json # ❶
>>> json_string = '{"name": "Alice Doe", "age": 30, "car": null, "programmer":
 true, "address": {"street": "100 Larkin St.", "city": "San Francisco", "zip":
 "94102"}, "phone": [{"type": "mobile", "number": "415-555-7890"}, {"type": 
"work", "number": "415-555-1234"}]}'
>>> python_data = json.loads(json_string) # ❷
>>> python_data
{'name': 'Alice Doe', 'age': 30, 'car': None, 'programmer': True, 'address':
{'street': '100 Larkin St.', 'city': 'San Francisco', 'zip': '94102'},
'phone': [{'type': 'mobile', 'number': '415-555-7890'}, {'type': 'work',
'number': '415-555-1234'}]} 

在导入 json 模块 ❶ 之后,你可以调用 loads() ❷ 并传递一个 JSON 数据字符串。请注意,JSON 字符串始终使用双引号。它应该返回一个 Python 字典。

编写 JSON 数据

json.dumps() 函数(其含义为“导出字符串”,而非“dumps”)将 Python 数据转换为 JSON 格式的字符串。在交互式 shell 中输入以下内容:

>>> import json
>>> python_data = {'name': 'Alice Doe', 'age': 30, 'car': None, 'programmer': True, 'address':
{'street': '100 Larkin St.', 'city': 'San Francisco', 'zip': '94102'}, 'phone': [{'type':
'mobile', 'number': '415-555-7890'}, {'type': 'work', 'number': '415-555-1234'}]}
>>> json_string = json.dumps(python_data) ❶
>>> print(json_string) ❷
{"name": "Alice Doe", "age": 30, "car": null, "programmer": true, "address": {"street":
"100 Larkin St.", "city": "San Francisco", "zip": "94102"}, "phone": [{"type": "mobile",
"number": "415-555-7890"}, {"type": "work", "number": "415-555-1234"}]}
>>> json_string = json.dumps(python_data, indent=2) ❸
>>> print(json_string)
{
  "name": "Alice Doe",
  "age": 30,
  "car": null,
  "programmer": true,
  "address": {
    "street": "100 Larkin St.",
    "city": "San Francisco",
# --snip--
} 

传递给 json.dumps() ❶ 的值只能由以下基本 Python 数据类型组成:字符串、整数、浮点数、布尔值、列表、字典和 NoneType

默认情况下,整个 JSON 文本将写在一行上 ❷。这种压缩格式适合在程序之间读取和写入 JSON 文本,但多行、缩进的形式更适合人类阅读。indent=2 关键字参数 ❸ 将 JSON 文本格式化为单独的行,每个嵌套字典或列表缩进两个空格。除非你的 JSON 文件大小达到兆字节级别,否则通过添加空格和换行符来增加大小对于可读性是值得的。

一旦你有了 JSON 文本作为 Python 字符串值,你可以将其写入 .json 文件,传递给一个函数,用于网络请求,或执行任何可以用字符串完成的操作。

XML

XML 文件格式比 JSON 更老,但仍然被广泛使用。其语法类似于 HTML,我们在第十八章中讨论过,它涉及在包含其他内容的尖括号内嵌套开放和闭合标签。这些标签被称为元素。SVG 图像文件由 XML 文本组成。RSS 和 Atom 网络源格式也是用 XML 编写的,而 Microsoft Word 文档只是具有.docx文件扩展名的 ZIP 文件,其中包含 XML 文件。

我们将 XML 格式的文本存储在具有.xml文件扩展名的纯文本文件中。以下示例数据结构按 XML 格式排列:

<person>
    <name>Alice Doe</name>
    <age>30</age>
    <programmer>true</programmer>
    <car xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>
    <address>
        <street>100 Larkin St.</street>
        <city>San Francisco</city>
        <zip>94102</zip>
    </address>
    <phone>
        <phoneEntry>
            <type>mobile</type>
            <number>415-555-7890</number>
        </phoneEntry>
        <phoneEntry>
            <type>work</type>
            <number>415-555-1234</number>
        </phoneEntry>
    </phone>
</person> 

在这个例子中,<person>元素有子元素<name><age>等。<name><age>子元素是子元素,而<person>是它们的父元素。有效的 XML 文档必须有一个单一的根元素,它包含所有其他元素,例如本例中的<person>元素。以下具有多个根元素的文档是不有效的:

<person><name>Alice Doe</name></person>
<person><name>Bob Smith</name></person>
<person><name>Carol Watanabe</name></person> 

与更现代的序列化格式如 JSON 相比,XML 相当冗长。每个元素都有一个开放和闭合标签,例如<age></age>。XML 元素是一个键值对,键是元素的标签(在本例中为<age>),值是开放和闭合标签之间的文本。XML 文本没有数据类型;开放和闭合标签之间的所有内容都被视为字符串,包括我们示例数据中的94102true文本。数据列表,如<phone>元素,必须使用它们自己的元素来命名各自的项,例如<phoneEntry>。这些子元素的“Entry”后缀只是一个命名约定。

XML 的注释与 HTML 的注释相同:在<!---->之间的任何内容都意味着要被忽略。

开放和闭合标签之外的空间是无关紧要的,你可以按自己的喜好来格式化它。在 XML 中不存在“null”值,但你可以通过向标签添加xsi:nil="true"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"属性来近似它。XML 属性是以 key="value"格式在开放标签内写成的键值对。标签被写成自闭合标签;而不是使用闭合标签,开放标签以/>结束,例如<car xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>

标签和属性名可以写成任何大小写,但按照惯例是小写的。属性值可以放在单引号或双引号内,但双引号是标准的。

是否使用子元素或属性通常是模糊的。我们的示例数据使用这些元素来处理地址数据:

<address>
    <street>100 Larkin St.</street>
    <city>San Francisco</city>
    <zip>94102</zip>
</address> 

然而,它很容易将子元素数据格式化为自闭合<address>元素中的属性:

<address street="100 Larkin St." city="San Francisco" zip="94102" />

这类歧义以及标签的冗长性质使得 XML 的使用不如以前那么普遍。XML 在 1990 年代和 2000 年代得到了广泛部署,并且其中许多软件至今仍在使用。但除非你有特定的理由使用 XML,否则使用 JSON 会更好。

通常,XML 软件库有两种读取 XML 文档的方式。文档对象模型 (DOM) 方法一次性将整个 XML 文档读入内存。这使得访问 XML 文档中的任何数据变得容易,但通常只适用于小型或中等大小的 XML 文档。简单 XML API (SAX) 方法将 XML 文档作为元素流读取,因此不需要一次性将整个文档加载到内存中。这种方法对于大小为千兆的 XML 文档来说很理想,但不太方便,因为你必须迭代文档中的元素才能与元素一起工作。

Python 的标准库提供了 xml.domxml.saxxml.etree.ElementTree 模块来处理 XML 文本。在我们的简单示例中,我们将使用 Python 的 xml.etree.ElementTree 模块一次性读取整个 XML 文档。

读取 XML 文件

xml.etree 模块使用 Element 对象来表示 XML 元素及其子元素。在交互式外壳中输入以下内容:

>>> import xml.etree.ElementTree as ET # ❶
>>> xml_string = """<person><name>Alice Doe</name><age>30</age> # ❷
<programmer>true</programmer><car xsi:nil="true" xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance”/><address><street>
100 Larkin St.</street><city>San Francisco</city><zip>94102</zip>
</address><phone><phoneEntry><type>mobile</type><number>415-555-
7890</number></phoneEntry><phoneEntry><type>work</type><number>
415-555-1234</number></phoneEntry></phone></person>"""
>>> root = ET.fromstring(xml_string) # ❸
>>> root
<Element 'person' at 0x000001942999BBA0> 

我们使用 as ET 语法导入 xml.etree.ElementTree 模块 ❶,这样我们就可以输入 ET 而不是长 xml.etree.ElementTree 模块名称。xml_string 变量 ❷ 包含我们希望解析的 XML 文本,尽管这段文本也可以很容易地从具有 .xml 扩展名的文本文件中读取。最后,我们将此文本传递给 ET.fromstring() 函数 ❸,该函数返回一个包含我们想要访问的数据的 Element 对象。我们将此 Element 对象存储在一个名为 root 的变量中。

xml.etree.ElementTree 模块也提供了一个 parse() 函数。你可以传递一个文件的名称,从中加载 XML,它将返回一个 Element 对象:

>>> import xml.etree.ElementTree as ET
>>> tree = ET.parse('my_data.xml')
>>> root = tree.getroot() 

一旦你有了 Element 对象,你可以通过访问其 tagtext Python 属性来查看标签的名称,以及其开闭标签之间的文本。如果你将 Element 对象传递给 list() 函数,它应该返回一个包含其直接子元素列表。通过输入以下内容继续交互式外壳:

>>> root.tag
'person'
>>> list(root)
[<Element 'name' at 0x00000150BA4ADDF0>, <Element 'age' at
0x00000150BA4ADF30>, <Element 'programmer' at 0x00000150BA4ADEE0>,
<Element 'car' at 0x00000150BA4ADD00>, <Element 'address' at
0x00000150BA4ADCB0>, <Element 'phone' at 0x00000150BA4ADA30>] 

Element 对象的子 Element 对象可以通过整数索引访问,就像 Python 列表一样。因此,如果 root 包含 <person> 元素,那么 root[0]root[1] 分别包含 <name><age> 元素。你可以访问所有这些 Element 对象的 tagtext 属性。然而,任何自闭合标签,如 <car/>,将使用 None 作为它们的 text 属性。例如,在交互式外壳中输入以下内容:

>>> root[0].tag
'name'
>>> root[0].text
'Alice Doe'
>>> root[3].tag
'car'
>>> root[3].text == None  # <car/> has no text.
True
>>> root[4].tag
'address'
>>> root[4][0].tag
'street'
>>> root[4][0].text
'100 Larkin St.' 

root 元素开始,您可以探索整个 XML 文档中的数据。您还可以通过将 Element 对象放入 for 循环中来迭代直接子元素:

>>> for elem in root:
...     print(elem.tag, '--', elem.text)
...
name -- Alice Doe
age -- 30
programmer -- true
car -- None
address -- None
phone -- None 

如果您想迭代 Element 下的所有子元素,您可以在 for 循环中调用 iter() 方法:

>>> for elem in root.iter():
...     print(elem.tag, '--', elem.text)
...
person -- None
name -- Alice Doe
age -- 30
programmer -- true
car -- None
address -- None
street -- 100 Larkin St.
city -- San Francisco
zip -- 94102
phone -- None
phoneEntry -- None
type -- mobile
number -- 415-555-7890
phoneEntry -- None
type -- work
number -- 415-555-1234 

可选地,您可以将一个字符串传递给 iter() 方法以过滤具有匹配标签的 XML 元素。此示例调用 iter('number') 以仅迭代根元素的 <number> 子元素:

>>> for elem in root.iter('number'):
...     print(elem.tag, '--', elem.text)
...
number -- 415-555-7890
number -- 415-555-1234 

在 XML 文档中浏览数据的内容远不止本节中涵盖的属性和方法。例如,正如第十三章中介绍的 CSS 选择器可以在网页的 HTML 中找到元素一样,一种称为 XPath 的语言可以定位 XML 文档中的元素。这些概念超出了本章的范围,但您可以在 Python 文档中了解它们,网址为 docs.python.org/3/library/xml.etree.elementtree.html

Python 的 XML 模块没有将 XML 文本转换为 Python 数据结构的方法。然而,第三方 xmltodict 模块在 pypi.org/project/xmltodict/ 可以做到这一点。完整的安装说明见附录 A。以下是其使用示例:

>>> import xmltodict
>>> xml_string = """<person><name>Alice Doe</name><age>30</age>
<programmer>true</programmer><car xsi:nil="true" xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance”/><address><street>
100 Larkin St.</street><city>San Francisco</city><zip>94102
</zip></address><phone><phoneEntry><type>mobile</type><number>
415-555-7890</number></phoneEntry><phoneEntry><type>work</type>
<number>415-555-1234</number></phoneEntry></phone></person>"""
>>> python_data = xmltodict.parse(xml_string)
>>> python_data
{'person': {'name': 'Alice Doe', 'age': '30', 'programmer': 'true',
'car': {'@xsi:nil': 'true', '@xmlns:xsi': 'http://www.w3.org/2001/
XMLSchema-instance'}, 'address': {'street': '100 Larkin St.', 'city':
'San Francisco', 'zip': '94102'}, 'phone': {'phoneEntry': [{'type':
'mobile', 'number': '415-555-7890'}, {'type': 'work', 'number':
'415-555-1234'}]}}} 

XML 标准相对于 JSON 等格式被边缘化的一个原因是,在 XML 中表示数据类型更复杂。例如,<programmer> 元素被解析为字符串值 'true' 而不是布尔值 True。而 <car> 元素被解析为尴尬的 'car': {'@xsi:nil': 'true', '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance'} 键值对,而不是值 None。您必须仔细检查任何 XML 模块的输入和输出,以验证它是否按您的意图表示数据。

编写 XML 文件

xml.etree 模块有点难以操作,因此对于小型项目,您可能更愿意调用 open() 函数和 write() 方法来自己创建 XML 文本。但是,要使用 xml.etree 模块从头开始创建 XML 文档,您需要创建一个根 Element 对象(例如我们示例中的 <person> 元素),然后调用 SubElement() 函数为其创建子元素。您可以使用 set() 方法在元素中设置任何 XML 属性。例如,输入以下内容:

>>> import xml.etree.ElementTree as ET
>>> person = ET.Element('person')  # Create the root XML element.
>>> name = ET.SubElement(person, 'name')  # Create <name> and put it under <person>.
>>> name.text = 'Alice Doe'  # Set the text between <name> and </name>.
>>> age = ET.SubElement(person, 'age')
>>> age.text = '30'  # XML content is always a string.
>>> programmer = ET.SubElement(person, 'programmer')
>>> programmer.text = 'true'
>>> car = ET.SubElement(person, 'car')
>>> car.set('xsi:nil', 'true')
>>> car.set('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance')
>>> address = ET.SubElement(person, 'address')
>>> street = ET.SubElement(address, 'street')
>>> street.text = '100 Larkin St.' 

为了简洁,我们将省略 <address><phone> 元素的其余部分。使用 ET.tostring()decode() 函数与根 Element 对象一起调用,以获取 XML 文本的 Python 字符串:

>>> ET.tostring(person, encoding='utf-8').decode('utf-8')
'<person><name>Alice Doe</name><age>30</age><programmer>true</programmer>
<car xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>
<address><street>100 Larkin St.</street></address></person>' 

很不幸,tostring() 函数返回一个 bytes 对象而不是字符串,这需要调用 decode() 方法来获取实际的字符串。但一旦你有 XML 文本作为 Python 字符串值,你可以将其写入 .xml 文件,传递给函数,用于网络请求,或执行任何其他可以使用字符串执行的操作。

读取 XML 文件

xml.etree 模块使用 Element 对象来表示一个 XML 元素及其子元素。在交互式 shell 中输入以下内容:

>>> import xml.etree.ElementTree as ET # ❶
>>> xml_string = """<person><name>Alice Doe</name><age>30</age> # ❷
<programmer>true</programmer><car xsi:nil="true" xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance”/><address><street>
100 Larkin St.</street><city>San Francisco</city><zip>94102</zip>
</address><phone><phoneEntry><type>mobile</type><number>415-555-
7890</number></phoneEntry><phoneEntry><type>work</type><number>
415-555-1234</number></phoneEntry></phone></person>"""
>>> root = ET.fromstring(xml_string) # ❸
>>> root
<Element 'person' at 0x000001942999BBA0> 

我们使用 as ET 语法导入 xml.etree.ElementTree 模块 ❶,以便我们可以输入 ET 而不是长长的 xml.etree.ElementTree 模块名称。xml_string 变量 ❷ 包含我们希望解析的 XML 文本,尽管这个文本也可以很容易地从具有 .xml 扩展名的文本文件中读取。最后,我们将此文本传递给 ET.fromstring() 函数 ❸,该函数返回一个包含我们想要访问的数据的 Element 对象。我们将此 Element 对象存储在一个名为 root 的变量中。

xml.etree.ElementTree 模块也提供了一个 parse() 函数。你可以传递一个文件的名称,从中加载 XML,它将返回一个 Element 对象:

>>> import xml.etree.ElementTree as ET
>>> tree = ET.parse('my_data.xml')
>>> root = tree.getroot() 

一旦你有一个 Element 对象,你可以通过访问它的 tagtext Python 属性来查看标签的名称,以及其开闭标签之间的文本。如果你将 Element 对象传递给 list() 函数,它应该返回一个包含其直接子元素的列表。继续在交互式 shell 中输入以下内容:

>>> root.tag
'person'
>>> list(root)
[<Element 'name' at 0x00000150BA4ADDF0>, <Element 'age' at
0x00000150BA4ADF30>, <Element 'programmer' at 0x00000150BA4ADEE0>,
<Element 'car' at 0x00000150BA4ADD00>, <Element 'address' at
0x00000150BA4ADCB0>, <Element 'phone' at 0x00000150BA4ADA30>] 

Element 对象的子 Element 对象可以通过整数索引访问,就像 Python 列表一样。因此,如果 root 包含 <person> 元素,那么 root[0]root[1] 分别包含 <name><age> 元素。你可以访问所有这些 Element 对象的 tagtext 属性。然而,任何自闭合标签,如 <car/>,将使用 None 作为它们的 text 属性。例如,在交互式 shell 中输入以下内容:

>>> root[0].tag
'name'
>>> root[0].text
'Alice Doe'
>>> root[3].tag
'car'
>>> root[3].text == None  # <car/> has no text.
True
>>> root[4].tag
'address'
>>> root[4][0].tag
'street'
>>> root[4][0].text
'100 Larkin St.' 

root 元素开始,你可以探索整个 XML 文档中的数据。你还可以通过将 Element 对象放入 for 循环中来迭代直接子元素:

>>> for elem in root:
...     print(elem.tag, '--', elem.text)
...
name -- Alice Doe
age -- 30
programmer -- true
car -- None
address -- None
phone -- None 

如果你想要迭代 Element 下的所有子元素,你可以在 for 循环中调用 iter() 方法:

>>> for elem in root.iter():
...     print(elem.tag, '--', elem.text)
...
person -- None
name -- Alice Doe
age -- 30
programmer -- true
car -- None
address -- None
street -- 100 Larkin St.
city -- San Francisco
zip -- 94102
phone -- None
phoneEntry -- None
type -- mobile
number -- 415-555-7890
phoneEntry -- None
type -- work
number -- 415-555-1234 

可选地,你可以传递一个字符串到 iter() 方法以过滤具有匹配标签的 XML 元素。此示例调用 iter('number') 以仅迭代根元素的 <number> 子元素:

>>> for elem in root.iter('number'):
...     print(elem.tag, '--', elem.text)
...
number -- 415-555-7890
number -- 415-555-1234 

在 XML 文档中浏览数据的内容远不止本节中涵盖的属性和方法。例如,就像第十三章中介绍的 CSS 选择器可以在网页的 HTML 中找到元素一样,一种称为 XPath 的语言可以定位 XML 文档中的元素。这些概念超出了本章的范围,但你可以在 Python 文档中了解它们,网址为 docs.python.org/3/library/xml.etree.elementtree.html

Python 的 XML 模块没有将 XML 文本转换为 Python 数据结构的方法。然而,第三方 xmltodict 模块在 pypi.org/project/xmltodict/ 可以做到这一点。完整的安装说明见附录 A。以下是其使用示例:

>>> import xmltodict
>>> xml_string = """<person><name>Alice Doe</name><age>30</age>
<programmer>true</programmer><car xsi:nil="true" xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance”/><address><street>
100 Larkin St.</street><city>San Francisco</city><zip>94102
</zip></address><phone><phoneEntry><type>mobile</type><number>
415-555-7890</number></phoneEntry><phoneEntry><type>work</type>
<number>415-555-1234</number></phoneEntry></phone></person>"""
>>> python_data = xmltodict.parse(xml_string)
>>> python_data
{'person': {'name': 'Alice Doe', 'age': '30', 'programmer': 'true',
'car': {'@xsi:nil': 'true', '@xmlns:xsi': 'http://www.w3.org/2001/
XMLSchema-instance'}, 'address': {'street': '100 Larkin St.', 'city':
'San Francisco', 'zip': '94102'}, 'phone': {'phoneEntry': [{'type':
'mobile', 'number': '415-555-7890'}, {'type': 'work', 'number':
'415-555-1234'}]}}} 

XML 标准之所以不如 JSON 等格式那样受到重视,一个原因是 XML 中表示数据类型更复杂。例如,<programmer> 元素被解析为字符串值 'true' 而不是布尔值 True。而 <car> 元素被解析为尴尬的 'car': {'@xsi:nil': 'true', '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance'} 键值对,而不是值 None。你必须仔细检查任何 XML 模块的输入和输出,以验证它是否按照你的意图表示数据。

编写 XML 文件

xml.etree 模块有点难以操作,所以对于小型项目,你可能更愿意调用 open() 函数和 write() 方法来自己创建 XML 文本。但是要使用 xml.etree 模块从头创建一个 XML 文档,你需要创建一个根 Element 对象(例如我们例子中的 <person> 元素),然后调用 SubElement() 函数来为它创建子元素。你可以使用 set() 方法在元素中设置任何 XML 属性。例如,输入以下内容:

>>> import xml.etree.ElementTree as ET
>>> person = ET.Element('person')  # Create the root XML element.
>>> name = ET.SubElement(person, 'name')  # Create <name> and put it under <person>.
>>> name.text = 'Alice Doe'  # Set the text between <name> and </name>.
>>> age = ET.SubElement(person, 'age')
>>> age.text = '30'  # XML content is always a string.
>>> programmer = ET.SubElement(person, 'programmer')
>>> programmer.text = 'true'
>>> car = ET.SubElement(person, 'car')
>>> car.set('xsi:nil', 'true')
>>> car.set('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance')
>>> address = ET.SubElement(person, 'address')
>>> street = ET.SubElement(address, 'street')
>>> street.text = '100 Larkin St.' 

为了简洁,我们将省略 <address><phone> 元素的其余部分。使用根 Element 对象调用 ET.tostring()decode() 函数,以获取 XML 文本的 Python 字符串:

>>> ET.tostring(person, encoding='utf-8').decode('utf-8')
'<person><name>Alice Doe</name><age>30</age><programmer>true</programmer>
<car xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>
<address><street>100 Larkin St.</street></address></person>' 

很不幸,tostring() 函数返回的是一个 bytes 对象而不是字符串,这需要调用 decode() 方法来获取实际的字符串。但是一旦你有了作为 Python 字符串值的 XML 文本,你可以将其写入 .xml 文件,传递给一个函数,用于网络请求,或者做任何你可以用字符串做的事情。

摘要

CSV、JSON 和 XML 是常见的纯文本格式,用于存储数据。它们易于程序解析,同时仍然易于人类阅读,因此常用于简单的电子表格或网络应用程序数据。Python 标准库中的 csvjsonxml.etree.ElementTree 模块极大地简化了读取和写入这些文件的过程,因此你不需要使用 open() 函数来这样做。

这些格式并不仅限于 Python;许多其他编程语言和软件应用程序也使用这些文件类型。本章可以帮助你编写可以与使用这些文件类型的任何应用程序交互的 Python 程序。

实践问题

1.  Excel 电子表格有哪些特性是 CSV 电子表格没有的?

2.  创建 readerwriter 对象时,向 csv.reader()csv.writer() 传递什么?

3.  readerwriter 对象的 File 对象需要以什么模式打开?

4.  哪种方法可以将列表参数写入 CSV 文件?

5.  delimiterlineterminator 关键字参数有什么作用?

6.  在 CSV、JSON 和 XML 中,哪些格式可以用文本编辑器应用程序轻松编辑?

7.  哪种函数可以将 JSON 数据字符串转换为 Python 数据结构?

8.  哪种函数可以将 Python 数据结构转换为 JSON 数据字符串?

9.  哪种数据序列化格式类似于 HTML,带有尖括号内的标签?

10.  JSON 如何写入 None 值?

11.  如何在 JSON 中写入布尔值?

实践程序:Excel 到 CSV 转换器

使用 Excel 只需几点击就能将电子表格保存为 CSV 文件,但如果你需要将数百个 Excel 文件转换为 CSV,可能需要数小时点击。使用第十四章中的 openpyxl 模块,编写一个程序,读取当前工作目录中的所有 Excel 文件,并将它们输出为 CSV 文件。

一个 Excel 文件可能包含多个工作表;你需要为每个工作表创建一个 CSV 文件。CSV 文件的文件名应该是 _.csv,其中 是不带文件扩展名的 Excel 文件名(例如,spam_data,而不是 spam_data.xlsx),Worksheet 对象的 title 变量中的字符串。

这个程序将涉及许多嵌套的 for 循环。程序的结构应该看起来像这样:

for excel_file in os.listdir('.'):
    # Skip non-xlsx files, load the workbook object.
    for sheet_name in wb.sheetnames:
        # Loop through every sheet in the workbook.
        # Create the CSV filename from the Excel filename and sheet title.
        # Create the csv.writer object for this CSV file.

        # Loop through every row in the sheet.
        for row_num in range(1, sheet.max_row + 1):
            row_data = []    # Append each cell to this list.
            # Loop through each cell in the row.
            for col_num in range(1, sheet.max_column + 1):
                # Append each cell's data to row_data

            # Write the row_data list to the CSV file.

        csv_file.close() 

从本书的在线资源下载 ZIP 文件 excelSpreadsheets.zip,并将电子表格解压缩到与你的程序相同的目录中。你可以使用这些文件来测试程序。

posted @ 2026-02-06 10:27  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报