feat: 内置技能全量收录 presentation-forge (#83)

## 概述
将 PPT 工作流技能 **presentation-forge**(原 codex-ppt-skill)以 **builtin
内置技能**形态全量收录进 DesireCore 官方市场。

> 注:本 PR 最初以 entry.json pointer 形态提交,现按仓库负责人意见改为 builtin 层全量
vendoring。分支保留两个提交(pointer → builtin 转换)。

## 形态:builtin 全量 vendoring(遵循 guizang-ppt 先例)
- 全量内容进 `skills/presentation-forge/`:`SKILL.md` + `SKILL.zh-CN.md` +
`references/` + `scripts/` + `styles/`(9 套风格)+ `templates/` +
`schemas/`。
- builtin frontmatter:`version/type/risk_level/status/tags` +
`metadata.i18n`(zh-CN/en-US)+
`market.icon/category/maintainer`,`category: creative`。
- 列入 `builtin-skills.json`(33 项)。
- 附 `LICENSE`(MIT)+ `NOTICE.md` +
`_desirecore/{frontmatter.yaml,upstream.json}` 溯源。
- 溯源 commit:`mashagua/presentation-forge@b7b1a9c`(MIT)。
- 排除非运行内容:`tests/` 夹具、未被任何 vendored 文件引用的 1.3MB 流程图、Codex `agents/` 绑定。

## 变更
- 新增 `skills/presentation-forge/`(全量内容,约 440KB)
- `builtin-skills.json`:新增 `presentation-forge`(32 → 33)
- `manifest.json`:`stats.totalSkills` 60 → 61,`version` 1.2.26 →
1.2.27,`lastUpdated` → 2026-08-10
- `README.md`:built-in 32 → 33、external 29 → 28、total 61 不变,两处代码块同步

## 验证
```
uv run scripts/i18n/validate-i18n.py
OK: no i18n issues found.
```
stats 公式核对:entry.json 目录 28 + SKILL.md 目录 33 = 61 = manifest.totalSkills
✓

---------

Co-authored-by: Yige <a@wyr.me>
Co-authored-by: yige <yige@yigedeMacBook-Neo.local>
This commit is contained in:
mashagua
2026-08-23 23:31:26 +08:00
committed by GitHub
parent 5a0a80d31c
commit 98ffc8fe92
84 changed files with 11125 additions and 5 deletions

View File

@@ -23,9 +23,9 @@ DesireCore 官方市场仓库,存放官方维护的 Agent/Skill 定义,以
The market currently contains: The market currently contains:
- `1` Agent: `desirecore` - `1` Agent: `desirecore`
- `32` local built-in skills with `SKILL.md` - `33` local built-in skills with `SKILL.md`
- `28` external skill entries with `entry.json` - `28` external skill entries with `entry.json`
- `60` publishable skills in total (`SKILL.md` + `entry.json`) - `61` publishable skills in total (`SKILL.md` + `entry.json`)
## Skill Sources ## Skill Sources
@@ -36,7 +36,7 @@ code-intelligence, configuring-compute, create-agent, dashscope-image-gen, delet
dev-environment-setup, discover-agent, docx, frontend-design, guizang-ppt, dev-environment-setup, discover-agent, docx, frontend-design, guizang-ppt,
image-to-image, mail-operations, manage-skills, manage-teams, markdown, image-to-image, mail-operations, manage-skills, manage-teams, markdown,
minimax-music-gen, minimax-video-gen, nodejs-runtime, pdf, pptx, minimax-music-gen, minimax-video-gen, nodejs-runtime, pdf, pptx,
python-runtime, registering-services, s3-storage-operations, skill-creator, presentation-forge, python-runtime, registering-services, s3-storage-operations, skill-creator,
tech-diagram, update-agent, using-services, web-access, workflow, workforce-optimization, tech-diagram, update-agent, using-services, web-access, workflow, workforce-optimization,
xiaomi-tts, xlsx xiaomi-tts, xlsx
``` ```

View File

@@ -20,6 +20,7 @@
"nodejs-runtime", "nodejs-runtime",
"pdf", "pdf",
"pptx", "pptx",
"presentation-forge",
"python-runtime", "python-runtime",
"registering-services", "registering-services",
"s3-storage-operations", "s3-storage-operations",

View File

@@ -1,6 +1,6 @@
{ {
"name": "DesireCore Official Market", "name": "DesireCore Official Market",
"version": "1.2.28", "version": "1.2.29",
"schemaVersion": "1.1.0", "schemaVersion": "1.1.0",
"supportedLocales": ["zh-CN", "en-US"], "supportedLocales": ["zh-CN", "en-US"],
"defaultLocale": "en-US", "defaultLocale": "en-US",
@@ -27,7 +27,7 @@
}, },
"stats": { "stats": {
"totalAgents": 1, "totalAgents": 1,
"totalSkills": 60, "totalSkills": 61,
"lastUpdated": "2026-08-23" "lastUpdated": "2026-08-23"
}, },
"features": [ "features": [

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ronnie2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,22 @@
# NOTICE — presentation-forge
本技能skill id`presentation-forge`)的内容 vendored搬运自上游开源项目并在 DesireCore 官方市场以聚合形式分发。
## 来源 / Attribution
- **上游项目**presentation-forge原名 codex-ppt-skill
- **作者 / Author**Ronnie2025
- **规范源仓库 / Canonical repo**https://github.com/mashagua/presentation-forge
- **许可证 / License**MIT见同目录 `LICENSE`
DesireCore 仅对原作品进行打包分发与元数据适配(`_desirecore/frontmatter.yaml` 覆盖层),未修改 `references/``assets/``scripts/``styles/``templates/``schemas/``SKILL.md` 的实质内容。上游 commit 溯源记录见 `_desirecore/upstream.json`
## 许可与合规 / License & Compliance
本技能遵循 **MIT**。再分发或修改时须保留版权与许可声明。完整许可文本见 `LICENSE`
## 维护方式 / Maintenance
- DesireCore 维护态(不随上游覆盖):`_desirecore/`
- `frontmatter.yaml`DesireCore 市场 frontmatter 覆盖层
- `upstream.json`:上游 commit / ref 溯源

View File

@@ -0,0 +1,382 @@
---
name: presentation-forge
description: "创建中文商业汇报,从主题、文档或 slides.md 生成原生可编辑 PPTX提供 Markdown-first 实时画布、样式切换和同版导出,使用 image-2 辅助生成独立视觉资产,把截图或图片型幻灯片重建为可编辑 PPTX/SVG或用原生 PPTX 模板填充新内容。用户要求制作售前方案、项目汇报、产品介绍、行业分析、培训材料、像 Markdown 一样编辑并导出、AIPPT 式可编辑演示、套用 PowerPoint 模板、图片页转可编辑 PowerPoint、重建幻灯片或输出 SVG 时使用。"
version: 0.1.0
type: procedural
risk_level: low
status: enabled
tags:
- ppt
- pptx
- svg
- presentation
- slides
metadata:
author: Ronnie2025
updated_at: '2026-08-10'
i18n:
default_locale: en-US
source_locale: zh-CN
locales:
- zh-CN
- en-US
zh-CN:
name: Presentation Forge
short_desc: 中文 toB 商业汇报 PPT 工作流:整页生图、图片页重建为可编辑 PPTX/SVG、原生模板填充适配 Codex/Claude Code
description: >-
面向中文 toB 商业汇报的 PPT 工作流,支持整页生图、把截图或图片型幻灯片重建为可编辑 PPTX/SVG或用原生 PPTX
模板直接填充新内容。制作售前方案、项目汇报、产品介绍、行业分析、培训材料,或图片页转可编辑 PowerPoint 时使用。
body: ./SKILL.zh-CN.md
translated_by: human
en-US:
name: Presentation Forge
short_desc: Chinese toB business-presentation workflow — image decks, rebuild slides into editable PPTX/SVG, native template filling
description: >-
Chinese toB business-presentation workflow: full-page image decks,
rebuilding image/screenshot slides into editable PPTX/SVG, and native
PPTX template filling. Use for pre-sales decks, project reports, product
intros, industry analysis, training material, or image-to-editable
PowerPoint. The skill body is authored in Chinese, so the en-US locale
intentionally falls back to the shared body and is locked as
human-maintained.
translated_by: human
source_hash: 'sha256:49a6a6b6867b2d1b'
body: ./SKILL.md
market:
icon: >-
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0
24 24" fill="none"><rect x="3" y="4" width="18" height="12" rx="1.5"
stroke="#2563EB" stroke-width="1.7"/><path d="M12 16v4M8.5 20h7"
stroke="#2563EB" stroke-width="1.7" stroke-linecap="round"/><path d="m6.5 12
3-3 2.2 2.2L16 7" stroke="#2563EB" stroke-width="1.7" stroke-linecap="round"
stroke-linejoin="round"/></svg>
short_desc: Chinese toB business-presentation workflow — image decks, editable PPTX/SVG rebuild, native template filling
category: creative
maintainer:
name: Ronnie2025
verified: false
channel: latest
---
# Presentation Forge
本文件是给执行体使用的路由和操作规程。面向用户的项目说明、效果图和安装命令见上游仓库 <https://github.com/mashagua/presentation-forge>。
先判断输入来源、沟通任务和最终目标:
1. **新做且重视觉的 PPT 汇报**:走整页生图 PPT生成图片型 PPTX / PDF / PNG。
2. **已有图片页、截图页、旧 PPT 渲染页,或整页生图页**:再根据目标进入拆解。
3. **要 PowerPoint 内继续改字、移元素、换图标**:走元素重组。
4. **只要 SVG、网页/文档复用,或低成本看结构**:走 SVG 拆解。
5. **提供原生 PPTX 模板和新内容,要求保留模板设计直接填充**:走原生 PPTX 模板填充。
6. **从主题或文档新做演示或可编辑 PPTX**:默认走 Markdown-first Canvas`slides.md` 为人工内容源,先展示完整可编辑画布;确认后使用原生可编辑 Deck 导出 PPTX。
7. **用户明确要求直接导出、不要画布或跳过预览**:走原生可编辑 Deck 的 `direct-build`;默认使用 `native-image-assisted`,文字、结构、表格和图表保持原生。
路径可以串联:先用整页生图 PPT 形成视觉页;如果后续需要编辑或复用,再把单页 PNG 交给元素重组或 SVG 拆解继续处理。从零新做演示时默认进入 Markdown-first Canvas已有图片页要求可编辑时走元素重组。SVG 拆解不是默认的 PPT 可编辑路线。
## 路由判断
### 使用单一权威路由
完整读取 [references/routing-workflow.md](references/routing-workflow.md),把用户意图写成结构化 request JSON并运行 `scripts/route_deck_workflow.py``reports/route-decision.json` 是唯一有效的路线决定:
- `PASS`:只读取并执行 `authority` 指向的路线,不并行准备其他路线的产物。
- `NEEDS_INPUT`:只询问报告中的一个 `blocking_question`,不要展示路线菜单。
- `BLOCKED`:补齐 `missing_prerequisites`,不要静默降级。
手工启发式只能用于填写 request JSON不能覆盖脚本结果。
### 建立最低可执行简报
开始制作前,读取用户材料,并确认以下字段:
- 材料:附件、文件路径、网页内容或用户提供的事实。
- 汇报类型:售前方案、项目汇报、产品介绍、行业分析、复盘、培训或其他。
- 受众:具体角色、行业、决策层级和已有认知。
- 沟通目标:希望受众理解什么、相信什么、最终采取什么行动。
- 使用场合:现场演示、线上会议、邮件发送、留档或自行阅读。
- 篇幅:页数或汇报时长;页数是约束,不是内容目标。
- 视觉与品牌:风格、主色、模板、真实 Logo 和禁止内容。
- 编辑要求与交付:图片型 PPTX、可编辑 PPTX、PDF、PNG、SVG、提示词或 QA 文件。
如果用户要求交付 `PPTX`,但没有明确说明是否需要编辑,必须先询问并确认选择“图片型 PPTX”还是“可编辑 PPTX”在获得回答前不得默认进入整页生图或元素重组。
新做 PPT 时,读取 [references/planning-workflow.md](references/planning-workflow.md),并把确认后的编辑要求写入 session 的 `metadata.json`。禁止使用含义模糊的 `pptx` 作为交付类型;只能记录 `image-pptx``editable-pptx`
如果缺少“材料、汇报类型、受众、沟通目标”中的任意两项,先提出不超过 3 个关键问题,不要直接生成页面。若用户授权自行判断,列出明确假设后继续规划。
逐页大纲始终写入 `slides_plan.md`,但默认采用连续执行:生成大纲后直接进入后续制作,不因大纲暂停,也不把“请先判断缺失信息”“使用明确假设”视为等待确认。只有用户明确表达“先输出大纲”“等我确认”“确认后再生成”“暂时不要制作”等审阅意图时,才展示大纲并等待;将 `outline_review_mode` 记录为 `explicit``outline_approval` 记录为 `pending`。其他情况记录 `outline_review_mode: continuous``outline_approval: auto-proceed`。用户明确要求审阅时不得静默越过确认门。
不得仅凭宽泛的行业或风格描述虚构客户背景、业务数据、产品能力、案例或收益。
### 选择交付路线
确认这四类条件:
- 最终交付:图片型 PPTX / PDF / PNG / SVG / 可编辑 PPTX。
- 是否需要后续编辑:不编辑、少量替换、还是对象级编辑。
- 输入材料:文档、旧 PPT、截图、图片页、整页生图页、参考模板。
- 质量优先级视觉观感、可编辑性、速度、token 成本、结构复用。
直接选择:
- 用户只说要 `PPTX`、未说明可编辑性:暂停路线选择,先询问需要“图片型 PPTX”还是“可编辑 PPTX”。
- 用户要新做汇报,内容偏观点表达、概念框架或方案展示,且视觉完成度优先:选择 **整页生图 PPT**
- 用户要从主题或文档新做演示或可编辑 PPTX且没有明确要求跳过画布默认选择 **Markdown-first Canvas**,设置 `authoring_mode=markdown-canvas``editor_workflow_mode=canvas-first`,并锁定 `native-editable-deck` 构建 authority。
- 用户明确说“直接导出”“不要画布”“跳过预览”:选择 **原生可编辑 Deck**`slides-plan + direct-build`。默认 `native-image-assisted`;用户明确不生图时使用 `native-only`,强视觉且仍要求可编辑时使用 `image-led-editable`
- 用户要新做汇报,包含密集数据、财务表格、需频繁更新的数字、大量脚注或长期协作模板:选择原生可编辑 Deck 的 `native-only`,不要走整页生图。
- 用户已有图片页,且明确要可编辑 PPTX、PowerPoint 内改字、移动元素、替换图标、元素化重建或图片页转可编辑:强制选择 **元素重组**
- 用户已有图片页,且明确要 SVG、网页/文档复用,或明确接受低成本结构样板:选择 **SVG 拆解**
- 用户提供原生 PPTX 模板和新内容,并要求保留模板设计、直接替换文字:选择 **原生 PPTX 模板填充**;不要先转成 SVG 或整页图片。
- 用户既重视视觉又要求后续编辑:优先原生可编辑 Deck 的 `native-image-assisted`;只有明确接受图片主导并需要参考页重建时才先整页生图再元素重组。
成本提示元素重组通常更耗时、token 更高,但 PPTX 可编辑性更强SVG 拆解通常更快更省,但复杂视觉会被简化,导入 PowerPoint 后不承诺对象级稳定可编辑。
如果用户目标是可编辑 PPTX但当前时间、token、图像生成能力或资产素材不足以完成元素重组必须明确说明只能交付降级草稿或改走 SVG 结构预览;不要自动把 SVG 拆解、原生形状重画或通用图标拼装称为元素重组完成。
不适配时直接说明原因,并建议改用原生可编辑 PPT、数据报表、文档或设计工具流程。
### 建立统一 Session
确定交付路线后,在生成大纲、提示词、图片或 PPTX 前,先用 `scripts/init_deck_session.py` 建立独立 session。所有源材料、大纲、结构化提示词、生成结果、渲染文件和 QA 报告都写入该 session不要把同一任务的产物散落在工作区根目录。
Session 必须至少包含 `slides_plan.md``prompts.json``metadata.json``sources/``analysis/``references/``generated/``assets/``final/``render/``compare/``reports/`。详细约定见 [references/planning-workflow.md](references/planning-workflow.md)。
### 使用统一 Scene 与 Revision
当新做 PPT、把图片版升级为可编辑版、修改已生成页面或需要回滚时读取 [references/scene-workflow.md](references/scene-workflow.md)。使用 `scenes/slide-NNN.scene.json` 作为页面语义事实源;图片版和可编辑版共享同一个 `metadata.json`,禁止复制成两个互相漂移的项目。
生产构建前必须:
1.`scripts/compile_scenes.py` 从当前有效计划和 prompts 编译或更新 scene`explicit` 模式下必须先获得大纲批准。
2.`scripts/validate_scene.py` 校验所有 scene。
3.`scripts/preflight_ppt_environment.py` 检查字体、构建依赖和渲染后端;默认 `gui_validation_mode: final-only`,中间预检不得启动 PowerPoint`BLOCKED` 时不得宣称完成生产 QA。
4.`scripts/revision_session.py snapshot` 建立 revision 后再进行正式生成或局部修改。
只改一页时使用 `scripts/rebuild_slide.py prepare` 判断该页是否失效;只重新生成目标页的昂贵资产,再用 `commit` 登记新产物。其他页必须保持原 hash 和缓存。最终 PPTX 可以整套快速重新封装,不直接修改脆弱的 OOXML 单页关系。
回滚必须使用 `scripts/revision_session.py rollback`。脚本会先保存当前状态,再恢复目标 revision 并创建新的 rollback revision不要覆盖或删除历史版本。
### 通过统一质量门
所有路线的最后一步都运行 `scripts/run_quality_gate.py --session <session> --artifact <artifact>`。该脚本校验路线报告、session 元数据、路线专属证据和最终文件,并写入 `reports/quality-gate.json`
- 只有 `PASS` 可以表述为正式完成。
- `WARN` 必须说明警告及人工复核结果。
- `BLOCKED` 只能报告阻塞点和已生成草稿,不得把某条子验证通过说成整套交付通过。
不要为不同路线另设互不兼容的“最终完成”标准;路线内部验证只能作为统一质量门的证据。
### 字体与渲染后端
- 新做可编辑 PPTX、原生模板填充或正式中文汇报时完整读取 [references/typography-workflow.md](references/typography-workflow.md)。从 `styles/catalog.json` 继承 `typography_profile``table_profile`,并写入 `metadata.json`;用户模板或品牌规范优先。
- 图片型 PPT 的 PDF 必须由最终页 PNG 直接合并;禁止再经 LibreOffice 或 PowerPoint 转换。
- 可编辑 PPTX 的每个中文文本 run 必须显式写入 DrawingML `latin``ea``cs` 字体,并同步 Theme major/minor 东亚字体;构建后运行 `scripts/validate_pptx_fonts.py`
- 可编辑 PPTX 构建后运行 `scripts/validate_pptx_typography.py`,检查语义字号 token、`0.5pt` 网格、段落设置和表格对齐;将结果写入 `reports/typography-validation.json``WARN` 必须说明,`NOT_CHECKED` 不得算作通过。
- `scripts/preflight_ppt_environment.py` 默认只做静态环境检查并把真实渲染推迟到最终 artifact。只有 `gui_validation_mode: eager` 才在中间执行中文真实导出探测。
- macOS PowerPoint 自动化需要系统 Automation/Apple Events 权限。代理运行真实探测时应申请 GUI/自动化执行权限;被系统拒绝时记录为 `BLOCKED`,不要循环重试或临时改写 AppleScript。
-`scripts/render_pptx.py` 统一导出。默认中间调用使用 `--validation-stage intermediate --gui-validation-mode final-only` 并返回 `DEFERRED`;最终构建完成后仅调用一次 `--validation-stage final`。PowerPoint 成功标记 `PASS`LibreOffice 只能标记 `APPROXIMATE`,不得作为正式中文可编辑 PPTX 的最终视觉证据。
- 可编辑 PPTX 的正式视觉验收以目标 PowerPoint 后端为准LibreOffice 仅用于发现明显结构问题。
- 原生模板填充禁止用 `xml.etree.ElementTree` round-trip OOXML必须保留 `p14``a14` 等原始前缀,并在交付前运行 `scripts/validate_ooxml_namespaces.py`
- 用户指定宋体等字体时,在 `fill_plan.json.font_policy` 记录字体与作用域。默认只作用于替换文字;只有用户明确要求全局统一时才使用 `all-selected-text``theme-and-replaced`
### 选择商业风格
新做 PPT 时读取 [references/style-library.md](references/style-library.md) 和 `styles/catalog.json`,根据受众、行业、目标和场合推荐 12 套风格。用户未指定且风格选择不影响内容方向时,可以采用最匹配的一套,但必须在大纲中明确标注为假设。
用户要求画布、可视化编辑、先编辑后导出或样式选择器时,强制使用 `canvas-first`:展示 24 个预设不自动把推荐第一项确认为最终风格。AI、科技、售前等关键词只能影响推荐排序不能强制选择深蓝。
canvas-first 必须展示用户要求的完整页数并逐页可切换:用户说 10 页,就先从 manifest 动态渲染 10 个缩略图和 10 页 scene。禁止交付固定 4 页的界面示意,禁止点击缩略图只改变边框而不载入该页对象。导出必须提交全量 scene并从同一 scene 原生构建 PPTX使页面数量、文字、对象、坐标、样式和层级与画布一致不要在导出后另起一套布局或只后处理封面。完整合同见 [references/editor-ui-integration.md](references/editor-ui-integration.md)。
选定后完整读取 `styles/<style_id>/STYLE.md``layouts.json`,并执行:
-`style_id``style_variant` 写入 `slides_plan.md` frontmatter、`prompts.json``metadata.json`。风格库提供 8 个家族、24 个可选预设。
- 将风格绑定的 `typography_profile``table_profile` 写入同一组文件;字号由语义 token 取得,不在页面代码中临时发明任意小数档位。
- 每页选择一个 `layout_id`,写入逐页计划与结构化 prompt不得超过该布局的 `content_capacity`,超出时拆页。
- 冒烟页使用正式风格和正式布局。用户确认的是该风格方向,不代表允许虚构图片、案例或数字。
- 连续页面避免完全相同的构图;优先复用标记为 `reuse_friendly: true` 的内容布局。
- 用户提供真实品牌模板或品牌规范时,以用户材料为准;内置风格只补足未定义部分。
### 通过构建前设计门
所有新生成路线在正式构建或全量生图前完整读取 [references/design-quality-workflow.md](references/design-quality-workflow.md),并运行 `scripts/validate_design_quality.py --session <session>`。禁止无意义卡片化、过量圆角矩形、无理由窄边强调条、默认阴影或渐变、形状上叠独立文本框、整页可移动图片背景,以及把设计说明或讲者提示写入正文。硬错误必须先修复;启发式警告只有在 spec 中记录页码、规则代码和明确理由后才能豁免。最终统一质量门必须读取 `reports/design-quality.json`
## 路径 A整页生图 PPT
适合单点沟通、重视觉、接受图片化交付的材料,例如方案汇报、售前交流、内部同步、培训讲解、复盘展示。
执行步骤:
1. 按“最低可执行简报”确认材料、汇报类型、受众、沟通目标、使用场合、篇幅、风格、品牌、输出格式和禁止内容。
2. 建立 session选择或确认商业风格读取用户材料按 [references/planning-workflow.md](references/planning-workflow.md) 写 `slides_plan.md`。它是内容 source of truth必须包含 `style_id`、页面类型、`layout_id`、标题、页面目标、核心信息、事实边界、素材和布局意图。
3. 根据 `outline_review_mode` 处理计划:`continuous` 直接继续;`explicit``slides_plan.md` 交给用户并等待确认。需要修改文案时只修改 Markdown任何 JSON 都是派生产物,不作为人工编辑源。
4. 按 [references/prompt-patterns.md](references/prompt-patterns.md) 将计划编译为结构化 `prompts.json`。每页记录 `slide_number``page_type``style_id``layout_id``layout_intent`、完整 `prompt`、参考图和生成状态;禁止只保存无法映射回页码的散装 prompt 文本。
5. 先生成 1 页视觉冒烟:默认封面;如果封面信息简单而其他页面风险更高,选择架构页、数据页或信息最密集的代表页。向用户展示样张并等待确认后再生成全量。只有用户明确要求跳过、或整套只有 1 页时,才可跳过确认。
6. 用户确认冒烟页后,调用 imagegen 生成其余 16:9 整页图。中文少而大,避免密集表格和小字号脚注;已通过的页面不要因局部问题一起重生。
7. 可以使用用户提供或明确授权的真实公司 Logo。用透明 PNG 或 SVG 原文件在后处理阶段叠加,不交给 imagegen 生成或仿造;未提供 Logo 时只预留位置。
8.`scripts/package_image_deck.py``make pack` 封装 PPTXPDF 由最终页 PNG 直接合并,可选输出每页 PNG 和 contact sheet。
9. 检查页数、尺寸、文字可读性、Logo 位置、伪 Logo、敏感信息和明显错字并把结果写入 session 的 `reports/``metadata.json`
默认交付:
- 图片型 PPTX
- PDF 或每页 PNG按用户要求
- contact sheet
- 逐页提示词和 QA 记录
边界:图片型 PPT 不等于可编辑 PPT。正文、图表和版式在图片里不能承诺 PowerPoint 内逐字逐对象编辑。
## 路径 B元素重组
适合用户已经有图片页、截图页、图片型 PPT或路径 A 生成的页图,并且希望重建为实用级可编辑 PPTX。
路径 B 是参考图驱动的语义资产重组,不是 SVG 反编译,也不是只用 PPT 原生形状画一张可编辑草稿。
核心判断:
- 文本、标题、卡片、容器、分隔线、普通结构箭头:优先做 PPT 原生对象。
- 图标、徽章、3D 装饰、复杂箭头、插画、设备图、UI 装饰:必须用 Codex 的 `imagegen` 技能生成独立透明资产。
- 原图、截图、硬裁 crop 只能作为参考和中间素材,不能作为最终整页背景或带残边的最终对象。
- 生产级元素重组默认只接受 `imagegen_asset``api_generated_asset` 仅在用户明确指定外部图像 API 时使用;`provided_asset` 只用于用户已提供素材、合规素材库或明确标注的 synthetic/demo 试跑。
- 只有在资产有合规来源记录,并且一语义单元一透明 PNG 插入 PPT 后,才算元素重组资产;不能把程序化近似图标、通用图标库、原图 crop 或提示词占位图当成正式元素重组资产。
完整跑触发规则:
- 用户说“完整跑”“正式跑”“完整 imagegen 元素重组”“不要试跑版”“不要 provided_asset”“高保真元素拆解”等表达时必须进入完整元素重组模式。
- 完整元素重组模式下,所有语义视觉资产必须通过 Codex `imagegen` 基于整页参考图 + 局部 crop/residual crop 生成,并在 `asset_manifest.json` 中标记为 `imagegen_asset`
- 完整元素重组模式下,禁止静默降级为 `provided_asset`、程序画图、SVG 重画、图标库拼装或原图 crop。若 imagegen 调用失败、资产不合格、切图失败或无法继续,直接报告阻塞点、已完成文件和下一步,不要把降级版说成完整完成。
- 如果用户只是说“试试”“验证链路”“跑通工具链”“先看结构”,可以交付明确标注的结构预览或 synthetic demo报告里必须写清它不是完整 imagegen 元素重组。
图片生成硬规则:
- 所有涉及图片生成、视觉资产生成、透明资产生成的步骤,默认且优先使用 Codex 的 `imagegen` 技能;不要用 LibreOffice、SVG 重画、程序画图、图标库拼装或其他渲染工具冒充生图。
- `api_generated_asset` 只保留为兼容字段,除非用户明确要求指定外部 API否则不要主动选择。
- `provided_asset` 只适合用户已提供的合规素材、明确授权素材库或 synthetic demo不能把程序化图标、通用图标库或原图硬裁片当作生产级视觉复刻资产。
- LibreOffice / PowerPoint / soffice 这类工具只能用于把已生成的 PPTX 渲染成 PDF/PNG 做验收预览,不能用于生成图片资产。使用时要明确标记为“渲染 QA”不是“图片生成”。
- 大尺寸中心视觉、跨格 3D 元素、复杂箭头、装饰性底座和高风险关键资产必须单独调用 `imagegen` 生成;不要塞进一个大 asset grid 再等分切。
能力预检:开始完整元素重组前,确认 imagegen 可用、所有参考图可读取、目标目录可写、Pillow 与 python-pptx 可导入,并确认存在 PPTX 渲染工具。任一生产级依赖缺失时,先报告缺口和可执行的修复方式,不要静默改成交付标准更低的路线。
执行步骤采用 v4/v5 验证过的生产链路:
1. 建立项目目录,保存参考图为 `reference_page_*.png`
2. 建立 `visual_inventory.json`按文本、容器、图标、箭头、装饰、3D 元素等对象做页面清单。
3. 建立 `asset_anchors.json``layout_rules.json`,记录每个待生成资产的 bbox、含义、目标尺寸、层级、字体策略、禁用媒体和 diff 阈值。
4. 为资产写 `prompts/assets_cycle_*.jsonl` 或 prompt pack。每条 prompt 必须绑定整页 reference 和相关 crop/residual crop明确对象顺序、grid 行列、纯色背景、无文字、无数字、无标签、无水印、无卡片框、无周边页面碎片;完整元素重组时必须实际调用 Codex `imagegen` 生成独立资产或 isolated asset grid。小图标可以成组生成中心大视觉和复杂元素必须单独生成。
5.`scripts/grid_cut.py``make cut` 从合格 generated grid 切成一个元素一个透明 PNG并生成 `asset_manifest.json`。如果切出的资产贴边、被截断、带绿边、含相邻元素,必须废弃并重新用 `imagegen` 单独生成。
6. 必要时用 `scripts/clean_assets.py` 清理透明边缘、小碎片和残留底色。不能把带残字、残框、硬边的 crop 直接插入 PPT。
7.`scripts/validate_semantic_inputs.py``make semantic-preflight` 做 build 前校验,确认 inventory、manifest、anchors 和资产文件对齐。
8.`scripts/build_semantic_deck.py``visual_inventory.json``asset_manifest.json` 构建 PPTX文本、卡片、容器和结构走 PPT 原生对象;图标、箭头和装饰元素走独立透明资产。构建时必须输出 `build_report.json`其中包含每个文本框的原始字号、自动适配后字号、估算溢出比例、bbox、父容器、z-index 和图片 fit 结果。
9. 渲染 PPTX再用 `scripts/compare_render.py``make compare` 输出 contact sheet / diff heatmap。
10.`scripts/validate_semantic_deck.py``make validate` 检查没有整页原图、参考图 hash、SVG 和不合规媒体,并传入 `--build-report` 做文字适配、碰撞检测和布局 QA。
11. 如果出现标题断行、文字溢出、文本互相覆盖、文字压住非装饰图片,先调 `visual_inventory.json` 的 bbox/role/parent_id/fit_mode或调整 `layout_rules.json` 的字体策略,再重建、重渲染、重对比。不能只看 PPTX 对象数就宣布完成。
验收标准:
- PPTX 可以打开并正常渲染。
- 文本可选中、可编辑,不用隐藏透明文本冒充可编辑。
- 重要图标、箭头、装饰元素可单独选中、移动、替换。
- 没有整页参考图作为最终媒体。
- 没有带残字、残框、硬边的原图裁片。
- 没有用非 Codex imagegen 的方式生成生产级图片资产LibreOffice 等仅可作为渲染验收工具。
- 输出 manifest、渲染预览、对比图和验证报告。
- `visual_inventory.json``asset_manifest.json` 对齐:每个语义视觉资产都有合规来源、真实文件和 PPT 插入对象。
- 完整元素重组模式下,`asset_manifest.json` 中的语义视觉资产应为 `imagegen_asset`;除用户显式提供素材外,出现 `provided_asset` 必须降级标注为结构预览或 demo。
- `build_report.json``layout_qa.error_count = 0`;可接受少量 micro label 字号或设计性文字压图 warning但必须人工看过预览。
- `compare_render.py` 输出的视觉回归状态为 `PASS` 或明确标记为需要人工复核contact sheet 要能直接看出标题、卡片和关键流程没有重叠。
- 最终 PPTX 不包含 SVG 媒体、整页或近整页参考图、raw crop、prompt-only 占位图或手绘低保真语义视觉。
- 生产级复刻必须有 `generated/``assets/``prompts/``asset_anchors.json``layout_rules.json`、渲染对比和验证报告;只有 synthetic `provided_asset` 的样例只能证明闭环可运行,不能当作视觉复刻效果验收。
不满足以上条件时,只能标记为“可编辑草稿”或“结构预览”,不能标记为元素重组合格。
详细执行规则见 [references/semantic-replica-workflow.md](references/semantic-replica-workflow.md);局限性见 [references/limitations.md](references/limitations.md)。
## 路径 CSVG 拆解
适合用户明确要 `.svg`,或要把图片页转换成网页/文档可复用的轻量结构。它可以接收原图、截图、图片页,也可以接收路径 A 生成的单页 PNG。
执行步骤:
1. 读取参考图,识别文本、容器、背景、线条、箭头、简单图标和装饰层。
2. 用原生 SVG 重建页面结构,文本、色块、线条和图形尽量成为独立 SVG 对象。
3. 输出 `.svg` 和 PNG 预览。
4.`scripts/validate_svg_slide.py` 检查画布比例、外部资源、脚本、`foreignObject` 和过小文字。
5. 简短说明哪些对象是 SVG 原生对象,哪些复杂视觉被简化,是否适合继续进入元素重组。
边界SVG 适合结构复用,但导入 PowerPoint 后不保证内部对象稳定可编辑。用户要对象级 PPTX 编辑时,转回路径 B。
## 路径 D原生 PPTX 模板填充
适合用户提供原生 `.pptx` 模板和新内容,要求保留模板主题、版式和原有 PowerPoint 对象并直接替换文字。
进入后完整读取 [references/native-template-fill-workflow.md](references/native-template-fill-workflow.md),并严格执行:
1.`scripts/native_template_fill.py analyze` 提取页面、文本框和表格单元格版式库。
2. 建立 `analysis/fill_plan.json`,按目标故事选择、删除和重排源页面;记录每页 `layout_rationale`
3.`check-plan` 校验精确 slot、文本容量和 v1 能力边界。
4. 向用户展示页序、删页和内容到版式的映射;确认后才把计划改为 `confirmed`
5.`apply` 直接修改 OOXML需要宋体时传入 `--font 宋体 --font-scope replaced-text`。禁止覆盖源 PPTX也禁止中途转成 SVG 或整页图片。
6.`validate` 回读文字,并用 `validate_ooxml_namespaces.py` 检查兼容性前缀,再进入统一质量门。
v1 不支持重复克隆同一源页、替换图片、编辑图表/SmartArt、改写对象级动画不得把保留原对象说成已经编辑这些对象。
## 路径 E原生可编辑 Deck
适合从主题、文档或逐页计划新建可编辑 PPTX提供类似 AIPPT 的组件化生成体验。进入后完整读取 [references/native-editable-workflow.md](references/native-editable-workflow.md)。
1. 建立 session确认 `visual_asset_policy`:默认 `native-image-assisted`,可选 `native-only``image-led-editable`
2.`outline_review_mode` 处理 `slides_plan.md`;连续模式直接建立 `analysis/native_deck_spec.json`,显式审阅模式等待确认后再建立。每页选择一个 v1 原型并登记原生组件和可选 `visual_slot`
3. 需要视觉资产时,调用 Codex `imagegen` 技能使用 image-2 生成独立图片;禁止让 image-2 生成整页、正文、真实数字、表格、Logo 或页码。
4. 先通过 Anti-AI-slop 设计门,再用 `scripts/build_native_editable_deck.py` 生成原生文本、形状、流程、表格、Office 图表和独立图片对象。首次构建后运行 `scripts/sync_canvas_scene.py`,把原生对象同步为 scene element v2。
5. 依次完成字体、排版、可编辑性、渲染预览和人工视觉检查,再进入统一质量门。
`native-image-assisted` 不等于每页强制生图:封面、章节和概念页优先使用 image-2流程、表格和数据页默认优先原生对象。`image-led-editable` 必须至少包含一个有 prompt 记录的 image-2 资产,但仍不得用整页图片冒充可编辑页面。
## 可视化编辑器接入
用户要开发可视化 PPTX 编辑 UI、浏览器内修改页面或接入类似 Codex 的任务界面时,完整读取 [references/editor-ui-integration.md](references/editor-ui-integration.md)。以 scene element v2 和 `scripts/editor_bridge.py export/apply` 作为唯一 editor seamUI 读取 v3 manifest提供“整套风格 / 本页变体 / 对象样式”,通过受保护的 `apply-style` 或 element capability 修改画布Skill 负责 snapshot、冲突检测、scene/spec 同步、页面缓存失效,并用 `build_native_editable_deck.py --scene-dir` 把覆盖值写回原生 PPTX。禁止让 UI 直接修改 OOXML 或自行猜测可编辑字段。
新建演示默认建立 `canvas-first` session不要求用户额外写“先显示画布”。最终导出前只写 `slides.md`、scene、manifest、缩略图和 `cache/editable/` 预览;不得提前写入 `final/`。只有用户明确要求直接导出、不要画布或跳过预览时才使用 `direct-build`。画布中确认样式并完成编辑后,运行 `editor_bridge.py approve-export`,再构建和验证最终 PPTX。
### Markdown-first Canvas
从主题或文档新建演示时默认完整读取 [references/markdown-canvas-workflow.md](references/markdown-canvas-workflow.md),并强制使用;无需用户额外声明 Markdown-first
```bash
python scripts/init_deck_session.py ... \
--delivery-type editable-pptx \
--editor-workflow-mode canvas-first \
--authoring-mode markdown-canvas
python scripts/compile_slides_markdown.py --session <session> --build-preview --render-canvas
```
`slides.md` 是人工编辑源,`native_deck_spec.json``slides_plan.md``prompts.json` 和 scene 是派生产物。画布修改文字后必须回写 Markdown自由拖拽只写 scene 几何覆盖。对话中展示 `reports/editor-canvas.html`,不得用静态示意代替。最终只在用户点击导出后批准并构建 `final/*.pptx`
## 输出纪律
- 先确定输入来源:新做汇报先生成;已有图片页或生成后的页图再拆解。
- 再确定路径和交付承诺,不把生成、元素重组和 SVG 拆解混成同一种能力。
- 新做 PPT 必须以 `slides_plan.md` 作为内容源、以 `prompts.json` 作为逐页生成记录,并在独立 session 内工作。
- Markdown-first Canvas 例外:`slides.md` 是人工内容源,`slides_plan.md``prompts.json` 由编译器派生,禁止同时手工维护三份内容。
- 大纲默认连续执行;只有用户明确要求审阅或等待确认时才展示并暂停。必须在 metadata 记录 `outline_review_mode``outline_approval`,不得把没有要求确认的用户默认拦在大纲阶段。
- 新做可编辑 PPTX 必须记录 `visual_asset_policy`image-2 资产必须登记为独立 `visual_slot` 并保留 prompt 记录。
- 可编辑 PPTX 默认记录 `gui_validation_mode: final-only`;中间只输出 PNG 预览和静态报告,最终 artifact 完成后才允许一次 PowerPoint 真实渲染。用户明确要求逐轮渲染时才使用 `eager``never` 必须在交付中说明未做真实 PowerPoint 验收。
- 全量生图前必须完成单页视觉冒烟并获得确认,除非用户明确要求跳过或整套只有 1 页。
- 每次交付都说明最终文件、可编辑范围、已知限制和 QA 结果。
- 每次正式交付必须附带 `reports/quality-gate.json` 的最终状态;没有统一质量门 PASS 不得宣称完成。
- 用户只给出主题、页数等少量信息时,先建立最低可执行简报,不要自行补造业务背景。
- 能运行脚本时优先运行脚本并保留输出;不能运行时说明环境缺口。
- 公开复用或发布仓库前,按 [references/publication-boundaries.md](references/publication-boundaries.md) 检查边界,并运行:
```bash
python scripts/audit_public_skill.py --root .
```
## 硬性规则
- 不编造数字、来源、标准、客户名或证据。
- 不把图片型 PPT 说成可编辑 PPT。
- 不把 SVG 嵌入说成 PowerPoint 对象级可编辑。
- 不让 imagegen 生成真实 Logo、二维码、证书、印章或品牌标识。
- 原生可编辑模式下,不让 image-2 生成整页 PPT、中文正文、表格和可追责数据image-2 只作为独立视觉资产后端。
- 元素重组模式下,原图 crop 只能是中间参考,不是最终资产。

View File

@@ -0,0 +1,324 @@
<!-- locale: zh-CN -->
# Presentation Forge
本文件是给执行体使用的路由和操作规程。面向用户的项目说明、效果图和安装命令见上游仓库 <https://github.com/mashagua/presentation-forge>。
先判断输入来源、沟通任务和最终目标:
1. **新做且重视觉的 PPT 汇报**:走整页生图 PPT生成图片型 PPTX / PDF / PNG。
2. **已有图片页、截图页、旧 PPT 渲染页,或整页生图页**:再根据目标进入拆解。
3. **要 PowerPoint 内继续改字、移元素、换图标**:走元素重组。
4. **只要 SVG、网页/文档复用,或低成本看结构**:走 SVG 拆解。
5. **提供原生 PPTX 模板和新内容,要求保留模板设计直接填充**:走原生 PPTX 模板填充。
6. **从主题或文档新做演示或可编辑 PPTX**:默认走 Markdown-first Canvas`slides.md` 为人工内容源,先展示完整可编辑画布;确认后使用原生可编辑 Deck 导出 PPTX。
7. **用户明确要求直接导出、不要画布或跳过预览**:走原生可编辑 Deck 的 `direct-build`;默认使用 `native-image-assisted`,文字、结构、表格和图表保持原生。
路径可以串联:先用整页生图 PPT 形成视觉页;如果后续需要编辑或复用,再把单页 PNG 交给元素重组或 SVG 拆解继续处理。从零新做演示时默认进入 Markdown-first Canvas已有图片页要求可编辑时走元素重组。SVG 拆解不是默认的 PPT 可编辑路线。
## 路由判断
### 使用单一权威路由
完整读取 [references/routing-workflow.md](references/routing-workflow.md),把用户意图写成结构化 request JSON并运行 `scripts/route_deck_workflow.py``reports/route-decision.json` 是唯一有效的路线决定:
- `PASS`:只读取并执行 `authority` 指向的路线,不并行准备其他路线的产物。
- `NEEDS_INPUT`:只询问报告中的一个 `blocking_question`,不要展示路线菜单。
- `BLOCKED`:补齐 `missing_prerequisites`,不要静默降级。
手工启发式只能用于填写 request JSON不能覆盖脚本结果。
### 建立最低可执行简报
开始制作前,读取用户材料,并确认以下字段:
- 材料:附件、文件路径、网页内容或用户提供的事实。
- 汇报类型:售前方案、项目汇报、产品介绍、行业分析、复盘、培训或其他。
- 受众:具体角色、行业、决策层级和已有认知。
- 沟通目标:希望受众理解什么、相信什么、最终采取什么行动。
- 使用场合:现场演示、线上会议、邮件发送、留档或自行阅读。
- 篇幅:页数或汇报时长;页数是约束,不是内容目标。
- 视觉与品牌:风格、主色、模板、真实 Logo 和禁止内容。
- 编辑要求与交付:图片型 PPTX、可编辑 PPTX、PDF、PNG、SVG、提示词或 QA 文件。
如果用户要求交付 `PPTX`,但没有明确说明是否需要编辑,必须先询问并确认选择“图片型 PPTX”还是“可编辑 PPTX”在获得回答前不得默认进入整页生图或元素重组。
新做 PPT 时,读取 [references/planning-workflow.md](references/planning-workflow.md),并把确认后的编辑要求写入 session 的 `metadata.json`。禁止使用含义模糊的 `pptx` 作为交付类型;只能记录 `image-pptx``editable-pptx`
如果缺少“材料、汇报类型、受众、沟通目标”中的任意两项,先提出不超过 3 个关键问题,不要直接生成页面。若用户授权自行判断,列出明确假设后继续规划。
逐页大纲始终写入 `slides_plan.md`,但默认采用连续执行:生成大纲后直接进入后续制作,不因大纲暂停,也不把“请先判断缺失信息”“使用明确假设”视为等待确认。只有用户明确表达“先输出大纲”“等我确认”“确认后再生成”“暂时不要制作”等审阅意图时,才展示大纲并等待;将 `outline_review_mode` 记录为 `explicit``outline_approval` 记录为 `pending`。其他情况记录 `outline_review_mode: continuous``outline_approval: auto-proceed`。用户明确要求审阅时不得静默越过确认门。
不得仅凭宽泛的行业或风格描述虚构客户背景、业务数据、产品能力、案例或收益。
### 选择交付路线
确认这四类条件:
- 最终交付:图片型 PPTX / PDF / PNG / SVG / 可编辑 PPTX。
- 是否需要后续编辑:不编辑、少量替换、还是对象级编辑。
- 输入材料:文档、旧 PPT、截图、图片页、整页生图页、参考模板。
- 质量优先级视觉观感、可编辑性、速度、token 成本、结构复用。
直接选择:
- 用户只说要 `PPTX`、未说明可编辑性:暂停路线选择,先询问需要“图片型 PPTX”还是“可编辑 PPTX”。
- 用户要新做汇报,内容偏观点表达、概念框架或方案展示,且视觉完成度优先:选择 **整页生图 PPT**
- 用户要从主题或文档新做演示或可编辑 PPTX且没有明确要求跳过画布默认选择 **Markdown-first Canvas**,设置 `authoring_mode=markdown-canvas``editor_workflow_mode=canvas-first`,并锁定 `native-editable-deck` 构建 authority。
- 用户明确说“直接导出”“不要画布”“跳过预览”:选择 **原生可编辑 Deck**`slides-plan + direct-build`。默认 `native-image-assisted`;用户明确不生图时使用 `native-only`,强视觉且仍要求可编辑时使用 `image-led-editable`
- 用户要新做汇报,包含密集数据、财务表格、需频繁更新的数字、大量脚注或长期协作模板:选择原生可编辑 Deck 的 `native-only`,不要走整页生图。
- 用户已有图片页,且明确要可编辑 PPTX、PowerPoint 内改字、移动元素、替换图标、元素化重建或图片页转可编辑:强制选择 **元素重组**
- 用户已有图片页,且明确要 SVG、网页/文档复用,或明确接受低成本结构样板:选择 **SVG 拆解**
- 用户提供原生 PPTX 模板和新内容,并要求保留模板设计、直接替换文字:选择 **原生 PPTX 模板填充**;不要先转成 SVG 或整页图片。
- 用户既重视视觉又要求后续编辑:优先原生可编辑 Deck 的 `native-image-assisted`;只有明确接受图片主导并需要参考页重建时才先整页生图再元素重组。
成本提示元素重组通常更耗时、token 更高,但 PPTX 可编辑性更强SVG 拆解通常更快更省,但复杂视觉会被简化,导入 PowerPoint 后不承诺对象级稳定可编辑。
如果用户目标是可编辑 PPTX但当前时间、token、图像生成能力或资产素材不足以完成元素重组必须明确说明只能交付降级草稿或改走 SVG 结构预览;不要自动把 SVG 拆解、原生形状重画或通用图标拼装称为元素重组完成。
不适配时直接说明原因,并建议改用原生可编辑 PPT、数据报表、文档或设计工具流程。
### 建立统一 Session
确定交付路线后,在生成大纲、提示词、图片或 PPTX 前,先用 `scripts/init_deck_session.py` 建立独立 session。所有源材料、大纲、结构化提示词、生成结果、渲染文件和 QA 报告都写入该 session不要把同一任务的产物散落在工作区根目录。
Session 必须至少包含 `slides_plan.md``prompts.json``metadata.json``sources/``analysis/``references/``generated/``assets/``final/``render/``compare/``reports/`。详细约定见 [references/planning-workflow.md](references/planning-workflow.md)。
### 使用统一 Scene 与 Revision
当新做 PPT、把图片版升级为可编辑版、修改已生成页面或需要回滚时读取 [references/scene-workflow.md](references/scene-workflow.md)。使用 `scenes/slide-NNN.scene.json` 作为页面语义事实源;图片版和可编辑版共享同一个 `metadata.json`,禁止复制成两个互相漂移的项目。
生产构建前必须:
1.`scripts/compile_scenes.py` 从当前有效计划和 prompts 编译或更新 scene`explicit` 模式下必须先获得大纲批准。
2.`scripts/validate_scene.py` 校验所有 scene。
3.`scripts/preflight_ppt_environment.py` 检查字体、构建依赖和渲染后端;默认 `gui_validation_mode: final-only`,中间预检不得启动 PowerPoint`BLOCKED` 时不得宣称完成生产 QA。
4.`scripts/revision_session.py snapshot` 建立 revision 后再进行正式生成或局部修改。
只改一页时使用 `scripts/rebuild_slide.py prepare` 判断该页是否失效;只重新生成目标页的昂贵资产,再用 `commit` 登记新产物。其他页必须保持原 hash 和缓存。最终 PPTX 可以整套快速重新封装,不直接修改脆弱的 OOXML 单页关系。
回滚必须使用 `scripts/revision_session.py rollback`。脚本会先保存当前状态,再恢复目标 revision 并创建新的 rollback revision不要覆盖或删除历史版本。
### 通过统一质量门
所有路线的最后一步都运行 `scripts/run_quality_gate.py --session <session> --artifact <artifact>`。该脚本校验路线报告、session 元数据、路线专属证据和最终文件,并写入 `reports/quality-gate.json`
- 只有 `PASS` 可以表述为正式完成。
- `WARN` 必须说明警告及人工复核结果。
- `BLOCKED` 只能报告阻塞点和已生成草稿,不得把某条子验证通过说成整套交付通过。
不要为不同路线另设互不兼容的“最终完成”标准;路线内部验证只能作为统一质量门的证据。
### 字体与渲染后端
- 新做可编辑 PPTX、原生模板填充或正式中文汇报时完整读取 [references/typography-workflow.md](references/typography-workflow.md)。从 `styles/catalog.json` 继承 `typography_profile``table_profile`,并写入 `metadata.json`;用户模板或品牌规范优先。
- 图片型 PPT 的 PDF 必须由最终页 PNG 直接合并;禁止再经 LibreOffice 或 PowerPoint 转换。
- 可编辑 PPTX 的每个中文文本 run 必须显式写入 DrawingML `latin``ea``cs` 字体,并同步 Theme major/minor 东亚字体;构建后运行 `scripts/validate_pptx_fonts.py`
- 可编辑 PPTX 构建后运行 `scripts/validate_pptx_typography.py`,检查语义字号 token、`0.5pt` 网格、段落设置和表格对齐;将结果写入 `reports/typography-validation.json``WARN` 必须说明,`NOT_CHECKED` 不得算作通过。
- `scripts/preflight_ppt_environment.py` 默认只做静态环境检查并把真实渲染推迟到最终 artifact。只有 `gui_validation_mode: eager` 才在中间执行中文真实导出探测。
- macOS PowerPoint 自动化需要系统 Automation/Apple Events 权限。代理运行真实探测时应申请 GUI/自动化执行权限;被系统拒绝时记录为 `BLOCKED`,不要循环重试或临时改写 AppleScript。
-`scripts/render_pptx.py` 统一导出。默认中间调用使用 `--validation-stage intermediate --gui-validation-mode final-only` 并返回 `DEFERRED`;最终构建完成后仅调用一次 `--validation-stage final`。PowerPoint 成功标记 `PASS`LibreOffice 只能标记 `APPROXIMATE`,不得作为正式中文可编辑 PPTX 的最终视觉证据。
- 可编辑 PPTX 的正式视觉验收以目标 PowerPoint 后端为准LibreOffice 仅用于发现明显结构问题。
- 原生模板填充禁止用 `xml.etree.ElementTree` round-trip OOXML必须保留 `p14``a14` 等原始前缀,并在交付前运行 `scripts/validate_ooxml_namespaces.py`
- 用户指定宋体等字体时,在 `fill_plan.json.font_policy` 记录字体与作用域。默认只作用于替换文字;只有用户明确要求全局统一时才使用 `all-selected-text``theme-and-replaced`
### 选择商业风格
新做 PPT 时读取 [references/style-library.md](references/style-library.md) 和 `styles/catalog.json`,根据受众、行业、目标和场合推荐 12 套风格。用户未指定且风格选择不影响内容方向时,可以采用最匹配的一套,但必须在大纲中明确标注为假设。
用户要求画布、可视化编辑、先编辑后导出或样式选择器时,强制使用 `canvas-first`:展示 24 个预设不自动把推荐第一项确认为最终风格。AI、科技、售前等关键词只能影响推荐排序不能强制选择深蓝。
canvas-first 必须展示用户要求的完整页数并逐页可切换:用户说 10 页,就先从 manifest 动态渲染 10 个缩略图和 10 页 scene。禁止交付固定 4 页的界面示意,禁止点击缩略图只改变边框而不载入该页对象。导出必须提交全量 scene并从同一 scene 原生构建 PPTX使页面数量、文字、对象、坐标、样式和层级与画布一致不要在导出后另起一套布局或只后处理封面。完整合同见 [references/editor-ui-integration.md](references/editor-ui-integration.md)。
选定后完整读取 `styles/<style_id>/STYLE.md``layouts.json`,并执行:
-`style_id``style_variant` 写入 `slides_plan.md` frontmatter、`prompts.json``metadata.json`。风格库提供 8 个家族、24 个可选预设。
- 将风格绑定的 `typography_profile``table_profile` 写入同一组文件;字号由语义 token 取得,不在页面代码中临时发明任意小数档位。
- 每页选择一个 `layout_id`,写入逐页计划与结构化 prompt不得超过该布局的 `content_capacity`,超出时拆页。
- 冒烟页使用正式风格和正式布局。用户确认的是该风格方向,不代表允许虚构图片、案例或数字。
- 连续页面避免完全相同的构图;优先复用标记为 `reuse_friendly: true` 的内容布局。
- 用户提供真实品牌模板或品牌规范时,以用户材料为准;内置风格只补足未定义部分。
### 通过构建前设计门
所有新生成路线在正式构建或全量生图前完整读取 [references/design-quality-workflow.md](references/design-quality-workflow.md),并运行 `scripts/validate_design_quality.py --session <session>`。禁止无意义卡片化、过量圆角矩形、无理由窄边强调条、默认阴影或渐变、形状上叠独立文本框、整页可移动图片背景,以及把设计说明或讲者提示写入正文。硬错误必须先修复;启发式警告只有在 spec 中记录页码、规则代码和明确理由后才能豁免。最终统一质量门必须读取 `reports/design-quality.json`
## 路径 A整页生图 PPT
适合单点沟通、重视觉、接受图片化交付的材料,例如方案汇报、售前交流、内部同步、培训讲解、复盘展示。
执行步骤:
1. 按“最低可执行简报”确认材料、汇报类型、受众、沟通目标、使用场合、篇幅、风格、品牌、输出格式和禁止内容。
2. 建立 session选择或确认商业风格读取用户材料按 [references/planning-workflow.md](references/planning-workflow.md) 写 `slides_plan.md`。它是内容 source of truth必须包含 `style_id`、页面类型、`layout_id`、标题、页面目标、核心信息、事实边界、素材和布局意图。
3. 根据 `outline_review_mode` 处理计划:`continuous` 直接继续;`explicit``slides_plan.md` 交给用户并等待确认。需要修改文案时只修改 Markdown任何 JSON 都是派生产物,不作为人工编辑源。
4. 按 [references/prompt-patterns.md](references/prompt-patterns.md) 将计划编译为结构化 `prompts.json`。每页记录 `slide_number``page_type``style_id``layout_id``layout_intent`、完整 `prompt`、参考图和生成状态;禁止只保存无法映射回页码的散装 prompt 文本。
5. 先生成 1 页视觉冒烟:默认封面;如果封面信息简单而其他页面风险更高,选择架构页、数据页或信息最密集的代表页。向用户展示样张并等待确认后再生成全量。只有用户明确要求跳过、或整套只有 1 页时,才可跳过确认。
6. 用户确认冒烟页后,调用 imagegen 生成其余 16:9 整页图。中文少而大,避免密集表格和小字号脚注;已通过的页面不要因局部问题一起重生。
7. 可以使用用户提供或明确授权的真实公司 Logo。用透明 PNG 或 SVG 原文件在后处理阶段叠加,不交给 imagegen 生成或仿造;未提供 Logo 时只预留位置。
8.`scripts/package_image_deck.py``make pack` 封装 PPTXPDF 由最终页 PNG 直接合并,可选输出每页 PNG 和 contact sheet。
9. 检查页数、尺寸、文字可读性、Logo 位置、伪 Logo、敏感信息和明显错字并把结果写入 session 的 `reports/``metadata.json`
默认交付:
- 图片型 PPTX
- PDF 或每页 PNG按用户要求
- contact sheet
- 逐页提示词和 QA 记录
边界:图片型 PPT 不等于可编辑 PPT。正文、图表和版式在图片里不能承诺 PowerPoint 内逐字逐对象编辑。
## 路径 B元素重组
适合用户已经有图片页、截图页、图片型 PPT或路径 A 生成的页图,并且希望重建为实用级可编辑 PPTX。
路径 B 是参考图驱动的语义资产重组,不是 SVG 反编译,也不是只用 PPT 原生形状画一张可编辑草稿。
核心判断:
- 文本、标题、卡片、容器、分隔线、普通结构箭头:优先做 PPT 原生对象。
- 图标、徽章、3D 装饰、复杂箭头、插画、设备图、UI 装饰:必须用 Codex 的 `imagegen` 技能生成独立透明资产。
- 原图、截图、硬裁 crop 只能作为参考和中间素材,不能作为最终整页背景或带残边的最终对象。
- 生产级元素重组默认只接受 `imagegen_asset``api_generated_asset` 仅在用户明确指定外部图像 API 时使用;`provided_asset` 只用于用户已提供素材、合规素材库或明确标注的 synthetic/demo 试跑。
- 只有在资产有合规来源记录,并且一语义单元一透明 PNG 插入 PPT 后,才算元素重组资产;不能把程序化近似图标、通用图标库、原图 crop 或提示词占位图当成正式元素重组资产。
完整跑触发规则:
- 用户说“完整跑”“正式跑”“完整 imagegen 元素重组”“不要试跑版”“不要 provided_asset”“高保真元素拆解”等表达时必须进入完整元素重组模式。
- 完整元素重组模式下,所有语义视觉资产必须通过 Codex `imagegen` 基于整页参考图 + 局部 crop/residual crop 生成,并在 `asset_manifest.json` 中标记为 `imagegen_asset`
- 完整元素重组模式下,禁止静默降级为 `provided_asset`、程序画图、SVG 重画、图标库拼装或原图 crop。若 imagegen 调用失败、资产不合格、切图失败或无法继续,直接报告阻塞点、已完成文件和下一步,不要把降级版说成完整完成。
- 如果用户只是说“试试”“验证链路”“跑通工具链”“先看结构”,可以交付明确标注的结构预览或 synthetic demo报告里必须写清它不是完整 imagegen 元素重组。
图片生成硬规则:
- 所有涉及图片生成、视觉资产生成、透明资产生成的步骤,默认且优先使用 Codex 的 `imagegen` 技能;不要用 LibreOffice、SVG 重画、程序画图、图标库拼装或其他渲染工具冒充生图。
- `api_generated_asset` 只保留为兼容字段,除非用户明确要求指定外部 API否则不要主动选择。
- `provided_asset` 只适合用户已提供的合规素材、明确授权素材库或 synthetic demo不能把程序化图标、通用图标库或原图硬裁片当作生产级视觉复刻资产。
- LibreOffice / PowerPoint / soffice 这类工具只能用于把已生成的 PPTX 渲染成 PDF/PNG 做验收预览,不能用于生成图片资产。使用时要明确标记为“渲染 QA”不是“图片生成”。
- 大尺寸中心视觉、跨格 3D 元素、复杂箭头、装饰性底座和高风险关键资产必须单独调用 `imagegen` 生成;不要塞进一个大 asset grid 再等分切。
能力预检:开始完整元素重组前,确认 imagegen 可用、所有参考图可读取、目标目录可写、Pillow 与 python-pptx 可导入,并确认存在 PPTX 渲染工具。任一生产级依赖缺失时,先报告缺口和可执行的修复方式,不要静默改成交付标准更低的路线。
执行步骤采用 v4/v5 验证过的生产链路:
1. 建立项目目录,保存参考图为 `reference_page_*.png`
2. 建立 `visual_inventory.json`按文本、容器、图标、箭头、装饰、3D 元素等对象做页面清单。
3. 建立 `asset_anchors.json``layout_rules.json`,记录每个待生成资产的 bbox、含义、目标尺寸、层级、字体策略、禁用媒体和 diff 阈值。
4. 为资产写 `prompts/assets_cycle_*.jsonl` 或 prompt pack。每条 prompt 必须绑定整页 reference 和相关 crop/residual crop明确对象顺序、grid 行列、纯色背景、无文字、无数字、无标签、无水印、无卡片框、无周边页面碎片;完整元素重组时必须实际调用 Codex `imagegen` 生成独立资产或 isolated asset grid。小图标可以成组生成中心大视觉和复杂元素必须单独生成。
5.`scripts/grid_cut.py``make cut` 从合格 generated grid 切成一个元素一个透明 PNG并生成 `asset_manifest.json`。如果切出的资产贴边、被截断、带绿边、含相邻元素,必须废弃并重新用 `imagegen` 单独生成。
6. 必要时用 `scripts/clean_assets.py` 清理透明边缘、小碎片和残留底色。不能把带残字、残框、硬边的 crop 直接插入 PPT。
7.`scripts/validate_semantic_inputs.py``make semantic-preflight` 做 build 前校验,确认 inventory、manifest、anchors 和资产文件对齐。
8.`scripts/build_semantic_deck.py``visual_inventory.json``asset_manifest.json` 构建 PPTX文本、卡片、容器和结构走 PPT 原生对象;图标、箭头和装饰元素走独立透明资产。构建时必须输出 `build_report.json`其中包含每个文本框的原始字号、自动适配后字号、估算溢出比例、bbox、父容器、z-index 和图片 fit 结果。
9. 渲染 PPTX再用 `scripts/compare_render.py``make compare` 输出 contact sheet / diff heatmap。
10.`scripts/validate_semantic_deck.py``make validate` 检查没有整页原图、参考图 hash、SVG 和不合规媒体,并传入 `--build-report` 做文字适配、碰撞检测和布局 QA。
11. 如果出现标题断行、文字溢出、文本互相覆盖、文字压住非装饰图片,先调 `visual_inventory.json` 的 bbox/role/parent_id/fit_mode或调整 `layout_rules.json` 的字体策略,再重建、重渲染、重对比。不能只看 PPTX 对象数就宣布完成。
验收标准:
- PPTX 可以打开并正常渲染。
- 文本可选中、可编辑,不用隐藏透明文本冒充可编辑。
- 重要图标、箭头、装饰元素可单独选中、移动、替换。
- 没有整页参考图作为最终媒体。
- 没有带残字、残框、硬边的原图裁片。
- 没有用非 Codex imagegen 的方式生成生产级图片资产LibreOffice 等仅可作为渲染验收工具。
- 输出 manifest、渲染预览、对比图和验证报告。
- `visual_inventory.json``asset_manifest.json` 对齐:每个语义视觉资产都有合规来源、真实文件和 PPT 插入对象。
- 完整元素重组模式下,`asset_manifest.json` 中的语义视觉资产应为 `imagegen_asset`;除用户显式提供素材外,出现 `provided_asset` 必须降级标注为结构预览或 demo。
- `build_report.json``layout_qa.error_count = 0`;可接受少量 micro label 字号或设计性文字压图 warning但必须人工看过预览。
- `compare_render.py` 输出的视觉回归状态为 `PASS` 或明确标记为需要人工复核contact sheet 要能直接看出标题、卡片和关键流程没有重叠。
- 最终 PPTX 不包含 SVG 媒体、整页或近整页参考图、raw crop、prompt-only 占位图或手绘低保真语义视觉。
- 生产级复刻必须有 `generated/``assets/``prompts/``asset_anchors.json``layout_rules.json`、渲染对比和验证报告;只有 synthetic `provided_asset` 的样例只能证明闭环可运行,不能当作视觉复刻效果验收。
不满足以上条件时,只能标记为“可编辑草稿”或“结构预览”,不能标记为元素重组合格。
详细执行规则见 [references/semantic-replica-workflow.md](references/semantic-replica-workflow.md);局限性见 [references/limitations.md](references/limitations.md)。
## 路径 CSVG 拆解
适合用户明确要 `.svg`,或要把图片页转换成网页/文档可复用的轻量结构。它可以接收原图、截图、图片页,也可以接收路径 A 生成的单页 PNG。
执行步骤:
1. 读取参考图,识别文本、容器、背景、线条、箭头、简单图标和装饰层。
2. 用原生 SVG 重建页面结构,文本、色块、线条和图形尽量成为独立 SVG 对象。
3. 输出 `.svg` 和 PNG 预览。
4.`scripts/validate_svg_slide.py` 检查画布比例、外部资源、脚本、`foreignObject` 和过小文字。
5. 简短说明哪些对象是 SVG 原生对象,哪些复杂视觉被简化,是否适合继续进入元素重组。
边界SVG 适合结构复用,但导入 PowerPoint 后不保证内部对象稳定可编辑。用户要对象级 PPTX 编辑时,转回路径 B。
## 路径 D原生 PPTX 模板填充
适合用户提供原生 `.pptx` 模板和新内容,要求保留模板主题、版式和原有 PowerPoint 对象并直接替换文字。
进入后完整读取 [references/native-template-fill-workflow.md](references/native-template-fill-workflow.md),并严格执行:
1.`scripts/native_template_fill.py analyze` 提取页面、文本框和表格单元格版式库。
2. 建立 `analysis/fill_plan.json`,按目标故事选择、删除和重排源页面;记录每页 `layout_rationale`
3.`check-plan` 校验精确 slot、文本容量和 v1 能力边界。
4. 向用户展示页序、删页和内容到版式的映射;确认后才把计划改为 `confirmed`
5.`apply` 直接修改 OOXML需要宋体时传入 `--font 宋体 --font-scope replaced-text`。禁止覆盖源 PPTX也禁止中途转成 SVG 或整页图片。
6.`validate` 回读文字,并用 `validate_ooxml_namespaces.py` 检查兼容性前缀,再进入统一质量门。
v1 不支持重复克隆同一源页、替换图片、编辑图表/SmartArt、改写对象级动画不得把保留原对象说成已经编辑这些对象。
## 路径 E原生可编辑 Deck
适合从主题、文档或逐页计划新建可编辑 PPTX提供类似 AIPPT 的组件化生成体验。进入后完整读取 [references/native-editable-workflow.md](references/native-editable-workflow.md)。
1. 建立 session确认 `visual_asset_policy`:默认 `native-image-assisted`,可选 `native-only``image-led-editable`
2.`outline_review_mode` 处理 `slides_plan.md`;连续模式直接建立 `analysis/native_deck_spec.json`,显式审阅模式等待确认后再建立。每页选择一个 v1 原型并登记原生组件和可选 `visual_slot`
3. 需要视觉资产时,调用 Codex `imagegen` 技能使用 image-2 生成独立图片;禁止让 image-2 生成整页、正文、真实数字、表格、Logo 或页码。
4. 先通过 Anti-AI-slop 设计门,再用 `scripts/build_native_editable_deck.py` 生成原生文本、形状、流程、表格、Office 图表和独立图片对象。首次构建后运行 `scripts/sync_canvas_scene.py`,把原生对象同步为 scene element v2。
5. 依次完成字体、排版、可编辑性、渲染预览和人工视觉检查,再进入统一质量门。
`native-image-assisted` 不等于每页强制生图:封面、章节和概念页优先使用 image-2流程、表格和数据页默认优先原生对象。`image-led-editable` 必须至少包含一个有 prompt 记录的 image-2 资产,但仍不得用整页图片冒充可编辑页面。
## 可视化编辑器接入
用户要开发可视化 PPTX 编辑 UI、浏览器内修改页面或接入类似 Codex 的任务界面时,完整读取 [references/editor-ui-integration.md](references/editor-ui-integration.md)。以 scene element v2 和 `scripts/editor_bridge.py export/apply` 作为唯一 editor seamUI 读取 v3 manifest提供“整套风格 / 本页变体 / 对象样式”,通过受保护的 `apply-style` 或 element capability 修改画布Skill 负责 snapshot、冲突检测、scene/spec 同步、页面缓存失效,并用 `build_native_editable_deck.py --scene-dir` 把覆盖值写回原生 PPTX。禁止让 UI 直接修改 OOXML 或自行猜测可编辑字段。
新建演示默认建立 `canvas-first` session不要求用户额外写“先显示画布”。最终导出前只写 `slides.md`、scene、manifest、缩略图和 `cache/editable/` 预览;不得提前写入 `final/`。只有用户明确要求直接导出、不要画布或跳过预览时才使用 `direct-build`。画布中确认样式并完成编辑后,运行 `editor_bridge.py approve-export`,再构建和验证最终 PPTX。
### Markdown-first Canvas
从主题或文档新建演示时默认完整读取 [references/markdown-canvas-workflow.md](references/markdown-canvas-workflow.md),并强制使用;无需用户额外声明 Markdown-first
```bash
python scripts/init_deck_session.py ... \
--delivery-type editable-pptx \
--editor-workflow-mode canvas-first \
--authoring-mode markdown-canvas
python scripts/compile_slides_markdown.py --session <session> --build-preview --render-canvas
```
`slides.md` 是人工编辑源,`native_deck_spec.json``slides_plan.md``prompts.json` 和 scene 是派生产物。画布修改文字后必须回写 Markdown自由拖拽只写 scene 几何覆盖。对话中展示 `reports/editor-canvas.html`,不得用静态示意代替。最终只在用户点击导出后批准并构建 `final/*.pptx`
## 输出纪律
- 先确定输入来源:新做汇报先生成;已有图片页或生成后的页图再拆解。
- 再确定路径和交付承诺,不把生成、元素重组和 SVG 拆解混成同一种能力。
- 新做 PPT 必须以 `slides_plan.md` 作为内容源、以 `prompts.json` 作为逐页生成记录,并在独立 session 内工作。
- Markdown-first Canvas 例外:`slides.md` 是人工内容源,`slides_plan.md``prompts.json` 由编译器派生,禁止同时手工维护三份内容。
- 大纲默认连续执行;只有用户明确要求审阅或等待确认时才展示并暂停。必须在 metadata 记录 `outline_review_mode``outline_approval`,不得把没有要求确认的用户默认拦在大纲阶段。
- 新做可编辑 PPTX 必须记录 `visual_asset_policy`image-2 资产必须登记为独立 `visual_slot` 并保留 prompt 记录。
- 可编辑 PPTX 默认记录 `gui_validation_mode: final-only`;中间只输出 PNG 预览和静态报告,最终 artifact 完成后才允许一次 PowerPoint 真实渲染。用户明确要求逐轮渲染时才使用 `eager``never` 必须在交付中说明未做真实 PowerPoint 验收。
- 全量生图前必须完成单页视觉冒烟并获得确认,除非用户明确要求跳过或整套只有 1 页。
- 每次交付都说明最终文件、可编辑范围、已知限制和 QA 结果。
- 每次正式交付必须附带 `reports/quality-gate.json` 的最终状态;没有统一质量门 PASS 不得宣称完成。
- 用户只给出主题、页数等少量信息时,先建立最低可执行简报,不要自行补造业务背景。
- 能运行脚本时优先运行脚本并保留输出;不能运行时说明环境缺口。
- 公开复用或发布仓库前,按 [references/publication-boundaries.md](references/publication-boundaries.md) 检查边界,并运行:
```bash
python scripts/audit_public_skill.py --root .
```
## 硬性规则
- 不编造数字、来源、标准、客户名或证据。
- 不把图片型 PPT 说成可编辑 PPT。
- 不把 SVG 嵌入说成 PowerPoint 对象级可编辑。
- 不让 imagegen 生成真实 Logo、二维码、证书、印章或品牌标识。
- 原生可编辑模式下,不让 image-2 生成整页 PPT、中文正文、表格和可追责数据image-2 只作为独立视觉资产后端。
- 元素重组模式下,原图 crop 只能是中间参考,不是最终资产。

View File

@@ -0,0 +1,57 @@
name: presentation-forge
description: "创建中文商业汇报,从主题、文档或 slides.md 生成原生可编辑 PPTX提供 Markdown-first 实时画布、样式切换和同版导出,使用 image-2 辅助生成独立视觉资产,把截图或图片型幻灯片重建为可编辑 PPTX/SVG或用原生 PPTX 模板填充新内容。用户要求制作售前方案、项目汇报、产品介绍、行业分析、培训材料、像 Markdown 一样编辑并导出、AIPPT 式可编辑演示、套用 PowerPoint 模板、图片页转可编辑 PowerPoint、重建幻灯片或输出 SVG 时使用。"
version: 0.1.0
type: procedural
risk_level: low
status: enabled
tags:
- ppt
- pptx
- svg
- presentation
- slides
metadata:
author: Ronnie2025
updated_at: '2026-08-10'
i18n:
default_locale: en-US
source_locale: zh-CN
locales:
- zh-CN
- en-US
zh-CN:
name: Presentation Forge
short_desc: 中文 toB 商业汇报 PPT 工作流:整页生图、图片页重建为可编辑 PPTX/SVG、原生模板填充适配 Codex/Claude Code
description: >-
面向中文 toB 商业汇报的 PPT 工作流,支持整页生图、把截图或图片型幻灯片重建为可编辑 PPTX/SVG或用原生 PPTX
模板直接填充新内容。制作售前方案、项目汇报、产品介绍、行业分析、培训材料,或图片页转可编辑 PowerPoint 时使用。
body: ./SKILL.zh-CN.md
translated_by: human
en-US:
name: Presentation Forge
short_desc: Chinese toB business-presentation workflow — image decks, rebuild slides into editable PPTX/SVG, native template filling
description: >-
Chinese toB business-presentation workflow: full-page image decks,
rebuilding image/screenshot slides into editable PPTX/SVG, and native
PPTX template filling. Use for pre-sales decks, project reports, product
intros, industry analysis, training material, or image-to-editable
PowerPoint. The skill body is authored in Chinese, so the en-US locale
intentionally falls back to the shared body and is locked as
human-maintained.
translated_by: human
source_hash: 'sha256:49a6a6b6867b2d1b'
body: ./SKILL.md
market:
icon: >-
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0
24 24" fill="none"><rect x="3" y="4" width="18" height="12" rx="1.5"
stroke="#2563EB" stroke-width="1.7"/><path d="M12 16v4M8.5 20h7"
stroke="#2563EB" stroke-width="1.7" stroke-linecap="round"/><path d="m6.5 12
3-3 2.2 2.2L16 7" stroke="#2563EB" stroke-width="1.7" stroke-linecap="round"
stroke-linejoin="round"/></svg>
short_desc: Chinese toB business-presentation workflow — image decks, editable PPTX/SVG rebuild, native template filling
category: creative
maintainer:
name: Ronnie2025
verified: false
channel: latest

View File

@@ -0,0 +1,10 @@
{
"skillId": "presentation-forge",
"repoUrl": "https://github.com/mashagua/presentation-forge.git",
"branch": "main",
"ref": "main",
"commit": "3c8e6ffa5fa97d0c14c6af8fec4beb440802b9a0",
"license": "MIT",
"author": "Ronnie2025",
"vendoredAt": "2026-08-10T13:30:00.000Z"
}

View File

@@ -0,0 +1,36 @@
# Anti-AI-slop 设计质量门
所有新生成页面在正式构建或全量生图前运行:
```bash
python scripts/validate_design_quality.py --session <session>
```
报告固定写入 `reports/design-quality.json`,统一质量门必须读取它。原生可编辑 Deck 构建器会在创建 PowerPoint 对象之前自动运行同一检查。
## 硬错误
- 正文出现设计说明、配图建议、讲者备注或 prompt 文本。
- 默认启用阴影或渐变;只有带明确设计理由的例外才可进入 spec。
- 可编辑模式使用覆盖 85% 以上页面的独立图片对象冒充背景或整页设计。
硬错误使报告成为 `FAIL`,禁止继续生产构建。
## 启发式警告
- 同页超过 4 个卡片,存在无意义卡片化风险。
- 同页超过 5 个圆角矩形。
- 出现无语义的窄边强调条。
- 形状上叠加独立文本框,而文字本可写入形状或通过留白分组。
警告使报告成为 `WARN`。先修复;确有设计理由时,在 `native_deck_spec.json.design_exceptions` 记录 `code``slide_number` 和非空 `reason`。不要使用全局“全部忽略”。
```json
{
"design_exceptions": [
{"code": "shape-with-overlay-textbox", "slide_number": 4, "reason": "该文本需要独立动画与可访问性朗读顺序。"}
]
}
```
设计门只判断结构性坏味道,不替代逐页视觉检查。通过检查也不代表页面已经具有良好构图。

View File

@@ -0,0 +1,196 @@
# 可视化 PPT 编辑器接入规范
当外部 UI 需要像 Codex 一样展示任务状态、页面预览并允许修改可编辑 PPTX 时使用本规范。Skill 是生成与验证后端UI 是 adapter。两者只通过 editor manifest、patch 和事件文件交互,不让 UI 直接改 OOXML。
## 稳定 seam
UI 只依赖两个命令:
```bash
python scripts/editor_bridge.py export --session <session>
python scripts/editor_bridge.py apply --session <session> --patch <patch.json>
python scripts/editor_bridge.py approve-export --session <session>
```
`export` 写入 `reports/editor-manifest.json`。v3 manifest 提供 session revision、文档 hash、画布 hash、页面缩略图、完整 scene element、当前风格和 24 个可选视觉预设。每个 element 自带 `capabilities`UI 不自行猜测哪些字段可改。
## 画布与 PPTX 同版合同
canvas-first 不是示意界面。画布和最终 PPTX 必须使用同一份完整 scene 集合:
- 用户要求 N 页时,`native_deck_spec.json.slides``scenes/`、editor manifest、画布缩略图和最终 PPTX 必须都是 N 页;不得用 4 页静态 Demo 代替。
- 使用 `editor_bridge.py export``slides` 动态创建全部缩略图;不得在 HTML 中硬编码页面标题或页数。
- 点击任一缩略图必须装载该页 `elements`,而不是只切换 active 边框。
- 导出必须提交所有页面的 `slide_number``canvas``elements`。只提交封面状态视为导出失败。
- 最终构建只读取当前 `scenes/`。画布已有而基础 archetype 没有的文本、形状与 connector由构建器原生 materialize禁止再写一个仅在导出后运行的封面后处理脚本。
- 质量门前比较 `manifest.slide_count`、scene 文件数和 PPTX 页数;任一不一致即 `BLOCKED`
生成真实画布:
```bash
python scripts/editor_bridge.py export --session <session>
cp assets/editor/render_editor_canvas.py <session>/analysis/render_editor_canvas.py
python scripts/render_editor_canvas.py \
--manifest <session>/reports/editor-manifest.json \
--out <session>/reports/editor-canvas.html
```
对话中展示的是该 `editor-canvas.html`,不要手写固定 4 页的 HTML 示例。
`apply` 接收 `schemas/editor-patch.schema.json` 约定的 patch。桥接脚本负责
- revision、文档 hash 与画布 hash 冲突检测;
- 修改前自动 snapshot
- 更新原生 Deck spec
- 同步 scene 标题和消息;
- 标记受影响页面缓存为 stale
- 写入 `reports/editor-events.jsonl`
- 返回新 manifest。
## Canvas-first 工作流
用户明确要求“先在画布编辑,确认后再输出 PPTX”时建立 session 必须传入:
```bash
python scripts/init_deck_session.py ... --editor-workflow-mode canvas-first
```
该模式不是“先生成最终 PPTX 再打开”。正确顺序是:
```text
建立 slides_plan / native_deck_spec / scene
-> export manifest
-> 画布展示 24 个样式预设与逐页 scene
-> 用户选择整套风格style_selection_status=confirmed
-> 用户逐页编辑文字、位置和对象样式
-> 预览构建只写 cache/editable/,不得写 final/
-> 用户点击导出
-> approve-export
-> 构建、验证并写入 final/*.pptx
```
`editor_export_approval=approved` 之前,构建器会拒绝把文件写进 session 的 `final/`。UI 可以直接按 scene element 渲染画布,不需要先创建临时 PPTX需要核对 PowerPoint 排版时,预览 PPTX 只能写到 `cache/editable/preview.pptx`
画布采用“主画布优先”的可折叠三栏结构:左侧页面缩略图默认窄栏,中间 16:9 可编辑 scene 占据主要可用空间,右侧对象属性与样式面板默认收起。当前页面不得因展示 24 个预设而缩小到文字不可读;页面显示尺寸不得小于约 `746×420` CSS 像素,空间不足时让中间区域滚动。顶部必须提供页面列表、对象编辑和专注画布开关,不显示 Markdown 按钮、标签页或编辑框。点击页面中的文字对象时展开右侧面板,宽度约 `320px`样式预设在面板内滚动不能永久挤占主画布也不能只显示当前深蓝主题。用户没有选定样式时应显示“待选择”不能把推荐样式冒充用户选择。Markdown 仅保留为内部内容源和导出回写数据。
首次打开画布前,先从已构建的原生 PPTX 同步 element
```bash
python scripts/sync_canvas_scene.py --session <session> --pptx <session>/final/deck.pptx
```
该 adapter 不启动 PowerPoint。它通过 `python-pptx` 提取原生对象的 ID、位置、尺寸、旋转、图层、文字和基础样式写入 scene element v2。
## UI 运行循环
```text
打开 session
-> export manifest
-> 显示页面缩略图与可编辑字段
-> 用户提交 patch
-> apply patch
-> build_native_editable_deck.py --scene-dir <session>/scenes --session <session>
-> 更新 PNG 缩略图
-> export 新 manifest
-> 最终一次 PowerPoint 渲染
```
UI 可以轮询或 tail `reports/editor-events.jsonl` 展示 `planning``generating``rebuild_required``rendering``validated` 等状态。未来接入 WebSocket 时,只需写一个读取同一事件和 manifest 的 adapter不改变 Skill interface。
Markdown-first session 的 manifest 额外提供 `authoring.markdown``authoring.markdown_sha256` 和 round-trip capability。UI 必须把完整 Markdown 源显示在可编辑面板中;导出时同时提交完整 Markdown 和完整 scene。浏览器无法直接写本地文件时由 follow-up 请求交给 Skill 写回并重新编译,不得只保存在页面内存。
## Patch 示例
```json
{
"schema_version": 3,
"request_id": "ui-0001",
"base_revision": "r0003",
"document_sha256": "<manifest 中的值>",
"canvas_sha256": "<manifest 中的值>",
"operations": [
{
"op": "update-element",
"slide_number": 1,
"element_id": "s001_hero",
"changes": {
"bbox": [180, 220, 720, 190],
"text": "新的封面标题",
"style": {"font_size_pt": 30, "font_color": "C62828"},
"rotation": 0,
"z_index": 4
}
}
]
}
```
v3 支持 `replace``update-element` 和受保护的 `apply-style`。element 只接受自身 `capabilities` 允许的 `text/geometry/style/rotation/z-order`。禁止通过通用 `replace` 修改页码、archetype、风格、字体策略、视觉资产来源和安全状态。旧 revision、document hash 或 canvas hash 必须返回冲突,不覆盖新版本。
### 风格切换
UI 用三个标签呈现风格能力:`整套风格 / 本页变体 / 对象样式`。前两者使用 `apply-style`,对象样式继续使用 `update-element`
```json
{
"schema_version": 3,
"request_id": "style-0001",
"base_revision": "r0003",
"document_sha256": "<manifest 中的值>",
"canvas_sha256": "<manifest 中的值>",
"operations": [{
"op": "apply-style",
"scope": "deck",
"style_id": "executive-minimal",
"variant_id": "charcoal-gold",
"mode": "preserve-layout"
}]
}
```
- `scope=deck` 可以切换主风格家族和变体,并同步 typography/table profile。
- `scope=slide` 只允许选择当前主风格家族内的变体,避免随机混搭。
- `replace-theme` 清空目标页的手工 canvas override按新主题完整重建。
- `preserve-layout` 保留位置、尺寸、文字、旋转和层级,清除旧颜色/字体覆盖后换肤。
- 风格卡片使用 manifest `style_catalog[].design_tokens` 生成预览;每张卡显示家族、变体名、主背景、主色、辅助色和文字色。
- 推荐顺序不得等同于自动应用。AI、技术或售前主题可以把企业科技、咨询商务、极简高管列为候选但必须等待画布选择后才能在 `canvas-first` 模式确认风格。
## Scene element v2
```json
{
"id": "s001_hero",
"type": "text",
"bbox": [122.4, 194.4, 979.2, 208.8],
"rotation": 0,
"z_index": 1,
"editable": true,
"locked": false,
"capabilities": ["text", "geometry", "style", "rotation", "z-order"],
"role": "hero",
"text": "企业 AI 知识库",
"style": {"font_size_pt": 34, "font_color": "111827", "bold": true},
"source_binding": {"document": "analysis/native_deck_spec.json", "path": "/slides/0/title"}
}
```
画布固定使用 `1920 × 1080 px` 逻辑坐标。UI 可以自由缩放显示,但提交 patch 时必须转换回逻辑坐标。文本 element 的 `source_binding` 让画布文字和 Deck spec 同步,避免两个事实源漂移。
## PowerPoint GUI 策略
默认 `gui_validation_mode: final-only`
- 中间冒烟只展示 PNG
- 中间预检不启动 PowerPoint
- UI 编辑后只重建页面与缩略图;
- 最终 artifact 完成后才调用一次 `render_pptx.py --validation-stage final`
- `reports/final-render.json` 必须绑定最终 PPTX 和 PowerPoint 生成的 PDF。
`eager` 允许中间真实渲染;`never` 完全禁止 GUI但统一质量门只能给出带警告的结果。
## v3 边界
- v3 面向 `native-editable-deck`,支持文本对象移动、缩放、旋转、图层、基础字体/颜色样式和风格预设切换。
- 图片、图表和表格支持几何、旋转和图层;内部数据编辑继续走 Deck spec不直接拆 OOXML。
- v3 还不支持组合/取消组合、自由曲线路径编辑、动画时间轴和图片裁剪手柄。
- 真正的多人协作、评论和实时光标属于 UI/协作 adapter不写进 PPT 生成 Skill。

View File

@@ -0,0 +1,35 @@
# 局限性说明
这个 skill 适合把图片型 PPT 工作流产品化,也适合把截图页、整页生图页重建成更可编辑的 PPTX。但它不是像素级还原工具也不是把任意图片一键变成完全原生 PPT 的工具。
## 整页生图 PPT 的局限性
- 正文、图表和版式都在图片里,交付后的文字不能直接编辑。
- imagegen 对中文长文本、密集表格、脚注和小字号仍有不稳定性,必须通过减字、放大字号和重抽修正。
- 真实 Logo、二维码、证书、印章、监管披露等不能交给 imagegen 生成,必须用后处理叠加真实素材。
- 对数字、标准、来源和事实性陈述没有天然校验能力,需要独立做资料核验。
- PPTX 只是图片封装,适合讲解和视觉展示,不适合长期维护、频繁改字、财务留档或强模板一致性的材料。
## 可编辑重组的局限性
- 图片到可编辑 PPT 的重建本质是重新搭版,不是自动反编译。复杂页面通常只能做到实用级还原,不能承诺百分百一致。
- 文本、卡片、线条、箭头、表格框架适合重建为原生 PPT 对象复杂图标、3D 元素、插画和装饰件通常只能作为独立透明图片资产插入。
- SVG 导入 PowerPoint 后经常是一个整体图形对象,不等于内部每条路径、每个图标都可稳定编辑。真正高可编辑性要优先用 PPT 原生对象重建。
- 如果把图标强拆成大量 PPT 形状,在 PowerPoint、LibreOffice、WPS、Keynote 之间容易出现错位、层级丢失、组合失效和字体度量变化。
- PowerPoint 与 LibreOffice 的字体渲染、自动换行、文本框高度和图标解释存在差异。最终验收应以目标用户实际打开的 PowerPoint 为准。
- 未安装同名字体时,字号看起来会变大或变小,中文换行尤其容易漂移。建议使用目标环境可用的中文字体,并预留 10%-15% 文本余量。
- 图表页、指标页和表格页应尽量从结构化数据重建,而不是从截图 OCR 后强行复刻。
## 适合的验收口径
- 对图片型 PPT验收视觉一致性、文字可读性、Logo 正确性、页数完整性和敏感信息边界。
- 对可编辑重组:验收文字可编辑、结构可拖拽、核心图标可替换、没有整页原图、没有私有素材泄漏。
- 对高保真复制需要明确可接受误差范围并保留渲染对比、contact sheet、对象统计和已知限制。
## 不应承诺的能力
- 不承诺任意图片一键转成完全可编辑 PPT。
- 不承诺复杂 SVG 导入后所有子路径都可在 PowerPoint 内稳定编辑。
- 不承诺跨 PowerPoint、LibreOffice、WPS、Keynote 的完全一致。
- 不承诺 imagegen 生成的中文、图标和排版一次正确。
- 不承诺对私有客户材料自动完成公开脱敏,发布前必须人工确认边界。

View File

@@ -0,0 +1,111 @@
# Markdown 画布与原生 PPTX 工作流
从主题或文档新建演示时默认使用本规范。只有用户明确要求直接导出、不要画布或跳过预览时,才改用 `slides-plan + direct-build`
## 单一事实源
`slides.md` 是人工编辑的内容源;`analysis/native_deck_spec.json``scenes/*.scene.json` 都是派生产物。画布读取 scenePPTX 构建器也读取同一 scene。禁止让 HTML/CSS 和 PPTX 构建器分别计算布局。
```text
slides.md
-> compile_slides_markdown.py
-> native_deck_spec.json + scenes/
-> editor manifest + SVG/DOM scene canvas
-> editor patch 回写 spec、scene 和 slides.md
-> approve-export
-> build_native_editable_deck.py --scene-dir
-> PPTX / PDF
```
## 初始化
```bash
python scripts/init_deck_session.py \
--title "企业 AI 知识库解决方案" \
--delivery-type editable-pptx \
--style-id consulting-blue-white \
--editor-workflow-mode canvas-first \
--authoring-mode markdown-canvas \
--out-root ./outputs
```
初始化会创建 `slides.md`。不要同时手工维护 `slides_plan.md`;编译器会从 `slides.md` 重建逐页计划和 prompts。
## Markdown 语法
- YAML frontmatter`title``style``variant``ratio``fontCN``visualAssetPolicy`
- 使用单独一行 `---` 分隔页面。
- 每页第一个 `#` 是页面标题。
- `>` 是副标题或页面关键信息。
- `::layout{type="..."}` 选择版式。
- `##` 与其后的正文构成卡片、步骤、对比区或架构层。
- 普通 `-` 列表用于行动项或普通内容。
- Markdown 表格用于 `table` 页面。
支持的布局映射:
| Markdown layout | 原生 archetype |
|---|---|
| `cover``cover-left` | `cover` |
| `content``cards``columns` | `content-structured` |
| `timeline``process` | `process-flow` |
| `comparison` | `comparison-two-zone` |
| `data``metrics` | `data-callouts` |
| `table` | `table` |
| `architecture` | `architecture` |
| `closing``action` | `closing-action` |
不在语法内的任意 CSS、HTML、JavaScript 和浏览器滤镜不得进入生产画布。需要绝对定位时,通过 scene element 的稳定 ID 和 `bbox` 覆盖,不把像素坐标写进正文。
## 编译并打开画布
```bash
python scripts/compile_slides_markdown.py \
--session <session> \
--build-preview \
--render-canvas
```
该命令:
1. 解析 `slides.md`
2. 写入原生 deck spec、逐页计划、prompts 和完整 scene 集合;
3. 只在 `cache/editable/preview.pptx` 构建结构预览;
4. 将预览中的原生对象同步为 scene element
5. 导出 editor manifest
6. 生成 `reports/editor-canvas.html`
对话中必须展示生成的 HTML 画布,不得手写静态 4 页示意。用户要求 N 页时Markdown 页面数、spec、scene、manifest、画布缩略图和最终 PPTX 都必须为 N 页。
画布必须以可读编辑为首要布局目标:默认收起右侧检查器,让当前页面占据主要可用区域;页面不得小于约 `746×420` CSS 像素,空间不足时允许主区域滚动,不能继续缩小到文字不可读。顶部提供页面列表、对象编辑和专注画布按钮;点击页面中的文字对象时,展开约 `320px` 宽的对象属性栏。Markdown 仍作为内部内容源和导出回写数据,但不在画布界面暴露按钮、标签页或编辑框。样式预设不得永久占据主画布空间,应放在可收起检查器中。
## 修改与回写
- 用户直接编辑 `slides.md`:重新运行编译命令。
- 用户在画布改字或对象样式:使用 `editor_bridge.py apply`;桥接器更新 spec 和 scene并把受支持内容规范化回写 `slides.md`
- 用户拖动对象:只更新 scene `bbox`Markdown 保留内容和结构,几何覆盖留在 scene。
- 用户切换风格:同步 frontmatter、spec、scene、metadata 和 prompts。
画布中的 Markdown 面板用于内容修改。浏览器不能直接写本地文件时,导出动作必须把完整 `slides_markdown` 与完整 scene 状态一起提交给 Skill再由 Skill 落盘、编译和构建。
## 导出
用户点击导出后:
```bash
python scripts/editor_bridge.py approve-export --session <session>
python scripts/build_native_editable_deck.py \
--spec <session>/analysis/native_deck_spec.json \
--scene-dir <session>/scenes \
--session <session> \
--out-pptx <session>/final/deck.pptx
```
随后执行字体、排版、可编辑性、最终 PowerPoint 渲染和统一质量门。不得在导出阶段重新选择模板或重新解释布局。
## v1 边界
- Markdown 负责内容、页面结构和组件;自由拖拽几何保存在 scene。
- 画布可以实时修改已有页面内容;在浏览器中新增或删除整页后,需要重新编译 Markdown。
- PPTX 与画布共享对象、文字、坐标、样式和层级,但浏览器与 PowerPoint 的字体抗锯齿可能存在轻微像素差异。
- 复杂 image-2 视觉仍是独立图片对象,其内部不可编辑。

View File

@@ -0,0 +1,112 @@
# 原生可编辑 Deck 工作流
从主题、文档或逐页计划新建可编辑 PPTX 时使用本规范。该路线类似 AIPPT 的组件化生成:文字、形状、流程、表格和 Office 图表保持原生可编辑image-2 负责高价值视觉资产,不生成整页底图。
## 三档视觉资产策略
在 request、`metadata.json``analysis/native_deck_spec.json` 中使用同一个 `visual_asset_policy`
| 策略 | 用途 | image-2 规则 |
|---|---|---|
| `native-only` | 财务、制度、密集数据、长期维护模板 | 不生成 image-2 资产;页面使用原生对象和用户提供素材 |
| `native-image-assisted` | 默认;售前、技术方案、项目汇报 | 按页需要生成封面主视觉、插画、3D 元素或复杂装饰 |
| `image-led-editable` | 强视觉发布、品牌故事、概念表达 | 至少包含一个 image-2 主视觉,但标题、正文、结构和数据仍必须原生可编辑 |
不要把 `native-image-assisted` 理解为每页强制生图。数据页、表格页和普通流程页默认不用 image-2封面、章节、产品场景和概念页优先评估视觉资产槽。
## 内容与组件合同
`outline_review_mode` 处理 `slides_plan.md``continuous` 写完即继续,`explicit` 等待用户批准。随后建立 `analysis/native_deck_spec.json`,结构遵循 `schemas/native-editable-deck.schema.json`。支持以下 v1 页面原型:
- `cover`
- `content-structured`
- `process-flow`
- `comparison-two-zone`
- `data-callouts`
- `table`
- `architecture`
- `closing-action`
最小示例:
```json
{
"schema_version": 1,
"title": "企业 AI 知识库解决方案",
"style_id": "enterprise-tech-dark",
"style_variant": "tech-blue",
"typography_profile": "zh-business-present",
"table_profile": "presentation-data-table",
"visual_asset_policy": "native-image-assisted",
"slides": [
{
"slide_number": 1,
"archetype": "cover",
"title": "企业 AI 知识库解决方案",
"subtitle": "让可信知识进入业务工作流",
"visual_slot": {
"backend": "image-2",
"source_type": "imagegen_asset",
"status": "generated",
"asset_path": "../assets/generated/cover-hero.png",
"prompt_record": "../analysis/image-prompts/cover-hero.md"
}
}
]
}
```
## image-2 资产槽
需要视觉资产时,先把 `visual_slot` 写入 spec再使用 Codex `imagegen` 技能生成。提示词和结果必须满足:
- 只生成主视觉、插画、3D 元素、装饰或场景图片,不生成整页 PPT。
- 不在图片中生成标题、正文、真实数字、表格、页码、Logo、二维码或认证标识。
- 记录 prompt 文件、输出文件、来源类型和生成状态。
- `backend=image-2` 时,`source_type` 必须是 `imagegen_asset`,状态必须达到 `generated``validated`
- 非封面页如果使用视觉资产,显式填写 `bbox_in`,避免覆盖原生内容。
- 生成失败时标记 `blocked`;不要用程序绘图、原图裁片或占位图冒充 image-2 正式资产。
## 构建与验证
构建前先运行 `validate_design_quality.py --session <session>`。构建器也会在创建 PowerPoint 对象前自动执行同一检查;`FAIL` 时停止,`WARN` 写入报告并要求人工复核或带理由豁免。
执行:
```bash
python scripts/build_native_editable_deck.py \
--spec <session>/analysis/native_deck_spec.json \
--base-dir <session>/analysis \
--scene-dir <session>/scenes \
--session <session> \
--out-pptx <session>/final/deck.pptx \
--report <session>/reports/native-editable-build.json
```
构建器输出原生文本、形状、线条、表格、Office 图表和独立图片对象。完成后依次执行:
首次构建后运行 `sync_canvas_scene.py`,将 PPTX 原生对象同步为 scene element v2。UI 修改 element 后,使用同一构建命令和 `--scene-dir` 重建;构建器按对象 ID 应用位置、尺寸、文字、样式、旋转与图层覆盖。
1. `validate_pptx_fonts.py`
2. `validate_pptx_typography.py`
3. `audit_pptx_editability.py`
4. 中间阶段只生成 PNG/结构预览,不打开 PPTX最终 artifact 完成后运行一次 `render_pptx.py --validation-stage final --gui-validation-mode final-only`
5. 人工检查逐页预览或 contact sheet
6. `run_quality_gate.py`
画布风格切换使用 editor v3 的 `apply-style`。整套切换可跨风格家族;单页切换只允许同一家族内的变体。`preserve-layout` 保留几何和文字并清除旧样式覆盖,`replace-theme` 清空目标页 canvas override 后完整重建。
用户要求先画布后 PPTX 时使用 `editor_workflow_mode=canvas-first`。此时不要先把初稿写入 `final/`:直接从 scene manifest 渲染页面;需要构建中间预览时写入 `cache/editable/preview.pptx`。只有收到画布导出动作、风格已确认且 `editor_bridge.py approve-export` 成功后,才能执行最终构建和质量门。
用户同时要求 Markdown 编辑时,设置 `authoring_mode=markdown-canvas` 并读取 [markdown-canvas-workflow.md](markdown-canvas-workflow.md)。先编译 `slides.md`,再在 `cache/editable/preview.pptx` 建立一次原生结构预览并同步对象 ID后续画布和最终 PPTX 都使用同一 scene不再从 Markdown 或 archetype 单独重算第二套布局。
画布导出后不得重新解释布局。必须把画布提交的全部页面 scene 写回 session再用同一 scene 构建 PPTX。禁止只读取 `cover` 字段、用临时脚本后处理封面,或让未在画布出现的模板布局覆盖画布对象。页数按用户要求建立;例如用户要求 10 页,画布必须先显示并可切换 10 页,然后才能批准导出 10 页 PPTX。
`reports/native-editable-build.json` 必须记录原生形状、connector、表格、Office 图表、图片对象和 image-2 资产数量。`image-led-editable` 没有 image-2 资产时构建必须失败。
## 能力边界
- v1 不生成 SmartArt、对象级动画、音频和讲者备注。
- Python figure、复杂研究图和照片可以作为独立图片插入但不能宣称其内部可编辑。
- Office 图表的数据和图表对象可编辑;复杂视觉优先保证表达质量,不强制全部原生化。
- image-2 是视觉资产后端不是事实、数字、Logo 或中文正文后端。

View File

@@ -0,0 +1,131 @@
# 原生 PPTX 模板填充
当用户提供原生 `.pptx` 模板和新内容,并要求保留 PowerPoint 设计、直接填字时使用。本路线直接修改 OOXML不经过整页生图、SVG 或元素重组。
## 能力边界
v1 支持:
- 分析原生文本框、占位符和表格单元格。
- 根据几何尺寸与字号估算文本容量。
- 选择、删除和重排源页面。
- 替换文本框和普通表格单元格文字。
- 为替换文字显式设置宋体等指定字体,并可选择作用于全部选中页面或同步主题字体。
- 保留源主题、版式、图片、图表、SmartArt、动画和页面切换的原始包部件。
v1 不支持:
- 重复克隆同一源页面。
- 替换图片、编辑图表数据、改写 SmartArt。
- 改写对象级动画。
- 自动缩小字体来容纳超长文字。
遇到不支持的需求时返回 `BLOCKED` 或保留原对象;不要宣称已经编辑。
## 固定目录
在统一 session 内使用:
```text
sources/source-template.pptx
analysis/slide-library.json
analysis/fill_plan.json
analysis/check_report.json
final/filled-deck.pptx
reports/native-template-validation.json
reports/quality-gate.json
```
## 1. 分析模板
```bash
python3 scripts/native_template_fill.py analyze \
<session>/sources/source-template.pptx \
--out <session>/analysis/slide-library.json
```
`slide-library.json` 当作版式库读取。按页面的文本框角色、几何尺寸、容量和原有修辞结构选择页面,不按源页面顺序机械替换。
## 2. 建立填充计划
```bash
python3 scripts/native_template_fill.py scaffold \
<session>/analysis/slide-library.json \
--slides 1,3,5 \
--out <session>/analysis/fill_plan.json
```
`fill_plan.json` 中为每页填写:
- `purpose`:该页在目标故事中的作用。
- `layout_rationale`:为什么源版式适合这段内容,以及主要风险。
- `replacements`:使用分析结果中的精确 `slot_id` 和新文字。
需要指定字体时,在计划顶层加入:
```json
"font_policy": {
"font_face": "宋体",
"scope": "replaced-text"
}
```
`scope` 支持:
- `replaced-text`:只给本次替换的新文字显式设置字体,最适合保留模板设计。
- `all-selected-text`:给选中页面内全部中文 run 设置字体。
- `theme-and-replaced`:设置替换文字,并同步 Theme major/minor 的东亚字体;可能影响模板中继承主题字体的其他文字。
substantive claim 必须来自用户材料。标题和短标签优先改写压缩,不默认缩小字号。
## 3. 容量与目标校验
```bash
python3 scripts/native_template_fill.py check-plan \
<session>/analysis/slide-library.json \
<session>/analysis/fill_plan.json \
--out <session>/analysis/check_report.json
```
`error_count > 0` 时必须修复。`text_capacity` 是需要人工复核的警告;优先缩短、拆页或换版式。
## 4. 用户确认门
向用户展示目标页序、删除页面、内容到版式的映射和容量警告。只有用户确认后,才把计划顶层 `status``draft` 改为 `confirmed`
不得使用调试参数绕过确认门。
## 5. 原生填充
```bash
python3 scripts/native_template_fill.py apply \
<session>/sources/source-template.pptx \
<session>/analysis/slide-library.json \
<session>/analysis/fill_plan.json \
--out <session>/final/filled-deck.pptx \
--font 宋体 \
--font-scope replaced-text
```
禁止覆盖源 PPTX。脚本会保留源包并修改页面顺序与目标文本节点。
## 6. 回读与统一质量门
```bash
python3 scripts/native_template_fill.py validate \
<session>/final/filled-deck.pptx \
<session>/analysis/fill_plan.json \
--out <session>/reports/native-template-validation.json
python3 scripts/validate_ooxml_namespaces.py \
<session>/final/filled-deck.pptx \
--out <session>/reports/ooxml-namespace-validation.json
python3 scripts/run_quality_gate.py \
--session <session> \
--artifact <session>/final/filled-deck.pptx
```
只有 `reports/quality-gate.json.status=PASS` 才能宣称正式完成。视觉渲染仍按 `scripts/render_pptx.py` 的 PowerPoint/LibreOffice 等级执行;回读通过不等于视觉溢出已人工确认。
禁止使用 `xml.etree.ElementTree` round-trip 模板中的 slide、presentation、theme 或 layout XML。它会把 `p14``a14` 等命名空间改成 `ns*`,但不会同步 `mc:Choice Requires` 等属性值PowerPoint 会提示修复文件。统一质量门必须检查这些兼容性前缀。

View File

@@ -0,0 +1,184 @@
# 新 PPT 的计划、Session 与 Prompt 规范
新做 PPT 时使用本规范。目标是让内容可审阅、生成过程可追踪、失败页面可单独重做。
## 执行顺序
```text
确认 PPTX 可编辑性与最低简报
-> 建立 session
-> 运行确定性路由并锁定唯一 authority
-> 编写 slides_plan.md
-> 按 outline_review_mode 连续执行或显式等待确认
-> 编译 prompts.json
-> 生成单页视觉冒烟
-> 用户确认视觉冒烟
-> 生成全量
-> 打包与 QA
```
大纲默认不构成阻塞门:没有明确审阅意图时记录 `outline_review_mode: continuous``outline_approval: auto-proceed`,写完计划后直接继续。用户明确说“先输出大纲”“等我确认”“确认后再生成”“暂时不要制作”等表达时,记录 `outline_review_mode: explicit``outline_approval: pending`,展示大纲并暂停;确认后改为 `approved`。用户明确要求跳过已经触发的确认门时记录 `outline_approval: waived`。冒烟仍是独立确认门;用户明确要求跳过时记录 `smoke_approval: waived`
## Session 目录
使用:
```bash
python scripts/init_deck_session.py \
--title "企业 AI 知识库解决方案" \
--delivery-type image-pptx \
--outline-review-mode continuous \
--gui-validation-mode final-only \
--style-id enterprise-tech-dark \
--out-root ./outputs
```
目录结构:
```text
outputs/<session-id>/
├── slides_plan.md
├── slides_plan.json # 可选Markdown 计划稳定后再派生
├── prompts.json
├── metadata.json
├── sources/
├── analysis/
├── scenes/
├── versions/
├── cache/image/
├── cache/editable/
├── references/
├── generated/
├── assets/
├── final/
├── render/
├── compare/
└── reports/
```
`slides_plan.json` 是可选派生文件;只有脚本明确需要时才生成。不要手改派生 JSON。
用户选择 Markdown-first Canvas 时session 额外包含 `slides.md`,并将 `metadata.authoring_mode` 设为 `markdown-canvas``metadata.content_source` 设为 `slides.md`。此时 `slides_plan.md``prompts.json` 都由 `compile_slides_markdown.py` 派生;人工只编辑 `slides.md`。完整规则见 [markdown-canvas-workflow.md](markdown-canvas-workflow.md)。
建立 session 后按 [routing-workflow.md](routing-workflow.md) 生成 `reports/route-decision.json`。路线未达到 `PASS` 前,不得编译 scene、生成图片或填充 PPTX。
`continuous` 模式写完 `slides_plan.md``prompts.json` 后直接运行 `scripts/compile_scenes.py``explicit` 模式等待大纲批准后再运行。后续图片版与可编辑版都使用这些 scene详细规则见 [scene-workflow.md](scene-workflow.md)。
## slides_plan.md
Markdown 是内容 source of truth。推荐格式
```markdown
---
title: 企业 AI 知识库解决方案
delivery_type: image-pptx
style_id: enterprise-tech-dark
typography_profile: zh-business-present
table_profile: presentation-data-table
visual_asset_policy: native-image-assisted
audience: CIO、IT 负责人、知识管理负责人
goal: 同意开展四周 PoC
---
## 1. [cover] 企业 AI 知识库解决方案
布局cover-hero
页面目标:建立方案定位并提出 PoC 行动。
核心信息:
- 让企业知识真正可用
- 四周验证价值与落地条件
事实边界:
- 不使用客户案例、准确率或 ROI 数据
素材:
- 无真实 Logo右上角预留区域
布局意图:左文右图的企业科技封面
```
每页必须包含:
- 页码与 `page_type``cover``agenda``section``content``data``architecture``process``closing``other`
- `layout_id`:从所选风格的 `layouts.json` 选择;若没有完全匹配,选最接近的布局并在“布局意图”说明调整。
- 标题、页面目标和 2-4 组核心信息。
- 事实边界:禁止虚构、待确认信息、不可生成内容。
- 素材真实图片、Logo、截图、图表或“无素材”。
- 布局意图:一句话说明页面形态,不写像素坐标。
## prompts.json
`prompts.json` 是逐页生成记录,不是内容源。最小结构:
```json
{
"schema_version": 1,
"session_id": "20260809-083000-enterprise-ai-knowledge-base",
"delivery_type": "image-pptx",
"style_id": "enterprise-tech-dark",
"typography_profile": "zh-business-present",
"table_profile": "presentation-data-table",
"visual_asset_policy": "native-image-assisted",
"slides": [
{
"slide_number": 1,
"page_type": "cover",
"style_id": "enterprise-tech-dark",
"layout_id": "cover-hero",
"layout_intent": "左文右图的企业科技封面",
"prompt": "完整的逐页生成提示词",
"reference_images": [],
"asset_reference_images": [],
"status": "planned",
"output_image": null,
"qa": {
"status": "pending",
"notes": []
}
}
]
}
```
生成前检查页码连续且与 `slides_plan.md` 一致。生成后更新 `status``output_image``qa`;不要删除失败记录。
## 单页视觉冒烟
- 默认选择封面;封面不能验证主体风格或信息密度时,选择风险最高的代表页。
- 冒烟页必须使用正式风格、正式提示词和正式尺寸,不能用低质量占位稿冒充。
- 向用户展示样张,并明确询问是否继续全量生成。
- 用户要求修改时,只重编译和重生成该页;确认前不生成其他页面。
- 用户确认后,在 `metadata.json` 记录页码、时间和 `smoke_approval: approved`
## metadata.json 状态
至少记录:
```json
{
"session_id": "...",
"title": "...",
"delivery_type": "image-pptx",
"style_id": "enterprise-tech-dark",
"typography_profile": "zh-business-present",
"table_profile": "presentation-data-table",
"visual_asset_policy": "native-image-assisted",
"editability_confirmed": true,
"route": "image-generation",
"route_status": "PASS",
"status": "planning",
"outline_review_mode": "continuous",
"outline_approval": "auto-proceed",
"gui_validation_mode": "final-only",
"final_powerpoint_validation": "pending",
"smoke_slide": null,
"smoke_approval": "pending",
"final_qa": "pending"
}
```
`delivery_type` 只允许:`image-pptx``editable-pptx``pdf``png``svg`。用户只说 PPTX 时,先询问,不得写入模糊值。
正式交付前运行 `scripts/run_quality_gate.py`;该脚本负责把 `final_qa` 更新为 `pass``warn``blocked`

View File

@@ -0,0 +1,159 @@
# 生图提示词模式
本文件用于生成逐页提示词。提示词要足够具体,能指导 imagegen 生成完整幻灯片;但不要把文字塞得过密,避免小字不可读。
## 逐页提示词结构
```text
页面标题:
{精确标题}
页面目标:
{这一页要让受众理解什么}
核心文字:
- {短要点1}
- {短要点2}
- {短要点3}
版式结构:
{选择一种清晰构图:链路图、场景地图、矩阵、飞轮、左右对照、路线图、证据墙、仪表盘、架构图、结束卡片等}
视觉元素:
{希望 imagegen 画出的具体元素:控制台、系统节点、文档墙、风险告警、流程箭头、产品界面、抽象平台等}
风格提示词:
16:9 整页 PPT 图片,{配色}{气质}{背景},中文文字清晰,层级专业,不要像普通模板页。
Logo 规则:
预留真实 Logo 后叠区域。不要生成 Logo、伪 Logo、品牌标识、水印、二维码或印章。
负向约束:
不要水印、二维码、长 URL、伪 Logo、乱码、虚假数据、未经确认的信息、内部敏感内容、拥挤脚注、不可读小字。
```
## 全局风格块
在逐页提示词前可以先写一个全局风格块:
```text
使用 16:9 横版演示文稿构图,目标 1920x1080 或更高。
使用选定配色:主色 {primary},辅助色 {secondary},强调色 {accent},强调色只小面积使用。
页面适合现场讲解:标题强、层级清晰、中文可读、留白充足。
避免所有页面都变成同一种卡片网格,整套 PPT 要有节奏变化。
不要绘制任何真实 Logo给后处理叠加真实 Logo 留出干净区域。
```
## 常用版式
- **封面**:左侧标题区 + 右侧沉浸式主题视觉 + 底部 Logo 区。
- **目录**:大编号 + 错落章节条 + 简单视觉锚点。
- **核心结论**:三段结论链、升级路径或主张堆栈。
- **场景地图**:分层地图、泳道或节点网络。
- **证据墙**:资料卡、趋势线、时间轴和短结论。
- **风险升级**:从左到右的风险演进链路,最后状态用强调色。
- **优先级矩阵**:高/中/低区域热力图或象限图。
- **方案架构**:顶部接入层、中间控制面、左右链路、底部审计运营。
- **能力映射**:风险节点连接到能力模块。
- **评测体系**:题集、攻击、执行、指标的流水线。
- **闭环飞轮**:发现、分析、优化、验证、上线的循环。
- **路线图**:阶段化路径和能力里程碑。
- **结束页**2-3 个行动建议卡片、验收指标和下一步建议。
## 中文文字控制
- 优先使用短标题和短要点。
- 每页只保留一个中心主张。
- 使用 2-4 组主要文字,不要写成长段落。
- 只要求 imagegen 精确生成关键标题和要点,非关键信息宁可少写。
- 如果中文错字或乱码,减少文字量、放大文字块后重抽。
- 真实 Logo、法律标识和合规印章一律后处理叠加不交给 imagegen。
## 语义资产网格提示词
当目标是把图片页重建为可编辑 PPT 时,不要让 imagegen 生成整页截图,也不要直接裁原图当最终资产。应生成可透明化的 isolated asset grid
```text
Create an isolated asset grid for a PowerPoint visual replica.
Use the reference slide for style only. Create standalone assets, not slide crops.
Objects in order:
1. {generic_icon_01}
2. {generic_icon_02}
3. {workflow_arrow_01}
4. {decorative_asset_01}
Grid:
{rows}x{cols}, generous margin, equal cells, each object centered.
Background:
uniform chroma key #00ff00 for later transparent cutout.
Style:
clean business presentation asset, consistent lighting, no surrounding card, no slide background.
Negative constraints:
no readable text, no numbers, no labels, no watermark, no logo, no QR code, no card frame, no rectangular crop border, no neighboring object fragments.
```
生成后用 `scripts/grid_cut.py` 切成一个语义单元一个 PNG再插入 PPT。
## 通用示例:风险升级路径页
这个示例来自一次真实生图 PPT 流程,但已去除具体品牌、客户和内部信息,可作为“链路型说明页”的提示词参考。
```text
16:9 中文安全趋势页,白底,目标 1920x1080。
主标题必须清晰:“风险升级路径:从误答到真实环境副作用”。
核心文字必须清晰:
- 普通问答:幻觉、误导、违规回答
- RAG / 外部内容:间接提示注入、上下文污染
- Agent / 工具调用:越权操作、数据外发、环境副作用
版式结构:
中间是一条横向升级路径,不要表格:
普通聊天机器人 → RAG / 网页文档 → 工具调用 Agent → 插件 / 能力扩展 → 真实环境执行。
每一站下方有短标签:幻觉误导、间接提示注入、上下文污染、计划漂移、越权操作、数据外发。
最后两站使用少量风险红强调。
视觉元素:
淡蓝执行链路、API 图标、文件图标、网络图标、Shell / 命令图标、风险告警三角、审计日志线条。
底部短句“Agent 化之后,风险不只发生在文本输出,还会落到环境动作。”
风格提示词:
专业企业汇报风,白底,主色为深蓝和浅蓝,红色只用于高危风险;信息图化,有空间层次,适合现场讲解。
Logo 规则:
右上角预留真实 Logo 区,不要生成任何 Logo 或伪标识。
负向约束:
无水印、无二维码、无长 URL、无乱码、无内部客户名、无真实攻击步骤、无虚假数据。
```
## 通用示例:优先级矩阵页
```text
16:9 中文商务 PPT 页面,白底,目标 1920x1080。
主标题必须清晰:“风险优先级矩阵”。
核心文字:
- 高优先级:数据泄露、越权动作、合规红线
- 中优先级:幻觉误导、上下文污染、工具供应链
- 低优先级:格式错误、低影响输出偏差
版式结构:
做成热力矩阵,不要做普通表格。纵轴是影响程度,横轴是治理优先级。高风险区域用红色,中风险区域用蓝色,低风险区域用浅灰。
视觉元素:
风险图标、小型盾牌、治理闭环、淡色科技网格。
风格提示词:
专业企业演示风,白底商务,主色深蓝,辅助浅蓝,少量红色强调,中文文字清晰,留白充足。
Logo 规则:
右上角留出空白 Logo 区。不要生成任何 Logo 或伪标识。
负向约束:
不要水印、二维码、长 URL、乱码、伪品牌标识、虚构统计数据、密集脚注或不可读小字。
```

View File

@@ -0,0 +1,33 @@
# 公开发布边界
把项目沉淀成公开 skill 时,只保留通用流程、抽象规则、脚本和 synthetic 示例。不要把客户交付物或真实项目中间产物放进仓库。
## 可以公开
- 通用工作流:需求澄清、逐页提示词、图片封装、语义拆解、渲染 QA。
- 通用脚本图片裁切、PPTX 封装、可编辑性审计、资产网格切分、公开发布扫描。
- 抽象示例:不包含客户名、真实 Logo、真实数据和真实 badcase 的示例图。
- 参数化配置:示例品牌色、占位 Logo 路径、示例字体名、示例输出目录。
- 局限性说明和适配边界。
## 不应公开
- 客户成品 PPT、PDF、PNG、contact sheet、渲染预览和交付说明。
- 真实 Logo、客户模板、内部图标库、品牌素材和源文件。
- 逐页详细提示词、证据清单、QA 记录、会议材料、投标材料和内部评审记录。
- 真实客户名、真实场景名、内部产品名、报价、合同、SLA、指标、QPS、模型版本和参数。
- 私有路径、账号、token、cookie、API key、内部域名和可访问链接。
- 未脱敏 badcase、真实攻击样本、客户数据、用户数据和日志。
## 发布前检查
1. 运行敏感扫描:
```bash
python scripts/audit_public_skill.py --root .
```
2. 检查 git 状态,只提交 skill 本身需要的文件。
3. 打开 README 和 SKILL.md确认没有客户名、私有路径和内部项目口径。
4. 确认示例图确实是可公开的 synthetic demo。
5. 如果来源不确定,默认不公开。

View File

@@ -0,0 +1,67 @@
# 确定性路由
本文件是路线选择的唯一权威。先把用户意图写成结构化 request JSON再运行 `scripts/route_deck_workflow.py`。一旦得到 `PASS`,本次任务只能读取和执行 `authority` 指向的路线;不得把多条生产链混在一起。
## 路由矩阵
| 路线 | 触发条件 | 必要输入 | 输出承诺 |
|---|---|---|---|
| `image-generation` | 图片型 PPTX、PDF、PNG且不要求对象级编辑 | 内容简报 | 整页图片、图片型 PPTX/PDF |
| `native-editable-deck` | 从主题或文档新建演示;默认先进入 Markdown 可编辑画布 | 内容简报 | `slides.md`、完整画布、原生文本/形状/表格/Office 图表与可选 image-2 视觉资产 |
| `element-rebuild` | 把已有图片页或截图重建为可编辑对象 | 参考页图片 | 原生文本/形状与独立视觉资产组成的 PPTX |
| `svg-redraw` | 明确要求 SVG | 参考页图片 | SVG 与预览,不承诺 PowerPoint 对象级编辑 |
| `native-template-fill` | 原生 PPTX 模板加新内容,要求保留模板设计并填充 | 源 PPTX、新内容 | 直接修改 OOXML 的新 PPTX不经过整页生图或 SVG |
`operation=enhance`(备注、音频、动画增强)当前必须返回 `BLOCKED`,留给后续阶段实现。
## Request JSON
```json
{
"delivery_type": "editable-pptx",
"operation": "fill",
"input_kind": "pptx-template",
"editability": "editable",
"authoring_mode": "markdown-canvas",
"editor_workflow_mode": "canvas-first",
"has_source_pptx": true,
"has_new_content": true,
"has_reference_slides": false,
"preserve_native_design": true,
"explicit_template_fill": true
}
```
允许的核心值:
- `delivery_type`: `image-pptx``editable-pptx``pdf``png``svg``pptx``unspecified`
- `operation`: `create``fill``rebuild``enhance`
- `input_kind`: `topic``document``pptx-template``pptx-finished``slide-images``mixed`
- `editability`: `image``editable``unspecified`
- `visual_asset_policy`: `native-only``native-image-assisted``image-led-editable`
- `authoring_mode`: `markdown-canvas``slides-plan`
- `editor_workflow_mode`: `canvas-first``direct-build`
从零创建演示或可编辑 PPTX 时,默认 `authoring_mode=markdown-canvas``editor_workflow_mode=canvas-first``visual_asset_policy=native-image-assisted`。先生成并展示完整画布,不写入 `final/`;用户批准导出后再构建 PPTX。只有用户明确要求直接导出、不要画布或跳过预览时才使用 `slides-plan + direct-build`。已有图片页重建仍走 `element-rebuild`
用户只说 `PPTX` 时保留 `delivery_type=pptx`,脚本会返回 `NEEDS_INPUT` 和唯一问题。不要在脚本外自行猜测。
用户只说“制作演示”“根据文档做汇报”而没有指定交付格式时,记录 `delivery_type=unspecified``operation=create`;默认路由到 `native-editable-deck + markdown-canvas + canvas-first`。这与只说 `PPTX` 不同:后者仍需确认图片型还是可编辑型。
## 命令
```bash
python3 scripts/route_deck_workflow.py \
--request <session>/route-request.json \
--session <session>
```
结果写入 `reports/route-decision.json` 并同步到 `metadata.json`
- `PASS`:进入且只进入所选路线。
- `NEEDS_INPUT`:只询问 `blocking_question`,不得开始生产。
- `BLOCKED`:报告 `missing_prerequisites`,不得静默降级。
## 失败恢复
修复结构化 request 或补齐必要输入后重新运行路由。不要通过手改 `metadata.json.route` 绕过路由报告;统一质量门会校验两者一致。

View File

@@ -0,0 +1,61 @@
# Scene、局部重建与版本工作流
Scene 是每页唯一的结构化渲染事实源。普通模式由 `slides_plan.md` 决定内容Markdown-first 模式由 `slides.md` 决定内容。scene 固化页面语义、对象和几何,图片版、画布和可编辑版都从 scene 派生,不再各自维护一套页面描述。
## Session v2
```text
session/
├── slides_plan.md
├── prompts.json
├── metadata.json
├── scenes/slide-001.scene.json
├── versions/r0001/
├── cache/image/slide-001/
├── cache/editable/slide-001/
├── generated/
├── assets/
├── final/
├── render/
└── reports/
```
`metadata.json` 只保存共享身份、当前 revision、环境报告和两个交付 variant 的状态。逐页细节留在 scene构建报告留在 `reports/`
## 执行顺序
```text
slides_plan.md + prompts.json
-> compile_scenes.py
-> validate_scene.py
-> preflight_ppt_environment.py
-> revision_session.py snapshot
-> rebuild_slide.py prepare
-> imagegen 或 editable builder 生成目标页
-> rebuild_slide.py commit
-> 重新封装整套 PPTX
```
局部重建只让目标页的昂贵依赖失效。最终 PPTX 可以整套快速封装,不直接修改 OOXML 中的单页关系。
`prepare` 会生成后端输入:图片版写入 `cache/image/slide-NNN/prompt.json`;可编辑版写入 `cache/editable/slide-NNN/visual_inventory.json`。其中未解析的 `semantic_visual` 会被标成 `unresolved`,必须完成资产绑定后才能进入正式可编辑构建。
全套可编辑构建使用 `compile_scenes.py --emit-editable-inventory` 生成 `cache/editable/visual_inventory.json`,再交给现有 semantic builder。该派生文件不会覆盖旧项目手工维护的根目录 `visual_inventory.json`
## Scene 规则
- `slide_id` 永久稳定,使用 `slide-001` 格式;改标题不能改 ID。
- 每个 element ID 在页内唯一bbox 使用像素坐标 `[x, y, width, height]`
- `semantic_visual` 是尚未绑定来源的语义视觉;进入可编辑正式构建前转换为 `imagegen_asset` 或有来源记录的 `provided_asset`
- `scene_hash` 排除自身和构建状态后计算;它决定页面是否失效。
- 普通版使用 `generation.prompt` 生成整页图;可编辑版读取 `elements` 生成原生文字、容器和独立资产。
## Revision 规则
- snapshot 保存 metadata、计划、prompts、scenes 和核心 manifest不复制大体积生成图。
- revision manifest 保存所有被追踪文件的 SHA-256资产继续由 session 中的稳定路径或内容 hash 引用。
- rollback 先自动 snapshot 当前状态,再恢复目标 revision并创建新的 rollback revision不得删除历史 revision。
## 向后兼容
旧 session 首次运行 `compile_scenes.py` 时自动把 metadata 提升为 schema v2并从 `slides_plan.md``prompts.json` 生成 scene。旧的 `visual_inventory.json``asset_manifest.json` 仍可被现有 builder 使用,但新项目应逐步改为从 scene 派生。

View File

@@ -0,0 +1,337 @@
# Semantic Editable Replica Workflow
Use this reference when an image-only slide, screenshot, rendered PPT page, or generated page must become a practical editable PPTX.
## Contents
- [Required Flow](#required-flow)
- [Reusable Project Layout](#reusable-project-layout)
- [Object Classes](#object-classes)
- [Minimum Semantic Unit](#minimum-semantic-unit)
- [Asset Generation Rules](#asset-generation-rules)
- [Grid vs Standalone Assets](#grid-vs-standalone-assets)
- [Required Manifests](#required-manifests)
- [Text Fit, Collision, And Visual Regression](#text-fit-collision-and-visual-regression)
- [Build Rules](#build-rules)
- [v4/v5 Gates](#v4v5-gates)
- [QA Rules](#qa-rules)
- [Practical Limits](#practical-limits)
## Required Flow
Element rebuild is a reference-driven asset workflow:
```text
reference
-> visual_inventory
-> asset_anchors
-> layout_rules
-> reference crops / residual redboxes
-> imagegen or API asset grid
-> grid_cut and alpha cleanup
-> asset_manifest
-> semantic input preflight
-> build_semantic_deck
-> text-fit and layout QA report
-> render compare
-> validation
```
If this flow is skipped, the result is only an editable draft or structure preview. Do not call it a completed element rebuild.
## Reusable Project Layout
Use the same artifact layout for every production reconstruction:
```text
reference/
reference_crops/
generated/
assets/
render/
compare/
reports/
prompts/assets_cycle_1.jsonl
visual_inventory.json
asset_anchors.json
asset_manifest.json
layout_rules.json
conversion_report.md
```
Run `scripts/init_semantic_project.py` to create the layout. The v4/v5 lesson is that project-local scripts can be temporary, but these ledgers must stay stable across pages:
- `visual_inventory.json`: every text, native layout object, and semantic visual placement.
- `asset_anchors.json`: target bboxes, z-order, fitted bboxes, and placement rules for semantic visuals.
- `asset_manifest.json`: one final transparent asset file per semantic unit, including source grid and cell.
- `layout_rules.json`: font policy, image-fit policy, forbidden media, source-type whitelist, and comparison thresholds.
## Object Classes
Classify every visible element before building:
- `text`: editable PPT text boxes.
- `layout_native`: page background, panels, cards, dividers, frames, tables, simple containers, and ordinary straight connectors.
- `imagegen_asset`: semantic non-text visuals such as icons, pictograms, 3D objects, chart symbols, badges, decorative UI renders, screenshots, devices, network diagrams, illustrations, and stylized arrows.
- `provided_asset`: user-supplied or approved transparent assets with documented source.
- `unresolved`: anything that still needs a decision.
If a visual carries meaning beyond being a border, divider, plain container, or text box, classify it as `imagegen_asset`. Complex arrows with gradients, curves, shadows, dashed returns, 3D depth, or brand-like styling are semantic assets, not native PPT arrows.
## Minimum Semantic Unit
The final PPT should expose the smallest useful selectable unit:
- one icon = one asset;
- one badge or risk marker = one asset;
- one decorative UI render = one asset;
- one 3D object or illustration = one asset;
- one stylized arrow = one asset;
- one text block = one text box;
- one card, panel, table frame, divider, or simple connector = one native PPT object.
Generated grids are extraction sheets only. Cut them into one transparent asset file per semantic unit before inserting anything into PPT.
## Asset Generation Rules
Every production image asset must be generated with Codex's `imagegen` skill. Use the full reference plus the relevant crop or residual crop as visual context when the tool supports references. Text-only generation is acceptable only for synthetic fixtures or generic demos, not for reference-matched production reconstruction.
Do not use LibreOffice, PowerPoint, SVG redraws, programmatic icon drawing, generic icon libraries, or browser screenshots as substitutes for image generation. LibreOffice / PowerPoint / soffice may be used only after the deck is built to render PPTX into PDF/PNG for QA. Label that step as render QA, not image generation.
`api_generated_asset` remains in the schema for compatibility, but do not choose it unless the user explicitly asks for an external image API. `provided_asset` is valid only for user-supplied or licensed assets and synthetic tests; it is not a production replacement for Codex `imagegen`.
Prompt each grid like this:
```text
Create an isolated asset grid for a PowerPoint visual replica.
Use the full reference for style and the supplied crops/residuals for object identity.
Objects in order: {object list}.
Grid: {rows}x{cols}, generous margins and equal cells.
Background: uniform chroma key #00ff00 or clean white.
Style: {short style guide}.
Text: no readable text, no numbers, no labels, no watermark.
Do not include card frames, slide fragments, fake logos, QR codes, crop borders, or surrounding context.
```
After generation:
- cut with declared grid geometry;
- remove white/chroma background to alpha;
- trim transparent borders;
- reject assets with readable text, residual card frames, hard crop edges, neighboring objects, or one-axis distortion;
- reject any grid-cut asset whose visible alpha touches the cell boundary, contains green/chroma spill, is clipped, or includes multiple semantic objects;
- record every final asset in `asset_manifest.json`.
The generated grid is never a final slide asset. It is only an extraction sheet. A production-grade v4/v5-style page should normally have:
- `prompts/assets_cycle_*.jsonl`;
- one or more files under `generated/`;
- transparent files under `assets/`;
- `asset_manifest.json` records with `source_type: imagegen_asset` or `api_generated_asset`;
- `asset_anchors.json` records for every placement.
If the manifest contains only synthetic `provided_asset` entries, the run proves the build chain but not visual replica quality.
## Grid vs Standalone Assets
Asset grids are a convenience, not the default for every visual. Use grids only for small, similarly sized, low-risk icons where each object is unlikely to cross cell boundaries.
Use standalone Codex `imagegen` calls for:
- central hero visuals and large 3D objects;
- shields, devices, dashboards, platform bases, and decorated scenes;
- complex arrows with shadows or gradients;
- assets that must preserve exact silhouette or visual weight;
- any asset that failed grid cutting because it was clipped, attached to neighbors, or left chroma spill.
If a grid asset fails, do not "fix" it by resizing or hiding the problem in PPT. Regenerate that semantic unit as a standalone `imagegen_asset`, remove chroma to alpha, and update `asset_manifest.json`.
For public examples, keep object names generic, for example `domain_icon_01`, `workflow_arrow_01`, `decorative_asset_01`.
## Required Manifests
`visual_inventory.json` should describe the page-level decomposition:
```json
{
"slide_size_px": [1920, 1080],
"final_deck_type": "semantic_editable_replica",
"source_image_policy": "reference only; do not embed the full source image in the final PPTX",
"items": [
{
"id": "title_main",
"class": "text",
"text": "Example title",
"bbox_px": [80, 60, 900, 80]
},
{
"id": "domain_icon_01",
"class": "imagegen_asset",
"bbox_px": [120, 220, 120, 120],
"semantic_unit_count": 1
}
]
}
```
`asset_manifest.json` should record final asset files:
```json
[
{
"semantic_unit_id": "domain_icon_01",
"source_type": "imagegen_asset",
"asset_path": "assets/domain_icon_01.png",
"semantic_unit_count": 1,
"generated_grid": "generated/domain_icons_grid.png",
"grid_cell": [0, 0],
"placement_rule": "uniform contain scaling; no one-axis stretch"
}
]
```
Acceptable source types are `imagegen_asset`, `api_generated_asset`, and documented `provided_asset`. `raw_crop`, `reference_crop`, `screenshot_crop`, `placeholder`, and `prompt_only_asset` are not acceptable final sources.
`asset_anchors.json` should record placements independently from asset provenance:
```json
[
{
"semantic_unit_id": "domain_icon_01",
"slide": 1,
"target_bbox_px": [120, 220, 120, 120],
"z_index": 20,
"placement_rule": "uniform contain scaling; no one-axis stretch"
}
]
```
`layout_rules.json` should be executable enough for QA:
```json
{
"slide_size_px": [1920, 1080],
"font_policy": {
"default_font_face": "Microsoft YaHei",
"text_box_extra_room_pct": 12
},
"image_fit": "uniform_contain_only",
"allowed_source_types": ["imagegen_asset", "api_generated_asset", "provided_asset"],
"forbidden_media": ["full_slide_reference_image", "near_full_slide_reference_image", "svg_media", "raw_crop_asset"],
"comparison": {
"diff_threshold": 18,
"changed_pixel_pct_target": 0.25
}
}
```
## Text Fit, Collision, And Visual Regression
The builder must not only count editable objects. It must record layout facts
that can be inspected after the PPTX is built:
- every text box: `slide`, `id`, `bbox_in`, `font_size`,
`effective_font_size`, `text_role`, `fit_mode`, `line_spacing_pt`,
`overflow_height_ratio`, `overflow_width_ratio`, `estimated_text_bbox_in`,
`parent_id`, and `z_index`;
- every image asset: semantic unit id, source path, placement slot, fitted bbox,
and uniform contain rule;
- layout QA: text overflow, text-text collision, text on non-decorative images,
out-of-slide bounds, and parent containment warnings.
- text-on-image is an error by default. Allow it only when the inventory item
explicitly sets `text_overlay_allowed: true`, for example intentional white
text on a clean shield, badge, or button asset.
Default text behavior:
- `fit_mode=shrink` unless explicitly disabled.
- Titles, subtitles, headers, pills, chips, tags, and badges default to
no-wrap; they should shrink before accidental one-character wrapping.
- Body text may wrap, but must report overflow when estimated content exceeds
the inner text box.
- Micro labels may go below the ordinary body font floor, but this must remain
visible in the build report as a warning or consciously accepted exception.
Validation should pass `--build-report` to `validate_semantic_deck.py`. A deck
with a clean media check but missing build-report layout QA is only a structure
draft.
Visual regression is a gate, not decoration:
- run `validate_pptx_fonts.py` to confirm explicit East Asian typefaces;
- render the editable PPTX through `render_pptx.py`; PowerPoint is target fidelity and LibreOffice is approximate QA only;
- run `compare_render.py` against the reference pages;
- inspect the contact sheet for title wrapping, card text overflow, icon/text
collisions, lost decorative assets, and broken z-order;
- if the compare status is `REVIEW`, update the inventory/layout rules and
rebuild rather than accepting the first PPTX.
## Build Rules
- Use native PPT text for all readable text.
- Keep line counts deliberate; do not let PowerPoint create accidental one-character wraps.
- Use `fit_mode=shrink` and role-aware font floors for dense pages; avoid
solving overflow by silently shrinking ordinary body text into unreadability.
- Use native shapes for cards, panels, frames, shadows, tables, dividers, and simple connectors.
- Insert semantic visuals as independent PNG assets.
- Fit images with uniform contain scaling.
- Never stretch images on one axis.
- Use `scripts/build_semantic_deck.py` for manifest-driven PPTX construction when the inventory fits the public schema.
- Use `scripts/validate_semantic_inputs.py` before build; do not wait for PPTX validation to discover missing assets or raw crops.
- Use `build_report.json` as the source of truth for text fitting, collision
checks, and fitted image positions.
- Never insert SVG media in a path B final PPTX.
- Never insert the full reference image or near-full-slide reference media in the final deck.
- Do not use generic hand-drawn icons or low-fidelity native shapes as replacements for semantic visuals.
## v4/v5 Gates
The v4/v5 trial succeeded because it passed these gates:
- Generated visual assets were created from reference-aware grids, not drawn as generic placeholders.
- Grids were cut into one transparent PNG per semantic unit.
- Dirty assets were cleaned before insertion.
- Semantic placements were recorded separately from source provenance.
- Text/card/container layers were rebuilt as native PPT objects.
- Render comparison produced contact sheets and diff heatmaps.
- Validation rejected full-slide references, raw crops, SVG media, and manifest/object mismatches.
Do not certify a page as `semantic visual-replica` unless the same gates exist for the page being delivered.
## QA Rules
Validate before handoff:
- PPTX opens and renders.
- Slide count matches the reference.
- No full-slide or near-full-slide reference bitmap is embedded.
- Reference image hashes do not appear in PPTX media.
- No SVG media exists in the final path B PPTX.
- Every semantic image object has a manifest record.
- Every `imagegen_asset` or `provided_asset` in inventory has a real asset file.
- Picture objects in PPTX are at least enough to cover manifest placements.
- Text items have visible native PPT text boxes; hidden transparent text over a screenshot is not a completed element rebuild.
- Render previews, contact sheet, and diff heatmaps exist when reference render is available.
- Report lists unresolved regions, retained bitmap exceptions, and known limits.
Use `scripts/build_semantic_deck.py`, `scripts/validate_semantic_deck.py`, `scripts/compare_render.py`, and `scripts/audit_pptx_editability.py` together. Any failed gate means the result is not a certified element rebuild.
For a reusable run, add:
```bash
python scripts/validate_semantic_inputs.py --inventory visual_inventory.json --manifest asset_manifest.json --anchors asset_anchors.json --stage build
python scripts/build_semantic_deck.py --inventory visual_inventory.json --manifest asset_manifest.json --out-pptx final/reconstructed.pptx --report reports/build_report.json
python scripts/validate_pptx_fonts.py final/reconstructed.pptx --out reports/font-validation.json
python scripts/audit_pptx_editability.py final/reconstructed.pptx --json reports/editability-audit.json
python scripts/validate_semantic_deck.py --pptx final/reconstructed.pptx --reference reference/page-01.png --manifest asset_manifest.json --inventory visual_inventory.json --build-report reports/build_report.json --out reports/semantic-validation.md
python scripts/run_quality_gate.py --session <session> --artifact <session>/final/reconstructed.pptx
```
## Practical Limits
- Use native PPT shapes for text, cards, tables, lines, connectors, and simple arrows.
- Use independent transparent assets for complex icons, pictograms, illustrations, 3D visuals, UI renders, and stylized arrows.
- Do not promise that SVG imports will remain internally editable in PowerPoint.
- Avoid decomposing one complex icon into many ungrouped shapes unless the user explicitly needs that tradeoff and accepts fragility.
- Keep 10%-15% extra room inside text boxes to absorb PowerPoint font and line-height differences.
- Open the PPTX in the target PowerPoint environment when possible. LibreOffice render is useful for fast iteration, but it is not the final source of truth for Chinese font metrics.

View File

@@ -0,0 +1,51 @@
# 中文商业风格库
风格库用于新做 PPT 的视觉定向,不替代用户提供的品牌规范或模板。用户有真实模板、品牌色、字体和 Logo 规范时,以用户材料为最高优先级。
## 选择流程
1. 根据受众、沟通目标、行业和使用场合,从 `styles/catalog.json` 推荐 12 套风格家族,并在家族内选择一个视觉变体。
2. 用户没有指定且选择不影响方向时,可以采用最匹配的一套,但须在大纲的假设中明确标注。
- `direct-build` 可以采用该假设并继续。
- `canvas-first` 只能把候选排序展示给用户,不得自动确认第一项;选择前记录 `style_selection_status: pending`
3. 选择后完整读取 `styles/<style_id>/STYLE.md``layouts.json`
4.`style_id``style_variant` 写入 `slides_plan.md` frontmatter、`prompts.json``metadata.json`
5.`styles/catalog.json` 读取该风格绑定的 `typography_profile``table_profile`,写入同一组计划和 session 元数据;详细规则见 [typography-workflow.md](typography-workflow.md)。
6. 每页从该风格的布局库选择一个 `layout_id`。布局只约束信息组织,不是固定模板截图。
7. 冒烟页必须使用正式 `style_id``style_variant``layout_id`、正式文案和正式尺寸。
当前风格库包含 8 个家族、每个家族 3 个变体,共 24 个可选视觉预设。家族定义信息组织语言,变体定义配色和视觉气质;不要为每个变体复制一套布局文件。
“24 个预设”表示可选择的视觉系统,不表示一套 PPT 同时混用 24 种样式,也不表示系统会随机选一种。默认仍只使用一个主风格;需要用户可见选择时必须进入 `canvas-first`
## 快速匹配
| 风格 | 优先场景 | 主要气质 |
|---|---|---|
| 企业科技深蓝 | AI、数字化、技术架构、售前 | 深色、克制光效、可信科技 |
| 蓝白咨询商务 | 咨询建议、管理层方案 | 明亮、清晰、理性、留白 |
| 制造工业 | 制造、工程、能源、供应链 | 硬朗、网格、材质感、秩序 |
| 政府正式 | 政府、国企、政策、专项汇报 | 稳重、规范、权威、克制 |
| 极简高管汇报 | 董事会、经营决策、行动建议 | 一页一结论、强层级、大留白 |
| 数据咨询 | 数据战略、洞察、指标、架构 | 深色咨询、图形化、分析感 |
| 品牌发布 | 新品、品牌升级、创意提案 | 高对比、强节奏、发布感 |
| 培训插画 | 培训、课程、科普、宣导 | 友好、易懂、统一插画语言 |
具体变体以 `styles/catalog.json` 为准。画布从 editor manifest 的 `style_catalog` 读取扁平化预设,不自行维护另一份样式清单。
## 使用约束
- 不从参考风格复制客户名、品牌标识、案例、数字或图片素材。
- 不因风格需要而虚构参数、准确率、ROI、排名或政策结论。
- 同一套 PPT 默认只选一套主风格;章节可改变布局和背景明暗,但不得随意混搭视觉语言。
- 单页只能切换当前主风格家族内的变体;跨家族切换必须作用于整套。
- 封面和结束页可以使用不可复用的强构图;内容页优先使用 `reuse_friendly: true` 的布局。
- 连续页面避免重复完全相同的布局;内容密度超出 `content_capacity` 时,应拆页而不是缩小字体。
- 内置字体建议只是方向;交付前应检查目标设备的中文字体可用性并准备回退字体。
- 宋体和小字号只用于正式阅读型 profile会议投屏与高管演讲不得机械套用。
修改或新增风格后运行:
```bash
python scripts/validate_style_library.py
```

View File

@@ -0,0 +1,57 @@
# 中文排版与表格策略
新做可编辑 PPTX、原生模板填充或检查正式中文汇报时读取本规范。目标是把字体、字号、段落和表格对齐变成可执行策略而不是依赖页面级临时判断。
## 选择 Profile
内置配置位于 `styles/typography-profiles.json`。风格在 `styles/catalog.json` 中绑定默认 `typography_profile``table_profile`;用户模板、品牌规范或明确指定字体始终优先。
| Profile | 适用场景 | 主要特点 |
|---|---|---|
| `zh-formal-reading` | 研报、政府专项材料、邮件阅读型正式汇报 | 标题黑体、正文宋体、西文 Times New Roman正文和表格密度较高 |
| `zh-business-present` | 售前、项目汇报、技术说明、会议投屏 | 中文无衬线;正文与表格字号适合会议屏幕 |
| `zh-executive-stage` | 高管演讲、董事会重点页、品牌发布 | 更大字号、低密度、强页面节奏 |
宋体、12pt 和 1.5 倍行距不是全局默认值,只属于正式阅读型 profile。存在模板真实字号系统时以模板为准并把例外写入 review note。
## Token 纪律
构建前在 `metadata.json` 锁定 `typography_profile``table_profile`。文本对象使用语义角色,不直接发明任意字号:
- `hero``section_title``page_title`
- `subtitle``minor_title`
- `body``label``caption`
- `table`
字号使用 `0.5pt` 网格。超出容量时优先精简、扩大文本框或拆页;不要把正文持续缩到 profile token 以下。用户确有模板或品牌字号要求时允许偏离,但必须记录原因。
构建器会把语义角色写入 shape name 的 `[pf-role=<role>]` 标记,供 `validate_pptx_typography.py` 回读。手工或模板对象缺少标记时,验证器只能根据 placeholder、名称和内容做有限推断无法判断必须报告 `NOT_CHECKED`,不能算作通过。
## 表格策略
- 单元格默认上下居中。
- 表头居中;索引或类目列居左;普通文本列居左;数值列居右。
- 表格段前段后为 0、单倍行距、无首行缩进或异常层级。
- 百分比、单位、负数和小数位数在同列保持一致;单位优先写入表头。
- 无法可靠识别列语义时记录 `NOT_CHECKED`,不要猜测后强制改写。
## 验证
先运行字体声明检查,再运行语义排版检查:
```bash
python scripts/validate_pptx_fonts.py final/deck.pptx --font "微软雅黑"
python scripts/validate_pptx_typography.py final/deck.pptx \
--profile zh-business-present \
--table-profile presentation-data-table \
--out reports/typography-validation.json
```
结果语义:
- `PASS`:已检查项符合策略。
- `WARN`:可交付但应修复或解释,例如字号偏离 token、表格对齐不一致。
- `NOT_CHECKED`:没有足够的角色或格式信息完成判断。
- `FAIL`:配置、文件或检查过程无效。
统一质量门把 `WARN``NOT_CHECKED` 作为显式警告,把 `FAIL` 作为阻断项。

View File

@@ -0,0 +1,52 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://local.codex/presentation-forge/editor-manifest.schema.json",
"title": "Presentation Forge Editor Manifest",
"type": "object",
"required": ["schema_version", "protocol", "session_id", "document_kind", "editable_document", "document_sha256", "canvas_sha256", "workflow", "current_style", "style_catalog", "slides", "slide_count", "capabilities"],
"properties": {
"schema_version": {"const": 3},
"protocol": {"const": "presentation-forge-editor.v3"},
"session_id": {"type": "string"},
"base_revision": {"type": ["string", "null"]},
"route": {"type": ["string", "null"]},
"document_kind": {"const": "native-editable-deck"},
"editable_document": {"type": "string"},
"document_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
"canvas_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
"workflow": {
"type": "object",
"required": ["mode", "style_selection_status", "export_approval", "final_pptx_ready"],
"properties": {
"mode": {"enum": ["direct-build", "canvas-first"]},
"style_selection_status": {"enum": ["pending", "auto-selected", "confirmed"]},
"export_approval": {"enum": ["pending", "auto-proceed", "approved"]},
"final_pptx_ready": {"type": "boolean"}
}
},
"global_editable_paths": {"type": "array", "items": {"type": "string"}},
"current_style": {"type": "object", "required": ["style_id", "variant_id", "family_name", "name"]},
"style_catalog": {"type": "array", "minItems": 24, "items": {"type": "object", "required": ["preset_id", "style_id", "variant_id", "family_name", "name", "design_tokens"]}},
"slide_count": {"type": "integer", "minimum": 1},
"slides": {
"type": "array",
"items": {
"type": "object",
"required": ["slide_number", "title", "scene", "editable_paths", "canvas", "elements"],
"properties": {
"slide_number": {"type": "integer", "minimum": 1},
"title": {"type": "string"},
"archetype": {"type": ["string", "null"]},
"style": {"type": "object", "required": ["style_id", "variant_id"]},
"scene": {"type": "string"},
"thumbnail": {"type": ["string", "null"]},
"editable_paths": {"type": "array", "items": {"type": "string"}},
"canvas": {"type": "object"},
"elements": {"type": "array"}
}
}
},
"capabilities": {"type": "object"},
"events": {"type": "string"}
}
}

View File

@@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://local.codex/presentation-forge/editor-patch.schema.json",
"title": "Presentation Forge Editor Patch",
"type": "object",
"required": ["schema_version", "request_id", "base_revision", "document_sha256", "canvas_sha256", "operations"],
"properties": {
"schema_version": {"const": 3},
"request_id": {"type": "string", "minLength": 1},
"base_revision": {"type": ["string", "null"]},
"document_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
"canvas_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
"operations": {
"type": "array",
"minItems": 1,
"items": {
"oneOf": [
{
"type": "object",
"required": ["op", "path", "value"],
"properties": {"op": {"const": "replace"}, "path": {"type": "string", "pattern": "^/"}, "value": {}}
},
{
"type": "object",
"required": ["op", "slide_number", "element_id", "changes"],
"properties": {
"op": {"const": "update-element"},
"slide_number": {"type": "integer", "minimum": 1},
"element_id": {"type": "string", "minLength": 1},
"changes": {
"type": "object",
"minProperties": 1,
"properties": {
"bbox": {"type": "array", "minItems": 4, "maxItems": 4, "items": {"type": "number"}},
"text": {"type": "string"},
"style": {"type": "object"},
"rotation": {"type": "number"},
"z_index": {"type": "integer", "minimum": 0}
}
}
}
},
{
"type": "object",
"required": ["op", "scope", "style_id", "variant_id", "mode"],
"properties": {
"op": {"const": "apply-style"},
"scope": {"enum": ["deck", "slide"]},
"slide_number": {"type": "integer", "minimum": 1},
"style_id": {"type": "string", "minLength": 1},
"variant_id": {"type": "string", "minLength": 1},
"mode": {"enum": ["replace-theme", "preserve-layout"]}
},
"allOf": [
{"if": {"properties": {"scope": {"const": "slide"}}}, "then": {"required": ["slide_number"]}}
]
}
]
}
}
}
}

View File

@@ -0,0 +1,56 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://local.codex/presentation-forge/native-editable-deck.schema.json",
"title": "Native Editable Deck Spec",
"type": "object",
"required": ["schema_version", "title", "style_id", "visual_asset_policy", "slides"],
"properties": {
"schema_version": {"const": 1},
"title": {"type": "string", "minLength": 1},
"style_id": {"type": "string", "minLength": 1},
"style_variant": {"type": ["string", "null"]},
"typography_profile": {"type": ["string", "null"]},
"table_profile": {"type": ["string", "null"]},
"visual_asset_policy": {"enum": ["native-only", "native-image-assisted", "image-led-editable"]},
"design_exceptions": {
"type": "array",
"items": {
"type": "object",
"required": ["code", "slide_number", "reason"],
"properties": {
"code": {"type": "string", "minLength": 1},
"slide_number": {"type": "integer", "minimum": 1},
"reason": {"type": "string", "minLength": 8}
}
}
},
"slides": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["slide_number", "archetype", "title"],
"properties": {
"slide_number": {"type": "integer", "minimum": 1},
"archetype": {"enum": ["cover", "content-structured", "process-flow", "comparison-two-zone", "data-callouts", "table", "architecture", "closing-action"]},
"title": {"type": "string", "minLength": 1},
"style_id": {"type": "string", "minLength": 1},
"style_variant": {"type": "string", "minLength": 1},
"key_message": {"type": "string"},
"visual_slot": {
"type": "object",
"required": ["backend", "source_type", "status", "asset_path"],
"properties": {
"backend": {"enum": ["image-2", "provided"]},
"source_type": {"enum": ["imagegen_asset", "provided_asset"]},
"status": {"enum": ["planned", "pending_user_generation", "generated", "validated", "blocked"]},
"asset_path": {"type": "string"},
"prompt_record": {"type": ["string", "null"]},
"bbox_in": {"type": "array", "minItems": 4, "maxItems": 4, "items": {"type": "number"}}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,80 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://local.codex/presentation-forge/scene.schema.json",
"title": "PPT Page Scene",
"type": "object",
"required": ["schema_version", "slide_id", "slide_number", "revision", "page_type", "canvas", "content", "elements", "dependencies"],
"properties": {
"schema_version": {"const": 2},
"slide_id": {"type": "string", "pattern": "^slide-[0-9]{3}$"},
"slide_number": {"type": "integer", "minimum": 1},
"revision": {"type": "integer", "minimum": 1},
"page_type": {"type": "string"},
"style_id": {"type": ["string", "null"]},
"style_variant": {"type": ["string", "null"]},
"layout_id": {"type": ["string", "null"]},
"typography_profile": {"type": ["string", "null"]},
"table_profile": {"type": ["string", "null"]},
"visual_asset_policy": {"enum": ["native-only", "native-image-assisted", "image-led-editable", null]},
"canvas": {
"type": "object",
"required": ["width", "height", "unit"],
"properties": {"width": {"type": "integer", "minimum": 1}, "height": {"type": "integer", "minimum": 1}, "unit": {"const": "px"}}
},
"content": {
"type": "object",
"required": ["title"],
"properties": {"title": {"type": "string"}, "message": {"type": "string"}, "facts": {"type": "array"}}
},
"generation": {"type": "object"},
"elements": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "type", "bbox", "rotation", "z_index", "editable", "locked", "capabilities", "style"],
"properties": {
"id": {"type": "string", "minLength": 1},
"type": {"enum": ["text", "shape", "line", "connector", "image", "chart", "table", "group", "layout_native", "line_native", "connector_native", "semantic_visual", "imagegen_asset", "provided_asset", "unresolved"]},
"editable": {"type": "boolean"},
"locked": {"type": "boolean"},
"bbox": {"type": "array", "minItems": 4, "maxItems": 4, "prefixItems": [{"type": "number", "minimum": 0}, {"type": "number", "minimum": 0}, {"type": "number", "exclusiveMinimum": 0}, {"type": "number", "exclusiveMinimum": 0}]},
"rotation": {"type": "number"},
"z_index": {"type": "integer", "minimum": 0},
"role": {"type": ["string", "null"]},
"geometry": {"type": "string"},
"text": {"type": "string"},
"capabilities": {"type": "array", "uniqueItems": true, "items": {"enum": ["text", "geometry", "style", "rotation", "z-order", "asset"]}},
"style": {
"type": "object",
"properties": {
"fill": {"type": "string", "pattern": "^[A-Fa-f0-9]{6}$"},
"line": {"type": "string", "pattern": "^[A-Fa-f0-9]{6}$"},
"line_width_pt": {"type": "number", "minimum": 0},
"font_size_pt": {"type": "number", "exclusiveMinimum": 0},
"font_family": {"type": "string"},
"font_color": {"type": "string", "pattern": "^[A-Fa-f0-9]{6}$"},
"bold": {"type": "boolean"},
"align": {"enum": ["left", "center", "right", "justify"]},
"vertical_align": {"enum": ["top", "middle", "bottom"]},
"line_spacing": {"type": "number", "exclusiveMinimum": 0}
}
},
"source_binding": {
"type": "object",
"required": ["document", "path"],
"properties": {"document": {"type": "string"}, "path": {"type": "string", "pattern": "^/"}}
}
}
}
},
"dependencies": {
"type": "object",
"required": ["scene_hash", "prompt_hash", "asset_hashes"],
"properties": {
"scene_hash": {"type": "string"},
"prompt_hash": {"type": "string"},
"asset_hashes": {"type": "array", "items": {"type": "string"}}
}
}
}
}

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Audit whether a PPTX is likely image-only or practically editable."""
from __future__ import annotations
import argparse
import json
import re
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
NS = {
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pptx", help="PPTX file to audit.")
parser.add_argument("--json", dest="json_path", help="Optional JSON report path.")
return parser.parse_args()
def natural_slide_key(name: str) -> list[object]:
return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", name)]
def audit_pptx(pptx_path: Path) -> dict:
with zipfile.ZipFile(pptx_path) as zf:
names = zf.namelist()
media = [name for name in names if name.startswith("ppt/media/")]
slides = sorted(
[name for name in names if re.match(r"ppt/slides/slide\d+\.xml$", name)],
key=natural_slide_key,
)
slide_reports = []
for idx, name in enumerate(slides, start=1):
root = ET.fromstring(zf.read(name))
text_runs = [node.text or "" for node in root.findall(".//a:t", NS)]
text_chars = sum(len(text.strip()) for text in text_runs)
shape_count = len(root.findall(".//p:sp", NS))
picture_count = len(root.findall(".//p:pic", NS))
group_count = len(root.findall(".//p:grpSp", NS))
graphic_frame_count = len(root.findall(".//p:graphicFrame", NS))
flags = []
if picture_count > 0 and text_chars == 0 and shape_count <= 1:
flags.append("likely_image_only_slide")
if text_chars > 0 and picture_count > 0:
flags.append("mixed_native_text_and_images")
if group_count > 20:
flags.append("many_group_shapes_may_be_fragile")
slide_reports.append(
{
"slide": idx,
"shape_count": shape_count,
"picture_count": picture_count,
"group_count": group_count,
"graphic_frame_count": graphic_frame_count,
"text_run_count": len(text_runs),
"text_char_count": text_chars,
"flags": flags,
}
)
media_by_ext: dict[str, int] = {}
for item in media:
ext = Path(item).suffix.lower() or "<none>"
media_by_ext[ext] = media_by_ext.get(ext, 0) + 1
deck_flags = []
if all("likely_image_only_slide" in slide["flags"] for slide in slide_reports):
deck_flags.append("deck_looks_image_only")
if media_by_ext.get(".svg", 0) > 0:
deck_flags.append("contains_svg_media_check_powerpoint_editability")
return {
"pptx": str(pptx_path),
"slide_count": len(slide_reports),
"media_count": len(media),
"media_by_ext": media_by_ext,
"deck_flags": deck_flags,
"slides": slide_reports,
}
def print_report(report: dict) -> None:
print(f"PPTX: {report['pptx']}")
print(f"Slides: {report['slide_count']} | Media: {report['media_count']} | Flags: {', '.join(report['deck_flags']) or 'none'}")
for slide in report["slides"]:
flags = ", ".join(slide["flags"]) or "none"
print(
f"- slide {slide['slide']:02d}: text={slide['text_char_count']} chars, "
f"shapes={slide['shape_count']}, pics={slide['picture_count']}, "
f"groups={slide['group_count']}, frames={slide['graphic_frame_count']}, flags={flags}"
)
def main() -> None:
args = parse_args()
report = audit_pptx(Path(args.pptx))
print_report(report)
if args.json_path:
out = Path(args.json_path)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Scan a public skill package for obvious private paths, secrets, and client residue."""
from __future__ import annotations
import argparse
import re
from pathlib import Path
DEFAULT_BLOCK_PATTERNS = [
r"/" + "Users" + r"/",
r"/" + "home" + r"/",
r"/" + "Volumes" + r"/",
r"Bearer\s+[A-Za-z0-9._-]+",
r"sk-[A-Za-z0-9_-]{12,}",
r"(?i)(api[_-]?key|OPENAI_API_KEY|password|passwd|cookie|secret|token)\s*[:=]",
]
PROJECT_RESIDUE_PATTERNS: list[str] = []
TEXT_SUFFIXES = {
".md",
".txt",
".py",
".js",
".ts",
".json",
".jsonl",
".yaml",
".yml",
".toml",
".xml",
".html",
".css",
".svg",
}
SKIP_DIR_NAMES = {
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"build",
"dist",
"env",
"node_modules",
"output",
"venv",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", default=".", help="Repository or skill root to scan.")
parser.add_argument("--extra-pattern", action="append", default=[], help="Additional regex pattern to block.")
parser.add_argument("--allow-project-words", action="store_true", help="Do not fail on built-in project residue word list.")
return parser.parse_args()
def should_scan(path: Path) -> bool:
if any(part in SKIP_DIR_NAMES for part in path.parts):
return False
if path.name in {".DS_Store"}:
return False
return path.suffix.lower() in TEXT_SUFFIXES
def main() -> None:
args = parse_args()
root = Path(args.root)
patterns = [re.compile(p, re.IGNORECASE) for p in DEFAULT_BLOCK_PATTERNS + args.extra_pattern]
if not args.allow_project_words:
patterns.extend(re.compile(re.escape(p), re.IGNORECASE) for p in PROJECT_RESIDUE_PATTERNS)
findings = []
for path in sorted(p for p in root.rglob("*") if p.is_file() and should_scan(p)):
rel = path.relative_to(root)
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
for line_no, line in enumerate(text.splitlines(), start=1):
for pattern in patterns:
if pattern.search(line):
findings.append((str(rel), line_no, pattern.pattern))
if findings:
for rel, line_no, pattern in findings:
print(f"{rel}:{line_no}: matched {pattern}")
raise SystemExit(1)
print("Public skill audit passed.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,702 @@
#!/usr/bin/env python3
"""Build an AIPPT-style native editable deck with optional image-2 visual assets."""
from __future__ import annotations
import argparse
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from pptx import Presentation
from pptx.chart.data import ChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
from build_semantic_deck import fit_contain, hex_to_rgb, patch_theme_east_asian_fonts, set_run_typefaces
from style_presets import merged_design_tokens, resolve_style
from validate_design_quality import analyze_design
ROOT = Path(__file__).resolve().parents[1]
SLIDE_W = 13.333333
SLIDE_H = 7.5
POLICIES = {"native-only", "native-image-assisted", "image-led-editable"}
ARCHETYPES = {"cover", "content-structured", "process-flow", "comparison-two-zone", "data-callouts", "table", "architecture", "closing-action"}
ROLE_SIZE_KEY = {
"hero": "hero",
"section_title": "section_title",
"title": "page_title",
"subtitle": "subtitle",
"header": "minor_title",
"body": "body",
"label": "label",
"caption": "caption",
"table": "table",
}
def read_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"expected JSON object: {path}")
return value
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def first_color(value: Any, fallback: str) -> str:
if isinstance(value, list):
return str(value[0]) if value else fallback
return str(value or fallback)
def second_color(value: Any, fallback: str) -> str:
if isinstance(value, list) and len(value) > 1:
return str(value[1])
return fallback
def load_design(style_id: str, variant_id: str | None, profile_id: str | None, table_id: str | None) -> tuple[dict[str, str], dict[str, Any], dict[str, Any], str, str, str]:
style, variant, raw = merged_design_tokens(style_id, variant_id)
text_values = raw.get("text") if isinstance(raw.get("text"), list) else [raw.get("text")]
background = first_color(raw.get("background"), "FFFFFF")
colors = {
"background": background,
"primary": first_color(raw.get("primary"), "2563EB"),
"secondary": first_color(raw.get("secondary"), "60A5FA"),
"text": str((text_values or ["111827"])[0] or "111827"),
"muted": str((text_values or ["111827", "64748B"])[1] if len(text_values or []) > 1 else "64748B"),
"panel": second_color(raw.get("background"), "F3F6FA"),
}
profiles = read_json(ROOT / "styles" / "typography-profiles.json")
resolved_profile = profile_id or style.get("typography_profile")
resolved_table = table_id or style.get("table_profile")
typography = next((row for row in profiles.get("typography_profiles", []) if row.get("id") == resolved_profile), None)
table_policy = next((row for row in profiles.get("table_profiles", []) if row.get("id") == resolved_table), None)
if not typography or not table_policy:
raise ValueError(f"unknown typography/table profile: {resolved_profile}/{resolved_table}")
return colors, typography, table_policy, str(resolved_profile), str(resolved_table), str(variant["id"])
def set_role(shape: Any, role: str, object_id: str) -> None:
shape._element.nvSpPr.cNvPr.set("name", f"{object_id} [pf-role={role}]")
def set_object_name(shape: Any, object_id: str) -> None:
for attr in ("nvSpPr", "nvCxnSpPr", "nvPicPr", "nvGraphicFramePr", "nvGrpSpPr"):
node = getattr(shape._element, attr, None)
if node is not None and getattr(node, "cNvPr", None) is not None:
node.cNvPr.set("name", object_id)
return
def object_id(shape: Any) -> str:
return str(getattr(shape, "name", "")).split(" [pf-role=", 1)[0]
def object_role(shape: Any) -> str | None:
name = str(getattr(shape, "name", ""))
return name.split("[pf-role=", 1)[1].rstrip("]") if "[pf-role=" in name else None
def role_settings(profile: dict[str, Any], role: str) -> tuple[float, str, str, dict[str, Any]]:
size_key = ROLE_SIZE_KEY[role]
size = float(profile["tokens"][size_key])
heading = size_key in {"hero", "section_title", "page_title", "subtitle", "minor_title"}
fonts = profile["fonts"]
east_asia = fonts["heading_east_asia"] if heading else fonts["body_east_asia"]
latin = fonts["latin"]
paragraph_group = "title" if heading else "caption" if size_key in {"label", "caption"} else "body"
return size, east_asia, latin, profile.get("paragraph", {}).get(paragraph_group, {})
def add_text(
slide: Any,
text: str,
box: tuple[float, float, float, float],
role: str,
profile: dict[str, Any],
color: str,
object_id: str,
*,
align: str = "left",
bold: bool | None = None,
vertical: str = "middle",
) -> Any:
x, y, w, h = box
shape = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
set_role(shape, role, object_id)
frame = shape.text_frame
frame.clear()
frame.word_wrap = True
frame.margin_left = Inches(0.04)
frame.margin_right = Inches(0.04)
frame.margin_top = Inches(0.02)
frame.margin_bottom = Inches(0.02)
shape.vertical_anchor = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM}.get(vertical, MSO_ANCHOR.MIDDLE)
size, east_asia, latin, paragraph_policy = role_settings(profile, role)
lines = str(text).split("\n")
for index, line in enumerate(lines):
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
run = paragraph.add_run()
run.text = line
run.font.name = latin
run.font.size = Pt(size)
run.font.bold = bool(role in {"hero", "section_title", "title", "header"}) if bold is None else bold
run.font.color.rgb = hex_to_rgb(color, "111827")
set_run_typefaces(run, east_asia, latin, profile["fonts"].get("complex_script") or latin)
paragraph.alignment = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(align, PP_ALIGN.LEFT)
paragraph.line_spacing = float(paragraph_policy.get("line_spacing_multiple", 1.0))
paragraph.space_before = Pt(size * float(paragraph_policy.get("space_before_lines", 0)))
paragraph.space_after = Pt(size * float(paragraph_policy.get("space_after_lines", 0)))
return shape
def add_box(slide: Any, box: tuple[float, float, float, float], fill: str, line: str, object_id: str, radius: bool = True) -> Any:
x, y, w, h = box
shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE
shape = slide.shapes.add_shape(shape_type, Inches(x), Inches(y), Inches(w), Inches(h))
shape._element.nvSpPr.cNvPr.set("name", object_id)
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(fill, "FFFFFF")
shape.line.color.rgb = hex_to_rgb(line, "D7E2F0")
shape.line.width = Pt(0.8)
return shape
def add_title(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str]) -> None:
add_text(slide, str(row.get("title", "")), (0.65, 0.34, 12.0, 0.7), "title", profile, colors["text"], f"s{row['slide_number']:03d}_title", vertical="top")
if row.get("key_message"):
add_text(slide, str(row["key_message"]), (0.68, 1.02, 11.7, 0.42), "subtitle", profile, colors["muted"], f"s{row['slide_number']:03d}_message", vertical="top")
def add_visual(slide: Any, slot: dict[str, Any], base_dir: Path, default_box: tuple[float, float, float, float], state: dict[str, int], object_name: str) -> None:
asset = Path(str(slot["asset_path"]))
asset_path = asset if asset.is_absolute() else base_dir / asset
raw_box = slot.get("bbox_in") or list(default_box)
box = tuple(float(value) for value in raw_box)
fitted = fit_contain(asset_path, box)
picture = slide.shapes.add_picture(str(asset_path), Inches(fitted[0]), Inches(fitted[1]), width=Inches(fitted[2]), height=Inches(fitted[3]))
set_object_name(picture, object_name)
state["image_objects"] += 1
if slot.get("backend") == "image-2":
state["image2_assets"] += 1
def validate_visual_slot(slot: dict[str, Any], base_dir: Path, policy: str, slide_number: int) -> list[str]:
errors: list[str] = []
backend = str(slot.get("backend") or "provided")
source_type = str(slot.get("source_type") or "provided_asset")
status = str(slot.get("status") or "planned")
if policy == "native-only" and backend == "image-2":
errors.append(f"slide_{slide_number}:native_only_rejects_image2")
if backend == "image-2":
if source_type != "imagegen_asset":
errors.append(f"slide_{slide_number}:image2_source_type_must_be_imagegen_asset")
if status not in {"generated", "validated"}:
errors.append(f"slide_{slide_number}:image2_asset_not_generated")
prompt = slot.get("prompt_record")
if not prompt:
errors.append(f"slide_{slide_number}:image2_prompt_record_missing")
else:
prompt_path = Path(str(prompt))
prompt_path = prompt_path if prompt_path.is_absolute() else base_dir / prompt_path
if not prompt_path.is_file():
errors.append(f"slide_{slide_number}:image2_prompt_record_not_found")
asset = slot.get("asset_path")
if not asset:
errors.append(f"slide_{slide_number}:visual_asset_path_missing")
else:
asset_path = Path(str(asset))
asset_path = asset_path if asset_path.is_absolute() else base_dir / asset_path
if not asset_path.is_file():
errors.append(f"slide_{slide_number}:visual_asset_not_found")
return errors
def build_cover(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], base_dir: Path, state: dict[str, int]) -> None:
visual = row.get("visual_slot")
title_box = (0.85, 1.35, 6.8 if visual else 11.6, 1.45)
add_text(slide, str(row.get("title", "")), title_box, "hero", profile, colors["text"], f"s{row['slide_number']:03d}_hero", vertical="top")
if row.get("subtitle"):
add_text(slide, str(row["subtitle"]), (0.9, 3.0, 6.4 if visual else 10.8, 0.8), "subtitle", profile, colors["muted"], f"s{row['slide_number']:03d}_subtitle", vertical="top")
if row.get("meta"):
add_text(slide, " · ".join(str(value) for value in row["meta"]), (0.9, 6.45, 8.0, 0.35), "caption", profile, colors["muted"], f"s{row['slide_number']:03d}_meta")
if visual:
add_visual(slide, visual, base_dir, (8.0, 0.9, 4.5, 5.8), state, f"s{row['slide_number']:03d}_visual")
def build_content(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
cards = row.get("cards", [])
columns = 2 if len(cards) <= 4 else 3
rows = (len(cards) + columns - 1) // columns
gap = 0.25
card_w = (12.0 - gap * (columns - 1)) / columns
card_h = (5.35 - gap * (rows - 1)) / max(1, rows)
for index, card in enumerate(cards):
col, row_index = index % columns, index // columns
x = 0.65 + col * (card_w + gap)
y = 1.62 + row_index * (card_h + gap)
add_box(slide, (x, y, card_w, card_h), colors["panel"], colors["primary"], f"s{row['slide_number']:03d}_card_{index+1}")
add_text(slide, str(card.get("title", "")), (x + 0.22, y + 0.18, card_w - 0.44, 0.45), "header", profile, colors["text"], f"s{row['slide_number']:03d}_card_{index+1}_title", vertical="top")
add_text(slide, str(card.get("body", "")), (x + 0.22, y + 0.75, card_w - 0.44, card_h - 0.95), "body", profile, colors["muted"], f"s{row['slide_number']:03d}_card_{index+1}_body", vertical="top")
state["native_shapes"] += 1
def build_process(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
steps = row.get("steps", [])
gap = 0.28
width = (12.0 - gap * (len(steps) - 1)) / max(1, len(steps))
for index, step in enumerate(steps):
x = 0.65 + index * (width + gap)
add_box(slide, (x, 2.0, width, 3.65), colors["panel"], colors["primary"], f"s{row['slide_number']:03d}_step_{index+1}")
add_text(slide, f"{index + 1:02d}", (x + 0.18, 2.2, 0.7, 0.42), "label", profile, colors["primary"], f"s{row['slide_number']:03d}_step_{index+1}_number")
add_text(slide, str(step.get("title", "")), (x + 0.18, 2.85, width - 0.36, 0.55), "header", profile, colors["text"], f"s{row['slide_number']:03d}_step_{index+1}_title", vertical="top")
add_text(slide, str(step.get("body", "")), (x + 0.18, 3.55, width - 0.36, 1.55), "body", profile, colors["muted"], f"s{row['slide_number']:03d}_step_{index+1}_body", vertical="top")
state["native_shapes"] += 1
if index < len(steps) - 1:
connector = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, Inches(x + width), Inches(3.8), Inches(x + width + gap), Inches(3.8))
set_object_name(connector, f"s{row['slide_number']:03d}_connector_{index+1}")
connector.line.color.rgb = hex_to_rgb(colors["secondary"], "60A5FA")
connector.line.width = Pt(1.5)
state["connectors"] += 1
def add_bullet_list(slide: Any, values: list[Any], box: tuple[float, float, float, float], profile: dict[str, Any], color: str, object_id: str) -> None:
add_text(slide, "\n".join(f"{value}" for value in values), box, "body", profile, color, object_id, vertical="top")
def build_comparison(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
sides = [row.get("left", {}), row.get("right", {})]
for index, side in enumerate(sides):
x = 0.65 + index * 6.15
add_box(slide, (x, 1.75, 5.85, 4.9), colors["panel"], colors["primary"] if index else colors["secondary"], f"s{row['slide_number']:03d}_compare_{index+1}")
add_text(slide, str(side.get("title", "")), (x + 0.3, 2.05, 5.25, 0.6), "header", profile, colors["text"], f"s{row['slide_number']:03d}_compare_{index+1}_title")
add_bullet_list(slide, list(side.get("points", [])), (x + 0.35, 2.85, 5.1, 3.35), profile, colors["muted"], f"s{row['slide_number']:03d}_compare_{index+1}_points")
state["native_shapes"] += 1
def add_native_chart(slide: Any, chart_spec: dict[str, Any], box: tuple[float, float, float, float], colors: dict[str, str], state: dict[str, int], object_name: str) -> None:
chart_data = ChartData()
chart_data.categories = [str(value) for value in chart_spec.get("categories", [])]
for series in chart_spec.get("series", []):
chart_data.add_series(str(series.get("name", "Series")), [float(value) for value in series.get("values", [])])
chart_type = {
"column": XL_CHART_TYPE.COLUMN_CLUSTERED,
"bar": XL_CHART_TYPE.BAR_CLUSTERED,
"line": XL_CHART_TYPE.LINE_MARKERS,
"pie": XL_CHART_TYPE.PIE,
}.get(str(chart_spec.get("type", "column")), XL_CHART_TYPE.COLUMN_CLUSTERED)
x, y, w, h = box
chart_shape = slide.shapes.add_chart(chart_type, Inches(x), Inches(y), Inches(w), Inches(h), chart_data)
set_object_name(chart_shape, object_name)
chart = chart_shape.chart
chart.has_legend = len(chart_spec.get("series", [])) > 1 or chart_type == XL_CHART_TYPE.PIE
if chart.has_legend:
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False
chart.has_title = bool(chart_spec.get("title"))
if chart.has_title:
chart.chart_title.text_frame.text = str(chart_spec["title"])
state["office_charts"] += 1
def build_data(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
metrics = row.get("metrics", [])
width = 12.0 / max(1, len(metrics))
for index, metric in enumerate(metrics):
x = 0.65 + index * width
add_text(slide, str(metric.get("value", "")), (x, 1.55, width - 0.2, 0.72), "section_title", profile, colors["primary"], f"s{row['slide_number']:03d}_metric_{index+1}_value")
add_text(slide, str(metric.get("label", "")), (x, 2.25, width - 0.2, 0.38), "label", profile, colors["muted"], f"s{row['slide_number']:03d}_metric_{index+1}_label", vertical="top")
if row.get("chart"):
add_native_chart(slide, row["chart"], (0.8, 3.0, 11.7, 3.65), colors, state, f"s{row['slide_number']:03d}_chart")
def build_table(slide: Any, row: dict[str, Any], profile: dict[str, Any], table_policy: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
headers = [str(value) for value in row.get("headers", [])]
data_rows = [[str(value) for value in values] for values in row.get("rows", [])]
columns = len(headers)
table_shape = slide.shapes.add_table(len(data_rows) + 1, columns, Inches(0.7), Inches(1.65), Inches(11.95), Inches(4.95))
table_shape._element.nvGraphicFramePr.cNvPr.set("name", f"s{row['slide_number']:03d}_table")
table = table_shape.table
column_types = list(row.get("column_types", []))
size, east_asia, latin, paragraph_policy = role_settings(profile, "table")
for row_index, values in enumerate([headers, *data_rows]):
for col_index, value in enumerate(values):
cell = table.cell(row_index, col_index)
cell.text = value
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
cell.fill.solid()
cell.fill.fore_color.rgb = hex_to_rgb(colors["primary"] if row_index == 0 else colors["panel"], "FFFFFF")
paragraph = cell.text_frame.paragraphs[0]
kind = column_types[col_index] if col_index < len(column_types) else ("index" if col_index == 0 else "text")
expected = table_policy["header_alignment"] if row_index == 0 else table_policy["numeric_alignment"] if kind == "numeric" else table_policy["index_alignment"] if kind == "index" else table_policy["text_alignment"]
paragraph.alignment = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}[expected]
paragraph.line_spacing = float(paragraph_policy.get("line_spacing_multiple", 1.0))
paragraph.space_before = Pt(0)
paragraph.space_after = Pt(0)
for run in paragraph.runs:
run.font.name = latin
run.font.size = Pt(size)
run.font.bold = row_index == 0
run.font.color.rgb = hex_to_rgb("FFFFFF" if row_index == 0 else colors["text"], "111827")
set_run_typefaces(run, east_asia, latin, profile["fonts"].get("complex_script") or latin)
state["native_tables"] += 1
def build_architecture(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
layers = row.get("layers", [])
height = 4.9 / max(1, len(layers))
for index, layer in enumerate(layers):
y = 1.65 + index * height
add_box(slide, (1.0, y, 11.3, height - 0.16), colors["panel"], colors["primary"], f"s{row['slide_number']:03d}_layer_{index+1}", radius=False)
add_text(slide, str(layer.get("title", "")), (1.25, y + 0.14, 2.3, height - 0.42), "header", profile, colors["primary"], f"s{row['slide_number']:03d}_layer_{index+1}_title")
add_text(slide, str(layer.get("body", "")), (3.6, y + 0.14, 8.35, height - 0.42), "body", profile, colors["text"], f"s{row['slide_number']:03d}_layer_{index+1}_body")
state["native_shapes"] += 1
def build_closing(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_text(slide, str(row.get("title", "")), (1.0, 1.2, 11.3, 1.2), "hero", profile, colors["text"], f"s{row['slide_number']:03d}_closing_title", align="center")
if row.get("key_message"):
add_text(slide, str(row["key_message"]), (1.4, 2.55, 10.5, 0.75), "subtitle", profile, colors["muted"], f"s{row['slide_number']:03d}_closing_message", align="center")
actions = row.get("actions", [])
width = 9.6 / max(1, len(actions))
for index, action in enumerate(actions):
x = 1.85 + index * width
add_box(slide, (x, 4.05, width - 0.25, 1.25), colors["panel"], colors["primary"], f"s{row['slide_number']:03d}_action_{index+1}")
add_text(slide, str(action), (x + 0.14, 4.25, width - 0.53, 0.75), "body", profile, colors["text"], f"s{row['slide_number']:03d}_action_{index+1}_text", align="center")
state["native_shapes"] += 1
def apply_text_override(shape: Any, element: dict[str, Any], profile: dict[str, Any]) -> None:
if not getattr(shape, "has_text_frame", False):
return
role = str(element.get("role") or object_role(shape) or "body")
if role not in ROLE_SIZE_KEY:
role = "body"
default_size, east_asia, latin, paragraph_policy = role_settings(profile, role)
style = element.get("style") if isinstance(element.get("style"), dict) else {}
size = float(style.get("font_size_pt", default_size))
font_family = str(style.get("font_family") or latin)
font_color = str(style.get("font_color") or "111827")
bold = bool(style.get("bold", role in {"hero", "section_title", "title", "header"}))
align = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(str(style.get("align", "left")), PP_ALIGN.LEFT)
frame = shape.text_frame
frame.clear()
frame.word_wrap = True
vertical = str(style.get("vertical_align", "middle"))
frame.vertical_anchor = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM}.get(vertical, MSO_ANCHOR.MIDDLE)
for index, line in enumerate(str(element.get("text", "")).split("\n")):
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
run = paragraph.add_run(); run.text = line
run.font.name = font_family
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = hex_to_rgb(font_color, "111827")
set_run_typefaces(run, east_asia, font_family, profile["fonts"].get("complex_script") or font_family)
paragraph.alignment = align
paragraph.line_spacing = float(style.get("line_spacing", paragraph_policy.get("line_spacing_multiple", 1.0)))
paragraph.space_before = Pt(size * float(paragraph_policy.get("space_before_lines", 0)))
paragraph.space_after = Pt(size * float(paragraph_policy.get("space_after_lines", 0)))
def materialize_scene_element(slide: Any, element: dict[str, Any], canvas_w: float, canvas_h: float) -> Any | None:
"""Create native PowerPoint objects that exist on the canvas but not in the base archetype."""
element_id = str(element.get("id") or "")
bbox = element.get("bbox")
if not element_id or not isinstance(bbox, list) or len(bbox) != 4:
return None
x, y, w, h = (float(value) for value in bbox)
left, top = Inches(x / canvas_w * SLIDE_W), Inches(y / canvas_h * SLIDE_H)
width, height = Inches(max(1.0, w) / canvas_w * SLIDE_W), Inches(max(1.0, h) / canvas_h * SLIDE_H)
kind = str(element.get("type") or "")
if kind == "text":
shape = slide.shapes.add_textbox(left, top, width, height)
set_role(shape, str(element.get("role") or "body"), element_id)
return shape
if kind == "shape":
geometry = str(element.get("geometry") or "rectangle").lower()
shape_type = {
"ellipse": MSO_SHAPE.OVAL,
"oval": MSO_SHAPE.OVAL,
"rounded_rectangle": MSO_SHAPE.ROUNDED_RECTANGLE,
"roundrect": MSO_SHAPE.ROUNDED_RECTANGLE,
}.get(geometry, MSO_SHAPE.RECTANGLE)
shape = slide.shapes.add_shape(shape_type, left, top, width, height)
set_object_name(shape, element_id)
return shape
if kind == "connector":
shape = slide.shapes.add_connector(
MSO_CONNECTOR.STRAIGHT,
left,
top,
Inches((x + w) / canvas_w * SLIDE_W),
Inches((y + h) / canvas_h * SLIDE_H),
)
set_object_name(shape, element_id)
return shape
return None
def apply_scene_overrides(slide: Any, scene: dict[str, Any], profile: dict[str, Any]) -> tuple[int, int, int]:
canvas = scene.get("canvas", {})
canvas_w = float(canvas.get("width", 1920))
canvas_h = float(canvas.get("height", 1080))
shapes = {object_id(shape): shape for shape in slide.shapes if object_id(shape)}
applied = 0
materialized_shapes = 0
materialized_connectors = 0
z_rows: list[tuple[int, Any]] = []
for element in scene.get("elements", []):
shape = shapes.get(str(element.get("id")))
if shape is None:
shape = materialize_scene_element(slide, element, canvas_w, canvas_h)
if shape is None:
continue
shapes[str(element.get("id"))] = shape
if str(element.get("type")) == "connector":
materialized_connectors += 1
else:
materialized_shapes += 1
bbox = element.get("bbox")
if isinstance(bbox, list) and len(bbox) == 4:
x, y, w, h = (float(value) for value in bbox)
shape.left = Inches(x / canvas_w * SLIDE_W)
shape.top = Inches(y / canvas_h * SLIDE_H)
shape.width = Inches(w / canvas_w * SLIDE_W)
shape.height = Inches(h / canvas_h * SLIDE_H)
if "rotation" in element and hasattr(shape, "rotation"):
shape.rotation = float(element["rotation"])
style = element.get("style") if isinstance(element.get("style"), dict) else {}
if style.get("fill") and hasattr(shape, "fill"):
shape.fill.solid(); shape.fill.fore_color.rgb = hex_to_rgb(str(style["fill"]), "FFFFFF")
if style.get("line") and hasattr(shape, "line"):
shape.line.color.rgb = hex_to_rgb(str(style["line"]), "D7E2F0")
if style.get("line_width_pt") is not None and hasattr(shape, "line"):
shape.line.width = Pt(float(style["line_width_pt"]))
if "text" in element:
apply_text_override(shape, element, profile)
z_rows.append((int(element.get("z_index", 0)), shape))
applied += 1
tree = slide.shapes._spTree
for _, shape in sorted(z_rows, key=lambda row: row[0]):
tree.remove(shape._element)
tree.append(shape._element)
return applied, materialized_shapes, materialized_connectors
def validate_spec(spec: dict[str, Any], base_dir: Path) -> list[str]:
errors: list[str] = []
policy = str(spec.get("visual_asset_policy") or "native-image-assisted")
if policy not in POLICIES:
errors.append(f"invalid_visual_asset_policy:{policy}")
try:
resolve_style(str(spec.get("style_id") or "consulting-blue-white"), spec.get("style_variant"))
except ValueError as exc:
errors.append(str(exc))
slides = spec.get("slides")
if not isinstance(slides, list) or not slides:
return [*errors, "slides_missing"]
image2_count = 0
for expected_number, row in enumerate(slides, start=1):
number = int(row.get("slide_number", 0))
if number != expected_number:
errors.append(f"slide_number_not_contiguous:{number}:{expected_number}")
archetype = str(row.get("archetype") or "")
if archetype not in ARCHETYPES:
errors.append(f"slide_{number}:unsupported_archetype:{archetype}")
if not str(row.get("title") or "").strip():
errors.append(f"slide_{number}:title_missing")
try:
resolve_style(str(row.get("style_id") or spec.get("style_id") or "consulting-blue-white"), row.get("style_variant") or spec.get("style_variant"))
except ValueError as exc:
errors.append(f"slide_{number}:{exc}")
visual = row.get("visual_slot")
if visual:
errors.extend(validate_visual_slot(visual, base_dir, policy, number))
if visual.get("backend") == "image-2":
image2_count += 1
if archetype == "content-structured" and not 2 <= len(row.get("cards", [])) <= 6:
errors.append(f"slide_{number}:cards_must_be_2_to_6")
if archetype == "process-flow" and not 3 <= len(row.get("steps", [])) <= 5:
errors.append(f"slide_{number}:steps_must_be_3_to_5")
if archetype == "data-callouts" and not 2 <= len(row.get("metrics", [])) <= 5:
errors.append(f"slide_{number}:metrics_must_be_2_to_5")
if archetype == "table" and (not row.get("headers") or not row.get("rows")):
errors.append(f"slide_{number}:table_data_missing")
if policy == "image-led-editable" and image2_count == 0:
errors.append("image_led_editable_requires_image2_asset")
return errors
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--spec", required=True)
parser.add_argument("--out-pptx", required=True)
parser.add_argument("--base-dir")
parser.add_argument("--scene-dir", help="Optional scene directory whose element geometry/text/style overrides the generated layout.")
parser.add_argument("--session", help="Optional session to mark editor canvas rebuilds complete.")
parser.add_argument("--report")
parser.add_argument("--design-report", help="Optional Anti-AI-slop design-gate report path.")
args = parser.parse_args()
spec_path = Path(args.spec).resolve()
base_dir = Path(args.base_dir).resolve() if args.base_dir else spec_path.parent
spec = read_json(spec_path)
if args.session:
session_path = Path(args.session).resolve()
metadata_path = session_path / "metadata.json"
if metadata_path.is_file():
editor_metadata = read_json(metadata_path)
out_candidate = Path(args.out_pptx).resolve()
final_dir = (session_path / "final").resolve()
if (
editor_metadata.get("editor_workflow_mode") == "canvas-first"
and (out_candidate == final_dir or final_dir in out_candidate.parents)
and editor_metadata.get("editor_export_approval") != "approved"
):
report = {"schema_version": 1, "status": "FAIL", "errors": ["canvas_export:not_approved"]}
print(json.dumps(report, ensure_ascii=False))
raise SystemExit(2)
errors = validate_spec(spec, base_dir)
if errors:
report = {"schema_version": 1, "status": "FAIL", "spec": str(spec_path), "errors": errors}
if args.report:
out_report = Path(args.report).resolve()
out_report.parent.mkdir(parents=True, exist_ok=True)
out_report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
raise SystemExit(2)
scene_dir = Path(args.scene_dir).resolve() if args.scene_dir else None
scenes = [read_json(path) for path in sorted(scene_dir.glob("*.scene.json"))] if scene_dir and scene_dir.is_dir() else []
design_report = analyze_design(spec, scenes)
design_report_path = Path(args.design_report).resolve() if args.design_report else (Path(args.session).resolve() / "reports" / "design-quality.json" if args.session else None)
if design_report_path:
design_report_path.parent.mkdir(parents=True, exist_ok=True)
design_report_path.write_text(json.dumps(design_report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if design_report["status"] == "FAIL":
print(json.dumps(design_report, ensure_ascii=False))
raise SystemExit(2)
style_id = str(spec.get("style_id") or "consulting-blue-white")
colors, profile, table_policy, profile_id, table_id, style_variant = load_design(style_id, spec.get("style_variant"), spec.get("typography_profile"), spec.get("table_profile"))
presentation = Presentation()
presentation.slide_width = Inches(SLIDE_W)
presentation.slide_height = Inches(SLIDE_H)
blank = presentation.slide_layouts[6]
state: dict[str, int] = {"native_shapes": 0, "connectors": 0, "native_tables": 0, "office_charts": 0, "image_objects": 0, "image2_assets": 0, "canvas_overrides_applied": 0}
for row in spec["slides"]:
slide_style_id = str(row.get("style_id") or style_id)
slide_colors, slide_profile, slide_table_policy, _, _, _ = load_design(
slide_style_id,
row.get("style_variant") or spec.get("style_variant"),
spec.get("typography_profile"),
spec.get("table_profile"),
)
builders = {
"cover": lambda slide, item: build_cover(slide, item, slide_profile, slide_colors, base_dir, state),
"content-structured": lambda slide, item: build_content(slide, item, slide_profile, slide_colors, state),
"process-flow": lambda slide, item: build_process(slide, item, slide_profile, slide_colors, state),
"comparison-two-zone": lambda slide, item: build_comparison(slide, item, slide_profile, slide_colors, state),
"data-callouts": lambda slide, item: build_data(slide, item, slide_profile, slide_colors, state),
"table": lambda slide, item: build_table(slide, item, slide_profile, slide_table_policy, slide_colors, state),
"architecture": lambda slide, item: build_architecture(slide, item, slide_profile, slide_colors, state),
"closing-action": lambda slide, item: build_closing(slide, item, slide_profile, slide_colors, state),
}
slide = presentation.slides.add_slide(blank)
slide.background.fill.solid()
slide.background.fill.fore_color.rgb = hex_to_rgb(slide_colors["background"], "FFFFFF")
builders[str(row["archetype"])](slide, row)
if row.get("visual_slot") and row["archetype"] != "cover":
add_visual(slide, row["visual_slot"], base_dir, (9.4, 5.2, 3.1, 1.75), state, f"s{row['slide_number']:03d}_visual")
if scene_dir:
scene_path = scene_dir / f"slide-{int(row['slide_number']):03d}.scene.json"
if scene_path.is_file():
applied, added_shapes, added_connectors = apply_scene_overrides(slide, read_json(scene_path), profile)
state["canvas_overrides_applied"] += applied
state["native_shapes"] += added_shapes
state["connectors"] += added_connectors
out_pptx = Path(args.out_pptx).resolve()
out_pptx.parent.mkdir(parents=True, exist_ok=True)
presentation.save(out_pptx)
patch_theme_east_asian_fonts(out_pptx, profile["fonts"]["heading_east_asia"], profile["fonts"]["body_east_asia"])
if args.session:
session = Path(args.session).resolve()
metadata_path = session / "metadata.json"
metadata = read_json(metadata_path)
try:
artifact_ref = str(out_pptx.relative_to(session))
except ValueError:
artifact_ref = str(out_pptx)
built_at = datetime.now(timezone.utc).isoformat()
variant = metadata.setdefault("variants", {}).setdefault("editable", {"status": "not_built", "artifact": None, "slides": {}})
variant["status"] = "built"
variant["artifact"] = artifact_ref
for row in spec["slides"]:
number = int(row["slide_number"])
scene_path = session / "scenes" / f"slide-{number:03d}.scene.json"
scene_hash = read_json(scene_path).get("dependencies", {}).get("scene_hash") if scene_path.is_file() else None
variant.setdefault("slides", {})[str(number)] = {
"status": "built", "scene_hash": scene_hash, "artifact": artifact_ref,
"artifact_hash": sha256(out_pptx), "built_at": built_at,
}
if isinstance(metadata.get("editor"), dict):
metadata["editor"]["status"] = "ready"
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
events_path = session / "reports" / "editor-events.jsonl"
events_path.parent.mkdir(parents=True, exist_ok=True)
with events_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps({
"event": "editor.rebuild.completed", "artifact": artifact_ref,
"slides": [int(row["slide_number"]) for row in spec["slides"]], "created_at": built_at,
}, ensure_ascii=False) + "\n")
state["native_text_objects"] = sum(
1
for slide in presentation.slides
for shape in slide.shapes
if getattr(shape, "has_text_frame", False) and shape.text_frame.text.strip()
)
report = {
"schema_version": 1,
"status": "PASS",
"route": "native-editable-deck",
"pptx": str(out_pptx),
"slide_count": len(spec["slides"]),
"style_id": style_id,
"style_variant": style_variant,
"typography_profile": profile_id,
"table_profile": table_id,
"visual_asset_policy": spec.get("visual_asset_policy") or "native-image-assisted",
"design_quality": design_report["status"],
"session": str(Path(args.session).resolve()) if args.session else None,
**state,
"errors": [],
}
if args.report:
report_path = Path(args.report).resolve()
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,770 @@
#!/usr/bin/env python3
"""Build an editable PPTX from semantic inventory and asset manifest files."""
from __future__ import annotations
import argparse
import json
import os
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from PIL import Image
from pptx import Presentation
from pptx.oxml.ns import qn
from pptx.oxml.xmlchemy import OxmlElement
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.util import Inches, Pt
EMU_PER_INCH = 914400
DEFAULT_SLIDE_W = 13.333333
DEFAULT_SLIDE_H = 7.5
VALID_ASSET_SOURCES = {"imagegen_asset", "api_generated_asset", "provided_asset"}
TEXT_WIDTH_FACTOR = 1.08
NO_WRAP_DEFAULT_ROLES = {"title", "subtitle", "header", "micro_label"}
ROOT = Path(__file__).resolve().parents[1]
ROLE_SIZE_KEY = {
"hero": "hero",
"section_title": "section_title",
"title": "page_title",
"subtitle": "subtitle",
"header": "minor_title",
"body": "body",
"label": "label",
"micro_label": "caption",
"caption": "caption",
"table": "table",
}
def set_run_typefaces(run, east_asia_font: str, latin_font: str | None = None, complex_script_font: str | None = None) -> None:
"""Write explicit Latin, East Asian, and complex-script typefaces on a text run."""
r_pr = run._r.get_or_add_rPr()
faces = {
"a:latin": latin_font or east_asia_font,
"a:ea": east_asia_font,
"a:cs": complex_script_font or latin_font or east_asia_font,
}
for tag, font_face in faces.items():
node = r_pr.find(qn(tag))
if node is None:
node = OxmlElement(tag)
r_pr.append(node)
node.set("typeface", font_face)
def patch_theme_east_asian_fonts(pptx_path: Path, heading_font: str, body_font: str | None = None) -> None:
"""Set major/minor theme East Asian and complex-script defaults without changing Latin theme fonts."""
from lxml import etree
namespaces = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
handle = tempfile.NamedTemporaryFile(prefix=pptx_path.stem + "-font-", suffix=".pptx", dir=pptx_path.parent, delete=False)
temp_path = Path(handle.name)
handle.close()
try:
with zipfile.ZipFile(pptx_path, "r") as source, zipfile.ZipFile(temp_path, "w") as target:
for info in source.infolist():
data = source.read(info.filename)
if info.filename.startswith("ppt/theme/theme") and info.filename.endswith(".xml"):
root = etree.fromstring(data)
for branch, font_face in (("majorFont", heading_font), ("minorFont", body_font or heading_font)):
for leaf in ("ea", "cs"):
nodes = root.xpath(f".//a:fontScheme/a:{branch}/a:{leaf}", namespaces=namespaces)
for node in nodes:
node.set("typeface", font_face)
data = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True)
target.writestr(info, data)
os.replace(temp_path, pptx_path)
finally:
if temp_path.exists():
temp_path.unlink()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inventory", required=True, help="visual_inventory.json path.")
parser.add_argument("--manifest", required=True, help="asset_manifest.json path.")
parser.add_argument("--out-pptx", required=True, help="Output editable PPTX path.")
parser.add_argument("--base-dir", help="Base directory for relative asset paths. Defaults to inventory parent.")
parser.add_argument("--layout-rules", help="Optional layout_rules.json path for font and QA policy.")
parser.add_argument("--slide-width", type=float, default=DEFAULT_SLIDE_W, help="Slide width in inches.")
parser.add_argument("--slide-height", type=float, default=DEFAULT_SLIDE_H, help="Slide height in inches.")
parser.add_argument("--report", help="Optional JSON build report path.")
parser.add_argument("--min-body-font", type=float, default=5.8, help="QA warning floor for body text after fitting.")
parser.add_argument("--min-title-font", type=float, default=8.0, help="QA error floor for title/header text after fitting.")
parser.add_argument("--max-overflow-ratio", type=float, default=1.03, help="Allowed estimated text overflow ratio.")
parser.add_argument("--collision-threshold", type=float, default=0.08, help="Minimum text overlap share to report as collision.")
parser.add_argument("--fail-on-layout-qa", action="store_true", help="Exit non-zero when layout QA has errors.")
return parser.parse_args()
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def resolve_typography_profile(inventory: dict[str, Any], layout_rules: dict[str, Any]) -> tuple[str | None, dict[str, Any] | None]:
profile_id = layout_rules.get("typography_profile") or inventory.get("typography_profile")
style_id = layout_rules.get("style_id") or inventory.get("style_id")
if not profile_id and style_id:
catalog = read_json(ROOT / "styles" / "catalog.json")
style = next((row for row in catalog.get("styles", []) if row.get("id") == style_id), {})
profile_id = style.get("typography_profile")
if not profile_id:
return None, None
profiles = read_json(ROOT / "styles" / "typography-profiles.json")
profile = next((row for row in profiles.get("typography_profiles", []) if row.get("id") == profile_id), None)
if profile is None:
raise ValueError(f"unknown typography_profile: {profile_id}")
return str(profile_id), profile
def typography_defaults(item: dict[str, Any], role: str, profile: dict[str, Any] | None) -> dict[str, Any]:
styled = dict(item)
if not profile:
return styled
size_key = ROLE_SIZE_KEY.get(role, "label")
styled.setdefault("font_size", profile["tokens"][size_key])
paragraph_group = "title" if size_key in {"hero", "section_title", "page_title", "subtitle", "minor_title"} else "caption" if size_key in {"label", "caption"} else "body"
paragraph = profile.get("paragraph", {}).get(paragraph_group, {})
styled.setdefault("line_spacing", paragraph.get("line_spacing_multiple", 1.0))
styled.setdefault("space_before_lines", paragraph.get("space_before_lines", 0))
styled.setdefault("space_after_lines", paragraph.get("space_after_lines", 0))
fonts = profile.get("fonts", {})
styled.setdefault("font_east_asia", fonts.get("heading_east_asia") if paragraph_group == "title" else fonts.get("body_east_asia"))
styled.setdefault("font_latin", fonts.get("latin"))
styled.setdefault("font_complex_script", fonts.get("complex_script") or fonts.get("latin"))
styled.setdefault("typography_token", size_key)
return styled
def hex_to_rgb(value: str | None, default: str = "FFFFFF") -> RGBColor:
value = (value or default).strip().lstrip("#")
if len(value) != 6:
value = default
return RGBColor(int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
def rel_or_abs(base: Path, value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else base / path
def px_box_to_inches(box: list[float], slide_size_px: list[float], slide_w: float, slide_h: float) -> tuple[float, float, float, float]:
if len(box) != 4:
raise ValueError(f"bbox must contain 4 numbers: {box}")
sx = slide_w / float(slide_size_px[0])
sy = slide_h / float(slide_size_px[1])
return box[0] * sx, box[1] * sy, box[2] * sx, box[3] * sy
def as_emu(value_in: float) -> int:
return int(round(value_in * EMU_PER_INCH))
def text_units(value: str) -> float:
units = 0.0
for char in value:
code = ord(char)
if char.isspace():
units += 0.35
elif code < 128:
units += 0.55
elif "\u3000" <= char <= "\u303f" or "\uff00" <= char <= "\uffef":
units += 0.55
else:
units += 1.0
return units
def wrap_line_by_units(value: str, max_units: float) -> list[str]:
if not value:
return [""]
if max_units <= 1:
return [value]
lines: list[str] = []
current = ""
current_units = 0.0
for char in value:
unit = text_units(char)
if current and current_units + unit > max_units:
lines.append(current)
current = char
current_units = unit
else:
current += char
current_units += unit
if current:
lines.append(current)
return lines or [value]
def infer_text_role(item: dict[str, Any]) -> str:
value = str(item.get("text_role") or item.get("role") or "").lower()
if value:
return value
item_id = str(item.get("id") or "").lower()
font_size = float(item.get("font_size", 14))
if any(token in item_id for token in ("tag", "pill", "chip", "badge")):
return "micro_label"
if "title" in item_id or font_size >= 24:
return "title"
if "head" in item_id or "_h" in item_id or font_size >= 14:
return "header"
if "sub" in item_id:
return "subtitle"
if "body" in item_id or "_b" in item_id or font_size <= 9:
return "body"
return "label"
def role_min_font(role: str, item: dict[str, Any], args: argparse.Namespace) -> float:
if item.get("min_font_size") is not None:
return float(item["min_font_size"])
if role == "micro_label":
return 4.8
if role == "header":
return 7.0
if role in {"title", "subtitle"}:
return args.min_title_font
return args.min_body_font
def line_step_pt(font_size: float, item: dict[str, Any]) -> float:
if item.get("line_spacing_pt") is not None:
return float(item["line_spacing_pt"])
if item.get("line_spacing") is not None:
raw = float(item["line_spacing"])
return raw if raw > 3 else font_size * raw
return font_size * float(item.get("line_height", 1.0))
def width_factor_for_role(role: str) -> float:
if role == "title":
return 1.32
if role in {"subtitle", "header"}:
return 1.16
if role == "micro_label":
return 1.10
return TEXT_WIDTH_FACTOR
def text_margins_in(item: dict[str, Any], box: tuple[float, float, float, float] | None = None) -> tuple[float, float, float, float]:
if box:
_, _, w, h = box
default_lr = 0.005 if w < 0.75 else 0.015 if w < 1.25 else 0.02
default_tb = 0.0 if h < 0.13 else 0.004 if h < 0.2 else 0.01
else:
default_lr = 0.02
default_tb = 0.01
return (
float(item.get("margin_left", default_lr)),
float(item.get("margin_right", default_lr)),
float(item.get("margin_top", default_tb)),
float(item.get("margin_bottom", default_tb)),
)
def estimate_text_layout(
item: dict[str, Any],
box: tuple[float, float, float, float],
font_size: float,
) -> dict[str, Any]:
x, y, w, h = box
ml, mr, mt, mb = text_margins_in(item, box)
inner_w_pt = max(1.0, (w - ml - mr) * 72.0)
inner_h_pt = max(1.0, (h - mt - mb) * 72.0)
role = infer_text_role(item)
width_factor = width_factor_for_role(role)
max_units = max(1.0, inner_w_pt / max(1.0, font_size * width_factor))
word_wrap = bool(item.get("word_wrap", role not in NO_WRAP_DEFAULT_ROLES))
visual_lines: list[str] = []
for raw_line in str(item.get("text", "")).split("\n"):
if word_wrap:
visual_lines.extend(wrap_line_by_units(raw_line, max_units))
else:
visual_lines.append(raw_line)
step = line_step_pt(font_size, item)
estimated_h_pt = max(step, len(visual_lines) * step)
longest_units = max((text_units(line) for line in visual_lines), default=0.0)
estimated_w_pt = longest_units * font_size * width_factor
overflow_h = estimated_h_pt / inner_h_pt
overflow_w = estimated_w_pt / inner_w_pt
vertical = str(item.get("vertical_anchor", "middle")).lower()
content_h_in = estimated_h_pt / 72.0
inner_x = x + ml
inner_y = y + mt
inner_w = max(0.001, w - ml - mr)
inner_h = max(0.001, h - mt - mb)
if vertical == "bottom":
content_y = y + h - mb - content_h_in
elif vertical == "top":
content_y = inner_y
else:
content_y = y + (h - content_h_in) / 2.0
return {
"visual_line_count": len(visual_lines),
"estimated_height_pt": round(estimated_h_pt, 3),
"estimated_width_pt": round(estimated_w_pt, 3),
"inner_width_pt": round(inner_w_pt, 3),
"inner_height_pt": round(inner_h_pt, 3),
"overflow_height_ratio": round(overflow_h, 4),
"overflow_width_ratio": round(overflow_w, 4),
"max_units_per_line": round(max_units, 3),
"line_spacing_pt": round(step, 3),
"inner_bbox_in": [round(inner_x, 4), round(inner_y, 4), round(inner_w, 4), round(inner_h, 4)],
"estimated_text_bbox_in": [
round(inner_x, 4),
round(content_y, 4),
round(min(inner_w, max(inner_w, estimated_w_pt / 72.0)), 4),
round(content_h_in, 4),
],
"wrapped_lines_preview": visual_lines[:8],
}
def choose_effective_font(
item: dict[str, Any],
box: tuple[float, float, float, float],
args: argparse.Namespace,
) -> tuple[float, dict[str, Any], str]:
start = float(item.get("font_size", 14))
role = infer_text_role(item)
min_size = role_min_font(role, item, args)
fit_mode = str(item.get("fit_mode", "shrink")).lower()
if fit_mode not in {"shrink", "none"}:
fit_mode = "shrink"
size = start
layout = estimate_text_layout(item, box, size)
if fit_mode == "shrink":
while size > min_size and (
layout["overflow_height_ratio"] > args.max_overflow_ratio
or layout["overflow_width_ratio"] > args.max_overflow_ratio
):
size = max(min_size, size - 0.5)
layout = estimate_text_layout(item, box, size)
return round(size, 2), layout, role
def add_text(
slide,
item: dict[str, Any],
box: tuple[float, float, float, float],
default_font: str,
args: argparse.Namespace,
typography_profile: dict[str, Any] | None = None,
) -> dict[str, Any]:
x, y, w, h = box
shape = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
initial_role = infer_text_role(item)
styled_item = typography_defaults(item, initial_role, typography_profile)
styled_item["text_role"] = initial_role
effective_font, layout, role = choose_effective_font(styled_item, box, args)
c_nv_pr = shape._element.nvSpPr.cNvPr
c_nv_pr.set("name", f"{item.get('id') or shape.name} [pf-role={role}]")
vertical = str(styled_item.get("vertical_anchor", "middle")).lower()
shape.vertical_anchor = {
"top": MSO_ANCHOR.TOP,
"middle": MSO_ANCHOR.MIDDLE,
"bottom": MSO_ANCHOR.BOTTOM,
}.get(vertical, MSO_ANCHOR.MIDDLE)
if styled_item.get("rotation") is not None:
shape.rotation = float(styled_item.get("rotation"))
tf = shape.text_frame
tf.clear()
tf.word_wrap = bool(styled_item.get("word_wrap", role not in NO_WRAP_DEFAULT_ROLES))
ml, mr, mt, mb = text_margins_in(styled_item, box)
tf.margin_left = Inches(ml)
tf.margin_right = Inches(mr)
tf.margin_top = Inches(mt)
tf.margin_bottom = Inches(mb)
lines = str(styled_item.get("text", "")).split("\n")
p = tf.paragraphs[0]
p.text = lines[0] if lines else ""
align = str(styled_item.get("align", "left")).lower()
p.alignment = {
"left": PP_ALIGN.LEFT,
"center": PP_ALIGN.CENTER,
"right": PP_ALIGN.RIGHT,
}.get(align, PP_ALIGN.LEFT)
for line in lines[1:]:
next_p = tf.add_paragraph()
next_p.text = line
next_p.alignment = p.alignment
for para in tf.paragraphs:
para.space_before = Pt(effective_font * float(styled_item.get("space_before_lines", 0)))
para.space_after = Pt(effective_font * float(styled_item.get("space_after_lines", 0)))
if styled_item.get("line_spacing_pt") is not None:
para.line_spacing = Pt(float(styled_item.get("line_spacing_pt")))
elif styled_item.get("line_spacing") is not None:
raw_spacing = float(styled_item.get("line_spacing"))
para.line_spacing = Pt(raw_spacing) if raw_spacing > 3 else raw_spacing
for run in para.runs:
east_asia_font = styled_item.get("font_east_asia") or styled_item.get("font_face") or default_font
latin_font = styled_item.get("font_latin") or east_asia_font
complex_script_font = styled_item.get("font_complex_script") or latin_font
run.font.name = latin_font
set_run_typefaces(run, east_asia_font, latin_font, complex_script_font)
run.font.size = Pt(effective_font)
run.font.bold = bool(styled_item.get("bold", False))
run.font.italic = bool(styled_item.get("italic", False))
run.font.color.rgb = hex_to_rgb(styled_item.get("color"), "003B7A")
return {
"id": item.get("id"),
"slide": int(item.get("slide", 1)),
"z_index": float(item.get("z_index", item.get("z", 0))),
"text": str(styled_item.get("text", "")),
"text_role": role,
"typography_token": styled_item.get("typography_token"),
"bbox_in": [round(v, 4) for v in box],
"font_size": float(styled_item.get("font_size", 14)),
"effective_font_size": effective_font,
"fit_mode": str(styled_item.get("fit_mode", "shrink")).lower(),
"min_font_size": role_min_font(role, styled_item, args),
"align": str(styled_item.get("align", "left")).lower(),
"font_east_asia": styled_item.get("font_east_asia") or styled_item.get("font_face") or default_font,
"font_latin": styled_item.get("font_latin") or styled_item.get("font_east_asia") or styled_item.get("font_face") or default_font,
"vertical_anchor": vertical,
"margins_in": [round(v, 4) for v in (ml, mr, mt, mb)],
"parent_id": item.get("parent_id"),
"text_overlay_allowed": bool(item.get("text_overlay_allowed", False)),
**layout,
}
def add_native_shape(slide, item: dict[str, Any], box: tuple[float, float, float, float]) -> None:
x, y, w, h = box
shape_name = str(item.get("shape", "round_rect")).lower()
shape_type = {
"rect": MSO_SHAPE.RECTANGLE,
"rectangle": MSO_SHAPE.RECTANGLE,
"round_rect": MSO_SHAPE.ROUNDED_RECTANGLE,
"rounded_rectangle": MSO_SHAPE.ROUNDED_RECTANGLE,
"ellipse": MSO_SHAPE.OVAL,
"oval": MSO_SHAPE.OVAL,
}.get(shape_name, MSO_SHAPE.ROUNDED_RECTANGLE)
shape = slide.shapes.add_shape(shape_type, Inches(x), Inches(y), Inches(w), Inches(h))
if item.get("rotation") is not None:
shape.rotation = float(item.get("rotation"))
fill = str(item.get("fill", "FFFFFF"))
if fill.lower() in {"none", "transparent"}:
shape.fill.background()
else:
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(fill)
transparency = float(item.get("fill_transparency", 0))
if transparency:
shape.fill.transparency = max(0, min(100, transparency)) / 100
line = item.get("line", "C7DAF6")
if str(line).lower() in {"none", "transparent"}:
shape.line.fill.background()
else:
shape.line.color.rgb = hex_to_rgb(str(line), "C7DAF6")
shape.line.width = Pt(float(item.get("line_width", 1)))
if item.get("shadow"):
try:
shadow = shape.shadow
shadow.inherit = False
shadow.visible = True
shadow.distance = Pt(float(item.get("shadow", {}).get("distance", 2)))
shadow.blur_radius = Pt(float(item.get("shadow", {}).get("blur", 2)))
shadow.transparency = float(item.get("shadow", {}).get("transparency", 55)) / 100
except Exception:
pass
def add_native_line(slide, item: dict[str, Any], box: tuple[float, float, float, float]) -> None:
x, y, w, h = box
connector = slide.shapes.add_connector(
MSO_CONNECTOR.STRAIGHT,
Inches(x),
Inches(y),
Inches(x + w),
Inches(y + h),
)
connector.line.color.rgb = hex_to_rgb(str(item.get("line", item.get("color", "5A93EA"))), "5A93EA")
connector.line.width = Pt(float(item.get("line_width", 1.5)))
def fit_contain(img_path: Path, box: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
x, y, w, h = box
with Image.open(img_path) as img:
src_ratio = img.width / img.height
slot_ratio = w / h
if src_ratio > slot_ratio:
fw = w
fh = w / src_ratio
fx = x
fy = y + (h - fh) / 2
else:
fh = h
fw = h * src_ratio
fx = x + (w - fw) / 2
fy = y
return fx, fy, fw, fh
def add_asset(slide, item: dict[str, Any], asset: dict[str, Any], box: tuple[float, float, float, float], base_dir: Path) -> dict[str, Any]:
source = asset.get("source_type")
if source not in VALID_ASSET_SOURCES:
raise ValueError(f"{item.get('id')} has invalid source_type: {source}")
if asset.get("semantic_unit_count") != 1:
raise ValueError(f"{item.get('id')} must have semantic_unit_count=1")
img_path = rel_or_abs(base_dir, str(asset["asset_path"]))
if not img_path.exists():
raise FileNotFoundError(f"asset not found for {item.get('id')}: {img_path}")
fx, fy, fw, fh = fit_contain(img_path, box)
pic = slide.shapes.add_picture(str(img_path), Inches(fx), Inches(fy), width=Inches(fw), height=Inches(fh))
if item.get("rotation") is not None:
pic.rotation = float(item.get("rotation"))
return {
"semantic_unit_id": item.get("id"),
"asset_path": str(img_path),
"slot_in": [round(v, 4) for v in box],
"fitted_in": [round(v, 4) for v in (fx, fy, fw, fh)],
}
def get_items(inventory: dict[str, Any]) -> list[dict[str, Any]]:
if isinstance(inventory.get("slides"), list):
items = []
for idx, slide in enumerate(inventory["slides"], start=1):
for item in slide.get("items", []):
item = dict(item)
item.setdefault("slide", idx)
items.append(item)
return sorted(items, key=lambda item: (int(item.get("slide", 1)), float(item.get("z_index", item.get("z", 0)))))
return sorted([dict(item) for item in inventory.get("items", [])], key=lambda item: (int(item.get("slide", 1)), float(item.get("z_index", item.get("z", 0)))))
def intersect_area(a: list[float], b: list[float]) -> float:
ax, ay, aw, ah = a
bx, by, bw, bh = b
ix = max(0.0, min(ax + aw, bx + bw) - max(ax, bx))
iy = max(0.0, min(ay + ah, by + bh) - max(ay, by))
return ix * iy
def area(box: list[float]) -> float:
return max(0.0, box[2]) * max(0.0, box[3])
def qa_layout(
records: list[dict[str, Any]],
text_records: list[dict[str, Any]],
args: argparse.Namespace,
slide_w: float,
slide_h: float,
) -> dict[str, Any]:
errors: list[dict[str, Any]] = []
warnings: list[dict[str, Any]] = []
by_id = {rec["id"]: rec for rec in records if rec.get("id")}
for rec in records:
x, y, w, h = rec["bbox_in"]
if x < -0.02 or y < -0.02 or x + w > slide_w + 0.02 or y + h > slide_h + 0.02:
warnings.append({"type": "bounds", "id": rec.get("id"), "slide": rec.get("slide"), "bbox_in": rec["bbox_in"]})
for text in text_records:
overflow = max(float(text["overflow_height_ratio"]), float(text["overflow_width_ratio"]))
if overflow > args.max_overflow_ratio:
errors.append({
"type": "text_overflow",
"id": text["id"],
"slide": text["slide"],
"overflow_ratio": round(overflow, 4),
"effective_font_size": text["effective_font_size"],
"bbox_in": text["bbox_in"],
})
min_allowed = role_min_font(str(text["text_role"]), {"min_font_size": text.get("min_font_size")}, args)
if text["text_role"] in {"title", "subtitle", "header"} and text["effective_font_size"] < min_allowed:
errors.append({"type": "title_font_too_small", "id": text["id"], "slide": text["slide"], "effective_font_size": text["effective_font_size"]})
elif text["text_role"] == "micro_label":
pass
elif text["text_role"] not in {"title", "subtitle", "header"} and text["effective_font_size"] < args.min_body_font:
warnings.append({"type": "body_font_too_small", "id": text["id"], "slide": text["slide"], "effective_font_size": text["effective_font_size"]})
parent_id = text.get("parent_id")
if parent_id and parent_id in by_id:
parent = by_id[parent_id]["bbox_in"]
tx, ty, tw, th = text["bbox_in"]
px, py, pw, ph = parent
if tx < px - 0.03 or ty < py - 0.03 or tx + tw > px + pw + 0.03 or ty + th > py + ph + 0.03:
warnings.append({"type": "parent_containment", "id": text["id"], "parent_id": parent_id, "slide": text["slide"]})
for idx, a in enumerate(text_records):
if not a.get("text", "").strip():
continue
for b in text_records[idx + 1:]:
if a["slide"] != b["slide"] or not b.get("text", "").strip():
continue
if a.get("parent_id") and a.get("parent_id") == b.get("parent_id"):
continue
ia = intersect_area(a["estimated_text_bbox_in"], b["estimated_text_bbox_in"])
min_area = max(0.0001, min(area(a["estimated_text_bbox_in"]), area(b["estimated_text_bbox_in"])))
overlap = ia / min_area
if overlap >= args.collision_threshold:
warnings.append({
"type": "text_collision",
"slide": a["slide"],
"a": a["id"],
"b": b["id"],
"overlap_share": round(overlap, 4),
})
for text in text_records:
for rec in records:
if rec["slide"] != text["slide"] or rec["class"] not in {"imagegen_asset", "api_generated_asset", "provided_asset"}:
continue
role = str(rec.get("asset_role") or rec.get("role") or "").lower()
if text.get("text_overlay_allowed") or rec.get("text_overlay_allowed"):
continue
if role in {"decor", "decoration", "underlay", "background", "icon", "badge"}:
continue
ia = intersect_area(text["estimated_text_bbox_in"], rec["bbox_in"])
if ia / max(0.0001, area(text["estimated_text_bbox_in"])) >= 0.12:
errors.append({
"type": "text_on_image",
"slide": text["slide"],
"text_id": text["id"],
"image_id": rec["id"],
"overlap_share": round(ia / max(0.0001, area(text["estimated_text_bbox_in"])), 4),
})
return {
"status": "FAIL" if errors else "PASS_WITH_WARNINGS" if warnings else "PASS",
"error_count": len(errors),
"warning_count": len(warnings),
"errors": errors,
"warnings": warnings,
}
def main() -> None:
args = parse_args()
inventory_path = Path(args.inventory)
manifest_path = Path(args.manifest)
base_dir = Path(args.base_dir) if args.base_dir else inventory_path.parent
inventory = read_json(inventory_path)
manifest = read_json(manifest_path)
layout_rules = read_json(Path(args.layout_rules)) if args.layout_rules else {}
manifest_by_id = {item["semantic_unit_id"]: item for item in manifest}
slide_size_px = inventory.get("slide_size_px") or inventory.get("canvas_px") or [1920, 1080]
typography_profile_id, typography_profile = resolve_typography_profile(inventory, layout_rules)
font_override = (
layout_rules.get("font_policy", {}).get("default_font_face")
or inventory.get("font_face")
)
if typography_profile and font_override:
typography_profile = dict(typography_profile)
typography_profile["fonts"] = dict(typography_profile.get("fonts", {}))
typography_profile["fonts"]["heading_east_asia"] = font_override
typography_profile["fonts"]["body_east_asia"] = font_override
profile_fonts = (typography_profile or {}).get("fonts", {})
default_font = font_override or profile_fonts.get("body_east_asia") or "Arial Unicode MS"
heading_font = font_override or profile_fonts.get("heading_east_asia") or default_font
prs = Presentation()
prs.slide_width = Inches(args.slide_width)
prs.slide_height = Inches(args.slide_height)
blank = prs.slide_layouts[6]
slides = {}
report = {
"text_objects": 0,
"native_layout_objects": 0,
"asset_objects": 0,
"text_placements": [],
"native_layout_placements": [],
"asset_placements": [],
}
records: list[dict[str, Any]] = []
for item in get_items(inventory):
slide_no = int(item.get("slide", 1))
while len(slides) < slide_no:
slides[len(slides) + 1] = prs.slides.add_slide(blank)
slide = slides[slide_no]
cls = item.get("class") or item.get("type")
bbox = item.get("bbox_px") or item.get("bbox")
if not bbox:
raise ValueError(f"item {item.get('id')} missing bbox_px")
box = px_box_to_inches(bbox, slide_size_px, args.slide_width, args.slide_height)
if cls == "text":
text_report = add_text(slide, item, box, default_font, args, typography_profile)
report["text_placements"].append(text_report)
report["text_objects"] += 1
elif cls == "layout_native":
add_native_shape(slide, item, box)
report["native_layout_placements"].append({
"id": item.get("id"),
"slide": slide_no,
"z_index": float(item.get("z_index", item.get("z", 0))),
"bbox_in": [round(v, 4) for v in box],
"shape": item.get("shape", "round_rect"),
})
report["native_layout_objects"] += 1
elif cls in {"line_native", "connector_native"}:
add_native_line(slide, item, box)
report["native_layout_placements"].append({
"id": item.get("id"),
"slide": slide_no,
"z_index": float(item.get("z_index", item.get("z", 0))),
"bbox_in": [round(v, 4) for v in box],
"shape": "line",
})
report["native_layout_objects"] += 1
elif cls in {"imagegen_asset", "api_generated_asset", "provided_asset"}:
asset = manifest_by_id.get(item.get("id"))
if not asset:
raise ValueError(f"no manifest entry for asset item: {item.get('id')}")
report["asset_placements"].append(add_asset(slide, item, asset, box, base_dir))
report["asset_objects"] += 1
elif cls == "unresolved":
raise ValueError(f"unresolved item cannot be built: {item.get('id')}")
else:
raise ValueError(f"unsupported item class for {item.get('id')}: {cls}")
records.append({
"id": item.get("id"),
"slide": slide_no,
"class": cls,
"z_index": float(item.get("z_index", item.get("z", 0))),
"bbox_in": [round(v, 4) for v in box],
"parent_id": item.get("parent_id"),
"role": item.get("role"),
"asset_role": item.get("asset_role"),
"text_overlay_allowed": bool(item.get("text_overlay_allowed", False)),
})
out_pptx = Path(args.out_pptx)
out_pptx.parent.mkdir(parents=True, exist_ok=True)
prs.save(out_pptx)
patch_theme_east_asian_fonts(out_pptx, heading_font, default_font)
report["pptx"] = str(out_pptx)
report["slide_count"] = len(slides)
report["layout_qa"] = qa_layout(records, report["text_placements"], args, args.slide_width, args.slide_height)
report["layout_policy"] = {
"default_font_face": default_font,
"heading_east_asia_font": heading_font,
"typography_profile": typography_profile_id,
"typography_tokens": (typography_profile or {}).get("tokens"),
"min_body_font": args.min_body_font,
"min_title_font": args.min_title_font,
"max_overflow_ratio": args.max_overflow_ratio,
"collision_threshold": args.collision_threshold,
"east_asian_font_declared": True,
}
if args.report:
report_path = Path(args.report)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if args.fail_on_layout_qa and report["layout_qa"]["error_count"]:
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Clean transparent semantic asset PNG files after grid cutting."""
from __future__ import annotations
import argparse
import json
from collections import deque
from pathlib import Path
from PIL import Image, ImageFilter
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", action="append", default=[], help="PNG asset file. Repeat as needed.")
parser.add_argument("--input-dir", help="Directory of PNG assets to clean.")
parser.add_argument("--out-dir", help="Output directory. Required unless --in-place is set.")
parser.add_argument("--in-place", action="store_true", help="Overwrite input files.")
parser.add_argument("--trim-pad", type=int, default=4, help="Transparent trim padding in pixels.")
parser.add_argument("--min-component-area", type=int, default=80, help="Remove alpha components smaller than this area.")
parser.add_argument("--alpha-threshold", type=int, default=10, help="Pixels below this alpha become fully transparent.")
parser.add_argument("--soften-alpha", type=float, default=0.0, help="Optional alpha blur radius.")
parser.add_argument("--report", help="Optional JSON report path.")
return parser.parse_args()
def trim_alpha(img: Image.Image, pad: int) -> Image.Image:
bbox = img.getchannel("A").getbbox()
if not bbox:
return img
left, top, right, bottom = bbox
return img.crop((
max(0, left - pad),
max(0, top - pad),
min(img.width, right + pad),
min(img.height, bottom + pad),
))
def threshold_alpha(img: Image.Image, threshold: int) -> Image.Image:
img = img.convert("RGBA")
pix = img.load()
for y in range(img.height):
for x in range(img.width):
r, g, b, a = pix[x, y]
if a <= threshold:
pix[x, y] = (255, 255, 255, 0)
return img
def remove_small_components(img: Image.Image, min_area: int) -> Image.Image:
if min_area <= 0:
return img
img = img.convert("RGBA")
alpha = img.getchannel("A")
w, h = alpha.size
a = alpha.load()
visited = bytearray(w * h)
keep = bytearray(w * h)
for yy in range(h):
for xx in range(w):
idx = yy * w + xx
if visited[idx] or a[xx, yy] < 12:
continue
queue = deque([(xx, yy)])
visited[idx] = 1
comp = []
while queue:
x, y = queue.popleft()
comp.append((x, y))
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if nx < 0 or ny < 0 or nx >= w or ny >= h:
continue
ni = ny * w + nx
if visited[ni] or a[nx, ny] < 12:
continue
visited[ni] = 1
queue.append((nx, ny))
if len(comp) >= min_area:
for x, y in comp:
keep[y * w + x] = 1
pix = img.load()
for yy in range(h):
for xx in range(w):
if a[xx, yy] >= 12 and not keep[yy * w + xx]:
pix[xx, yy] = (255, 255, 255, 0)
return img
def soften_alpha(img: Image.Image, radius: float) -> Image.Image:
if radius <= 0:
return img
img = img.convert("RGBA")
alpha = img.getchannel("A").filter(ImageFilter.GaussianBlur(radius))
img.putalpha(alpha)
return img
def input_files(args: argparse.Namespace) -> list[Path]:
files = [Path(p) for p in args.input]
if args.input_dir:
files.extend(sorted(Path(args.input_dir).glob("*.png")))
return files
def main() -> None:
args = parse_args()
files = input_files(args)
if not files:
raise SystemExit("no input PNG files")
if not args.in_place and not args.out_dir:
raise SystemExit("--out-dir is required unless --in-place is set")
out_dir = Path(args.out_dir) if args.out_dir else None
if out_dir:
out_dir.mkdir(parents=True, exist_ok=True)
rows = []
for src in files:
if not src.exists():
raise SystemExit(f"missing input: {src}")
img = Image.open(src).convert("RGBA")
before = img.size
img = threshold_alpha(img, args.alpha_threshold)
img = remove_small_components(img, args.min_component_area)
img = trim_alpha(img, args.trim_pad)
img = soften_alpha(img, args.soften_alpha)
dst = src if args.in_place else out_dir / src.name
img.save(dst)
rows.append({"input": str(src), "output": str(dst), "before_size": list(before), "after_size": list(img.size)})
if args.report:
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
Path(args.report).write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"cleaned": len(rows)}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Create render comparison metrics, diff heatmaps, and a contact sheet."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageOps, ImageStat
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--reference", action="append", required=True, help="Reference slide image. Repeat per slide.")
parser.add_argument("--render", action="append", required=True, help="Rendered output slide image. Repeat per slide.")
parser.add_argument("--out-dir", required=True, help="Comparison output directory.")
parser.add_argument("--thumb-width", type=int, default=520, help="Contact sheet column width.")
parser.add_argument("--max-changed-pixel-pct", type=float, default=0.35, help="Warn when changed pixel share is above this value.")
parser.add_argument("--max-mad", type=float, default=35.0, help="Warn when mean absolute difference is above this value.")
return parser.parse_args()
def fit_width(img: Image.Image, width: int) -> Image.Image:
height = round(img.height * width / img.width)
return img.resize((width, height), Image.LANCZOS)
def main() -> None:
args = parse_args()
refs = [Path(p) for p in args.reference]
renders = [Path(p) for p in args.render]
if len(refs) != len(renders):
raise SystemExit("--reference and --render counts must match")
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
metrics = []
rows = []
for idx, (ref_path, render_path) in enumerate(zip(refs, renders), start=1):
ref = Image.open(ref_path).convert("RGB")
render = Image.open(render_path).convert("RGB")
render_for_diff = render.resize(ref.size, Image.LANCZOS)
diff = ImageChops.difference(ref, render_for_diff)
stat = ImageStat.Stat(diff)
mad = sum(stat.mean) / 3.0
changed = sum(1 for px in diff.getdata() if max(px) > 18) / (ref.width * ref.height)
gray = ImageEnhance.Contrast(ImageOps.grayscale(diff)).enhance(2.2)
heat = ImageOps.colorize(gray, black=(255, 255, 255), white=(255, 74, 74))
overlay = Image.blend(ref, heat, 0.45)
heat_path = out_dir / f"slide_{idx:02d}_diff_heatmap.png"
overlay.save(heat_path)
metrics.append({
"slide": idx,
"reference": str(ref_path),
"render": str(render_path),
"reference_size": list(ref.size),
"render_size": list(render.size),
"mean_absolute_difference": round(mad, 3),
"changed_pixel_pct_threshold_18": round(changed, 4),
"status": "PASS" if mad <= args.max_mad and changed <= args.max_changed_pixel_pct else "REVIEW",
"repair_hints": [
hint for hint, enabled in [
("large visual drift: inspect missing assets, z-order, panel sizes, and shape fills", mad > args.max_mad),
("many changed pixels: compare title/card positions and large decorative regions first", changed > args.max_changed_pixel_pct),
("if text is visibly noisy, rerun build with text-fit report and collision QA enabled", mad > args.max_mad or changed > args.max_changed_pixel_pct),
] if enabled
],
"heatmap": str(heat_path),
})
thumbs = [fit_width(ref, args.thumb_width), fit_width(render, args.thumb_width), fit_width(overlay, args.thumb_width)]
row_h = max(t.height for t in thumbs) + 34
row = Image.new("RGB", (args.thumb_width * 3 + 28, row_h), "white")
draw = ImageDraw.Draw(row)
labels = [f"Slide {idx} reference", f"Slide {idx} render", f"Slide {idx} diff"]
for col, thumb in enumerate(thumbs):
x = col * (args.thumb_width + 14)
row.paste(thumb, (x, 30))
draw.rectangle((x, 2, x + 220, 25), fill=(255, 255, 255))
draw.text((x + 8, 6), labels[col], fill=(2, 64, 148))
rows.append(row)
sheet = Image.new("RGB", (max(r.width for r in rows), sum(r.height for r in rows) + 20 * (len(rows) - 1)), "white")
y = 0
for row in rows:
sheet.paste(row, (0, y))
y += row.height + 20
sheet_path = out_dir / "contact_sheet.png"
sheet.save(sheet_path)
(out_dir / "comparison_metrics.json").write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8")
status = "PASS" if all(item["status"] == "PASS" for item in metrics) else "REVIEW"
print(json.dumps({"contact_sheet": str(sheet_path), "slides": len(metrics), "status": status}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Compile per-page scene files from slides_plan.md and prompts.json."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
HEADING = re.compile(r"^##\s+(\d+)\.\s+\[([^]]+)]\s+(.+?)\s*$")
ROOT = Path(__file__).resolve().parents[1]
TYPE_MIGRATION = {"layout_native": "shape", "line_native": "line", "connector_native": "connector", "semantic_visual": "image", "imagegen_asset": "image", "provided_asset": "image"}
def upgrade_element(element: dict, index: int) -> dict:
upgraded = dict(element)
upgraded["type"] = TYPE_MIGRATION.get(str(upgraded.get("type")), upgraded.get("type", "unresolved"))
upgraded.setdefault("rotation", 0)
upgraded.setdefault("z_index", index)
upgraded.setdefault("editable", True)
upgraded.setdefault("locked", False)
upgraded.setdefault("style", {})
upgraded.setdefault("capabilities", ["text", "geometry", "style", "rotation", "z-order"] if upgraded["type"] == "text" else ["geometry", "style", "rotation", "z-order"])
return upgraded
def canonical_hash(value: object) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(raw).hexdigest()
def parse_plan(path: Path) -> dict[int, dict[str, str]]:
result: dict[int, dict[str, str]] = {}
current: dict[str, str] | None = None
for line in path.read_text(encoding="utf-8").splitlines():
match = HEADING.match(line)
if match:
number = int(match.group(1))
current = {"page_type": match.group(2), "title": match.group(3), "layout_id": ""}
result[number] = current
elif current and (line.startswith("布局:") or line.startswith("布局:")):
current["layout_id"] = line.split(":" if ":" in line else "", 1)[1].strip()
return result
def upgrade_metadata(path: Path, prompts: dict) -> dict:
metadata = json.loads(path.read_text(encoding="utf-8"))
metadata["schema_version"] = max(int(metadata.get("schema_version", 1)), 4)
metadata.setdefault("current_revision", None)
metadata.setdefault("style_id", prompts.get("style_id"))
style_id = metadata.get("style_id")
if style_id and (not metadata.get("style_variant") or not metadata.get("typography_profile") or not metadata.get("table_profile")):
catalog = json.loads((ROOT / "styles" / "catalog.json").read_text(encoding="utf-8"))
style = next((row for row in catalog.get("styles", []) if row.get("id") == style_id), {})
if not metadata.get("style_variant"):
metadata["style_variant"] = prompts.get("style_variant") or style.get("default_variant")
if not metadata.get("typography_profile"):
metadata["typography_profile"] = style.get("typography_profile")
if not metadata.get("table_profile"):
metadata["table_profile"] = style.get("table_profile")
metadata.setdefault("variants", {
"image": {"status": "not_built", "artifact": None, "slides": {}},
"editable": {"status": "not_built", "artifact": None, "slides": {}},
})
metadata.setdefault("environment", {"preflight_report": None, "status": "pending"})
metadata.setdefault("gui_validation_mode", "final-only")
metadata.setdefault("final_powerpoint_validation", "pending")
path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return metadata
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session", required=True)
parser.add_argument("--force", action="store_true", help="Replace existing scene content instead of preserving elements.")
parser.add_argument("--emit-editable-inventory", action="store_true", help="Derive a full editable inventory under cache/editable/.")
args = parser.parse_args()
session = Path(args.session).resolve()
prompts_path = session / "prompts.json"
plan_path = session / "slides_plan.md"
metadata_path = session / "metadata.json"
for path in (prompts_path, plan_path, metadata_path):
if not path.is_file():
raise SystemExit(f"missing required session file: {path}")
prompts = json.loads(prompts_path.read_text(encoding="utf-8"))
metadata = upgrade_metadata(metadata_path, prompts)
plan = parse_plan(plan_path)
prompt_slides = {int(row["slide_number"]): row for row in prompts.get("slides", [])}
numbers = sorted(set(plan) | set(prompt_slides))
if not numbers:
raise SystemExit("no slides found in slides_plan.md or prompts.json")
scenes_dir = session / "scenes"
scenes_dir.mkdir(parents=True, exist_ok=True)
written = []
compiled_scenes = []
for number in numbers:
info = plan.get(number, {})
prompt = prompt_slides.get(number, {})
scene_path = scenes_dir / f"slide-{number:03d}.scene.json"
old = json.loads(scene_path.read_text(encoding="utf-8")) if scene_path.is_file() and not args.force else {}
generation = {
"prompt": prompt.get("prompt", old.get("generation", {}).get("prompt", "")),
"reference_images": prompt.get("reference_images", old.get("generation", {}).get("reference_images", [])),
"asset_reference_images": prompt.get("asset_reference_images", old.get("generation", {}).get("asset_reference_images", [])),
}
prompt_hash = canonical_hash(generation)
old_canvas = old.get("canvas", {"width": 1920, "height": 1080})
canvas = {"width": int(old_canvas.get("width", 1920)), "height": int(old_canvas.get("height", 1080)), "unit": "px"}
scene = {
"schema_version": 2,
"slide_id": f"slide-{number:03d}",
"slide_number": number,
"revision": int(old.get("revision", 1)),
"page_type": prompt.get("page_type") or info.get("page_type") or old.get("page_type", "other"),
"style_id": prompt.get("style_id") or prompts.get("style_id") or metadata.get("style_id"),
"style_variant": prompt.get("style_variant") or prompts.get("style_variant") or metadata.get("style_variant"),
"layout_id": prompt.get("layout_id") or info.get("layout_id") or old.get("layout_id") or None,
"typography_profile": metadata.get("typography_profile"),
"table_profile": metadata.get("table_profile"),
"visual_asset_policy": metadata.get("visual_asset_policy"),
"canvas": canvas,
"content": {
"title": info.get("title") or old.get("content", {}).get("title", ""),
"message": old.get("content", {}).get("message", ""),
"facts": old.get("content", {}).get("facts", []),
},
"generation": generation,
"elements": [upgrade_element(element, index) for index, element in enumerate(old.get("elements", []))],
"dependencies": {"scene_hash": "", "prompt_hash": prompt_hash, "asset_hashes": old.get("dependencies", {}).get("asset_hashes", [])},
}
hashable = dict(scene)
hashable.pop("revision", None)
hashable["dependencies"] = {"prompt_hash": prompt_hash, "asset_hashes": scene["dependencies"]["asset_hashes"]}
next_hash = canonical_hash(hashable)
previous_hash = old.get("dependencies", {}).get("scene_hash")
if old and previous_hash != next_hash:
scene["revision"] = int(old.get("revision", 1)) + 1
scene["dependencies"]["scene_hash"] = next_hash
scene_path.write_text(json.dumps(scene, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
written.append(str(scene_path))
compiled_scenes.append(scene)
inventory_path = None
if args.emit_editable_inventory:
items = []
for scene in compiled_scenes:
for element in scene.get("elements", []):
item = dict(element)
element_type = item.pop("type", "unresolved")
item["class"] = "unresolved" if element_type == "semantic_visual" else element_type
item["bbox_px"] = item.pop("bbox")
item["slide"] = scene["slide_number"]
items.append(item)
inventory = {
"schema_version": 1,
"source": "scene-derived",
"slide_size_px": [compiled_scenes[0]["canvas"]["width"], compiled_scenes[0]["canvas"]["height"]],
"font_face": metadata.get("environment", {}).get("selected_font"),
"style_id": metadata.get("style_id"),
"typography_profile": metadata.get("typography_profile"),
"table_profile": metadata.get("table_profile"),
"visual_asset_policy": metadata.get("visual_asset_policy"),
"items": items,
}
inventory_path = session / "cache" / "editable" / "visual_inventory.json"
inventory_path.parent.mkdir(parents=True, exist_ok=True)
inventory_path.write_text(json.dumps(inventory, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"session": str(session), "scene_count": len(written), "scenes": written, "editable_inventory": str(inventory_path) if inventory_path else None}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,409 @@
#!/usr/bin/env python3
"""Compile slides.md into a native editable spec, scenes, and an optional editor canvas."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
from style_presets import resolve_style
try:
import yaml as _yaml
except ModuleNotFoundError: # Keep the editor bridge usable with the system Python.
_yaml = None
ROOT = Path(__file__).resolve().parents[1]
CANVAS = {"width": 1920, "height": 1080, "unit": "px"}
LAYOUT_TO_ARCHETYPE = {
"cover": "cover", "cover-left": "cover",
"content": "content-structured", "cards": "content-structured", "columns": "content-structured",
"timeline": "process-flow", "process": "process-flow",
"comparison": "comparison-two-zone",
"data": "data-callouts", "metrics": "data-callouts",
"table": "table", "architecture": "architecture",
"closing": "closing-action", "action": "closing-action",
}
ARCHETYPE_TO_LAYOUT = {
"cover": "cover", "content-structured": "content", "process-flow": "timeline",
"comparison-two-zone": "comparison", "data-callouts": "metrics", "table": "table",
"architecture": "architecture", "closing-action": "closing",
}
DIRECTIVE = re.compile(r'^::(?P<name>[\w-]+)\{(?P<attrs>.*)}\s*$')
ATTR = re.compile(r'([\w-]+)\s*=\s*"([^"]*)"')
def yaml_load(text: str) -> dict[str, Any]:
if _yaml is not None:
value = _yaml.safe_load(text) or {}
if not isinstance(value, dict):
raise ValueError("slides.md frontmatter must be a mapping")
return value
result: dict[str, Any] = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
raise ValueError(f"unsupported frontmatter line without PyYAML: {raw}")
key, value = line.split(":", 1)
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
result[key.strip()] = value
return result
def yaml_dump(value: dict[str, Any]) -> str:
if _yaml is not None:
return _yaml.safe_dump(value, allow_unicode=True, sort_keys=False).strip()
rows = []
for key, item in value.items():
rendered = json.dumps(item, ensure_ascii=False) if isinstance(item, str) else str(item).lower() if isinstance(item, bool) else str(item)
rows.append(f"{key}: {rendered}")
return "\n".join(rows)
def load_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"expected JSON object: {path}")
return value
def save_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def canonical_hash(value: object) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(raw).hexdigest()
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end < 0:
raise ValueError("slides.md frontmatter is not closed")
value = yaml_load(text[4:end])
return value, text[end + 5:]
def split_slides(body: str) -> list[str]:
return [part.strip() for part in re.split(r"(?m)^---\s*$", body) if part.strip()]
def directive_attrs(line: str, name: str) -> dict[str, str] | None:
match = DIRECTIVE.match(line.strip())
if not match or match.group("name") != name:
return None
return {key: value for key, value in ATTR.findall(match.group("attrs"))}
def parse_sections(lines: list[str]) -> tuple[list[dict[str, Any]], list[str], list[list[str]]]:
sections: list[dict[str, Any]] = []
bullets: list[str] = []
table_rows: list[list[str]] = []
current: dict[str, Any] | None = None
for raw in lines:
line = raw.strip()
if line.startswith("## "):
current = {"title": line[3:].strip(), "body_lines": [], "points": []}
sections.append(current)
elif re.match(r"^[-*+]\s+", line):
value = re.sub(r"^[-*+]\s+", "", line).strip()
(current["points"] if current else bullets).append(value)
elif line.startswith("|") and line.endswith("|"):
cells = [cell.strip() for cell in line.strip("|").split("|")]
if not all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells):
table_rows.append(cells)
elif line and not line.startswith("::") and current:
current["body_lines"].append(line)
for section in sections:
section["body"] = "\n".join(section.pop("body_lines")).strip()
return sections, bullets, table_rows
def infer_layout(index: int, count: int, explicit: str | None) -> str:
if explicit:
if explicit not in LAYOUT_TO_ARCHETYPE:
raise ValueError(f"unsupported Markdown layout: {explicit}")
return explicit
if index == 0:
return "cover"
return "closing" if index == count - 1 else "content"
def parse_slide(block: str, index: int, count: int) -> dict[str, Any]:
lines = block.splitlines()
title = next((line[2:].strip() for line in lines if line.startswith("# ")), "")
if not title:
raise ValueError(f"slide {index + 1} is missing a '# ' title")
quote = "\n".join(line[1:].strip() for line in lines if line.startswith(">" )).strip()
layout_value: str | None = None
visual: dict[str, str] | None = None
for line in lines:
attrs = directive_attrs(line, "layout")
if attrs is not None:
layout_value = attrs.get("type")
visual_attrs = directive_attrs(line, "visual")
if visual_attrs is not None:
visual = visual_attrs
layout = infer_layout(index, count, layout_value)
archetype = LAYOUT_TO_ARCHETYPE[layout]
sections, bullets, table_rows = parse_sections(lines)
row: dict[str, Any] = {"slide_number": index + 1, "archetype": archetype, "title": title}
if quote:
row["subtitle" if archetype == "cover" else "key_message"] = quote
if archetype == "content-structured":
cards = [{"title": item["title"], "body": item["body"] or "\n".join(item["points"])} for item in sections]
if not cards:
cards = [{"title": item, "body": ""} for item in bullets]
if not 2 <= len(cards) <= 6:
raise ValueError(f"slide {index + 1} content layout requires 2-6 '##' sections or bullets")
row["cards"] = cards
elif archetype == "process-flow":
steps = [{"title": item["title"], "body": item["body"] or "\n".join(item["points"])} for item in sections]
if not 3 <= len(steps) <= 5:
raise ValueError(f"slide {index + 1} timeline layout requires 3-5 '##' steps")
row["steps"] = steps
elif archetype == "comparison-two-zone":
if len(sections) != 2:
raise ValueError(f"slide {index + 1} comparison layout requires exactly two '##' sections")
for key, item in zip(("left", "right"), sections):
points = item["points"] or ([item["body"]] if item["body"] else [])
row[key] = {"title": item["title"], "points": points}
elif archetype == "data-callouts":
metrics = [{"value": item["title"], "label": item["body"] or "\n".join(item["points"])} for item in sections]
if not 2 <= len(metrics) <= 5:
raise ValueError(f"slide {index + 1} metrics layout requires 2-5 '##' sections")
row["metrics"] = metrics
elif archetype == "table":
if len(table_rows) < 2:
raise ValueError(f"slide {index + 1} table layout requires a Markdown table")
row["headers"], row["rows"] = table_rows[0], table_rows[1:]
row["column_types"] = ["text"] * len(row["headers"])
elif archetype == "architecture":
layers = [{"title": item["title"], "body": item["body"] or "\n".join(item["points"])} for item in sections]
if not layers:
raise ValueError(f"slide {index + 1} architecture layout requires '##' layers")
row["layers"] = layers
elif archetype == "closing-action":
actions = bullets or [item["title"] for item in sections]
if actions:
row["actions"] = actions[:5]
if visual and visual.get("asset"):
backend = visual.get("backend", "provided")
row["visual_slot"] = {
"backend": backend,
"source_type": "imagegen_asset" if backend == "image-2" else "provided_asset",
"status": visual.get("status", "generated"),
"asset_path": visual["asset"],
"prompt_record": visual.get("prompt"),
}
return row
def parse_markdown(text: str, metadata: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
frontmatter, body = split_frontmatter(text)
blocks = split_slides(body)
if not blocks:
raise ValueError("slides.md contains no slides")
metadata = metadata or {}
style_id = str(frontmatter.get("style") or metadata.get("style_id") or "consulting-blue-white")
requested_variant = frontmatter.get("variant") or metadata.get("style_variant")
style, variant = resolve_style(style_id, None if requested_variant in {None, "", "default", "待确认"} else str(requested_variant))
policy = str(frontmatter.get("visualAssetPolicy") or metadata.get("visual_asset_policy") or "native-only")
slides = [parse_slide(block, index, len(blocks)) for index, block in enumerate(blocks)]
spec = {
"schema_version": 1,
"title": str(frontmatter.get("title") or slides[0]["title"]),
"style_id": style["id"],
"style_variant": variant["id"],
"typography_profile": style.get("typography_profile"),
"table_profile": style.get("table_profile"),
"visual_asset_policy": policy,
"slides": slides,
}
return frontmatter, spec
def render_markdown(spec: dict[str, Any], existing_frontmatter: dict[str, Any] | None = None) -> str:
fm = dict(existing_frontmatter or {})
fm.update({
"title": spec.get("title"), "style": spec.get("style_id"),
"variant": spec.get("style_variant"), "ratio": fm.get("ratio", "16:9"),
"fontCN": fm.get("fontCN", "微软雅黑"),
"visualAssetPolicy": spec.get("visual_asset_policy", "native-only"),
})
parts = ["---\n" + yaml_dump(fm) + "\n---"]
for row in spec.get("slides", []):
lines = [f"# {row.get('title', '')}"]
message = row.get("subtitle") or row.get("key_message")
if message:
lines.extend(["", f"> {message}"])
lines.extend(["", f'::layout{{type="{ARCHETYPE_TO_LAYOUT.get(str(row.get("archetype")), "content")}"}}'])
archetype = row.get("archetype")
collection = {
"content-structured": ("cards", "title", "body"),
"process-flow": ("steps", "title", "body"),
"data-callouts": ("metrics", "value", "label"),
"architecture": ("layers", "title", "body"),
}.get(str(archetype))
if collection:
key, title_key, body_key = collection
for item in row.get(key, []):
lines.extend(["", f"## {item.get(title_key, '')}"])
if item.get(body_key):
lines.extend(["", str(item[body_key])])
elif archetype == "comparison-two-zone":
for key in ("left", "right"):
item = row.get(key, {})
lines.extend(["", f"## {item.get('title', '')}"])
lines.extend(["", *[f"- {value}" for value in item.get("points", [])]])
elif archetype == "table":
headers = [str(value) for value in row.get("headers", [])]
lines.extend(["", "| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"])
lines.extend("| " + " | ".join(str(value) for value in values) + " |" for values in row.get("rows", []))
elif archetype == "closing-action":
lines.extend(["", *[f"- {value}" for value in row.get("actions", [])]])
visual = row.get("visual_slot")
if isinstance(visual, dict) and visual.get("asset_path"):
attrs = [f'asset="{visual["asset_path"]}"', f'backend="{visual.get("backend", "provided")}"', f'status="{visual.get("status", "generated")}"']
if visual.get("prompt_record"):
attrs.append(f'prompt="{visual["prompt_record"]}"')
lines.extend(["", "::visual{" + " ".join(attrs) + "}"])
parts.append("\n".join(lines).rstrip())
return "\n\n---\n\n".join(parts) + "\n"
def write_plan(session: Path, spec: dict[str, Any]) -> None:
lines = [
"---", f"title: {spec['title']}", "delivery_type: editable-pptx",
f"style_id: {spec['style_id']}", f"style_variant: {spec['style_variant']}",
f"typography_profile: {spec.get('typography_profile')}", f"table_profile: {spec.get('table_profile')}",
f"visual_asset_policy: {spec.get('visual_asset_policy')}", "---", "", "# 逐页计划", "",
]
for row in spec["slides"]:
lines.extend([
f"## {row['slide_number']}. [{row['archetype']}] {row['title']}", "",
f"布局:{ARCHETYPE_TO_LAYOUT.get(row['archetype'], 'content')}", "",
f"页面目标:{row.get('key_message') or row.get('subtitle') or row['title']}", "",
"事实边界:沿用用户提供内容;禁止补造案例、参数、准确率或收益数据。", "",
])
(session / "slides_plan.md").write_text("\n".join(lines), encoding="utf-8")
def write_prompts(session: Path, spec: dict[str, Any]) -> None:
metadata = load_json(session / "metadata.json")
prompts = {
"schema_version": 1, "session_id": metadata.get("session_id"), "delivery_type": "editable-pptx",
"style_id": spec["style_id"], "style_variant": spec["style_variant"],
"typography_profile": spec.get("typography_profile"), "table_profile": spec.get("table_profile"),
"visual_asset_policy": spec.get("visual_asset_policy"),
"slides": [{
"slide_number": row["slide_number"], "page_type": row["archetype"],
"style_id": row.get("style_id") or spec["style_id"], "style_variant": row.get("style_variant") or spec["style_variant"],
"layout_id": ARCHETYPE_TO_LAYOUT.get(row["archetype"], "content"),
"layout_intent": "由 slides.md 结构化组件确定",
"prompt": "Markdown-first 原生可编辑页面;不生成整页图片。", "reference_images": [],
"asset_reference_images": [], "status": "planned", "output_image": None,
"qa": {"status": "pending", "notes": []},
} for row in spec["slides"]],
}
save_json(session / "prompts.json", prompts)
def write_scenes(session: Path, spec: dict[str, Any]) -> None:
scenes_dir = session / "scenes"
scenes_dir.mkdir(parents=True, exist_ok=True)
expected: set[Path] = set()
for row in spec["slides"]:
number = int(row["slide_number"])
path = scenes_dir / f"slide-{number:03d}.scene.json"
expected.add(path)
old = load_json(path) if path.is_file() else {}
content = {"title": row["title"], "message": row.get("key_message") or row.get("subtitle") or "", "facts": []}
scene = {
"schema_version": 2, "slide_id": f"slide-{number:03d}", "slide_number": number,
"revision": int(old.get("revision", 1)), "page_type": row["archetype"],
"style_id": row.get("style_id") or spec["style_id"], "style_variant": row.get("style_variant") or spec["style_variant"],
"layout_id": ARCHETYPE_TO_LAYOUT.get(row["archetype"]),
"typography_profile": spec.get("typography_profile"), "table_profile": spec.get("table_profile"),
"visual_asset_policy": spec.get("visual_asset_policy"), "canvas": CANVAS, "content": content,
"generation": {"prompt": "", "reference_images": [], "asset_reference_images": []},
"elements": old.get("elements", []),
"dependencies": {"scene_hash": "", "prompt_hash": canonical_hash(""), "asset_hashes": old.get("dependencies", {}).get("asset_hashes", [])},
}
hashable = dict(scene); hashable.pop("revision", None)
hashable["dependencies"] = {"prompt_hash": scene["dependencies"]["prompt_hash"], "asset_hashes": scene["dependencies"]["asset_hashes"]}
next_hash = canonical_hash(hashable)
if old and old.get("dependencies", {}).get("scene_hash") != next_hash:
scene["revision"] += 1
scene["dependencies"]["scene_hash"] = next_hash
save_json(path, scene)
for path in scenes_dir.glob("slide-*.scene.json"):
if path not in expected:
path.unlink()
def run_checked(*args: str) -> dict[str, Any]:
result = subprocess.run([sys.executable, *args], cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if result.returncode:
raise RuntimeError(f"command failed: {' '.join(args)}\n{result.stdout}\n{result.stderr}")
return json.loads(result.stdout)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session", required=True)
parser.add_argument("--source", help="Default: <session>/slides.md")
parser.add_argument("--build-preview", action="store_true", help="Build cache/editable/preview.pptx and sync its native objects to scenes.")
parser.add_argument("--render-canvas", action="store_true", help="Export manifest and write reports/editor-canvas.html; implies --build-preview.")
args = parser.parse_args()
session = Path(args.session).resolve()
source = Path(args.source).resolve() if args.source else session / "slides.md"
if not source.is_file():
raise SystemExit(f"missing Markdown source: {source}")
metadata_path = session / "metadata.json"
metadata = load_json(metadata_path)
frontmatter, spec = parse_markdown(source.read_text(encoding="utf-8"), metadata)
spec_path = session / "analysis" / "native_deck_spec.json"
save_json(spec_path, spec)
write_plan(session, spec)
write_prompts(session, spec)
write_scenes(session, spec)
metadata.update({
"authoring_mode": "markdown-canvas", "content_source": "slides.md", "style_id": spec["style_id"],
"style_variant": spec["style_variant"], "typography_profile": spec.get("typography_profile"),
"table_profile": spec.get("table_profile"), "visual_asset_policy": spec.get("visual_asset_policy"),
"markdown_source_hash": hashlib.sha256(source.read_bytes()).hexdigest(), "status": "canvas_editing",
})
save_json(metadata_path, metadata)
result: dict[str, Any] = {"status": "PASS", "source": str(source), "slide_count": len(spec["slides"]), "spec": str(spec_path)}
if args.build_preview or args.render_canvas:
preview = session / "cache" / "editable" / "preview.pptx"
build = run_checked("scripts/build_native_editable_deck.py", "--spec", str(spec_path), "--session", str(session), "--out-pptx", str(preview), "--report", str(session / "reports" / "markdown-preview-build.json"))
sync = run_checked("scripts/sync_canvas_scene.py", "--session", str(session), "--pptx", str(preview))
result.update({"preview": str(preview), "preview_build": build["status"], "scene_sync": sync["status"]})
if args.render_canvas:
manifest = run_checked("scripts/editor_bridge.py", "export", "--session", str(session))
canvas = session / "reports" / "editor-canvas.html"
rendered = run_checked("scripts/render_editor_canvas.py", "--manifest", str(session / "reports" / "editor-manifest.json"), "--out", str(canvas))
result.update({"manifest_slide_count": manifest["slide_count"], "canvas": str(canvas), "canvas_status": rendered["status"]})
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,549 @@
#!/usr/bin/env python3
"""Export an editor manifest or apply optimistic-concurrency patches to a deck session."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from revision_session import snapshot
from style_presets import flattened_presets, resolve_style
from compile_slides_markdown import render_markdown, split_frontmatter
PROTECTED_KEYS = {
"schema_version", "slide_number", "backend", "source_type", "status",
"asset_path", "prompt_record", "visual_asset_policy", "style_id", "style_variant",
"typography_profile", "table_profile", "archetype",
}
ELEMENT_CHANGE_KEYS = {"bbox", "text", "style", "rotation", "z_index"}
STYLE_KEYS = {"fill", "line", "line_width_pt", "font_size_pt", "font_family", "font_color", "bold", "align", "vertical_align", "line_spacing"}
def validate_style_changes(style: dict) -> None:
if set(style) - STYLE_KEYS:
raise ValueError("unsupported style field")
for key in ("fill", "line", "font_color"):
if key in style and (not isinstance(style[key], str) or not re.fullmatch(r"[A-Fa-f0-9]{6}", style[key])):
raise ValueError(f"{key} must be a six-digit hex color")
if "font_size_pt" in style and (not isinstance(style["font_size_pt"], (int, float)) or not 1 <= float(style["font_size_pt"]) <= 200):
raise ValueError("font_size_pt must be between 1 and 200")
if "line_width_pt" in style and (not isinstance(style["line_width_pt"], (int, float)) or not 0 <= float(style["line_width_pt"]) <= 50):
raise ValueError("line_width_pt must be between 0 and 50")
if "line_spacing" in style and (not isinstance(style["line_spacing"], (int, float)) or not 0.5 <= float(style["line_spacing"]) <= 5):
raise ValueError("line_spacing must be between 0.5 and 5")
if "font_family" in style and (not isinstance(style["font_family"], str) or not style["font_family"].strip()):
raise ValueError("font_family must be a non-empty string")
if "bold" in style and not isinstance(style["bold"], bool):
raise ValueError("bold must be boolean")
if "align" in style and style["align"] not in {"left", "center", "right", "justify"}:
raise ValueError("invalid text alignment")
if "vertical_align" in style and style["vertical_align"] not in {"top", "middle", "bottom"}:
raise ValueError("invalid vertical alignment")
def load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def save(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def canonical_hash(value: object) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(raw).hexdigest()
def pointer_escape(value: str) -> str:
return value.replace("~", "~0").replace("/", "~1")
def pointer_unescape(value: str) -> str:
return value.replace("~1", "/").replace("~0", "~")
def editable_paths(value: object, prefix: str = "") -> list[str]:
paths: list[str] = []
if isinstance(value, dict):
for key, child in value.items():
if key in PROTECTED_KEYS:
continue
child_prefix = f"{prefix}/{pointer_escape(str(key))}"
paths.extend(editable_paths(child, child_prefix))
elif isinstance(value, list):
for index, child in enumerate(value):
paths.extend(editable_paths(child, f"{prefix}/{index}"))
elif prefix:
paths.append(prefix)
return paths
def source_document(session: Path) -> tuple[str, Path, dict]:
native = session / "analysis" / "native_deck_spec.json"
if native.is_file():
return "native-editable-deck", native, load(native)
raise ValueError("editor bridge v1 requires analysis/native_deck_spec.json")
def thumbnail_for(session: Path, slide_number: int) -> str | None:
names = (f"slide-{slide_number:03d}.png", f"slide-{slide_number}.png")
for folder in ("render", "generated", "final"):
for name in names:
path = session / folder / name
if path.is_file():
return str(path.relative_to(session))
return None
def current_style(document: dict) -> dict:
style, variant = resolve_style(str(document.get("style_id") or "consulting-blue-white"), document.get("style_variant"))
return {
"style_id": style["id"], "variant_id": variant["id"],
"family_name": style["name"], "name": variant["name"],
}
def build_manifest(session: Path) -> dict:
metadata = load(session / "metadata.json")
document_kind, document_path, document = source_document(session)
allowed = set(editable_paths(document))
slides = []
canvas_state = []
for index, slide in enumerate(document.get("slides", [])):
number = int(slide["slide_number"])
prefix = f"/slides/{index}/"
scene_path = session / "scenes" / f"slide-{number:03d}.scene.json"
scene = load(scene_path) if scene_path.is_file() else {"canvas": {"width": 1920, "height": 1080, "unit": "px"}, "elements": []}
canvas_state.append({"slide_number": number, "revision": scene.get("revision"), "elements": scene.get("elements", [])})
slides.append({
"slide_number": number,
"title": slide.get("title", ""),
"archetype": slide.get("archetype"),
"style": {
"style_id": slide.get("style_id") or document.get("style_id"),
"variant_id": slide.get("style_variant") or document.get("style_variant") or resolve_style(str(document.get("style_id") or "consulting-blue-white"), None)[1]["id"],
},
"scene": f"scenes/slide-{number:03d}.scene.json",
"thumbnail": thumbnail_for(session, number),
"editable_paths": sorted(path for path in allowed if path.startswith(prefix)),
"canvas": scene.get("canvas"),
"elements": scene.get("elements", []),
})
validate_slide_contract(session, document, slides)
markdown_path = session / "slides.md"
markdown_source = markdown_path.read_text(encoding="utf-8") if markdown_path.is_file() else None
return {
"schema_version": 3,
"protocol": "presentation-forge-editor.v3",
"session_id": metadata.get("session_id"),
"base_revision": metadata.get("current_revision"),
"route": metadata.get("route"),
"workflow": {
"mode": metadata.get("editor_workflow_mode", "direct-build"),
"style_selection_status": metadata.get("style_selection_status", "auto-selected"),
"export_approval": metadata.get("editor_export_approval", "auto-proceed"),
"final_pptx_ready": metadata.get("editor_export_approval") in {"approved", "auto-proceed"},
},
"document_kind": document_kind,
"editable_document": str(document_path.relative_to(session)),
"authoring": {
"mode": metadata.get("authoring_mode", "slides-plan"),
"source": metadata.get("content_source", "slides_plan.md"),
"markdown": markdown_source,
"markdown_sha256": hashlib.sha256(markdown_source.encode()).hexdigest() if markdown_source is not None else None,
"round_trip": markdown_source is not None,
},
"document_sha256": canonical_hash(document),
"canvas_sha256": canonical_hash(canvas_state),
"global_editable_paths": sorted(path for path in allowed if not path.startswith("/slides/")),
"current_style": current_style(document),
"style_catalog": flattened_presets(),
"slides": slides,
"slide_count": len(slides),
"capabilities": {
"replace_scalar": True,
"element_canvas": True,
"move_resize": True,
"text_edit": True,
"style_edit": True,
"rotation": True,
"z_order": True,
"page_level_invalidation": True,
"optimistic_concurrency": True,
"revision_rollback": True,
"deck_style_switch": True,
"slide_variant_switch": True,
"style_switch_modes": ["replace-theme", "preserve-layout"],
"explicit_export_approval": metadata.get("editor_workflow_mode") == "canvas-first",
"markdown_source_edit": markdown_source is not None,
"markdown_round_trip": markdown_source is not None,
"asset_replace": False,
"direct_ooxml_edit": False,
},
"events": "reports/editor-events.jsonl",
}
def validate_slide_contract(session: Path, document: dict, slides: list[dict]) -> None:
document_numbers = [int(row["slide_number"]) for row in document.get("slides", [])]
manifest_numbers = [int(row["slide_number"]) for row in slides]
scene_numbers = sorted(int(path.stem.split("-")[1].split(".")[0]) for path in (session / "scenes").glob("slide-*.scene.json"))
expected = list(range(1, len(document_numbers) + 1))
if document_numbers != expected:
raise ValueError(f"canvas slide contract failed: non-contiguous document slides {document_numbers}")
if manifest_numbers != document_numbers:
raise ValueError(f"canvas slide contract failed: manifest {manifest_numbers} != document {document_numbers}")
if scene_numbers != document_numbers:
raise ValueError(f"canvas slide contract failed: scenes {scene_numbers} != document {document_numbers}")
def export_manifest(session: Path, out: Path | None = None) -> dict:
manifest = build_manifest(session)
target = out or session / "reports" / "editor-manifest.json"
save(target, manifest)
metadata_path = session / "metadata.json"
metadata = load(metadata_path)
previous_editor = metadata.get("editor") if isinstance(metadata.get("editor"), dict) else {}
metadata["editor"] = {
"protocol": manifest["protocol"],
"manifest": str(target.relative_to(session)) if target.is_relative_to(session) else str(target),
"status": "editing" if metadata.get("editor_workflow_mode") == "canvas-first" and metadata.get("editor_export_approval") != "approved" else previous_editor.get("status", "ready"),
}
save(metadata_path, metadata)
return manifest
def resolve_pointer(document: object, pointer: str) -> tuple[object, str | int]:
if not pointer.startswith("/"):
raise ValueError(f"invalid JSON pointer: {pointer}")
parts = [pointer_unescape(part) for part in pointer[1:].split("/")]
current = document
for part in parts[:-1]:
if isinstance(current, list):
current = current[int(part)]
elif isinstance(current, dict):
current = current[part]
else:
raise ValueError(f"pointer traverses scalar: {pointer}")
key: str | int = int(parts[-1]) if isinstance(current, list) else parts[-1]
return current, key
def replace_value(document: dict, pointer: str, value: object) -> None:
parent, key = resolve_pointer(document, pointer)
old = parent[key] # type: ignore[index]
if isinstance(old, bool) != isinstance(value, bool):
raise ValueError(f"type mismatch at {pointer}")
if old is not None and value is not None and not isinstance(value, type(old)):
if not (isinstance(old, (int, float)) and isinstance(value, (int, float)) and not isinstance(value, bool)):
raise ValueError(f"type mismatch at {pointer}")
parent[key] = value # type: ignore[index]
def refresh_scene_hash(scene: dict) -> None:
hashable = dict(scene)
hashable.pop("revision", None)
dependencies = dict(scene.get("dependencies", {}))
dependencies.pop("scene_hash", None)
hashable["dependencies"] = dependencies
scene.setdefault("dependencies", {})["scene_hash"] = canonical_hash(hashable)
def clear_scene_style_overrides(scene: dict, mode: str) -> None:
if mode == "replace-theme":
scene["elements"] = []
return
for element in scene.get("elements", []):
element["style"] = {}
def update_frontmatter(text: str, values: dict[str, str]) -> str:
if not text.startswith("---\n") or "\n---\n" not in text[4:]:
return text
frontmatter, body = text[4:].split("\n---\n", 1)
lines = frontmatter.splitlines()
found: set[str] = set()
for index, line in enumerate(lines):
key = line.split(":", 1)[0].strip() if ":" in line else ""
if key in values:
lines[index] = f"{key}: {values[key]}"
found.add(key)
for key, value in values.items():
if key not in found:
lines.append(f"{key}: {value}")
return "---\n" + "\n".join(lines) + "\n---\n" + body
def sync_scenes(session: Path, document: dict, changed_slides: set[int]) -> None:
by_number = {int(slide["slide_number"]): slide for slide in document.get("slides", [])}
for number in sorted(changed_slides):
scene_path = session / "scenes" / f"slide-{number:03d}.scene.json"
if not scene_path.is_file() or number not in by_number:
continue
slide = by_number[number]
scene = load(scene_path)
scene["revision"] = int(scene.get("revision", 1)) + 1
scene["page_type"] = slide.get("archetype", scene.get("page_type", "other"))
scene.setdefault("content", {})["title"] = slide.get("title", "")
scene["content"]["message"] = slide.get("key_message") or slide.get("subtitle") or ""
for element in scene.get("elements", []):
binding = element.get("source_binding")
if not isinstance(binding, dict) or binding.get("document") != "analysis/native_deck_spec.json" or "text" not in element:
continue
try:
parent, key = resolve_pointer(document, str(binding["path"]))
value = parent[key] # type: ignore[index]
except (KeyError, IndexError, TypeError, ValueError):
continue
if isinstance(value, (str, int, float)):
element["text"] = str(value)
refresh_scene_hash(scene)
save(scene_path, scene)
def invalidate_slides(metadata: dict, changed_slides: set[int]) -> None:
variant = metadata.setdefault("variants", {}).setdefault("editable", {"status": "not_built", "artifact": None, "slides": {}})
slides = variant.setdefault("slides", {})
for number in changed_slides:
previous = slides.get(str(number), {})
slides[str(number)] = {**previous, "status": "stale", "reason": "editor_patch"}
if changed_slides:
variant["status"] = "partial_stale"
def append_event(session: Path, event: dict) -> None:
path = session / "reports" / "editor-events.jsonl"
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, ensure_ascii=False) + "\n")
def apply_patch(session: Path, patch_path: Path) -> dict:
patch = load(patch_path)
if patch.get("schema_version") not in {1, 2, 3}:
raise ValueError("unsupported editor patch schema_version")
metadata_path = session / "metadata.json"
metadata = load(metadata_path)
current_revision = metadata.get("current_revision")
if patch.get("base_revision") != current_revision:
raise ValueError(f"revision conflict: expected {current_revision!r}, got {patch.get('base_revision')!r}")
_, document_path, document = source_document(session)
current_hash = canonical_hash(document)
if patch.get("document_sha256") != current_hash:
raise ValueError("document hash conflict")
current_manifest = build_manifest(session)
if patch.get("schema_version") in {2, 3} and patch.get("canvas_sha256") != current_manifest.get("canvas_sha256"):
raise ValueError("canvas hash conflict")
allowed = set(editable_paths(document))
prompts_path = session / "prompts.json"
prompts = load(prompts_path) if prompts_path.is_file() else {}
plan_path = session / "slides_plan.md"
plan_text = plan_path.read_text(encoding="utf-8") if plan_path.is_file() else ""
deck_style_change: dict | None = None
changed_slides: set[int] = set()
changed_scene_docs: dict[int, tuple[Path, dict]] = {}
for operation in patch.get("operations", []):
if operation.get("op") == "apply-style":
scope = operation.get("scope")
mode = operation.get("mode")
if scope not in {"deck", "slide"} or mode not in {"replace-theme", "preserve-layout"}:
raise ValueError("invalid style scope or mode")
target_style, target_variant = resolve_style(str(operation.get("style_id") or document.get("style_id")), operation.get("variant_id"))
if scope == "slide" and target_style["id"] != document.get("style_id"):
raise ValueError("slide style switch must remain inside the current deck style family")
target_numbers = [int(row["slide_number"]) for row in document.get("slides", [])] if scope == "deck" else [int(operation.get("slide_number", 0))]
if not target_numbers or any(number < 1 for number in target_numbers):
raise ValueError("style operation targets no valid slides")
by_number = {int(row["slide_number"]): row for row in document.get("slides", [])}
if any(number not in by_number for number in target_numbers):
raise ValueError("style operation targets an unknown slide")
if scope == "deck":
document["style_id"] = target_style["id"]
document["style_variant"] = target_variant["id"]
document["typography_profile"] = target_style["typography_profile"]
document["table_profile"] = target_style["table_profile"]
for row in document.get("slides", []):
row.pop("style_id", None); row.pop("style_variant", None)
prompts["style_id"] = target_style["id"]
prompts["style_variant"] = target_variant["id"]
prompts["typography_profile"] = target_style["typography_profile"]
prompts["table_profile"] = target_style["table_profile"]
plan_text = update_frontmatter(plan_text, {
"style_id": target_style["id"], "style_variant": target_variant["id"],
"typography_profile": target_style["typography_profile"], "table_profile": target_style["table_profile"],
})
deck_style_change = {"style_id": target_style["id"], "style_variant": target_variant["id"], "typography_profile": target_style["typography_profile"], "table_profile": target_style["table_profile"]}
else:
row = by_number[target_numbers[0]]
row["style_id"] = target_style["id"]
row["style_variant"] = target_variant["id"]
for prompt_row in prompts.get("slides", []):
if int(prompt_row.get("slide_number", 0)) in target_numbers:
prompt_row["style_id"] = target_style["id"]
prompt_row["style_variant"] = target_variant["id"]
for number in target_numbers:
scene_path = session / "scenes" / f"slide-{number:03d}.scene.json"
if not scene_path.is_file():
raise ValueError(f"scene not found for slide {number}")
scene = changed_scene_docs.get(number, (scene_path, load(scene_path)))[1]
scene["style_id"] = target_style["id"]
scene["style_variant"] = target_variant["id"]
scene["typography_profile"] = target_style["typography_profile"]
scene["table_profile"] = target_style["table_profile"]
clear_scene_style_overrides(scene, str(mode))
changed_scene_docs[number] = (scene_path, scene)
changed_slides.add(number)
continue
if operation.get("op") == "update-element":
number = int(operation.get("slide_number", 0))
scene_path = session / "scenes" / f"slide-{number:03d}.scene.json"
if not scene_path.is_file():
raise ValueError(f"scene not found for slide {number}")
scene = changed_scene_docs.get(number, (scene_path, load(scene_path)))[1]
element = next((row for row in scene.get("elements", []) if row.get("id") == operation.get("element_id")), None)
if element is None:
raise ValueError(f"element not found: {operation.get('element_id')}")
if element.get("locked") or not element.get("editable"):
raise ValueError(f"element is locked: {operation.get('element_id')}")
changes = operation.get("changes")
if not isinstance(changes, dict) or not changes or set(changes) - ELEMENT_CHANGE_KEYS:
raise ValueError("invalid element changes")
capabilities = set(element.get("capabilities", []))
if "bbox" in changes:
if "geometry" not in capabilities:
raise ValueError("element geometry is not editable")
bbox = changes["bbox"]
canvas = scene.get("canvas", {})
if not isinstance(bbox, list) or len(bbox) != 4 or not all(isinstance(value, (int, float)) for value in bbox):
raise ValueError("bbox must contain four numbers")
if bbox[2] <= 0 or bbox[3] <= 0 or bbox[0] < 0 or bbox[1] < 0 or bbox[0] + bbox[2] > canvas.get("width", 0) or bbox[1] + bbox[3] > canvas.get("height", 0):
raise ValueError("bbox must remain inside canvas")
element["bbox"] = bbox
if "text" in changes:
if "text" not in capabilities or not isinstance(changes["text"], str):
raise ValueError("element text is not editable")
element["text"] = changes["text"]
binding = element.get("source_binding")
if isinstance(binding, dict) and binding.get("document") == "analysis/native_deck_spec.json":
replace_value(document, str(binding["path"]), changes["text"])
if "style" in changes:
if "style" not in capabilities or not isinstance(changes["style"], dict) or set(changes["style"]) - STYLE_KEYS:
raise ValueError("element style is not editable")
validate_style_changes(changes["style"])
element.setdefault("style", {}).update(changes["style"])
if "rotation" in changes:
if "rotation" not in capabilities or not isinstance(changes["rotation"], (int, float)) or not -360 <= float(changes["rotation"]) <= 360:
raise ValueError("element rotation is not editable")
element["rotation"] = float(changes["rotation"])
if "z_index" in changes:
if "z-order" not in capabilities or not isinstance(changes["z_index"], int) or not 0 <= changes["z_index"] <= 10000:
raise ValueError("element z-order is not editable")
element["z_index"] = changes["z_index"]
changed_scene_docs[number] = (scene_path, scene)
changed_slides.add(number)
continue
if operation.get("op") != "replace":
raise ValueError("unsupported editor operation")
pointer = operation.get("path")
if pointer not in allowed:
raise ValueError(f"path is not editable: {pointer}")
parts = pointer.split("/")
if len(parts) > 3 and parts[1] == "slides":
index = int(parts[2])
changed_slides.add(int(document["slides"][index]["slide_number"]))
elif pointer == "/title":
changed_slides.update(int(slide["slide_number"]) for slide in document.get("slides", []))
if pointer.endswith("/title") and (not isinstance(operation.get("value"), str) or not operation.get("value").strip()):
raise ValueError(f"title cannot be empty: {pointer}")
replace_value(document, pointer, operation.get("value"))
if not patch.get("operations"):
raise ValueError("patch has no operations")
revision = snapshot(session, f"editor patch {patch.get('request_id', '')}".strip())
save(document_path, document)
if prompts_path.is_file():
save(prompts_path, prompts)
if plan_path.is_file():
plan_path.write_text(plan_text, encoding="utf-8")
markdown_path = session / "slides.md"
if metadata.get("authoring_mode") == "markdown-canvas" or markdown_path.is_file():
existing_frontmatter = split_frontmatter(markdown_path.read_text(encoding="utf-8"))[0] if markdown_path.is_file() else {}
markdown_path.write_text(render_markdown(document, existing_frontmatter), encoding="utf-8")
for _, (scene_path, scene) in changed_scene_docs.items():
scene["revision"] = int(scene.get("revision", 1)) + 1
refresh_scene_hash(scene)
save(scene_path, scene)
sync_scenes(session, document, changed_slides)
metadata = load(metadata_path)
if deck_style_change:
metadata.update(deck_style_change)
metadata["style_selection_status"] = "confirmed"
invalidate_slides(metadata, changed_slides)
metadata.setdefault("editor", {})["status"] = "rebuild_required" if changed_slides else "ready"
save(metadata_path, metadata)
event = {
"event": "editor.patch.applied",
"request_id": patch.get("request_id"),
"revision": revision,
"changed_slides": sorted(changed_slides),
"created_at": datetime.now(timezone.utc).isoformat(),
}
append_event(session, event)
manifest = export_manifest(session)
return {"status": "applied", **event, "manifest": manifest}
def approve_export(session: Path) -> dict:
metadata_path = session / "metadata.json"
metadata = load(metadata_path)
if metadata.get("editor_workflow_mode") != "canvas-first":
raise ValueError("export approval is only used by canvas-first sessions")
if metadata.get("style_selection_status") != "confirmed":
raise ValueError("select and confirm a style before exporting PPTX")
revision = snapshot(session, "canvas export approved")
metadata = load(metadata_path)
metadata["editor_export_approval"] = "approved"
metadata.setdefault("editor", {})["status"] = "export_approved"
save(metadata_path, metadata)
event = {
"event": "editor.export.approved",
"revision": revision,
"created_at": datetime.now(timezone.utc).isoformat(),
}
append_event(session, event)
return {"status": "approved", **event}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
export_cmd = sub.add_parser("export")
export_cmd.add_argument("--session", required=True)
export_cmd.add_argument("--out")
apply_cmd = sub.add_parser("apply")
apply_cmd.add_argument("--session", required=True)
apply_cmd.add_argument("--patch", required=True)
approve_cmd = sub.add_parser("approve-export")
approve_cmd.add_argument("--session", required=True)
args = parser.parse_args()
session = Path(args.session).resolve()
try:
if args.command == "export":
result = export_manifest(session, Path(args.out).resolve() if args.out else None)
elif args.command == "approve-export":
result = approve_export(session)
else:
result = apply_patch(session, Path(args.patch).resolve())
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"status": "BLOCKED", "error": str(exc)}, ensure_ascii=False))
raise SystemExit(2)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""Cut image-generated asset grids into transparent per-object PNG assets."""
from __future__ import annotations
import argparse
import json
from collections import deque
from pathlib import Path
from typing import Any
from PIL import Image
VALID_SOURCE_TYPES = {"imagegen_asset", "api_generated_asset", "provided_asset"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--grid", help="Generated asset grid image.")
parser.add_argument("--rows", type=int, help="Grid row count.")
parser.add_argument("--cols", type=int, help="Grid column count.")
parser.add_argument("--names", help="Comma-separated asset ids or a JSON file containing a string list.")
parser.add_argument("--grid-manifest", help="JSON prompt/grid manifest. Supports one or many grids.")
parser.add_argument("--out-dir", required=True, help="Output directory for cut PNG assets.")
parser.add_argument("--manifest-out", help="Optional asset_manifest.json output path.")
parser.add_argument("--source-type", default="imagegen_asset", choices=sorted(VALID_SOURCE_TYPES), help="Source type written to manifest.")
parser.add_argument("--background", default="chroma", choices=["chroma", "white", "auto"], help="Background removal mode.")
parser.add_argument("--chroma", default="00ff00", help="Chroma key color as RRGGBB. Default: 00ff00.")
parser.add_argument("--tolerance", type=int, default=70, help="Background color distance tolerance.")
parser.add_argument("--trim-pad", type=int, default=4, help="Transparent trim padding in pixels.")
parser.add_argument("--min-component-area", type=int, default=80, help="Remove alpha components smaller than this area.")
parser.add_argument("--absolute-paths", action="store_true", help="Write absolute asset paths to manifest.")
return parser.parse_args()
def load_names(value: str | list[Any]) -> list[str]:
if isinstance(value, list):
names = []
for item in value:
if isinstance(item, str):
names.append(item)
elif isinstance(item, dict):
sid = item.get("semantic_unit_id") or item.get("id") or item.get("name")
if sid:
names.append(str(sid))
return names
path = Path(value)
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
return load_names(data)
return [x.strip() for x in value.split(",") if x.strip()]
def hex_rgb(value: str) -> tuple[int, int, int]:
value = value.strip().lstrip("#")
if len(value) != 6:
raise SystemExit("--chroma must be RRGGBB")
return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)
def bg_distance(pixel: tuple[int, int, int, int], target: tuple[int, int, int]) -> int:
r, g, b, _ = pixel
tr, tg, tb = target
return abs(r - tr) + abs(g - tg) + abs(b - tb)
def remove_background(img: Image.Image, mode: str, chroma: tuple[int, int, int], tolerance: int) -> Image.Image:
img = img.convert("RGBA")
pix = img.load()
for y in range(img.height):
for x in range(img.width):
r, g, b, a = pix[x, y]
remove = False
if mode in {"chroma", "auto"}:
remove = bg_distance((r, g, b, a), chroma) <= tolerance or (g > 150 and r < 140 and b < 140)
if mode in {"white", "auto"} and not remove:
remove = r >= 245 and g >= 245 and b >= 245
if remove:
pix[x, y] = (255, 255, 255, 0)
return img
def remove_small_components(img: Image.Image, min_area: int) -> Image.Image:
if min_area <= 0:
return img
img = img.convert("RGBA")
alpha = img.getchannel("A")
w, h = alpha.size
a = alpha.load()
visited = bytearray(w * h)
keep = bytearray(w * h)
for yy in range(h):
for xx in range(w):
idx = yy * w + xx
if visited[idx] or a[xx, yy] < 12:
continue
queue = deque([(xx, yy)])
visited[idx] = 1
comp = []
while queue:
x, y = queue.popleft()
comp.append((x, y))
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if nx < 0 or ny < 0 or nx >= w or ny >= h:
continue
ni = ny * w + nx
if visited[ni] or a[nx, ny] < 12:
continue
visited[ni] = 1
queue.append((nx, ny))
if len(comp) >= min_area:
for x, y in comp:
keep[y * w + x] = 1
pix = img.load()
for yy in range(h):
for xx in range(w):
if a[xx, yy] >= 12 and not keep[yy * w + xx]:
pix[xx, yy] = (255, 255, 255, 0)
return img
def trim_alpha(img: Image.Image, pad: int) -> Image.Image:
bbox = img.getchannel("A").getbbox()
if not bbox:
return img
left, top, right, bottom = bbox
return img.crop((
max(0, left - pad),
max(0, top - pad),
min(img.width, right + pad),
min(img.height, bottom + pad),
))
def normalize_grid_specs(args: argparse.Namespace) -> list[dict[str, Any]]:
if args.grid_manifest:
manifest_path = Path(args.grid_manifest)
data = json.loads(manifest_path.read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("grids"), list):
grids = data["grids"]
elif isinstance(data, list):
grids = data
elif isinstance(data, dict):
grids = [data]
else:
raise SystemExit("--grid-manifest must contain an object or list")
specs = []
for spec in grids:
if not isinstance(spec, dict):
raise SystemExit("grid manifest entries must be objects")
grid_meta = spec.get("grid") if isinstance(spec.get("grid"), dict) else {}
grid = spec.get("output") or spec.get("grid_image") or (spec.get("grid") if isinstance(spec.get("grid"), str) else None)
if not grid:
raise SystemExit("grid manifest entry missing grid/output")
grid_path = Path(grid)
if not grid_path.is_absolute():
grid_path = manifest_path.parent / grid_path
rows = spec.get("rows") or spec.get("grid_rows") or grid_meta.get("rows")
cols = spec.get("cols") or spec.get("grid_cols") or grid_meta.get("cols")
if not rows or not cols:
raise SystemExit("grid manifest entry missing rows/cols")
objects = spec.get("objects") or spec.get("names")
specs.append({
"grid": str(grid_path),
"rows": int(rows),
"cols": int(cols),
"names": load_names(objects),
"prompt_id": spec.get("prompt_id"),
"source_reference": spec.get("source_reference") or spec.get("reference"),
"quality_notes": spec.get("quality_notes"),
"source_type": spec.get("source_type") or args.source_type,
"background": spec.get("background") or args.background,
"chroma": spec.get("chroma") or args.chroma,
"tolerance": int(spec.get("tolerance") or args.tolerance),
})
return specs
if not (args.grid and args.rows and args.cols and args.names):
raise SystemExit("either --grid-manifest or --grid/--rows/--cols/--names is required")
return [{
"grid": args.grid,
"rows": args.rows,
"cols": args.cols,
"names": load_names(args.names),
"prompt_id": None,
"source_reference": None,
"quality_notes": None,
"source_type": args.source_type,
"background": args.background,
"chroma": args.chroma,
"tolerance": args.tolerance,
}]
def manifest_path_for(out: Path, manifest_out: str | None, absolute: bool) -> str:
if absolute or not manifest_out:
return str(out)
try:
return str(out.relative_to(Path(manifest_out).parent))
except ValueError:
return str(out)
def cut_one_grid(spec: dict[str, Any], out_dir: Path, args: argparse.Namespace) -> list[dict[str, Any]]:
names = spec["names"]
rows = int(spec["rows"])
cols = int(spec["cols"])
expected = rows * cols
if len(names) != expected:
raise SystemExit(f"expected {expected} names for {rows}x{cols}, got {len(names)}")
source_type = spec.get("source_type") or args.source_type
if source_type not in VALID_SOURCE_TYPES:
raise SystemExit(f"invalid source_type: {source_type}")
grid_path = Path(spec["grid"])
src = Image.open(grid_path).convert("RGBA")
cell_w = src.width / cols
cell_h = src.height / rows
chroma = hex_rgb(spec.get("chroma") or args.chroma)
manifest = []
for idx, name in enumerate(names):
row, col = divmod(idx, cols)
box = (
int(round(col * cell_w)),
int(round(row * cell_h)),
int(round((col + 1) * cell_w)),
int(round((row + 1) * cell_h)),
)
asset = src.crop(box)
asset = remove_background(asset, spec.get("background") or args.background, chroma, int(spec.get("tolerance") or args.tolerance))
asset = remove_small_components(asset, args.min_component_area)
asset = trim_alpha(asset, args.trim_pad)
out = out_dir / f"{name}.png"
asset.save(out)
manifest.append({
"semantic_unit_id": name,
"source_type": source_type,
"asset_path": manifest_path_for(out, args.manifest_out, args.absolute_paths),
"semantic_unit_count": 1,
"generated_grid": str(grid_path),
"grid_cell": [row, col],
"prompt_id": spec.get("prompt_id"),
"source_reference": spec.get("source_reference"),
"quality_notes": spec.get("quality_notes"),
})
return manifest
def main() -> None:
args = parse_args()
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
manifest = []
for spec in normalize_grid_specs(args):
manifest.extend(cut_one_grid(spec, out_dir, args))
if args.manifest_out:
Path(args.manifest_out).parent.mkdir(parents=True, exist_ok=True)
Path(args.manifest_out).write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"assets": len(manifest), "out_dir": str(out_dir)}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from datetime import datetime
from pathlib import Path
DELIVERY_TYPES = ("image-pptx", "editable-pptx", "pdf", "png", "svg")
SESSION_DIRS = (
"sources", "references", "analysis", "scenes", "versions", "cache/image", "cache/editable",
"generated", "assets", "final", "render", "compare", "reports",
)
ROOT = Path(__file__).resolve().parents[1]
def available_style_ids() -> tuple[str, ...]:
catalog_path = ROOT / "styles" / "catalog.json"
if not catalog_path.is_file():
return ()
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
return tuple(entry["id"] for entry in catalog.get("styles", []))
def style_policies(style_id: str | None) -> tuple[str | None, str | None, str | None]:
if not style_id:
return None, None, None
catalog_path = ROOT / "styles" / "catalog.json"
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
entry = next((row for row in catalog.get("styles", []) if row.get("id") == style_id), {})
return entry.get("typography_profile"), entry.get("table_profile"), entry.get("default_variant")
def slugify(value: str) -> str:
slug = re.sub(r"[^\w]+", "-", value.lower(), flags=re.UNICODE).replace("_", "-").strip("-")
return slug[:48] or "deck"
def main() -> None:
parser = argparse.ArgumentParser(description="Initialize a session for a new PPT workflow.")
parser.add_argument("--title", required=True, help="Presentation title.")
parser.add_argument("--delivery-type", required=True, choices=DELIVERY_TYPES)
parser.add_argument("--out-root", default="outputs", help="Parent directory for sessions.")
parser.add_argument("--session-id", help="Explicit session id. Default: timestamp-title slug.")
style_ids = available_style_ids()
parser.add_argument("--style-id", choices=style_ids or None, help="Built-in style id from styles/catalog.json.")
parser.add_argument("--visual-asset-policy", choices=("native-only", "native-image-assisted", "image-led-editable"), help="Image-2 usage policy for native editable decks.")
parser.add_argument(
"--editor-workflow-mode",
choices=("direct-build", "canvas-first"),
help="Default: canvas-first for editable-pptx, direct-build for other deliveries.",
)
parser.add_argument(
"--authoring-mode",
choices=("slides-plan", "markdown-canvas"),
help="Default: markdown-canvas for editable-pptx, slides-plan for other deliveries.",
)
parser.add_argument(
"--outline-review-mode",
choices=("continuous", "explicit"),
default="continuous",
help="continuous proceeds after writing the plan; explicit pauses until the user approves it.",
)
parser.add_argument(
"--gui-validation-mode",
choices=("final-only", "eager", "never"),
default="final-only",
help="Control when PowerPoint/GUI rendering may run. Default: final artifact only.",
)
args = parser.parse_args()
editor_workflow_mode = args.editor_workflow_mode or ("canvas-first" if args.delivery_type == "editable-pptx" else "direct-build")
authoring_mode = args.authoring_mode or ("markdown-canvas" if args.delivery_type == "editable-pptx" else "slides-plan")
typography_profile, table_profile, style_variant = style_policies(args.style_id)
visual_asset_policy = args.visual_asset_policy or ("native-image-assisted" if args.delivery_type == "editable-pptx" else None)
session_id = args.session_id or f"{datetime.now():%Y%m%d-%H%M%S}-{slugify(args.title)}"
session = Path(args.out_root).expanduser().resolve() / session_id
if session.exists():
raise SystemExit(f"Session already exists: {session}")
session.mkdir(parents=True)
for name in SESSION_DIRS:
(session / name).mkdir(parents=True)
plan = f"""---
title: {args.title}
delivery_type: {args.delivery_type}
style_id: {args.style_id or '待确认'}
style_variant: {style_variant or '待确认'}
typography_profile: {typography_profile or '待确认'}
table_profile: {table_profile or '待确认'}
visual_asset_policy: {visual_asset_policy or '不适用'}
audience: 待确认
goal: 待确认
---
# 逐页计划
<!-- 按 references/planning-workflow.md 添加页面;此文件是内容 source of truth。 -->
"""
prompts = {
"schema_version": 1,
"session_id": session_id,
"delivery_type": args.delivery_type,
"style_id": args.style_id,
"style_variant": style_variant,
"typography_profile": typography_profile,
"table_profile": table_profile,
"visual_asset_policy": visual_asset_policy,
"slides": [],
}
metadata = {
"schema_version": 4,
"session_id": session_id,
"title": args.title,
"delivery_type": args.delivery_type,
"style_id": args.style_id,
"style_variant": style_variant,
"typography_profile": typography_profile,
"table_profile": table_profile,
"visual_asset_policy": visual_asset_policy,
"authoring_mode": authoring_mode,
"content_source": "slides.md" if authoring_mode == "markdown-canvas" else "slides_plan.md",
"editor_workflow_mode": editor_workflow_mode,
"style_selection_status": "pending" if editor_workflow_mode == "canvas-first" else "auto-selected",
"editor_export_approval": "pending" if editor_workflow_mode == "canvas-first" else "auto-proceed",
"current_revision": None,
"editability_confirmed": True,
"status": "planning",
"route": None,
"route_status": "pending",
"route_report": None,
"outline_review_mode": args.outline_review_mode,
"outline_approval": "auto-proceed" if args.outline_review_mode == "continuous" else "pending",
"gui_validation_mode": args.gui_validation_mode,
"final_powerpoint_validation": "skipped" if args.gui_validation_mode == "never" else "pending",
"smoke_slide": None,
"smoke_approval": "pending",
"final_qa": "pending",
"quality_gate_report": None,
"variants": {
"image": {"status": "not_built", "artifact": None, "slides": {}},
"editable": {"status": "not_built", "artifact": None, "slides": {}},
},
"environment": {"preflight_report": None, "status": "pending", "render_probe": "deferred" if args.gui_validation_mode == "final-only" else "pending"},
}
(session / "slides_plan.md").write_text(plan, encoding="utf-8")
if authoring_mode == "markdown-canvas":
slides_markdown = f"""---
title: {args.title}
style: {args.style_id or 'consulting-blue-white'}
variant: {style_variant or 'default'}
ratio: 16:9
fontCN: 微软雅黑
visualAssetPolicy: {visual_asset_policy or 'native-only'}
---
# {args.title}
> 在这里编辑副标题
::layout{{type="cover"}}
"""
(session / "slides.md").write_text(slides_markdown, encoding="utf-8")
(session / "prompts.json").write_text(json.dumps(prompts, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
(session / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"session_id": session_id, "path": str(session)}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Initialize a reusable semantic visual-replica project folder."""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
from PIL import Image
SUBDIRS = [
"reference",
"reference_crops",
"generated",
"assets",
"render",
"compare",
"reports",
"prompts",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out-dir", required=True, help="Project directory to create.")
parser.add_argument("--reference", action="append", default=[], help="Reference slide image. Repeat per slide.")
parser.add_argument("--force", action="store_true", help="Allow writing into an existing directory.")
return parser.parse_args()
def slide_size(path: Path) -> list[int]:
with Image.open(path) as img:
return [img.width, img.height]
def main() -> None:
args = parse_args()
out = Path(args.out_dir)
if out.exists() and any(out.iterdir()) and not args.force:
raise SystemExit(f"{out} already exists and is not empty; pass --force to reuse it")
out.mkdir(parents=True, exist_ok=True)
for subdir in SUBDIRS:
(out / subdir).mkdir(parents=True, exist_ok=True)
references = []
slide_px = [1920, 1080]
for idx, ref in enumerate(args.reference, start=1):
src = Path(ref)
if not src.exists():
raise SystemExit(f"reference image not found: {src}")
ext = src.suffix.lower() or ".png"
dst = out / "reference" / f"page-{idx:02d}{ext}"
shutil.copy2(src, dst)
size = slide_size(dst)
if idx == 1:
slide_px = size
references.append({"slide": idx, "path": str(dst.relative_to(out)), "size_px": size})
inventory = {
"slide_size_px": slide_px,
"final_deck_type": "semantic_editable_replica",
"source_image_policy": "reference only; do not embed the full source image in the final PPTX",
"font_face": "Microsoft YaHei",
"references": references,
"slides": [
{
"slide": ref["slide"],
"reference": ref["path"],
"items": [],
}
for ref in references
] or [{"slide": 1, "reference": "", "items": []}],
}
layout_rules = {
"slide_size_px": slide_px,
"font_policy": {
"default_font_face": "Microsoft YaHei",
"text_box_extra_room_pct": 12,
},
"image_fit": "uniform_contain_only",
"allowed_source_types": ["imagegen_asset", "api_generated_asset", "provided_asset"],
"forbidden_media": [
"full_slide_reference_image",
"near_full_slide_reference_image",
"svg_media",
"raw_crop_asset",
"prompt_only_asset",
],
"comparison": {
"diff_threshold": 18,
"changed_pixel_pct_target": 0.25,
},
}
prompt_stub = {
"slide": 1,
"prompt_id": "assets_cycle_1_grid_01",
"output": "generated/assets_cycle_1_grid_01.png",
"grid": {"rows": 1, "cols": 1},
"objects": [{"semantic_unit_id": "domain_icon_01", "description": "replace with target semantic visual"}],
"prompt": "Create an isolated asset grid for a PowerPoint visual replica. Use the full reference and crops for style. No readable text, no numbers, no labels, no watermark.",
"negative": "no card frames, no crop borders, no fake logos, no surrounding slide context",
}
(out / "visual_inventory.json").write_text(json.dumps(inventory, ensure_ascii=False, indent=2), encoding="utf-8")
(out / "asset_anchors.json").write_text("[]\n", encoding="utf-8")
(out / "asset_manifest.json").write_text("[]\n", encoding="utf-8")
(out / "layout_rules.json").write_text(json.dumps(layout_rules, ensure_ascii=False, indent=2), encoding="utf-8")
(out / "prompts" / "assets_cycle_1.jsonl").write_text(json.dumps(prompt_stub, ensure_ascii=False) + "\n", encoding="utf-8")
(out / "conversion_report.md").write_text(
"# Semantic Visual-Replica Conversion Report\n\n"
"- Reference images: see `reference/`\n"
"- Generated grids: see `generated/`\n"
"- Final assets: see `assets/`\n"
"- Render comparison: see `compare/`\n"
"- Validation reports: see `reports/`\n",
encoding="utf-8",
)
print(json.dumps({"project": str(out), "references": len(references)}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,451 @@
#!/usr/bin/env python3
"""Analyze, plan, check, apply, and validate conservative native PPTX fills."""
from __future__ import annotations
import argparse
import copy
import json
import posixpath
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from lxml import etree as ET
from pptx import Presentation
P = "http://schemas.openxmlformats.org/presentationml/2006/main"
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
REL = "http://schemas.openxmlformats.org/package/2006/relationships"
XML = "http://www.w3.org/XML/1998/namespace"
NS = {"p": P, "a": A, "r": R, "rel": REL}
def write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def role_for_shape(shape: object) -> str:
if getattr(shape, "is_placeholder", False):
try:
name = shape.placeholder_format.type.name.lower()
except (AttributeError, ValueError):
name = "placeholder"
if "title" in name:
return "title"
if "sub" in name:
return "subtitle"
if any(word in name for word in ("body", "object", "text")):
return "body"
return name
name = str(getattr(shape, "name", "")).lower()
return "title" if "title" in name else "text"
def font_size_pt(text_frame: object, default: float = 18.0) -> float:
for paragraph in text_frame.paragraphs:
for run in paragraph.runs:
if run.font.size:
return float(run.font.size.pt)
return default
def capacity_units(width: int, height: int, font_pt: float) -> int:
width_pt = max(1.0, width / 12700)
height_pt = max(1.0, height / 12700)
chars_per_line = max(1.0, width_pt / max(font_pt * 0.95, 1.0))
lines = max(1, int(height_pt / max(font_pt * 1.25, 1.0)))
return max(4, int(chars_per_line * lines))
def visual_units(text: str) -> float:
units = 0.0
for char in text:
if char.isspace():
units += 0.25
elif ord(char) >= 0x2E80:
units += 1.0
elif char.isalnum():
units += 0.55
else:
units += 0.45
return units
def analyze_pptx(source: Path) -> dict[str, object]:
prs = Presentation(str(source))
slides: list[dict[str, object]] = []
for slide_number, slide in enumerate(prs.slides, start=1):
slots: list[dict[str, object]] = []
for shape in slide.shapes:
geometry = {
"left": int(shape.left), "top": int(shape.top),
"width": int(shape.width), "height": int(shape.height),
}
if getattr(shape, "has_text_frame", False):
size = font_size_pt(shape.text_frame)
slots.append({
"slot_id": f"s{slide_number:03d}_sh{shape.shape_id}",
"kind": "text",
"shape_id": int(shape.shape_id),
"shape_name": shape.name,
"role": role_for_shape(shape),
"geometry": geometry,
"font_size_pt": size,
"paragraph_count": len(shape.text_frame.paragraphs),
"capacity_visual_units": capacity_units(shape.width, shape.height, size),
"old_text": shape.text,
})
if getattr(shape, "has_table", False):
rows = len(shape.table.rows)
cols = len(shape.table.columns)
cell_width = max(1, int(shape.width / max(cols, 1)))
cell_height = max(1, int(shape.height / max(rows, 1)))
for row_index, row in enumerate(shape.table.rows):
for col_index, cell in enumerate(row.cells):
size = font_size_pt(cell.text_frame, 14.0)
slots.append({
"slot_id": f"s{slide_number:03d}_tbl{shape.shape_id}_r{row_index}_c{col_index}",
"kind": "table-cell",
"shape_id": int(shape.shape_id),
"shape_name": shape.name,
"role": "table-cell",
"row": row_index,
"col": col_index,
"geometry": geometry,
"font_size_pt": size,
"paragraph_count": len(cell.text_frame.paragraphs),
"capacity_visual_units": capacity_units(cell_width, cell_height, size),
"old_text": cell.text,
})
summary = " | ".join(slot["old_text"] for slot in slots if slot["old_text"] and slot["role"] in {"title", "subtitle", "body"})
slides.append({"slide_number": slide_number, "text_summary": summary[:500], "slots": slots})
return {
"schema": "native_template_library.v1",
"source_pptx": str(source),
"slide_size": {"width": int(prs.slide_width), "height": int(prs.slide_height)},
"slide_count": len(slides),
"slides": slides,
"boundaries": {
"repeat_source_slide": False,
"replace_images": False,
"edit_charts": False,
"edit_smartart": False,
},
}
def slot_map(library: dict[str, object]) -> dict[str, dict[str, object]]:
return {
str(slot["slot_id"]): slot
for slide in library.get("slides", [])
for slot in slide.get("slots", [])
}
def check_plan(library: dict[str, object], plan: dict[str, object]) -> dict[str, object]:
errors: list[dict[str, object]] = []
warnings: list[dict[str, object]] = []
slots = slot_map(library)
max_slide = int(library.get("slide_count", 0))
seen: set[int] = set()
planned = plan.get("slides")
font_policy = plan.get("font_policy")
if font_policy is not None:
if not isinstance(font_policy, dict) or not str(font_policy.get("font_face", "")).strip():
errors.append({"code": "font_policy_invalid", "message": "font_policy requires a non-empty font_face."})
elif font_policy.get("scope", "replaced-text") not in {"replaced-text", "all-selected-text", "theme-and-replaced"}:
errors.append({"code": "font_scope_invalid", "scope": font_policy.get("scope")})
if not isinstance(planned, list) or not planned:
errors.append({"code": "slides_missing", "message": "Plan must contain at least one slide."})
planned = []
for plan_index, slide in enumerate(planned, start=1):
source_slide = int(slide.get("source_slide", 0))
if source_slide < 1 or source_slide > max_slide:
errors.append({"code": "source_slide_missing", "plan_slide": plan_index, "source_slide": source_slide})
continue
if source_slide in seen:
errors.append({"code": "repeat_source_slide_unsupported", "plan_slide": plan_index, "source_slide": source_slide})
seen.add(source_slide)
if not slide.get("layout_rationale"):
warnings.append({"code": "layout_rationale_missing", "plan_slide": plan_index})
for replacement in slide.get("replacements", []):
slot_id = str(replacement.get("slot_id", ""))
slot = slots.get(slot_id)
if not slot:
errors.append({"code": "slot_missing", "plan_slide": plan_index, "slot_id": slot_id})
continue
expected_prefix = f"s{source_slide:03d}_"
if not slot_id.startswith(expected_prefix):
errors.append({"code": "slot_slide_mismatch", "plan_slide": plan_index, "slot_id": slot_id})
continue
text = str(replacement.get("text", ""))
capacity = float(slot.get("capacity_visual_units", 0) or 0)
used = visual_units(text)
ratio = used / capacity if capacity else 0
if ratio > 1.15:
warnings.append({
"code": "text_capacity", "plan_slide": plan_index, "slot_id": slot_id,
"used_visual_units": round(used, 2), "capacity_visual_units": capacity,
"ratio": round(ratio, 2),
})
return {
"schema": "native_template_check.v1",
"status": "FAIL" if errors else "WARN" if warnings else "PASS",
"error_count": len(errors),
"warning_count": len(warnings),
"errors": errors,
"warnings": warnings,
}
def presentation_slide_parts(files: dict[str, bytes]) -> tuple[ET.Element, list[ET.Element], list[str]]:
presentation = ET.fromstring(files["ppt/presentation.xml"])
rels = ET.fromstring(files["ppt/_rels/presentation.xml.rels"])
targets = {
rel.get("Id"): posixpath.normpath(posixpath.join("ppt", rel.get("Target", "")))
for rel in rels.findall(f"{{{REL}}}Relationship")
}
slide_list = presentation.find(f"{{{P}}}sldIdLst")
if slide_list is None:
raise ValueError("presentation has no slide list")
slide_ids = list(slide_list)
parts = [targets.get(node.get(f"{{{R}}}id"), "") for node in slide_ids]
return presentation, slide_ids, parts
def set_rpr_typefaces(r_pr: ET.Element, font_face: str) -> None:
for child in list(r_pr):
if ET.QName(child).namespace == A and ET.QName(child).localname in {"latin", "ea", "cs"}:
r_pr.remove(child)
late_children = {"sym", "hlinkClick", "hlinkMouseOver", "extLst"}
insertion_index = len(r_pr)
for index, child in enumerate(r_pr):
if ET.QName(child).namespace == A and ET.QName(child).localname in late_children:
insertion_index = index
break
for offset, leaf in enumerate(("latin", "ea", "cs")):
node = ET.Element(f"{{{A}}}{leaf}")
node.set("typeface", font_face)
r_pr.insert(insertion_index + offset, node)
def replace_text_body(tx_body: ET.Element, text: str, font_face: str | None = None) -> None:
paragraphs = tx_body.findall(f"{{{A}}}p")
template = paragraphs[0] if paragraphs else ET.Element(f"{{{A}}}p")
template_ppr = template.find(f"{{{A}}}pPr")
first_run = template.find(f"{{{A}}}r")
template_rpr = first_run.find(f"{{{A}}}rPr") if first_run is not None else None
template_end = template.find(f"{{{A}}}endParaRPr")
for paragraph in paragraphs:
tx_body.remove(paragraph)
lines = text.splitlines() or [""]
for line in lines:
paragraph = ET.Element(f"{{{A}}}p")
if template_ppr is not None:
paragraph.append(copy.deepcopy(template_ppr))
run = ET.SubElement(paragraph, f"{{{A}}}r")
run_rpr = copy.deepcopy(template_rpr) if template_rpr is not None else None
if font_face:
run_rpr = run_rpr if run_rpr is not None else ET.Element(f"{{{A}}}rPr")
set_rpr_typefaces(run_rpr, font_face)
if run_rpr is not None:
run.append(run_rpr)
text_node = ET.SubElement(run, f"{{{A}}}t")
if line[:1].isspace() or line[-1:].isspace():
text_node.set(f"{{{XML}}}space", "preserve")
text_node.text = line
if template_end is not None:
paragraph.append(copy.deepcopy(template_end))
tx_body.append(paragraph)
def apply_replacement(root: ET.Element, slot_id: str, text: str, font_face: str | None = None) -> None:
text_match = re.fullmatch(r"s\d{3}_sh(\d+)", slot_id)
table_match = re.fullmatch(r"s\d{3}_tbl(\d+)_r(\d+)_c(\d+)", slot_id)
if text_match:
shape_id = text_match.group(1)
for shape in root.findall(f".//{{{P}}}sp"):
c_nv_pr = shape.find(f"{{{P}}}nvSpPr/{{{P}}}cNvPr")
if c_nv_pr is not None and c_nv_pr.get("id") == shape_id:
tx_body = shape.find(f"{{{P}}}txBody")
if tx_body is None:
raise ValueError(f"text body missing for {slot_id}")
replace_text_body(tx_body, text, font_face)
return
elif table_match:
shape_id, row_index, col_index = map(int, table_match.groups())
for frame in root.findall(f".//{{{P}}}graphicFrame"):
c_nv_pr = frame.find(f"{{{P}}}nvGraphicFramePr/{{{P}}}cNvPr")
if c_nv_pr is None or int(c_nv_pr.get("id", -1)) != shape_id:
continue
rows = frame.findall(f".//{{{A}}}tbl/{{{A}}}tr")
if row_index >= len(rows):
break
cells = rows[row_index].findall(f"{{{A}}}tc")
if col_index >= len(cells):
break
tx_body = cells[col_index].find(f"{{{A}}}txBody")
if tx_body is None:
raise ValueError(f"table text body missing for {slot_id}")
replace_text_body(tx_body, text, font_face)
return
raise ValueError(f"slot not found in slide XML: {slot_id}")
def patch_cjk_runs(root: ET.Element, font_face: str) -> int:
updated = 0
for run in root.xpath(".//a:r | .//a:fld", namespaces={"a": A}):
text = "".join(run.xpath("./a:t/text()", namespaces={"a": A}))
if not any(ord(char) >= 0x2E80 for char in text):
continue
r_pr = run.find(f"{{{A}}}rPr")
if r_pr is None:
r_pr = ET.Element(f"{{{A}}}rPr")
run.insert(0, r_pr)
set_rpr_typefaces(r_pr, font_face)
updated += 1
return updated
def patch_theme_fonts(files: dict[str, bytes], font_face: str) -> None:
for name in list(files):
if not name.startswith("ppt/theme/theme") or not name.endswith(".xml"):
continue
root = ET.fromstring(files[name])
for node in root.xpath(".//a:fontScheme/a:majorFont/a:ea | .//a:fontScheme/a:majorFont/a:cs | .//a:fontScheme/a:minorFont/a:ea | .//a:fontScheme/a:minorFont/a:cs", namespaces={"a": A}):
node.set("typeface", font_face)
files[name] = ET.tostring(root, encoding="UTF-8", xml_declaration=True, standalone=True)
def apply_plan(
source: Path,
library: dict[str, object],
plan: dict[str, object],
output: Path,
font_face: str | None = None,
font_scope: str = "replaced-text",
) -> None:
if plan.get("status") != "confirmed":
raise ValueError("fill plan must be confirmed before apply")
report = check_plan(library, plan)
if report["error_count"]:
raise ValueError(f"fill plan has {report['error_count']} blocking error(s)")
if source.resolve() == output.resolve():
raise ValueError("output must not overwrite the source PPTX")
with zipfile.ZipFile(source) as archive:
files = {info.filename: archive.read(info.filename) for info in archive.infolist()}
infos = archive.infolist()
presentation, slide_ids, parts = presentation_slide_parts(files)
slide_list = presentation.find(f"{{{P}}}sldIdLst")
assert slide_list is not None
for child in list(slide_list):
slide_list.remove(child)
for entry in plan["slides"]:
source_slide = int(entry["source_slide"])
slide_list.append(copy.deepcopy(slide_ids[source_slide - 1]))
part = parts[source_slide - 1]
root = ET.fromstring(files[part])
for replacement in entry.get("replacements", []):
apply_replacement(root, str(replacement["slot_id"]), str(replacement.get("text", "")), font_face)
if font_face and font_scope == "all-selected-text":
patch_cjk_runs(root, font_face)
files[part] = ET.tostring(root, encoding="UTF-8", xml_declaration=True, standalone=True)
if font_face and font_scope == "theme-and-replaced":
patch_theme_fonts(files, font_face)
files["ppt/presentation.xml"] = ET.tostring(presentation, encoding="UTF-8", xml_declaration=True, standalone=True)
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=output.parent, suffix=".pptx", delete=False) as handle:
temp_path = Path(handle.name)
try:
with zipfile.ZipFile(temp_path, "w") as archive:
for info in infos:
archive.writestr(info, files[info.filename])
shutil.move(str(temp_path), output)
finally:
temp_path.unlink(missing_ok=True)
def validate_output(pptx: Path, plan: dict[str, object]) -> dict[str, object]:
errors: list[str] = []
try:
prs = Presentation(str(pptx))
except Exception as exc: # python-pptx surfaces malformed-package details
return {"status": "FAIL", "errors": [f"pptx_open_failed:{exc}"], "slide_count": 0}
if len(prs.slides) != len(plan.get("slides", [])):
errors.append(f"slide_count_mismatch:{len(prs.slides)}:{len(plan.get('slides', []))}")
for index, entry in enumerate(plan.get("slides", [])):
if index >= len(prs.slides):
break
visible = "\n".join(shape.text for shape in prs.slides[index].shapes if getattr(shape, "has_text_frame", False))
visible += "\n" + "\n".join(
cell.text
for shape in prs.slides[index].shapes if getattr(shape, "has_table", False)
for row in shape.table.rows for cell in row.cells
)
for replacement in entry.get("replacements", []):
value = str(replacement.get("text", ""))
if value and value not in visible:
errors.append(f"replacement_not_readable:slide={index + 1}:slot={replacement.get('slot_id')}")
return {"status": "FAIL" if errors else "PASS", "errors": errors, "slide_count": len(prs.slides)}
def parse_slide_selection(value: str | None, slide_count: int) -> list[int]:
if not value:
return list(range(1, slide_count + 1))
selected = [int(item.strip()) for item in value.split(",") if item.strip()]
if any(item < 1 or item > slide_count for item in selected):
raise ValueError("slide selection is out of range")
if len(set(selected)) != len(selected):
raise ValueError("repeating a source slide is not supported in v1")
return selected
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
analyze = sub.add_parser("analyze"); analyze.add_argument("source"); analyze.add_argument("--out", required=True)
scaffold = sub.add_parser("scaffold"); scaffold.add_argument("library"); scaffold.add_argument("--slides"); scaffold.add_argument("--out", required=True)
check = sub.add_parser("check-plan"); check.add_argument("library"); check.add_argument("plan"); check.add_argument("--out", required=True)
apply = sub.add_parser("apply"); apply.add_argument("source"); apply.add_argument("library"); apply.add_argument("plan"); apply.add_argument("--out", required=True); apply.add_argument("--font"); apply.add_argument("--font-scope", choices=("replaced-text", "all-selected-text", "theme-and-replaced"))
validate = sub.add_parser("validate"); validate.add_argument("pptx"); validate.add_argument("plan"); validate.add_argument("--out", required=True)
args = parser.parse_args()
if args.command == "analyze":
result = analyze_pptx(Path(args.source).resolve()); write_json(Path(args.out), result)
elif args.command == "scaffold":
library = json.loads(Path(args.library).read_text(encoding="utf-8"))
selected = parse_slide_selection(args.slides, int(library["slide_count"]))
result = {
"schema": "native_template_fill_plan.v1", "status": "draft",
"source_pptx": library["source_pptx"],
"font_policy": None,
"slides": [{"source_slide": number, "purpose": "待填写", "layout_rationale": None, "replacements": []} for number in selected],
}
write_json(Path(args.out), result)
elif args.command == "check-plan":
library = json.loads(Path(args.library).read_text(encoding="utf-8")); plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
result = check_plan(library, plan); write_json(Path(args.out), result)
if result["error_count"]:
raise SystemExit(2)
elif args.command == "apply":
source = Path(args.source).resolve(); library = json.loads(Path(args.library).read_text(encoding="utf-8")); plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
font_policy = plan.get("font_policy") if isinstance(plan.get("font_policy"), dict) else {}
font_face = args.font or font_policy.get("font_face")
font_scope = args.font_scope or font_policy.get("scope") or "replaced-text"
apply_plan(source, library, plan, Path(args.out).resolve(), font_face, font_scope)
result = {"status": "PASS", "output": str(Path(args.out).resolve()), "font_face": font_face, "font_scope": font_scope}
else:
plan = json.loads(Path(args.plan).read_text(encoding="utf-8")); result = validate_output(Path(args.pptx).resolve(), plan); write_json(Path(args.out), result)
if result["status"] != "PASS":
raise SystemExit(2)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""将整页生图封装为图片型 PPTX并可选叠加真实 Logo。"""
from __future__ import annotations
import argparse
import math
import re
from pathlib import Path
from PIL import Image, ImageDraw
from pptx import Presentation
from pptx.util import Inches
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--images-dir", required=True, help="包含原始整页生图的目录。")
parser.add_argument("--out-pptx", required=True, help="输出 PPTX 路径。")
parser.add_argument("--final-dir", help="后处理后的最终 slide PNG 输出目录。")
parser.add_argument("--contact-sheet", help="contact sheet 缩略图输出路径。")
parser.add_argument("--slide-count", type=int, help="期望页数,用于检查缺页。")
parser.add_argument("--width", type=int, default=1920, help="最终 slide 图片宽度。")
parser.add_argument("--height", type=int, default=1080, help="最终 slide 图片高度。")
parser.add_argument("--logo", help="每页叠加的 Logo通常放在右上角。")
parser.add_argument("--logo-width", type=int, default=160, help="每页 Logo 的像素宽度。")
parser.add_argument("--logo-margin-x", type=int, default=70, help="每页 Logo 距右侧边距。")
parser.add_argument("--logo-margin-y", type=int, default=45, help="每页 Logo 距顶部边距。")
parser.add_argument("--mask-logo-zone", action="store_true", help="在右上角 Logo 后方绘制白色遮罩,覆盖预留框。")
parser.add_argument(
"--footer-logo",
action="append",
default=[],
metavar="PATH:WIDTH",
help="封面/尾页页脚 Logo格式为 PATH:WIDTH可重复传入。",
)
parser.add_argument("--footer-logo-slides", default="first,last", help="页脚 Logo 应用范围first,last,all,none可逗号分隔。")
parser.add_argument("--export-pdf", action="store_true", help="如果本机有 soffice/libreoffice则同时导出 PDF。")
return parser.parse_args()
def natural_key(path: Path):
return [int(s) if s.isdigit() else s.lower() for s in re.split(r"(\\d+)", path.name)]
def find_images(images_dir: Path) -> list[Path]:
exts = {".png", ".jpg", ".jpeg", ".webp"}
return sorted([p for p in images_dir.iterdir() if p.suffix.lower() in exts], key=natural_key)
def crop_to_size(img: Image.Image, width: int, height: int) -> Image.Image:
img = img.convert("RGB")
src_ratio = img.width / img.height
dst_ratio = width / height
if src_ratio > dst_ratio:
new_h = height
new_w = round(height * src_ratio)
else:
new_w = width
new_h = round(width / src_ratio)
resized = img.resize((new_w, new_h), Image.LANCZOS)
left = (new_w - width) // 2
top = (new_h - height) // 2
return resized.crop((left, top, left + width, top + height))
def load_logo(spec: str) -> tuple[Image.Image, int]:
if ":" in spec:
path_s, width_s = spec.rsplit(":", 1)
width = int(width_s)
else:
path_s = spec
width = 180
logo = Image.open(path_s).convert("RGBA")
h = round(logo.height * (width / logo.width))
return logo.resize((width, h), Image.LANCZOS), width
def overlay_logo(base: Image.Image, logo_path: str, width: int, mx: int, my: int, mask: bool) -> None:
logo = Image.open(logo_path).convert("RGBA")
h = round(logo.height * (width / logo.width))
logo = logo.resize((width, h), Image.LANCZOS)
x = base.width - width - mx
y = my
if mask:
draw = ImageDraw.Draw(base)
pad = 26
draw.rounded_rectangle(
[x - pad, y - pad, x + width + pad, y + h + pad],
radius=12,
fill=(255, 255, 255),
)
base.paste(logo, (x, y), logo)
def footer_slide_indices(count: int, mode: str) -> set[int]:
parts = {p.strip().lower() for p in mode.split(",") if p.strip()}
if "none" in parts:
return set()
if "all" in parts:
return set(range(count))
indices = set()
if "first" in parts:
indices.add(0)
if "last" in parts:
indices.add(count - 1)
return indices
def overlay_footer_logos(base: Image.Image, specs: list[str]) -> None:
if not specs:
return
logos = [load_logo(spec)[0] for spec in specs]
x = 95
y = base.height - 155
mask_w = sum(l.width for l in logos) + 55 * max(0, len(logos) - 1) + 70
mask_h = max(l.height for l in logos) + 46
draw = ImageDraw.Draw(base)
draw.rounded_rectangle([x - 25, y - 22, x - 25 + mask_w, y - 22 + mask_h], radius=10, fill=(255, 255, 255))
for logo in logos:
base.paste(logo, (x, y), logo)
x += logo.width + 55
def make_contact_sheet(files: list[Path], out: Path) -> None:
thumb_w, thumb_h, label_h = 384, 216, 34
cols = 3
rows = math.ceil(len(files) / cols)
sheet = Image.new("RGB", (cols * thumb_w, rows * (thumb_h + label_h)), "white")
draw = ImageDraw.Draw(sheet)
for idx, file in enumerate(files):
img = Image.open(file).convert("RGB").resize((thumb_w, thumb_h), Image.LANCZOS)
col, row = idx % cols, idx // cols
x, y = col * thumb_w, row * (thumb_h + label_h)
sheet.paste(img, (x, y))
draw.text((x + 10, y + thumb_h + 8), f"slide-{idx + 1:02d}", fill=(80, 80, 80))
out.parent.mkdir(parents=True, exist_ok=True)
sheet.save(out, quality=92)
def build_pptx(files: list[Path], out_pptx: Path) -> None:
prs = Presentation()
prs.slide_width = Inches(13.333333)
prs.slide_height = Inches(7.5)
blank = prs.slide_layouts[6]
for file in files:
slide = prs.slides.add_slide(blank)
slide.shapes.add_picture(str(file), 0, 0, width=prs.slide_width, height=prs.slide_height)
out_pptx.parent.mkdir(parents=True, exist_ok=True)
prs.save(out_pptx)
def export_image_pdf(files: list[Path], out_pdf: Path) -> None:
"""Create a raster PDF directly from final slide images; never round-trip through office software."""
pages = [Image.open(path).convert("RGB") for path in files]
if not pages:
raise ValueError("cannot export an empty image deck")
out_pdf.parent.mkdir(parents=True, exist_ok=True)
pages[0].save(out_pdf, "PDF", resolution=150.0, save_all=True, append_images=pages[1:])
for page in pages:
page.close()
def main() -> None:
args = parse_args()
images_dir = Path(args.images_dir)
out_pptx = Path(args.out_pptx)
final_dir = Path(args.final_dir) if args.final_dir else out_pptx.with_suffix("").parent / "final_slides"
final_dir.mkdir(parents=True, exist_ok=True)
images = find_images(images_dir)
if args.slide_count and len(images) != args.slide_count:
raise SystemExit(f"期望 {args.slide_count} 张图片,但在 {images_dir} 找到 {len(images)} 张。")
if not images:
raise SystemExit(f"目录中没有找到图片:{images_dir}")
footer_indices = footer_slide_indices(len(images), args.footer_logo_slides)
final_files = []
for idx, img_path in enumerate(images):
img = crop_to_size(Image.open(img_path), args.width, args.height)
if args.logo:
overlay_logo(img, args.logo, args.logo_width, args.logo_margin_x, args.logo_margin_y, args.mask_logo_zone)
if idx in footer_indices:
overlay_footer_logos(img, args.footer_logo)
out = final_dir / f"slide-{idx + 1:02d}.png"
img.save(out)
final_files.append(out)
build_pptx(final_files, out_pptx)
if args.contact_sheet:
make_contact_sheet(final_files, Path(args.contact_sheet))
if args.export_pdf:
export_image_pdf(final_files, out_pptx.with_suffix(".pdf"))
print(f"已写入 {out_pptx}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Inspect fonts, build dependencies, and PPT rendering backends before production."""
from __future__ import annotations
import argparse
import importlib.util
import json
import platform
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
DEFAULT_FONTS = ("Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Arial Unicode MS")
def font_families() -> set[str]:
fc_list = shutil.which("fc-list")
if fc_list:
result = subprocess.run([fc_list, ":", "family"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False)
if result.returncode == 0:
return {name.strip().lower() for line in result.stdout.splitlines() for name in line.split(",") if name.strip()}
roots = [Path("/System/Library/Fonts"), Path("/Library/Fonts"), Path.home() / "Library/Fonts", Path("/usr/share/fonts")]
return {path.stem.lower() for root in roots if root.exists() for path in root.rglob("*") if path.suffix.lower() in {".ttf", ".otf", ".ttc"}}
def contains_font(available: set[str], requested: str) -> bool:
needle = requested.lower().replace(" ", "")
return any(needle in value.replace(" ", "") or value.replace(" ", "") in needle for value in available)
def render_backends() -> list[dict[str, str]]:
found: list[dict[str, str]] = []
if platform.system() == "Darwin":
app = Path("/Applications/Microsoft PowerPoint.app")
if app.exists():
found.append({"id": "powerpoint-macos", "path": str(app), "fidelity": "target"})
for name in ("soffice", "libreoffice"):
path = shutil.which(name)
if path and not any(row["path"] == path for row in found):
found.append({"id": "libreoffice", "path": path, "fidelity": "approximate"})
return found
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session", required=True)
parser.add_argument("--target", choices=("image-pptx", "editable-pptx", "pdf"), required=True)
parser.add_argument("--font", action="append", default=[])
parser.add_argument("--strict-render", action="store_true", help="Fail when no rendering backend is available.")
parser.add_argument("--render-backend", choices=("auto", "powerpoint", "libreoffice"), default="auto")
parser.add_argument("--gui-validation-mode", choices=("final-only", "eager", "never"), help="Override session GUI validation policy.")
parser.add_argument("--skip-render-probe", action="store_true", help="Only discover renderers; do not execute a Chinese smoke export.")
parser.add_argument("--allow-approximate-render", action="store_true", help="Permit LibreOffice-only smoke export as WARN instead of BLOCKED.")
args = parser.parse_args()
session = Path(args.session).resolve(); metadata_path = session / "metadata.json"
if not metadata_path.is_file():
raise SystemExit(f"metadata.json missing: {metadata_path}")
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
gui_validation_mode = args.gui_validation_mode or metadata.get("gui_validation_mode", "final-only")
requested = tuple(args.font) or DEFAULT_FONTS
available = font_families()
fonts = [{"name": name, "available": contains_font(available, name)} for name in requested]
selected_font = next((row["name"] for row in fonts if row["available"]), None)
dependencies = {name: importlib.util.find_spec(module) is not None for name, module in {"Pillow": "PIL", "python-pptx": "pptx"}.items()}
backends = render_backends()
render_probe = None
errors: list[str] = []; warnings: list[str] = []
if not all(dependencies.values()):
errors.append("missing Python build dependency")
if not selected_font:
errors.append("none of the requested CJK fonts is available")
elif selected_font != requested[0]:
warnings.append(f"font fallback selected: {selected_font}")
require_backend_now = args.strict_render or gui_validation_mode == "eager"
if not backends:
message = "no PowerPoint or LibreOffice rendering backend found"
if require_backend_now:
errors.append(message)
else:
warnings.append(message)
elif backends[0]["fidelity"] != "target" and args.target == "editable-pptx":
warnings.append("LibreOffice render is approximate for Chinese PowerPoint font metrics")
run_probe = gui_validation_mode == "eager" and not args.skip_render_probe
if args.target in {"editable-pptx", "pdf"} and run_probe and all(dependencies.values()) and selected_font:
reports_dir = session / "reports"; reports_dir.mkdir(parents=True, exist_ok=True)
probe_dir = reports_dir / "render-probe"; probe_dir.mkdir(parents=True, exist_ok=True)
inventory = {"slide_size_px": [1920, 1080], "font_face": selected_font, "items": [{"id": "probe_title", "class": "text", "text": "企业 AI 知识库 0123 ABC", "bbox_px": [120, 160, 1600, 120], "font_size": 36}]}
(probe_dir / "inventory.json").write_text(json.dumps(inventory, ensure_ascii=False), encoding="utf-8")
(probe_dir / "manifest.json").write_text("[]\n", encoding="utf-8")
smoke_pptx = probe_dir / "font-render-smoke.pptx"
build = subprocess.run([sys.executable, str(Path(__file__).with_name("build_semantic_deck.py")), "--inventory", str(probe_dir / "inventory.json"), "--manifest", str(probe_dir / "manifest.json"), "--out-pptx", str(smoke_pptx)], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
render_report_path = reports_dir / "render-probe.json"
if render_report_path.exists():
render_report_path.unlink()
render_cmd = [
sys.executable, str(Path(__file__).with_name("render_pptx.py")),
"--input", str(smoke_pptx), "--out-dir", str(probe_dir),
"--backend", args.render_backend, "--validation-stage", "intermediate",
"--gui-validation-mode", gui_validation_mode, "--report", str(render_report_path),
]
if args.allow_approximate_render:
render_cmd.append("--allow-approximate")
probe_timed_out = False
try:
render = subprocess.run(render_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, timeout=240) if build.returncode == 0 else None
except subprocess.TimeoutExpired:
render = None
probe_timed_out = True
errors.append("render probe timed out")
if render_report_path.is_file():
render_probe = json.loads(render_report_path.read_text(encoding="utf-8"))
if build.returncode != 0:
errors.append("Chinese font render probe PPTX build failed")
elif not probe_timed_out and (render is None or render.returncode != 0):
errors.append(f"requested render probe failed: {args.render_backend}")
elif render_probe and render_probe.get("status") == "APPROXIMATE":
warnings.append("render probe passed only through approximate LibreOffice output")
probed_backend_id = render_probe.get("selected_backend") if render_probe else None
selected_backend = next((row for row in backends if row["id"] == probed_backend_id), None)
if selected_backend is None and backends:
selected_backend = backends[0]
status = "BLOCKED" if errors else "WARN" if warnings else "PASS"
probe_state = "completed" if render_probe else "skipped" if args.skip_render_probe or gui_validation_mode == "never" else "deferred"
report = {
"schema_version": 1,
"checked_at": datetime.now(timezone.utc).isoformat(),
"target": args.target,
"platform": {"system": platform.system(), "release": platform.release(), "python": platform.python_version()},
"dependencies": dependencies,
"fonts": fonts,
"selected_font": selected_font,
"render_backends": backends,
"selected_render_backend": selected_backend,
"gui_validation_mode": gui_validation_mode,
"render_probe_state": probe_state,
"render_probe": render_probe,
"status": status,
"errors": errors,
"warnings": warnings,
}
out = session / "reports" / "preflight.json"; out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
metadata["environment"] = {
"preflight_report": str(out.relative_to(session)),
"status": status,
"selected_font": selected_font,
"render_backend": selected_backend["id"] if selected_backend else None,
"render_probe": probe_state,
}
metadata["gui_validation_mode"] = gui_validation_mode
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if status == "BLOCKED":
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Prepare or commit a page-level rebuild while preserving other slide caches."""
from __future__ import annotations
import argparse
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def save(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
for name in ("prepare", "commit"):
cmd = sub.add_parser(name)
cmd.add_argument("--session", required=True)
cmd.add_argument("--slide", type=int, required=True)
cmd.add_argument("--variant", choices=("image", "editable"), required=True)
if name == "commit":
cmd.add_argument("--artifact", required=True, help="New page image or editable page artifact.")
args = parser.parse_args()
session = Path(args.session).resolve()
scene_path = session / "scenes" / f"slide-{args.slide:03d}.scene.json"
metadata_path = session / "metadata.json"
if not scene_path.is_file() or not metadata_path.is_file():
raise SystemExit("scene or metadata.json missing; run compile_scenes.py first")
scene = load(scene_path)
metadata = load(metadata_path)
variants = metadata.setdefault("variants", {})
variant = variants.setdefault(args.variant, {"status": "not_built", "artifact": None, "slides": {}})
slides = variant.setdefault("slides", {})
key = str(args.slide)
state = slides.get(key, {})
scene_hash = scene["dependencies"]["scene_hash"]
cache_dir = session / "cache" / args.variant / f"slide-{args.slide:03d}"
cache_dir.mkdir(parents=True, exist_ok=True)
if args.command == "prepare":
stale = state.get("scene_hash") != scene_hash or not state.get("artifact") or not (session / state.get("artifact", "")).exists()
payload = {
"slide": args.slide,
"variant": args.variant,
"scene": str(scene_path),
"scene_hash": scene_hash,
"status": "stale" if stale else "clean",
"reason": "scene_or_artifact_changed" if stale else "cache_hit",
"generation": scene.get("generation", {}),
"elements": scene.get("elements", []) if args.variant == "editable" else [],
}
save(cache_dir / "rebuild-plan.json", payload)
if args.variant == "image":
save(cache_dir / "prompt.json", {
"slide_number": args.slide,
"scene_hash": scene_hash,
"style_id": scene.get("style_id"),
"layout_id": scene.get("layout_id"),
**scene.get("generation", {}),
})
else:
class_map = {"semantic_visual": "unresolved"}
items = []
for element in scene.get("elements", []):
item = dict(element)
item["class"] = class_map.get(item.pop("type", "unresolved"), element.get("type"))
item["bbox_px"] = item.pop("bbox")
item["slide"] = 1
items.append(item)
save(cache_dir / "visual_inventory.json", {
"schema_version": 1,
"source_slide_number": args.slide,
"scene_hash": scene_hash,
"slide_size_px": [scene["canvas"]["width"], scene["canvas"]["height"]],
"font_face": metadata.get("environment", {}).get("selected_font"),
"items": items,
})
if stale:
slides[key] = {**state, "status": "stale", "scene_hash": scene_hash}
variant["status"] = "partial_stale"
save(metadata_path, metadata)
print(json.dumps(payload, ensure_ascii=False))
return
artifact = Path(args.artifact).resolve()
if not artifact.is_file():
raise SystemExit(f"artifact not found: {artifact}")
try:
artifact_ref = str(artifact.relative_to(session))
except ValueError:
artifact_ref = str(artifact)
slides[key] = {
"status": "built",
"scene_hash": scene_hash,
"artifact": artifact_ref,
"artifact_hash": sha256(artifact),
"built_at": datetime.now(timezone.utc).isoformat(),
}
scene_count = len(list((session / "scenes").glob("*.scene.json")))
built_count = sum(row.get("status") == "built" for row in slides.values())
variant["status"] = "built" if scene_count and built_count == scene_count else "partial"
save(metadata_path, metadata)
payload = {"slide": args.slide, "variant": args.variant, **slides[key]}
save(cache_dir / "build-state.json", payload)
print(json.dumps(payload, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Render the real multi-page editor UI from an editor manifest."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", required=True)
parser.add_argument("--out", required=True)
args = parser.parse_args()
helper = Path(__file__).resolve().parents[1] / "assets" / "editor" / "render_editor_canvas.py"
result = subprocess.run([sys.executable, str(helper), "--manifest", args.manifest, "--out", args.out], check=False)
raise SystemExit(result.returncode)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Render PPTX to PDF through a tested PowerPoint or isolated LibreOffice backend."""
from __future__ import annotations
import argparse
import json
import os
import platform
import shutil
import subprocess
import tempfile
from pathlib import Path
POWERPOINT_APP = Path("/Applications/Microsoft PowerPoint.app")
def powerpoint_available() -> bool:
return platform.system() == "Darwin" and POWERPOINT_APP.exists() and shutil.which("osascript") is not None
def libreoffice_path() -> str | None:
return shutil.which("soffice") or shutil.which("libreoffice")
def valid_pdf(path: Path) -> bool:
if not path.is_file() or path.stat().st_size <= 100:
return False
data = path.read_bytes()
return data.startswith(b"%PDF-") and b"%%EOF" in data[-2048:]
def run_powerpoint(source: Path, output: Path) -> dict[str, object]:
script = r'''
on run argv
set sourcePath to item 1 of argv
set outputPath to item 2 of argv
set openedDeck to missing value
try
with timeout of 180 seconds
tell application "Microsoft PowerPoint"
activate
open POSIX file sourcePath
delay 1
set openedDeck to active presentation
save openedDeck in POSIX file outputPath as save as PDF
delay 1
close openedDeck saving no
end tell
end timeout
on error errorMessage number errorNumber
try
if openedDeck is not missing value then
tell application "Microsoft PowerPoint" to close openedDeck saving no
end if
end try
error errorMessage number errorNumber
end try
end run
'''
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
output.unlink()
try:
result = subprocess.run(
["osascript", "-", str(source), str(output)],
input=script,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=210,
)
return {"backend": "powerpoint-macos", "fidelity": "target", "returncode": result.returncode, "stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:], "output_exists": output.is_file(), "output_size": output.stat().st_size if output.is_file() else 0, "valid_pdf": valid_pdf(output)}
except subprocess.TimeoutExpired as exc:
return {"backend": "powerpoint-macos", "fidelity": "target", "returncode": 124, "stdout": (exc.stdout or "")[-2000:], "stderr": "PowerPoint automation timed out", "output_exists": output.is_file(), "output_size": output.stat().st_size if output.is_file() else 0, "valid_pdf": valid_pdf(output)}
def run_libreoffice(source: Path, output: Path) -> dict[str, object]:
executable = libreoffice_path()
if not executable:
return {"backend": "libreoffice", "fidelity": "approximate", "returncode": 127, "stderr": "soffice/libreoffice not found", "output_exists": False, "output_size": 0}
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
output.unlink()
with tempfile.TemporaryDirectory(prefix="codex-ppt-lo-profile-") as profile:
env = os.environ.copy()
env.setdefault("SAL_USE_VCLPLUGIN", "svp")
cache_dir = Path(profile) / "cache"
cache_dir.mkdir()
env["XDG_CACHE_HOME"] = str(cache_dir)
try:
result = subprocess.run(
[executable, f"-env:UserInstallation={Path(profile).as_uri()}", "--headless", "--convert-to", "pdf", "--outdir", str(output.parent), str(source)],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=180,
env=env,
)
except subprocess.TimeoutExpired as exc:
return {"backend": "libreoffice", "fidelity": "approximate", "returncode": 124, "stdout": (exc.stdout or "")[-2000:], "stderr": "LibreOffice conversion timed out", "output_exists": False, "output_size": 0, "valid_pdf": False}
generated = output.parent / source.with_suffix(".pdf").name
if generated.is_file() and generated != output:
generated.replace(output)
return {"backend": "libreoffice", "fidelity": "approximate", "returncode": result.returncode, "stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:], "output_exists": output.is_file(), "output_size": output.stat().st_size if output.is_file() else 0, "valid_pdf": valid_pdf(output)}
def succeeded(attempt: dict[str, object]) -> bool:
return attempt.get("returncode") == 0 and attempt.get("output_exists") is True and attempt.get("valid_pdf") is True
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", required=True)
parser.add_argument("--out-dir", required=True)
parser.add_argument("--backend", choices=("auto", "powerpoint", "libreoffice"), default="auto")
parser.add_argument("--validation-stage", choices=("intermediate", "final"), default="final")
parser.add_argument("--gui-validation-mode", choices=("final-only", "eager", "never"), default="final-only")
parser.add_argument("--allow-approximate", action="store_true", help="Allow LibreOffice output to pass instead of remaining QA-only.")
parser.add_argument("--report")
args = parser.parse_args()
source = Path(args.input).resolve()
if not source.is_file():
raise SystemExit(f"input PPTX not found: {source}")
output = Path(args.out_dir).resolve() / source.with_suffix(".pdf").name
if args.gui_validation_mode == "never" or (args.gui_validation_mode == "final-only" and args.validation_stage == "intermediate"):
status = "SKIPPED" if args.gui_validation_mode == "never" else "DEFERRED"
report = {
"status": status,
"input": str(source),
"output": None,
"selected_backend": None,
"attempts": [],
"validation_stage": args.validation_stage,
"gui_validation_mode": args.gui_validation_mode,
}
if args.report:
report_path = Path(args.report); report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
return
attempts: list[dict[str, object]] = []
if args.backend in {"auto", "powerpoint"}:
if powerpoint_available():
attempts.append(run_powerpoint(source, output))
else:
attempts.append({"backend": "powerpoint-macos", "fidelity": "target", "returncode": 127, "stderr": "PowerPoint automation unavailable", "output_exists": False, "output_size": 0})
if not any(succeeded(row) for row in attempts) and args.backend in {"auto", "libreoffice"}:
attempts.append(run_libreoffice(source, output))
selected = next((row for row in attempts if succeeded(row)), None)
status = "FAIL"
if selected:
status = "PASS" if selected["fidelity"] == "target" else "APPROXIMATE"
report = {
"status": status,
"input": str(source),
"output": str(output) if selected else None,
"selected_backend": selected["backend"] if selected else None,
"attempts": attempts,
"validation_stage": args.validation_stage,
"gui_validation_mode": args.gui_validation_mode,
}
if args.report:
report_path = Path(args.report); report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if status == "FAIL" or (status == "APPROXIMATE" and not args.allow_approximate):
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Snapshot, list, and non-destructively roll back a deck session."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
TRACKED_ROOT_FILES = ("metadata.json", "slides.md", "slides_plan.md", "prompts.json", "visual_inventory.json", "asset_manifest.json", "asset_anchors.json", "layout_rules.json")
TRACKED_RELATIVE_FILES = ("analysis/native_deck_spec.json", "analysis/fill_plan.json")
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def next_revision(versions: Path) -> str:
values = [int(p.name[1:]) for p in versions.glob("r[0-9][0-9][0-9][0-9]") if p.is_dir()]
return f"r{(max(values, default=0) + 1):04d}"
def tracked_files(session: Path) -> list[Path]:
files = [session / name for name in TRACKED_ROOT_FILES if (session / name).is_file()]
files.extend(session / name for name in TRACKED_RELATIVE_FILES if (session / name).is_file())
files.extend(sorted((session / "scenes").glob("*.scene.json")))
return files
def snapshot(session: Path, reason: str, source_revision: str | None = None) -> str:
versions = session / "versions"; versions.mkdir(parents=True, exist_ok=True)
revision = next_revision(versions)
target = versions / revision; target.mkdir()
records = []
for source in tracked_files(session):
rel = source.relative_to(session)
destination = target / rel
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
records.append({"path": str(rel), "sha256": sha256(source), "size": source.stat().st_size})
manifest = {
"revision": revision,
"created_at": datetime.now(timezone.utc).isoformat(),
"reason": reason,
"source_revision": source_revision,
"files": records,
}
(target / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
metadata_path = session / "metadata.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
metadata["current_revision"] = revision
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return revision
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
snap = sub.add_parser("snapshot"); snap.add_argument("--session", required=True); snap.add_argument("--reason", required=True)
listing = sub.add_parser("list"); listing.add_argument("--session", required=True)
rollback = sub.add_parser("rollback"); rollback.add_argument("--session", required=True); rollback.add_argument("--revision", required=True)
args = parser.parse_args()
session = Path(args.session).resolve(); versions = session / "versions"
if args.command == "snapshot":
print(json.dumps({"revision": snapshot(session, args.reason)}, ensure_ascii=False)); return
if args.command == "list":
rows = [json.loads((p / "manifest.json").read_text(encoding="utf-8")) for p in sorted(versions.glob("r[0-9][0-9][0-9][0-9]")) if (p / "manifest.json").is_file()]
print(json.dumps(rows, ensure_ascii=False)); return
target = versions / args.revision
manifest_path = target / "manifest.json"
if not manifest_path.is_file():
raise SystemExit(f"revision not found: {args.revision}")
before = snapshot(session, f"automatic snapshot before rollback to {args.revision}")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
target_paths = {row["path"] for row in manifest["files"]}
for current in tracked_files(session):
if str(current.relative_to(session)) not in target_paths:
current.unlink()
for row in manifest["files"]:
source = target / row["path"]
destination = session / row["path"]
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
new_revision = snapshot(session, f"rollback to {args.revision}", source_revision=args.revision)
print(json.dumps({"status": "rolled_back", "target": args.revision, "safety_snapshot": before, "current_revision": new_revision}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Select exactly one PPT production route from a structured request."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
ROUTES = {
"image-generation": "references/planning-workflow.md",
"native-editable-deck": "references/native-editable-workflow.md",
"element-rebuild": "references/semantic-replica-workflow.md",
"svg-redraw": "SKILL.md#路径-csvg-拆解",
"native-template-fill": "references/native-template-fill-workflow.md",
}
VALID_VALUES = {
"delivery_type": {"image-pptx", "editable-pptx", "pdf", "png", "svg", "pptx", "unspecified"},
"operation": {"create", "fill", "rebuild", "enhance"},
"input_kind": {"topic", "document", "pptx-template", "pptx-finished", "slide-images", "mixed"},
"editability": {"image", "editable", "unspecified"},
"visual_asset_policy": {"native-only", "native-image-assisted", "image-led-editable"},
"authoring_mode": {"slides-plan", "markdown-canvas"},
"editor_workflow_mode": {"direct-build", "canvas-first"},
}
DEFAULT_VALUES = {
"delivery_type": "unspecified",
"operation": "create",
"input_kind": "topic",
"editability": "unspecified",
"visual_asset_policy": "native-image-assisted",
"authoring_mode": "markdown-canvas",
"editor_workflow_mode": "canvas-first",
}
def truthy(value: object) -> bool:
return value is True
def decide(request: dict[str, object]) -> dict[str, object]:
delivery = str(request.get("delivery_type") or "unspecified")
operation = str(request.get("operation") or "create")
input_kind = str(request.get("input_kind") or "topic")
editability = str(request.get("editability") or "unspecified")
visual_asset_policy = str(request.get("visual_asset_policy") or "native-image-assisted")
authoring_mode = str(request.get("authoring_mode") or "markdown-canvas")
editor_workflow_mode = str(request.get("editor_workflow_mode") or "canvas-first")
has_source_pptx = truthy(request.get("has_source_pptx")) or input_kind in {"pptx-template", "pptx-finished"}
has_new_content = truthy(request.get("has_new_content"))
has_reference_slides = truthy(request.get("has_reference_slides")) or input_kind == "slide-images"
preserve_native = truthy(request.get("preserve_native_design"))
template_fill = truthy(request.get("explicit_template_fill")) or operation == "fill"
invalid_fields = {
field: value
for field, allowed in VALID_VALUES.items()
if (value := str(request.get(field) or DEFAULT_VALUES[field])) not in allowed
}
if invalid_fields:
return {
"schema_version": 1,
"status": "BLOCKED",
"route": None,
"authority": None,
"reason_codes": ["invalid_request_value"],
"missing_prerequisites": [],
"blocking_question": None,
"invalid_fields": invalid_fields,
}
reasons: list[str] = []
missing: list[str] = []
question = None
route = None
status = "PASS"
if delivery == "pptx" or (delivery == "unspecified" and truthy(request.get("requests_pptx")) and editability == "unspecified"):
status = "NEEDS_INPUT"
reasons.append("pptx_editability_ambiguous")
question = "需要图片型 PPTX还是可在 PowerPoint 中逐字逐对象编辑的 PPTX"
elif operation == "enhance":
status = "BLOCKED"
reasons.append("native_enhancement_not_implemented")
missing.append("notes/audio/animation enhancement route")
elif template_fill or (has_source_pptx and preserve_native and has_new_content):
route = "native-template-fill"
reasons.append("raw_pptx_plus_native_fill_intent")
if not has_source_pptx:
missing.append("source_pptx")
if not has_new_content:
missing.append("new_content")
elif delivery == "svg":
route = "svg-redraw"
reasons.append("svg_delivery_requested")
if not has_reference_slides:
missing.append("reference_slide_image")
elif editability == "editable" or delivery == "editable-pptx":
if operation == "create" and not has_reference_slides:
route = "native-editable-deck"
reasons.append("new_native_editable_deck_requested")
else:
route = "element-rebuild"
reasons.append("reference_driven_object_editability_required")
if not has_reference_slides:
missing.append("reference_slide_image")
elif editability == "image" or delivery in {"image-pptx", "pdf", "png"}:
route = "image-generation"
reasons.append("visual_delivery_without_object_editability")
elif has_reference_slides and operation == "rebuild":
status = "NEEDS_INPUT"
reasons.append("rebuild_delivery_ambiguous")
question = "重建后需要可编辑 PPTX还是 SVG"
elif operation == "create" and input_kind in {"topic", "document", "mixed"} and delivery == "unspecified":
route = "native-editable-deck"
reasons.append("new_presentation_defaults_to_markdown_canvas")
else:
status = "NEEDS_INPUT"
reasons.append("delivery_not_resolved")
question = "最终需要图片型 PPTX、可编辑 PPTX还是 SVG"
if missing:
status = "BLOCKED"
return {
"schema_version": 1,
"status": status,
"route": route,
"authority": ROUTES.get(route) if route else None,
"reason_codes": reasons,
"missing_prerequisites": missing,
"blocking_question": question,
"visual_asset_policy": visual_asset_policy if route == "native-editable-deck" else None,
"authoring_mode": authoring_mode if route == "native-editable-deck" else None,
"editor_workflow_mode": editor_workflow_mode if route == "native-editable-deck" else None,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--request", required=True, help="Structured request JSON.")
parser.add_argument("--session", help="Optional initialized deck session to update.")
parser.add_argument("--out", help="Decision report path; defaults to session/reports/route-decision.json.")
args = parser.parse_args()
request_path = Path(args.request).resolve()
request = json.loads(request_path.read_text(encoding="utf-8"))
result = decide(request)
canonical = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
result["request_fingerprint"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
result["request"] = request
session = Path(args.session).resolve() if args.session else None
if args.out:
out = Path(args.out).resolve()
elif session:
out = session / "reports" / "route-decision.json"
else:
out = request_path.with_name("route-decision.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if session:
metadata_path = session / "metadata.json"
if not metadata_path.is_file():
raise SystemExit(f"metadata.json missing: {metadata_path}")
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
metadata["route"] = result["route"]
metadata["route_status"] = result["status"]
metadata["route_report"] = str(out.relative_to(session))
if result.get("visual_asset_policy"):
metadata["visual_asset_policy"] = result["visual_asset_policy"]
if result.get("authoring_mode"):
metadata["authoring_mode"] = result["authoring_mode"]
metadata["content_source"] = "slides.md" if result["authoring_mode"] == "markdown-canvas" else "slides_plan.md"
if result.get("editor_workflow_mode"):
metadata["editor_workflow_mode"] = result["editor_workflow_mode"]
metadata["style_selection_status"] = "pending" if result["editor_workflow_mode"] == "canvas-first" else "auto-selected"
metadata["editor_export_approval"] = "pending" if result["editor_workflow_mode"] == "canvas-first" else "auto-proceed"
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result, ensure_ascii=False))
if result["status"] == "BLOCKED":
raise SystemExit(2)
if result["status"] == "NEEDS_INPUT":
raise SystemExit(3)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""Run the single final quality gate for every supported PPT route."""
from __future__ import annotations
import argparse
import json
import zipfile
from pathlib import Path
from lxml import etree
from validate_ooxml_namespaces import validate_package
def read_json(path: Path, errors: list[str], code: str) -> dict[str, object] | None:
if not path.is_file():
errors.append(f"{code}:missing:{path}")
return None
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"{code}:invalid:{exc}")
return None
if not isinstance(value, dict):
errors.append(f"{code}:not_object:{path}")
return None
return value
def pptx_slide_count(path: Path) -> int:
if not zipfile.is_zipfile(path):
return 0
with zipfile.ZipFile(path) as archive:
root = etree.fromstring(archive.read("ppt/presentation.xml"))
namespace = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
return len(root.xpath("./p:sldIdLst/p:sldId", namespaces=namespace))
def status_is_pass(report: dict[str, object] | None) -> bool:
return bool(report) and str(report.get("status", "")).upper() in {"PASS", "OK"}
def check_final_render(session: Path, artifact: Path, metadata: dict[str, object], errors: list[str], warnings: list[str], checks: list[dict[str, object]]) -> None:
mode = str(metadata.get("gui_validation_mode") or "legacy")
if mode == "legacy":
return
if mode == "never":
warnings.append("final_render:skipped_by_policy")
checks.append({"id": "final_powerpoint_render", "status": "SKIPPED", "mode": mode})
return
report = read_json(session / "reports" / "final-render.json", errors, "final_render")
if not report:
return
status = str(report.get("status", "FAIL")).upper()
checks.append({"id": "final_powerpoint_render", "status": status, "mode": mode, "backend": report.get("selected_backend")})
if status != "PASS":
errors.append(f"final_render:{status.lower()}")
if report.get("validation_stage") != "final":
errors.append("final_render:not_final_stage")
if report.get("gui_validation_mode") != mode:
errors.append("final_render:mode_mismatch")
if report.get("selected_backend") != "powerpoint-macos":
errors.append("final_render:not_target_powerpoint")
try:
rendered_input = Path(str(report.get("input"))).resolve()
except (TypeError, ValueError):
rendered_input = Path()
if rendered_input != artifact:
errors.append("final_render:artifact_mismatch")
try:
rendered_output = Path(str(report.get("output"))).resolve()
except (TypeError, ValueError):
rendered_output = Path()
if not rendered_output.is_file() or not rendered_output.read_bytes().startswith(b"%PDF-"):
errors.append("final_render:output_missing_or_invalid")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session", required=True)
parser.add_argument("--artifact", required=True)
parser.add_argument("--route", choices=("image-generation", "native-editable-deck", "element-rebuild", "svg-redraw", "native-template-fill"))
parser.add_argument("--out")
args = parser.parse_args()
session = Path(args.session).resolve()
artifact = Path(args.artifact).resolve()
errors: list[str] = []
warnings: list[str] = []
checks: list[dict[str, object]] = []
metadata_path = session / "metadata.json"
metadata = read_json(metadata_path, errors, "metadata") or {}
decision = read_json(session / "reports" / "route-decision.json", errors, "route_decision")
route = args.route or str(metadata.get("route") or (decision or {}).get("route") or "")
if route not in {"image-generation", "native-editable-deck", "element-rebuild", "svg-redraw", "native-template-fill"}:
errors.append(f"route:unsupported:{route or 'missing'}")
if decision:
if decision.get("status") != "PASS":
errors.append(f"route_decision:not_pass:{decision.get('status')}")
if decision.get("route") != route:
errors.append("route_decision:mismatch")
if metadata.get("route") != route:
errors.append("metadata_route:mismatch")
if not artifact.is_file():
errors.append(f"artifact:missing:{artifact}")
elif artifact.suffix.lower() == ".pptx":
count = pptx_slide_count(artifact)
checks.append({"id": "pptx_package", "slide_count": count})
if count < 1:
errors.append("artifact:invalid_pptx")
namespace_report = validate_package(artifact)
checks.append({
"id": "ooxml_namespace_integrity",
"status": namespace_report["status"],
"checked_parts": namespace_report["checked_parts"],
"checked_references": namespace_report["checked_references"],
})
if namespace_report["status"] != "PASS":
for item in namespace_report["errors"]:
errors.append(f"ooxml_namespace:{item.get('code')}:{item.get('part', '')}:{item.get('prefix', '')}")
if route in {"native-editable-deck", "element-rebuild", "native-template-fill"} and metadata.get("typography_profile"):
typography = read_json(session / "reports" / "typography-validation.json", errors, "typography_validation")
if typography:
typography_status = str(typography.get("status", "FAIL")).upper()
checks.append({
"id": "typography_validation",
"status": typography_status,
"profile": typography.get("typography_profile"),
"table_profile": typography.get("table_profile"),
})
if typography.get("typography_profile") != metadata.get("typography_profile"):
errors.append("typography_validation:profile_mismatch")
if typography.get("table_profile") != metadata.get("table_profile"):
errors.append("typography_validation:table_profile_mismatch")
if typography_status in {"FAIL", "ERROR", "BLOCKED"}:
errors.append(f"typography_validation:{typography_status.lower()}")
elif typography_status in {"WARN", "NOT_CHECKED"}:
warnings.append(f"typography_validation:{typography_status.lower()}")
outline_mode = metadata.get("outline_review_mode", "legacy")
outline_states = {"approved", "confirmed", "waived"}
if outline_mode == "continuous":
outline_states.add("auto-proceed")
if route in {"image-generation", "native-editable-deck", "element-rebuild"}:
design_quality = read_json(session / "reports" / "design-quality.json", errors, "design_quality")
if design_quality:
design_status = str(design_quality.get("status", "FAIL")).upper()
checks.append({
"id": "anti_ai_slop_design_gate",
"status": design_status,
"error_count": design_quality.get("error_count", 0),
"warning_count": design_quality.get("warning_count", 0),
})
if design_status in {"FAIL", "ERROR", "BLOCKED"}:
errors.append(f"design_quality:{design_status.lower()}")
elif design_status == "WARN":
warnings.append("design_quality:warn")
if route == "image-generation":
if metadata.get("outline_approval") not in outline_states:
errors.append("outline_approval:not_approved")
if metadata.get("smoke_approval") not in {"approved", "confirmed", "skipped", "waived"}:
errors.append("smoke_approval:not_approved")
images = sorted({*session.glob("generated/*.png"), *session.glob("final/*.png")})
checks.append({"id": "final_images", "count": len(images)})
if not images:
errors.append("final_images:missing")
if artifact.suffix.lower() == ".pptx" and images and pptx_slide_count(artifact) != len(images):
errors.append("image_slide_count:mismatch")
elif route == "native-editable-deck":
if metadata.get("editor_workflow_mode") == "canvas-first":
if metadata.get("style_selection_status") != "confirmed":
errors.append("canvas_style:not_confirmed")
if metadata.get("editor_export_approval") != "approved":
errors.append("canvas_export:not_approved")
if metadata.get("outline_approval") not in outline_states:
errors.append("outline_approval:not_approved")
spec = read_json(session / "analysis" / "native_deck_spec.json", errors, "native_deck_spec")
editor_manifest = read_json(session / "reports" / "editor-manifest.json", errors, "editor_manifest") if metadata.get("editor_workflow_mode") == "canvas-first" else None
build_report = read_json(session / "reports" / "native-editable-build.json", errors, "native_editable_build")
visual_policy = str(metadata.get("visual_asset_policy") or "native-image-assisted")
if spec and spec.get("visual_asset_policy") != visual_policy:
errors.append("native_deck_spec:visual_asset_policy_mismatch")
if spec and editor_manifest:
spec_count = len(spec.get("slides", [])) if isinstance(spec.get("slides"), list) else 0
manifest_slides = editor_manifest.get("slides", []) if isinstance(editor_manifest.get("slides"), list) else []
manifest_count = int(editor_manifest.get("slide_count", len(manifest_slides)))
artifact_count = pptx_slide_count(artifact)
checks.append({"id": "canvas_slide_parity", "spec": spec_count, "manifest": manifest_count, "pptx": artifact_count})
if not (spec_count == manifest_count == len(manifest_slides) == artifact_count):
errors.append(f"canvas_slide_parity:mismatch:{spec_count}:{manifest_count}:{len(manifest_slides)}:{artifact_count}")
if build_report:
if not status_is_pass(build_report):
errors.append("native_editable_build:not_pass")
if build_report.get("visual_asset_policy") != visual_policy:
errors.append("native_editable_build:visual_asset_policy_mismatch")
if visual_policy == "image-led-editable" and int(build_report.get("image2_assets", 0)) < 1:
errors.append("native_editable_build:image2_asset_required")
checks.append({
"id": "native_editable_objects",
"native_text_objects": build_report.get("native_text_objects", 0),
"native_shapes": build_report.get("native_shapes", 0),
"native_tables": build_report.get("native_tables", 0),
"office_charts": build_report.get("office_charts", 0),
"image_objects": build_report.get("image_objects", 0),
"image2_assets": build_report.get("image2_assets", 0),
})
font_report = read_json(session / "reports" / "font-validation.json", errors, "font_validation")
if font_report and not status_is_pass(font_report):
errors.append("font_validation:not_pass")
editability = read_json(session / "reports" / "editability-audit.json", errors, "editability_audit")
if editability:
if str(editability.get("status", "PASS")).upper() not in {"PASS", "OK"}:
errors.append("editability_audit:not_pass")
if "deck_looks_image_only" in editability.get("deck_flags", []):
errors.append("editability_audit:image_only")
if isinstance(metadata.get("editor"), dict) and metadata["editor"].get("status") == "rebuild_required":
errors.append("editor_canvas:changes_not_rebuilt")
check_final_render(session, artifact, metadata, errors, warnings, checks)
elif route == "element-rebuild":
preflight = read_json(session / "reports" / "preflight.json", errors, "preflight")
if preflight and preflight.get("status") == "BLOCKED":
errors.append("preflight:blocked")
elif preflight and preflight.get("status") == "WARN":
warnings.append("preflight:warn")
font_report = read_json(session / "reports" / "font-validation.json", errors, "font_validation")
if font_report and not status_is_pass(font_report):
errors.append("font_validation:not_pass")
build_report = read_json(session / "reports" / "build_report.json", errors, "build_report")
if build_report:
if build_report.get("status") == "FAIL":
errors.append("build_report:fail")
error_count = ((build_report.get("layout_qa") or {}) if isinstance(build_report.get("layout_qa"), dict) else {}).get("error_count")
if error_count not in {0, None}:
errors.append(f"layout_qa:error_count:{error_count}")
editability = read_json(session / "reports" / "editability-audit.json", errors, "editability_audit")
if editability:
if str(editability.get("status", "PASS")).upper() not in {"PASS", "OK"}:
errors.append("editability_audit:not_pass")
if "deck_looks_image_only" in editability.get("deck_flags", []):
errors.append("editability_audit:image_only")
semantic_report = session / "reports" / "semantic-validation.md"
if not semantic_report.is_file():
errors.append(f"semantic_validation:missing:{semantic_report}")
elif "- Result status: PASS" not in semantic_report.read_text(encoding="utf-8"):
errors.append("semantic_validation:not_pass")
check_final_render(session, artifact, metadata, errors, warnings, checks)
elif route == "svg-redraw":
svgs = sorted(session.glob("final/*.svg"))
checks.append({"id": "final_svg", "count": len(svgs)})
if not svgs:
errors.append("final_svg:missing")
validation = read_json(session / "reports" / "svg-validation.json", errors, "svg_validation")
if validation and not status_is_pass(validation):
errors.append("svg_validation:not_pass")
elif route == "native-template-fill":
plan = read_json(session / "analysis" / "fill_plan.json", errors, "fill_plan")
if plan and plan.get("status") != "confirmed":
errors.append("fill_plan:not_confirmed")
check_report = read_json(session / "analysis" / "check_report.json", errors, "fill_check")
if check_report and int(check_report.get("error_count", 0)) != 0:
errors.append(f"fill_check:error_count:{check_report.get('error_count')}")
validation = read_json(session / "reports" / "native-template-validation.json", errors, "native_template_validation")
if validation and not status_is_pass(validation):
errors.append("native_template_validation:not_pass")
check_final_render(session, artifact, metadata, errors, warnings, checks)
status = "BLOCKED" if errors else "WARN" if warnings else "PASS"
report = {
"schema_version": 1,
"status": status,
"route": route,
"artifact": str(artifact),
"checks": checks,
"errors": errors,
"warnings": warnings,
}
out = Path(args.out).resolve() if args.out else session / "reports" / "quality-gate.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if metadata_path.is_file():
metadata["final_qa"] = status.lower()
if metadata.get("gui_validation_mode") == "never":
metadata["final_powerpoint_validation"] = "skipped"
elif any(check.get("id") == "final_powerpoint_render" and check.get("status") == "PASS" for check in checks):
metadata["final_powerpoint_validation"] = "pass"
elif metadata.get("gui_validation_mode") in {"final-only", "eager"}:
metadata["final_powerpoint_validation"] = "blocked"
metadata["quality_gate_report"] = str(out.relative_to(session))
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if status == "BLOCKED":
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Resolve presentation style families and their selectable visual variants."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
def load_catalog() -> dict[str, Any]:
value = json.loads((ROOT / "styles" / "catalog.json").read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError("styles/catalog.json must contain an object")
return value
def resolve_style(style_id: str, variant_id: str | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
catalog = load_catalog()
style = next((row for row in catalog.get("styles", []) if row.get("id") == style_id), None)
if not style:
raise ValueError(f"unknown style_id: {style_id}")
variants = style.get("variants") or []
resolved_variant_id = variant_id or style.get("default_variant")
variant = next((row for row in variants if row.get("id") == resolved_variant_id), None)
if not variant:
raise ValueError(f"unknown style variant: {style_id}/{resolved_variant_id}")
return style, variant
def merged_design_tokens(style_id: str, variant_id: str | None = None) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
style, variant = resolve_style(style_id, variant_id)
layout_data = json.loads((ROOT / "styles" / style_id / "layouts.json").read_text(encoding="utf-8"))
tokens = dict(layout_data.get("design_tokens", {}))
tokens.update(variant.get("design_tokens", {}))
return style, variant, tokens
def flattened_presets() -> list[dict[str, Any]]:
presets: list[dict[str, Any]] = []
for style in load_catalog().get("styles", []):
for variant in style.get("variants", []):
_, _, tokens = merged_design_tokens(str(style["id"]), str(variant["id"]))
presets.append({
"preset_id": f"{style['id']}--{variant['id']}",
"style_id": style["id"],
"variant_id": variant["id"],
"family_name": style["name"],
"name": variant["name"],
"description": variant.get("description", ""),
"best_for": style.get("best_for", []),
"typography_profile": style.get("typography_profile"),
"table_profile": style.get("table_profile"),
"design_tokens": tokens,
})
return presets

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Extract editable PowerPoint objects into unified scene element v2 canvas records."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
from typing import Any
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.enum.text import PP_ALIGN
SLIDE_W = 13.333333
SLIDE_H = 7.5
CANVAS_W = 1920
CANVAS_H = 1080
def load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def save(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def canonical_hash(value: object) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(raw).hexdigest()
def object_id(shape: Any) -> str:
return str(getattr(shape, "name", "")).split(" [pf-role=", 1)[0]
def object_role(shape: Any) -> str | None:
name = str(getattr(shape, "name", ""))
return name.split("[pf-role=", 1)[1].rstrip("]") if "[pf-role=" in name else None
def rgb_value(color: Any) -> str | None:
try:
rgb = color.rgb
return str(rgb) if rgb is not None else None
except (AttributeError, TypeError, ValueError):
return None
def first_run(shape: Any) -> Any | None:
if not getattr(shape, "has_text_frame", False):
return None
for paragraph in shape.text_frame.paragraphs:
if paragraph.runs:
return paragraph.runs[0]
return None
def shape_style(shape: Any) -> dict[str, Any]:
style: dict[str, Any] = {}
try:
fill = rgb_value(shape.fill.fore_color)
if fill:
style["fill"] = fill
except (AttributeError, TypeError):
pass
try:
line = rgb_value(shape.line.color)
if line:
style["line"] = line
if shape.line.width:
style["line_width_pt"] = round(float(shape.line.width.pt), 2)
except (AttributeError, TypeError):
pass
run = first_run(shape)
if run is not None:
if run.font.size:
style["font_size_pt"] = round(float(run.font.size.pt), 2)
if run.font.name:
style["font_family"] = run.font.name
font_color = rgb_value(run.font.color)
if font_color:
style["font_color"] = font_color
if run.font.bold is not None:
style["bold"] = bool(run.font.bold)
paragraph = run._parent
style["align"] = {
PP_ALIGN.CENTER: "center", PP_ALIGN.RIGHT: "right", PP_ALIGN.JUSTIFY: "justify",
}.get(paragraph.alignment, "left")
try:
anchor = shape.text_frame.vertical_anchor
style["vertical_align"] = {1: "top", 3: "middle", 4: "bottom"}.get(int(anchor), "middle") if anchor is not None else "middle"
except (AttributeError, TypeError, ValueError):
pass
return style
def shape_kind(shape: Any) -> str:
if getattr(shape, "has_table", False):
return "table"
if getattr(shape, "has_chart", False):
return "chart"
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
return "image"
if shape.shape_type == MSO_SHAPE_TYPE.LINE:
return "connector"
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
return "group"
if getattr(shape, "has_text_frame", False) and str(shape.text).strip():
return "text"
return "shape"
def shape_geometry(shape: Any) -> str | None:
try:
value = shape.auto_shape_type
name = getattr(value, "name", None)
if name:
return str(name).lower()
text = str(value).split(" ", 1)[0].lower()
return text if text and text != "none" else None
except (AttributeError, TypeError, ValueError):
return None
def source_binding(slide_index: int, shape_id: str) -> dict[str, str] | None:
suffix = re.sub(r"^s\d{3}_", "", shape_id)
direct = {
"hero": "title", "title": "title", "closing_title": "title",
"subtitle": "subtitle", "message": "key_message", "closing_message": "key_message",
}
if suffix in direct:
return {"document": "analysis/native_deck_spec.json", "path": f"/slides/{slide_index}/{direct[suffix]}"}
patterns = (
(r"card_(\d+)_(title|body)$", "cards"),
(r"step_(\d+)_(title|body)$", "steps"),
(r"metric_(\d+)_(value|label)$", "metrics"),
(r"layer_(\d+)_(title|body)$", "layers"),
)
for pattern, collection in patterns:
match = re.fullmatch(pattern, suffix)
if match:
return {"document": "analysis/native_deck_spec.json", "path": f"/slides/{slide_index}/{collection}/{int(match.group(1)) - 1}/{match.group(2)}"}
match = re.fullmatch(r"compare_(\d+)_title", suffix)
if match:
side = "left" if int(match.group(1)) == 1 else "right"
return {"document": "analysis/native_deck_spec.json", "path": f"/slides/{slide_index}/{side}/title"}
match = re.fullmatch(r"action_(\d+)_text", suffix)
if match:
return {"document": "analysis/native_deck_spec.json", "path": f"/slides/{slide_index}/actions/{int(match.group(1)) - 1}"}
return None
def capabilities(kind: str) -> list[str]:
if kind == "text":
return ["text", "geometry", "style", "rotation", "z-order"]
if kind in {"shape", "connector"}:
return ["geometry", "style", "rotation", "z-order"]
if kind in {"image", "chart", "table", "group"}:
return ["geometry", "rotation", "z-order"]
return ["geometry"]
def element_from_shape(shape: Any, slide_index: int, z_index: int) -> dict[str, Any] | None:
shape_id = object_id(shape)
if not re.fullmatch(r"s\d{3}_.+", shape_id):
return None
x = float(shape.left.inches) / SLIDE_W * CANVAS_W
y = float(shape.top.inches) / SLIDE_H * CANVAS_H
w = max(1.0, float(shape.width.inches) / SLIDE_W * CANVAS_W)
h = max(1.0, float(shape.height.inches) / SLIDE_H * CANVAS_H)
kind = shape_kind(shape)
element: dict[str, Any] = {
"id": shape_id,
"type": kind,
"bbox": [round(x, 2), round(y, 2), round(w, 2), round(h, 2)],
"rotation": round(float(getattr(shape, "rotation", 0) or 0), 2),
"z_index": z_index,
"editable": True,
"locked": False,
"capabilities": capabilities(kind),
"role": object_role(shape),
"style": shape_style(shape),
}
if kind == "text":
element["text"] = str(shape.text)
geometry = shape_geometry(shape)
if geometry:
element["geometry"] = geometry
binding = source_binding(slide_index, shape_id)
if binding:
element["source_binding"] = binding
return element
def refresh_hash(scene: dict[str, Any]) -> None:
hashable = dict(scene)
hashable.pop("revision", None)
dependencies = dict(scene.get("dependencies", {})); dependencies.pop("scene_hash", None)
hashable["dependencies"] = dependencies
scene.setdefault("dependencies", {})["scene_hash"] = canonical_hash(hashable)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session", required=True)
parser.add_argument("--pptx", required=True)
parser.add_argument("--report")
args = parser.parse_args()
session = Path(args.session).resolve()
pptx = Path(args.pptx).resolve()
presentation = Presentation(pptx)
scenes_dir = session / "scenes"; scenes_dir.mkdir(parents=True, exist_ok=True)
counts: list[dict[str, int]] = []
for slide_index, slide in enumerate(presentation.slides):
number = slide_index + 1
scene_path = scenes_dir / f"slide-{number:03d}.scene.json"
if not scene_path.is_file():
raise SystemExit(f"scene missing: {scene_path}; run compile_scenes.py first")
scene = load(scene_path)
elements = [element for z, shape in enumerate(slide.shapes) if (element := element_from_shape(shape, slide_index, z))]
previous = canonical_hash(scene.get("elements", []))
scene["schema_version"] = 2
scene["canvas"] = {"width": CANVAS_W, "height": CANVAS_H, "unit": "px"}
scene["elements"] = elements
if previous != canonical_hash(elements):
scene["revision"] = int(scene.get("revision", 1)) + 1
refresh_hash(scene)
save(scene_path, scene)
counts.append({"slide_number": number, "element_count": len(elements)})
report = {"schema_version": 1, "status": "PASS", "pptx": str(pptx), "slide_count": len(counts), "slides": counts}
report_path = Path(args.report).resolve() if args.report else session / "reports" / "canvas-sync.json"
save(report_path, report)
print(json.dumps(report, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Run the pre-build Anti-AI-slop design gate for presentation sessions."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
NOTE_MARKER = re.compile(r"(?:设计说明|讲者备注|演讲备注|配图建议|排版说明|speaker\s*notes?|design\s*notes?|prompt\s*:)", re.I)
HARD_EFFECT_KEYS = {"shadow": "default-shadow", "gradient": "default-gradient"}
def load(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"expected JSON object: {path}")
return value
def finding(code: str, severity: str, message: str, slide_number: int | None = None, element_ids: list[str] | None = None) -> dict[str, Any]:
return {"code": code, "severity": severity, "slide_number": slide_number, "message": message, "element_ids": element_ids or []}
def iter_effects(value: Any, path: str = ""):
if isinstance(value, dict):
for key, child in value.items():
child_path = f"{path}/{key}"
disabled = child is None or child is False or (isinstance(child, str) and child.lower() in {"none", "off"})
if key in HARD_EFFECT_KEYS and not disabled:
yield HARD_EFFECT_KEYS[key], child_path
yield from iter_effects(child, child_path)
elif isinstance(value, list):
for index, child in enumerate(value):
yield from iter_effects(child, f"{path}/{index}")
def iter_slide_text(value: Any, path: str = ""):
if isinstance(value, dict):
for key, child in value.items():
if key in {"visual_slot", "prompt", "prompt_record", "asset_path", "design_exceptions"}:
continue
yield from iter_slide_text(child, f"{path}/{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
yield from iter_slide_text(child, f"{path}/{index}")
elif isinstance(value, str):
yield path, value
def area_ratio(bbox: list[float], width: float, height: float) -> float:
return float(bbox[2]) * float(bbox[3]) / max(1.0, width * height)
def contains(outer: list[float], inner: list[float]) -> bool:
margin = 2.0
return (
inner[0] >= outer[0] - margin and inner[1] >= outer[1] - margin
and inner[0] + inner[2] <= outer[0] + outer[2] + margin
and inner[1] + inner[3] <= outer[1] + outer[3] + margin
)
def analyze_design(spec: dict[str, Any], scenes: list[dict[str, Any]]) -> dict[str, Any]:
findings: list[dict[str, Any]] = []
for code, path in iter_effects(spec):
findings.append(finding(code, "ERROR", f"禁止默认启用阴影或渐变:{path}"))
for slide in spec.get("slides", []):
number = int(slide.get("slide_number", 0)) or None
cards = slide.get("cards") if isinstance(slide.get("cards"), list) else []
if len(cards) > 4:
findings.append(finding("meaningless-cardification", "WARN", f"同页出现 {len(cards)} 个卡片;应确认它们确有同级语义,或改用分区、流程、表格。", number))
for path, text in iter_slide_text(slide):
if NOTE_MARKER.search(text):
findings.append(finding("design-notes-in-body", "ERROR", f"页面正文包含设计说明或讲者提示:{path}", number))
visual = slide.get("visual_slot")
if isinstance(visual, dict):
bbox = visual.get("bbox_in")
if isinstance(bbox, list) and len(bbox) == 4 and area_ratio([float(v) for v in bbox], 13.333333, 7.5) >= 0.85:
findings.append(finding("full-slide-movable-image", "ERROR", "独立图片覆盖整页或近整页;可编辑模式禁止用可移动图片冒充页面背景。", number))
for scene in scenes:
number = int(scene.get("slide_number", 0)) or None
canvas = scene.get("canvas", {})
width, height = float(canvas.get("width", 1920)), float(canvas.get("height", 1080))
elements = scene.get("elements", []) if isinstance(scene.get("elements"), list) else []
rounded = [row for row in elements if str(row.get("geometry", "")).lower() in {"rounded_rectangle", "round_rect", "rounded-rectangle"}]
if len(rounded) > 5:
findings.append(finding("excessive-rounded-rectangles", "WARN", f"本页包含 {len(rounded)} 个圆角矩形;请减少容器层级或改用留白分组。", number, [str(row.get("id")) for row in rounded]))
shapes = [row for row in elements if row.get("type") == "shape" and isinstance(row.get("bbox"), list)]
texts = [row for row in elements if row.get("type") == "text" and isinstance(row.get("bbox"), list)]
for row in elements:
bbox = row.get("bbox")
if row.get("type") == "image" and isinstance(bbox, list) and area_ratio(bbox, width, height) >= 0.85:
findings.append(finding("full-slide-movable-image", "ERROR", "画布中存在覆盖整页或近整页的独立图片对象。", number, [str(row.get("id"))]))
if row.get("type") == "shape" and isinstance(bbox, list):
short, long = min(float(bbox[2]), float(bbox[3])), max(float(bbox[2]), float(bbox[3]))
if short <= 18 and long / max(short, 1.0) >= 12:
findings.append(finding("unjustified-accent-strip", "WARN", "检测到窄边强调条;若它不是坐标轴、时间线或有语义的分隔线,请删除。", number, [str(row.get("id"))]))
for shape in shapes:
contained = [text for text in texts if contains(shape["bbox"], text["bbox"])]
if contained:
findings.append(finding("shape-with-overlay-textbox", "WARN", "形状上叠加了独立文本框;优先把文字写入形状本身,或使用无容器的排版分组。", number, [str(shape.get("id")), *[str(row.get("id")) for row in contained]]))
for code, path in iter_effects(scene):
findings.append(finding(code, "ERROR", f"禁止默认启用阴影或渐变:{path}", number))
exceptions = spec.get("design_exceptions", []) if isinstance(spec.get("design_exceptions"), list) else []
for item in findings:
waiver = next((row for row in exceptions if row.get("code") == item["code"] and row.get("slide_number") in {None, item["slide_number"]} and str(row.get("reason", "")).strip()), None)
if waiver:
item["waived"] = True
item["waiver_reason"] = waiver["reason"]
active = [item for item in findings if not item.get("waived")]
errors = [item for item in active if item["severity"] == "ERROR"]
warnings = [item for item in active if item["severity"] == "WARN"]
return {
"schema_version": 1,
"status": "FAIL" if errors else "WARN" if warnings else "PASS",
"policy": "anti-ai-slop.v1",
"error_count": len(errors),
"warning_count": len(warnings),
"waived_count": len(findings) - len(active),
"findings": findings,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session")
parser.add_argument("--spec")
parser.add_argument("--scene-dir")
parser.add_argument("--out")
parser.add_argument("--strict-warnings", action="store_true")
args = parser.parse_args()
session = Path(args.session).resolve() if args.session else None
spec_path = Path(args.spec).resolve() if args.spec else None
if not spec_path and session:
native = session / "analysis" / "native_deck_spec.json"
spec_path = native if native.is_file() else session / "prompts.json"
if not spec_path or not spec_path.is_file():
raise SystemExit("native deck spec is required")
scene_dir = Path(args.scene_dir).resolve() if args.scene_dir else (session / "scenes" if session else None)
scenes = [load(path) for path in sorted(scene_dir.glob("*.scene.json"))] if scene_dir and scene_dir.is_dir() else []
report = analyze_design(load(spec_path), scenes)
out = Path(args.out).resolve() if args.out else (session / "reports" / "design-quality.json" if session else None)
if out:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if report["status"] == "FAIL" or (args.strict_warnings and report["status"] == "WARN"):
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Validate namespace prefixes referenced by OOXML compatibility attributes."""
from __future__ import annotations
import argparse
import json
import zipfile
from pathlib import Path
from lxml import etree
MC = "http://schemas.openxmlformats.org/markup-compatibility/2006"
PREFIX_LIST_ATTRIBUTES = {"Requires", "Ignorable"}
QNAME_LIST_ATTRIBUTES = {"PreserveAttributes", "PreserveElements", "ProcessContent"}
def attribute_local_name(name: str) -> str:
return etree.QName(name).localname if name.startswith("{") else name
def referenced_prefixes(element: etree._Element) -> set[str]:
prefixes: set[str] = set()
for name, value in element.attrib.items():
local = attribute_local_name(name)
if local in PREFIX_LIST_ATTRIBUTES:
prefixes.update(token for token in value.split() if token)
elif local in QNAME_LIST_ATTRIBUTES:
prefixes.update(token.split(":", 1)[0] for token in value.split() if ":" in token)
return prefixes
def validate_package(pptx: Path) -> dict[str, object]:
errors: list[dict[str, object]] = []
checked_parts = 0
checked_references = 0
if not zipfile.is_zipfile(pptx):
return {"status": "FAIL", "pptx": str(pptx), "checked_parts": 0, "checked_references": 0, "errors": [{"code": "not_zip_package"}]}
with zipfile.ZipFile(pptx) as package:
for name in package.namelist():
if not name.endswith((".xml", ".rels", ".vml")):
continue
checked_parts += 1
try:
root = etree.fromstring(package.read(name))
except etree.XMLSyntaxError as exc:
errors.append({"code": "xml_parse_error", "part": name, "message": str(exc)})
continue
tree = root.getroottree()
for element in root.iter():
prefixes = referenced_prefixes(element)
checked_references += len(prefixes)
for prefix in sorted(prefixes):
if prefix not in element.nsmap:
errors.append({
"code": "compatibility_prefix_undeclared",
"part": name,
"path": tree.getpath(element),
"prefix": prefix,
})
return {
"status": "FAIL" if errors else "PASS",
"pptx": str(pptx),
"checked_parts": checked_parts,
"checked_references": checked_references,
"errors": errors,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pptx")
parser.add_argument("--out")
args = parser.parse_args()
report = validate_package(Path(args.pptx).resolve())
if args.out:
out = Path(args.out); out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if report["status"] != "PASS":
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Validate explicit East Asian font declarations in a PPTX package."""
from __future__ import annotations
import argparse
import json
import re
import zipfile
from pathlib import Path
from lxml import etree
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
NS = {"a": A_NS, "p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
CJK = re.compile(r"[\u3400-\u9fff\uf900-\ufaff]")
ROLE = re.compile(r"\[pf-role=([a-z0-9_-]+)]", re.I)
HEADING_ROLES = {"hero", "section_title", "section-title", "title", "page_title", "page-title", "subtitle", "header", "minor_title", "minor-title"}
def run_role(run) -> str | None:
shape = next(iter(run.xpath("ancestor::p:sp[1]", namespaces=NS)), None)
if shape is None:
return None
names = shape.xpath("./p:nvSpPr/p:cNvPr/@name", namespaces=NS)
match = ROLE.search(names[0] if names else "")
return match.group(1).lower() if match else None
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pptx")
parser.add_argument("--font", help="Require this exact East Asian typeface.")
parser.add_argument("--heading-font", help="Expected East Asian font for heading roles and the major theme font.")
parser.add_argument("--body-font", help="Expected East Asian font for body roles and the minor theme font.")
parser.add_argument("--latin-font", help="Require this Latin and complex-script typeface on checked CJK runs.")
parser.add_argument("--out")
args = parser.parse_args()
pptx = Path(args.pptx)
errors: list[dict[str, object]] = []
checked_runs = 0
theme_nodes = 0
with zipfile.ZipFile(pptx) as package:
for name in package.namelist():
if name.startswith("ppt/slides/slide") and name.endswith(".xml"):
root = etree.fromstring(package.read(name))
for run in root.xpath(".//a:r", namespaces=NS):
text = "".join(run.xpath("./a:t/text()", namespaces=NS))
if not CJK.search(text):
continue
checked_runs += 1
role = run_role(run)
expected_ea = args.font or (args.heading_font if role in HEADING_ROLES else args.body_font)
ea_nodes = run.xpath("./a:rPr/a:ea", namespaces=NS)
ea_face = ea_nodes[0].get("typeface") if ea_nodes else None
if not ea_face or (expected_ea and ea_face != expected_ea):
errors.append({"part": name, "text": text, "role": role, "east_asian_typeface": ea_face, "expected": expected_ea})
for tag in ("latin", "cs"):
nodes = run.xpath(f"./a:rPr/a:{tag}", namespaces=NS)
face = nodes[0].get("typeface") if nodes else None
if not face or (args.latin_font and face != args.latin_font):
errors.append({"part": name, "text": text, "role": role, "font_slot": tag, "typeface": face, "expected": args.latin_font})
elif name.startswith("ppt/theme/theme") and name.endswith(".xml"):
root = etree.fromstring(package.read(name))
for branch, expected in (("majorFont", args.font or args.heading_font), ("minorFont", args.font or args.body_font)):
nodes = root.xpath(f".//a:fontScheme/a:{branch}/a:ea", namespaces=NS)
theme_nodes += len(nodes)
for node in nodes:
face = node.get("typeface")
if not face or (expected and face != expected):
errors.append({"part": name, "theme_node": branch, "east_asian_typeface": face, "expected": expected})
if checked_runs == 0:
errors.append({"type": "no_cjk_runs_found"})
if theme_nodes < 2:
errors.append({"type": "missing_major_or_minor_theme_east_asian_font", "count": theme_nodes})
report = {"status": "FAIL" if errors else "PASS", "pptx": str(pptx), "checked_cjk_runs": checked_runs, "theme_east_asian_nodes": theme_nodes, "errors": errors}
if args.out:
out = Path(args.out); out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if errors:
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Validate semantic typography tokens and table alignment in a PPTX."""
from __future__ import annotations
import argparse
import json
import math
import re
from collections import defaultdict
from pathlib import Path
from typing import Any
from pptx import Presentation
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PROFILES = ROOT / "styles" / "typography-profiles.json"
DEFAULT_CATALOG = ROOT / "styles" / "catalog.json"
ROLE_MARKER = re.compile(r"\[pf-role=([a-z0-9_-]+)]", re.I)
NUMERIC = re.compile(r"^\s*[¥¥$€£]?\(?[-+]?\d[\d,]*(?:\.\d+)?%?\)?\s*$")
ROLE_SIZE_KEY = {
"hero": "hero",
"section_title": "section_title",
"section-title": "section_title",
"title": "page_title",
"page_title": "page_title",
"page-title": "page_title",
"subtitle": "subtitle",
"header": "minor_title",
"minor_title": "minor_title",
"minor-title": "minor_title",
"body": "body",
"label": "label",
"micro_label": "caption",
"micro-label": "caption",
"caption": "caption",
"table": "table",
}
def read_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"expected JSON object: {path}")
return value
def select_policies(args: argparse.Namespace) -> tuple[dict[str, Any], dict[str, Any], str, str]:
profiles = read_json(Path(args.profiles))
typography = {row["id"]: row for row in profiles.get("typography_profiles", [])}
tables = {row["id"]: row for row in profiles.get("table_profiles", [])}
profile_id = args.profile
table_id = args.table_profile
if args.style_id:
catalog = read_json(Path(args.catalog))
style = next((row for row in catalog.get("styles", []) if row.get("id") == args.style_id), None)
if not style:
raise ValueError(f"unknown style_id: {args.style_id}")
profile_id = profile_id or style.get("typography_profile")
table_id = table_id or style.get("table_profile")
if not profile_id or profile_id not in typography:
raise ValueError(f"unknown or missing typography profile: {profile_id!r}")
if not table_id or table_id not in tables:
raise ValueError(f"unknown or missing table profile: {table_id!r}")
return typography[profile_id], tables[table_id], profile_id, table_id
def add_finding(store: dict[tuple[str, str], dict[str, Any]], code: str, severity: str, sample: dict[str, Any]) -> None:
key = (code, severity)
finding = store.setdefault(key, {"code": code, "severity": severity, "count": 0, "samples": []})
finding["count"] += 1
if len(finding["samples"]) < 8:
finding["samples"].append(sample)
def role_from_shape(shape: Any) -> str | None:
name = str(getattr(shape, "name", ""))
marker = ROLE_MARKER.search(name)
if marker:
return marker.group(1).lower()
lowered = name.lower()
for token, role in (("subtitle", "subtitle"), ("caption", "caption"), ("label", "label"), ("title", "title"), ("body", "body")):
if token in lowered:
return role
if getattr(shape, "is_placeholder", False):
placeholder = str(shape.placeholder_format.type).upper()
if "SUBTITLE" in placeholder:
return "subtitle"
if "TITLE" in placeholder:
return "title"
if "BODY" in placeholder or "OBJECT" in placeholder or "TEXT" in placeholder:
return "body"
return None
def alignment_name(value: Any) -> str | None:
return {
PP_ALIGN.LEFT: "left",
PP_ALIGN.CENTER: "center",
PP_ALIGN.RIGHT: "right",
PP_ALIGN.JUSTIFY: "justify",
PP_ALIGN.DISTRIBUTE: "distribute",
}.get(value)
def check_run_sizes(
paragraphs: Any,
role: str,
profile: dict[str, Any],
location: dict[str, Any],
findings: dict[tuple[str, str], dict[str, Any]],
counts: dict[str, int],
) -> None:
size_key = ROLE_SIZE_KEY.get(role)
if not size_key:
add_finding(findings, "text_role_unresolved", "not_checked", {**location, "role": role})
return
expected = float(profile["tokens"][size_key])
grid = float(profile.get("font_size_grid", 0.5))
for paragraph_index, paragraph in enumerate(paragraphs):
if paragraph.text and not paragraph.runs:
add_finding(findings, "font_size_unresolved", "not_checked", {**location, "paragraph": paragraph_index, "role": role})
for run_index, run in enumerate(paragraph.runs):
if not run.text.strip():
continue
counts["text_runs"] += 1
if run.font.size is None:
add_finding(findings, "font_size_unresolved", "not_checked", {**location, "paragraph": paragraph_index, "run": run_index, "role": role})
continue
size = float(run.font.size.pt)
grid_units = size / grid
if not math.isclose(grid_units, round(grid_units), abs_tol=0.04):
add_finding(findings, "font_size_off_grid", "warning", {**location, "role": role, "font_size": round(size, 3), "grid": grid})
if size + 0.04 < expected:
add_finding(findings, "font_size_below_token", "warning", {**location, "role": role, "font_size": round(size, 3), "size_key": size_key, "expected": expected})
def check_paragraph_policy(
paragraphs: Any,
role: str,
profile: dict[str, Any],
location: dict[str, Any],
findings: dict[tuple[str, str], dict[str, Any]],
) -> None:
group = "title" if ROLE_SIZE_KEY.get(role) in {"hero", "section_title", "page_title", "subtitle", "minor_title"} else "caption" if ROLE_SIZE_KEY.get(role) in {"label", "caption"} else "body"
expected = profile.get("paragraph", {}).get(group, {})
expected_spacing = expected.get("line_spacing_multiple")
for paragraph_index, paragraph in enumerate(paragraphs):
spacing = paragraph.line_spacing
if spacing is None:
add_finding(findings, "line_spacing_unresolved", "not_checked", {**location, "paragraph": paragraph_index, "role": role})
elif isinstance(spacing, float) and expected_spacing is not None and not math.isclose(spacing, float(expected_spacing), abs_tol=0.03):
add_finding(findings, "line_spacing_mismatch", "warning", {**location, "paragraph": paragraph_index, "role": role, "actual": round(spacing, 3), "expected": expected_spacing})
def expected_cell_alignment(row: int, col: int, text: str, table_policy: dict[str, Any]) -> str:
if row == 0:
return str(table_policy["header_alignment"])
if NUMERIC.fullmatch(text or ""):
return str(table_policy["numeric_alignment"])
if col == 0:
return str(table_policy["index_alignment"])
return str(table_policy["text_alignment"])
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pptx")
parser.add_argument("--profile")
parser.add_argument("--table-profile")
parser.add_argument("--style-id")
parser.add_argument("--profiles", default=str(DEFAULT_PROFILES))
parser.add_argument("--catalog", default=str(DEFAULT_CATALOG))
parser.add_argument("--out")
parser.add_argument("--fail-on-warning", action="store_true")
args = parser.parse_args()
pptx_path = Path(args.pptx).resolve()
findings: dict[tuple[str, str], dict[str, Any]] = {}
counts: dict[str, int] = defaultdict(int)
try:
profile, table_policy, profile_id, table_id = select_policies(args)
presentation = Presentation(pptx_path)
for slide_index, slide in enumerate(presentation.slides, start=1):
for shape in slide.shapes:
location = {"slide": slide_index, "shape": getattr(shape, "name", "")}
if getattr(shape, "has_table", False):
table = shape.table
for row_index, row in enumerate(table.rows):
for col_index, cell in enumerate(row.cells):
counts["table_cells"] += 1
cell_location = {**location, "row": row_index, "column": col_index}
if cell.vertical_anchor is None:
add_finding(findings, "table_vertical_alignment_unresolved", "not_checked", cell_location)
elif cell.vertical_anchor != MSO_ANCHOR.MIDDLE:
add_finding(findings, "table_vertical_alignment_mismatch", "warning", {**cell_location, "expected": "middle"})
expected_alignment = expected_cell_alignment(row_index, col_index, cell.text, table_policy)
for paragraph_index, paragraph in enumerate(cell.text_frame.paragraphs):
actual_alignment = alignment_name(paragraph.alignment)
if actual_alignment is None:
add_finding(findings, "table_horizontal_alignment_unresolved", "not_checked", {**cell_location, "paragraph": paragraph_index, "expected": expected_alignment})
elif actual_alignment != expected_alignment:
add_finding(findings, "table_horizontal_alignment_mismatch", "warning", {**cell_location, "paragraph": paragraph_index, "actual": actual_alignment, "expected": expected_alignment})
if paragraph.level:
add_finding(findings, "table_paragraph_special_indent", "warning", {**cell_location, "paragraph": paragraph_index, "level": paragraph.level})
check_run_sizes(cell.text_frame.paragraphs, "table", profile, cell_location, findings, counts)
continue
if not getattr(shape, "has_text_frame", False) or not shape.text_frame.text.strip():
continue
role = role_from_shape(shape)
if not role:
add_finding(findings, "text_role_unresolved", "not_checked", location)
continue
check_run_sizes(shape.text_frame.paragraphs, role, profile, location, findings, counts)
check_paragraph_policy(shape.text_frame.paragraphs, role, profile, location, findings)
if not counts["text_runs"] and not counts["table_cells"]:
add_finding(findings, "no_typography_content", "not_checked", {"pptx": str(pptx_path)})
findings_list = sorted(findings.values(), key=lambda row: (row["severity"], row["code"]))
warning_count = sum(row["count"] for row in findings_list if row["severity"] == "warning")
not_checked_count = sum(row["count"] for row in findings_list if row["severity"] == "not_checked")
status = "WARN" if warning_count else "NOT_CHECKED" if not_checked_count else "PASS"
report = {
"schema_version": 1,
"status": status,
"pptx": str(pptx_path),
"typography_profile": profile_id,
"table_profile": table_id,
"checked": dict(counts),
"warning_count": warning_count,
"not_checked_count": not_checked_count,
"findings": findings_list,
}
except Exception as exc:
report = {"schema_version": 1, "status": "FAIL", "pptx": str(pptx_path), "errors": [str(exc)]}
payload = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
if args.out:
out = Path(args.out).resolve()
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(payload, encoding="utf-8")
print(payload, end="")
if report["status"] == "FAIL" or (args.fail_on_warning and report["status"] == "WARN"):
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Validate scene files without external jsonschema dependencies."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
ALLOWED_TYPES = {"text", "shape", "line", "connector", "image", "chart", "table", "group", "layout_native", "line_native", "connector_native", "semantic_visual", "imagegen_asset", "provided_asset", "unresolved"}
ALLOWED_CAPABILITIES = {"text", "geometry", "style", "rotation", "z-order", "asset"}
def validate(path: Path) -> list[str]:
errors: list[str] = []
try:
scene = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
return [f"invalid JSON: {exc}"]
required = {"schema_version", "slide_id", "slide_number", "revision", "page_type", "canvas", "content", "elements", "dependencies"}
if missing := required - scene.keys():
errors.append(f"missing keys: {sorted(missing)}")
if scene.get("schema_version") != 2:
errors.append("schema_version must be 2")
if not re.fullmatch(r"slide-\d{3}", str(scene.get("slide_id", ""))):
errors.append("slide_id must match slide-001")
canvas = scene.get("canvas", {})
if not all(isinstance(canvas.get(k), int) and canvas[k] > 0 for k in ("width", "height")):
errors.append("canvas width and height must be positive integers")
seen: set[str] = set()
for idx, element in enumerate(scene.get("elements", [])):
element_id = element.get("id")
if not element_id or element_id in seen:
errors.append(f"element {idx}: missing or duplicate id")
seen.add(element_id)
if element.get("type") not in ALLOWED_TYPES:
errors.append(f"element {element_id}: unsupported type {element.get('type')}")
bbox = element.get("bbox")
if not isinstance(bbox, list) or len(bbox) != 4 or not all(isinstance(v, (int, float)) for v in bbox):
errors.append(f"element {element_id}: bbox must contain four numbers")
elif bbox[2] <= 0 or bbox[3] <= 0:
errors.append(f"element {element_id}: bbox width and height must be positive")
elif bbox[0] < 0 or bbox[1] < 0 or bbox[0] + bbox[2] > canvas.get("width", 0) + 0.1 or bbox[1] + bbox[3] > canvas.get("height", 0) + 0.1:
errors.append(f"element {element_id}: bbox must remain inside canvas")
capabilities = element.get("capabilities", [])
if not isinstance(capabilities, list) or any(value not in ALLOWED_CAPABILITIES for value in capabilities):
errors.append(f"element {element_id}: invalid capabilities")
if not isinstance(element.get("editable"), bool):
errors.append(f"element {element_id}: editable must be boolean")
if not isinstance(element.get("z_index"), int) or element.get("z_index", -1) < 0:
errors.append(f"element {element_id}: z_index must be a non-negative integer")
if not isinstance(element.get("rotation", 0), (int, float)):
errors.append(f"element {element_id}: rotation must be numeric")
deps = scene.get("dependencies", {})
if not all(key in deps for key in ("scene_hash", "prompt_hash", "asset_hashes")):
errors.append("dependencies must include scene_hash, prompt_hash and asset_hashes")
return errors
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", help="Scene JSON file or session/scenes directory.")
parser.add_argument("--out")
args = parser.parse_args()
target = Path(args.path)
paths = [target] if target.is_file() else sorted(target.glob("*.scene.json"))
if not paths:
raise SystemExit(f"no scene files found: {target}")
results = {str(path): validate(path) for path in paths}
report = {"status": "FAIL" if any(results.values()) else "PASS", "scene_count": len(paths), "results": results}
if args.out:
out = Path(args.out); out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if report["status"] == "FAIL":
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Validate a semantic editable PPTX reconstruction."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from io import BytesIO
from pathlib import Path
from zipfile import ZipFile
from PIL import Image
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pptx", required=True, help="PPTX to validate.")
parser.add_argument("--reference", action="append", default=[], help="Reference image to hash-check. Repeat per slide.")
parser.add_argument("--manifest", help="asset_manifest.json path.")
parser.add_argument("--inventory", help="visual_inventory.json path.")
parser.add_argument("--build-report", help="Optional build_report.json with text fitting and layout QA facts.")
parser.add_argument("--out", required=True, help="Markdown validation report path.")
parser.add_argument("--full-slide-size", default="", help="Optional WxH full-slide media size to reject, e.g. 1672x941.")
parser.add_argument("--near-full-slide-ratio", type=float, default=0.85, help="Flag media covering at least this share of full-slide area.")
return parser.parse_args()
def parse_size(value: str) -> tuple[int, int] | None:
if not value:
return None
match = re.fullmatch(r"(\d+)x(\d+)", value)
if not match:
raise SystemExit("--full-slide-size must be WxH")
return int(match.group(1)), int(match.group(2))
def count_slide_objects(zf: ZipFile) -> list[dict]:
slides = sorted(
[n for n in zf.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)],
key=lambda n: int(re.search(r"slide(\d+)", n).group(1)),
)
rows = []
for slide_name in slides:
xml = zf.read(slide_name).decode("utf-8", errors="ignore")
rows.append({
"slide_xml": slide_name,
"picture_objects": xml.count("<p:pic>"),
"shape_objects": xml.count("<p:sp>"),
"text_runs": xml.count("<a:t>"),
})
return rows
def inventory_items(inventory: object) -> list[dict]:
if not isinstance(inventory, dict):
return []
if isinstance(inventory.get("slides"), list):
items = []
for slide in inventory["slides"]:
if isinstance(slide, dict):
items.extend([item for item in slide.get("items", []) if isinstance(item, dict)])
return items
if isinstance(inventory.get("semantic_assets"), list):
items = []
for item in inventory["semantic_assets"]:
if not isinstance(item, dict):
continue
normalized = dict(item)
normalized.setdefault("id", item.get("semantic_unit_id"))
normalized.setdefault("class", item.get("source_type", "imagegen_asset"))
items.append(normalized)
return items
return [item for item in inventory.get("items", []) if isinstance(item, dict)]
def main() -> None:
args = parse_args()
pptx = Path(args.pptx)
reject_size = parse_size(args.full_slide_size)
ref_hashes = {}
for ref in args.reference:
path = Path(ref)
ref_hashes[hashlib.sha256(path.read_bytes()).hexdigest()] = str(path)
errors = []
media = []
exact_ref_hits = []
full_slide_hits = []
near_full_slide_hits = []
svg_media = []
with ZipFile(pptx) as zf:
bad_member = zf.testzip()
if bad_member:
errors.append(f"zip test failed at {bad_member}")
for name in sorted(n for n in zf.namelist() if n.startswith("ppt/media/")):
data = zf.read(name)
sha = hashlib.sha256(data).hexdigest()
dims = None
try:
with Image.open(BytesIO(data)) as img:
dims = img.size
except Exception:
pass
item = {"name": name, "bytes": len(data), "size": list(dims) if dims else None}
media.append(item)
if Path(name).suffix.lower() == ".svg":
svg_media.append(item)
if sha in ref_hashes:
exact_ref_hits.append({"name": name, "reference": ref_hashes[sha]})
if reject_size and dims == reject_size:
full_slide_hits.append(item)
if reject_size and dims:
media_area = dims[0] * dims[1]
slide_area = reject_size[0] * reject_size[1]
if media_area >= slide_area * args.near_full_slide_ratio:
near_full_slide_hits.append(item)
slide_counts = count_slide_objects(zf)
if exact_ref_hits:
errors.append("reference image hash found in PPTX media")
if full_slide_hits:
errors.append(f"full-slide media size found: {reject_size[0]}x{reject_size[1]}")
if near_full_slide_hits:
errors.append("near-full-slide media found")
if svg_media:
errors.append("svg media found in semantic PPTX")
manifest = None
manifest_by_id = {}
if args.manifest:
manifest = json.loads(Path(args.manifest).read_text(encoding="utf-8"))
for idx, item in enumerate(manifest):
if item.get("semantic_unit_count") != 1:
errors.append(f"manifest item {idx} is not semantic_unit_count=1")
if item.get("source_type") not in {"imagegen_asset", "api_generated_asset", "provided_asset"}:
errors.append(f"manifest item {idx} has invalid source_type")
if item.get("source_type") in {"raw_crop", "reference_crop", "screenshot_crop", "placeholder", "prompt_only_asset"}:
errors.append(f"manifest item {idx} has forbidden source_type")
asset_path = item.get("asset_path")
if asset_path:
manifest_path = Path(args.manifest)
resolved = Path(asset_path)
if not resolved.is_absolute():
resolved = manifest_path.parent / resolved
if not resolved.exists():
errors.append(f"manifest item {idx} asset file missing: {asset_path}")
if item.get("semantic_unit_id") in manifest_by_id:
errors.append(f"duplicate manifest semantic_unit_id: {item.get('semantic_unit_id')}")
manifest_by_id[item.get("semantic_unit_id")] = item
inventory = None
semantic_inventory_items = []
if args.inventory:
inventory = json.loads(Path(args.inventory).read_text(encoding="utf-8"))
semantic_inventory_items = [
item for item in inventory_items(inventory)
if item.get("class") in {"imagegen_asset", "api_generated_asset", "provided_asset"}
or item.get("type") in {"imagegen_asset", "api_generated_asset", "provided_asset"}
]
if manifest is not None:
for item in semantic_inventory_items:
item_id = item.get("id")
if item_id not in manifest_by_id:
errors.append(f"inventory semantic item missing manifest entry: {item_id}")
total_picture_objects = sum(row["picture_objects"] for row in slide_counts)
if manifest is not None and total_picture_objects < len(manifest):
errors.append("pptx picture object count is lower than manifest entries")
total_text_runs = sum(row["text_runs"] for row in slide_counts)
build_report = None
layout_qa = None
text_fit_rows = []
if args.build_report:
build_report = json.loads(Path(args.build_report).read_text(encoding="utf-8"))
layout_qa = build_report.get("layout_qa") if isinstance(build_report, dict) else None
if isinstance(layout_qa, dict) and layout_qa.get("error_count", 0):
errors.append("build report layout QA contains errors")
if isinstance(build_report, dict):
text_fit_rows = build_report.get("text_placements", [])[:]
status = "PASS"
if errors:
status = "FAIL"
elif inventory is None or manifest is None or not semantic_inventory_items or total_text_runs == 0:
status = "DRAFT_ONLY"
report = [
"# Semantic PPTX Validation Report",
"",
f"- PPTX: `{pptx}`",
f"- Zip integrity: {'PASS' if not any(e.startswith('zip test') for e in errors) else 'FAIL'}",
f"- Result status: {status}",
f"- Slide count: {len(slide_counts)}",
f"- Media object count: {len(media)}",
f"- Exact reference image hash check: {'PASS' if not exact_ref_hits else 'FAIL'}",
f"- Full-slide media check: {'PASS' if not full_slide_hits else 'FAIL'}",
f"- Near-full-slide media check: {'PASS' if not near_full_slide_hits else 'FAIL'}",
f"- SVG media check: {'PASS' if not svg_media else 'FAIL'}",
f"- Manifest entries: {len(manifest) if manifest is not None else 'not provided'}",
f"- Inventory provided: {'yes' if inventory is not None else 'no'}",
f"- Build report provided: {'yes' if build_report is not None else 'no'}",
f"- Inventory semantic asset items: {len(semantic_inventory_items)}",
f"- PPT picture objects: {total_picture_objects}",
f"- PPT text runs: {total_text_runs}",
"",
"## Slide Object Counts",
]
for row in slide_counts:
report.append(
f"- {row['slide_xml']}: pictures {row['picture_objects']}, shapes {row['shape_objects']}, text runs {row['text_runs']}"
)
report += ["", "## Largest Embedded Media"]
for item in sorted(media, key=lambda m: (m["size"] or [0, 0])[0] * (m["size"] or [0, 0])[1], reverse=True)[:10]:
report.append(f"- {item['name']}: size {item['size']}, bytes {item['bytes']}")
report += ["", "## Text Layout QA"]
if layout_qa:
report.append(f"- Status: {layout_qa.get('status')}")
report.append(f"- Errors: {layout_qa.get('error_count', 0)}")
report.append(f"- Warnings: {layout_qa.get('warning_count', 0)}")
for err in layout_qa.get("errors", [])[:20]:
report.append(f"- ERROR {err.get('type')}: {err}")
for warn in layout_qa.get("warnings", [])[:30]:
report.append(f"- WARN {warn.get('type')}: {warn}")
else:
report.append("- Not provided. Pass `--build-report` to validate text fitting, collisions, z-order, and layout QA.")
if text_fit_rows:
fitted = [row for row in text_fit_rows if row.get("effective_font_size") != row.get("font_size")]
min_font = min((float(row.get("effective_font_size", 999)) for row in text_fit_rows), default=0)
max_overflow = max((
max(float(row.get("overflow_height_ratio", 0)), float(row.get("overflow_width_ratio", 0)))
for row in text_fit_rows
), default=0)
report.append(f"- Native text objects in build report: {len(text_fit_rows)}")
report.append(f"- Auto-fitted text objects: {len(fitted)}")
report.append(f"- Minimum effective font size: {round(min_font, 2)} pt")
report.append(f"- Maximum estimated overflow ratio: {round(max_overflow, 4)}")
report += ["", "## Result"]
if status == "FAIL":
report.extend([f"- FAIL: {err}" for err in errors])
elif status == "DRAFT_ONLY":
report.append("- DRAFT_ONLY: no hard media violation found, but manifest, inventory, semantic assets, or native text evidence is incomplete.")
else:
report.append("- PASS: no reference image hash, rejected full-slide media, SVG media, invalid manifest entries, or manifest/object mismatch found.")
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(report) + "\n", encoding="utf-8")
print(json.dumps({"report": str(out), "status": status, "errors": errors}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Preflight semantic visual-replica inventory, manifest, and anchors."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ALLOWED_CLASSES = {
"text",
"layout_native",
"line_native",
"connector_native",
"imagegen_asset",
"api_generated_asset",
"provided_asset",
"unresolved",
}
SEMANTIC_CLASSES = {"imagegen_asset", "api_generated_asset", "provided_asset"}
ALLOWED_SOURCE_TYPES = {"imagegen_asset", "api_generated_asset", "provided_asset"}
FORBIDDEN_SOURCE_TYPES = {"raw_crop", "reference_crop", "screenshot_crop", "placeholder", "prompt_only_asset"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inventory", required=True, help="visual_inventory.json path.")
parser.add_argument("--manifest", help="asset_manifest.json path.")
parser.add_argument("--anchors", help="asset_anchors.json path.")
parser.add_argument("--stage", choices=["plan", "build"], default="build", help="Validation strictness.")
parser.add_argument("--require-anchors", action="store_true", help="Require every semantic placement to have an anchor.")
parser.add_argument("--out", help="Optional JSON report path.")
return parser.parse_args()
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def as_items(inventory: dict[str, Any]) -> list[dict[str, Any]]:
if isinstance(inventory.get("slides"), list):
items: list[dict[str, Any]] = []
for idx, slide in enumerate(inventory["slides"], start=1):
if not isinstance(slide, dict):
continue
slide_no = slide.get("slide", idx)
for item in slide.get("items", []):
if isinstance(item, dict):
item = dict(item)
item.setdefault("slide", slide_no)
items.append(item)
return items
return [item for item in inventory.get("items", []) if isinstance(item, dict)]
def read_optional_list(path_value: str | None) -> list[dict[str, Any]] | None:
if not path_value:
return None
data = read_json(Path(path_value))
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict) and isinstance(data.get("items"), list):
return [item for item in data["items"] if isinstance(item, dict)]
if isinstance(data, dict) and isinstance(data.get("anchors"), list):
return [item for item in data["anchors"] if isinstance(item, dict)]
return []
def valid_bbox(value: Any, allow_zero_axis: bool = False) -> bool:
if not (
isinstance(value, list)
and len(value) == 4
and all(isinstance(v, (int, float)) for v in value)
):
return False
if allow_zero_axis:
return value[2] >= 0 and value[3] >= 0 and (value[2] > 0 or value[3] > 0)
return value[2] > 0 and value[3] > 0
def rel_path(base: Path, value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else base / path
def main() -> None:
args = parse_args()
inventory_path = Path(args.inventory)
inventory = read_json(inventory_path)
base_dir = inventory_path.parent
errors: list[str] = []
warnings: list[str] = []
slide_size = inventory.get("slide_size_px") or inventory.get("canvas_px")
if not (
isinstance(slide_size, list)
and len(slide_size) == 2
and all(isinstance(v, (int, float)) and v > 0 for v in slide_size)
):
errors.append("inventory missing valid slide_size_px")
slide_size = [1, 1]
items = as_items(inventory)
seen_ids: set[str] = set()
semantic_ids: list[str] = []
for idx, item in enumerate(items):
item_id = item.get("id")
if not item_id:
errors.append(f"inventory item {idx} missing id")
continue
if item_id in seen_ids:
errors.append(f"duplicate inventory item id: {item_id}")
seen_ids.add(str(item_id))
cls = item.get("class") or item.get("type")
if cls not in ALLOWED_CLASSES:
errors.append(f"{item_id} has invalid class: {cls}")
if cls == "unresolved" and args.stage == "build":
errors.append(f"{item_id} is unresolved during build stage")
bbox = item.get("bbox_px") or item.get("bbox")
if not valid_bbox(bbox, allow_zero_axis=cls in {"line_native", "connector_native"}):
errors.append(f"{item_id} missing valid bbox_px")
elif slide_size != [1, 1]:
x, y, w, h = bbox
if x < -2 or y < -2 or x + w > float(slide_size[0]) + 2 or y + h > float(slide_size[1]) + 2:
warnings.append(f"{item_id} bbox extends outside slide bounds")
if cls in SEMANTIC_CLASSES:
semantic_ids.append(str(item_id))
if item.get("semantic_unit_count") not in {None, 1}:
errors.append(f"{item_id} semantic_unit_count must be 1")
manifest_items = read_optional_list(args.manifest)
manifest_by_id: dict[str, dict[str, Any]] = {}
if manifest_items is None:
if args.stage == "build" and semantic_ids:
errors.append("manifest is required for build stage")
else:
manifest_base = Path(args.manifest).parent if args.manifest else base_dir
for idx, item in enumerate(manifest_items):
sid = item.get("semantic_unit_id")
if not sid:
errors.append(f"manifest item {idx} missing semantic_unit_id")
continue
if sid in manifest_by_id:
errors.append(f"duplicate manifest semantic_unit_id: {sid}")
manifest_by_id[str(sid)] = item
source_type = item.get("source_type")
if source_type in FORBIDDEN_SOURCE_TYPES:
errors.append(f"manifest item {sid} uses forbidden source_type: {source_type}")
if source_type not in ALLOWED_SOURCE_TYPES:
errors.append(f"manifest item {sid} has invalid source_type: {source_type}")
if item.get("semantic_unit_count") != 1:
errors.append(f"manifest item {sid} semantic_unit_count must be 1")
asset_path = item.get("asset_path")
if args.stage == "build":
if not asset_path:
errors.append(f"manifest item {sid} missing asset_path")
elif not rel_path(manifest_base, str(asset_path)).exists():
errors.append(f"manifest item {sid} asset file missing: {asset_path}")
if manifest_items is not None:
for sid in semantic_ids:
if sid not in manifest_by_id:
errors.append(f"semantic inventory item missing manifest entry: {sid}")
anchors = read_optional_list(args.anchors)
anchor_ids: set[str] = set()
if anchors is not None:
for idx, anchor in enumerate(anchors):
sid = anchor.get("semantic_unit_id") or anchor.get("id")
if not sid:
errors.append(f"anchor {idx} missing semantic_unit_id")
continue
anchor_ids.add(str(sid))
bbox = anchor.get("target_bbox_px") or anchor.get("bbox_px") or anchor.get("bbox")
if not valid_bbox(bbox):
errors.append(f"anchor {sid} missing valid target bbox")
if anchor.get("placement_rule") and "stretch" in str(anchor.get("placement_rule")).lower() and "no one-axis" not in str(anchor.get("placement_rule")).lower():
errors.append(f"anchor {sid} placement_rule appears to allow image stretch")
elif args.require_anchors:
errors.append("anchors file is required")
if args.require_anchors and anchors is not None:
for sid in semantic_ids:
if sid not in anchor_ids:
errors.append(f"semantic inventory item missing anchor: {sid}")
report = {
"status": "FAIL" if errors else "PASS",
"stage": args.stage,
"inventory_items": len(items),
"semantic_items": len(semantic_ids),
"manifest_entries": len(manifest_items) if manifest_items is not None else None,
"anchor_entries": len(anchors) if anchors is not None else None,
"errors": errors,
"warnings": warnings,
}
if args.out:
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if errors:
raise SystemExit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STYLES_ROOT = ROOT / "styles"
REQUIRED_LAYOUTS = {
"cover-hero",
"content-structured",
"process-flow",
"comparison-two-zone",
"data-callouts",
"closing-action",
}
HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
def fail(message: str) -> None:
raise SystemExit(f"ERROR: {message}")
def iter_colors(value: object):
if isinstance(value, dict):
for key, child in value.items():
if key in {"background", "primary", "secondary", "text"}:
values = child if isinstance(child, list) else [child]
for item in values:
yield item
yield from iter_colors(child)
elif isinstance(value, list):
for child in value:
yield from iter_colors(child)
def main() -> None:
catalog_path = STYLES_ROOT / "catalog.json"
profiles_path = STYLES_ROOT / "typography-profiles.json"
if not catalog_path.is_file():
fail("missing styles/catalog.json")
if not profiles_path.is_file():
fail("missing styles/typography-profiles.json")
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
profiles = json.loads(profiles_path.read_text(encoding="utf-8"))
if catalog.get("schema_version") != 2:
fail("catalog schema_version must be 2")
entries = catalog.get("styles")
if not isinstance(entries, list) or not entries:
fail("catalog styles must be a non-empty list")
ids = [entry.get("id") for entry in entries]
if len(ids) != len(set(ids)):
fail("duplicate style ids in catalog")
typography_profiles = {row.get("id"): row for row in profiles.get("typography_profiles", [])}
table_profiles = {row.get("id"): row for row in profiles.get("table_profiles", [])}
if profiles.get("schema_version") != 1 or not typography_profiles or not table_profiles:
fail("typography profile catalog must contain typography_profiles and table_profiles")
required_tokens = {"hero", "section_title", "page_title", "subtitle", "minor_title", "body", "label", "caption", "table"}
for profile_id, profile in typography_profiles.items():
if not isinstance(profile_id, str) or not re.fullmatch(r"[a-z0-9-]+", profile_id):
fail(f"invalid typography profile id: {profile_id!r}")
missing_tokens = required_tokens - set(profile.get("tokens", {}))
if missing_tokens:
fail(f"{profile_id}: missing typography tokens {sorted(missing_tokens)}")
grid = profile.get("font_size_grid")
if not isinstance(grid, (int, float)) or grid <= 0:
fail(f"{profile_id}: font_size_grid must be positive")
for entry in entries:
style_id = entry.get("id")
if not isinstance(style_id, str) or not re.fullmatch(r"[a-z0-9-]+", style_id):
fail(f"invalid style id: {style_id!r}")
if entry.get("typography_profile") not in typography_profiles:
fail(f"{style_id}: unknown typography_profile {entry.get('typography_profile')!r}")
if entry.get("table_profile") not in table_profiles:
fail(f"{style_id}: unknown table_profile {entry.get('table_profile')!r}")
variants = entry.get("variants")
if not isinstance(variants, list) or len(variants) < 3:
fail(f"{style_id}: at least three visual variants are required")
variant_ids = [variant.get("id") for variant in variants]
if len(variant_ids) != len(set(variant_ids)):
fail(f"{style_id}: duplicate variant ids")
if entry.get("default_variant") not in variant_ids:
fail(f"{style_id}: default_variant must reference a variant")
for variant in variants:
variant_id = variant.get("id")
if not isinstance(variant_id, str) or not re.fullmatch(r"[a-z0-9-]+", variant_id):
fail(f"{style_id}: invalid variant id {variant_id!r}")
if not isinstance(variant.get("name"), str) or not variant["name"].strip():
fail(f"{style_id}/{variant_id}: name is required")
for color in iter_colors(variant.get("design_tokens")):
if not isinstance(color, str) or not HEX_COLOR.fullmatch(color):
fail(f"{style_id}/{variant_id}: invalid color token {color!r}")
style_dir = STYLES_ROOT / style_id
style_md = style_dir / "STYLE.md"
layouts_path = style_dir / "layouts.json"
if not style_md.is_file() or not layouts_path.is_file():
fail(f"{style_id}: STYLE.md or layouts.json missing")
data = json.loads(layouts_path.read_text(encoding="utf-8"))
required_top = {"schema_version", "style_id", "display_name", "global_prompt", "design_tokens", "layouts"}
missing_top = required_top - data.keys()
if missing_top:
fail(f"{style_id}: missing top-level keys {sorted(missing_top)}")
if data["schema_version"] != 1 or data["style_id"] != style_id:
fail(f"{style_id}: schema_version or style_id mismatch")
if data["display_name"] != entry.get("name"):
fail(f"{style_id}: display name differs from catalog")
layouts = data.get("layouts")
if not isinstance(layouts, list):
fail(f"{style_id}: layouts must be a list")
layout_ids = [layout.get("id") for layout in layouts]
if set(layout_ids) != REQUIRED_LAYOUTS or len(layout_ids) != len(REQUIRED_LAYOUTS):
fail(f"{style_id}: layout ids must equal {sorted(REQUIRED_LAYOUTS)}")
required_layout = {"id", "page_type", "summary", "content_capacity", "best_for", "avoid_for", "reuse_friendly", "composition"}
for layout in layouts:
missing_layout = required_layout - layout.keys()
if missing_layout:
fail(f"{style_id}/{layout.get('id')}: missing keys {sorted(missing_layout)}")
for color in iter_colors(data.get("design_tokens")):
if not isinstance(color, str) or not HEX_COLOR.fullmatch(color):
fail(f"{style_id}: invalid color token {color!r}")
preset_count = sum(len(entry.get("variants", [])) for entry in entries)
print(f"Style library is valid: {len(entries)} families, {preset_count} presets, {len(entries) * len(REQUIRED_LAYOUTS)} layouts")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Validate an SVG slide for portable, safe structural reuse."""
from __future__ import annotations
import argparse
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
XLINK_HREF = "{http://www.w3.org/1999/xlink}href"
LENGTH_RE = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("svg", help="SVG file to validate.")
parser.add_argument("--out", help="Optional JSON report path.")
parser.add_argument("--min-font-size", type=float, default=16.0, help="Warn below this SVG font size.")
parser.add_argument("--aspect-ratio", type=float, default=16 / 9, help="Expected slide aspect ratio.")
parser.add_argument("--aspect-tolerance", type=float, default=0.03, help="Allowed ratio difference.")
return parser.parse_args()
def number(value: str | None) -> float | None:
if not value:
return None
match = LENGTH_RE.match(value)
return float(match.group(1)) if match else None
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def main() -> None:
args = parse_args()
path = Path(args.svg)
errors: list[str] = []
warnings: list[str] = []
facts: dict[str, object] = {"file": str(path)}
if not path.is_file():
raise SystemExit(f"SVG not found: {path}")
try:
root = ET.parse(path).getroot()
except ET.ParseError as exc:
raise SystemExit(f"Invalid SVG XML: {exc}") from exc
if local_name(root.tag) != "svg":
errors.append("root element is not <svg>")
width = number(root.get("width"))
height = number(root.get("height"))
viewbox = root.get("viewBox", "").replace(",", " ").split()
if len(viewbox) == 4:
try:
width, height = float(viewbox[2]), float(viewbox[3])
except ValueError:
errors.append("viewBox contains non-numeric values")
elif width is None or height is None:
errors.append("SVG needs a numeric viewBox or width/height")
if width and height:
ratio = width / height
facts.update({"width": width, "height": height, "aspect_ratio": ratio})
if abs(ratio - args.aspect_ratio) > args.aspect_tolerance:
warnings.append(f"aspect ratio {ratio:.4f} differs from expected {args.aspect_ratio:.4f}")
counts: dict[str, int] = {}
for element in root.iter():
name = local_name(element.tag)
counts[name] = counts.get(name, 0) + 1
if name in {"script", "foreignObject"}:
errors.append(f"forbidden <{name}> element")
href = element.get("href") or element.get(XLINK_HREF)
if href and not href.startswith(("#", "data:")):
errors.append(f"external resource reference: {href}")
if name == "text":
font_size = number(element.get("font-size"))
style = element.get("style", "")
if font_size is None:
match = re.search(r"font-size\s*:\s*([0-9]+(?:\.[0-9]+)?)", style)
font_size = float(match.group(1)) if match else None
if font_size is not None and font_size < args.min_font_size:
warnings.append(f"text font-size {font_size:g} is below {args.min_font_size:g}")
facts["elements"] = counts
report = {
"status": "FAIL" if errors else "PASS",
"errors": sorted(set(errors)),
"warnings": sorted(set(warnings)),
"facts": facts,
}
payload = json.dumps(report, ensure_ascii=False, indent=2)
if args.out:
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(payload + "\n", encoding="utf-8")
print(payload)
if errors:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,28 @@
# 品牌发布
## 定位
适合新品发布、品牌升级、创意提案和市场传播。通过强对比、节奏和记忆点建立发布感,同时保证商业信息可读。
## 视觉令牌
- 背景:白色 `#FFFFFF` 或深紫 `#4E365C`
- 主色:珊瑚橙 `#FF765F`;辅色:深紫 `#654A72`;深文字 `#221F26`
- 辅助色:浅粉 `#FFE4DD`,仅用于层次。
- 容器50/50 分屏、大色块、细结构线;直角为主,扁平无厚阴影。
- 字体:标题可超大、紧凑、有海报感;正文保持标准无衬线。
## 图形与图片
使用高质量产品图、人物或场景主视觉,配合双色调处理、裁切和大胆几何。主视觉只设一个焦点;真实品牌标识必须来自用户素材。
## 提示词锚点
现代品牌发布、珊瑚橙与深紫双色、高对比分屏、超大中文标题、扁平几何、强节奏发布会演示、16:9。
## 禁止项
- 同页使用三种以上高饱和主色。
- 柔软渐变、玻璃拟态和廉价广告横幅感。
- 仿造产品包装、Logo、代言人或媒体背书。
- 用视觉冲击掩盖关键文案和行动信息。

View File

@@ -0,0 +1,108 @@
{
"schema_version": 1,
"style_id": "brand-launch",
"display_name": "品牌发布",
"global_prompt": "现代品牌发布,珊瑚橙与深紫双色,高对比分屏,超大中文标题,扁平几何和单一主视觉焦点",
"design_tokens": {
"background": [
"#FFFFFF",
"#4E365C"
],
"primary": "#FF765F",
"secondary": [
"#654A72",
"#FFE4DD"
],
"text": [
"#221F26",
"#FFFFFF"
],
"shape": "大色块、五五分屏和细结构线"
},
"production_notes": [
"同页不超过两个高饱和主色",
"品牌标识只能使用用户素材"
],
"layouts": [
{
"id": "cover-hero",
"page_type": "cover",
"summary": "主标题与单一视觉焦点建立主题。",
"content_capacity": {
"title": "618 个中文字符",
"subtitle": "1 行",
"meta": "02 项"
},
"best_for": "开场、主题发布",
"avoid_for": "密集背景说明",
"reuse_friendly": false,
"composition": "左侧或中心标题,另一侧为单一主视觉;保留品牌区和呼吸空间。"
},
{
"id": "content-structured",
"page_type": "content",
"summary": "用 24 个模块解释一个核心观点。",
"content_capacity": {
"sections": "24 组",
"points_per_section": "13 条"
},
"best_for": "价值、能力、问题、原则",
"avoid_for": "超过 4 个同级模块",
"reuse_friendly": true,
"composition": "标题区固定,主体使用二至四栏或不对称卡片网格,阅读顺序明确。"
},
{
"id": "process-flow",
"page_type": "process",
"summary": "按 35 个步骤呈现过程、阶段或链路。",
"content_capacity": {
"steps": "35 步",
"text_per_step": "标题加 1 句"
},
"best_for": "流程、路线图、实施路径",
"avoid_for": "复杂并行泳道",
"reuse_friendly": true,
"composition": "步骤沿横向、纵向或折线路径推进,编号、连接和方向统一。"
},
{
"id": "comparison-two-zone",
"page_type": "content",
"summary": "左右两个区域做对比、现状与目标或选择。",
"content_capacity": {
"groups": "2 组",
"points_per_group": "24 条"
},
"best_for": "前后对比、方案比较",
"avoid_for": "三方以上比较",
"reuse_friendly": true,
"composition": "左右等权或六四分区,中轴或色块明确分隔,比较维度对齐。"
},
{
"id": "data-callouts",
"page_type": "data",
"summary": "用 25 个指标或图形证据支撑一个结论。",
"content_capacity": {
"metrics": "25 项",
"charts": "02 个"
},
"best_for": "指标、洞察、使用分析",
"avoid_for": "无来源的虚构数字",
"reuse_friendly": true,
"composition": "一个主结论配大数字、简化图表或结构图,标签直接贴近证据。"
},
{
"id": "closing-action",
"page_type": "closing",
"summary": "总结核心价值并提出明确下一步。",
"content_capacity": {
"headline": "1 条",
"actions": "13 项",
"contact": "03 项"
},
"best_for": "PoC 邀请、行动清单、结束页",
"avoid_for": "新增复杂论证",
"reuse_friendly": false,
"composition": "用一句结论收束,行动项位于视觉终点,联系方式保持次级。"
}
]
}

View File

@@ -0,0 +1,69 @@
{
"schema_version": 2,
"styles": [
{
"id": "enterprise-tech-dark", "name": "企业科技深蓝", "best_for": ["AI 方案", "技术架构", "数字化转型", "售前提案"], "avoid_for": ["政府公文式汇报", "儿童培训"], "default_smoke_layout": "content-structured", "typography_profile": "zh-business-present", "table_profile": "presentation-data-table", "default_variant": "tech-blue",
"variants": [
{"id": "tech-blue", "name": "科技蓝", "description": "深空蓝底与克制亮蓝强调", "design_tokens": {"background": ["#0A0F1E", "#111A33"], "primary": "#4F7CFF", "secondary": ["#25D0D8", "#8C6CFF"], "text": ["#F7F9FF", "#AAB5CE"]}},
{"id": "indigo-grid", "name": "靛青网格", "description": "靛青背景与青绿信息层级", "design_tokens": {"background": ["#11142B", "#191D3D"], "primary": "#6D7CFF", "secondary": ["#35D6B4", "#A78BFA"], "text": ["#F7F7FF", "#B9BFDA"]}},
{"id": "black-gold-tech", "name": "黑金科技", "description": "近黑背景与低饱和金色技术感", "design_tokens": {"background": ["#111111", "#1C1B18"], "primary": "#D7B45A", "secondary": ["#7AA7A1", "#8E7CB8"], "text": ["#FAF8F1", "#BDB7A8"]}}
]
},
{
"id": "consulting-blue-white", "name": "蓝白咨询商务", "best_for": ["咨询建议", "管理层汇报", "方案说明", "轻数据页面"], "avoid_for": ["潮流品牌发布", "沉浸式暗色大屏"], "default_smoke_layout": "content-structured", "typography_profile": "zh-business-present", "table_profile": "presentation-data-table", "default_variant": "clean-blue-white",
"variants": [
{"id": "clean-blue-white", "name": "纯净蓝白", "description": "白底、冰蓝层次和清晰网格", "design_tokens": {"background": ["#FFFFFF", "#F4F7FF"], "primary": "#315EFB", "secondary": ["#DCE7FF", "#4DB7C5"], "text": ["#13213C", "#61708F"]}},
{"id": "purple-blue-consulting", "name": "紫蓝咨询", "description": "使用紫蓝色阶表达层级,不启用默认渐变", "design_tokens": {"background": ["#FBFAFF", "#F2F0FF"], "primary": "#6554D9", "secondary": ["#DCD6FF", "#5CA8A8"], "text": ["#211E3A", "#696582"]}},
{"id": "warm-gray-business", "name": "暖灰商务", "description": "暖白、石墨灰和克制蓝色强调", "design_tokens": {"background": ["#FBFAF7", "#F1EFEA"], "primary": "#355C7D", "secondary": ["#DCE3E8", "#A8835A"], "text": ["#25282B", "#6C7074"]}}
]
},
{
"id": "industrial-business", "name": "制造工业", "best_for": ["制造业", "能源与工程", "供应链", "生产运营"], "avoid_for": ["生活方式品牌", "轻松培训"], "default_smoke_layout": "process-flow", "typography_profile": "zh-business-present", "table_profile": "presentation-data-table", "default_variant": "steel-blue",
"variants": [
{"id": "steel-blue", "name": "钢铁蓝", "description": "钢蓝、冷灰与工程橙", "design_tokens": {"background": ["#F4F6F7", "#E7ECEF"], "primary": "#315B73", "secondary": ["#F28C45", "#7D929E"], "text": ["#1F2D35", "#5F7079"]}},
{"id": "factory-charcoal", "name": "厂务炭灰", "description": "炭灰底与安全黄强调", "design_tokens": {"background": ["#20262B", "#2B3339"], "primary": "#E0B43C", "secondary": ["#6FA9B7", "#858F96"], "text": ["#F5F7F7", "#B8C0C5"]}},
{"id": "energy-green", "name": "能源绿", "description": "深绿、砂岩与能源蓝", "design_tokens": {"background": ["#F5F7F2", "#E8EEE5"], "primary": "#28634A", "secondary": ["#C4975B", "#4D7C91"], "text": ["#203129", "#65736B"]}}
]
},
{
"id": "government-formal", "name": "政府正式", "best_for": ["政府与国企", "政策解读", "专项汇报", "领导汇报"], "avoid_for": ["娱乐品牌", "高饱和创意发布"], "default_smoke_layout": "content-structured", "typography_profile": "zh-formal-reading", "table_profile": "formal-data-table", "default_variant": "formal-red",
"variants": [
{"id": "formal-red", "name": "政务红", "description": "米白底与克制政务红", "design_tokens": {"background": ["#FFFDF8", "#F5F0E6"], "primary": "#9D2A2A", "secondary": ["#D8C6A3", "#5B6C7D"], "text": ["#2A2522", "#6E625B"]}},
{"id": "state-blue", "name": "国企蓝", "description": "正式蓝、浅灰和低饱和红", "design_tokens": {"background": ["#F7F9FC", "#EAF0F6"], "primary": "#214F7A", "secondary": ["#B23B3B", "#8297A8"], "text": ["#202B35", "#5F6C77"]}},
{"id": "ink-gold", "name": "墨金正式", "description": "墨色、宣纸白与小面积金色", "design_tokens": {"background": ["#FBFAF5", "#EEEAE0"], "primary": "#2F3437", "secondary": ["#A88949", "#7A302E"], "text": ["#242524", "#696965"]}}
]
},
{
"id": "executive-minimal", "name": "极简高管汇报", "best_for": ["董事会", "经营决策", "一页一结论", "行动建议"], "avoid_for": ["详细培训", "复杂技术教程"], "default_smoke_layout": "data-callouts", "typography_profile": "zh-executive-stage", "table_profile": "presentation-data-table", "default_variant": "black-white",
"variants": [
{"id": "black-white", "name": "黑白极简", "description": "暖白、炭黑与大量留白", "design_tokens": {"background": ["#FAFAF7", "#2C2F33"], "primary": "#202327", "secondary": ["#C8A842", "#73777E"], "text": ["#202327", "#666A70"]}},
{"id": "ivory-red", "name": "象牙白红", "description": "象牙白与低饱和暗红", "design_tokens": {"background": ["#FFFCF4", "#F2EDE1"], "primary": "#8F2D2D", "secondary": ["#C8A56A", "#7A7A74"], "text": ["#282421", "#6D655E"]}},
{"id": "charcoal-gold", "name": "深灰金", "description": "深灰底与克制金色焦点", "design_tokens": {"background": ["#242629", "#303338"], "primary": "#D0B268", "secondary": ["#8C969E", "#B38A72"], "text": ["#FAF9F5", "#C0C1BE"]}}
]
},
{
"id": "data-consulting", "name": "数据咨询", "best_for": ["数据战略", "指标体系", "分析洞察", "技术咨询"], "avoid_for": ["政府公文", "情感型品牌故事"], "default_smoke_layout": "data-callouts", "typography_profile": "zh-business-present", "table_profile": "presentation-data-table", "default_variant": "navy-dashboard",
"variants": [
{"id": "navy-dashboard", "name": "深海数据", "description": "海军蓝底与青蓝数据焦点", "design_tokens": {"background": ["#0C1830", "#142542"], "primary": "#38C6D9", "secondary": ["#F0A58F", "#A995E8"], "text": ["#F5F7FC", "#AAB7CC"]}},
{"id": "light-analytics", "name": "浅色分析", "description": "浅灰蓝底与高可读分析色", "design_tokens": {"background": ["#F8FAFD", "#ECF2F8"], "primary": "#176B87", "secondary": ["#E8876A", "#7967B7"], "text": ["#172A38", "#607584"]}},
{"id": "editorial-data", "name": "杂志数据", "description": "纸白、墨黑和编辑化强调色", "design_tokens": {"background": ["#FCFBF7", "#EEECE5"], "primary": "#22252A", "secondary": ["#C95D3F", "#3A7D78"], "text": ["#22252A", "#686A68"]}}
]
},
{
"id": "brand-launch", "name": "品牌发布", "best_for": ["新品发布", "品牌升级", "创意提案", "市场传播"], "avoid_for": ["严肃政策汇报", "密集财务数据"], "default_smoke_layout": "cover-hero", "typography_profile": "zh-executive-stage", "table_profile": "presentation-data-table", "default_variant": "coral-purple",
"variants": [
{"id": "coral-purple", "name": "珊瑚紫", "description": "珊瑚橙与深紫双色", "design_tokens": {"background": ["#FFFFFF", "#4E365C"], "primary": "#FF765F", "secondary": ["#654A72", "#FFE4DD"], "text": ["#221F26", "#6D6270"]}},
{"id": "electric-blue", "name": "电光蓝", "description": "亮蓝、酸橙与深靛色", "design_tokens": {"background": ["#F7F8FF", "#1D2453"], "primary": "#3366FF", "secondary": ["#B7E34B", "#7D65E8"], "text": ["#161A2D", "#68708E"]}},
{"id": "fashion-monochrome", "name": "时尚黑白", "description": "高对比黑白与单一洋红强调", "design_tokens": {"background": ["#FFFFFF", "#171717"], "primary": "#D94076", "secondary": ["#D7D7D7", "#665D63"], "text": ["#171717", "#6B6669"]}}
]
},
{
"id": "training-illustration", "name": "培训插画", "best_for": ["内部培训", "课程讲解", "流程科普", "文化宣导"], "avoid_for": ["董事会财务汇报", "严肃监管材料"], "default_smoke_layout": "process-flow", "typography_profile": "zh-business-present", "table_profile": "presentation-data-table", "default_variant": "friendly-blue",
"variants": [
{"id": "friendly-blue", "name": "亲和蓝", "description": "明快蓝色与柔和绿色辅助", "design_tokens": {"background": ["#FAFCFF", "#EDF5FF"], "primary": "#3978D4", "secondary": ["#65B894", "#F0B45E"], "text": ["#24344A", "#66778A"]}},
{"id": "warm-classroom", "name": "暖色课堂", "description": "奶油白、陶土橙与鼠尾草绿", "design_tokens": {"background": ["#FFF9EF", "#F5ECDD"], "primary": "#D36B47", "secondary": ["#7DA38C", "#D9A84E"], "text": ["#3B302A", "#75685F"]}},
{"id": "playful-purple", "name": "活力紫", "description": "低饱和紫、蓝和珊瑚色", "design_tokens": {"background": ["#FBF9FF", "#F0ECFA"], "primary": "#7357B8", "secondary": ["#4F9EB1", "#E68170"], "text": ["#302842", "#71677F"]}}
]
}
]
}

View File

@@ -0,0 +1,28 @@
# 蓝白咨询商务
## 定位
适合咨询建议、方案说明和管理层沟通的明亮商业风格。页面以结构、留白和阅读效率为先,保持专业但不僵硬。
## 视觉令牌
- 背景:纯白 `#FFFFFF` 与冰蓝 `#F4F7FF`
- 主色:商务蓝 `#315EFB`;深色文字 `#13213C`;次文字 `#61708F`
- 辅色:浅蓝 `#DCE7FF` 与少量青色 `#4DB7C5`
- 容器白色卡片、1px 浅蓝描边、极轻阴影;留白占画面约 40% 以上。
- 字体:现代中文无衬线,标题中粗,正文常规,标签短而清楚。
## 图形与图片
使用扁平几何、轻量 3D 物件或企业场景局部图。图表优先蓝色梯度、直接标签和简洁注释,不用装饰性图例堆叠。
## 提示词锚点
蓝白咨询商务、清晰网格、大留白、冰蓝卡片、理性可信、轻量企业科技、中文管理咨询演示、16:9。
## 禁止项
- 黑色沉浸背景、霓虹、重玻璃拟态。
- 过多圆角色块和无意义图标。
- 小字号脚注堆积、卡片超过内容容量。
- 虚构业绩、案例和收益。

View File

@@ -0,0 +1,108 @@
{
"schema_version": 1,
"style_id": "consulting-blue-white",
"display_name": "蓝白咨询商务",
"global_prompt": "蓝白咨询商务,白色与冰蓝背景,清晰网格,大留白,浅描边卡片,理性可信的中文商业演示",
"design_tokens": {
"background": [
"#FFFFFF",
"#F4F7FF"
],
"primary": "#315EFB",
"secondary": [
"#DCE7FF",
"#4DB7C5"
],
"text": [
"#13213C",
"#61708F"
],
"shape": "轻描边白卡与简洁几何"
},
"production_notes": [
"留白优先于装饰",
"图表采用直接标签"
],
"layouts": [
{
"id": "cover-hero",
"page_type": "cover",
"summary": "主标题与单一视觉焦点建立主题。",
"content_capacity": {
"title": "618 个中文字符",
"subtitle": "1 行",
"meta": "02 项"
},
"best_for": "开场、主题发布",
"avoid_for": "密集背景说明",
"reuse_friendly": false,
"composition": "左侧或中心标题,另一侧为单一主视觉;保留品牌区和呼吸空间。"
},
{
"id": "content-structured",
"page_type": "content",
"summary": "用 24 个模块解释一个核心观点。",
"content_capacity": {
"sections": "24 组",
"points_per_section": "13 条"
},
"best_for": "价值、能力、问题、原则",
"avoid_for": "超过 4 个同级模块",
"reuse_friendly": true,
"composition": "标题区固定,主体使用二至四栏或不对称卡片网格,阅读顺序明确。"
},
{
"id": "process-flow",
"page_type": "process",
"summary": "按 35 个步骤呈现过程、阶段或链路。",
"content_capacity": {
"steps": "35 步",
"text_per_step": "标题加 1 句"
},
"best_for": "流程、路线图、实施路径",
"avoid_for": "复杂并行泳道",
"reuse_friendly": true,
"composition": "步骤沿横向、纵向或折线路径推进,编号、连接和方向统一。"
},
{
"id": "comparison-two-zone",
"page_type": "content",
"summary": "左右两个区域做对比、现状与目标或选择。",
"content_capacity": {
"groups": "2 组",
"points_per_group": "24 条"
},
"best_for": "前后对比、方案比较",
"avoid_for": "三方以上比较",
"reuse_friendly": true,
"composition": "左右等权或六四分区,中轴或色块明确分隔,比较维度对齐。"
},
{
"id": "data-callouts",
"page_type": "data",
"summary": "用 25 个指标或图形证据支撑一个结论。",
"content_capacity": {
"metrics": "25 项",
"charts": "02 个"
},
"best_for": "指标、洞察、使用分析",
"avoid_for": "无来源的虚构数字",
"reuse_friendly": true,
"composition": "一个主结论配大数字、简化图表或结构图,标签直接贴近证据。"
},
{
"id": "closing-action",
"page_type": "closing",
"summary": "总结核心价值并提出明确下一步。",
"content_capacity": {
"headline": "1 条",
"actions": "13 项",
"contact": "03 项"
},
"best_for": "PoC 邀请、行动清单、结束页",
"avoid_for": "新增复杂论证",
"reuse_friendly": false,
"composition": "用一句结论收束,行动项位于视觉终点,联系方式保持次级。"
}
]
}

View File

@@ -0,0 +1,28 @@
# 数据咨询
## 定位
适合数据战略、指标体系、分析洞察、AI 架构和技术咨询。强调结构化推理、数据关系和可解释性,而不是炫技仪表盘。
## 视觉令牌
- 背景:深海军蓝 `#0C1830`;层级底色 `#142542`
- 主色:青蓝 `#38C6D9`;辅色:桃色 `#F0A58F`、淡紫 `#A995E8`
- 文字:主文字 `#F5F7FC`,次文字 `#AAB7CC`
- 容器:扁平深色模块、细线网格、轻微圆角;不使用厚重阴影。
- 字体:清晰无衬线,数字与标签层级鲜明。
## 图形与图片
以自绘图表、数据块、指标树、数据流和架构示意为主,少用照片。图表必须有直接标签和口径说明;无真实数据时使用结构示意,不制造数值。
## 提示词锚点
深色数据咨询、海军蓝底、青蓝数据流、稀疏几何方块、清晰分析框架、扁平 2D 图表、16:9 中文演示。
## 禁止项
- 大屏驾驶舱、密集仪表盘、3D 饼图。
- 无意义随机数字、伪代码和虚构准确率。
- 彩色系列过多、图例与图表分离难读。
- 发光和网格压过正文。

View File

@@ -0,0 +1,108 @@
{
"schema_version": 1,
"style_id": "data-consulting",
"display_name": "数据咨询",
"global_prompt": "深色数据咨询,海军蓝底,青蓝数据流,桃色和淡紫辅助,扁平二维图表,结构化分析框架",
"design_tokens": {
"background": [
"#0C1830",
"#142542"
],
"primary": "#38C6D9",
"secondary": [
"#F0A58F",
"#A995E8"
],
"text": [
"#F5F7FC",
"#AAB7CC"
],
"shape": "扁平深色模块、稀疏方块和细线"
},
"production_notes": [
"无真实数据时只画结构示意",
"避免驾驶舱式信息拥挤"
],
"layouts": [
{
"id": "cover-hero",
"page_type": "cover",
"summary": "主标题与单一视觉焦点建立主题。",
"content_capacity": {
"title": "618 个中文字符",
"subtitle": "1 行",
"meta": "02 项"
},
"best_for": "开场、主题发布",
"avoid_for": "密集背景说明",
"reuse_friendly": false,
"composition": "左侧或中心标题,另一侧为单一主视觉;保留品牌区和呼吸空间。"
},
{
"id": "content-structured",
"page_type": "content",
"summary": "用 24 个模块解释一个核心观点。",
"content_capacity": {
"sections": "24 组",
"points_per_section": "13 条"
},
"best_for": "价值、能力、问题、原则",
"avoid_for": "超过 4 个同级模块",
"reuse_friendly": true,
"composition": "标题区固定,主体使用二至四栏或不对称卡片网格,阅读顺序明确。"
},
{
"id": "process-flow",
"page_type": "process",
"summary": "按 35 个步骤呈现过程、阶段或链路。",
"content_capacity": {
"steps": "35 步",
"text_per_step": "标题加 1 句"
},
"best_for": "流程、路线图、实施路径",
"avoid_for": "复杂并行泳道",
"reuse_friendly": true,
"composition": "步骤沿横向、纵向或折线路径推进,编号、连接和方向统一。"
},
{
"id": "comparison-two-zone",
"page_type": "content",
"summary": "左右两个区域做对比、现状与目标或选择。",
"content_capacity": {
"groups": "2 组",
"points_per_group": "24 条"
},
"best_for": "前后对比、方案比较",
"avoid_for": "三方以上比较",
"reuse_friendly": true,
"composition": "左右等权或六四分区,中轴或色块明确分隔,比较维度对齐。"
},
{
"id": "data-callouts",
"page_type": "data",
"summary": "用 25 个指标或图形证据支撑一个结论。",
"content_capacity": {
"metrics": "25 项",
"charts": "02 个"
},
"best_for": "指标、洞察、使用分析",
"avoid_for": "无来源的虚构数字",
"reuse_friendly": true,
"composition": "一个主结论配大数字、简化图表或结构图,标签直接贴近证据。"
},
{
"id": "closing-action",
"page_type": "closing",
"summary": "总结核心价值并提出明确下一步。",
"content_capacity": {
"headline": "1 条",
"actions": "13 项",
"contact": "03 项"
},
"best_for": "PoC 邀请、行动清单、结束页",
"avoid_for": "新增复杂论证",
"reuse_friendly": false,
"composition": "用一句结论收束,行动项位于视觉终点,联系方式保持次级。"
}
]
}

View File

@@ -0,0 +1,28 @@
# 企业科技深蓝
## 定位
面向 CIO、IT 负责人和企业决策者的可信科技风格。适合 AI、知识库、数字化转型和技术架构售前强调稳健与先进感不做夸张赛博朋克。
## 视觉令牌
- 背景:深空蓝黑 `#0A0F1E`,可用 `#111A33` 做层次。
- 主色:电光蓝 `#4F7CFF`;辅色:青蓝 `#25D0D8`;少量紫 `#8C6CFF`
- 文字:主文字 `#F7F9FF`,次文字 `#AAB5CE`
- 容器:半透明深蓝卡片、细描边、轻微玻璃感;圆角克制。
- 字体:中文无衬线;标题粗、正文常规;数字可用窄体或等宽气质。
## 图形与图片
使用抽象数据流、知识节点、光带、半透明结构层和干净的企业场景。最多两个明显发光区域,图形服务于信息结构。真实 Logo 只用用户提供的原文件后置叠加。
## 提示词锚点
深空企业科技、克制蓝紫极光、精密网格、可信 AI 基础设施、深蓝玻璃卡片、清晰中文信息层级、16:9 商业演示、非赛博朋克。
## 禁止项
- 大面积霓虹、游戏 UI、黑客终端、机器人脸和芯片大脑俗套。
- 高饱和红绿蓝同时出现。
- 过多光晕导致正文对比不足。
- 虚构 Logo、认证、客户和数据。

View File

@@ -0,0 +1,108 @@
{
"schema_version": 1,
"style_id": "enterprise-tech-dark",
"display_name": "企业科技深蓝",
"global_prompt": "深空蓝黑企业科技,克制蓝紫光带,半透明深蓝卡片,精密网格,高对比中文排版,可信而非赛博朋克",
"design_tokens": {
"background": [
"#0A0F1E",
"#111A33"
],
"primary": "#4F7CFF",
"secondary": [
"#25D0D8",
"#8C6CFF"
],
"text": [
"#F7F9FF",
"#AAB5CE"
],
"shape": "克制圆角玻璃卡片与细发光线"
},
"production_notes": [
"光效最多集中在两个区域",
"正文区域保持纯净深色底"
],
"layouts": [
{
"id": "cover-hero",
"page_type": "cover",
"summary": "主标题与单一视觉焦点建立主题。",
"content_capacity": {
"title": "618 个中文字符",
"subtitle": "1 行",
"meta": "02 项"
},
"best_for": "开场、主题发布",
"avoid_for": "密集背景说明",
"reuse_friendly": false,
"composition": "左侧或中心标题,另一侧为单一主视觉;保留品牌区和呼吸空间。"
},
{
"id": "content-structured",
"page_type": "content",
"summary": "用 24 个模块解释一个核心观点。",
"content_capacity": {
"sections": "24 组",
"points_per_section": "13 条"
},
"best_for": "价值、能力、问题、原则",
"avoid_for": "超过 4 个同级模块",
"reuse_friendly": true,
"composition": "标题区固定,主体使用二至四栏或不对称卡片网格,阅读顺序明确。"
},
{
"id": "process-flow",
"page_type": "process",
"summary": "按 35 个步骤呈现过程、阶段或链路。",
"content_capacity": {
"steps": "35 步",
"text_per_step": "标题加 1 句"
},
"best_for": "流程、路线图、实施路径",
"avoid_for": "复杂并行泳道",
"reuse_friendly": true,
"composition": "步骤沿横向、纵向或折线路径推进,编号、连接和方向统一。"
},
{
"id": "comparison-two-zone",
"page_type": "content",
"summary": "左右两个区域做对比、现状与目标或选择。",
"content_capacity": {
"groups": "2 组",
"points_per_group": "24 条"
},
"best_for": "前后对比、方案比较",
"avoid_for": "三方以上比较",
"reuse_friendly": true,
"composition": "左右等权或六四分区,中轴或色块明确分隔,比较维度对齐。"
},
{
"id": "data-callouts",
"page_type": "data",
"summary": "用 25 个指标或图形证据支撑一个结论。",
"content_capacity": {
"metrics": "25 项",
"charts": "02 个"
},
"best_for": "指标、洞察、使用分析",
"avoid_for": "无来源的虚构数字",
"reuse_friendly": true,
"composition": "一个主结论配大数字、简化图表或结构图,标签直接贴近证据。"
},
{
"id": "closing-action",
"page_type": "closing",
"summary": "总结核心价值并提出明确下一步。",
"content_capacity": {
"headline": "1 条",
"actions": "13 项",
"contact": "03 项"
},
"best_for": "PoC 邀请、行动清单、结束页",
"avoid_for": "新增复杂论证",
"reuse_friendly": false,
"composition": "用一句结论收束,行动项位于视觉终点,联系方式保持次级。"
}
]
}

View File

@@ -0,0 +1,28 @@
# 极简高管汇报
## 定位
面向董事会、经营班子和决策会议。每页只传达一个结论,先给判断,再给证据和行动,不用密集模板填满页面。
## 视觉令牌
- 背景:暖白 `#FAFAF7` 或炭黑 `#2C2F33`
- 主文字:`#202327`;反白文字 `#FFFFFF`;次文字 `#73777E`
- 强调色:克制金黄 `#C8A842`,只用于结论、数字或行动。
- 容器:极少卡片,优先大留白、细线和清晰块面;直角为主。
- 字体:标题粗而短,正文少;关键结论与数字明显放大。
## 图形与图片
图表只保留支撑结论的系列与标注。图片优先黑白或低饱和,作为证据或情绪锚点,不做装饰图库拼贴。
## 提示词锚点
极简高管汇报、一页一结论、大留白、炭黑与暖白、克制金色强调、决策导向、16:9 中文董事会演示。
## 禁止项
- 一页塞入多个同等重要结论。
- 大量小卡片、图标矩阵和装饰性流程。
- 荧光黄大面积铺底、潮牌海报感。
- 没有证据的判断、收益或预测。

View File

@@ -0,0 +1,108 @@
{
"schema_version": 1,
"style_id": "executive-minimal",
"display_name": "极简高管汇报",
"global_prompt": "极简高管汇报,暖白与炭黑,大留白,一页一结论,克制金色强调,决策和行动导向",
"design_tokens": {
"background": [
"#FAFAF7",
"#2C2F33"
],
"primary": "#202327",
"secondary": [
"#C8A842",
"#73777E"
],
"text": [
"#202327",
"#FFFFFF"
],
"shape": "极少卡片、细线和大块留白"
},
"production_notes": [
"每页一个主结论",
"只保留支撑结论的图表系列"
],
"layouts": [
{
"id": "cover-hero",
"page_type": "cover",
"summary": "主标题与单一视觉焦点建立主题。",
"content_capacity": {
"title": "618 个中文字符",
"subtitle": "1 行",
"meta": "02 项"
},
"best_for": "开场、主题发布",
"avoid_for": "密集背景说明",
"reuse_friendly": false,
"composition": "左侧或中心标题,另一侧为单一主视觉;保留品牌区和呼吸空间。"
},
{
"id": "content-structured",
"page_type": "content",
"summary": "用 24 个模块解释一个核心观点。",
"content_capacity": {
"sections": "24 组",
"points_per_section": "13 条"
},
"best_for": "价值、能力、问题、原则",
"avoid_for": "超过 4 个同级模块",
"reuse_friendly": true,
"composition": "标题区固定,主体使用二至四栏或不对称卡片网格,阅读顺序明确。"
},
{
"id": "process-flow",
"page_type": "process",
"summary": "按 35 个步骤呈现过程、阶段或链路。",
"content_capacity": {
"steps": "35 步",
"text_per_step": "标题加 1 句"
},
"best_for": "流程、路线图、实施路径",
"avoid_for": "复杂并行泳道",
"reuse_friendly": true,
"composition": "步骤沿横向、纵向或折线路径推进,编号、连接和方向统一。"
},
{
"id": "comparison-two-zone",
"page_type": "content",
"summary": "左右两个区域做对比、现状与目标或选择。",
"content_capacity": {
"groups": "2 组",
"points_per_group": "24 条"
},
"best_for": "前后对比、方案比较",
"avoid_for": "三方以上比较",
"reuse_friendly": true,
"composition": "左右等权或六四分区,中轴或色块明确分隔,比较维度对齐。"
},
{
"id": "data-callouts",
"page_type": "data",
"summary": "用 25 个指标或图形证据支撑一个结论。",
"content_capacity": {
"metrics": "25 项",
"charts": "02 个"
},
"best_for": "指标、洞察、使用分析",
"avoid_for": "无来源的虚构数字",
"reuse_friendly": true,
"composition": "一个主结论配大数字、简化图表或结构图,标签直接贴近证据。"
},
{
"id": "closing-action",
"page_type": "closing",
"summary": "总结核心价值并提出明确下一步。",
"content_capacity": {
"headline": "1 条",
"actions": "13 项",
"contact": "03 项"
},
"best_for": "PoC 邀请、行动清单、结束页",
"avoid_for": "新增复杂论证",
"reuse_friendly": false,
"composition": "用一句结论收束,行动项位于视觉终点,联系方式保持次级。"
}
]
}

View File

@@ -0,0 +1,29 @@
# 政府正式
## 定位
适合政府、国企、政策解读、专项建设和领导汇报。核心是规范、权威、克制和可审阅性,不仿造公文红头或官方印章。
## 视觉令牌
- 背景:白色 `#FFFFFF`、浅灰蓝 `#F3F6FA`
- 主色:政务深蓝 `#163A66`;辅色:中蓝 `#3F6D9B`
- 强调色:克制朱红 `#B83A3A`,只用于重点、序号或政策提示。
- 文字:深蓝黑 `#162435`,次文字 `#627083`
- 容器:严格栅格、细边框、直角或极小圆角,层级对齐。
- 字体:正式中文无衬线或稳重黑体;标题端正,正文行距舒展。
## 图形与图片
使用地图轮廓、公共服务、城市与产业的纪实照片或简洁线性图标。图表直接标注,来源与口径位置清晰;没有来源的数据不生成。
## 提示词锚点
政府正式汇报、政务深蓝、严格模块网格、克制朱红强调、权威清晰、规范中文排版、16:9。
## 禁止项
- 仿造国徽、印章、红头文件、证书或官方 Logo。
- 大面积亮红、庆典金光和宣传海报式口号堆叠。
- 未经来源支持的政策结论、排名和数字。
- 过度创意字体、斜切构图和装饰性渐变。

View File

@@ -0,0 +1,108 @@
{
"schema_version": 1,
"style_id": "government-formal",
"display_name": "政府正式",
"global_prompt": "政府正式汇报,白色与浅灰蓝背景,政务深蓝,克制朱红重点,严格栅格,权威清晰的中文排版",
"design_tokens": {
"background": [
"#FFFFFF",
"#F3F6FA"
],
"primary": "#163A66",
"secondary": [
"#3F6D9B",
"#B83A3A"
],
"text": [
"#162435",
"#627083"
],
"shape": "直角或极小圆角的规范模块"
},
"production_notes": [
"朱红只用于重点和序号",
"不得仿造印章与官方标识"
],
"layouts": [
{
"id": "cover-hero",
"page_type": "cover",
"summary": "主标题与单一视觉焦点建立主题。",
"content_capacity": {
"title": "618 个中文字符",
"subtitle": "1 行",
"meta": "02 项"
},
"best_for": "开场、主题发布",
"avoid_for": "密集背景说明",
"reuse_friendly": false,
"composition": "左侧或中心标题,另一侧为单一主视觉;保留品牌区和呼吸空间。"
},
{
"id": "content-structured",
"page_type": "content",
"summary": "用 24 个模块解释一个核心观点。",
"content_capacity": {
"sections": "24 组",
"points_per_section": "13 条"
},
"best_for": "价值、能力、问题、原则",
"avoid_for": "超过 4 个同级模块",
"reuse_friendly": true,
"composition": "标题区固定,主体使用二至四栏或不对称卡片网格,阅读顺序明确。"
},
{
"id": "process-flow",
"page_type": "process",
"summary": "按 35 个步骤呈现过程、阶段或链路。",
"content_capacity": {
"steps": "35 步",
"text_per_step": "标题加 1 句"
},
"best_for": "流程、路线图、实施路径",
"avoid_for": "复杂并行泳道",
"reuse_friendly": true,
"composition": "步骤沿横向、纵向或折线路径推进,编号、连接和方向统一。"
},
{
"id": "comparison-two-zone",
"page_type": "content",
"summary": "左右两个区域做对比、现状与目标或选择。",
"content_capacity": {
"groups": "2 组",
"points_per_group": "24 条"
},
"best_for": "前后对比、方案比较",
"avoid_for": "三方以上比较",
"reuse_friendly": true,
"composition": "左右等权或六四分区,中轴或色块明确分隔,比较维度对齐。"
},
{
"id": "data-callouts",
"page_type": "data",
"summary": "用 25 个指标或图形证据支撑一个结论。",
"content_capacity": {
"metrics": "25 项",
"charts": "02 个"
},
"best_for": "指标、洞察、使用分析",
"avoid_for": "无来源的虚构数字",
"reuse_friendly": true,
"composition": "一个主结论配大数字、简化图表或结构图,标签直接贴近证据。"
},
{
"id": "closing-action",
"page_type": "closing",
"summary": "总结核心价值并提出明确下一步。",
"content_capacity": {
"headline": "1 条",
"actions": "13 项",
"contact": "03 项"
},
"best_for": "PoC 邀请、行动清单、结束页",
"avoid_for": "新增复杂论证",
"reuse_friendly": false,
"composition": "用一句结论收束,行动项位于视觉终点,联系方式保持次级。"
}
]
}

View File

@@ -0,0 +1,28 @@
# 制造工业
## 定位
面向制造、工程、能源、供应链和生产运营场景。强调可靠、秩序、现场感与执行力,避免把工业风做成脏乱或危险警示海报。
## 视觉令牌
- 背景:暖灰白 `#EEEAE4`、炭黑 `#17191C`
- 主色:工业棕 `#9A7C63`;辅色:钢灰 `#6F7780`;强调色 `#D79A32`
- 文字:深色 `#17191C` 或反白 `#F7F4EF`
- 容器:直角矩形、粗细有序的分隔线、严格模块网格,少用圆角。
- 字体:稳重无衬线,标题粗体,关键数字可放大。
## 图形与图片
可使用去饱和工厂、设备、产线、仓储和工程细节照片,置于整齐矩形框。流程图用方向明确的线和编号,不使用煤炭等特定行业素材,除非用户材料要求。
## 提示词锚点
现代制造工业、严格矩形网格、炭黑与暖灰、钢材质感、去饱和现场影像、硬朗商业报告、16:9 中文演示。
## 禁止项
- 锈迹、烟尘、危险符号和灾难感滥用。
- 圆润可爱图标、柔和渐变和漂浮玻璃卡片。
- 从参考风格带入煤炭、企业名或行业数据。
- 为营造工业感而牺牲信息可读性。

View File

@@ -0,0 +1,108 @@
{
"schema_version": 1,
"style_id": "industrial-business",
"display_name": "制造工业",
"global_prompt": "现代制造工业,炭黑与暖灰,工业棕和少量琥珀强调,严格矩形网格,去饱和现场影像,硬朗中文商业报告",
"design_tokens": {
"background": [
"#EEEAE4",
"#17191C"
],
"primary": "#9A7C63",
"secondary": [
"#6F7780",
"#D79A32"
],
"text": [
"#17191C",
"#F7F4EF"
],
"shape": "直角模块、分隔线和黑色图片框"
},
"production_notes": [
"不默认使用煤炭或危险符号",
"流程方向必须明确"
],
"layouts": [
{
"id": "cover-hero",
"page_type": "cover",
"summary": "主标题与单一视觉焦点建立主题。",
"content_capacity": {
"title": "618 个中文字符",
"subtitle": "1 行",
"meta": "02 项"
},
"best_for": "开场、主题发布",
"avoid_for": "密集背景说明",
"reuse_friendly": false,
"composition": "左侧或中心标题,另一侧为单一主视觉;保留品牌区和呼吸空间。"
},
{
"id": "content-structured",
"page_type": "content",
"summary": "用 24 个模块解释一个核心观点。",
"content_capacity": {
"sections": "24 组",
"points_per_section": "13 条"
},
"best_for": "价值、能力、问题、原则",
"avoid_for": "超过 4 个同级模块",
"reuse_friendly": true,
"composition": "标题区固定,主体使用二至四栏或不对称卡片网格,阅读顺序明确。"
},
{
"id": "process-flow",
"page_type": "process",
"summary": "按 35 个步骤呈现过程、阶段或链路。",
"content_capacity": {
"steps": "35 步",
"text_per_step": "标题加 1 句"
},
"best_for": "流程、路线图、实施路径",
"avoid_for": "复杂并行泳道",
"reuse_friendly": true,
"composition": "步骤沿横向、纵向或折线路径推进,编号、连接和方向统一。"
},
{
"id": "comparison-two-zone",
"page_type": "content",
"summary": "左右两个区域做对比、现状与目标或选择。",
"content_capacity": {
"groups": "2 组",
"points_per_group": "24 条"
},
"best_for": "前后对比、方案比较",
"avoid_for": "三方以上比较",
"reuse_friendly": true,
"composition": "左右等权或六四分区,中轴或色块明确分隔,比较维度对齐。"
},
{
"id": "data-callouts",
"page_type": "data",
"summary": "用 25 个指标或图形证据支撑一个结论。",
"content_capacity": {
"metrics": "25 项",
"charts": "02 个"
},
"best_for": "指标、洞察、使用分析",
"avoid_for": "无来源的虚构数字",
"reuse_friendly": true,
"composition": "一个主结论配大数字、简化图表或结构图,标签直接贴近证据。"
},
{
"id": "closing-action",
"page_type": "closing",
"summary": "总结核心价值并提出明确下一步。",
"content_capacity": {
"headline": "1 条",
"actions": "13 项",
"contact": "03 项"
},
"best_for": "PoC 邀请、行动清单、结束页",
"avoid_for": "新增复杂论证",
"reuse_friendly": false,
"composition": "用一句结论收束,行动项位于视觉终点,联系方式保持次级。"
}
]
}

View File

@@ -0,0 +1,29 @@
# 培训插画
## 定位
适合内部培训、课程讲解、流程科普和文化宣导。用统一插画语言降低理解门槛,保持友好但不幼稚。
## 视觉令牌
- 背景:暖米白 `#F7F0E2`
- 线条:深棕 `#3E342D`,约 23px 的统一描边感。
- 主色:珊瑚 `#E77B67`、薄荷 `#75B7A6`、芥黄 `#D7AA45`、蓝 `#6D91C6`
- 文字:深棕 `#3E342D`,次文字 `#786A60`
- 容器:扁平色块、轻微硬阴影、圆角适中;所有页保持同一插画笔触。
- 字体:友好无衬线,标题清楚,正文短句化。
## 图形与图片
使用人物、物件、场景和流程的扁平矢量插画;图表也采用同样描边与配色。复杂概念通过比喻和分步骤画面解释,不用照片与 3D 混搭。
## 提示词锚点
友好商业培训插画、暖米白、复古低饱和配色、统一深棕描边、扁平矢量、清晰步骤、16:9 中文课程演示。
## 禁止项
- 儿童绘本化表情、夸张卡通和表情包。
- 3D 黏土、照片、写实人物与扁平插画混搭。
- 每页更换绘画风格或线条粗细。
- 用插画替代必须准确呈现的数据与制度条款。

View File

@@ -0,0 +1,108 @@
{
"schema_version": 1,
"style_id": "training-illustration",
"display_name": "培训插画",
"global_prompt": "友好商业培训插画,暖米白底,复古低饱和配色,统一深棕描边,扁平矢量人物与场景",
"design_tokens": {
"background": [
"#F7F0E2"
],
"primary": "#E77B67",
"secondary": [
"#75B7A6",
"#D7AA45",
"#6D91C6"
],
"text": [
"#3E342D",
"#786A60"
],
"shape": "统一描边的扁平色块与适中圆角"
},
"production_notes": [
"全套保持同一插画笔触",
"不与照片或三维黏土风混搭"
],
"layouts": [
{
"id": "cover-hero",
"page_type": "cover",
"summary": "主标题与单一视觉焦点建立主题。",
"content_capacity": {
"title": "618 个中文字符",
"subtitle": "1 行",
"meta": "02 项"
},
"best_for": "开场、主题发布",
"avoid_for": "密集背景说明",
"reuse_friendly": false,
"composition": "左侧或中心标题,另一侧为单一主视觉;保留品牌区和呼吸空间。"
},
{
"id": "content-structured",
"page_type": "content",
"summary": "用 24 个模块解释一个核心观点。",
"content_capacity": {
"sections": "24 组",
"points_per_section": "13 条"
},
"best_for": "价值、能力、问题、原则",
"avoid_for": "超过 4 个同级模块",
"reuse_friendly": true,
"composition": "标题区固定,主体使用二至四栏或不对称卡片网格,阅读顺序明确。"
},
{
"id": "process-flow",
"page_type": "process",
"summary": "按 35 个步骤呈现过程、阶段或链路。",
"content_capacity": {
"steps": "35 步",
"text_per_step": "标题加 1 句"
},
"best_for": "流程、路线图、实施路径",
"avoid_for": "复杂并行泳道",
"reuse_friendly": true,
"composition": "步骤沿横向、纵向或折线路径推进,编号、连接和方向统一。"
},
{
"id": "comparison-two-zone",
"page_type": "content",
"summary": "左右两个区域做对比、现状与目标或选择。",
"content_capacity": {
"groups": "2 组",
"points_per_group": "24 条"
},
"best_for": "前后对比、方案比较",
"avoid_for": "三方以上比较",
"reuse_friendly": true,
"composition": "左右等权或六四分区,中轴或色块明确分隔,比较维度对齐。"
},
{
"id": "data-callouts",
"page_type": "data",
"summary": "用 25 个指标或图形证据支撑一个结论。",
"content_capacity": {
"metrics": "25 项",
"charts": "02 个"
},
"best_for": "指标、洞察、使用分析",
"avoid_for": "无来源的虚构数字",
"reuse_friendly": true,
"composition": "一个主结论配大数字、简化图表或结构图,标签直接贴近证据。"
},
{
"id": "closing-action",
"page_type": "closing",
"summary": "总结核心价值并提出明确下一步。",
"content_capacity": {
"headline": "1 条",
"actions": "13 项",
"contact": "03 项"
},
"best_for": "PoC 邀请、行动清单、结束页",
"avoid_for": "新增复杂论证",
"reuse_friendly": false,
"composition": "用一句结论收束,行动项位于视觉终点,联系方式保持次级。"
}
]
}

View File

@@ -0,0 +1,115 @@
{
"schema_version": 1,
"typography_profiles": [
{
"id": "zh-formal-reading",
"description": "面向研报、政府专项材料和邮件阅读型正式汇报。",
"fonts": {
"heading_east_asia": "黑体",
"body_east_asia": "宋体",
"latin": "Times New Roman",
"complex_script": "Times New Roman"
},
"tokens": {
"hero": 36,
"section_title": 28,
"page_title": 24,
"subtitle": 16,
"minor_title": 14,
"body": 12,
"label": 10.5,
"caption": 9,
"table": 10.5
},
"paragraph": {
"title": {"line_spacing_multiple": 1.0, "space_before_lines": 0, "space_after_lines": 0.5},
"body": {"line_spacing_multiple": 1.5, "space_before_lines": 0, "space_after_lines": 0.5},
"caption": {"line_spacing_multiple": 1.0, "space_before_lines": 0, "space_after_lines": 0},
"table": {"line_spacing_multiple": 1.0, "space_before_lines": 0, "space_after_lines": 0}
},
"font_size_grid": 0.5
},
{
"id": "zh-business-present",
"description": "面向售前、项目汇报、技术说明和会议投屏。",
"fonts": {
"heading_east_asia": "微软雅黑",
"body_east_asia": "微软雅黑",
"latin": "Arial",
"complex_script": "Arial"
},
"tokens": {
"hero": 40,
"section_title": 30,
"page_title": 24,
"subtitle": 18,
"minor_title": 16,
"body": 18,
"label": 13.5,
"caption": 10.5,
"table": 13.5
},
"paragraph": {
"title": {"line_spacing_multiple": 1.0, "space_before_lines": 0, "space_after_lines": 0.25},
"body": {"line_spacing_multiple": 1.15, "space_before_lines": 0, "space_after_lines": 0.25},
"caption": {"line_spacing_multiple": 1.0, "space_before_lines": 0, "space_after_lines": 0},
"table": {"line_spacing_multiple": 1.0, "space_before_lines": 0, "space_after_lines": 0}
},
"font_size_grid": 0.5
},
{
"id": "zh-executive-stage",
"description": "面向高管演讲、董事会重点页和发布会式低密度表达。",
"fonts": {
"heading_east_asia": "微软雅黑",
"body_east_asia": "微软雅黑",
"latin": "Arial",
"complex_script": "Arial"
},
"tokens": {
"hero": 44,
"section_title": 34,
"page_title": 28,
"subtitle": 20,
"minor_title": 18,
"body": 20,
"label": 14,
"caption": 11,
"table": 14
},
"paragraph": {
"title": {"line_spacing_multiple": 1.0, "space_before_lines": 0, "space_after_lines": 0.25},
"body": {"line_spacing_multiple": 1.1, "space_before_lines": 0, "space_after_lines": 0.25},
"caption": {"line_spacing_multiple": 1.0, "space_before_lines": 0, "space_after_lines": 0},
"table": {"line_spacing_multiple": 1.0, "space_before_lines": 0, "space_after_lines": 0}
},
"font_size_grid": 0.5
}
],
"table_profiles": [
{
"id": "formal-data-table",
"font_token": "table",
"vertical_alignment": "middle",
"header_alignment": "center",
"index_alignment": "left",
"text_alignment": "left",
"numeric_alignment": "right",
"line_spacing_multiple": 1.0,
"paragraph_space_lines": 0,
"first_line_indent_chars": 0
},
{
"id": "presentation-data-table",
"font_token": "table",
"vertical_alignment": "middle",
"header_alignment": "center",
"index_alignment": "left",
"text_alignment": "left",
"numeric_alignment": "right",
"line_spacing_multiple": 1.0,
"paragraph_space_lines": 0,
"first_line_indent_chars": 0
}
]
}

View File

@@ -0,0 +1,16 @@
[
{
"semantic_unit_id": "domain_icon_01",
"slide": 1,
"target_bbox_px": [140, 240, 120, 120],
"z_index": 20,
"placement_rule": "uniform contain scaling; no one-axis stretch"
},
{
"semantic_unit_id": "workflow_arrow_01",
"slide": 1,
"target_bbox_px": [420, 300, 180, 48],
"z_index": 15,
"placement_rule": "uniform contain scaling; no one-axis stretch"
}
]

View File

@@ -0,0 +1,24 @@
[
{
"semantic_unit_id": "domain_icon_01",
"source_type": "imagegen_asset",
"asset_path": "assets/domain_icon_01.png",
"semantic_unit_count": 1,
"generated_grid": "generated/domain_icons_grid.png",
"grid_cell": [0, 0],
"prompt_id": "domain_icons_grid_01",
"source_reference": "reference/page-01.png",
"placement_rule": "uniform contain scaling; no one-axis stretch"
},
{
"semantic_unit_id": "workflow_arrow_01",
"source_type": "imagegen_asset",
"asset_path": "assets/workflow_arrow_01.png",
"semantic_unit_count": 1,
"generated_grid": "generated/workflow_arrows_grid.png",
"grid_cell": [0, 1],
"prompt_id": "workflow_arrows_grid_01",
"source_reference": "reference/page-01.png",
"placement_rule": "uniform contain scaling; no one-axis stretch"
}
]

View File

@@ -0,0 +1,35 @@
# Conversion Report
- Source type:
- Output PPTX:
- Final deck type:
- Slide count:
- Reference images embedded in final PPTX: yes/no
- Full-slide media detected: yes/no
- Semantic asset count:
- Editable text object count:
- Native layout object count:
- Render preview path:
- Contact sheet path:
- Diff heatmap path:
## Decomposition Summary
- Text:
- Native layout:
- Generated assets:
- Provided assets:
- Retained bitmap exceptions:
## QA Notes
- Typography:
- Cropping or clipping:
- Asset edge cleanup:
- Known limitations:
## Handoff
- PPTX:
- Preview:
- Validation report:

View File

@@ -0,0 +1,20 @@
{
"slide_size_px": [1920, 1080],
"font_policy": {
"default_font_face": "Microsoft YaHei",
"text_box_extra_room_pct": 12
},
"image_fit": "uniform_contain_only",
"allowed_source_types": ["imagegen_asset", "api_generated_asset", "provided_asset"],
"forbidden_media": [
"full_slide_reference_image",
"near_full_slide_reference_image",
"svg_media",
"raw_crop_asset",
"prompt_only_asset"
],
"comparison": {
"diff_threshold": 18,
"changed_pixel_pct_target": 0.25
}
}

View File

@@ -0,0 +1 @@
{"slide":1,"prompt_id":"domain_icons_grid_01","output":"generated/domain_icons_grid_01.png","grid":{"rows":2,"cols":2},"objects":[{"semantic_unit_id":"domain_icon_01","description":"blue financial technology icon"},{"semantic_unit_id":"domain_icon_02","description":"blue shield icon"},{"semantic_unit_id":"workflow_arrow_01","description":"soft blue right arrow"},{"semantic_unit_id":"decorative_asset_01","description":"light blue UI decoration"}],"prompt":"Create an isolated asset grid for a PowerPoint visual replica. Use the full reference for style and supplied crops for object identity. Objects in order: domain_icon_01, domain_icon_02, workflow_arrow_01, decorative_asset_01. Grid: 2x2, generous margins, equal cells. Background: uniform chroma key #00ff00. Text: no readable text, no numbers, no labels, no watermark.","negative":"no card frames, no slide fragments, no fake logos, no QR codes, no crop borders, no surrounding context"}

View File

@@ -0,0 +1,56 @@
---
title: 企业 AI 知识库解决方案
style: consulting-blue-white
variant: clean-blue-white
ratio: 16:9
fontCN: 微软雅黑
visualAssetPolicy: native-image-assisted
---
# 企业 AI 知识库解决方案
> 让可信知识进入业务工作流
::layout{type="cover"}
---
# 企业知识面临的三类挑战
> 从知识分散、权限边界和答案可信度三个方面理解现状
::layout{type="content"}
## 知识分散
文档分布在多个系统,查找成本高。
## 权限复杂
新系统必须继承已有访问边界。
## 答案可信
回答需要展示来源并支持追溯。
---
# 四周 PoC 路径
::layout{type="timeline"}
## 第一周
确认场景、范围和知识材料。
## 第二周
完成文档接入和权限配置。
## 第三周
开展代表性问题验证和优化。
## 第四周
完成评估、复盘和下一步建议。

View File

@@ -0,0 +1,38 @@
{
"slide_size_px": [1920, 1080],
"final_deck_type": "semantic_editable_replica",
"source_image_policy": "reference only; do not embed the full source image in the final PPTX",
"items": [
{
"id": "title_main",
"class": "text",
"text": "Example slide title",
"bbox_px": [72, 56, 920, 72],
"ppt_role": "editable_text"
},
{
"id": "panel_main",
"class": "layout_native",
"bbox_px": [80, 180, 980, 620],
"ppt_role": "native_shape",
"z_index": 0
},
{
"id": "connector_01",
"class": "line_native",
"bbox_px": [300, 300, 260, 0],
"line": "5A93EA",
"line_width": 2,
"ppt_role": "native_connector",
"z_index": 5
},
{
"id": "domain_icon_01",
"class": "imagegen_asset",
"bbox_px": [140, 240, 120, 120],
"semantic_unit_count": 1,
"ppt_role": "independent_image",
"z_index": 10
}
]
}