Skill: standed/ppt-ultrawide-relayout (version 1.0.0)

--- name: ppt-ultrawide-relayout description: 把普通比例的 PPT 重新排版成更宽的超宽横版，或参考另一份 PPT 的页面比例与视觉语言进行重排。只要用户提到“改成更宽屏”“参考另一份 PPT 的比例”“不要拉伸文字”“只改字号和版式适配”“按参考稿的宽屏风格重排”，就应该主动使用这个 skill。尤其适用于需要保留原始文案、字体风格、颜色系统与层级关系，但要把 16:9 或其他常规页面改造成超宽横版的场景。 --- # PPT Ultra-wide Relayout 这个 skill 用来处理“PPT 比例改造 + 文字重排”。 它适合这类任务： - 用户给了一份源 PPT，要改成更宽的横版比例 - 用户给了一份参考 PPT，希望页面比例和整体构图向参考稿靠拢 - 用户明确要求“不要拉伸文字，只能通过字号、框体、留白、位置来适配” - 用户希望保留原稿的主题色、字体、层级、内容顺序，只重新组织版面 ## 默认策略 默认优先使用“保留源稿高度 + 套用参考稿宽高比”的方式，而不是直接把页面压成参考稿的绝对尺寸。 这样更稳的原因是： - 不容易把纵向空间压扁 - 更符合“不要拉伸文字”的要求 - 文字和卡片可以通过字号、文本框宽度和留白来适配 ## 硬性约束 - 不要拉伸文字，不要通过非等比缩放让字形变扁或变长 - 不要直接把整页元素做机械等比缩放后交差 - 优先保留原稿的字体、字重、字号层级、颜色和阅读顺序 - 优先保留原稿每一页的元素顺序和块状关系，不要把本来成组的元素拆散 - 可以放大或缩小文本框，可以减小字号，可以改变换行位置 - 可以延展背景图、裁切背景图、重设图像位置，但要避免把人物或主体裁没 - 先做可编辑的 PPTX 输出，不要把内容转成纯图片页 ## 推荐工作流 ### 1. 先分析结构 ```bash python3 scripts/pptx_layout_dump.py /path/to/source.pptx --pretty python3 scripts/pptx_layout_dump.py /path/to/reference.pptx --pretty ``` 重点看： - 画布尺寸和宽高比 - 每页主要文本框数量 - 标题字号、正文字号、字体 - 是否存在全屏背景图、右侧主视觉图、底部页脚信息 ### 2. 决定目标画布 默认公式： ```text target_width = source_height * (reference_width / reference_height) target_height = source_height ``` 如果用户明确要求精确尺寸，才直接使用参考稿的 `width x height`。 ### 3. 先映射版心，再微调 超宽重排时，默认先建立一个“居中的内容安全区”，把原始版心整体映射进去，再做小幅微调。 优先遵守这些规则： - 不要把每个元素各自拉开 - 先保住原页的阅读顺序、成组关系和视觉节奏 - 新增空间优先用作留白、背景延展和辅助氛围 - 如果页面原本就偏左或偏右构图，可以保留这种倾向，但整体重心要稳定 - 如果某个文本框原本在一个卡片、描边块或色块里，重排后必须继续待在那个块里 ### 4. 再按页面类型处理 优先把每页归为以下类型之一： - 封面页 - 章节页 - 内容页 - 双列或多卡片页 - 步骤/方法论页 - 收尾页 页面信息很多时，不要为了“看起来像超宽”而把内容排得过散。优先保证层级、可读性和块内安全。 ## 脚本用法 ### 结构分析 ```bash python3 scripts/pptx_layout_dump.py /path/to/file.pptx --pretty ``` ### 首轮超宽重排 ```bash python3 scripts/pptx_ultrawide_relayout.py \ /path/to/source.pptx \ --reference /path/to/reference.pptx \ --output /path/to/output.pptx ``` 默认行为： - 保留源稿高度 - 按参考稿宽高比扩展画布 - 保持文字不被拉伸 - 先把原始版心整体映射到居中的安全区 - 再对背景、正文框、标题框和装饰元素做温和微调 - 尽量避免块内溢出、顺序错乱和过度横向发散 如果用户明确要求精确尺寸： ```bash python3 scripts/pptx_ultrawide_relayout.py \ /path/to/source.pptx \ --reference /path/to/reference.pptx \ --target-mode exact-reference \ --output /path/to/output.pptx ``` ## XML 改稿原则 PPTX 本质是 zip 包。常见关键文件： - `ppt/presentation.xml` - `ppt/slides/slideN.xml` - `ppt/theme/theme1.xml` 重点关注这些节点： - `p:sldSz`：画布宽高 - `a:xfrm`：元素位置和尺寸 - `a:off`：左上角坐标 - `a:ext`：宽高 - `a:rPr`：文本字号、字重、颜色、字体 文字适配时优先这样做： 1. 先改文本框尺寸和位置 2. 再看是否需要减小字号 3. 最后再处理换行和行距 不要这样做： - 直接对文字所在形状做横向或纵向非等比拉伸 - 不检查块边界和换行就统一缩小所有字号 ## 验收清单 - 每一页都已经换成目标比例 - 没有任何文字被拉伸变形 - 字号层级仍然清晰 - 标题和正文没有明显溢出 - 文本框没有冲出所属卡片、描边块或底色块 - 背景图没有露白边 - 页脚、页码、辅助装饰没有被甩到错误位置 - 阅读顺序与原稿一致，没有出现“元素虽然都在，但视觉关系散掉了”的问题 - 版式整体看起来像“重新设计过”，不是“被拖宽了”

