Files
pp-qa-km/tools/convert/from_pptx.py
LittleYellow 3c1b268074 修復含空格/括號檔名的圖片連結失效,自檢改驗連結可 render
來源檔名含空格或不平衡括號時,convert 產出的 Markdown 圖片連結會失效——
圖檔明明存在於磁碟,整行卻退化成純文字(CommonMark 對未包住的空格與不平衡
括號視為分隔符)。用真 CommonMark parser 驗過:中文與成對括號正常,空格與
落單括號會壞。

- convert.py / from_docx.py / from_pdf.py / from_pptx.py:四處圖片連結目標
  一律以 <> 包住(![name](<rel>)),同時涵蓋空格與不平衡括號。
- selfcheck.py:新增 assert_links_ok()——以 markdown_it 真 render,斷言圖片
  連結數 == <img> 數且目標檔存在,取代原本只檢查 "![" 字串在不在的表面斷言。
  測資補內嵌圖片(docx/pdf/pptx 各一路徑)、檔名改含空格與不平衡括號。
- requirements.txt:明確宣告 markdown-it-py(原為 fastmcp 傳遞依賴,自檢直接
  使用,宣告以免上游調整依賴樹後自檢失效)。
- AGENTS.md §13.4:新增「斷言要驗結果可用,不是驗字串存在」規則。

驗證:四個自檢全通過;移除任一處 <> 修法,assert_links_ok 即失敗。
ingest / lint / search 在含括號檔名下全鏈路已另行驗證正常(source_ref 走
YAML 純量,不受影響)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 06:12:47 +08:00

76 lines
2.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PowerPoint (.pptx) → Markdown。每張投影片一節含表格、圖片佔位與講者備註。
可獨立執行python tools/convert/from_pptx.py <file.pptx> [-o out.md] [--dry-run]
"""
import argparse
import pathlib
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
def _table_md(tbl):
rows = [
[c.text.strip().replace("|", "\\|").replace("\n", " ") for c in row.cells]
for row in tbl.rows
]
if not rows:
return ""
width = len(rows[0])
out = ["| " + " | ".join(rows[0]) + " |", "|" + " --- |" * width]
out += ["| " + " | ".join(r) + " |" for r in rows[1:]]
return "\n".join(out)
def convert(src, assets_dir=None, md_dir=None):
"""回傳 (markdown, status)。status 恆為 'ok'"""
src = pathlib.Path(src)
md_dir = pathlib.Path(md_dir) if md_dir else src.parent
prs = Presentation(str(src))
counter = 0
sections = []
for i, slide in enumerate(prs.slides, 1):
blocks = [f"## Slide {i}"]
for shape in slide.shapes:
if shape.has_text_frame and shape.text_frame.text.strip():
blocks.append(shape.text_frame.text.strip())
elif shape.has_table:
blocks.append(_table_md(shape.table))
elif shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
counter += 1
name = f"img{counter}.{shape.image.ext}"
if assets_dir is not None:
assets_dir.mkdir(parents=True, exist_ok=True)
(assets_dir / name).write_bytes(shape.image.blob)
rel = (assets_dir / name).relative_to(md_dir).as_posix()
else:
rel = name
# <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
blocks.append(f"![{name}](<{rel}>)")
if slide.has_notes_slide:
notes = slide.notes_slide.notes_text_frame.text.strip()
if notes:
blocks.append(f"### 講者備註\n\n{notes}")
sections.append("\n\n".join(blocks))
return "\n\n".join(sections), "ok"
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("src")
ap.add_argument("-o", "--output")
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args(argv)
if a.dry_run:
md, status = convert(a.src)
print(f"[dry-run] {a.src}: status={status}, {len(md)} chars")
return
out = pathlib.Path(a.output) if a.output else pathlib.Path(a.src).with_suffix(".md")
md, _ = convert(a.src, assets_dir=out.parent / f"{out.stem}-assets", md_dir=out.parent)
out.write_text(md, encoding="utf-8")
print(out)
if __name__ == "__main__":
main()