news 2026/9/17 13:35:56

python-pptx 解析 PPTX 教案:结构化知识库与 Anki 卡片生成

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
python-pptx 解析 PPTX 教案:结构化知识库与 Anki 卡片生成

简介:《早期经济思想概述PPT学习教案.pptx》是一套面向经济学专业学生与自学者梳理学科源流的专题课件,适合课前预习、课堂讲授与考前复习使用。资源包体轻量,仅含1个pptx文件,约172KB,下载后可直接演示或打印成讲义。全篇共43页,先交代课程简介、教学目的、15讲教学计划、考核方式与推荐教材胡寄窗《西方经济学说史》,再依时间线索依次展开:从古希腊色诺芬《经济论》、柏拉图与亚里士多德的分工与交换思想,到古罗马法理对私有财产和契约自由的承认,再到中世纪经院学派关于公平价格与放贷取息的争论;随后进入15至18世纪地理大发现、西欧商品经济变化、重商主义兴起与古典政治经济学先驱,并延伸至斯密革命、古典体系分化、边际革命、马歇尔综合、凯恩斯革命以及新制度经济学的崛起。目前已有74人学习,可帮助读者快速搭起经济学说史的时间框架,并据此对照当代经济问题展开思考。

1. 43 页 PPTX 里藏着一条完整的经济思想时间线

一份名为《早期经济思想概述PPT学习教案.pptx》的专业资料,共 43 页。前 5 页是课程简介、15 讲教学计划、考核方式与教材信息,第 6 页起进入第一讲:15~18 世纪西方商品经济的发展与早期经济思想概述。多数人翻一遍就归档,但从文本工程的角度看,这份教案真正值钱的是它的排版规律:讲次、一级要点(溯源、地理大发现、重商主义、古典政治经济学先驱)、二级人物与著作,层级编号稳定,页脚统一带"第 X 页 / 共 43 页"。它天然适合被切成"讲次—主题—实体"三层结构,既能验证一套抽取流水线,也能搭出一个可追溯到具体页码的小型知识库。下面按解析、建模、生成、排错的顺序,把这份 pptx 拆成可复用的结构化数据。

2. python-pptx 解析教案文本层:占位符、页码与全角空格

2.1 pptx 本质是一个 zip,先搞清楚 slide 与文本框的从属关系

把 .pptx 后缀改成 .zip 解压,会看到ppt/slides/slide1.xmlslide43.xmlppt/slideLayouts/ppt/notesSlides/以及ppt/presentation.xml。真正决定幻灯片顺序的是presentation.xml里的p:sldIdLst,而不是文件名里的数字——这一点在别人手工增删过页面之后尤其重要,slide12.xml 未必是第 12 页。每页里每个可编辑对象都是一个p:sp形状,形状下挂p:txBody,文本按a:p段落切分,段落里再套a:r文本运行块。

python-pptx 把这套命名空间和 rId 解引用全部包好了,43 页的规模完全没必要手写 lxml。关键映射关系如下。

OOXML 位置python-pptx 访问方式教案中对应内容
ppt/presentation.xmlp:sldIdLstprs.slides的迭代顺序43 页物理顺序,与页脚页码一致
ppt/slides/slide6.xmlprs.slides[5]第一讲标题页,含"一、溯源"等四个要点
p:sp/p:nvSpPr/p:nvPr/p:ph[@type]shape.placeholder_format.type区分标题占位符与正文占位符
p:sp/p:txBody/a:pshape.text_frame.paragraphs每个要点占一个段落
ppt/notesSlides/slideN.xmlslide.notes_slide这份教案的备注页基本为空

注意:placeholder_format.type在非占位符的普通文本框上返回None,而教案里"第 1 页 / 共 43 页"和部分说明文字都是普通文本框,不能一刀切成正文。

2.2 按占位符类型抽标题,别按字号猜

教案第 6 页的"一、溯源""二、地理大发现与西欧社会经济的大变化"和下面的正文段落字号接近,有的还只是加粗,用"字号最大的是标题"这种启发式规则会误判。稳妥的做法是认占位符类型,再用视觉坐标兜底。

