office_extract.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """skill-case-get —— .docx / .pptx 解包抽取器(纯标准库)
  4. 本机没有 python-docx / python-pptx,Node 也没有对应库;
  5. .docx / .pptx 本质是 zip,因此用标准库 `zipfile` + `xml.etree.ElementTree` 解包解析。
  6. 抽取内容:
  7. - 文字段落(按文档/幻灯片顺序,保持阅读序)
  8. - 表格文本(.docx 的 w:tbl,逐行拼接)
  9. - 内嵌媒体(word/media/*、ppt/media/*、xl/media/*)落地到 --media-out
  10. - 超链接(document.xml 的 w:hyperlink r:id → rels 目标;slide 的 a:hlinkClick)
  11. - .pptx 的幻灯片切换顺序(presentation.xml 的 sldIdLst + rels)
  12. - .pptx 图解/备注:p:sp 的 a:t 文本(标题、正文、备注页)
  13. - .docx 页眉页脚(可选,--no-headers 关闭)
  14. 用法:
  15. python3 office_extract.py --input <file.docx|file.pptx> [--media-out <dir>] [--out <result.json>]
  16. python3 office_extract.py --input a.docx --input b.pptx --out bundle.json
  17. python3 office_extract.py --selftest
  18. 输出:一份 JSON(stdout 或 --out),结构见 `result` 组装处。
  19. 不写日志、不落任何 PII 到 stdout 之外的位置;媒体文件按原字节落地到 media-out。
  20. """
  21. import argparse
  22. import json
  23. import os
  24. import re
  25. import sys
  26. import zipfile
  27. import xml.etree.ElementTree as ET
  28. NS = {
  29. # WordprocessingML
  30. "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
  31. "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
  32. "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
  33. "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
  34. # PresentationML
  35. "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
  36. # package relationships
  37. "pr": "http://schemas.openxmlformats.org/package/2006/relationships",
  38. "ct": "http://schemas.openxmlformats.org/package/2006/content-types",
  39. }
  40. DOCX_EXT = {".docx", ".docm"}
  41. PPTX_EXT = {".pptx", ".pptm"}
  42. MEDIA_PREFIXES = ("word/media/", "ppt/media/", "xl/media/", "media/")
  43. IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".emf", ".wmf"}
  44. VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".avi", ".webm", ".mkv", ".wmv"}
  45. AUDIO_EXTS = {".mp3", ".m4a", ".wav", ".aac", ".flac", ".ogg", ".wma"}
  46. REL_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
  47. PR_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
  48. def qname(prefix, local):
  49. return "{%s}%s" % (NS[prefix], local)
  50. def safe_relpath(name):
  51. """把 zip 内条目名规整成安全的相对路径(防 zip slip)。
  52. 正确处理 `..`:向上弹一层(而不是直接丢弃),这样
  53. `ppt/slides/../media/a.png` → `ppt/media/a.png`;
  54. 越出根的 `..` 一律忽略,路径不可能逃出包外。
  55. """
  56. clean = name.replace("\\", "/").lstrip("/")
  57. parts = []
  58. for segment in clean.split("/"):
  59. if not segment or segment == ".":
  60. continue
  61. if segment == "..":
  62. if parts:
  63. parts.pop()
  64. continue
  65. parts.append(segment)
  66. return "/".join(parts)
  67. def local_name(tag):
  68. return tag.split("}", 1)[1] if "}" in tag else tag
  69. # ---------------------------------------------------------------------------
  70. # relationships
  71. # ---------------------------------------------------------------------------
  72. def parse_rels(zf, rels_path):
  73. """解析 _rels 部件 → {rId: {target, type, mode}}。"""
  74. out = {}
  75. try:
  76. raw = zf.read(rels_path)
  77. except (KeyError, OSError):
  78. return out
  79. try:
  80. root = ET.fromstring(raw)
  81. except ET.ParseError:
  82. return out
  83. for rel in root:
  84. rid = rel.get("Id") or ""
  85. target = rel.get("Target") or ""
  86. if not rid:
  87. continue
  88. out[rid] = {
  89. "id": rid,
  90. "target": target,
  91. "type": (rel.get("Type") or "").rsplit("/", 1)[-1],
  92. "mode": rel.get("TargetMode") or "Internal",
  93. }
  94. return out
  95. def rel_target_to_part(rels_path, target):
  96. """把相对 Target 规整成 zip 内条目路径。"""
  97. if target.startswith("/"):
  98. return safe_relpath(target)
  99. base = posix_dirname(posix_dirname(rels_path)) # _rels 的上一级
  100. return safe_relpath(posix_join(base, target))
  101. def posix_dirname(p):
  102. return p.rsplit("/", 1)[0] if "/" in p else ""
  103. def posix_join(a, b):
  104. if not a:
  105. return safe_relpath(b)
  106. return safe_relpath(a + "/" + b)
  107. # ---------------------------------------------------------------------------
  108. # text helpers
  109. # ---------------------------------------------------------------------------
  110. def collect_text_runs(element, run_tag, text_tag):
  111. """按阅读序收集某个节点下的全部文本片段。"""
  112. parts = []
  113. for node in element.iter():
  114. if node.tag == run_tag or local_name(node.tag) == local_name(text_tag):
  115. if node.tag == text_tag and node.text:
  116. parts.append(node.text)
  117. return "".join(parts)
  118. def paragraph_text(p_node):
  119. """一个 w:p / a:p 的文本。"""
  120. parts = []
  121. for node in p_node.iter():
  122. ln = local_name(node.tag)
  123. if ln == "t" and node.text:
  124. parts.append(node.text)
  125. elif ln == "tab":
  126. parts.append("\t")
  127. elif ln in ("br", "cr"):
  128. parts.append("\n")
  129. return "".join(parts).strip()
  130. def is_heading(p_node):
  131. """w:p 是否带 Heading 样式。"""
  132. for node in p_node.iter(qname("w", "pStyle")):
  133. val = node.get(qname("w", "val")) or ""
  134. if val.lower().startswith("heading") or val in ("Title", "标题"):
  135. return True
  136. return False
  137. # ---------------------------------------------------------------------------
  138. # docx
  139. # ---------------------------------------------------------------------------
  140. def extract_docx(zf, media_out, include_headers=True):
  141. result = {
  142. "kind": "document",
  143. "format": "docx",
  144. "paragraphs": [],
  145. "outline": [],
  146. "tables": [],
  147. "hyperlinks": [],
  148. "headings": [],
  149. "media": [],
  150. "embeddedObjects": [],
  151. "notes": [],
  152. }
  153. doc_part = "word/document.xml"
  154. if doc_part not in zf.namelist():
  155. for name in zf.namelist():
  156. if name.startswith("word/") and name.endswith("document.xml"):
  157. doc_part = name
  158. break
  159. if doc_part not in zf.namelist():
  160. result["error"] = "DOCX_MISSING_DOCUMENT_XML"
  161. return result
  162. rels = parse_rels(zf, "word/_rels/document.xml.rels")
  163. root = ET.fromstring(zf.read(doc_part))
  164. body = root.find(qname("w", "body"))
  165. if body is None:
  166. body = root
  167. def walk_blocks(container, scope):
  168. for child in container:
  169. ln = local_name(child.tag)
  170. if ln == "p":
  171. text = paragraph_text(child)
  172. if text:
  173. entry = {"index": len(result["paragraphs"]), "scope": scope, "text": text}
  174. if is_heading(child):
  175. entry["heading"] = True
  176. result["headings"].append(text)
  177. result["paragraphs"].append(entry)
  178. result["outline"].append({"level": 1 if entry.get("heading") else 0, "text": text})
  179. # 段落里的图片(drawing / pict)
  180. for blip in child.iter():
  181. if local_name(blip.tag) == "blip":
  182. rid = blip.get(REL_NS + "embed") or blip.get(REL_NS + "link")
  183. if rid and rid in rels:
  184. result["media"].append({
  185. "rId": rid,
  186. "part": rel_target_to_part("word/_rels/document.xml.rels", rels[rid]["target"]),
  187. "anchor": "inline",
  188. "nearbyText": text[:80],
  189. })
  190. elif ln == "tbl":
  191. rows = []
  192. for tr in child.findall(qname("w", "tr")):
  193. cells = []
  194. for tc in tr.findall(qname("w", "tc")):
  195. cells.append(" ".join(paragraph_text(p) for p in tc.findall(qname("w", "p"))).strip())
  196. rows.append(cells)
  197. if rows:
  198. result["tables"].append({"scope": scope, "rows": rows, "rowCount": len(rows)})
  199. walk_blocks(child, scope)
  200. elif ln in ("sdt", "sdtContent", "tc"):
  201. walk_blocks(child, scope)
  202. walk_blocks(body, "body")
  203. # 超链接:w:hyperlink r:id → rels
  204. for link in root.iter(qname("w", "hyperlink")):
  205. rid = link.get(REL_NS + "id") or link.get(qname("r", "id")) or ""
  206. target = rels.get(rid, {}).get("target", "")
  207. text = paragraph_text(link)
  208. if target or text:
  209. result["hyperlinks"].append({"text": text, "target": target, "scope": "body"})
  210. # 内嵌 OLE / 附件对象
  211. for obj in root.iter(qname("w", "object")):
  212. for node in obj.iter():
  213. if local_name(node.tag) == "OLEObject":
  214. rid = node.get(REL_NS + "id") or node.get(qname("r", "id")) or ""
  215. if rid in rels:
  216. result["embeddedObjects"].append({"rId": rid, "part": rel_target_to_part("word/_rels/document.xml.rels", rels[rid]["target"]), "progId": node.get("ProgID") or ""})
  217. if include_headers:
  218. for name in sorted(zf.namelist()):
  219. if re.match(r"^word/(header|footer)\d*\.xml$", name):
  220. try:
  221. hroot = ET.fromstring(zf.read(name))
  222. except ET.ParseError:
  223. continue
  224. texts = [paragraph_text(p) for p in hroot.iter(qname("w", "p"))]
  225. texts = [t for t in texts if t]
  226. if texts:
  227. result["notes"].append({"scope": name, "text": "\n".join(texts)})
  228. result["media"] = attach_media(zf, result["media"], media_out)
  229. return result
  230. # ---------------------------------------------------------------------------
  231. # pptx
  232. # ---------------------------------------------------------------------------
  233. def slide_sort_key(name):
  234. m = re.search(r"slide(\d+)\.xml$", name)
  235. return int(m.group(1)) if m else 10 ** 6
  236. def extract_pptx(zf, media_out):
  237. result = {
  238. "kind": "presentation",
  239. "format": "pptx",
  240. "slides": [],
  241. "paragraphs": [],
  242. "outline": [],
  243. "tables": [],
  244. "hyperlinks": [],
  245. "headings": [],
  246. "media": [],
  247. "embeddedObjects": [],
  248. "notes": [],
  249. "slideCount": 0,
  250. }
  251. pres_rels = parse_rels(zf, "ppt/_rels/presentation.xml.rels")
  252. ordered_parts = []
  253. # 首选:presentation.xml 的 sldIdLst(真实放映顺序)
  254. if "ppt/presentation.xml" in zf.namelist():
  255. try:
  256. proot = ET.fromstring(zf.read("ppt/presentation.xml"))
  257. for sld in proot.iter(qname("p", "sldId")):
  258. rid = sld.get(REL_NS + "id") or sld.get(qname("r", "id")) or ""
  259. if rid in pres_rels:
  260. ordered_parts.append(rel_target_to_part("ppt/_rels/presentation.xml.rels", pres_rels[rid]["target"]))
  261. except ET.ParseError:
  262. pass
  263. if not ordered_parts:
  264. ordered_parts = sorted(
  265. [n for n in zf.namelist() if re.match(r"^ppt/slides/slide\d+\.xml$", n)],
  266. key=slide_sort_key,
  267. )
  268. for idx, part in enumerate(ordered_parts, start=1):
  269. if part not in zf.namelist():
  270. continue
  271. try:
  272. sroot = ET.fromstring(zf.read(part))
  273. except ET.ParseError:
  274. continue
  275. rels = parse_rels(zf, posix_join(posix_dirname(part), "_rels/" + part.rsplit("/", 1)[-1] + ".rels"))
  276. title = ""
  277. body_lines = []
  278. slide_texts = []
  279. pic_count = 0
  280. for sp in sroot.iter(qname("p", "sp")):
  281. ph = None
  282. for node in sp.iter(qname("p", "ph")):
  283. ph = node.get("type") or "body"
  284. break
  285. texts = []
  286. for p_node in sp.iter(qname("a", "p")):
  287. t = paragraph_text(p_node)
  288. if t:
  289. texts.append(t)
  290. if not texts:
  291. continue
  292. if ph == "title" or ph == "ctrTitle" or (not title and ph is None and len(texts) == 1 and len(texts[0]) < 60):
  293. if not title:
  294. title = texts[0]
  295. result["headings"].append(texts[0])
  296. continue
  297. body_lines.extend(texts)
  298. # 表格
  299. for tbl in sroot.iter(qname("a", "tbl")):
  300. rows = []
  301. for tr in tbl.iter(qname("a", "tr")):
  302. cells = []
  303. for tc in tr.iter(qname("a", "tc")):
  304. cell_text = " ".join(paragraph_text(p) for p in tc.iter(qname("a", "p"))).strip()
  305. cells.append(cell_text)
  306. if cells:
  307. rows.append(cells)
  308. if rows:
  309. result["tables"].append({"scope": part, "slide": idx, "rows": rows, "rowCount": len(rows)})
  310. # 图片
  311. for blip in sroot.iter(qname("a", "blip")):
  312. rid = blip.get(REL_NS + "embed") or blip.get(REL_NS + "link")
  313. if rid and rid in rels:
  314. pic_count += 1
  315. result["media"].append({
  316. "rId": rid,
  317. "part": rel_target_to_part(posix_join(posix_dirname(part), "_rels/" + part.rsplit("/", 1)[-1] + ".rels"), rels[rid]["target"]),
  318. "anchor": "slide",
  319. "slide": idx,
  320. "nearbyText": title or (body_lines[0] if body_lines else ""),
  321. })
  322. # 超链接
  323. for link in sroot.iter(qname("a", "hlinkClick")):
  324. rid = link.get(REL_NS + "id")
  325. if rid and rid in rels:
  326. result["hyperlinks"].append({"text": title, "target": rels[rid]["target"], "scope": part, "slide": idx})
  327. # 备注页
  328. notes_part = posix_join(posix_dirname(part), "notesSlides/notesSlide%d.xml" % idx)
  329. notes_text = ""
  330. if notes_part in zf.namelist():
  331. try:
  332. nroot = ET.fromstring(zf.read(notes_part))
  333. notes_text = "\n".join(t for t in (paragraph_text(p) for p in nroot.iter(qname("a", "p"))) if t)
  334. except ET.ParseError:
  335. notes_text = ""
  336. if notes_text:
  337. result["notes"].append({"scope": notes_part, "slide": idx, "text": notes_text})
  338. slide_entry = {
  339. "index": idx,
  340. "part": part,
  341. "title": title,
  342. "texts": body_lines,
  343. "text": "\n".join([title] + body_lines).strip(),
  344. "notes": notes_text,
  345. "imageCount": pic_count,
  346. }
  347. result["slides"].append(slide_entry)
  348. for line in slide_entry["text"].split("\n"):
  349. if line.strip():
  350. result["paragraphs"].append({"index": len(result["paragraphs"]), "scope": part, "slide": idx, "text": line.strip()})
  351. result["outline"].append({"level": 1 if line.strip() == title else 0, "text": line.strip()})
  352. # 内嵌文件(ppt/embeddings/*)
  353. for name in zf.namelist():
  354. if name.startswith("ppt/embeddings/") and not name.endswith("/"):
  355. result["embeddedObjects"].append({"part": safe_relpath(name), "progId": ""})
  356. result["slideCount"] = len(result["slides"])
  357. result["media"] = attach_media(zf, result["media"], media_out)
  358. return result
  359. # ---------------------------------------------------------------------------
  360. # media
  361. # ---------------------------------------------------------------------------
  362. def classify_media(part):
  363. ext = os.path.splitext(part)[1].lower()
  364. if ext in IMAGE_EXTS or ext == "":
  365. return "image"
  366. if ext in VIDEO_EXTS:
  367. return "video"
  368. if ext in AUDIO_EXTS:
  369. return "audio"
  370. return "other"
  371. def attach_media(zf, media_entries, media_out):
  372. """把媒体条目去重、补上 kind/size,并按需落地到 media_out。"""
  373. by_part = {}
  374. order = []
  375. for entry in media_entries:
  376. part = safe_relpath(entry.get("part") or "")
  377. if not part:
  378. continue
  379. if part not in by_part:
  380. by_part[part] = {**entry, "part": part, "kind": classify_media(part), "occurrences": 0}
  381. order.append(part)
  382. by_part[part]["occurrences"] += 1
  383. out = []
  384. for part in order:
  385. entry = by_part[part]
  386. entry["size"] = zf.getinfo(part).file_size if part in zf.namelist() else 0
  387. entry["localPath"] = ""
  388. if media_out and part in zf.namelist():
  389. safe_name = part.replace("/", "__")
  390. target = os.path.join(media_out, safe_name)
  391. os.makedirs(media_out, exist_ok=True)
  392. with zf.open(part) as src, open(target, "wb") as dst:
  393. dst.write(src.read())
  394. entry["localPath"] = os.path.abspath(target)
  395. out.append(entry)
  396. return out
  397. # ---------------------------------------------------------------------------
  398. # entry
  399. # ---------------------------------------------------------------------------
  400. def package_kind(path):
  401. ext = os.path.splitext(path)[1].lower()
  402. if ext in DOCX_EXT:
  403. return "docx"
  404. if ext in PPTX_EXT:
  405. return "pptx"
  406. # 扩展名不认识时,看 package 内容
  407. try:
  408. with zipfile.ZipFile(path) as zf:
  409. names = zf.namelist()
  410. if any(n.startswith("word/") for n in names):
  411. return "docx"
  412. if any(n.startswith("ppt/") for n in names):
  413. return "pptx"
  414. except (zipfile.BadZipFile, OSError):
  415. pass
  416. return ""
  417. def extract_one(path, media_out=None, include_headers=True):
  418. kind = package_kind(path)
  419. base = {
  420. "input": os.path.abspath(path),
  421. "fileName": os.path.basename(path),
  422. "byteSize": os.path.getsize(path) if os.path.exists(path) else 0,
  423. }
  424. if not kind:
  425. return {**base, "ok": False, "error": "UNSUPPORTED_PACKAGE", "message": "不是 .docx/.pptx 包(zip 内无 word/ 或 ppt/)"}
  426. try:
  427. with zipfile.ZipFile(path) as zf:
  428. if kind == "docx":
  429. payload = extract_docx(zf, media_out, include_headers=include_headers)
  430. else:
  431. payload = extract_pptx(zf, media_out)
  432. except zipfile.BadZipFile:
  433. return {**base, "ok": False, "error": "BAD_ZIP", "message": "文件损坏或不是有效 zip 包"}
  434. except ET.ParseError as exc:
  435. return {**base, "ok": False, "error": "BAD_XML", "message": "XML 解析失败:%s" % exc}
  436. return {**base, "ok": True, **payload}
  437. def selftest():
  438. """自检:现场构造一个最小 docx + pptx,解包并断言关键结果。"""
  439. import io
  440. import tempfile
  441. doc_xml = (
  442. '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
  443. '<w:document xmlns:w="%s" xmlns:r="%s" xmlns:wp="%s" xmlns:a="%s">'
  444. "<w:body>"
  445. '<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>帝国理工 计算机科学 辅导案例</w:t></w:r></w:p>'
  446. "<w:p><w:r><w:t>学生考前冲刺,最终</w:t></w:r><w:r><w:t>提分 18 分</w:t></w:r></w:p>"
  447. "<w:p><w:r><w:drawing><wp:inline><a:graphic><a:blip r:embed=\"rId5\"/></a:graphic></wp:inline></w:drawing></w:r></w:p>"
  448. '<w:hyperlink r:id="rId9"><w:r><w:t>查看反馈</w:t></w:r></w:hyperlink>'
  449. "<w:tbl><w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"
  450. "</w:body></w:document>"
  451. ) % (NS["w"], NS["r"], NS["wp"], NS["a"])
  452. doc_rels = (
  453. '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
  454. '<Relationships xmlns="%s">'
  455. '<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>'
  456. '<Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.invalid/case" TargetMode="External"/>'
  457. "</Relationships>"
  458. ) % NS["pr"]
  459. slide_xml = (
  460. '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
  461. '<p:sld xmlns:p="%s" xmlns:a="%s" xmlns:r="%s"><p:cSld><p:spTree>'
  462. '<p:sp><p:nvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr><p:txBody><a:p><a:r><a:t>案例合集</a:t></a:r></a:p></p:txBody></p:sp>'
  463. "<p:sp><p:nvSpPr/><p:txBody><a:p><a:r><a:t>正文一</a:t></a:r></a:p></p:txBody></p:sp>"
  464. '<p:pic><p:blipFill><a:blip r:embed="rId2"/></p:blipFill></p:pic>'
  465. "</p:spTree></p:cSld></p:sld>"
  466. ) % (NS["p"], NS["a"], NS["r"])
  467. slide_rels = (
  468. '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
  469. '<Relationships xmlns="%s">'
  470. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image1.png"/>'
  471. "</Relationships>"
  472. ) % NS["pr"]
  473. pres_xml = (
  474. '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
  475. '<p:presentation xmlns:p="%s" xmlns:r="%s">'
  476. '<p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>'
  477. ) % (NS["p"], NS["r"])
  478. pres_rels = (
  479. '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
  480. '<Relationships xmlns="%s">'
  481. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>'
  482. "</Relationships>"
  483. ) % NS["pr"]
  484. png_bytes = bytes.fromhex(
  485. "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
  486. "0000000a49444154789c6300010000050001"
  487. "0d0a2db40000000049454e44ae426082"
  488. )
  489. failures = []
  490. with tempfile.TemporaryDirectory() as tmp:
  491. docx_path = os.path.join(tmp, "case.docx")
  492. with zipfile.ZipFile(docx_path, "w") as zf:
  493. zf.writestr("[Content_Types].xml", "<Types/>")
  494. zf.writestr("word/document.xml", doc_xml)
  495. zf.writestr("word/_rels/document.xml.rels", doc_rels)
  496. zf.writestr("word/media/image1.png", png_bytes)
  497. res = extract_one(docx_path, media_out=os.path.join(tmp, "media"))
  498. if not res.get("ok"):
  499. failures.append("docx 解包失败:%s" % res.get("error"))
  500. else:
  501. texts = [p["text"] for p in res["paragraphs"]]
  502. if "帝国理工 计算机科学 辅导案例" not in texts:
  503. failures.append("docx 标题段落缺失")
  504. if not any("提分 18 分" in t for t in texts):
  505. failures.append("docx 跨 run 文本拼接失败")
  506. if not res["headings"]:
  507. failures.append("docx 标题识别失败")
  508. if not res["tables"] or res["tables"][0]["rows"][0] != ["A", "B"]:
  509. failures.append("docx 表格抽取失败")
  510. if not res["hyperlinks"] or res["hyperlinks"][0]["target"] != "https://example.invalid/case":
  511. failures.append("docx 超链接抽取失败")
  512. media = [m for m in res["media"] if m["kind"] == "image"]
  513. if not media:
  514. failures.append("docx 内嵌图片未识别")
  515. elif not os.path.exists(media[0]["localPath"]):
  516. failures.append("docx 内嵌图片未落地")
  517. pptx_path = os.path.join(tmp, "deck.pptx")
  518. with zipfile.ZipFile(pptx_path, "w") as zf:
  519. zf.writestr("[Content_Types].xml", "<Types/>")
  520. zf.writestr("ppt/presentation.xml", pres_xml)
  521. zf.writestr("ppt/_rels/presentation.xml.rels", pres_rels)
  522. zf.writestr("ppt/slides/slide1.xml", slide_xml)
  523. zf.writestr("ppt/slides/_rels/slide1.xml.rels", slide_rels)
  524. zf.writestr("ppt/media/image1.png", png_bytes)
  525. res = extract_one(pptx_path, media_out=os.path.join(tmp, "media2"))
  526. if not res.get("ok"):
  527. failures.append("pptx 解包失败:%s" % res.get("error"))
  528. else:
  529. if res.get("slideCount") != 1:
  530. failures.append("pptx 页数不对:%s" % res.get("slideCount"))
  531. if not res["slides"] or res["slides"][0]["title"] != "案例合集":
  532. failures.append("pptx 标题占位符识别失败")
  533. if not any("正文一" in t for t in res["slides"][0]["texts"]):
  534. failures.append("pptx 正文抽取失败")
  535. if not [m for m in res["media"] if m["kind"] == "image"]:
  536. failures.append("pptx 图片未识别")
  537. elif res["media"][0]["part"] != "ppt/media/image1.png":
  538. failures.append("pptx 图片路径未正确解析 ..:%s" % res["media"][0]["part"])
  539. elif not os.path.exists(res["media"][0]["localPath"]):
  540. failures.append("pptx 内嵌图片未落地")
  541. if failures:
  542. print(json.dumps({"ok": False, "failures": failures}, ensure_ascii=False, indent=2))
  543. return 1
  544. print(json.dumps({"ok": True, "checked": ["docx", "pptx"], "message": "office 解包自检通过"}, ensure_ascii=False, indent=2))
  545. return 0
  546. def main(argv=None):
  547. parser = argparse.ArgumentParser(description="skill-case-get .docx/.pptx 解包抽取器(标准库实现)")
  548. parser.add_argument("--input", action="append", default=[], help="待解包的 .docx/.pptx(可重复)")
  549. parser.add_argument("--media-out", default="", help="内嵌媒体落地目录")
  550. parser.add_argument("--out", default="", help="结果 JSON 输出路径(默认 stdout)")
  551. parser.add_argument("--no-headers", action="store_true", help=".docx 不解页眉页脚")
  552. parser.add_argument("--selftest", action="store_true", help="运行内置自检")
  553. args = parser.parse_args(argv)
  554. if args.selftest:
  555. return selftest()
  556. if not args.input:
  557. parser.error("至少需要一个 --input")
  558. results = [extract_one(p, args.media_out or None, include_headers=not args.no_headers) for p in args.input]
  559. payload = {
  560. "ok": all(r.get("ok") for r in results),
  561. "generator": "skill-case-get/office_extract.py",
  562. "count": len(results),
  563. "results": results,
  564. }
  565. text = json.dumps(payload, ensure_ascii=False, indent=2)
  566. if args.out:
  567. with open(args.out, "w", encoding="utf-8") as fh:
  568. fh.write(text + "\n")
  569. else:
  570. sys.stdout.write(text + "\n")
  571. return 0 if payload["ok"] else 2
  572. if __name__ == "__main__":
  573. sys.exit(main())