1. 项目背景与整体设计思路
1.1 为什么办公文件解析绕不开Python
做后端开发这些年,Excel、Word、PDF这三类文件几乎是每个业务系统都躲不开的坎。客户那边一句"帮我导个报表"、"把这份合同附件存一下"、"生成个PDF发给客户",落到代码里就是一整套解析、处理、预览、下载的链路。
我这次接到的小项目很典型:需要把用户上传的 Excel、Word、PDF 文件做解析,提取内容做结构化存储,同时还要支持在浏览器里预览,以及按需下载原文件或转换后的文件。说白了,就是一个办公文件的"中转站+加工厂"。整个需求不复杂,但是细节非常多。Python 在这类场景里优势很明确:生态里有专门处理每种文件格式的成熟库,写起来快,遇到问题也好搜,团队里其他同事接手也容易读懂。
先说结论:Excel 用 openpyxl,Word 用 python-docx,PDF 用 pdfplumber(扫描版再挂 OCR)。这套组合在绝大多数业务场景下够用,而且都是纯 Python 实现,部署不需要额外装 Office 环境,这就把很多运维层面的麻烦提前规避掉了。
1.2 核心工具库选型对比
很多初学者一上来先问"哪个库最强",其实选库的关键是看你要处理的是哪种文件、处理到什么程度。我把常见的候选库整理了一张对比表,方便大家按图索骥。
| 文件类型 | 推荐库 | 擅长场景 | 不擅长的场景 |
|---|---|---|---|
| Excel | openpyxl | xlsx 读写、样式处理、公式读取 | xls 老格式(需 xlrd 配合) |
| Excel | pandas | 批量数据处理、行列表格操作 | 保留原有格式、写入样式 |
| Word | python-docx | docx 段落、表格、样式操作 | doc 老格式、复杂排版精准还原 |
| pdfplumber | 文字抽取、表格还原 | 扫描版图片型 PDF | |
| PyPDF2 / pypdf | 合并拆分、元数据读写 | 中文文本抽取准确率一般 | |
| pdf2image | PDF 转图片做预览 | 图片质量受原始清晰度影响大 |
我的选择逻辑很简单:不要用 pandas 去处理"需要保留原样式的 Excel",不要用 PyPDF2 去抽"包含复杂表格的 PDF",每种库都有它的边界,认清边界能少踩很多坑。
另外提一句,如果项目只需要"读取 Excel 里的数值做计算",pandas 确实快;但如果你要"把结果写回 Excel,还要保留原来的边框、配色、列宽",pandas 就很吃力,这时候 openpyxl 才是正解。Word 也一样,python-docx 对段落的控制力远强于"先转成 HTML 再改"这种思路。
2. Excel 解析与处理的完整实践
2.1 openpyxl 读取 Excel 的正确姿势
openpyxl 读 xlsx 文件,最基础的是load_workbook,但很多人第一步就走偏了。默认的load_workbook会把公式结果也带上,这在某些场景下反而有问题。我实际开发中会这样处理:
from openpyxl import load_workbook # 只读模式,大文件加载更快,同时保留公式(不计算结果) wb = load_workbook("销售数据.xlsx", read_only=True, data_only=False) sheet = wb["Sheet1"] # 遍历所有行 for row in sheet.iter_rows(values_only=False): for cell in row: if cell.value is not None: print(cell.coordinate, cell.value) wb.close()这里有两个关键选择值得说明。
read_only=True适合大文件。之前接过一个几千行的收入表,正常模式加载要好几秒,只读模式基本秒开。如果文件还要写回,就不能用只读模式,这点要记住。
data_only=False是保留公式原文,如果你想拿公式计算后的结果,要改成data_only=True。但这里有个巨坑:data_only=True只有在文件被 Excel 或 LibreOffice 保存过一次之后,缓存里才有计算值。如果用代码直接生成的 xlsx 再读取,计算值可能是None。这个我踩过不止一次,排查到最后才发现是缓存问题。
实际开发里我还会顺手封装一个通用读取函数,处理合并单元格、空行、表头映射这些琐碎问题:
def read_excel_2_dict(path, sheet_name=None): wb = load_workbook(path, read_only=True, data_only=True) ws = wb[sheet_name] if sheet_name else wb.active rows = ws.iter_rows(values_only=True) headers = [str(h).strip() if h else "" for h in next(rows)] result = [] for row in rows: if all(cell is None for cell in row): continue result.append(dict(zip(headers, row))) wb.close() return result把每行转成字典,后面处理逻辑写起来会舒服很多。注意空行过滤,很多运营同事导出的数据里夹杂大量全空行,不处理的话下游调用方会被搞疯。
2.2 数据清洗与多表拼接
解析完数据只是第一步,真正花时间的是清洗。Excel 里的数据可以说是"什么鬼样子都有":数字存成了文本、日期格式五花八门、金额带上了千分位逗号、单元格里混着换行符。
这个项目里我做了一个清洗函数,专治这类问题:
def clean_cell(value): if value is None: return "" if isinstance(value, str): value = value.replace("\n", "").replace("\r", "").strip() value = value.replace(",", "").replace("元", "") if isinstance(value, (int, float)): # 处理 float 精度问题 return round(value, 2) return value多表拼接也是高频需求。比如用户上传了 12 个月的分表,要合成年度数据。我会先用glob把这批文件全部读出来,再用pandas.concat合并,最后用 openpyxl 写回一个汇总表。这里有个经验:读数据用 pandas 很高效,但写回不要用 pandas 的to_excel,因为它的格式控制能力很弱。正确的做法是先用 pandas 整理完数据,再通过openpyxl逐个单元格写入,这样可以精确控制表头样式、列宽和冻结窗格。
import glob import pandas as pd from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment from openpyxl.utils import get_column_letter frames = [] for f in glob.glob("data/*.xlsx"): df = pd.read_excel(f) frames.append(df) merged = pd.concat(frames, ignore_index=True) # 用 openpyxl 写回,保留样式控制 wb = Workbook() ws = wb.active ws.append(list(merged.columns)) # 表头样式 for cell in ws[1]: cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill(start_color="4F81BD", end_color="4F81BD", fill_type="solid") cell.alignment = Alignment(horizontal="center") # 写数据行 for row in merged.itertuples(index=False): ws.append(list(row)) # 自适应列宽(中文场景需要乘以系数) for col_idx, col_name in enumerate(merged.columns, 1): max_len = max(merged[col_name].astype(str).map(len).max(), len(str(col_name))) * 1.2 ws.column_dimensions[get_column_letter(col_idx)].width = min(max_len + 2, 30) wb.save("年度汇总.xlsx")列宽处理是个容易忽略的细节。西文字符宽度和中文不一样,直接按字符数设置列宽,中文表头很容易被截断,所以乘一个 1.2 的系数是我试出来的经验值。
2.3 Excel 模板填充与批量生成报表
实际项目里,用户更常见的诉求不是让你"从头画一个 Excel",而是"我有一个公司的模板,你往里面填数据"。这个需求本质上是"模板渲染"。
我做的这个功能,后台维护了几个模板文件,每个模板里有固定的表头、公司 Logo、品牌色,甚至还有一行行的提示文字。程序要做的就是把参数填充到指定位置。
openpyxl 支持通过单元格坐标或者命名区域定位:
from openpyxl import load_workbook wb = load_workbook("模板.xlsx") ws = wb["销售报表"] # 已知模板结构:A1是标题,B3是日期,B4起是数据区 ws["A1"] = f"{year}年度销售报表" ws["B3"] = report_date start_row = 4 for i, item in enumerate(data_list): ws.cell(row=start_row + i, column=1, value=item["片区"]) ws.cell(row=start_row + i, column=2, value=item["销售额"]) ws.cell(row=start_row + i, column=3, value=item["同比"]) wb.save("报表输出.xlsx")这种方式的优点是完全保留模板的样式,生成的文件拿出去就可以直接给领导看。缺点也很明显:模板一旦被调整了结构,代码里的行列坐标就全乱套。所以模板必须锁定保护,然后给所有需要填写的单元格设置好"名称",代码里通过名称定位,这样即使别人在中间插了几行,只要名称没乱,程序不会崩。
有个小细节,如果单元格里本身带着公式,比如合计行是=SUM(B4:B10),你往里面填数据的时候要注意行号变化。更稳妥的方案是把合计行也做成代码计算后写入,而不是依赖模板公式。因为 openpyxl 写入的公式不会自动计算出结果缓存,用户用 Excel 打开时虽然能看到结果,但程序自己读这个文件时data_only=True会读出None。
3. Word 文档的解析与生成
3.1 用 python-docx 读懂 Word 的结构
Word 的 docx 格式和 xlsx 一样,本质上是一个 zip 压缩包,里面是 XML 文件。python-docx 做的事情就是把这些 XML 暴露成段落、表格、样式等对象。
读取段落比较直接:
from docx import Document doc = Document("合同.docx") for para in doc.paragraphs: if para.text.strip(): print(f"【{para.style.name}】 {para.text}")但实际业务里,Word 里的信息往往不止在段落里,还遍布在表格、页眉页脚、批注里。尤其是合同或标书类文档,正文用表格排版的情况非常多。python-docx 读表格的接口是doc.tables,每个 table 对象有rows和columns:
for table in doc.tables: for row in table.rows: for cell in row.cells: # 注意:合并单元格时 cell 会重复出现 print(cell.text)这里有个坑,合并单元格会导致cell对象在多个行列位置上重复出现。如果你按坐标去取值,同一个单元格会被打印多次。处理方式是去重,或者干脆先把所有 cell 文本放到一个 list 里,再转成集合。
Word 自动化处理场景里还有一个很常见的问题:用户发来一个 PDF,说"帮我把里面内容提取出来"。这种我通常先用 pdf 库抽文本,再组装成 docx。后面会展开讲。
3.2 占位符替换与样式还原
Word 模板填充是另一个高频需求。比如公司统一格式的说明函,只有客户名称和日期不同。传统做法是把模板另存为一份新的,再把变动的地方改掉。
python-docx 对段落的文本替换可以直接操作run对象:
from docx import Document doc = Document("函件模板.docx") def replace_placeholder(paragraph, old, new): for run in paragraph.runs: if old in run.text: run.text = run.text.replace(old, new)但问题来了:Word 的段落里,文字可能被拆成多个run。比如"【客户名称】"这几个字,可能第一个 run 是"【客",第二个是"户名称】,如果你直接遍历 run 找完整的占位符,往往会失败。
我在项目里用的方法是先把同段落的 run 文本合并起来替换,再写回:
def replace_placeholder_merge_runs(paragraph, mapping): full_text = "".join(run.text for run in paragraph.runs) for old, new in mapping.items(): full_text = full_text.replace(old, new) if len(paragraph.runs) > 0: # 全部写进第一个 run,其余清空 paragraph.runs[0].text = full_text for run in paragraph.runs[1:]: run.text = ""这样操作简单粗暴,但会丢失个别 run 上的特殊格式(比如下划线、颜色)。如果模板比较规整,占位符本身格式统一,这个方案完全够用。如果占位符是分散在多个 run 里的,你也只能这样处理。
另外强烈建议:模板里的占位符不要用花括号{},因为 Word 的自动更正可能会把它改掉,而且调试的时候难以区分。用【】这种全角括号在 Word 里体验最稳定。
3.3 从结构化数据生成 Word 报告
还有一个场景是从数据库或者其他系统拿到结构化数据,要批量生成一份份 Word 报告。这个项目里我实现了一个"月度经营分析报告"的生成逻辑,结构上分三个块:封面信息、正文段落、数据表格。
from docx import Document from docx.shared import Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH doc = Document() # 设置正文默认字体(中文场景关键步骤) style = doc.styles["Normal"] style.font.name = "Calibri" style.font.size = Pt(11) # 中文字体必须通过元素设置,否则默认为宋体且不生效于中文字符 style.element.rPr.rFonts.set(qn("w:eastAsia"), "微软雅黑") # 标题 title = doc.add_heading(level=0) title.alignment = WD_ALIGN_PARAGRAPH.CENTER run = title.add_run("2024年度经营分析报告") run.font.name = "微软雅黑" run.font.size = Pt(18) # 添加表格 table = doc.add_table(rows=1, cols=4) table.style = "Light Grid Accent 1" hdr = table.rows[0].cells hdr[0].text = "指标" hdr[1].text = "一季度" hdr[2].text = "二季度" hdr[3].text = "同比变化"这里不得不提醒一个几乎所有 python-docx 新手都会踩的坑:中文字体设置。font.name = "微软雅黑"只设置了西文字体,中文标题和正文仍然会用宋体。必须通过rFonts的w:eastAsia属性手动指定中文字体,代码里的qn("w:eastAsia")就是干这个的。不设置的话,生成的 docx 在自己电脑上打开是正常字体,发给同事一打开就变成默认宋体,格式一下就垮了。
段落间距和行距也是"报告观感"的关键。Word 默认段落间距偏小,我习惯统一设置:
from docx.shared import Pt for paragraph in doc.paragraphs: paragraph.paragraph_format.line_spacing = 1.5 paragraph.paragraph_format.space_after = Pt(6)这些细节不会影响功能,但会影响别人对你代码产物的第一印象。给领导汇报的东西,格式就是门面,不能丢。
4. PDF 解析与预览处理
4.1 pdfplumber 抽取文本和表格
PDF 在这个项目里三合一的角色:有时候是数据源(要解析里面的内容),有时候是展示层(网页预览直接上 PDF),有时候是出口(把数据导出成 PDF 给用户)。
解析 PDF 文本,我用的是 pdfplumber。它的底层是 pdfminer.six,但对外 API 友好很多,尤其是表格抽取能力,比直接用 pdfminer 省一大半力气。
import pdfplumber with pdfplumber.open("年报.pdf") as pdf: page = pdf.pages[5] # 取第6页(索引从0开始) text = page.extract_text() table = page.extract_table()extract_text的性能在纯文本型 PDF 上表现很好,中文没有明显乱码。但它的输出是按"行"组织的,遇到多栏排版会错乱,这是 pdfplumber 的固有缺陷。多栏 PDF 需要额外设置columns参数,或者手动裁切页面区域。
表格抽取同样有讲究。extract_table默认按页面上检测到的横线竖线来切表,但很多 PDF 里的表格没有明显的边框线,或者线是"画"出来的且颜色极浅。这种情况下我建议先看下表格是否可以用extract_text加正则去解析,反而更稳。举例,账单类 PDF 的行结构很规律,文本抽取加正则比依赖表格线靠谱得多。
PDF 里还有一类信息藏在"表单字段"里。比如政府网站下载的申请表格,内容是 AcroForm 字段。pdfplumber 读不了字段值,这时要用pypdf的get_fields()方法单独处理。
4.2 扫描版 PDF 的 OCR 处理思路
扫描版 PDF 本质是一堆图片,没有文本层,pdfplumberextract_text返回的是空字符串或零散乱码。这种文件要做解析,只能走 OCR 路线。
我的处理链路是:PDF 转图片(pdf2image),再用 Tesseract 或者 PaddleOCR 识别文字。
from pdf2image import convert_from_path images = convert_from_path("扫描件.pdf", dpi=300) for i, img in enumerate(images): img.save(f"page_{i}.png", "PNG")300 DPI 是经验值,低于这个准确率下降,高于这个文件体积暴涨但识别提升有限。如果原扫描件文字本来就清晰,200 DPI 就够,还能快不少。
OCR 我推荐 PaddleOCR,中文识别准确率比 Tesseract 好很多,尤其是印刷体和手写体混排的场景。它的调用方式也比较统一:
from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang="ch") result = ocr.ocr("page_0.png", cls=True) for line in result: print(line[1][0])OCR 出来的结果是带坐标的,这很关键。比如想提取"发票号"后面的数字,不能只按文本顺序找,还得结合坐标判断"发票号"和后面的数字是否在同一行。我通常的做法是拿到识别结果后,按行分组,再对每一行做正则匹配。
需要说明的是,扫描版 PDF 的解析永远做不到 100% 准确,业务上要留人工复核的入口。如果上游能提供电子版,坚决不要走 OCR,这是在项目立项时就要跟需求方说清楚的边界。
4.3 PDF 转图片做在线预览
网页端预览 PDF,最省事的方案是直接让浏览器渲染 PDF。Chrome、Edge、Firefox 都内置了 PDF 查看器,一个<iframe>就搞定。但坑在于不同浏览器渲染效果不一致,而且手机端体验很差,缩放、翻页都不太跟手。
这个项目我为了统一体验,选择了"后端转图片,前端看图"的方案。流程是:用户上传 PDF → 后端用pdf2image把每页渲染成 PNG → 前端用图片懒加载实现类似电子书的翻页效果。
def pdf_to_preview_images(pdf_path, output_dir, max_pages=20): images = convert_from_path(pdf_path, dpi=120) result = [] for i, img in enumerate(images[:max_pages]): out_path = f"{output_dir}/preview_{i:03d}.png" img.save(out_path, "PNG") result.append(out_path) return result120 DPI 对应的是 1440 像素宽左右,在普通屏幕上放大到全屏也不会虚。超过 20 页的 PDF 我只转前 20 页,避免后端内存爆掉。如果你的业务场景真的是长文档,应该考虑流式加载,只渲染当前页和前后两页,用完就释放。
还有一个小技巧:生成预览图之前,可以先用pypdf检查页数,如果文件超过某个阈值就直接提示"文件过大,请下载后查看",而不是硬着头皮转图。这种自我保护在真实项目里非常有用。
5. 预览与下载的完整实现
5.1 文件预览的三种方案对比
文件预览是整个需求里最容易被低估的部分。很多人以为"能打开就行",实际上预览的流畅度直接决定了用户对这个系统的印象分。我做的这个项目里,针对不同文件类型用了三种方案:
| 文件类型 | 预览方案 | 优势 | 劣势 |
|---|---|---|---|
| iframe 直接渲染(大文件做图片替代) | 实现简单,支持缩放搜索 | 手机端体验差 | |
| Excel | 后端转成 HTML 表格,前端渲染 | 体积小、加载快 | 公式、图表会丢失 |
| Word | 转 PDF 再预览 或 转 HTML | 相似度高 | 转换耗时,样式有偏差 |
Excel 转 HTML 我用的还是 openpyxl,读取每个单元格的值和样式,直接生成<table>标签。这套方案的优点是数据量小,几万行的 Excel 也能预览;缺点是公式不会计算,而且如果原表里有图表、数据透视表,这些内容都会被丢掉,只能作为"数据预览",不能作为"完整预览"。
Word 转 PDF 我用过docx2pdf,这个库本机用没问题,但它依赖 Microsoft Word 或者 LibreOffice,在服务器环境下跑需要额外安装系统组件。如果不想在服务器上装这么多东西,可以退而求其次:后台把 docx 转成 HTML 字符串,前端渲染 HTML。python-docx 可以读取段落和表格的结构,配合document_to_html逻辑,能还原 80% 的视觉效果。
5.2 文件下载与格式转换链路
下载功能看起来简单,就是返回一个文件流,但实际做起来有几个细节不能马虎。
第一个细节是文件下载的响应头,尤其是中文文件名。Flask 或 FastAPI 里直接设置Content-Disposition,中文字段可能会乱:
from urllib.parse import quote filename = "销售报表2024.xlsx" encoded = quote(filename) headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}第二个细节是"下载格式要和上传格式不同"。最常见的需求是"Excel 转 PDF"、"Word 转 PDF"。Excel 转 PDF 如果不想装 Office,可以用LibreOffice的命令行:
libreoffice --headless --convert-to pdf --outdir /output/ 上传文件.xlsxLibreOffice 转换有几个坑:中文字体缺失会导致生成的 PDF 出现豆腐块;如果在容器里跑,需要安装字体包;转换大文件时务必加超时控制,否则进程可能挂死。我的经验是转换前先调用fc-list检查中文字体是否可用,没有就先安装fonts-noto-cjk。
Word 转 PDF 也走同样的 LibreOffice 路线,兼容性比docx2pdf好得多,不依赖 Windows 和 Office。
5.3 临时文件清理与权限控制
文件类的应用,最怕的是磁盘被传爆、临时文件没人清理。我在这个项目里做了三层防护:
第一层,上传入口限制文件大小和类型,超过 20MB 直接拒绝。PDF 扫描件动辄五六十 MB,必须转图片的时候转完就删源文件。
第二层,生成预览文件时统一放到一个临时目录,临时文件名带时间戳,然后写一个后台定时任务,清理超过 24 小时的临时文件。
import os import time def clean_temp_dir(dir_path, expire_seconds=86400): now = time.time() for name in os.listdir(dir_path): file_path = os.path.join(dir_path, name) if os.path.isfile(file_path): stat_mtime = os.path.getmtime(file_path) if now - stat_mtime > expire_seconds: os.remove(file_path)第三层,下载接口要做权限校验,不能拿个 URL 就随便下。常见的做法是生成一次性的 signed URL,带过期时间。这个在 FastAPI 里就是加一个依赖注入的问题,但重要性不能忽视——内部业务系统的文件一旦泄露,麻烦不小。
6. 常见问题与排查避坑实录
6.1 中文乱码与编码问题
这个项目里中文乱码出现了三次,分别在不同的环节,我记录一下排查思路。
第一次是读旧版 xls 文件,pandas 读取时指定了encoding="gbk"就解决了,但要注意不是所有 xls 都是 GBK 编码的,还有 UTF-8、GB2312。判断编码可以用chardet去检测,虽然速度慢一点,但准确率高。
第二次是生成的 PDF 里中文变方块。原因就是前面说的 LibreOffice 容器里没有中文字体。解决方案是装fonts-noto-cjk,并配置好字体别名。
第三次是文件名乱码。在 Linux 服务器上,文件系统字符集默认 UTF-8,Windows 上传的文件名如果带了 GBK 编码的字符,存到磁盘后名字就是乱码。处理方案是后端保存文件时用 UUID 重命名,原始文件名存到数据库字段里,展示的时候从数据库取。
6.2 大文件与内存占用
PDF 转图片是最吃内存的环节。一本几百页的书,转成图片直接把 2GB 内存吃满不是开玩笑。我的解决方法是限制并发、分批处理、及时释放。
pdf2image底层调用pdftoppm,每页渲染都会占用内存,所以千万不要一次性把所有页转成图片,然后统一存 list。正确做法是逐页处理,用完立即释放:
from pdf2image import convert_from_path images = convert_from_path("large.pdf", dpi=150) # 逐页处理并保存,而不是先把所有页积累在内存里 for i, img in enumerate(images): img.save(f"page_{i}.png") # 这里还可以顺便做裁切、压缩 del imgExcel 也一样,openpyxl 的read_only模式配合iter_rows可以极大降低内存占用,处理十万行数据不会拖垮服务器。如果数据量再大,就应该考虑上数据库或者分片处理,而不是在 Python 进程里硬扛。
6.3 环境安装与依赖冲突
这个项目的依赖列表不长,但paddleocr和pdf2image的依赖树拉到一起,偶尔会碰到 protobuf 版本冲突。我的建议是:项目依赖一定要用requirements.txt锁版本,别用"最新版"三个字自欺欺人。上生产环境之前,在干净的容器里从零跑一遍安装流程,比省那几分钟时间划算得多。
还有一个高频问题:pdf2image装上之后报错找不到pdftoppm。这不是 Python 库的问题,而是系统缺少 poppler-utils。在 Ubuntu 上执行apt-get install -y poppler-utils就解决了。Mac 上brew install poppler。
遇到任何 "command not found" 性质的报错,先冷静检查系统依赖,再回头看 Python 代码。
6.4 预览加载慢的优化技巧
最后再说一个和调用方反馈最密切的问题:预览打开慢。
我排查过的一个例子是 10MB 的 PDF,转图片要 8 秒,用户等得直摔杯子。后来优化方案是:上传完成后立刻异步转图片,把结果存数据库;用户点击预览时,如果图片还没生成完,先返回"处理中"状态或者先展示 PDF 原文件;图片生成完以后,更新状态。
另一个优化是增加缓存。同一个文件被多次预览,不应该每次都重新转图片。我基于文件 MD5 做了一层磁盘缓存,命中就直接返回图片列表,省去了大量重复计算。
这些优化看似不起眼,但对用户体感提升非常大。我这套做下来,预览接口的平均响应时间从 8 秒级降到了 1 秒以内,用户再也没有抱怨过"打不开"。
做文件解析这类项目,我最深的体会是:真正有价值的不是某个库的 API 调用,而是对整个链路的掌控——文件从哪来、到哪去、中间有哪些环节可能出错、出错之后怎么恢复。把这套链路想清楚了,不管是 Excel、Word 还是 PDF,都只是处理流程里的一个节点而已。