path: README.md
content: # PPT Ultra-wide Relayout

Turn standard PowerPoint decks into cleaner ultra-wide layouts without stretching text.

想系统学习 AI 视频、PPT 重排、智能体工作流和相关自动化，欢迎联系西羊石 AI 视频团队。

微信号：`741375926`

如果页面图片未显示，也可以直接添加上面的微信号交流合作。

This skill focuses on:

- converting standard decks into ultra-wide layouts
- preserving font shape and reading order instead of stretching text
- adapting title blocks, content cards, and footers into a safer centered frame
- using reference decks for aspect ratio and visual direction
- exporting a still-editable `.pptx` instead of flattening slides into images

What is included:

- `SKILL.md` with the workflow and relayout rules
- `scripts/pptx_layout_dump.py` for PPTX structure analysis
- `scripts/pptx_ultrawide_relayout.py` for first-pass ultra-wide relayout
- `references/reference-style.md` for reference-ratio and composition guidance

What is not included:

- private source decks
- local test outputs
- environment-specific absolute paths

Recommended use cases:

- turning 16:9 decks into ultra-wide keynote-like pages
- re-laying out PPT covers, chapter pages, and dense content pages
- preserving grouped blocks and avoiding overflow in cards or background panels
- building an editable first draft before human fine-tuning


path: package.json
content: {
  "name": "ppt-ultrawide-relayout",
  "version": "1.0.0",
  "description": "Turn standard PPT decks into cleaner ultra-wide layouts without stretching text",
  "scripts": {
    "check:python": "python3 -m py_compile scripts/pptx_layout_dump.py scripts/pptx_ultrawide_relayout.py"
  },
  "keywords": [
    "ppt",
    "powerpoint",
    "presentation",
    "layout",
    "ultrawide",
    "design"
  ],
  "author": "",
  "license": "ISC"
}


path: references/reference-style.md
content: # Reference Style Notes

这份参考说明用于抽象“超宽横版 PPT”常见的比例和构图信号。

后续重排时，优先学习“比例和气质”，而不是逐字逐框照搬某一份参考稿。

## 画布比例

- 参考画布可以是明显比 16:9 更扁、更长的超宽横版
- 常见目标是保留源稿高度，只继承参考稿的宽高比
- 这样通常比直接压成参考稿绝对尺寸更稳

## 视觉气质

- 深色背景常常更适合承接超宽留白和氛围感
- 左侧更适合作为主标题和信息主区
- 右侧更适合承接光效、留白、背景氛围或辅助图

## 色彩线索

