拆解《岩石和矿物.pptx》:python-pptx 结构化抽取与生成
发布时间:2026/9/20 1:48:13 作者:尧图编辑部 阅读量:1,286

简介这份PPT教学课件围绕「岩石和矿物」主题展开面向小学科学或初中地理入门阶段的学习者与教师备课使用帮助解决岩石分类、观察描述方法等基础认知问题。包内共1个pptx文件压缩包约2.71MB可直接用于课堂演示或自主阅读。课件从观察描述岩石切入讲解看、摸、闻、敲击、刻划、称重六种观察手段进而归纳颗粒大小、软硬、形成方式、滴盐酸是否冒泡等分类标准并对岩浆岩、沉积岩、变质岩三大成因类型逐一说明。第二课进一步认识页岩、砂岩、石灰岩、砾岩、大理岩、花岗岩、板岩、卵石等常见岩石的特征与用途同时延伸出化石的形成过程和陨石的辨别方法如黑色熔壳、能被磁铁吸引等判断依据。内容配有课堂练习与总结便于梳理知识脉络、对照实物观察记录或直接用于授课讲解。目前已有109人学习。1. 岩石和矿物.pptx 不是文档是一个可以拆的压缩包科普馆的朋友给我一份 68 页的《岩石和矿物.pptx》要求把里面三百多个名词、莫氏硬度表、几十张矿物标本照片整理成能检索的知识库外加一套网页版图鉴。第一反应是打开 PowerPoint 一页页复制复制到第二十页就发现格式全乱了表格变成一堆制表符图片还得手动另存。换个思路就轻松了pptx 本质是一个 zip 包。unzip -l 岩石和矿物.pptx能直接列出里面的成员——ppt/slides/slide1.xml、ppt/media/image12.png、ppt/slideLayouts/slideLayout1.xml、[Content_Types].xml。也就是说这份课件可以被当成数据源用脚本读、用脚本写、用脚本比对两个版本之间的差异而不是只能靠人手点。适合往下看的人有三类手上有一堆存量课件、要批量转成知识库或题库的工程师需要在流水线里自动产出标准化课件的教研同学以及希望把课件喂给检索系统、让内容真正被搜到的人。下面按「拆结构、抽数据、反向生成、校验瘦身」四步推进每一步都给能直接跑的命令和代码。2. 拆开 岩石和矿物.pptxOOXML 路径与 python-pptx 读取链路2.1 用 unzip 摸清 slides 和 slideLayouts 的对应关系文件名带中文shell 里务必加引号否则中文可能被拆成多个参数或触发编码告警。先看目录清单再把单页 XML 打到终端最后全量解包方便用 grep 和 diff 定位问题。# 只列成员不解压先确认文件结构 unzip -l 岩石和矿物.pptx | head -30 # 把第一张幻灯片的 XML 打到终端看 XML 长什么样 unzip -p 岩石和矿物.pptx ppt/slides/slide1.xml | head -c 1200 # 全量解到 build/ 目录后面用 grep、diff 都在这里做 unzip -o 岩石和矿物.pptx -d build/-l的输出里ppt/slides/slideN.xml是第 N 张幻灯片编号顺序与放映顺序一致但真正的顺序由ppt/_rels/presentation.xml.rels里的sldIdLst决定手工改过页序的文件两者可能不一致。-p把指定成员输出到标准输出适合快速窥探-o表示覆盖已存在文件反复解包不会卡在交互提示上。路径内容抽取时的关注点ppt/slides/slideN.xml第 N 页的形状树文本、表格、图片引用都在这里ppt/slideLayouts/slideLayoutN.xml版式标题、页码、页脚等固定文字ppt/slideMasters/slideMasterN.xml母版主题字体、配色决定默认字号ppt/media/*图片、音频、视频实际二进制按页命名落盘ppt/notesSlides/notesSlideN.xml演讲者备注讲稿和补充说明常在备注里ppt/slides/_rels/slideN.xml.rels关系映射rId 到 media 的真实指向顺手 grep 一下文本位置grep -o a:t[^]*/a:t build/ppt/slides/slide1.xml。这里有个坑要提醒XML 里、、会被转义成amp;、lt;直接读出来带着实体所以 grep 只用来定位别拿正则当解析器真正的解析交给 XML 解析器或 python-pptx。2.2 python-pptx 的对象模型Presentation、Slide、Shape、TextFramepython-pptx 把上面那套 XML 包装成四层对象Presentation对应整个包Slide对应一页Shape对应一个形状TextFrame挂在形状上装文字。坐标和尺寸单位是 EMU914400 EMU 等于 1 英寸一张 13.333×7.5 英寸的 16:9 幻灯片就是 12192000×6858000 EMU。from pptx import Presentation prs Presentation(岩石和矿物.pptx) print(幻灯片尺寸(EMU):, prs.slide_width, prs.slide_height) print(总页数:, len(prs.slides)) slide prs.slides[0] for shape in slide.shapes: # shape_id 在一份文件里稳定shape.name 常与母版同名别拿来当唯一键 print(shape.shape_id, shape.name, shape.shape_type, shape.has_text_frame, shape.has_table)shape_type是MSO_SHAPE_TYPE枚举常见取值和处理方式如下表。判断类型时优先用枚举成员而不是数字可读性差别很大。shape_type典型对象处理方式PLACEHOLDER标题、正文占位符再查placeholder_format.type区分标题与正文TEXT_BOX手工插入的文本框直接读text_framePICTURE矿物照片走shape.image.blob取二进制TABLE硬度表、分类表走shape.table逐行读单元格GROUP成组摆放的图文必须递归进去否则文字漏抽AUTO_SHAPE标注框、箭头有text_frame时一样读2.3 组合形状和版式文字文字漏抽的两个来源第一类漏抽来自组合形状。课件里常见的做法是把一张矿物照片、一个箭头、一段说明文字打包成一组遍历slide.shapes时只能看到这个GROUP本身里面的文字一个都拿不到必须递归。from pptx.enum.shapes import MSO_SHAPE_TYPE def walk(shapes, depth0): 递归遍历形状组合形状要往里钻否则组内文字全部漏掉。 depth 记录嵌套层数排序和层级还原时会用到。 for shape in shapes: yield shape, depth if shape.shape_type MSO_SHAPE_TYPE.GROUP: yield from walk(shape.shapes, depth 1)第二类漏抽来自版式。页码、页脚、角落里的馆标文字并不在slideN.xml里而在slideLayout甚至slideMaster中遍历幻灯片是拿不到的。需要显式访问slide.slide_layout.shapes或者干脆在业务上忽略——判断标准很简单这段文字在 68 页里重复出现基本就是版式带来的抽进知识库只会变成噪音。提示判断一段文字该不该进知识库看它在整份文件里出现的页数占比超过一半的重复文本先怀疑版式和母版。2.4 备注页、图片和关系文件备注页是最容易被忽略的内容源教研写的讲稿、补充说明、易混淆点往往就放在备注里。python-pptx 的notes_slide属性有个副作用目标页没有备注时会顺手创建一个空备注页。只读场景必须先判断has_notes_slide否则读一遍文件保存下来就多出一堆空备注。for idx, slide in enumerate(prs.slides, 1): # 只读场景先判 has_notes_slide避免读取动作把空备注页写进内存模型 if slide.has_notes_slide: note slide.notes_slide.notes_text_frame.text.strip() if note: print(idx, note[:60])图片走shape.image能拿到blob二进制、ext扩展名、size像素宽高、dpi。这里要注意关系文件的复用机制slide1.xml.rels和slide7.xml.rels里的 rId 不同但Target可能都指向../media/image3.png。按 rId 统计会严重高估图片数量去重必须按二进制内容哈希来做而不是按引用次数。3. 把 岩石和矿物.pptx 抽成结构化数据文本、表格、图片一次落地3.1 文本层级还原占位符类型加 paragraph.level抽出来的文字如果全平铺成一段检索效果会非常差。还原成「页标题 → 二级要点 → 三级要点」的关键有三条线索形状是不是标题占位符、段落的level值、以及 run 上显式设置的字号。from pptx import Presentation from pptx.util import Pt prs Presentation(岩石和矿物.pptx) def is_title_shape(shape) - bool: 默认模板里标题占位符的 idx 通常是 0正文是 1。 if not shape.is_placeholder: return False return shape.placeholder_format.idx 0 records [] for page, slide in enumerate(prs.slides, 1): for shape, depth in walk(slide.shapes): if not shape.has_text_frame: continue for para in shape.text_frame.paragraphs: text para.text.strip() if not text: continue size para.runs[0].font.size if para.runs else None records.append({ page: page, shape: shape.name, depth: depth, level: para.level, size_pt: size.pt if isinstance(size, Pt) else None, is_title: is_title_shape(shape), text: text, }) print(len(records))is_title_shape用idx而不是占位符下标是因为占位符在slide.placeholders里的顺序并不等于 idx。size_pt出现None是常态——字号继承自母版只有人工改过字号才显式写进 XML所以不要用「字号大就是标题」这条规则单独判定层级它必须和level、占位符类型、形状位置一起看。3.2 表格抽取把莫氏硬度表和矿物分类表落成 CSV课件里的表格是结构化程度最高的部分能直接变成数据库表。要处理合并单元格的细节被合并的单元格在 python-pptx 里返回的是起始单元格的文本cell.is_merge_origin和cell.is_spanned能告诉你它的角色。import csv rows [] for page, slide in enumerate(prs.slides, 1): for shape in slide.shapes: if not shape.has_table: continue tbl shape.table header [c.text.strip() for c in tbl.rows[0].cells] # 首行当表头 for row in list(tbl.rows)[1:]: values [c.text.strip() for c in row.cells] if not any(values): continue # 跳过空行 rows.append({page: page, shape: shape.name, **dict(zip(header, values))}) fields sorted({k for r in rows for k in r}) with open(tables.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfields) writer.writeheader() writer.writerows(rows)encodingutf-8-sig是为了让 Excel 直接双击打开不乱码BOM 三个字节换来的便利通常值得。newline是 csv 模块的硬性要求缺了会在 Windows 上多出空行。fields用集合动态收集可以容忍不同页的表格列名不一致如果列名在课件里写法不统一比如「硬度」和「莫氏硬度」混用建议额外做一层列名归一化映射别指望下游去清洗。3.3 图片落盘与哈希去重避免同名覆盖矿物照片是这份课件里最有价值也最容易搞砸的部分。用shape.name命名一定出事——母版里生成的图片名大量重复后一张会覆盖前一张。import hashlib, pathlib from pptx.enum.shapes import MSO_SHAPE_TYPE out pathlib.Path(assets); out.mkdir(exist_okTrue) seen {} for page, slide in enumerate(prs.slides, 1): for shape in slide.shapes: if shape.shape_type ! MSO_SHAPE_TYPE.PICTURE: continue blob shape.image.blob digest hashlib.sha1(blob).hexdigest()[:12] if digest in seen: continue # 同一张图被多页复用只落一次 name fp{page:03d}_{digest}.{shape.image.ext} seen[digest] name (out / name).write_bytes(blob) print(唯一图片数:, len(seen))sha1取前 12 位做文件名碰撞概率在这个量级下可以忽略p{page:03d}前缀让文件能按页码排序回溯时不用去翻 JSON。文件名里保留ext是因为课件里可能混着 png、jpg、emf、wmfemf这类矢量图 PIL 打不开后续处理要单独分支。3.4 输出 JSONL 的字段约定与编码细节最终产物建议用 JSONL一行一条记录方便流式处理和按行 diff。字段约定如下表image存放落盘文件名而不是原始 rId下游不必理解 OOXML 的关系机制。字段类型说明pageint幻灯片页码从 1 开始levelint段落级别0 为顶层is_titlebool是否为标题占位符内的文字textstring段落纯文本tableobject表格行键为表头imagestring图片落盘文件名按内容哈希去重import json with open(slides.jsonl, w, encodingutf-8) as f: for rec in records: # ensure_asciiFalse 保留中文原样体积更小也便于肉眼核对 f.write(json.dumps(rec, ensure_asciiFalse) \n)ensure_asciiFalse让中文以原字符写入文件体积通常只有转义写法的三分之一左右grep 中文关键词时也能直接命中。这个细节在检索类项目里很关键——很多「明明是中文却搜不到」的问题根源就是入库时被转义成了\u5ca9。4. 反向生成用 python-pptx 写一份规范的岩石和矿物课件4.1 选母版和版式占位符而不是 blank 加绝对坐标抽取是读生成是写。新手最常走的路线是拿空白版式然后add_textbox指定绝对坐标结果字号、颜色、项目符号全要手填改一次模板要动几十行代码。正确做法是用版式让母版决定样式代码只填内容。from pptx import Presentation from pptx.util import Inches prs Presentation() # 默认模板先看版式索引 prs.slide_width Inches(13.333) # 16:9 宽度按 13.333 英寸算 prs.slide_height Inches(7.5) layout prs.slide_layouts[1] # 默认模板里通常是标题和内容 slide prs.slides.add_slide(layout) slide.shapes.title.text 岩浆岩的主要类型 body slide.placeholders[1] # 正文占位符索引以 idx 为准 body.text_frame.text 侵入岩花岗岩、辉长岩 for point in [喷出岩玄武岩、流纹岩, 特征结晶程度与冷却速度相关]: para body.text_frame.add_paragraph() para.text point para.level 0 prs.save(生成_岩石和矿物.pptx)改slide_width之后母版里的占位符不会自动跟着缩放这是很常见的踩坑点。稳妥做法是先用 PowerPoint 建一份 16:9 的空模板设好字体和主题色再拿它的路径去Presentation(模板.pptx)而不是在代码里动尺寸。占位符索引也别按数组下标硬编码用placeholder_format.idx查一遍更可靠。4.2 图片等比放进占位符标本照片不拉伸的做法矿物照片的长宽比千差万别直接给add_picture同时传 width 和 height 会硬拉伸标本变成扁的。先算缩放比例再居中是唯一省心的做法。from PIL import Image def add_fitted_picture(slide, img_path, left, top, box_w, box_h): 按给定框等比缩放并居中避免岩石标本照片被拉扁。 with Image.open(img_path) as im: w, h im.size scale min(box_w / w, box_h / h) # 取小值保证整张图放得下 new_w, new_h int(w * scale), int(h * scale) return slide.shapes.add_picture( img_path, left (box_w - new_w) // 2, # 水平居中 top (box_h - new_h) // 2, # 垂直居中 widthnew_w, heightnew_h, )scale用min而不是max是因为目标框固定、图片可变取小值才能保证图片完整放进框内如果业务要求视觉上铺满、允许裁边那就改成max并配合裁剪。坐标必须是整数EMU 不接受浮点除法一律用//。另外PIL只能读出 png、jpg 这类位图尺寸遇到 emf 矢量图需要先用 Office 转换或跳过。4.3 用一份 JSON 驱动整套岩石和矿物课件生成逻辑最好和数据彻底分离脚本固定数据从 JSON 读。这样教研同学改文案不用碰代码工程师改排版也不怕动到内容。import json data json.load(open(outline.json, encodingutf-8)) for item in data: slide prs.slides.add_slide(prs.slide_layouts[1]) slide.shapes.title.text item[title] body slide.placeholders[1] body.text_frame.text item[points][0] for point in item[points][1:]: body.text_frame.add_paragraph().text point if item.get(image): add_fitted_picture(slide, item[image], leftInches(8), topInches(2), box_wInches(4.8), box_hInches(3.6)) if item.get(note): # 讲稿写进备注页放映时不显示导出讲义时才有 slide.notes_slide.notes_text_frame.text item[note]JSON 字段类型用途titlestring写入标题占位符pointsarray第一个元素覆盖正文其余追加段落imagestring图片路径缺省则不放图notestring演讲者备注缺省则不建备注页body.text_frame.text item[points][0]是先覆盖再追加的写法比循环里判断首段更短。slide.notes_slide在这里会按需创建备注页正是我们想要的行为——需要写备注时才创建不写就不产生空备注。4.4 中文排版三个坑a:ea 字体、段落级别、自动换行第一坑是中文字体。run.font.name 微软雅黑只改拉丁字符中文仍走主题里的东亚字体因为 OOXML 把它们分成了a:latin和a:ea两个节点而 python-pptx 没有直接 API。from pptx.oxml.ns import qn def set_east_asian_font(run, name微软雅黑): python-pptx 无直接接口补写 a:ea 节点才能改中文字体。 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)run._r是底层 lxml 元素qn把a:前缀展开成完整命名空间手写命名空间字符串很容易出错一律用qn。第二坑是paragraph.level它只控制缩进层级不会自动加项目符号符号来自版式里的buChar。第三坑是自动换行长矿物名会被浏览器式的断行规则切开需要显式设text_frame.word_wrap True并检查auto_size避免文字溢出到版式外。5. 岩石和矿物.pptx 的校验、瘦身与版本差异排查5.1 生成前后各跑一次结构体检批量生成最怕静默失败页数对了内容全空。跑完生成脚本先做一次体检把空白页和缺图页揪出来。from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE prs Presentation(生成_岩石和矿物.pptx) empty [] for i, slide in enumerate(prs.slides, 1): text .join(s.text_frame.text for s in slide.shapes if s.has_text_frame).strip() pics sum(1 for s in slide.shapes if s.shape_type MSO_SHAPE_TYPE.PICTURE) if not text and pics 0: empty.append(i) print(f共 {len(prs.slides)} 页空白页 {len(empty)}: {empty})empty列表直接给出页码比翻六十页找问题快得多。这个检查同时适用于抽取阶段——解包后先体检一遍能提前发现源文件里本来就有空白页避免后期把它当成脚本的 bug 去查。5.2 瘦身重复图片合并与未引用媒体清理一份图文课件里同一张示意图被十几页复用很常见包体因此膨胀。用 zipfile 直接读成员和 rels能快速找出没人引用的媒体。import zipfile z zipfile.ZipFile(岩石和矿物.pptx) rels .join(z.read(n).decode(utf-8) for n in z.namelist() if n.endswith(.rels)) media [n for n in z.namelist() if n.startswith(ppt/media/)] # rels 里 Target 常写成 ../media/image3.png所以只比对文件名 unused [m for m in media if m.split(/)[-1] not in rels] print(未被引用的媒体:, unused)未引用不等于可以删chart、OLE 对象、备注页可能通过别的 rels 引用同一批文件动手前把slides/_rels、notesSlides/_rels、charts/_rels全部查一遍。真正的瘦身大头通常是分辨率标本照片按 4000 像素宽嵌入放映时最多显示到 1280 像素宽用 PIL 重采样到长边 1600 再回写包体常见能砍掉一半以上。另外ppt/fonts/下的嵌入字体会显著增重非必要可以在 PowerPoint 里取消嵌入。5.3 排错对照表和一份 pptx 的 XML 差异比对现象常见原因处理文字抽出来一堆乱码直接按字节读没按 UTF-8 解码交给 XML 解析器或显式decode(utf-8)组合形状里的字全丢没递归GROUP用walk()递归遍历图片数量对不上按 rId 统计忽略了 rel 复用按二进制哈希去重中文字体改了没效果只设了a:latin补写a:ea节点打开文件提示需要修复手工改 XML 时破坏了命名空间别手改 XML全部走 python-pptx最后一个高频场景是版本比对教研改完一版课件需要知道到底动了哪几页。把两次的 XML 格式化后直接 diff比肉眼翻页靠谱得多。# 把两版同名页面的 XML 格式化后对比只看前 40 行差异 unzip -p old/岩石和矿物.pptx ppt/slides/slide3.xml | xmllint --format - a.xml unzip -p new/岩石和矿物.pptx ppt/slides/slide3.xml | xmllint --format - b.xml diff -u a.xml b.xml | head -40xmllint --format做缩进美化否则整份 XML 挤在一行diff 会输出一整块而看不出改了哪个节点。把这几行写进 Makefile 的check目标改版提交前跑一次哪一页动了、动的是标题还是图片一眼就能看清。本文还有配套的精品资源点击获取