from pptx import Presentation def iter_slide_blocks(pptx_path): """按页产出 (页码, 标题, 正文段落列表)""" prs = Presentation(pptx_path) # 一次加载整包,43 页内存占用可忽略 for idx, slide in enumerate(prs.slides, start=1): shapes = [s for s in slide.shapes if s.has_text_frame] # 按视觉位置排序,避免 shape 索引与阅读顺序不一致 shapes.sort(key=lambda s: (s.top or 0, s.left or 0)) title, body = None, [] for shape in shapes: text = "\n".join(p.text for p in shape.text_frame.paragraphs).strip() if not text: continue ph = shape.placeholder_format is_title = ph.type is not None and "TITLE" in str(ph.type) if is_title and title is None: title = text else: body.append(text) yield idx, title, body

shapes.sort里的s.tops.left单位是 EMU,直接比较即可,不需要换算成厘米。ph.type is not None这一层判断很关键:普通文本框的typeNone,如果不排除,str(None)会返回字符串"None",虽然不会误命中 TITLE,但会让人误以为判断生效了。真正容易踩的坑是另一类:有些页面标题压根没放进标题占位符,这时候title会是None,需要退回到"取该页 top 最小、且文本长度小于 40 字的形状"作为标题。

2.3 页脚噪声清洗与中文编号归一

抽完正文后立刻遇到三个噪声源:页脚"第 X 页 / 共 43 页"、全角空格 U+3000、以及中文编号前缀"一、""1 、"混用(教案里第 15 页的"1 、'公平价格'"中间就多了一个空格)。

import re FOOTER = re.compile(r"^第\s*\d+\s*页\s*/\s*共\s*\d+\s*页$") CN_MARK = re.compile(r"^\s*(?P<no>[一二三四五六七八九十]+)\s*、\s*(?P<name>.+)$") AR_MARK = re.compile(r"^\s*(?P<no>\d+)\s*[、..]\s*(?P<name>.+)$") def clean(lines): out = [] for ln in lines: ln = ln.replace("\u3000", " ").strip() # 全角空格先归一 if not ln or FOOTER.match(ln): continue m = CN_MARK.match(ln) or AR_MARK.match(ln) out.append({"mark": m.group("no") if m else None, "text": m.group("name").strip() if m else ln}) return out

三个正则各管一段:FOOTER允许页码两侧有空格,因为不同页手写习惯不一致;CN_MARK覆盖"一、溯源"这种一级要点;AR_MARK覆盖"1 、公平价格"和"2 、放贷取息"这种经院学派争论点。把mark单独存一列,后面做讲次切分和题型生成时直接复用,比在纯文本里反复正则便宜得多。这一步做完,第 6 到第 15 页会得到 4 个一级要点标记和十余条人物、著作条目。

3. 从 15 讲清单到 SQLite:讲次切块与实体字段设计

3.1 用教学计划反推讲次边界

教案第 2 到第 4 页列了完整的 15 讲目录,这是全文唯一的"讲次词典"。先把讲次号和标题落成字典,再拿标题里的关键词回正文里定位起始页。