- 正文主色应保证对比度和可读性
- 强调色适合用于标签、短横线、章节标识和底部信息
- 不要因为扩宽画布就随意新增过多颜色

## 构图线索

- 标题通常不写满整行，而是以块状构图出现
- 新增的横向空间更像“呼吸区”，不是“必须塞内容的空白区”
- 底部适合做时间、地点、组织名等收束信息

## 对实际任务的启发

如果源稿是常规 16:9：

- 默认不要压缩高度去贴参考稿的绝对尺寸
- 更稳妥的做法是保留源稿高度，只继承参考稿的超宽比例
- 这样可以避免正文段落在垂直方向被压得太狠
- 文字仍可通过字号、行数、文本框宽度适配，而不是靠拉伸


path: scripts/pptx_layout_dump.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import re
from pathlib import Path
from zipfile import ZipFile

from lxml import etree


NS = {
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}


def local_name(tag: str) -> str:
    if "}" in tag:
        return tag.rsplit("}", 1)[1]
    return tag


def parse_xml(blob: bytes) -> etree._Element:
    parser = etree.XMLParser(remove_blank_text=False, recover=True)
    return etree.fromstring(blob, parser=parser)


def get_slide_size(presentation_root: etree._Element) -> dict[str, int]:
    node = presentation_root.find(".//p:sldSz", namespaces=NS)
    if node is None:
        raise ValueError("presentation.xml 中未找到 p:sldSz")
    return {"width": int(node.get("cx")), "height": int(node.get("cy"))}


def parse_theme(theme_root: etree._Element) -> dict[str, object]:
    colors: dict[str, str] = {}
    fonts: dict[str, str] = {}

    for scheme in theme_root.xpath(".//a:clrScheme/*", namespaces=NS):
        color_node = scheme.find(".//a:srgbClr", namespaces=NS)
        if color_node is not None and color_node.get("val"):
            colors[local_name(scheme.tag)] = color_node.get("val")

    font_scheme = theme_root.find(".//a:fontScheme", namespaces=NS)
    if font_scheme is not None:
        for group_name in ("majorFont", "minorFont"):
            group = font_scheme.find(f"./a:{group_name}", namespaces=NS)
            if group is None:
                continue
            for child_name in ("latin", "ea", "cs"):
                child = group.find(f"./a:{child_name}", namespaces=NS)
                if child is not None and child.get("typeface"):
                    fonts[f"{group_name}.{child_name}"] = child.get("typeface")

    return {"colors": colors, "fonts": fonts}


def find_xfrm(shape: etree._Element) -> etree._Element | None:
    xfrm = shape.find("./p:spPr/a:xfrm", namespaces=NS)
    if xfrm is not None:
        return xfrm
    return shape.find("./p:grpSpPr/a:xfrm", namespaces=NS)


def get_box(shape: etree._Element) -> dict[str, int] | None:
    xfrm = find_xfrm(shape)
    if xfrm is None:
        return None
    off = xfrm.find("./a:off", namespaces=NS)
    ext = xfrm.find("./a:ext", namespaces=NS)
    if off is None or ext is None:
        return None
    return {
        "x": int(off.get("x", "0")),
        "y": int(off.get("y", "0")),
        "width": int(ext.get("cx", "0")),
        "height": int(ext.get("cy", "0")),
    }


def collect_text(shape: etree._Element) -> list[str]:
    texts = shape.xpath(".//a:t/text()", namespaces=NS)
    return [t.strip() for t in texts if t and t.strip()]


def unique_ordered(values: list[str]) -> list[str]:
    seen: set[str] = set()
    ordered: list[str] = []
    for value in values:
        if value not in seen:
            seen.add(value)
            ordered.append(value)
    return ordered


def collect_font_sizes(shape: etree._Element) -> list[int]:
    sizes: list[int] = []
    for node in shape.xpath(".//a:rPr[@sz] | .//a:endParaRPr[@sz]", namespaces=NS):
        raw = node.get("sz")
        if raw:
            sizes.append(int(raw))
    return sizes


