"""pptx_guard.py: 平台层 .pptx 产物机检——拦「整页贴图」式伪导出。 背景(PROGRESS 0.35.1 及本次复发):模型两次绕开 ppt skill 官方导出器 svg_to_pptx.py,用外部渲染器把每页渲成整页 PNG 贴进幻灯片交付——产物零原生 文本、不可编辑,skill 价值归零。官方管线内的门只在模型用了官方脚本时生效, 绕开管线的产物只能在平台层拦:shell / run_python 每步执行后,对本步新产出的 .pptx 就地机检,命中特征即在 tool 结果里注入 [产物机检 ERROR],逼模型当场 返工回官方管线。 判定刻意保守,只拦"确证的伪产物":整本 >= _RASTER_PAGE_RATIO 的页是 「一张覆盖 >= _PIC_AREA_RATIO 版面的图片 + 本页零文本」,且全 deck 原生文本 字符数为 0,且页数 >= 2。正常 deck(原生导出、带背景大图但有文字、单页海报、 含表格图表)都不会命中。解析失败一律放行——这门不做通用校验。 """ from __future__ import annotations from pathlib import Path from typing import Iterator, List, Optional try: from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE except Exception: # pragma: no cover - 宿主缺 python-pptx 时静默降级为不检 Presentation = None MSO_SHAPE_TYPE = None # 单页判「贴图页」:存在一张覆盖 >= 85% 版面的图片,且本页无任何非空文本 _PIC_AREA_RATIO = 0.85 # 整本判废:贴图页占比 >= 80%(容忍手搓时混入个别空白/封面页) _RASTER_PAGE_RATIO = 0.8 # mtime 与 host 时钟的容差(秒) _MTIME_EPSILON = 2.0 def _iter_shapes(shapes) -> Iterator: """展平 group,产出所有叶子 shape。""" for sh in shapes: try: if sh.shape_type == MSO_SHAPE_TYPE.GROUP: yield from _iter_shapes(sh.shapes) continue except Exception: pass yield sh def find_new_pptx(root: Path, since: float) -> List[Path]: """root 下 mtime >= since - epsilon 的 .pptx;跳过隐藏目录(.build 等)。""" found: List[Path] = [] try: for p in root.rglob("*.pptx"): try: rel_parts = p.relative_to(root).parts[:-1] except ValueError: rel_parts = () if any(part.startswith(".") for part in rel_parts): continue try: if p.stat().st_mtime >= since - _MTIME_EPSILON: found.append(p) except OSError: continue except OSError: pass return found def check_raster_deck(path: Path) -> Optional[str]: """判「整页贴图」deck。返回 None = 通过;返回字符串 = 判废原因。 打不开 / 解析失败一律放行:这门只拦确证的伪产物,不承担通用文件校验。 """ if Presentation is None: return None try: prs = Presentation(str(path)) slides = list(prs.slides) if len(slides) < 2: return None page_area = float(prs.slide_width or 0) * float(prs.slide_height or 0) if page_area <= 0: return None raster_pages = 0 total_text_chars = 0 has_native_content = False # 表格/图表也算原生内容 for slide in slides: big_pic = False page_text = 0 for sh in _iter_shapes(slide.shapes): try: if sh.has_text_frame: page_text += len(sh.text_frame.text.strip()) st = sh.shape_type if st in (MSO_SHAPE_TYPE.PICTURE, MSO_SHAPE_TYPE.LINKED_PICTURE): w = float(sh.width or 0) h = float(sh.height or 0) if w * h / page_area >= _PIC_AREA_RATIO: big_pic = True elif st in (MSO_SHAPE_TYPE.TABLE, MSO_SHAPE_TYPE.CHART): has_native_content = True except Exception: continue total_text_chars += page_text if big_pic and page_text == 0: raster_pages += 1 if ( total_text_chars == 0 and not has_native_content and raster_pages / len(slides) >= _RASTER_PAGE_RATIO ): return ( f"共 {len(slides)} 页,其中 {raster_pages} 页仅为一张覆盖整版的位图," f"且全文档没有任何原生文本/表格/图表 —— 判定为「SVG 渲 PNG 整页贴图」" f"式伪导出(用户拿到的是不可编辑的图片册,不是 PPT)" ) return None except Exception: return None def scan_and_report(root: Path, since: float) -> Optional[str]: """扫 root 下本步新产出的 .pptx,命中伪导出则返回注入 tool 结果的 ERROR 文本。""" bad: List[str] = [] for p in find_new_pptx(root, since): reason = check_raster_deck(p) if reason: bad.append(f"- `{p.name}`:{reason}") if not bad: return None return ( "[产物机检 ERROR] 检测到不合格的 .pptx 产物:\n" + "\n".join(bad) + "\n该文件不可交付:请删除它,回 ppt skill 官方管线用 scripts/svg_to_pptx.py " "重新导出(它是纯 Python 原生导出,不需要任何渲染依赖;报错就按报错修," "修不动就把报错原文如实告知用户)。不要另写导出脚本或改用渲染贴图。" )