1. 先把时间账算清楚:这条流水线到底省了什么
去年一年我大概做了二十几场组会汇报和两次会议报告,最难受的一次是拿到一篇四十多页的论文,从读懂到做完 PPT 花了整整两个晚上。后来我把这条Codex + GPT 全自动生成学术汇报 PPT的链路搭起来,跑通之后同类任务的耗时压到了通常半小时左右,剩下的时间主要花在校对和微调上。所谓"完整工作流",指的是把论文解析 → 结构化提取 → 叙事大纲规划 → 幻灯片脚本生成 → PPTX 渲染 → 视觉质检这几段全部串起来,中间不靠人手工搬运内容。 Codex 在这里扮演的是"能读仓库、能跑脚本、能看报错自己改代码"的编码代理角色;GPT 扮演的是"读得懂论文、排得出叙事线"的内容角色。两者分工明确,串成一条管道,就能把一份 PDF 变成一份能直接开讲的演示文稿。
这篇文章适合三类人看:一是经常要交文献汇报的研究生和刚入组的工程师,二是需要批量产出技术分享材料的团队,三是对"模型驱动的内容自动化"感兴趣、想自己搭一套类似工具链的开发者。不需要你会写复杂的排版引擎,但至少要能看懂 Python、会用命令行、知道 PPT 文件本质上是一堆 XML 压缩包。全文我会把参数怎么定、字号怎么算、为什么这么选、哪些坑我踩过,都写清楚,你能直接抄作业。
1.1 一份学术汇报 PPT 的真实工时分布
我先把自己做一份 15 分钟汇报的工时拆开记录过,大概是这么分布的:读论文并做笔记 90 分钟,梳理叙事逻辑和大纲 40 分钟,画图重绘 60 分钟,往母版里填内容并调版式 100 分钟,最后一轮校对 30 分钟。加起来 320 分钟,其中真正需要"人的判断"的部分其实只有读论文和梳逻辑这 130 分钟,剩下 190 分钟全是机械劳动——复制粘贴、对齐文本框、改字号、导出图片、统一配色。
这条流水线的设计目标就是精准吃掉那 190 分钟。反过来说,如果你想用它把 130 分钟也干掉,大概率会失望,因为模型对论文创新点的判断力远不如你自己。我见过有人把整篇论文丢进去直接要"生成 PPT",出来的东西结构完整、字也通顺,但讲的全是摘要里的套话,重点完全跑偏。这条流水线的正确定位是"高级排版工 + 结构助手",不是"替你理解论文"。
1.2 全自动流水线的能力边界
把边界说清楚很重要,不然做完之后你会觉得"好像也就那样"。经过反复测试,我总结出下面这张能力对照表:
| 环节 | 自动化程度 | 需要人工介入的点 |
|---|---|---|
| PDF 文本与版面解析 | 完全自动 | 扫描版 PDF 需先做 OCR,双栏跨栏需校验 |
| 图表区域抽取 | 完全自动 | 矢量图判断能否重绘,需人工抽查 |
| 章节与要点提炼 | 高(约 85%) | 创新点权重、领域术语准确性需校对 |
| 叙事大纲排序 | 高(约 80%) | 听众背景、汇报时长需要人工给定 |
| 幻灯片文案 | 中高(约 75%) | 口语化表达、"讲稿体"需人工润色 |
| 版式渲染 | 完全自动 | 极长公式、超大表格需特殊处理 |
| 视觉质检 | 完全自动 | 溢出检测阈值需按母版调参 |
看到"中高"那两行了吗?这就是我为什么一直强调不要把最后一公里也交给模型。文案里最容易翻车的是"把论文里的第三人称叙述直接搬到幻灯片上",比如把"本文提出了一种基于注意力机制的方法"变成"本文提出了一种基于注意力机制的方法"——PPT 上应该是"我们的做法:注意力加权",语体完全不一样。这个转换我试过用 prompt 硬扭,效果不稳定,最后还是在 prompt 里塞了两个正反例才把命中率提到能接受的水平。
2. 整体架构拆解:把"做 PPT"这件事切成五段管道
一次性让模型"给我生成一份 PPT",本质上是让它同时解决理解、规划、写作、排版四个不同性质的问题,出错率必然爆炸。我的做法是把它拆成五段职责单一、接口固定的管道,每一段的输入输出都是确定的 JSON 结构,段与段之间可以独立调试。这个思路借鉴的是数据处理里的 ETL 分层思想——只要每段的契约定死了,你就能单独替换某一段的模型或实现,而不用担心整条链路崩掉。
2.1 五段式管道的职责与数据契约
管道划分成下面五段,我用一张表把职责、输入输出和技术选型一次列清楚:
| 段位 | 职责 | 输入 | 输出 | 主要工具 |
|---|---|---|---|---|
| S1 解析 | 把 PDF 拆成文本块、图块、表块 | PDF 路径 | paper_raw.json | PyMuPDF |
| S2 提取 | 提炼章节、要点、数据、创新点 | paper_raw.json | paper_struct.json | GPT |
| S3 规划 | 按听众和时长排叙事大纲 | paper_struct.json+ 配置 | outline.json | GPT + Codex |
| S4 渲染 | 把大纲渲染成 PPTX | outline.json | deck.pptx | python-pptx |
| S5 质检 | 找出溢出、错位、缺失 | deck.pptx | report.json | LibreOffice + 图像分析 |
S2 和 S3 是模型段,S1/S4 是纯代码段,S5 是混合段。这个划分有个实际好处:纯代码段出问题是确定性的,能靠日志定位;模型段出问题是概率性的,需要靠 prompt 和校验兜底。把两类问题隔离开,调试效率会高很多。我最早把 S2 和 S4 混在一起写,结果某次图表编号错乱,查了两个小时才发现是模型在生成时把图 3 写成了图 4,而不是渲染代码的锅。
2.2 中间表示为什么必须是 JSON 而不是 Markdown
一开始我用的是 Markdown 当中间格式,## 标题表示新的一页,-表示要点,看着很清爽。跑了三次就放弃了,原因有三个:
第一,Markdown 表达不了版面属性。某页需要"左文右图、图占 40% 宽",某页需要"三列对比卡片",这些在 Markdown 里没有语法,只能靠约定,越约定越乱。第二,模型在 Markdown 里会自由发挥。你让它输出要点,它会给你加粗、加引用、加嵌套列表,格式漂移后解析器就崩。第三,JSON 可以被 schema 约束。用 Pydantic 定义好结构,模型输出的字段名、类型、枚举值全部可校验,校验失败就触发重试,这是 Markdown 做不到的。
我把中间表示定义为三层:meta(标题、作者、汇报时长、听众)、slides(页列表)、每页里的layout(版式枚举)、title、bullets(要点数组)、visual(图表描述)、notes(讲稿)。这个结构后面会反复用到。
{ "meta": { "title": "某跨模态检索方法的复现与改进", "duration_min": 15, "audience": "课题组内部", "theme": "academic_dark" }, "slides": [ { "layout": "title", "title": "某跨模态检索方法的复现与改进", "subtitle": "组会汇报 · 2024 年秋季" }, { "layout": "bullets", "title": "研究背景:现有方法的三个痛点", "bullets": [ "跨模态对齐依赖大规模配对数据,标注成本高", "推理阶段检索效率随库规模线性下降", "长尾类别召回率显著低于头部类别" ], "notes": "先抛出问题,不要一上来讲方法,听众需要知道为什么值得听。" }, { "layout": "figure", "title": "整体框架", "visual": { "source": "fig2", "caption": "双塔编码 + 对比学习目标", "rebuild": true } } ] }2.3 目录结构与依赖清单
工程目录我习惯这样组织,Codex 在这个结构里干活效率最高,因为它能通过文件名直接定位到该改哪个模块:
deckgen/ ├── config/ │ ├── theme.yaml # 配色、字号、栅格 │ └── run.yaml # 本次运行的时长、听众、语言 ├── src/ │ ├── s1_parse.py │ ├── s2_extract.py │ ├── s3_outline.py │ ├── s4_render.py │ ├── s5_qa.py │ └── schema.py # Pydantic 模型 ├── assets/ │ ├── template.pptx # 母版 │ └── fonts/ ├── work/ # 中间产物 └── out/依赖就那几样,版本不要太新也不要太旧,稳定压倒一切:
pip install pymupdf pdfplumber python-pptx pydantic \ pillow matplotlib pyyaml jsonschema # 质检阶段需要文档转换,单独装系统包 # apt install libreoffice-impress poppler-utils注意:
python-pptx的自动缩放(auto_size)在中文环境里经常失效或者缩放过度,我在模板里统一关掉它,改成自己按字符宽度算行数。这个决定后面在质检章节还会再提一次。
3. 论文解析层:把 PDF 变成可复用的素材块
这是整条链路里最容易被低估的一段。很多人以为pdfplumber一把梭就完事了,实际上双栏论文的阅读顺序、跨栏图表、脚注混入正文这三个问题,会让后面所有环节跟着遭殃。我的原则是:解析层的目标是"保真",不做任何理解,理解的事留给 S2。所以 S1 输出的paper_raw.json里全是带坐标、带字号、带字体名的原始块,不做任何语义合并。
3.1 版面还原与阅读顺序
用 PyMuPDF 拿文本块,关键是用get_text("dict")而不是get_text("text"),前者能拿到每段的 bbox 和 span 级字号:
import fitz def extract_blocks(pdf_path): doc = fitz.open(pdf_path) blocks = [] for pno, page in enumerate(doc): d = page.get_text("dict") for b in d["blocks"]: if b.get("type") != 0: continue spans = [s for line in b["lines"] for s in line["spans"]] if not spans: continue text = "".join(s["text"] for s in spans).strip() if not text: continue blocks.append({ "page": pno, "bbox": b["bbox"], # x0, y0, x1, y1 "size": round(max(s["size"] for s in spans), 1), "font": spans[0]["font"], "text": text, }) return blocks拿到 bbox 之后做两件事。第一是判双栏:统计所有块的 x0 分布,如果在中线附近(页宽 45%~55%)出现明显的空白带,就判定为双栏,然后按"先左栏自上而下、再右栏自上而下"重排。第二是判标题:按字号聚类,字号显著大于正文中位数(通常是 1.2 倍以上)且长度较短的块,标为标题候选;再结合font里是否含Bold做二次确认。
这两步看着简单,实际有两个坑。一个坑是页眉页脚:期刊模板的页眉会带论文标题,字号还不小,经常被误判成一级标题。我的处理是把每页最上方 6% 和最下方 6% 区域内的块先过滤掉,除非它出现在 60% 以上的页面上——那种反而可能是真正的章节标题。另一个坑是跨栏图注:图注常常横跨两栏,居中排布,如果不处理会被塞进右栏开头,导致上下文断裂。我的办法是把宽度超过页宽 70% 的块单独抽出来,作为"跨栏块"插在两栏之后。
3.2 图表抽取:别急着截图,先判断能不能重绘
这里有个决策点值得展开说:论文里的图,是直接截出来贴进 PPT,还是重绘?我一开始全用截图,结果做出来的 PPT 惨不忍睹——原图的字号是按 A4 纸设计的 9pt,缩到 PPT 里只剩 3pt,投影出来根本看不清;配色还是期刊的黑白灰,跟演示文稿的主题完全不搭。
后来我改成"分级策略":
| 图形类型 | 处理方式 | 理由 |
|---|---|---|
| 流程图、架构图 | 截图 + 后处理(提亮、加边距) | 重绘成本高于收益,原图信息密度高 |
| 折线图、柱状图 | 优先重绘 | 数据可从正文或表格反推,重绘后字号配色统一 |
| 散点图、热力图 | 重绘或直接截取高 DPI 区域 | 取决于是否能拿到原始数据 |
| 照片、显微图 | 必须截取,按 300 DPI 渲染 | 无重绘可能 |
重绘的前提是拿到数据。很多论文会在正文或表格里给出关键数值,S2 阶段让模型把"图 X 对应的数据点"抽成 JSON,S4 用 matplotlib 重画,字号直接对齐 PPT 正文(后面会讲参数),颜色取自主题色板。这个改动让整份 PPT 的观感提升非常明显,也是我认为最值得做的一步投入。
抽取位图用page.get_images(full=True),但要注意矢量图(matplotlib 直接导出的 PDF 插图)是抽不出来的,这时候要用区域渲染:
def render_region(page, bbox, dpi=300): mat = fitz.Matrix(dpi / 72, dpi / 72) pix = page.get_pixmap(matrix=mat, clip=fitz.Rect(*bbox), alpha=False) return pix.tobytes("png")dpi / 72这个换算要记牢,PDF 的坐标单位是 point(1/72 英寸),300 DPI 就是放大 4.17 倍。低于 200 DPI 的截图在 1080p 投影上会明显发虚,我一般固定 300。
3.3 公式与表格的处理策略
公式是学术汇报 PPT 的老大难。我的结论很直接:不要试图自动把公式转成 LaTeX 再渲染。原因是从 PDF 里抠出来的公式符号经常是乱码或者拆散的字符,OCR 转 LaTeX 的错误率在复杂公式上高得离谱,改起来比自己敲还慢。
我的方案分两档:
- 关键公式(不超过 3 个):人工用 LaTeX 重写一遍,渲染成 SVG 或高 DPI PNG 插入。这类公式通常是方法的核心,值得花五分钟。
- 次要公式:直接按区域裁图,加个浅底框,当图片用。
表格的处理更简单,PDF 里的表格抽出来交给pdfplumber的extract_tables(),但它对无框线表格识别很弱。我的兜底方案是把表格区域也裁成图,或者让模型根据上下文把表格内容转成 JSON,再用python-pptx重新画表——后者效果最好,因为原生表格在 PPT 里可编辑、字号可控,投影清晰度远高于图片。
3.4 解析质量的三道校验
解析完不要急着往下走,先做三个自动检查,任何一个不过就停下人工看:
第一,字符数合理性。一篇正常论文的正文抽出来应该是 2 万到 6 万字符,如果只有几千,说明 PDF 是扫描版,需要先走 OCR。第二,标题链路完整性。把识别出的标题按页码排序,看是否呈现"引言 → 方法 → 实验 → 结论"的常规顺序,如果出现"结论"在"方法"前面,说明双栏重排出错了。第三,图表编号连续性。正则扫一遍Fig. \d+和Table \d+,看编号是否连续、是否有重复,这个检查帮我抓到过好几次跨栏图注错位。
4. 大纲规划层:让 Codex 按叙事逻辑而不是按目录顺序排页
这一段是整条链路里唯一真正体现"智能"的地方,也是最需要人工干预的地方。论文的章节顺序是为审稿人写的,汇报的顺序是为听众写的,两者经常冲突。最典型的是论文里"相关工作"放在第二节,但汇报里你绝对不能花五分钟讲别人的工作——听众关心的是你做了什么、为什么这么做。
4.1 学术汇报的四种叙事骨架
我把常见的汇报场景归纳成四种骨架,写进 prompt 里让模型选一个,比让它自由发挥稳定得多:
骨架 A:问题驱动型(适合组会、答辩)。结构是 痛点 → 现有方案不足 → 我们的思路 → 关键设计 → 实验验证 → 结论。核心是把"为什么"讲透,方法部分可以压缩。
骨架 B:方法拆解型(适合技术分享、内部培训)。结构是 任务定义 → 整体框架 → 模块逐个展开 → 关键技巧 → 效果。核心是让听众能复现,公式和细节要给够。
骨架 C:结果导向型(适合给非同行或管理层汇报)。结构是 一句话结论 → 三个关键数据 → 怎么做到的(高度简化)→ 意味着什么。核心是结论先行,技术细节几乎不要。
骨架 D:复现报告型(适合读书会、论文复现)。结构是 原文要点 → 我们的实现差异 → 踩坑记录 → 复现结果对比 → 结论。核心是差异点和坑,这部分最有价值。
选骨架这一步我会在run.yaml里显式指定,不让模型猜。因为一旦猜错,后面所有页面都得重排。
4.2 Prompt 的三个硬约束
prompt 我改过十几版,最后稳定下来靠的是三个硬约束。约束一:页数上限。不设上限模型会给你生成 40 页,明说不出这么多页它就停不下来。做法是先在 prompt 里算好页数配额,比如"总页数不超过 16 页,其中封面 1、目录 1、背景 2、方法 5、实验 4、结论 1、致谢 1",让它按配额分配。约束二:每页要点数量 3±1。超过 5 条要点在 16:9 的页面上必然挤,少于 2 条又显得空。约束三:禁止复制原文句式。这条要配正反例,我在 prompt 里固定放两组对照:
反例(论文体):本文提出了一种基于对比学习的跨模态对齐方法,该方法在三个数据集上取得了显著提升。 正例(讲稿体):我们的做法——用对比学习把两个模态拉到同一个空间;效果——三个数据集全面上涨。
这个正反例大概占了 prompt 的两百多字,但它把文案质量的提升幅度抵得上我调一整天参数。这是我认为投入产出比最高的一个 prompt 技巧:与其描述你要什么风格,不如直接给两行对照。
4.3 页数与时间的换算表
页数不是拍脑袋定的,得跟汇报时长挂钩。我实测的经验公式是:页数 ≈ 有效讲述分钟数 × 1.0,再加 3 页固定页(封面、目录、致谢)。有效讲述分钟数要打个折,15 分钟的场次实际能讲内容的只有 12 到 13 分钟,因为开场寒暄、翻页停顿、被打断提问都要吃掉时间。
| 汇报时长 | 有效讲述 | 建议总页数 | 内容页 | 每页平均停留 |
|---|---|---|---|---|
| 10 分钟 | 8 分钟 | 11 页 | 8 页 | 60 秒 |
| 15 分钟 | 12 分钟 | 15 页 | 12 页 | 60 秒 |
| 20 分钟 | 17 分钟 | 20 页 | 17 页 | 60 秒 |
| 30 分钟 | 26 分钟 | 28 页 | 25 页 | 62 秒 |
| 45 分钟 | 40 分钟 | 42 页 | 39 页 | 61 秒 |
这张表有个隐含规律:平均每页停留 60 秒左右是最舒服的节奏。低于 40 秒听众来不及看,高于 90 秒你会开始重复。如果你的内容页算出来平均停留时间偏离太多,说明页数定错了,回去改页数而不是改讲稿。
提示:答辩场景要额外预留 2 到 3 页给"可能的提问点",比如方法局限、实验设置细节。这些页放在附录位置,不占主线讲述时间,但被问到时能立刻翻出来,比现场画图强太多。
5. 渲染层:把 JSON 稳定地渲染成 PPTX
到了这一步,内容已经定了,剩下就是"怎么画得好看且不出错"。我的做法是先手工做一个 8 页的母版,把版式、配色、字号全部定死,然后用代码往占位符里填内容。这样渲染代码只负责"填",不负责"设计",稳定性和美观度都能保证。
5.1 母版与版式的设计原则
母版用 PowerPoint 手工做,不要用代码生成母版,那是自找麻烦。我一般只做五种版式:封面、目录、纯文字要点、左文右图、三列对比。这五种能覆盖 90% 的学术汇报页面。母版里要提前处理三件事:
第一,占位符命名统一。给每个占位符起英文 ID,比如title、body、figure、caption,代码里按名字找,不按索引找。索引找占位符在换模板时必炸。
第二,中文字体要设两层。python-pptx里run.font.name = "思源黑体"只设了西文字体,中文仍会回退到默认宋体,很难看。必须手写 XML 补a:ea:
from pptx.oxml.ns import qn def set_font(run, name, size=None, bold=None, color=None): run.font.name = name rPr = run._r.get_or_add_rPr() ea = rPr.find(qn("a:ea")) if ea is None: ea = rPr.makeelement(qn("a:ea"), {}) rPr.append(ea) ea.set("typeface", name) if size: run.font.size = size if bold is not None: run.font.bold = bold if color: run.font.color.rgb = color第三,关掉自动缩放。text_frame.word_wrap = True,auto_size = MSO_AUTO_SIZE.NONE,剩下的交给自己的溢出算法。自动缩放在中文里会给你缩成一个奇怪的半大不小的字号,破坏整份文档的字号阶梯。
5.2 栅格系统与字号阶梯
排版好看的核心是间距和字号成体系,不是靠眼调。我把 16:9 的画布(13.333 × 7.5 英寸)切成 12 栏栅格,左右安全边距各 0.6 英寸,栏间距 0.15 英寸。内容区宽度 = 13.333 - 1.2 = 12.133 英寸,每栏 0.861 英寸。左文右图的页面就是左占 7 栏、右占 5 栏,这个比例在视觉上最稳。
字号阶梯我做了一套,全文档只用这六个值,不允许出现第七个:
| 用途 | 字号 | 行距 | 说明 |
|---|---|---|---|
| 封面主标题 | 40pt | 1.15 | 加粗 |
| 页面标题 | 28pt | 1.2 | 加粗,距顶 0.5 英寸 |
| 一级要点 | 20pt | 1.35 | 正文主字号 |
| 二级要点 | 17pt | 1.35 | 缩进 0.3 英寸 |
| 图注 / 来源 | 12pt | 1.2 | 灰色,居中对齐 |
| 页脚页码 | 10pt | 1.0 | 右下角 |
新手最容易犯的错是页面标题和正文的字号差距不够,比如 24pt 标题配 20pt 正文,投影出来层级感几乎为零。我的经验是标题至少是正文的 1.4 倍,28 配 20 就刚好。如果不确定,宁可把正文降到 18,也别把标题降下来。
5.3 图表重绘的参数对齐
重绘图表时最容易忽略的是"字号一致性"。matplotlib 默认 10pt,跟 PPT 正文 20pt 差了一倍,插进去之后图里的坐标轴标签比正文小一大截,观感非常割裂。我的做法是让 matplotlib 的最终字号跟 PPT 正文对齐,按图在页面上的实际宽度反算:
import matplotlib matplotlib.rcParams.update({ "font.size": 11, "axes.titlesize": 12, "axes.labelsize": 11, "xtick.labelsize": 10, "ytick.labelsize": 10, "legend.fontsize": 10, "figure.dpi": 300, "savefig.dpi": 300, "axes.spines.top": False, "axes.spines.right": False, "font.sans-serif": ["Source Han Sans SC", "DejaVu Sans"], "axes.unicode_minus": False, })换算逻辑是这样:图在 PPT 里占 5 英寸宽,按 20pt 正文字号算,图内标签应该是正文字号的 0.55 倍左右(视觉上略小但不至于看不清),也就是约 11pt。但 matplotlib 的 11pt 是按它自己的画布尺寸算的,所以还要保证figsize的宽度等于 PPT 里的实际英寸宽度,这样 1:1 插入时字号才对得上。这一步我踩过坑:如果figsize设成 8 英寸宽,插到 5 英寸的位置上会被缩放,字就变小了。figsize 宽度必须等于 PPT 里的显示宽度,这个规则我写进了脚本注释里。
另外axes.unicode_minus一定要设 False,不然负号会变成方框,这个 bug 我排查过半小时。
5.4 完整渲染代码骨架
渲染主循环的核心逻辑是"按 layout 分派",每个 layout 一个函数,函数里只做填内容 + 定位,不做任何判断:
from pptx import Presentation from pptx.util import Inches, Pt from pptx.enum.text import MSO_AUTO_SIZE LAYOUT_MAP = { "title": 0, "toc": 1, "bullets": 2, "figure": 3, "columns": 4, } def add_bullets(tf, items, base_size=Pt(20)): tf.word_wrap = True tf.auto_size = MSO_AUTO_SIZE.NONE tf.clear() for i, item in enumerate(items): p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() p.level = 0 p.space_after = Pt(12) p.line_spacing = 1.35 r = p.add_run() r.text = "· " + item set_font(r, "Source Han Sans SC", base_size, color=RGBColor(0x22, 0x22, 0x22)) def render(deck, outline, template="assets/template.pptx"): prs = Presentation(template) for s in outline["slides"]: layout = prs.slide_layouts[LAYOUT_MAP[s["layout"]]] slide = prs.slides.add_slide(layout) slide.shapes.title.text = s["title"] set_font(slide.shapes.title.text_frame.paragraphs[0].runs[0], "Source Han Sans SC", Pt(28), bold=True) if s.get("bullets"): body = slide.placeholders["body"] add_bullets(body.text_frame, s["bullets"]) if s.get("visual"): insert_figure(slide, s["visual"]) if s.get("notes"): slide.notes_slide.notes_text_frame.text = s["notes"] prs.save(out_path)notes那一行值得单独说:把讲稿写进备注栏是我用下来最有价值的一个习惯。备注栏不影响放映,但能让你在排练模式里看到完整句子,不至于只看到几个关键词说不出话。而且生成备注的成本极低,模型顺手就写了。
6. 质检与返修:自动找出排版里的"丑"
人眼看排版问题很慢,尤其是 20 页的稿子,看三遍还是会有漏网的溢出。这一步交给代码,能把大部分低级问题挡在交付之前。质检我分三层:文本层查溢出和字数,视觉层查渲染结果,内容层查一致性。
6.1 文本溢出检测
python-pptx不会告诉你文字超框了,它只会默默让文字超出边界。所以要自己算:给定文本框宽度和字号,估算每行能放多少字符,再乘以行数看是否超过框高。中英文混排的估算公式我用的是加权字符宽度:
from PIL import ImageFont def text_width_px(text, font_path, size_pt, dpi=96): font = ImageFont.truetype(font_path, int(size_pt * dpi / 72)) return font.getlength(text) def est_lines(text, box_w_in, font_path, size_pt): width_px = text_width_px(text, font_path, size_pt) box_px = box_w_in * 96 return max(1, int(width_px / box_px) + 1)用 PIL 的getlength比按"中文算 1 个字宽、英文算 0.55"估算准得多,因为它真的读了字体度量。代价是要有字体文件路径,这个在assets/fonts/里放一份就行。
检测到溢出后的处理策略,我按严重程度分三档:轻微溢出(超出 10% 以内)自动降一号字;中度溢出(10%~30%)自动删掉最后一条要点,同时记一条日志提示人工确认;严重溢出(超过 30%)直接报错中止,因为这通常意味着这页内容本身就该拆成两页。
注意:降字号一定要在字号阶梯内降,不能出现 19pt 这种值。我的实现是维护一个
[28, 24, 20, 17, 14]的阶梯数组,降级就是取下一个。
6.2 渲染成图做视觉巡检
文本检测只能查字,查不了图错位、颜色对比度不够、图片被拉伸这些问题。所以还要把 PPTX 转成图片看一眼。用 LibreOffice 无头模式转 PDF,再用 PyMuPDF 转 PNG:
soffice --headless --convert-to pdf --outdir work/ out/deck.pptximport fitz doc = fitz.open("work/deck.pdf") for i, page in enumerate(doc): pix = page.get_pixmap(matrix=fitz.Matrix(1.5, 1.5)) pix.save(f"work/preview_{i:02d}.png")有了图就能做几个自动化检查:边缘留白检测(统计每页四周 3% 区域内的非白像素比例,超过阈值说明内容顶到边了)、大面积空白检测(如果某页中间 60% 区域几乎全白,说明布局塌了)、文字密度检测(一页里暗像素占比过高,说明字太多)。这三条能抓住 80% 的视觉问题。
至于"好不好看",代码判断不了,我一般会把 20 张预览图拼成一张联系表扫一眼,重点看有没有明显的不平衡。
6.3 内容一致性核对
这一层查的是"说的和写的是不是一回事"。我固定查三项:页数与大纲是否一致(防止渲染丢页)、图表引用是否存在(正文提到"如图 3"但页面上没有图 3)、术语拼写是否统一(同一个方法名在不同页出现两种写法)。第三项用简单的词频统计就能做,把全文档的高频专有名词抽出来,人工扫一眼有没有变体。
这一层还负责抓一个隐蔽问题:数字前后矛盾。比如方法页说"准确率提升 4.2 个点",实验页写"提升 3.8 个点"。这类错误人眼极难发现,但把全文所有百分比和数字抽成列表并排看,一眼就能看出来。
7. 常见问题与排查速查表
跑了这么久,故障基本集中在下面这些,我整理成表,遇到问题先查表。
7.1 十二个高频故障与处置
| 现象 | 根因 | 处置 |
|---|---|---|
| 解析出来全是乱码 | PDF 是扫描版或字体未嵌入 | 先做 OCR,或换 PDF 版本 |
| 双栏论文顺序错乱 | 跨栏块被塞进单栏 | 按 bbox 宽度 > 70% 页宽单独抽出 |
| 页眉被识别成章节标题 | 字号大且位置固定 | 过滤上下 6% 区域,除非出现率 > 60% |
| 图表抽出来是空白 | 矢量图不支持位图抽取 | 改用get_pixmap按 clip 渲染 |
| 中文变成宋体 | 只设了西文字体 | 补a:ea的 typeface |
| 负号变成方框 | matplotlib 缺字体 | axes.unicode_minus = False |
| 图里字比正文小一半 | figsize 与插入宽度不一致 | figsize 宽 = PPT 显示宽 |
| 文字溢出文本框 | 未做宽度检测 | PILgetlength估算后自动降级 |
| 表格行高错乱 | pptx 表格行高不自动 | 按内容行数手动算行高 |
| 生成页数超了 | prompt 未设页数配额 | 显式给出每章节页数 |
| 要点写成论文体 | 缺正反例约束 | prompt 里放两组对照示例 |
| 转 PDF 字体丢失 | 系统缺对应字体 | 把字体装到系统或嵌进 PDF |
7.2 我的几条实操心得
第一条,先跑通 5 页再跑 20 页。我第一次直接上全量,出了十几处问题混在一起,根本分不清是解析的锅还是渲染的锅。后来改成先用 5 页的小样本跑通全链路,确认每一段的中间产物都对,再放大规模,调试效率高了一个数量级。
第二条,中间产物一定要落盘,别放内存里传。paper_struct.json、outline.json全部写文件。好处是某一段出错时,你可以直接改 JSON 重跑下游,不用从头再解析一遍 PDF。这个习惯帮我省了大量时间,尤其是调版式的时候,一天要重跑几十次渲染,每次都重新解析 PDF 是不可接受的。
第三条,模板比 prompt 更能决定观感。我早期一直在 prompt 里描述"要简洁、要有留白、配色要专业",效果很一般。后来把精力转到母版上,手工把母版调到满意,让代码只负责填内容,观感立刻就上来了。这个先后顺序别搞反:结构上的事交给代码和模板,语义上的事才交给模型。
第四条,不要相信模型给的图注和数据。模型会根据上下文"推测"某个图表达了什么,推测得很像那么回事但经常是错的。我的规矩是所有图注必须能对应到原文的表述,对不上就留空人工填。这条听起来很保守,但它避免过我在组会上把对照组说成实验组的尴尬。
第五条,每次改 prompt 都留档。我在prompts/目录下用v01_xxx.md、v02_xxx.md命名,每次改动都新建一个版本,同时在run.yaml里记下这次用的哪个版本。因为 prompt 的效果是非单调的,你改了一版觉得更好,换一篇论文可能就崩了,能回滚非常关键。
我现在的常态是:拿到论文,先把duration_min和audience填进配置,跑一遍全自动,得到一份大概 80 分的稿子;然后花二十分钟重点看三件事——创新点的表述准不准、图表重绘的数据对不对、每页的要点是不是讲稿体。改完基本就能开讲了。真正花时间的从来不是排版,而是你对自己研究的那点判断力,这部分工具替不了,也不该替。