def collect_typefaces(shape: etree._Element) -> list[str]:
    faces: list[str] = []
    for node in shape.xpath(
        ".//a:rPr | .//a:endParaRPr | .//a:defRPr", namespaces=NS
    ):
        for child_name in ("ea", "latin", "cs"):
            child = node.find(f"./a:{child_name}", namespaces=NS)
            if child is not None and child.get("typeface"):
                faces.append(child.get("typeface"))
    return unique_ordered(faces)


def collect_colors(shape: etree._Element) -> list[str]:
    colors: list[str] = []
    for node in shape.xpath(".//a:solidFill", namespaces=NS):
        srgb = node.find("./a:srgbClr", namespaces=NS)
        if srgb is not None and srgb.get("val"):
            colors.append(srgb.get("val"))
        scheme = node.find("./a:schemeClr", namespaces=NS)
        if scheme is not None and scheme.get("val"):
            colors.append(f"scheme:{scheme.get('val')}")
    return unique_ordered(colors)


def summarize_shape(shape: etree._Element, slide_size: dict[str, int]) -> dict[str, object]:
    box = get_box(shape)
    texts = collect_text(shape)
    sizes = collect_font_sizes(shape)
    typefaces = collect_typefaces(shape)
    colors = collect_colors(shape)

    summary: dict[str, object] = {
        "shape_type": local_name(shape.tag),
        "name": None,
    }

    c_nv_pr = shape.find("./p:nvSpPr/p:cNvPr", namespaces=NS)
    if c_nv_pr is None:
        c_nv_pr = shape.find("./p:nvPicPr/p:cNvPr", namespaces=NS)
    if c_nv_pr is not None:
        summary["name"] = c_nv_pr.get("name")

    if box is not None:
        summary["box"] = box
        slide_w = slide_size["width"]
        slide_h = slide_size["height"]
        summary["box_ratio"] = {
            "x": round(box["x"] / slide_w, 4) if slide_w else 0,
            "y": round(box["y"] / slide_h, 4) if slide_h else 0,
            "width": round(box["width"] / slide_w, 4) if slide_w else 0,
            "height": round(box["height"] / slide_h, 4) if slide_h else 0,
        }

    if texts:
        summary["text"] = " | ".join(texts[:8])
        summary["text_count"] = len(texts)

    if sizes:
        summary["font_size_stats"] = {
            "min": min(sizes),
            "max": max(sizes),
            "distinct": sorted(set(sizes)),
        }

    if typefaces:
        summary["typefaces"] = typefaces

    if colors:
        summary["colors"] = colors

    return summary


def slide_sort_key(name: str) -> int:
    match = re.search(r"slide(\d+)\.xml$", name)
    return int(match.group(1)) if match else 0


def build_report(pptx_path: Path) -> dict[str, object]:
    with ZipFile(pptx_path) as zf:
        presentation = parse_xml(zf.read("ppt/presentation.xml"))
        slide_size = get_slide_size(presentation)

        theme = {}
        if "ppt/theme/theme1.xml" in zf.namelist():
            theme = parse_theme(parse_xml(zf.read("ppt/theme/theme1.xml")))

        slide_names = sorted(
            [name for name in zf.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", name)],
            key=slide_sort_key,
        )

        slides: list[dict[str, object]] = []
        for slide_name in slide_names:
            root = parse_xml(zf.read(slide_name))
            sp_tree = root.find(".//p:spTree", namespaces=NS)
            shape_summaries: list[dict[str, object]] = []
            slide_texts: list[str] = []

            if sp_tree is not None:
                for child in sp_tree:
                    if local_name(child.tag) not in {"sp", "pic", "grpSp", "graphicFrame"}:
                        continue
                    summary = summarize_shape(child, slide_size)
                    shape_summaries.append(summary)
                    text = summary.get("text")
                    if isinstance(text, str):
                        slide_texts.extend([part.strip() for part in text.split("|") if part.strip()])

            slides.append(
                {
                    "slide": slide_name,
                    "headline": " | ".join(slide_texts[:8]),
                    "shape_count": len(shape_summaries),
                    "shapes": shape_summaries,
                }
            )

    return {
        "file": str(pptx_path),
        "slide_size": slide_size,
        "aspect_ratio": round(slide_size["width"] / slide_size["height"], 4)
        if slide_size["height"]
        else None,
        "theme": theme,
        "slide_count": len(slides),
        "slides": slides,
    }


def main() -> None:
    parser = argparse.ArgumentParser(description="Dump PPTX layout and theme details as JSON.")
    parser.add_argument("pptx", type=Path, help="Path to a .pptx file")
    parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
    args = parser.parse_args()

    report = build_report(args.pptx)
    print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))


