文章目录
0 引言
在之前的文章中,我们介绍了有关PDF文件图片去水印和文字颜色加深的方法,详情见如下链接:
上述方法存在着一些不足之处,便是处理后保存的PDF文件占用内存过大,比原本文件大好几倍。这篇文章的目的就是对处理后的PDF内存大小进行优化。
1 解决思路与流程
因为我们处理的PDF主要是由每页的图片组成的,因此图片的内存大小是影响PDF体量的主要因素。我们边看代码结果边分析,首先代入必要的库
import io # 用于字节流转换
import os # 用于查看文件内存占用情况
import sys # 用于查看变量字节数
import pymupdf # 用于操作PDF
from PIL import Image # 用于图片处理
这里
import pymupdf
而不是之前的import fitz
,两者的区别参见官方文档说明1。
为了兼顾调试分析的效率,我们先随便拿一个只有两页的PDF(example.pdf
),该PDF由两张扫描图片组成。先读入文件,并打印内存大小。
doc = pymupdf.open("example.pdf") # open a document
print(f"example.pdf [size]: {os.path.getsize('example.pdf') / 1024 / 1024:.2f} MB")
example.pdf [size]: 1.72 MB
接下来,我们以第一页的图片为例。get_image_info
函数用于获取图片的信息,得到图像的尺寸信息,由于图像为彩色三通道,按像素矩阵保存的话需要24.80 MB的内存(1个通道值占1byte)。
page0 = doc.load_page(0)
image_list = page0.get_images()
xref0 = image_list[0][0]
img_info = page0.get_image_info(xrefs=xref0)[0]
print(f"img_array [size]: {(img_info['width'] * img_info['height'] * 3) / 1024 / 1024:.2f} MB")
img_array [size]: 24.80 MB
为了后续复用需要,我们定义函数print_size
用于打印变量字节数。这里我们使用两种方法提取PDF页面内的图片,这两者方法的功能一样,但后者在速度和内层占用上都具有优势,这在官方文档中提及2。我们肯定是选用后者,这里只是对比一下。
print_size = lambda n, p: print(f"{n} [size]: {sys.getsizeof(p) / 1024 / 1024:.2f} MB")
pix = pymupdf.Pixmap(doc, xref0) # create a Pixmap
if pix.n - pix.alpha > 3: # CMYK: convert to RGB first
pix = pymupdf.Pixmap(pymupdf.csRGB, pix)
print_size('Pixmap(doc, xref0)', pix.tobytes())
imgdict = doc.extract_image(xref0)
imgdata = imgdict["image"] # image data
print_size('imgdata(extract_image)', imgdata)
Pixmap(doc, xref0) [size]: 2.37 MB
imgdata(extract_image) [size]: 0.84 MB
我们发现extract_image
函数提取出来的图像内存占用仅为0.84 MB,与我们计算的24.80 MB小得多,这是因为imgdata
存储的是图像压缩后的字节信息,而不是原图像矩阵,图像压缩技术在存储上具备优势,不同的压缩格式所占用的内存大小也不同。页面图片原始的压缩标准可以通过imgdict["ext"]
获取,示例中是jpeg
格式(相较其他常见格式,该格式占用内存最小)。关于图片存储格式详情参见3。
因为图像处理需要PIL(或OpenCV等),我们需要将提取到的图像数据转换为图像处理库支持的格式。
pil_imgdata = Image.open(io.BytesIO(imgdata))
print_size('pil_imgdata', pil_imgdata.tobytes())
pil_imgdata [size]: 24.80 MB
通过打印的内存大小我们可以得知,PIL.Image
(24.80 MB)和之前计算的图像矩阵(24.80 MB)内存大小相同,猜测是因为PIL将压缩图像复原为矩阵了,方便后续的图像处理。pymupdf.Pixmap
(2.37 MB)图像对象的内存占用大小比图像矩阵(24.80 MB)小,但是比imgdata
(0.84 MB)大,通过调试,初步分析是因为imgdata
为压缩后的字节串,而Pixmap
为类对象,可能包含其他额外数据。无法直接对Pixmap对象进行图像处理操作,可能是因为其图像数据仍然是压缩格式?不知道。
图像处理后,需要替换掉原PDF中的图像,在这之前需要将PIL.Image
转换为压缩格式字节流,方便放入PDF文件中。调用save
函数,其中format
参数是压缩标准,我们就使用原PDF中的jpeg;quality
参数是压缩质量,取100会比原图像占用内存更大,取95占用内存更小(图像压缩效果再可接受范围内,以小内存为优);progressive
参数是保存渐进式jpeg(该格式相比默认格式内存更小,选它);dpi
参数设置越大,图像内存越大,我们和原图像dpi取值一致。
bio = io.BytesIO()
pil_imgdata.save(bio, format=imgdict["ext"], quality=95, progressive=True, dpi=(imgdict['xres'], imgdict['yres']))
print_size('pil_imgdata_save', bio)
72, 72
pil_imgdata_save [size]: 0.63 MB
很好,处理插入PDF的图像内存(0.63 MB)甚至比提取出的图像内存(0.84 MB)更小!可能是因为压缩(quality=95
)的原因。
接下来的步骤是用处理后的图像替换原页面图像,采用之前文章中的方法(replace_image
),该示例的完整代码如下
import io
import os
import pymupdf
from PIL import Image
doc = pymupdf.open("example.pdf") # open a document
print(f"example.pdf [size]: {os.path.getsize('example.pdf') / 1024 / 1024:.2f} MB")
for page_index in range(len(doc)): # iterate over pdf pages
page = doc[page_index] # get the page
image_list = page.get_images()
print(f"Find {len(image_list)} images on page {page_index}")
for image_index, img in enumerate(image_list, start=1): # enumerate the image list
xref = img[0] # get the XREF of the image
img_info = page.get_image_info(xrefs=xref)[0]
imgdict = doc.extract_image(xref)
imgdata = imgdict["image"] # image data
pil_imgdata = Image.open(io.BytesIO(imgdata))
# TODO:图像处理操作
pix_imgdata = pymupdf.Pixmap(imgdata)
bio = io.BytesIO()
pil_imgdata.save(bio, format=imgdict["ext"], quality=95, progressive=True,
dpi=(pix_imgdata.xres, pix_imgdata.yres))
# 页面图像替换
page.replace_image(xref, stream=bio)
print(f"Processed {image_index} images on page {page_index}")
# 保存PDF
doc.save('output.pdf')
doc.close()
print(f"output.pdf [size]: {os.path.getsize('output.pdf') / 1024 / 1024:.2f} MB")
example.pdf [size]: 1.72 MB
Find 1 images on page 0
Processed 1 images on page 0
Find 1 images on page 1
Processed 1 images on page 1
output.pdf [size]: 2.64 MB
可以看到,输出PDF比原PDF更大了,这不行。
后来发现这是个坑啊,官方文档中的描述如上,为了证实我的猜想,写个测试脚本
import pymupdf
doc = pymupdf.open("output.pdf") # open a document
for page_index in range(len(doc)): # iterate over pdf pages
page = doc[page_index] # get the page
image_list = page.get_images()
print(f"Find {len(image_list)} images on page {page_index}")
for image_index, img in enumerate(image_list, start=1): # enumerate the image list
xref = img[0] # get the XREF of the image
img_info = page.get_image_info(xrefs=xref)[0]
imgdict = doc.extract_image(xref)
imgdata = imgdict["image"] # image data
pix_imgdata = pymupdf.Pixmap(imgdata)
print_size("imgdata", imgdata)
Find 2 images on page 0
imgdata [size]: 0.63 MB
imgdata [size]: 0.63 MB
Find 2 images on page 1
imgdata [size]: 0.68 MB
imgdata [size]: 0.68 MB
我还以为是将新图片从内存上替换原图片呢,没想到逻辑是把新图像放到原图像显示的位置,原图像不显示,但数据仍在页面中,占着内存呢!
经翻阅文档函数,我尝试先将原图像删除,再插入新的图像,也就是把原来的page.replace_image(xref, stream=bio)
替换为
page.delete_image(xref)
page.insert_image(rect=img_info['bbox'], stream=bio)
example.pdf [size]: 1.72 MB
Find 1 images on page 0
Processed 1 images on page 0
Find 1 images on page 1
Processed 1 images on page 1
output.pdf [size]: 1.33 MB
继续查看页面图像组成
Find 3 images on page 0
imgdata [size]: 0.00 MB
imgdata [size]: 0.00 MB
imgdata [size]: 0.63 MB
Find 3 images on page 1
imgdata [size]: 0.00 MB
imgdata [size]: 0.00 MB
imgdata [size]: 0.68 MB
发现每页变成三张图像了,只不过除了新插入的图像外,其他两张都是小的透明Pixmap("虚拟"图像),占用内存可忽略不计。
对于强迫症来说受不了,于是翻阅文档,发现可以在保存时执行垃圾回收和清理,如下图所示
经尝试,发现只要在保存时设置doc.save('output.pdf', garbage=2, clean=True)
即可。同时,也解决了使用替换页面图片page.replace_image(xref, stream=bio)
带来的内存增大问题。测试结果如下:
example.pdf [size]: 1.72 MB
Find 1 images on page 0
Processed 1 images on page 0
Find 1 images on page 1
Processed 1 images on page 1
output.pdf [size]: 1.32 MB
Find 1 images on page 0
imgdata [size]: 0.63 MB
Find 1 images on page 1
imgdata [size]: 0.68 MB
以上便是我解决这一问题的思路。
2 完整代码与测试效果
示例最终完整代码如下:
import io
import os
import pymupdf
from PIL import Image
doc = pymupdf.open("example.pdf") # open a document
print(f"example.pdf [size]: {os.path.getsize('example.pdf') / 1024 / 1024:.2f} MB")
for page_index in range(len(doc)): # iterate over pdf pages
page = doc[page_index] # get the page
image_list = page.get_images()
print(f"Find {len(image_list)} images on page {page_index}")
for image_index, img in enumerate(image_list, start=1): # enumerate the image list
xref = img[0] # get the XREF of the image
imgdict = doc.extract_image(xref)
imgdata = imgdict["image"] # image data
pil_imgdata = Image.open(io.BytesIO(imgdata))
# TODO:图像处理操作
bio = io.BytesIO()
pil_imgdata.save(bio, format=imgdict["ext"], quality=95, progressive=True,
dpi=(imgdict['xres'], imgdict['yres']))
page.replace_image(xref, stream=bio)
print(f"Processed {image_index} images on page {page_index}")
# 保存PDF
doc.save('output.pdf', garbage=2, clean=True)
doc.close()
print(f"output.pdf [size]: {os.path.getsize('output.pdf') / 1024 / 1024:.2f} MB")
对前两篇文章的方法进行优化,结果如下:
文章1中示例
example.pdf [size]: 48.48 MB
Processing: 100%|████████████████████████████████████████████████| 55/55 [04:11<00:00, 4.57s/ Page]PDF处理完成!输出文件: output.pdf
output.pdf [size]: 69.04 MB文章2中示例