LECTURES = { 1: "15~18世纪西方商品经济的发展与早期经济思想概述", 2: "18世纪英国工场手工业的发展与斯密革命", 3: "李嘉图与古典经济学体系的完成", 4: "萨伊、马尔萨斯、穆勒:古典经济学体系的分化与综合", 5: "李斯特与德国历史学派", # 6~15 讲同理,只录入目录页能确认的条目 } def locate_lecture_start(slides, keyword): """返回首次命中关键词的页码,未命中返回 None""" for idx, title, body in slides: blob = (title or "") + "".join(body) if keyword[:8] in blob: # 取前 8 字做粗匹配,避开标点差异 return idx return None

这里有个取舍:目录页只给了讲次名,没给页码,所以 2~15 讲在当前 43 页版本里可能找不到正文。我的处理是把找不到的slide_fromNone,绝不臆造页码。理由很直接——教案是逐周更新的,第 6 页标着"第 1 页 / 共 43 页",说明作者按讲次分文件导出过,页码随时会变。留空比填错强,后面做检索时会明确排除slide_from IS NULL的记录。

3.2 实体抽取:书名号正则加人名种子词表双通道

经济思想史类文本的实体分布很有规律:著作几乎都带书名号,人物基本不带。单靠一种规则覆盖面不够,两条通道并行最省事。

import re BOOK = re.compile(r"《([^》]{1,30})》") PERSON_SEED = ["色诺芬", "柏拉图", "亚里士多德", "迪亚士", "达·伽马", "哥伦布", "麦哲伦"] CONCEPT_SEED = ["公平价格", "放贷取息", "万民法", "自然法则", "重商主义", "社会分工"] def extract_entities(text): books = BOOK.findall(text) persons = [p for p in PERSON_SEED if p in text] concepts = [c for c in CONCEPT_SEED if c in text] return {"book": books, "person": persons, "concept": concepts}

在教案第 8 到第 15 页上跑一遍,能得到这样的分布。

实体类型抽取方式教案样本备注
著作《》正则《经济论》《理想国》《法律论》《政治学》《伦理学》命中率高,几乎无误差
人物种子词表全文匹配色诺芬、柏拉图、亚里士多德人名不带标记,正则兜不住
概念手工词表公平价格、放贷取息、万民法、自然法则决定后续检索质量
地理与事件关键词好望角、印度、美洲、大西洋与"地理大发现"一节绑定

概念词表必须手工维护,这是这套流水线里唯一没法自动化的部分。但换个角度看,正因为要手工,词表本身就成了这份教案的知识骨架——你能一眼看出第一讲的重心在公平价格、放贷取息和重商主义,而不是散在 43 页里的碎句。

3.3 建表与切块参数

切块粒度上我不按字数硬切,而是按"一页 + 一个一级要点"。第一讲的四个要点(溯源、地理大发现、重商主义、古典政治经济学先驱)天然对应四个语义块,比 500 字滑窗切出来的结果干净得多。

CREATE TABLE lecture ( lecture_no INTEGER PRIMARY KEY, -- 1~15,来自目录页 title TEXT NOT NULL, slide_from INTEGER, -- 允许为 NULL,表示正文未收录 slide_to INTEGER ); CREATE TABLE chunk ( chunk_id INTEGER PRIMARY KEY, lecture_no INTEGER REFERENCES lecture(lecture_no), topic TEXT, -- 一级要点:溯源/地理大发现/… page INTEGER NOT NULL, -- 原始页码,供引用回填 entity TEXT, -- JSON 数组,存 book/person/concept content TEXT NOT NULL ); CREATE INDEX idx_chunk_lecture_page ON chunk(lecture_no, page); CREATE INDEX idx_chunk_topic ON chunk(topic);

page字段是整套设计里我最在意的一列。检索命中的时候,用户最想看到的不是一段孤立的话,而是"这句话在第 14 页"。idx_chunk_topic走的是前缀匹配,用来支撑"重商主义相关的内容都在哪几页"这类按主题聚合的查询。切块时如果某个要点跨页(比如第 17 到 19 页连着讲地理大发现),就按页拆成多条,topic保持相同,靠page排序还原阅读顺序。

4. 用编号层级生成自测题:模板匹配与 Anki 卡片导出

4.1 从标记反推题型模板

教案的编号体系本身就是出题线索。"一、溯源"是主题,"1 、公平价格"是子问题,"色诺芬""柏拉图"后面紧跟《》就是作者与著作的对应关系。按触发条件套模板,比让模型自由发挥稳定得多。

模板触发条件生成示例
作者—著作同一段落内出现人名与《》色诺芬的代表著作是什么?→《经济论》
归属—流派段首标记为"N、xx"且含学派名经院学派争论最多的两个问题是什么?→公平价格、放贷取息
事件—后果段落含"大西洋贸易""商业重心西移"地理大发现后欧洲商业重心如何转移?→由意大利、南德意志转向西、葡,再转向荷、英、法
数字—事实段落含世纪、比例等数字课程考核中平时作业占总成绩多少?→50%
def build_author_cards(chunks): cards = [] for c in chunks: books = c["entity"].get("book", []) persons = c["entity"].get("person", []) if len(books) == 1 and len(persons) == 1: q = f"{persons[0]}的代表著作是哪一部?" cards.append((q, books[0], c["page"])) return cards

这里的约束len(books) == 1 and len(persons) == 1是刻意的。第 10 页同时出现柏拉图和《理想国》《法律论》两个书名,硬套模板会生成一个答案不唯一的题。宁可漏掉几道,也不要往题库里塞有歧义的卡片——卡片一旦进口碑就崩了。

4.2 导出 Anki 可导入的 TSV

Anki 的文本导入对格式很挑,字段分隔符必须是制表符,字段内出现引号时还容易串列。

import csv, hashlib def emit_tsv(cards, path): seen = set() with open(path, "w", encoding="utf-8", newline="") as f: w = csv.writer(f, delimiter="\t", quoting=csv.QUOTE_NONE, escapechar="\\") for front, back, page in cards: key = hashlib.md5(front.encode("utf-8")).hexdigest() if key in seen: # 43 页里同一问题可能在多页重复 continue seen.add(key) w.writerow([front, back, f"经济思想史::第{page}页"])

csv.QUOTE_NONEescapechar="\\"是必须的:默认的QUOTE_MINIMAL会在字段里出现逗号或引号时自动加双引号,而 Anki 不认这层引号,会把它们当正文显示出来。标签用双冒号::分层,导入后 Anki 会自动生成"经济思想史"父牌组和"第 N 页"子标签,按讲次复习时直接按标签筛就行。

4.3 答案唯一性校验与页码回填

导完先别急着背,跑一遍冲突检测。同一问题对应多个答案,说明切块重叠把同一段算了两遍,或者实体同名不同人。

from collections import defaultdict def audit(cards): m = defaultdict(set) for front, back, page in cards: m[front].add(back) return {k: sorted(v) for k, v in m.items() if len(v) > 1}

在教案第一讲的卡片集上跑,典型的冲突来源有两个:一是"公平价格"在第 14 页和第 15 页各出现一次,表述略有差异;二是切块时topic相邻的两页被合并。处理方式很土但有效——把冲突项的page一起打出来,人工比对原文,保留表述更完整的那条。这一步花十分钟,能省掉后面复习时反复怀疑自己的时间。

5. 解析故障排查与 slide_id 版本比对

跑通一遍不代表下次还能跑通。教案这类文件通常一月一改,页码一变,之前抽好的page字段全废。常见故障和处理方式整理如下。

现象根因处理
标题恒为None该页用的是普通文本框,没走标题占位符退回"top 最小且文本短于 40 字"的形状
段落顺序错乱按 shape 索引遍历,与视觉顺序不一致(shape.top, shape.left)排序
页码与 slide 序号错位页脚是手写文本,中间增删过页面以页脚为准重排,或直接丢弃页脚
抽出内容只剩前几页slideLayouts当成slides读了只用prs.slides,layout 单独走prs.slide_layouts
文本里混进乱码空格原文使用全角空格 U+3000replace("\u3000", " ").strip()
中文编号漏识别"1 、"中间夹了空格或全角顿号正则里给分隔符加\s*并放行多个符号

真正值得单独说一句的是版本比对。教学计划里 15 讲的顺序和编号是稳定的,但 PPT 页码会随着插入案例、补充图表而整体后移。如果每次都重新抽一遍再人工核对,成本极高。稳妥做法是用slide_id做锚点。

from pptx import Presentation def slide_ids(pptx_path): """返回 [(顺序号, slide_id)],slide_id 在同一文件内稳定且唯一""" prs = Presentation(pptx_path) return [(i, s.slide_id) for i, s in enumerate(prs.slides, start=1)] def diff_versions(old_ids, new_ids): old_map = {sid: pos for pos, sid in old_ids} new_map = {sid: pos for pos, sid in new_ids} added = [sid for sid in new_map if sid not in old_map] removed = [sid for sid in old_map if sid not in new_map] moved = [(sid, old_map[sid], new_map[sid]) for sid in new_map if sid in old_map and old_map[sid] != new_map[sid]] return {"added": added, "removed": removed, "moved": moved}

slide_id由 PowerPoint 在创建页面时分配,同一文件内唯一且不随位置变化。改版后先跑一次diff_versions,如果added为空、moved也只涉及末尾几页,说明只是原有页面顺序微调,SQLite 里的page字段按moved的结果批量平移即可,不必重抽全文;只有当added非空、且有新增页面插进第一讲区间时,才需要对受影响的chunk重新生成卡片。这套判断逻辑跑一次不到一秒,比每次全量重建划算得多,也让"教案第几版"这件事有了可追溯的依据。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 13:32:27

STM32开发环境搭建:从Keil迁移到VS Code完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 13:28:46

SpringReport:企业级开源报表系统的架构与实践

1. 项目概述SpringReport是一款基于Spring生态构建的企业级开源报表系统。我在实际企业信息化建设项目中&#xff0c;曾多次遇到客户对报表系统的特殊需求——既需要满足复杂的业务数据展示需求&#xff0c;又要兼顾系统集成性和二次开发便利性。传统商业报表工具往往存在授权费…

作者头像 李华
网站建设 2026/9/17 13:28:21

RoboMaster硬件实战讲义:电源设计与故障定位指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 13:25:56

车载工控系统全链路设计:控制核心与三防移动端协同落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 13:24:56

2025年AI编程工具选型指南:12款主流工具横评与避坑经验

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华