if __name__ == "__main__":
    main()


path: scripts/pptx_ultrawide_relayout.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import re
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile

from lxml import etree


NS = {
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}


def parse_xml(blob: bytes) -> etree._Element:
    parser = etree.XMLParser(remove_blank_text=False, recover=True)
    return etree.fromstring(blob, parser=parser)


def serialize_xml(root: etree._Element) -> bytes:
    return etree.tostring(
        root, encoding="UTF-8", xml_declaration=True, standalone=True
    )


def local_name(tag: str) -> str:
    if "}" in tag:
        return tag.rsplit("}", 1)[1]
    return tag


def find_xfrm(shape: etree._Element) -> etree._Element | None:
    xfrm = shape.find("./p:spPr/a:xfrm", namespaces=NS)
    if xfrm is not None:
        return xfrm
    return shape.find("./p:grpSpPr/a:xfrm", namespaces=NS)


def get_box(shape: etree._Element) -> dict[str, int] | None:
    xfrm = find_xfrm(shape)
    if xfrm is None:
        return None
    off = xfrm.find("./a:off", namespaces=NS)
    ext = xfrm.find("./a:ext", namespaces=NS)
    if off is None or ext is None:
        return None
    return {
        "x": int(off.get("x", "0")),
        "y": int(off.get("y", "0")),
        "width": int(ext.get("cx", "0")),
        "height": int(ext.get("cy", "0")),
    }


def set_box(shape: etree._Element, box: dict[str, int]) -> None:
    xfrm = find_xfrm(shape)
    if xfrm is None:
        return
    off = xfrm.find("./a:off", namespaces=NS)
    ext = xfrm.find("./a:ext", namespaces=NS)
    if off is None or ext is None:
        return
    off.set("x", str(int(box["x"])))
    off.set("y", str(int(box["y"])))
    ext.set("cx", str(int(box["width"])))
    ext.set("cy", str(int(box["height"])))


def get_slide_size(presentation_root: etree._Element) -> tuple[int, int]:
    node = presentation_root.find(".//p:sldSz", namespaces=NS)
    if node is None:
        raise ValueError("presentation.xml 中未找到 p:sldSz")
    return int(node.get("cx")), int(node.get("cy"))


def set_slide_size(presentation_root: etree._Element, width: int, height: int) -> None:
    node = presentation_root.find(".//p:sldSz", namespaces=NS)
    if node is None:
        raise ValueError("presentation.xml 中未找到 p:sldSz")
    node.set("cx", str(width))
    node.set("cy", str(height))


def collect_text(shape: etree._Element) -> str:
    texts = [t.strip() for t in shape.xpath(".//a:t/text()", namespaces=NS) if t and t.strip()]
    return " ".join(texts)


def font_sizes(shape: etree._Element) -> list[int]:
    sizes: list[int] = []
    for node in shape.xpath(".//a:rPr[@sz] | .//a:endParaRPr[@sz]", namespaces=NS):
        raw = node.get("sz")
        if raw:
            sizes.append(int(raw))
    return sizes


def scale_fonts(shape: etree._Element, factor: float) -> None:
    if factor <= 0:
        return
    for node in shape.xpath(".//a:rPr[@sz] | .//a:endParaRPr[@sz]", namespaces=NS):
        raw = node.get("sz")
        if not raw:
            continue
        new_size = max(800, int(round(int(raw) * factor)))
        node.set("sz", str(new_size))


