Python实现pdg批量转PDF:从解析到合成的完整方案

发布时间:2026/9/13 3:28:17
Python实现pdg批量转PDF:从解析到合成的完整方案 1. 项目背景与需求拆解1.1 pdg文件到底是个什么来头先说清楚pdg是什么。pdg格式是早期数字图书馆系统里常见的一种扫描书格式很多老书、绝版书、内部资料扫描入库时用的就是它。这类文件最大的特点是没有统一的公开标准本质上它是一套自定义的图像封装方案——每一页扫描图被压缩编码后打包成独立文件目录、书签、页码信息则存在配套的元数据文件里。所以直接拿它当普通图片看吧大多数看图软件不认想直接打印吧打印驱动也不认想发给别人阅读吧对方连打开都费劲。我做这个项目时手上有一批扫描版的技术手册大概几百个pdg文件分散在几十个文件夹里每一本都要转成PDF才能方便地在平板、手机上阅读、标注、检索。折腾了一圈后发现市面上现成的转换工具要么收费、要么带水印、要么转换批量处理时卡死最后干脆用Python写了一个自动化脚本核心思路就是标题里写的那样先解析pdg文件把它还原成jpg图片再把jpg按页码顺序合成为一个pdf。这个方案的优势在于中间产物jpg是通用的随时可以拿出来单独使用比如图片裁剪、OCR识别、局部放大去噪不必每做一步操作就重新解析一次pdg。而且整个过程逻辑非常直接不依赖任何商业闭源工具所有环节都能看得见、可修改、可中断续跑。1.2 为什么中间要过一道jpg而不是直接转pdf很多人会问pdg2pdf一步到位不就行了为什么非要先落成jpg再合成PDF答案是稳定性和可控性。直接转换的方案在遇到特殊编码的pdg文件时非常容易中途报错一旦出错整本pdf就废了又得从头再来。而先转jpg的话转换进度以图片为单位可控哪一页有问题单独修复那一页就行不影响整体流程。另外jpg中间产物让你有机会在合成pdf之前做图像预处理比如把歪斜的扫描页校正、把黑边裁掉、把对比度拉高这些都是实际处理扫描书时经常遇到的需求。我自己在转换过程中就遇到过一批页面发灰的情况后来在合并前统一做了灰度增强出来的PDF阅读体验好了不少。还有一点非常实际pdg文件本身有大有小直接转大页码的pdg文件时如果内存管理不到位很容易把进程撑爆。而逐页转jpg再分批合入PDF每一步内存占用都可控不会因为某本书页数多就翻车。2. 环境准备与工具选型2.1 Python环境的搭建与依赖安装我是在Windows上跑的这套流程Python版本用的3.10。装Python这一步没什么特别的去官网下载安装包勾选Add Python to PATH一路下一步就行。如果系统里已经装了Anaconda或者其他Python发行版直接用也行但建议为这个项目单独建一个虚拟环境避免依赖冲突。核心依赖其实只有两个一个是pdg2pdf库另一个是图像处理库Pillow。pdg2pdf库用来解析pdg文件、提取页面图像数据Pillow负责把解析出来的图像数据编码成jpg文件顺便可以做基本的图像处理。此外我建议再装一个PyMuPDF也就是fitz这是一个非常好用的PDF生成和操作库后面合成PDF时要比直接用Pillow的save方法灵活得多支持设置压缩级别、嵌入书签、调整页面大小。pip install pdg2pdf Pillow PyMuPDF如果pip下载速度慢可以临时指到国内镜像源比如清华源pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pdg2pdf Pillow PyMuPDF装上之后验证一下能不能import成功这一步别省很多问题其实都出在环境没装干净。2.2 pdg2pdf库的能力边界与适用场景pdg2pdf这个库我实测下来的感受是好用但有脾气。它能处理的pdg文件主要是标准扫描版格式也就是每个页面都有一个独立的图像数据段如果遇到加密过或者改版过的pdg文件库会直接抛错或者输出的图片是花屏。所以用它之前最好先摸清楚你手上的pdg文件是什么来路是官方扫描入库的还是第三方手工制作的手工制作的文件编码往往不规范容易出问题。不过这个库的设计思路跟我们的项目标题完全吻合它内部也是先做pdg到图片的解析然后才涉及PDF合成。所以哪怕库本身处理不了某些特殊情况至少它把最复杂的pdg解析逻辑给你实现了省去了你去逆向文件格式的功夫。我在项目里使用的是它的图片解析能力把pdg文件的内容读出来喂给Pillow去保存jpg同时我也会直接调用它自带的pdg2pdf函数做一次快速转换当作交叉验证确保手动流程的结果和库直转的结果一致。我的建议是小批量文件、文件编码比较规范的时候直接用库自带的一条龙转换省事大批量文件、中间需要加图像处理步骤的时候拆成两段来处理更合适。这篇文章后面给出的脚本就是按两段式来设计的。3. 核心实操pdg转jpg再合成PDF的完整流程3.1 先理清输入输出的目录结构在实际动手之前我先把任务拆成了三个子步骤扫描目录找所有pdg文件、逐个把pdg转成jpg、把所有jpg按页码合并成一个pdf。输入目录结构大概是这样的books/ ├── book_a/ │ ├── 0001.pdg │ ├── 0002.pdg │ └── ... ├── book_b/ │ ├── 0001.pdg │ ├── 0002.pdg │ └── ...输出目录我建议单独建一个跟原文件隔离开因为中间jpg文件和最终pdf文件加起来体积不小混在一起容易乱。我习惯的输出结构是output/ ├── jpg/ │ ├── book_a/ │ │ ├── 0001.jpg │ │ ├── 0002.jpg │ │ └── ... │ └── book_b/ │ ├── 0001.jpg │ ├── 0002.jpg │ └── ... └── pdf/ ├── book_a.pdf └── book_b.pdf这样一本书对应一个jpg文件夹、一个pdf文件后期想补转某一本、想删掉中间产物重来都非常好操作。3.2 第一步扫描目录并解析pdg文件这部分代码的核心是获取目录下所有pdg文件的完整路径然后按文件名排序。注意文件名排序要用自然排序而不是字符串排序。因为如果文件叫1.pdg、2.pdg、10.pdg字符串排序会把10.pdg排到2.pdg前面最终合并pdf时页码顺序就错了。import os import re def natural_sort_key(filename): return [int(text) if text.isdigit() else text.lower() for text in re.split(r(\d), filename)] def find_pdg_files(directory): pdg_files [] for root, dirs, files in os.walk(directory): for f in files: if f.lower().endswith(.pdg): full_path os.path.join(root, f) pdg_files.append(full_path) return sorted(pdg_files, keylambda x: natural_sort_key(os.path.basename(x)))这里用os.walk是为了支持子目录递归扫描有些扫描书会把一个章节放在一个子目录里不递归的话会漏文件。natural_sort_key这个函数是用来做自然排序的关键正则表达式把数字和非数字拆开数字部分转成int比较这样1、2、10就能按数字大小排对了。3.3 第二步逐个pdg转jpg解析pdg文件时我建议直接用pdg2pdf库的内部函数把页面数据提取出来再转成Pillow图像对象。from pdg2pdf import pdg2img from PIL import Image import io def convert_pdg_to_jpg(pdg_path, jpg_output_path, quality85): images pdg2img(pdg_path) page_images [] for img_data in images: if isinstance(img_data, bytes): img Image.open(io.BytesIO(img_data)) else: img img_data if img.mode ! RGB: img img.convert(RGB) page_images.append(img) # 多页pdg时第一页存成主jpg其余页按序号追加 if len(page_images) 1: page_images[0].save(jpg_output_path, JPEG, qualityquality) else: base_name os.path.splitext(jpg_output_path)[0] for idx, img in enumerate(page_images, start1): img.save(f{base_name}_{idx}.jpg, JPEG, qualityquality) return len(page_images)这里有一个非常重要的细节pdg2img返回的可能是一个包含多帧图像的数据结构因为单个pdg文件有时会包含同一个页面的多个分辨率版本或者一个文件里打包了多页扫描内容。如果只取第一帧遇到多页打包的pdg文件就会漏页。所以代码里做了一个遍历把所有帧都提取出来。jpg的quality参数我推荐85到90之间。quality太高文件体积会爆炸一本书几百页每页几MB加起来就是几个Gquality太低文字边缘会发虚扫描书本身清晰度就一般再压狠了就没法看了。85是比较均衡的档位。3.4 第三步把jpg列表合成一个PDF合成PDF的方案我用过两种第一种是Pillow自带的save方法def images_to_pdf_pillow(image_list, pdf_path): first_img Image.open(image_list[0]).convert(RGB) imgs [Image.open(img).convert(RGB) for img in image_list[1:]] first_img.save(pdf_path, save_allTrue, append_imagesimgs, resolution150.0) return pdf_path这种方法写起来非常简洁适合几百页以下的书籍。但它有两个短板一是没法细粒度控制页面尺寸和压缩策略二是当图片数量特别多时内存压力很大因为Pillow会把所有图片的句柄都保存在内存列表里。第二种方案是用PyMuPDF也就是fitz逐页插入图片。这种方案内存占用小得多而且可以边插入边压缩对几千页的超大扫描书更友好。import fitz def images_to_pdf_pymupdf(image_list, pdf_path, target_dpi150): doc fitz.open() for img_path in image_list: img_doc fitz.open(img_path) pdf_bytes img_doc.convert_to_pdf() img_pdf fitz.open(pdf, pdf_bytes) page doc.new_page(widthimg_pdf[0].rect.width, heightimg_pdf[0].rect.height) page.show_pdf_page(page.rect, img_pdf, 0) img_doc.close() img_pdf.close() doc.save(pdf_path, deflateTrue, garbage3) doc.close() return pdf_path这个方法的核心思路是把jpg先转成一个单页pdf对象再用show_pdf_page把这一页嵌入到目标文档中。好处是可以精确控制页面大小等于图片原始尺寸不会出现页面被拉伸变形的问题。加target_dpi参数是给后续扩展用的如果图片分辨率过高可以在插入时按目标DPI等比缩小。3.5 一键串联完整的批量处理脚本把上面的步骤整合到一起再加上进度打印和错误日志就是一套可以直接跑批的脚本import os import time import traceback from pdg2pdf import pdg2img from PIL import Image import io import fitz def run_batch(): source_dir rD:\books output_jpg_dir rD:\output\jpg output_pdf_dir rD:\output\pdf os.makedirs(output_jpg_dir, exist_okTrue) os.makedirs(output_pdf_dir, exist_okTrue) book_dirs [d for d in os.listdir(source_dir) if os.path.isdir(os.path.join(source_dir, d))] book_dirs.sort() error_log [] start_time time.time() for book in book_dirs: book_path os.path.join(source_dir, book) print(f开始处理: {book}) book_start time.time() pdg_files find_pdg_files(book_path) if not pdg_files: print(f 未找到pdg文件跳过) continue jpg_dir os.path.join(output_jpg_dir, book) os.makedirs(jpg_dir, exist_okTrue) jpg_paths [] try: for idx, pdg_file in enumerate(pdg_files, start1): base_name os.path.splitext(os.path.basename(pdg_file))[0] jpg_path os.path.join(jpg_dir, f{base_name}.jpg) convert_pdg_to_jpg(pdg_file, jpg_path, quality88) jpg_paths.append(jpg_path) if idx % 50 0: print(f 已转换 {idx}/{len(pdg_files)} 页) pdf_path os.path.join(output_pdf_dir, f{book}.pdf) images_to_pdf_pymupdf(jpg_paths, pdf_path) elapsed time.time() - book_start print(f 完成: {book}.pdf, 共 {len(pdg_files)} 页, 耗时 {elapsed:.1f}s) except Exception as e: print(f 处理失败: {e}) error_log.append((book, traceback.format_exc())) total_time time.time() - start_time print(f\n全部处理完成, 总耗时 {total_time:.1f}s) if error_log: print(f有 {len(error_log)} 本书处理失败:) for book, err in error_log: print(f - {book}: {err.splitlines()[-1]}) if __name__ __main__: run_batch()这套脚本我在实际项目里跑过很多次几十本书批量处理下来基本不需要人工干预。关键设计是错误日志的收集机制某本书中途失败不会中断整个批处理而是记录到error_log里最后统一打印等你回头排查。4. 常见问题与排查技巧实录4.1 转换后jpg全黑或花屏这是pdg转jpg最经典的问题。原因通常是pdg文件内部的图像数据不是标准的JPEG编码而是自定义的压缩格式。pdg2pdf库对这种情况会尝试用内部解码器解析但一旦遇到特别古怪的编码变体解析出来就是一团黑或者花屏。我的排查思路分三步。第一步先确认单个pdg文件在原生阅读器里能正常显示如果能显示就说明文件本身没问题第二步检查pdg2pdf库的版本有些老旧版本对某些编码支持不全升级到最新版往往能解决第三步如果升级后仍然花屏考虑用另一个解析库pypdg作为备用方案不同库的解码逻辑有差异这个解不开也许那个能解开。还有一种容易忽略的情况pdg文件虽然扩展名是.pdg但内部其实就是标准的jpg数据。遇到这种情况最省事的办法是直接用二进制方式读取文件头判断是否是jpg格式的magic numberFFD8FF如果是就直接复制改扩展名完全不需要复杂的解析流程。def check_pdg_file_type(pdg_path): with open(pdg_path, rb) as f: header f.read(16) if header.startswith(b\xff\xd8\xff\xe0) or header.startswith(b\xff\xd8\xff\xe1): return jpeg elif header.startswith(b\x00\x00\x01\x00): return tiff-like else: return unknown4.2 合并PDF后发现页面顺序错乱页面顺序错乱基本只可能是一个原因文件排序时用了字符串排序而不是自然排序。前面已经提过1.pdg会排在10.pdg之后的坑这里再补充一种更隐蔽的情况文件名里有下划线或者汉字时不同操作系统的排序规则不一样Windows和Linux下os.walk返回的顺序可能就不是一致的所以必须显式用自然排序绝不能依赖遍历顺序。另外还有一种情况某些扫描书的第一页是封面文件名可能叫cover.pdg后面的页面叫0001.pdg、0002.pdg。自然排序会把cover.pdg排在0001.pdg前面但cover.pdg的语义上应该是封面。遇到这种情况需要对文件名做映射给特殊命名的文件指定一个排序权重。def book_page_sort_key(filename): name os.path.splitext(filename)[0].lower() if name.startswith(cover) or name.startswith(front): return (0, 0) elif name.startswith(back) or name.startswith(end): return (99999, 0) else: digits re.search(r\d, name) num int(digits.group()) if digits else 0 return (1, num)4.3 生成的PDF体积过大扫描书转出的PDF动辄几十上百MB这个问题我踩过很多次坑。主要原因是jpg保存时quality设置太高以及合成PDF时没有启用压缩。先说jpg阶段的控制。扫描书的内容大多是文字和简单图形其实对图片质量的要求没有照片那么高quality设置在80到85之间完全够用肉眼几乎看不出区别但文件体积能小一半以上。另外如果原图是纯黑白文字页可以在转jpg时把图像模式改成L灰度或者1二值文件体积会急剧缩小。判断一页是否是纯黑白页也很简单看图像的颜色数量如果每个像素的RGB三通道差值都很小就当成灰度图处理。再说PDF阶段的控制。PyMuPDF的save方法里我用了deflateTrue和garbage3两个参数前者对PDF内部的流对象做压缩后者清理文档结构里的冗余对象。这两个参数对PNG页面效果显著对JPEG页面也有一定作用。如果PDF里嵌入的图片本身就是jpg格式PDF容器通常会原样存储而不是重新压缩所以在jpg阶段把体积控制好才是治本之策。4.4 转换中途内存暴涨导致程序被杀遇到大页数pdg文件时内存管理必须讲究。pdg2img这个函数一次调用会把整本书的所有页面数据全部读入内存如果一本书有上千页内存直接飙升到几个GB。一个变通方案是分块解析先单独把pdg文件按某种粒度切成小段再逐段调用pdg2img。如果格式不允许切片那就用多进程而不是多线程——把不同书的转换任务分给不同进程单进程内仍然是顺序解析。Python的多线程在多核环境下受GIL锁限制对CPU密集型的图像解码任务提升几乎为零用multiprocessing模块才能真正利用多核能力。from concurrent.futures import ProcessPoolExecutor def process_one_book(book): # 这里面只处理一本书的完整流程 return book, True with ProcessPoolExecutor(max_workers4) as executor: futures [executor.submit(process_one_book, book) for book in book_dirs] for future in futures: book, success future.result() print(f{book}: {成功 if success else 失败})实测下来4个进程同时跑4本不同的书转换总耗时能缩减到原来的三分之一左右内存消耗也平滑很多。4.5 断点续转处理到一半崩溃怎么办大批量转换时最怕的就是跑到第800页程序崩了前面的努力全白费。这个问题我用一个简单办法解决jpg文件本身就天然具备断点续传功能。因为jpg是逐页生成的已经生成的jpg文件就是已完成的部分重新跑脚本时可以先检查jpg文件是否已存在且完整存在的直接跳过转换只处理缺失的部分。def is_valid_jpg(path): try: with Image.open(path) as img: img.verify() return True except Exception: return False def convert_with_resume(pdg_file, jpg_path, quality88): if os.path.exists(jpg_path) and is_valid_jpg(jpg_path): print(f {jpg_path} 已存在且完整, 跳过) return convert_pdg_to_jpg(pdg_file, jpg_path, qualityquality)这个方案有个额外的好处如果某本pdf合成时报错不需要重新转换所有图片只重新执行一遍合成步骤就行。jpg中间产物的价值在这里体现得淋漓尽致。5. 进阶技巧与扩展玩法5.1 批量OCR让扫描版PDF可以搜索PDF合成成功后如果你想更进一步让它支持文字搜索和复制就需要接入OCR识别。扫描书的文字是图片形式的位图不是真正的文本层OCR可以把图片里的文字识别出来以不可见的文本层嵌入到PDF中。PyMuPDF本身不包含OCR能力需要配合Tesseract或者PaddleOCR使用。我的做法是先用OCR把jpg识别成带坐标的文字块再用PyMuPDF的insert_text把这些文字写入到对应页面上的透明图层。这样既保留了原始扫描图像又增加了可搜索文本阅读器里直接搜索关键词和复制引用都没问题。我自己的经验是如果识别的是简体中文扫描书优先用PaddleOCR它的中文识别准确率在开源方案里是拔尖的如果主要是英文和技术符号Tesseract配置好语言包也够用。5.2 目录书签自动生成很多扫描版PDF没有目录书签几百页的书翻起来很痛苦。如果你能拿到pdg文件配套的目录结构文件比如Contents或者Bookinfo文件可以解析出章节标题和对应的页码然后在合成PDF时通过PyMuPDF的set_toc方法把目录写入。def add_toc(doc, toc_entries): # toc_entries 格式: [level, title, page_number] doc.set_toc(toc_entries)即使没有目录文件也可以根据页面的视觉特征做启发式识别比如每章第一页的文字区域分布和普通页有明显差异通过简单判断就能定位章节起始页然后手动命名章节标题。这个方法准确率在八成左右足以用来做一个可用的粗略目录。5.3 图像预处理黑边裁剪与方向校正老式扫描书经常会有扫描仪黑边、页面歪斜、方向颠倒等问题。这些问题如果在jpg阶段处理成本非常低如果等合成PDF后再想修就得对每一页重新做图像处理再替换页面内容麻烦得多。黑边裁剪可以用Pillow的getbbox方法找到内容区域的最小外接矩形然后按这个矩形裁剪。处理方向颠倒时可以先用OCR识别首页的开头文字如果倒过来了就旋转180度再输出。def trim_black_borders(img, threshold240): gray img.convert(L) bbox gray.point(lambda x: 255 if x threshold else 0).getbbox() if bbox: return img.crop(bbox) return img注意threshold的值要根据实际扫描质量调整扫描底色太暗或者纸张发黄的阈值太大会把内容边缘裁掉太小又裁不干净黑边。6. 实操总结与经验沉淀整个pdg到PDF的转换链路核心心得可以归结为三条第一中间产物很重要jpg这一步别看多占了点磁盘空间但它给你带来了断点续传、图像预处理、独立使用三个优势这是直转方案没法比的第二排序逻辑是所有转换工具里最容易出错但又最容易忽略的环节不管用什么方案页码顺序正确永远是最优先要保证的第三批量处理场景下宁可慢一点也要加错误日志和断点续传机制否则几百本书跑到一半全部重来时间成本无法接受。我实际把这套脚本用在日常的工作流中已经有一年多时间几百本pdg格式的资料都转成了统一PDF格式归入电子图书馆统一管理。工具本身不复杂但设计思路上把稳定性放在第一位遇到异常能恢复、能定位这比单纯追求转换速度更重要。如果你们手上的pdg文件量不大二三十本以内直接用pdg2pdf库自带的一条龙转换就够了如果跟我一样有批量处理和历史积累需求花点时间把两段式流程和断点续传写进脚本长期来看非常值得。