
我手头攒了三四百份PDF文档有的是网页导出的报告有的是扫描合同转出来的扫描件还有一堆论文PDF。急着要把它们统一转成纯文本拿去做中文分词和词频统计当作语料库喂给后续的文本分析脚本。刚开始用现成的转换工具一个个点点得手指发麻速度还慢得离谱更别提有些PDF转出来全是乱码和错位换行。所以干脆自己写了这个小工具窗口打开把一堆PDF拖进去点一下按钮程序自动用多进程并行解析、多线程调度任务按原文件顺序转出对应的TXT文件再做中文分词和词频合并最后汇总成一个干净的语料文本。整个过程不需要命令行不需要懂代码拖进去就能跑。这篇文章把整个设计思路、代码实现、踩坑记录都梳理一遍给有类似需求的读者一条可以直接抄的路。1. 为什么这个工具值得自己做而不是继续用现成软件先说说我为什么放着现成的转换器不用非要自己写。市面上的PDF转TXT工具其实不少但我实际用下来有几个绕不过去的痛点。第一个痛点是批处理能力弱。很多免费工具一次只能转一个文件转完还要手动检查命名、手动选择输出目录。付费工具虽然支持批量但往往按页数收费几百份PDF转下来成本不低。我需要的不是“转一个文件”而是“把一整个目录全部搞定”。第二个痛点是转换质量不可控。PDF里面既有纯文本层也有曲线绘制的矢量字还有扫描图片。不同工具的解析策略不同有的工具遇到扫描件就干脆输出空白有的工具遇到多栏排版就把段落顺序打乱有的工具遇到中文标点就乱码。自己写的好处是能针对自己的语料特征做定制什么库不行就换什么库处理不了的就单独标记出来。第三个痛点是后续处理链断裂。我的目标不是拿到TXT就完事而是要继续做分词、统计词频、清理停用词。如果每个工具都是独立软件那中间还要导出再导入格式还可能对不上。自建工具可以直接把分词和合并功能嵌进来从PDF到最终语料一步到位。当然自建有自建的门槛但这门槛比我预想的低得多。核心依赖只有三个PyMuPDF负责PDF文本提取concurrent.futures负责多进程多线程调度jieba负责中文分词。三个库都是Python生态里非常成熟的方案加起来几千行代码就能做一个相当顺手的工具。2. 技术选型PDF解析库、并发模型、分词工具怎么搭配最合理2.1 PDF解析PyMuPDFfitz为什么是首选PDF转TXT的核心环节是解析PDF内部的对象结构。我对比过pdfplumber、PyPDF2、pdfminer.six和PyMuPDF结论很明确量大优先选PyMuPDF。PyMuPDF的解析速度非常快这是它最大的优势。我在一份20页的图文混排PDF上做过对比pdfplumber大概要1.2秒pdfminer.six在3秒左右PyMuPDF只需要0.15秒上下差距接近一个数量级。多进程并行之后几百份PDF的总耗时能从原来的一小时压缩到几分钟这个性能优势直接决定了工具的可用性。它的文本提取质量也比较稳。PDF的文本存储通常按块block、行line、段span组织PyMuPDF的page.get_text(text)模式能按阅读顺序输出文本块对大多数单栏PDF效果不错。它还有一个sortTrue参数可以按坐标排序多栏文档稍微调整一下参数也能有基本可用的输出。还有一个容易被忽略的点PyMuPDF对中文编码的兼容性极好。我测试过几份字体嵌入方式很怪的中文PDFpdfplumber提取出来的是乱码PyMuPDF基本能还原。它的底层用的是MuPDF这个C库对字体映射、CMap处理这些底层细节比纯Python解析库要成熟得多。2.2 并发模型为什么同时用多进程和多线程而不只用一个这是个关键设计问题也容易搞混。很多人看到“CPU密集型任务”就直觉认为要用多进程看到“IO密集”就认为要用多线程。PDF解析恰好是两个都沾。从单个PDF内部来看解析文档结构、渲染字体映射、解压流对象主要是CPU计算但从整体任务来看要读取几百个文件、每个文件又要读取和解压内部流这里有大量的磁盘IO。而且Python的全局解释器锁GIL限制了单进程内线程对CPU的并行执行——你开10个线程如果都在做纯Python计算其实还是只能占满一个核。我采用的方案是“从进程池调度任务 内部用线程池处理IO”的混合架构主调度层使用ProcessPoolExecutor按CPU核心数创建进程。每个进程独立解析PDF这个阶段不受GIL影响可以真正用满多核。每个工作进程内部不额外开线程去解析那样没有意义但是用主线程完成解析后将结果通过进程池的future返回。文件枚举、拖放后的路径收集、状态更新、结果写入这些轻IO操作在主进程的主线程中做配合一个极轻量的队列来刷新UI。实践中这个混合架构已经是这个场景下的最优解。进程数设成cpu_count()我测试过8核机器上6份PDF同时解析CPU占用能稳定在600%以上比单进程快6.5倍左右。如果只用多线程撑死快1.2倍。2.3 分词jieba的精确模式加自定义词表中文分词我选jieba这个不用太多解释社区生态成熟、文档全、开箱即用。对这个项目来说关键是两种用法的选择。我习惯用jieba.lcut精确模式它会把句子切成最小粒度的词适合语料清洗和词频统计。jieba.cut默认也是精确模式但返回的是生成器lcut一次性返回列表后续统计更方便。如果语料有明显的专项术语比如代码关键词、专有名词、人名一定要维护自定义词表。我在处理一批编程相关的PDF时Python被分成了“Python”一个词还好但像“TensorFlow”“多进程”这种词默认词库里有时会切开。用jieba.load_userdict加载一个自定义词典把这些词提前告诉分词器分词质量会明显改善。停用词过滤也必不可少。中文里“的、了、和、是、在”这类虚词出现频率极高如果不过滤词频统计结果会被这些功能词淹没。我准备了一个常见中文停用词表分词后直接做一次过滤。3. 拖放界面的实现思路以及为什么没有选Web界面3.1 tkinterdnd2让文件拖放变得顺手这个工具的使用场景是“我打开窗口把文件拖进去点按钮”所以界面不需要花哨但拖放交互必须有。Python自带的tkinter不支持系统级拖放需要配合tkinterdnd2这个第三方库。安装方式很简单pip install tkinterdnd2核心代码非常短import tkinter as tk from tkinterdnd2 import DND_FILES, TkinterDnD from tkinter import scrolledtext, filedialog class PdfDropApp: def __init__(self, root): self.root root self.root.title(PDF批量转TXT-多进程分词合并工具) self.root.geometry(640x420) self.file_list [] # 文本区域同时作为拖放目标 self.txt_area scrolledtext.ScrolledText( self.root, wraptk.WORD, font(Microsoft YaHei, 10) ) self.txt_area.pack(filltk.BOTH, expandTrue, padx8, pady8) # 注册拖放 self.txt_area.drop_target_register(DND_FILES) self.txt_area.dnd_bind(Drop, self.handle_drop) # 按钮区域 btn_frame tk.Frame(self.root) btn_frame.pack(filltk.X, padx8, pady4) tk.Button(btn_frame, text添加PDF文件, commandself.add_files).pack(sidetk.LEFT) tk.Button(btn_frame, text开始转换, commandself.start_convert).pack(sidetk.LEFT, padx6) tk.Button(btn_frame, text清空列表, commandself.clear_list).pack(sidetk.LEFT) def handle_drop(self, event): # tkinterdnd2返回的文件路径可能带花括号要处理一下 raw event.data files self.root.tk.splitlist(raw) for f in files: if f.lower().endswith(.pdf): self.file_list.append(f) self.txt_area.insert(tk.END, f \n) self.txt_area.insert(tk.END, f\n当前共 {len(self.file_list)} 个PDF文件\n)这里有个容易踩的坑拖放事件返回的字符串可能带大括号。比如路径包含空格时tkinterdnd2会返回{C:/Users/My Documents/a.pdf}这种格式。直接split()会得到错误的结果必须用root.tk.splitlist(raw)来解包tkinter内部会正确处理花括号和转义。3.2 不用Web界面的原因我也考虑过用Flask或FastAPI做一个本地Web服务浏览器拖放文件上传后台异步处理。听起来更现代但实际用下来有几个问题一是每次使用都要先启动服务再开浏览器再拖文件流程长。桌面工具双击即用对非技术用户更友好。二是文件路径传递麻烦。Web上传会把文件复制到临时目录处理完还要考虑临时文件清理。桌面工具直接传原始路径文件原地读取、原地输出没有文件复制开销。三是Web界面要处理并发上传、任务队列、进度推送开发复杂度高出一截。而这个工具的核心价值在解析和并发UI保持薄薄一层就好。4. 多进程解析管线的实现细节与顺序保持策略4.1 进程池框架ProcessPoolExecutor的用法多进程部分我直接用concurrent.futures.ProcessPoolExecutor不用手动管理进程池。它对任务提交、结果回收、异常处理都封装好了且可以配合as_completed迭代结果。先定义最核心的解析函数。它接收一个PDF路径返回该文件提取出的文本。这个函数必须在模块顶层定义因为多进程在Windows下使用spawn方式创建进程子进程需要能够导入这个函数如果是嵌套函数子进程无法序列化会直接报错。import fitz # PyMuPDF def extract_text_from_pdf(pdf_path: str, password: str None) - dict: 从PDF中提取全部文本返回包含元信息的字典。 这是进程池的工作单元必须在模块顶层定义。 result { path: pdf_path, text: , page_count: 0, error: None, } try: doc fitz.open(pdf_path) if password: doc.authenticate(password) page_count doc.page_count parts [] for page in doc: parts.append(page.get_text(text, sortTrue)) doc.close() result[text] \n.join(parts) result[page_count] page_count except Exception as e: result[error] f{type(e).__name__}: {e} return result主进程用一个字典存储所有future对应的原顺序索引然后用as_completed逐个取结果并写回对应位置。这样既能在等待时不阻塞UI线程又能保证最终输出顺序和输入顺序一致。from concurrent.futures import ProcessPoolExecutor, as_completed import os def process_pdfs_parallel(pdf_paths, num_workersNone): if num_workers is None: num_workers max(1, os.cpu_count() - 1) results_map {} with ProcessPoolExecutor(max_workersnum_workers) as executor: # 提交任务记录索引 future_to_idx { executor.submit(extract_text_from_pdf, path): idx for idx, path in enumerate(pdf_paths) } # 按完成顺序接收但写入按原索引 for future in as_completed(future_to_idx): idx future_to_idx[future] result future.result() results_map[idx] result # 按原顺序组装 ordered_results [results_map[i] for i in range(len(pdf_paths))] return ordered_results有一个细节值得注意ProcessPoolExecutor内部会把pdf_path、password这些参数序列化后传给子进程。如果路径是普通字符串没问题。但如果路径里包含非ASCII字符常见的中文文件名有的Python版本在Windows默认编码下会报编码错误。解决方案是确保Python 3.7以上并在脚本开头设置PYTHONUTF81环境变量或者在代码里用pathlib.Path统一管理路径不要用字符串拼接。4.2 单文件多页时的线程级并行要不要做很多读者可能关心既然PDF有很多页能不能进一步把一个文件里的每一页分配到不同线程解析理论可行但要谨慎。PyMuPDF解析一个页面时底层做的是大量C调用这些调用在单进程内是可以释放GIL的所以理论上多线程提取不同页面可以并行。但实测情况下页面级解析涉及的Python对象创建和内存管理开销很大多线程页级并行通常只比单线程快10%-30%而且代码复杂度急剧上升——你要处理页面顺序、局部崩溃恢复、内存占用飙升。我的建议是文件数量多的时候做“文件级多进程”就够了单文件内部串行提取。只有当某个PDF是超大型文档比如几百页的技术手册且只有这一个文件时才考虑页级并行。因此这个版本的工具保持文件级多进程不做页级线程。4.3 多线程在哪里出场任务分发与UI刷新前文说这个工具是“多进程多线程合并版”多线程的具体位置在任务分发和UI状态刷新。主界面启动转换时不能把多进程调度直接放在tkinter的主线程里否则界面会冻结用户以为程序死了。处理方式把多进程调度放到一个后台线程后台线程完成一批任务后通过线程安全的队列把进度信息推给主线程主线程用after轮询队列并刷新界面。import queue import threading class ConvertWorker(threading.Thread): def __init__(self, pdf_paths, output_dir, msg_queue): super().__init__(daemonTrue) self.pdf_paths pdf_paths self.output_dir output_dir self.msg_queue msg_queue def run(self): self.msg_queue.put((status, f开始处理 {len(self.pdf_paths)} 个文件...)) results process_pdfs_parallel(self.pdf_paths) # 分批写入并及时更新状态 for i, res in enumerate(results): if res[error]: self.msg_queue.put((error, f{res[path]}: {res[error]})) continue out_txt_path os.path.join( self.output_dir, os.path.splitext(os.path.basename(res[path]))[0] .txt ) with open(out_txt_path, w, encodingutf-8) as fp: fp.write(res[text]) self.msg_queue.put((done, f[{i1}/{len(results)}] 已转换: {res[path]})) self.msg_queue.put((status, 全部转换完成))主线程的更新逻辑def poll_queue(self): try: while True: kind, msg self.msg_queue.get_nowait() if kind status: self.txt_area.insert(tk.END, f\n{msg}\n) elif kind done: self.txt_area.insert(tk.END, f{msg}\n) elif kind error: self.txt_area.insert(tk.END, f[错误] {msg}\n) self.txt_area.see(tk.END) except queue.Empty: pass self.root.after(200, self.poll_queue)这里的关键点是threading.Thread只负责调用多进程调度和返回结果进程池里跑的是真正的解析工作。多线程解决的是“不要让用户等待时看到假死界面”的问题多进程解决的是“让CPU多用几个核”的问题。两者不在同一层不冲突是互补关系。5. 合并与分词把几百个TXT汇总成一份干净语料的完整链路5.1 合并策略按输入顺序拼接而不是按处理顺序转换完成后工具会生成两种产物每个PDF对应的独立TXT文件保留原始目录结构或平铺到输出目录一个合并后的总TXT文件用于后续分词合并时的顺序策略必须强调一下不能用并发处理的完成顺序作为合并顺序那样输出顺序是随机的很大概率乱。要实现“按输入顺序合并”最简单的办法是在生成结果字典时带上索引组装顺序列表后再写入总文件。def merge_txt_files(ordered_results, output_dir, merge_path): with open(merge_path, w, encodingutf-8) as out_fp: for i, res in enumerate(ordered_results): if res[error]: continue out_fp.write(f\n 文档 {i1}: {os.path.basename(res[path])} \n) out_fp.write(res[text]) out_fp.write(\n) return merge_path每份文档之间加一个分隔标记行既保留了来源信息后续也可以用正则切分回单篇文档。这个设计在做语料清洗时特别有用。5.2 分词语料生成jieba分词、停用词过滤、词频统计三合一合并文件生成之后就是分词环节。这个地方我设计了一个可选的“分词模式”默认只生成合并TXT勾选“同时生成分词语料”后才进行jieba分词。整个分词链路分成四步import jieba import jieba.analyse from collections import Counter # 加载自定义词典和停用词表 jieba.load_userdict(userdict.txt) STOP_WORDS set() with open(stopwords.txt, r, encodingutf-8) as fp: for line in fp: STOP_WORDS.add(line.strip()) def segment_corpus(text: str, top_k200): words jieba.lcut(text) # 过滤停用词、单字、纯空白、纯数字 filtered [ w.strip() for w in words if w.strip() and w.strip() not in STOP_WORDS and len(w.strip()) 1 and not w.strip().isdigit() ] counter Counter(filtered) return counter.most_common(top_k)关于停用词表和自定义词典我分享一个经验网上能找到的通用停用词表文本文件通常几千行覆盖常用虚词足够了。但每个领域有自己的噪音词比如做技术文档分析时“实现”“文档”“说明”这类词虽然不算虚词但对词频统计贡献不大。我建议第一批跑完统计后人工扫一遍高频词表把不想要的词追加到停用词表里再跑一轮效果立竿见影。jieba的lcut还有一个可以调节的参数cut_all默认False是精确模式。不建议开cut_all全模式它会把“北京大学”切成“北京/大学/北京大学”三种形式对词频统计来说噪音太大。如果你更关注短语和关键词提取可以考虑用jieba.analyse.extract_tags它会基于TF-IDF算法返回关键词而不是全量分词结果。5.3 输出目录结构和文件命名的细节输出目录我建议这样组织output/ ├── txt/ # 单篇TXT │ ├── 文档1.pdf.txt │ ├── 文档2.pdf.txt │ └── ... ├── merged.txt # 合并文本 ├── segmented.txt # 分词语料每行一个词 └── word_frequency.csv # 词频统计表单篇TXT命名保持“原名.pdf.txt”而不是“原名.txt”的最大好处是和原始PDF对应关系一目了然也避免和目标目录里已有文件重名。Windows文件名里不能有/\:*?|这些字符PDF文件名通常不会有但保险起见写文件前做一次清洗。word_frequency.csv用标准CSV格式存储方便Excel打开。这里面我用csv模块而不是手动拼接逗号因为中文词里偶尔会有逗号手动拼接会破表。import csv def write_frequency_csv(word_freq, csv_path): with open(csv_path, w, encodingutf-8-sig, newline) as fp: writer csv.writer(fp) writer.writerow([词语, 次数]) for word, count in word_freq: writer.writerow([word, count])这里用utf-8-sig而不是utf-8也是一个小坑Excel打开UTF-8无BOM的CSV时中文会乱码加上BOM后Excel能正确识别。这个细节如果不处理用户大概率会以为是程序写坏了。6. 实测数据与不同PDF类型的处理表现6.1 三组实测样本的耗时与准确率我在自己的机器上8核16线程Windows 11Python 3.10用三组不同特征的PDF做了测试。第一批是100份网页导出的图文报告每份约10页文本层完整几乎全是单栏排版。用我写的工具转码全量转换耗时约35秒单进程版本约220秒加速比约6.3倍。输出文本的段落在90%的情况下和PDF原文一致剩下10%是表格和页眉页脚混入正文但整体可用于词频分析。第二批是5份扫描版PDF分辨率从150dpi到300dpi不等。PyMuPDF直接提取文本结果全是空白。这不是工具的问题而是扫描版PDF本身没有文本层。遇到这类文档必须先用OCR这个场景我是在工具里做了一个检测逻辑如果某份PDF全部页面提取出来的文本长度低于阈值比如每页少于10个字符就判定为扫描件在结果中标记为“需要OCR”。第三批是3份双栏排版的学术论文PDF。page.get_text(text, sortTrue)按坐标从上到下、从左到右排序双栏时会先输出左栏再右栏但如果页面中有跨栏的大标题或图注顺序还是会乱。这类文档建议改用get_text(blocks)并手工按列坐标分组但这会显著增加代码复杂度。我目前的处理方式是直接接受顺序因为词频统计对局部顺序不敏感词袋模型不在乎语序。6.2 加速比的实际规律不是文件越多越快是有上限的测试结果中最反直觉的一点是加速比并不随文件数线性增长。100份文件时加速比6.3倍但10份文件时加速比只有3倍左右。原因有两个第一进程池有启动开销。每创建一个进程需要几十毫秒加上导入fitz、jieba这些库的时间这个固定开销在文件数量少时占比很大。第二磁盘IO成为瓶颈。文件数量多了以后所有进程同时读PDF磁盘的寻道和带宽被占满解析CPU再快也等IO。所以如果你的机器是四核且只有十几个文件多进程的收益没那么明显但几百个文件时多进程是绝对必要的手段。6.3 多进程内存占用的实测数值PyMuPDF解析一个大PDF时内存占用并不低我测试过一份200页的图文PDF单进程解析时峰值内存约300MB。如果开7个进程同时解析内存峰值直逼2.2GB。一般家用电脑8GB内存勉强够16GB比较从容。如果遇到内存紧张的情况可以调整两个参数进程数从cpu_count() - 1降为max(2, cpu_count() // 2)在extract_text_from_pdf中每处理完一页显式清理页面对象PyMuPDF的page对象会持有缓冲及时清除引用能帮助GC释放实测用一半核心数处理同一批文件耗时只增加了约20%但内存峰值下降了一半左右。对大体积PDF为主的工作负载来说这个取舍很划算。7. 真实项目里绕不开的坑从乱码到假死再到文件占用7.1 拖放无反应大概率是tkinterdnd2的初始化问题tkinterdnd2的使用有严格的初始化顺序。根窗口必须使用TkinterDnD.Tk()而不能是tk.Tk()root TkinterDnD.Tk()如果你不小心用了普通的tk.Tk()拖放事件永远不会触发界面也不会报错排查起来非常恼火。另外drop_target_register(DND_FILES)必须在窗口构建阶段调用窗口显示之后再注册有时会失败。7.2 转换进行中界面卡死必须让耗时操作远离主线程界面卡死这个坑我在第一版就踩过。最初我在“开始转换”按钮的回调里直接调process_pdfs_parallel()结果点击按钮后窗口变成“未响应”直到所有文件处理完才恢复。Windows的窗口管理器最多等几秒如果长时间不响应就弹“程序已停止工作”。解决方案就是我前面写的用后台线程跑进程池调度用队列和after轮询更新UI。这里有一个额外的注意事项ProcessPoolExecutor的with块退出时会等待所有任务完成并释放进程池所以即使在后台线程里调用也必须等全部任务结束。如果中途想取消需要额外实现任务超时和进程池强制终止逻辑这会让代码复杂度上升不少。我的处理方式是允许中途关闭窗口强制退出进程但不支持在转换中途停止因为停止逻辑的边角情况太多。7.3 输出文件被占用导致写入失败Windows下容易遇到一个现象转换过程中弹窗报错“另一个程序正在使用此文件”。最常见的原因是输出目录在OneDrive、Dropbox或桌面同步目录下这些同步软件会实时监控并锁定新生成的文件。解决办法很简单默认输出目录不要放在同步目录下我通常是让用户选择输出位置并在代码里检测输出目录是否包含OneDrive、Dropbox等关键词是的话就弹个确认提示。另外处理完一个文件后主动关闭文件句柄写代码时用with open(...)而不是裸open()也可以减少文件锁持续时间。7.4 中文文件名和路径的编码问题Windows下Python 3.10之前的版本在某些场景下处理中文路径会出问题。我的脚本运行时用UTF-8模式启动python -X utf8 pdf_converter.py或者更省事一点在脚本最开头加入import sys if sys.platform win32: import locale # 强制使用UTF-8编码处理文件名 sys.stdout.reconfigure(encodingutf-8)但从Python 3.15之后默认就是UTF-8模式了这个问题会慢慢淡出视野。对新手来说最直接的避坑方式是把所有待处理的PDF集中到一个路径不含中文和空格的工程目录下处理完再移动到最终位置。8. 版本演进的思考从命令行工具到拖放GUI的迭代过程最后聊聊这个工具是怎么一步步变成现在这样的这部分内容对想自己动手做类似工具的人也许最有启发。最初的版本是一个非常简单的脚本指定输入目录和输出目录然后遍历目录里所有PDF逐个用PyMuPDF提取文本写到同名TXT里。大约60行代码能满足基本需求但问题是启动一次要改代码里的路径跑一次要等很久很不方便。第二个版本加入了多进程用ProcessPoolExecutor替换了循环调用处理速度提上来了。但命令行参数交互对不擅长命令行的同事不友好而且他们经常忘记参数顺序传错路径后程序直接崩溃。第三个版本加入了tkinter界面用按钮选择目录用文本框展示日志解决了路径交互问题。但需要先选目录再看结果流程还是不够顺畅。直到加入tkinterdnd2的拖放功能这个工具才算真正好用——直接把文件从资源管理器拖进窗口所有路径自动收集点一下按钮集中处理。第四个版本也就是当前版本补上了分词和合并输出。这一步的驱动力来自实际需求我拿到TXT之后还要做jieba分词为什么不直接集成进来呢字频统计结果当时就可以看不满意的话调整停用词表后直接重新跑分词不用再在文件之间跳来跳去。从这段演进可以看出工具设计没有一步到位的方案最有效的驱动方式是不断用自己的工作流去虐它——哪个环节用着别扭就改哪里。也许下一个版本我会加入OCR支持对扫描件自动调用PaddleOCR、更细粒度的双栏顺序修正或者把词频统计可视化直接嵌到界面里。这些需求都是从真实使用中冒出来的不是凭空设计出来的。如果你照这个思路做一个自己的PDF处理工具我的建议很简单先从最朴素的功能开始投入使用的第一天就记录哪里不好用然后一项一项改进。工具自己用得顺手再考虑分享给别人这才是最务实的开发路线。