def enable_text_autofit(shape: etree._Element) -> None:
    tx_body = shape.find("./p:txBody", namespaces=NS)
    if tx_body is None:
        return
    body_pr = tx_body.find("./a:bodyPr", namespaces=NS)
    if body_pr is None:
        return

    for child in list(body_pr):
        if local_name(child.tag) in {"noAutofit", "normAutofit", "spAutoFit"}:
            body_pr.remove(child)

    autofit = etree.SubElement(body_pr, f"{{{NS['a']}}}normAutofit")
    autofit.set("fontScale", "100000")
    autofit.set("lnSpcReduction", "0")


def classify_slide(slide_root: etree._Element, slide_index: int, total_slides: int) -> str:
    texts = [t.strip().upper() for t in slide_root.xpath(".//a:t/text()", namespaces=NS) if t and t.strip()]
    joined = " ".join(texts)
    if slide_index == 1:
        return "cover"
    if slide_index == total_slides:
        return "closing"
    if "CHAPTER" in joined:
        return "chapter"
    if any(token in joined for token in ["CONTENTS", "目录"]):
        return "contents"
    return "content"


def is_text_shape(shape: etree._Element) -> bool:
    return shape.find("./p:txBody", namespaces=NS) is not None


def is_picture(shape: etree._Element) -> bool:
    return local_name(shape.tag) == "pic"


def is_full_bleed(box: dict[str, int], old_w: int, old_h: int) -> bool:
    return (
        box["x"] <= old_w * 0.03
        and box["y"] <= old_h * 0.03
        and box["width"] >= old_w * 0.94
        and box["height"] >= old_h * 0.94
    )


def is_horizontal_rule(box: dict[str, int], old_w: int, old_h: int) -> bool:
    return box["height"] <= old_h * 0.025 and box["width"] >= old_w * 0.05


def frame_scale_for_slide(slide_kind: str, old_w: int, new_w: int) -> float:
    growth = max(0.0, new_w / old_w - 1.0) if old_w else 0.0
    weight = {
        "cover": 0.10,
        "chapter": 0.09,
        "closing": 0.10,
        "contents": 0.14,
        "content": 0.16,
    }.get(slide_kind, 0.14)
    return min(1.0 + growth * weight, (new_w / old_w) * 0.92 if old_w else 1.0)


def clamp_box(box: dict[str, int], slide_w: int, slide_h: int) -> dict[str, int]:
    clamped = dict(box)
    clamped["width"] = max(1, min(clamped["width"], slide_w))
    clamped["height"] = max(1, min(clamped["height"], slide_h))
    clamped["x"] = max(0, min(clamped["x"], slide_w - clamped["width"]))
    clamped["y"] = max(0, min(clamped["y"], slide_h - clamped["height"]))
    return clamped


def adjust_box(
    shape: etree._Element,
    box: dict[str, int],
    old_w: int,
    old_h: int,
    new_w: int,
    new_h: int,
    slide_kind: str,
    target_mode: str,
) -> dict[str, int]:
    x_scale = new_w / old_w if old_w else 1.0
    y_scale = new_h / old_h if old_h else 1.0

    if is_full_bleed(box, old_w, old_h):
        return {"x": 0, "y": 0, "width": new_w, "height": new_h}

    frame_scale = frame_scale_for_slide(slide_kind, old_w, new_w)
    frame_w = int(round(old_w * frame_scale))
    frame_x = int(round((new_w - frame_w) / 2))

    new_box = {
        "x": int(round(frame_x + box["x"] * frame_scale)),
        "y": box["y"],
        "width": int(round(box["width"] * frame_scale)),
        "height": box["height"],
    }

    if target_mode == "exact-reference":
        new_box["y"] = int(round(box["y"] * y_scale))
        new_box["height"] = max(1, int(round(box["height"] * y_scale)))

    if is_horizontal_rule(box, old_w, old_h):
        new_box["width"] = int(round(box["width"] * max(frame_scale, 1.14)))

    if is_text_shape(shape):
        text = collect_text(shape)
        sizes = font_sizes(shape)
        max_size = max(sizes) if sizes else 0
        title_like = max_size >= 3000 or len(text) <= 30

        if title_like:
            new_box["width"] = int(round(box["width"] * min(frame_scale, 1.08)))
        else:
            new_box["width"] = int(round(box["width"] * min(frame_scale, 1.12)))
            if max_size and max_size <= 2200:
                enable_text_autofit(shape)

        if target_mode == "exact-reference":
            font_factor = min(x_scale, y_scale)
            if not title_like:
                font_factor *= 0.96
            scale_fonts(shape, font_factor)

    return clamp_box(new_box, new_w, new_h)


