
前几天整理移动硬盘翻出一个名为「古代汉语词典」的文件夹里面躺着109个.pdg文件最早的时间戳停在2014年。这些文件当年是从某个数字图书馆资料站点下载的只能在老式专用阅读器里打开现在电脑换了几茬别说那个专用阅读器连能认出这种格式的软件都很难找。那天晚上我花了两小时用Python把这堆pdg先转成了jpg再把所有jpg合并成了一个PDF整个过程比想象中简单但中间确实踩了几个不大不小的坑。这篇文章就想把这条“从pdg到jpg再到PDF”的完整链路写透包括环境怎么搭、代码怎么组织、遇到加密和坏文件怎么处理给同样翻出老资料却打不开的你一份能直接抄作业的方案。内容适合刚接触Python的新手也适合想批量整理老格式电子书的进阶用户。1. pdg格式的“身世”以及为什么转PDF是当前最优解很多年轻读者可能没见过pdg文件。它曾经是超星数字图书馆早期主推的私有图像格式全称相关技术资料里通常标注为NTLFNew Type Library File把扫描好的图书页面按页拆开存储一页一个.pdg文件页码直接体现在文件名上。超星阅读器在Windows时代普及率很高很多高校图书馆采购的就是这套体系于是大量古籍、旧教材、内部资料都以这种格式流传出来一批老用户手里攒下了成百上千个“一打开就报错”的离线文件。1.1 NTLF与超星pdg文件到底是一个什么东西pdg并不像jpg那样是一个纯粹的图片文件。它的文件头里带有纠错码、页面尺寸、调色板信息、加密标记等元数据数据区才是压缩后的扫描图像。老版本的结构相对宽松把整个文件视作“带元数据的位图”基本可行这就是Pillow这类图像处理库能直接识别它的原因。新的加密版本则改用了另一种编码逻辑文件头经常是一段看不出结构的乱码单纯靠早期公开的解析方式很难解出图像数据这部分我后面会专门讲怎么识别和规避。可以这么理解pdg就是一本书被拆成几百页的“扫描碎片”每页自带一些档案信息但离开了超星的阅读环境它就像没有播放器的老磁带内容还在就是没法直接看。1.2 为什么是“jpg → PDF”而不是“pdg → pdf”也许你会问既然要转换成PDF为什么不直接找个工具把pdg转成PDF还要多一道jpg的工序我最初也是这么想的试了一圈才明白这里面有个历史遗留的技术约束。早期超星的格式是为自己的阅读器设计的官方虽然有“打印成PDF”的功能但那是GUI界面操作对一两百页的批量文件来说效率极低。而社区里流传的各种命令行转换工具多数也是先把pdg解成图片再调用图像库合成PDF。真正能“一步到位”的库并不是不存在只是它们对加密文件和文件头损坏的容忍度很低一个小问题就整本失败排查起来很痛苦。相比之下“先还原成jpg再合成PDF”这条链路每一步都是可验证的转换完翻一翻jpg有没有缺页、有没有花屏一目了然再进入合成步骤。而且pdg本身存储的就是扫描图像转成PDF之后每一页都是原始图像没有任何OCR识别带来的文字误差对于古籍、页面带有特殊符号的教材来说这反而是最保真的处理方式。1.3 三条转换路径的横向对比我实际试过三种路径各有各的适用场景列个表格直观对比一下路径核心依赖优点缺点适合场景A. ImageMagick命令行转换imagemagick老牌工具、批量方便对加密pdg兼容性一般、参数繁琐Linux环境下快速粗转B. pdg2pdf库直接转换pdg2pdf、Pillow接口简单、代码量最少遇到异常文件容易整体中断、灵活性低文件完整且未加密的小册子C. Pillow转jpg PyMuPDF合成PDFPillow、PyMuPDF可控性强、可断点续跑、兼容性好需要自己写一点胶水代码批量、整本、混合异常文件的场景我最终选择的是路径C原因在后面踩坑部分会越来越明显。简单说路径B适合“文件很干净”的场合而现实中从网上下载的pdg包缺页、错页、加密混杂是常态路径C的容错能力和过程可见性是最好的。2. 环境准备Python版本、依赖库与安装时的兼容性正式开始写代码之前先花几分钟把环境理顺。很多人倒在这一步不是因为代码不对而是Python版本和依赖库之间打架。2.1 Python版本怎么选我建议使用Python 3.8到3.11之间的版本。Pillow在新版本下一般没什么问题但pdg2pdf这类的老库维护并不活跃如果你用Python 3.12或3.13安装时很可能遇到C扩展编译失败或者ABI不兼容的报错。除非你有特别的原因必须用新版本否则直接装Python 3.10是当前稳妥的选择。如果你和我一样机器上已经装了多个Python版本建议为这个任务单独建一个虚拟环境免得把全局环境弄得一团糟。打开终端在你的项目目录下执行python3.10 -m venv pdg_env source pdg_env/bin/activate # Windows下执行 pdg_env\Scripts\activate后面安装的所有依赖都只会进这个环境不影响系统的其他项目。2.2 安装依赖库在激活的虚拟环境里一行命令把需要的库全部装上pip install pillow pymupdf pdg2pdfpillowPython图像处理库负责把pdg解码成图像数据以及后续的jpg保存。pymupdf对MuPDF的Python封装负责把jpg逐页合成PDF效率高、内存占用可控。pdg2pdf这里作为备选方案安装。它底层同样依赖Pillow接口简单适合文件完整度高的场景但它在pip install时偶尔会在老版本Python上遇到编译依赖问题。如果安装失败也不影响我们后面主要使用的路径C。安装时最常见的报错是error: legacy-install-failure或者编译zlib之类的依赖失败多半是因为Python版本太新或太老。解决思路不是去手动改源码而是先把Python版本切换到3.10再重新安装大多数情况下一次就好。2.3 快速验证安装是否可用装完别急着写完整脚本先做个最小验证确认Pillow能直接打开你的pdg文件。写一个十行左右的测试脚本from PIL import Image test_file sample.pdg try: with Image.open(test_file) as im: im.load() print(文件格式:, im.format) print(尺寸:, im.size, 模式:, im.mode) except Exception as e: print(无法打开:, e)如果输出里能看到尺寸和模式通常是L灰度图或RGB彩图说明你的pdg是老版未加密文件后面的流程可以顺利走通。如果这一步就报错那大概率是加密版或者文件头损坏可以先不用慌后面的踩坑章有对应的跳过策略。3. 核心代码pdg转jpg、jpg合并PDF的完整实现环境就绪后接下来是核心部分。我会把链路拆成两步来写先是pdg转jpg再是jpg合并PDF。每一步都给出可运行的代码和参数选择的理由。3.1 第一步用Pillow批量打开pdg并转成jpgPillow的Image.open可以识别大部分老版pdg的头部结构将其当作一种位图格式读入。代码并不复杂但有几个细节需要注意from PIL import Image import os def pdg_to_jpg(src_pdg, dst_jpg, quality95): 将单个pdg文件转成jpg图片 try: with Image.open(src_pdg) as im: im.load() # 统一转换到RGB避免保存JPEG时出现模式不支持 if im.mode not in (RGB, L): im im.convert(RGB) im.save(dst_jpg, JPEG, qualityquality) return True except Exception as e: print(f转换失败: {src_pdg}, 原因: {e}) return False为什么quality要设置成95而不是默认的75因为pdg来源多是扫描书页本身细节就多JPEG压缩率太高会明显出现块状伪影尤其文字周围会产生晕影影响后续阅读体验。95是个实测比较均衡的值文件体积不会大得离谱画质也足够保留扫描原貌。有一点我要特别提醒im.load()这行一定不要省。Image.open本身是惰性的只读了文件头图像数据还没载入内存如果不调用load()就直接save某些格式会保存出空白页。批量转换时避免用os.listdir直接遍历目录后马上进入转换建议先做一层过滤def convert_folder(src_dir, dst_dir): os.makedirs(dst_dir, exist_okTrue) files [f for f in os.listdir(src_dir) if f.lower().endswith(.pdg)] for idx, f in enumerate(files, 1): src os.path.join(src_dir, f) dst os.path.join(dst_dir, f.replace(.pdg, .jpg)) ok pdg_to_jpg(src, dst) if ok: print(f[{idx}/{len(files)}] {f} - {os.path.basename(dst)}) # 简单进度提示方便中断后知道从哪里继续3.2 第二步把jpg合成为PDF的两种写法jpg转PDF也有两条路一条用Pillow自带的多页保存能力另一条用PyMuPDF逐页插入。两条路代码都不长适用场景不同。先看Pillow方案的写法from PIL import Image import os def jpgs_to_pdf_pillow(jpg_dir, out_pdf): files [f for f in os.listdir(jpg_dir) if f.lower().endswith(.jpg)] # 自然排序避免 10.jpg 排在 2.jpg 前面 files.sort(keylambda x: int(.join(ch for ch in x if ch.isdigit()) or 0)) images [] for f in files: with Image.open(os.path.join(jpg_dir, f)) as im: if im.mode RGBA: im im.convert(RGB) images.append(im.copy()) if images: images[0].save( out_pdf, save_allTrue, append_imagesimages[1:], resolution150.0 ) print(fPDF已生成: {out_pdf})这段代码逻辑很直白但有一个致命短板images.append(im.copy())会把所有图片都放进内存里。一本两三百页的书如果每页jpg是1MB内存占用就要300MB起步页面尺寸大一些就更夸张。它只适合页数很少、图片体积较小的场景。再看PyMuPDF的写法import fitz import os def jpgs_to_pdf_pymupdf(jpg_dir, out_pdf): doc fitz.open() files [f for f in os.listdir(jpg_dir) if f.lower().endswith(.jpg)] files.sort(keylambda x: int(.join(ch for ch in x if ch.isdigit()) or 0)) for f in files: img_path os.path.join(jpg_dir, f) # 单个图片转成单页PDF with fitz.open(img_path) as img: pdf_bytes img.convert_to_pdf() with fitz.open(pdf, pdf_bytes) as page_pdf: doc.insert_pdf(page_pdf) doc.save(out_pdf) doc.close() print(fPDF已生成: {out_pdf})PyMuPDF的优势非常明显每一页都是“用完即走”convert_to_pdf只处理当前这一张图片处理完就释放。哪怕全书五百页内存占用也能保持在一个很平稳的低位。速度上MuPDF本就是C库性能很有保障一本两三百页的书通常几秒钟就能完成合并。3.3 两种合并方式的选型对比我直接给个建议不绕弯子页面少于30页的小册子用Pillow省事正经整本书直接用PyMuPDF。原因就是内存模型的差异。Pillow将所有页面驻留内存到后期不仅慢还极易因为内存不足导致进程被杀。PyMuPDF的流式处理方式从根本上回避了这个问题。对比项Pillow合成PyMuPDF合成代码复杂度低低内存占用随页数线性增长基本恒定合成速度中快单页异常容错中高适用场景小册子、临时拼接整本书、大批量3.4 输出参数清晰度、格式与命名合成PDF时有两个参数直接影响最终产物质量resolution和图片本身的分辨率。resolution只写入PDF内部的信息标记标注的是渲染时的DPI参考值并不会真的把图片变清晰。如果jpg本身只有300x400像素把resolution写成1500丝毫没有意义。要想PDF里清晰应该在pdg转jpg那一步就控制好输出尺寸。我遇到过的典型问题是部分pdg源文件尺寸很小可能是早期扫描设备的分辨率限制转出来的jpg只有两三百像素宽放大阅读时明显发虚。这种情况下不要指望合成阶段能补救正确做法是在pdg转jpg时做一次适当的放大重采样。比如把原图宽高等比放大到两倍再用Image.LANCZOS做重采样边缘会平滑很多if im.width 1000: # 太小的页面放大到合适宽度 scale 1000 / im.width im im.resize((1000, int(im.height * scale)), Image.LANCZOS)这个阈值不是固定的取决于你希望PDF在屏幕上阅读时页面宽度大概是多少。我一般以宽1000到1200像素作为目标手机上阅读缩放比较舒适文件大小也比较合理。4. 一个能直接跑的完整脚本批量处理整本书讲完核心步骤把它们组装成一个工程化脚本。这个脚本可以直接放进一个pdg书目录里运行会自动完成转换、排序、合成、清理临时jpg的完整流程。4.1 完整脚本import os import re import sys from PIL import Image import fitz def natural_key(filename): 自然排序把文件名中的数字按数值大小排序 parts re.split(r(\d), filename) return [int(part) if part.isdigit() else part.lower() for part in parts] def pdg_to_jpg(src_pdg, dst_jpg, min_width1000, quality95): try: with Image.open(src_pdg) as im: im.load() if im.mode not in (RGB, L): im im.convert(RGB) # 如果图片太窄放大到至少 min_width if im.width min_width: scale min_width / im.width im im.resize((min_width, int(im.height * scale)), Image.LANCZOS) im.save(dst_jpg, JPEG, qualityquality) return True except Exception as e: print(f[跳过] {os.path.basename(src_pdg)}: {e}) return False def convert_book(src_dir, dst_pdf, keep_jpgFalse): # 第一步所有pdg转jpg work_dir os.path.join(src_dir, _jpg_tmp) os.makedirs(work_dir, exist_okTrue) pdg_files [f for f in os.listdir(src_dir) if f.lower().endswith(.pdg)] pdg_files.sort(keynatural_key) print(f共发现 {len(pdg_files)} 个pdg文件) jpg_files [] for idx, f in enumerate(pdg_files, 1): src os.path.join(src_dir, f) dst os.path.join(work_dir, f.replace(.pdg, .jpg)) if pdg_to_jpg(src, dst): jpg_files.append(dst) if idx % 20 0: print(f进度: {idx}/{len(pdg_files)}) print(f成功转换 {len(jpg_files)} 个文件开始合成PDF...) # 第二步jpg合成PDF jpg_files.sort(keynatural_key) doc fitz.open() for jpg_path in jpg_files: with fitz.open(jpg_path) as img: pdf_bytes img.convert_to_pdf() with fitz.open(pdf, pdf_bytes) as page_pdf: doc.insert_pdf(page_pdf) doc.save(dst_pdf) doc.close() # 第三步清理临时jpg目录除非指定保留 if not keep_jpg: for f in os.listdir(work_dir): os.remove(os.path.join(work_dir, f)) os.rmdir(work_dir) print(f完成PDF文件: {dst_pdf}共 {len(jpg_files)} 页) if __name__ __main__: if len(sys.argv) 3: print(用法: python pdg2pdf.py pdg目录 输出.pdf [--keep-jpg]) sys.exit(1) src sys.argv[1] dst sys.argv[2] keep --keep-jpg in sys.argv convert_book(src, dst, keep_jpgkeep)4.2 脚本中的关键设计脚本有两个设计点值得解释。第一个是natural_key自然排序函数。普通字符串排序会把2.jpg排在10.jpg后面因为1的字符序小于2。我这里用正则把文件名拆成数字和文本交替的片段数字片段转成整数再比较这样10就正确排到2之后了。这一步不处理好全书页码顺序就乱了合成的PDF翻起来会被打乱的页序坑到崩溃。第二个是临时jpg目录和--keep-jpg参数。默认情况下脚本处理完会清理中间jpg因为一本书的jpg占空间不小。但排查问题时保留中间产物很有用所以留了开关。实际使用中我倾向于先不带这个参数跑一遍如果发现某页转换有问题再加--keep-jpg重新跑直接用jpg定位是哪个文件出了问题。4.3 运行效果与验证在终端执行python pdg2pdf.py ./shu ./shu.pdf --keep-jpg正常输出大致是这样的共发现 109 个pdg文件 进度: 20/109 进度: 40/109 进度: 60/109 进度: 80/109 进度: 100/109 成功转换 107 个文件开始合成PDF... 完成PDF文件: ./shu.pdf共 107 页成功转换107个而不是109个说明有两个文件被打了解析失败。脚本已经自动跳过不会中断整个批次这是实际应用中非常关键的能力。转换完成后别急着收工先打开PDF检查三件事页数是否和成功转换数一致、抽查几页内容是否清晰、确认目录顺序没有乱。我用PyMuPDF验证页数和顺序代码很简单import fitz doc fitz.open(shu.pdf) print(页数:, doc.page_count) # 渲染第5页检查内容 pix doc[4].get_pixmap(dpi150) pix.save(preview_page5.png)渲染出一张预览图肉眼确认没问题这单就完成了。5. 实战中我踩过的那些坑流程跑通之后真正烧时间的是各种反直觉的坑。我把这一段单独拿出来写因为网上教程大多只展示顺滑路径一旦你遇到异常文件九成会卡住很久。5.1 文件名排序字符串排序的陷阱第一个坑就是文件排序。前面代码里已经实现了natural_key为什么这么重视因为我自己第一次跑的时候就翻过车。当时一个文件夹里有六十几张jpg我用sorted(os.listdir())直接排序合成的PDF前几页还能看翻到中段就发现页码完全错乱了第10页跑到了第2页前面第100页跑到了第3页前面。原因很简单字符串排序是按字符顺序来的10这个字符串的第一个字符是1排在2前面于是10、100这些数字全部被排到了2的前面。排查过程不复杂但有点折磨人。一开始我以为是转换漏页反复对比源目录和jpg文件列表数量完全对得上再挨页翻PDF才发现是顺序问题。所以这里真心建议所有涉及文件名数字排序的场景一律用自然排序别用默认字符串排序。5.2 转出来的图片太模糊先确认源分辨率再考虑放大策略第二个坑是清晰度。有一批pdg文件转出来之后jpg特别小宽敞度只有不到400像素PDF页面放到全屏全是马赛克字都是糊的。最开始我以为是我保存jpg时quality设太低改成95重跑了一遍毫无变化这才意识到问题出在源文件本身pdg文件头里记录的页面尺寸就只有这么大。这类情况只能靠重采样放大来缓解。前面代码里的min_width参数就是这个用途当图片宽度小于1000像素时等比放大到1000像素宽用LANCZOS插值算法。这个算法在放大这类扫描文字页时效果比较理想边缘锐度保持得比双线性好虽然达不到原生高清的程度但至少阅读起来不费劲。如果你遇到的是更极端的情况源图只有两三百像素放大到1000像素会有些虚但总比看马赛克强。5.3 读不了的pdg文件加密与损坏文件的处理这是我踩得最深的一个坑。有一批来自不同站点的pdgPillow打开时直接抛错错误信息五花八门有的报cannot identify image file有的报image file is truncated。前者通常说明文件头已经不可识别多半是加密版后者则是数据区不完整可能是下载中断或者文件本身损坏。最初我的处理方式是一遇到异常就让整个程序退出结果转换中断在中间位置前面辛苦生成的jpg全得重来。后来改成try/except逐个跳过并把失败文件单独输出到日志一次运行能完整跑完整个目录最后再对照日志决定哪些文件值得抢救。对于加密版pdg说句实在话社区里的公共库能处理的概率很低我的建议是不要耗费太多时间先跳过看看同一站点是否存在同样书籍的未加密副本。5.4 内存占用异常与中断恢复使用Pillow方案合并PDF时我曾经处理过一本将近500页的书程序跑到一半直接报了MemoryError整个进程没了。前面也提过Pillow的合成方式会把每张图片的副本全部放进内存页数一多自然撑不住。切换到PyMuPDF的流式写法之后这种问题再没出现过。但内存之外还有一个更隐蔽的问题中断恢复。如果一本书转换到一半因为断电、系统重启等原因中断重新跑一遍耗时不说还可能因为jpg目录已经存在而导致新旧文件混杂。我在脚本里加了进度打印同时利用已有的jpg做增量跳过——如果目标jpg已存在且大小大于0就直接跳过转换。这个思路简单但非常实用长任务遇到意外中断时不用从头来过。5.5 灰度图与彩色图混合导致PDF渲染异常最后一个小坑部分pdg是灰度扫描部分是真彩扫描混合在一起时如果统一用RGB模式保存jpg文件体积会显著增大但如果不转换某些阅读器又会出现渲染不兼容的怪异现象。我的处理方式是在保存jpg时统一做一次模式判断灰度图保留L模式彩色图转成RGB这样既兼顾了兼容性又不至于让文件体积失控。这段逻辑已经包含在第一节的转换函数里了实际使用中没有再出过问题。6. 转成PDF之后图片增强与OCR检索PDF生成只是第一步真正让老资料“好用”起来通常还需要一点后期处理。这也算是我在整理过程中摸索出来的延伸工作流一并分享出来。6.1 去黑边、纠偏与二值化扫描书页最常见的瑕疵是四周出现黑色或灰白边框有的页面还有明显的倾斜角度。直接用Pillow可以做基础处理先转灰度图再用ImageOps.autocontrast增强对比度最后用crop裁掉边缘空白区域。from PIL import ImageOps def clean_scan(img): # 转灰度、增强对比 if img.mode ! L: img img.convert(L) img ImageOps.autocontrast(img) # 裁掉边缘大约1.5%的边距通常能去掉大部分扫描边框 w, h img.size margin_x, margin_y int(w * 0.015), int(h * 0.015) img img.crop((margin_x, margin_y, w - margin_x, h - margin_y)) return img纠偏操作比较复杂Pillow内置能力有限需要用到OpenCV的minAreaRect配合霍夫变换检测文本行的倾斜角度再做旋转。这个展开写又是一大篇如果你的资料明显倾斜建议单独研究效果提升非常明显。6.2 给PDF加OCR文字层让古籍可以搜索原始扫描PDF最大的痛点是文字不可搜索。如果你需要从几百本资料中检索特定词条一页页翻是不现实的。解决办法是给PDF做OCR并写入文字层生成“扫描图片隐藏文本”的混合PDF既能保留原页面图像又能被全文搜索。工具方面Tesseract对中文的支持稍弱但免费PaddleOCR的中文识别效果更好但安装依赖更重。如果你只是做一个个人资料库Tesseract配合中文语言包通常够用。处理完的PDF直接放到Windows、macOS自带的搜索或者Calibre里都能全文搜索体验从“存了一堆图片”直接升级成“可检索的电子书库”。6.3 双页扫描件的拆分我处理的一批书是扫描仪直接扫的摊开双页PDF每页其实是书的两页内容。在手机上看字太小非常费眼。这类情况建议在转换前就把图片沿中线拆成两半分别存成两页。用Pillow的crop就能实现def split_double_page(img): w, h img.size left img.crop((0, 0, w // 2, h)) right img.crop((w // 2, 0, w, h)) return left, right拆完之后的PDF阅读体验会有质的提升尤其在竖屏手机上单页阅读时字体大小几乎翻倍。这个操作要放在pdg转jpg之后、合成PDF之前进行这样临时jpg目录里存的已经是拆分好的单页后续合成逻辑完全不用改。回头来看整个pdg转jpg再合成PDF的流程并不复杂核心就是Pillow打开老格式、PyMuPDF流式合成这两个要点。我唯一后悔的是没有早点设计出带增量恢复和失败跳过的脚本导致前期几次大工程的转换任务都浪费了大量等待时间。如果你手头正躺着一堆打不开的pdg老资料照着上面的脚本跑一遍大概率能直接解决问题。最后再分享一个经验转换完成并核对无误后原始的pdg目录先别急着删。虽然PDF已经能正常阅读但万一之后你想换一个分辨率更高的转换算法重新处理或者想用某个更新的OCR模型重新识别原始文件还在就是最大的底气。硬盘不差这几百兆别给自己留一个说不清是“处理完就删”还是“数据丢失”的灰色地带。