def slide_sort_key(name: str) -> int:
    match = re.search(r"slide(\d+)\.xml$", name)
    return int(match.group(1)) if match else 0


def transform_pptx(
    source: Path,
    reference: Path,
    output: Path,
    target_mode: str,
) -> dict[str, object]:
    with ZipFile(source) as src_zip, ZipFile(reference) as ref_zip:
        presentation_root = parse_xml(src_zip.read("ppt/presentation.xml"))
        old_w, old_h = get_slide_size(presentation_root)

        ref_presentation = parse_xml(ref_zip.read("ppt/presentation.xml"))
        ref_w, ref_h = get_slide_size(ref_presentation)
        ref_ratio = ref_w / ref_h

        if target_mode == "exact-reference":
            new_w, new_h = ref_w, ref_h
        else:
            new_h = old_h
            new_w = int(round(old_h * ref_ratio))

        set_slide_size(presentation_root, new_w, new_h)

        slide_names = sorted(
            [name for name in src_zip.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", name)],
            key=slide_sort_key,
        )
        total_slides = len(slide_names)

        transformed: dict[str, bytes] = {
            "ppt/presentation.xml": serialize_xml(presentation_root)
        }
        slide_reports: list[dict[str, object]] = []

        for index, slide_name in enumerate(slide_names, start=1):
            root = parse_xml(src_zip.read(slide_name))
            sp_tree = root.find(".//p:spTree", namespaces=NS)
            slide_kind = classify_slide(root, index, total_slides)
            adjusted_shapes = 0

            if sp_tree is not None:
                for child in sp_tree:
                    if local_name(child.tag) not in {"sp", "pic", "grpSp", "graphicFrame"}:
                        continue
                    box = get_box(child)
                    if box is None:
                        continue
                    new_box = adjust_box(
                        child, box, old_w, old_h, new_w, new_h, slide_kind, target_mode
                    )
                    if new_box != box:
                        set_box(child, new_box)
                        adjusted_shapes += 1

            transformed[slide_name] = serialize_xml(root)
            slide_reports.append(
                {
                    "slide": slide_name,
                    "slide_kind": slide_kind,
                    "adjusted_shapes": adjusted_shapes,
                }
            )

        with ZipFile(output, "w", compression=ZIP_DEFLATED) as out_zip:
            for info in src_zip.infolist():
                data = transformed.get(info.filename)
                if data is None:
                    data = src_zip.read(info.filename)
                out_zip.writestr(info.filename, data)

    return {
        "source": str(source),
        "reference": str(reference),
        "output": str(output),
        "target_mode": target_mode,
        "old_size": {"width": old_w, "height": old_h},
        "new_size": {"width": new_w, "height": new_h},
        "aspect_ratio": round(new_w / new_h, 4) if new_h else None,
        "slides": slide_reports,
    }


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Retarget a PPTX to an ultra-wide aspect ratio without stretching text."
    )
    parser.add_argument("source", type=Path, help="Source PPTX")
    parser.add_argument("--reference", type=Path, required=True, help="Reference PPTX")
    parser.add_argument("--output", type=Path, required=True, help="Output PPTX path")
    parser.add_argument(
        "--target-mode",
        choices=["preserve-height", "exact-reference"],
        default="preserve-height",
        help="preserve-height: keep source height and use reference aspect ratio; exact-reference: use the reference canvas size exactly",
    )
    parser.add_argument("--report", action="store_true", help="Print JSON report")
    args = parser.parse_args()

    args.output.parent.mkdir(parents=True, exist_ok=True)
    report = transform_pptx(
        source=args.source,
        reference=args.reference,
        output=args.output,
        target_mode=args.target_mode,
    )

    if args.report:
        print(json.dumps(report, ensure_ascii=False, indent=2))
    else:
        print(f"Wrote {args.output}")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)