From 708d620e7d846c29ca1a1b18c1a0bd19543e51ec Mon Sep 17 00:00:00 2001 From: KeyInfo Bot Date: Mon, 15 Jun 2026 00:01:16 +0800 Subject: [PATCH] Sync third-party marketplace plugins Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed. --- .agents/plugins/marketplace.json | 24 +- config/external-sources.lock.json | 8 +- .../codex/.agents/plugins/marketplace.json | 24 +- .../oh-my-codex/.codex-plugin/plugin.json | 2 +- .../oh-my-codex/THIRD_PARTY_SOURCE.json | 4 +- .../oh-my-codex/hooks/codex-native-hook.mjs | 55 +++- .../skills/best-practice-research/SKILL.md | 7 +- plugins/codex/plugins/ppt-master/README.md | 12 +- .../ppt-master/THIRD_PARTY_SOURCE.json | 4 +- .../ppt-master/skills/ppt-master/SKILL.md | 3 + .../ppt-master/references/executor-base.md | 2 + .../ppt-master/references/modes/briefing.md | 2 + .../ppt-master/references/strategist.md | 10 + .../ppt-master/scripts/docs/conversion.md | 2 +- .../scripts/source_to_md/doc_to_md.py | 269 +++++++++++++++++- .../scripts/svg_finalize/embed_icons.py | 32 ++- .../ppt-master/scripts/svg_quality_checker.py | 38 ++- .../scripts/template_fill_pptx/analyzer.py | 45 ++- .../scripts/template_fill_pptx/checker.py | 89 +++++- .../ppt-master/workflows/refine-spec.md | 58 ++++ 20 files changed, 622 insertions(+), 68 deletions(-) create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/refine-spec.md diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 741d28b35..350d82112 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -184,18 +184,6 @@ }, "category": "文档处理" }, - { - "name": "oh-my-codex", - "source": { - "source": "local", - "path": "./plugins/codex/plugins/oh-my-codex" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "开发工具" - }, { "name": "ppt-translator", "source": { @@ -256,6 +244,18 @@ }, "category": "文档处理" }, + { + "name": "oh-my-codex", + "source": { + "source": "local", + "path": "./plugins/codex/plugins/oh-my-codex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "开发工具" + }, { "name": "ppt-master", "source": { diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index bdf5cca73..6f7fd10bf 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -15,8 +15,8 @@ "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "ref": "main", "adapter": "codex-plugin", - "commit": "0332e47d2a961f738cf4c52e86cd517eaae4197b", - "syncedAt": "2026-06-11T08:39:42Z" + "commit": "29d03f493095899104f2c47ece39b166c5b14568", + "syncedAt": "2026-06-14T16:00:01Z" }, { "id": "ui-ux-pro-max", @@ -69,8 +69,8 @@ "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", "adapter": "claude-skill", - "commit": "a0d62437f12d4f913a45b3acaadd61bba914f52e", - "syncedAt": "2026-06-13T16:00:00Z" + "commit": "34d1f0057d51ff2cc15bcbbec071ef35f6fc1ae1", + "syncedAt": "2026-06-14T16:00:01Z" } ] } diff --git a/plugins/codex/.agents/plugins/marketplace.json b/plugins/codex/.agents/plugins/marketplace.json index f2b44be1b..33cf425bc 100644 --- a/plugins/codex/.agents/plugins/marketplace.json +++ b/plugins/codex/.agents/plugins/marketplace.json @@ -184,18 +184,6 @@ }, "category": "文档处理" }, - { - "name": "oh-my-codex", - "source": { - "source": "local", - "path": "./plugins/oh-my-codex" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "开发工具" - }, { "name": "ppt-translator", "source": { @@ -256,6 +244,18 @@ }, "category": "文档处理" }, + { + "name": "oh-my-codex", + "source": { + "source": "local", + "path": "./plugins/oh-my-codex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "开发工具" + }, { "name": "ppt-master", "source": { diff --git a/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json b/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json index ff321c1b2..f6c6d50aa 100644 --- a/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "oh-my-codex", - "version": "0.18.11", + "version": "0.18.12", "description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。", "author": { "name": "Yeachan Heo", diff --git a/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json index e7c667c27..7e460786f 100644 --- a/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "oh-my-codex", "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "ref": "main", - "commit": "0332e47d2a961f738cf4c52e86cd517eaae4197b", + "commit": "29d03f493095899104f2c47ece39b166c5b14568", "adapter": "codex-plugin", "sourcePath": "plugins/oh-my-codex", - "syncedAt": "2026-06-11T08:39:42Z" + "syncedAt": "2026-06-14T16:00:01Z" } diff --git a/plugins/codex/plugins/oh-my-codex/hooks/codex-native-hook.mjs b/plugins/codex/plugins/oh-my-codex/hooks/codex-native-hook.mjs index 77e77c6bb..046b14c00 100644 --- a/plugins/codex/plugins/oh-my-codex/hooks/codex-native-hook.mjs +++ b/plugins/codex/plugins/oh-my-codex/hooks/codex-native-hook.mjs @@ -9,6 +9,7 @@ const hookDir = dirname(fileURLToPath(import.meta.url)); const OMX_PLUGIN_HOOK_LAUNCHER_CONTRACT_MARKER = 'omx-plugin-hook-launcher:v1'; const MAX_WRAPPER_STDIN_BYTES = 1024 * 1024; const RAW_EVENT_SCAN_BYTES = 64 * 1024; +const MAX_STOP_STDOUT_BYTES = 1024 * 1024; const CODEX_HOOK_EVENT_NAMES = new Set([ 'SessionStart', 'PreToolUse', @@ -270,6 +271,19 @@ function hasActiveAutopilotStateForOversizedStop(input) { return shouldContinueAutopilotState(sessionState); } + +function parseSingleJsonObjectOutput(raw) { + const text = String(raw ?? '').trim(); + if (!text) return null; + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return parsed; + } catch { + return null; + } +} + function writeJsonNoop() { process.stdout.write(`${JSON.stringify({})}\n`); process.exitCode = 0; @@ -310,13 +324,33 @@ async function main() { shell: process.platform === 'win32', }); + const stdoutChunks = []; let stdoutBytes = 0; + let bufferedStopStdoutBytes = 0; + let stopStdoutOversized = false; let childSpawnError = null; let childStdinError = null; child.stdout.on('data', (chunk) => { - stdoutBytes += Buffer.byteLength(chunk); - process.stdout.write(chunk); + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stdoutBytes += buffer.byteLength; + if (isStop) { + if (!stopStdoutOversized) { + const remaining = MAX_STOP_STDOUT_BYTES - bufferedStopStdoutBytes; + if (remaining > 0) { + const slice = buffer.subarray(0, remaining); + stdoutChunks.push(slice); + bufferedStopStdoutBytes += slice.byteLength; + } + if (buffer.byteLength > remaining) { + stopStdoutOversized = true; + child.stdout.destroy(); + child.kill(); + } + } + } else { + process.stdout.write(chunk); + } }); child.stderr.pipe(process.stderr); child.stdin.on('error', (error) => { @@ -326,6 +360,23 @@ async function main() { childSpawnError = error; }); child.on('close', (code, signal) => { + if (isStop && stopStdoutOversized) { + writeStopFallback('plugin_stop_hook_launcher_stdout_oversized', `codex-native-hook produced more than ${MAX_STOP_STDOUT_BYTES} bytes of Stop hook stdout`); + return; + } + + if (isStop && stdoutBytes > 0) { + const stdoutText = Buffer.concat(stdoutChunks).toString('utf8'); + const parsed = parseSingleJsonObjectOutput(stdoutText); + if (parsed) { + process.stdout.write(`${JSON.stringify(parsed)}\n`); + process.exitCode = 0; + return; + } + writeStopFallback('plugin_stop_hook_launcher_invalid_stdout', `codex-native-hook produced invalid Stop hook JSON stdout (${stdoutBytes} bytes)`); + return; + } + if (isStop && stdoutBytes === 0) { if (childSpawnError) { writeStopFallback('plugin_stop_hook_launcher_spawn_error', `failed to launch ${command} codex-native-hook: ${childSpawnError.message}`); diff --git a/plugins/codex/plugins/oh-my-codex/skills/best-practice-research/SKILL.md b/plugins/codex/plugins/oh-my-codex/skills/best-practice-research/SKILL.md index 105452cc3..be6fe73e2 100644 --- a/plugins/codex/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +++ b/plugins/codex/plugins/oh-my-codex/skills/best-practice-research/SKILL.md @@ -12,6 +12,10 @@ Use this skill when a task depends on current external best practices, version-a Produce a cited, reusable best-practice answer or handoff that separates current external evidence from repo-local facts and dependency-selection decisions. For pre-planning investigation, this is the ordinary first research wrapper: gather official/upstream evidence, then hand it to `$ralplan` or the caller as planning input. Do not present `$best-practice-research` as a final architecture component or as a validator-gated research loop. +## Terminal By Default + +This skill is terminal and read-only by default. It gathers evidence and produces a cited recommendation with a handoff, then stops. Do not write or edit files, create or amend commits, run mutating commands, or otherwise modify repository state under this skill — even when the question has clear implementation implications. When implementation is warranted, stop and hand off rather than continuing: name `$ralplan` for planning and `$ultragoal`, `$team`, or `executor` for execution, and resume only after the user explicitly switches to that workflow. + ## Activate When - The user asks for best practices, recommended approach, current guidance, official recommendations, standards, or version-aware external behavior. @@ -71,7 +75,7 @@ Produce a cited, reusable best-practice answer or handoff that separates current ### Handoff - + ``` ## Stop Rules @@ -79,5 +83,6 @@ Produce a cited, reusable best-practice answer or handoff that separates current - Stop after a source-backed recommendation is reusable by the caller. - Stop and route upward if the task becomes dependency comparison, broad architecture, or implementation. - Do not continue researching when remaining work would only polish wording rather than change the recommendation. +- This skill never implements. After delivering the recommendation and handoff, stop; do not modify repo files or repo state. Resume only when the user explicitly switches to a planning or implementation workflow named in the handoff. Task: {{ARGUMENTS}} diff --git a/plugins/codex/plugins/ppt-master/README.md b/plugins/codex/plugins/ppt-master/README.md index 1a498dfa1..24bb556a6 100644 --- a/plugins/codex/plugins/ppt-master/README.md +++ b/plugins/codex/plugins/ppt-master/README.md @@ -1,6 +1,6 @@ # PPT Master — AI generates natively editable PPTX from any document -[![Version](https://img.shields.io/badge/version-v2.9.0-blue.svg)](https://github.com/hugohe3/ppt-master/releases) +[![Version](https://img.shields.io/badge/version-v2.10.0-blue.svg)](https://github.com/hugohe3/ppt-master/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![GitHub stars](https://img.shields.io/github/stars/hugohe3/ppt-master.svg)](https://github.com/hugohe3/ppt-master/stargazers) [![AtomGit stars](https://atomgit.com/hugohe3/ppt-master/star/badge.svg)](https://atomgit.com/hugohe3/ppt-master) @@ -8,7 +8,7 @@ English | [中文](./README_CN.md)

- This project is kept free and open source with the support of PackyCode, APIKEY.FUN and other sponsors. + This project is kept free and open source with the support of PackyCode, APIKEY.FUN, RunAPI and other sponsors.

@@ -20,6 +20,10 @@ English | [中文](./README_CN.md) + + + +
APIKEY.FUN Thanks to APIKEY.FUN for sponsoring this project! APIKEY.FUN is a professional enterprise-grade AI relay service committed to stable, efficient, and low-cost AI access for businesses and developers. The platform supports mainstream models including Claude, OpenAI, and Gemini, with prices as low as 7% of official rates. Register through our dedicated link for an exclusive perk: up to 5% off on top-ups, permanently.
RunAPIThanks to RunAPI for sponsoring this project! RunAPI is an efficient and stable API platform — a single API Key gives you access to 150+ leading models, including OpenAI, Claude, Gemini, DeepSeek, and Grok, at prices as low as 10% of official rates, with exceptional stability and seamless compatibility with tools like Claude Code. RunAPI offers an exclusive perk for PPT Master users: register and contact an administrator via our dedicated link to claim ¥7 in free credit.
> [!IMPORTANT] @@ -187,7 +191,7 @@ PPT Master runs in **any tool with agent capability** — read/write files, exec > **Model recommendation**: for the best results, use **Claude Opus** with `gpt-image-2`; **Gemini 3.5 Flash** currently offers great overall value for money — notably fast and well worth a try. -**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.com/register?aff=ppt-master)** and **[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER)** can help — both offer pay-as-you-go access to Claude, GPT, Gemini and more, no subscription required. **PackyCode**: 10% off with promo code **`ppt-master`** at top-up. **APIKEY.FUN**: prices as low as **7% of official rates**; register via our link for an exclusive permanent discount of up to 5% on top-ups. +**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.com/register?aff=ppt-master)**, **[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER)** and **[RunAPI](https://runapi.co/register?aff=WMLJ)** can help — all offer pay-as-you-go access to Claude, GPT, Gemini and more, no subscription required. **PackyCode**: 10% off with promo code **`ppt-master`** at top-up. **APIKEY.FUN**: prices as low as **7% of official rates**; register via our link for an exclusive permanent discount of up to 5% on top-ups. **RunAPI**: 150+ models via one API Key at prices as low as **10% of official rates**; register and contact an administrator to claim **¥7 in free credit**. ### 3. Set Up @@ -336,6 +340,8 @@ PPT Master is currently built and maintained primarily by me. Every new template   APIKEY.FUN   +RunAPIPowered by DigitalOcean **Individual support** diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index 738888df9..1ad07daf8 100644 --- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "ppt-master", "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", - "commit": "a0d62437f12d4f913a45b3acaadd61bba914f52e", + "commit": "34d1f0057d51ff2cc15bcbbec071ef35f6fc1ae1", "adapter": "claude-skill", "sourcePath": "skills/ppt-master", - "syncedAt": "2026-06-13T16:00:00Z" + "syncedAt": "2026-06-14T16:00:01Z" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md index 4700ac94c..27e835a09 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md @@ -294,6 +294,8 @@ Read references/strategist.md This line is required output every run — the user must always see the mode choice exists. Whether to act on it is the user's call. +**Mandatory — spec-refinement note** (not a ninth confirmation): after the split-mode line, you MUST append one short opt-in line (rendered in the user's language, prefixed with 💡) telling the user they may **refine the spec first** — Strategist will produce the full design spec, then stop for review/revision of any part of it before any generation, via the [refine-spec](workflows/refine-spec.md) workflow. Default is OFF: no request → the spec is written in one go and the pipeline auto-proceeds as usual. Only when the user explicitly asks (e.g. "refine the spec first") does the [refine-spec](workflows/refine-spec.md) workflow take over after the Eight Confirmations. This line, like the split-mode line, is required output every run — the user must see the choice exists; whether to act on it is theirs. + **Formula rendering policy lives inside item 7 (Typography plan)**: | Policy | Behavior | @@ -330,6 +332,7 @@ python3 ${SKILL_DIR}/scripts/analyze_images.py /images ## ✅ Strategist Phase Complete - [x] Eight Confirmations completed (user confirmed) - [x] Split-mode note appended below the eight items (heavy or normal variant) +- [x] Spec-refinement opt-in line appended (default OFF; only the user's explicit request enters the refine-spec workflow) - [x] Design Specification & Content Outline generated - [x] Execution lock (spec_lock.md) generated - [ ] **Next**: Auto-proceed to [Image_Generator / Executor] phase diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md index 12d4136d8..a7f9012bf 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md @@ -39,6 +39,8 @@ Resolve the per-page template SVG via `spec_lock.md page_layouts` (authoritative > Note: `page_layouts` disambiguates the multiple content variants modern templates ship (e.g., `graduation_defense` has 8); the legacy table cannot. +**Templates supply structure, not skin (non-mirror)**: a chart or layout template's gradients, drop-shadows, and palette are placeholder. Inherit its geometry, label / legend placement, and series-encoding logic; re-skin every fill / stroke to the deck's `visual_style` + `spec_lock.colors` — flat styles strip the gradients and shadows, gradient / glass styles repaint their own. Forbidden — shipping a template's default `` / `cardShadow` / Tailwind fills unchanged. Mirror templates are the exception: §1.1 preserves their visuals verbatim. + ### 1.1 Mirror-mode templates — reference-style consumption When the project's chosen template is a `mirror` template (`design_spec.md` frontmatter declares `replication_mode: mirror`), Executor switches to a **reference-style** consumption path that bypasses placeholder substitution: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/modes/briefing.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/modes/briefing.md index 3bf3a9f01..bb46a8134 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/modes/briefing.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/modes/briefing.md @@ -10,6 +10,8 @@ Neutral information delivery. Lay the facts out plainly and completely, organize **Topic titles, not assertions**: the page title names its subject plainly ("Q3 headcount by team", "Supported file formats") — clarity for lookup beats a persuasive finding. This is the deliberate inverse of `pyramid`'s assertion titles. +**`core_message` states coverage, not a claim**: when filling `design_spec.md §IX`, write each page's `core_message` as what the page lays out ("Q3 headcount across teams"), not what it proves ("headcount is concentrating in engineering"). The §IX field reads as an assertion under the other modes; under `briefing` it names scope. + **Complete over selective**: include the full reference set the audience needs to scan, not only the points that support a case. Coverage is the value here. **Parallel, even treatment**: sibling items get the same shape and weight so they can be compared and located quickly; nothing is dramatized over its peers unless it genuinely differs. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md index 2313f03e8..c584d2b14 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md @@ -25,6 +25,8 @@ As a top-tier AI presentation strategist, receive source documents, perform cont ⛔ **BLOCKING**: After the read, present professional recommendations for the eight items below as a bundled package and wait for explicit user confirmation. > **Execution discipline**: This is the last BLOCKING checkpoint in the pipeline. After confirmation, complete the Design Spec and proceed to image generation / SVG / post-processing without further pauses. +> +> **One opt-in exception**: present the spec-refinement line alongside the split-mode note (SKILL.md Step 4). It is OFF by default — the above discipline holds unchanged. Only when the user *explicitly* asks to refine the spec do you hand off to the [refine-spec](../workflows/refine-spec.md) workflow, which produces the full spec first and stops for user review/revision of any part before generation. Never enter it unprompted. ### a. Canvas Format Confirmation @@ -94,6 +96,14 @@ Proactively provide a color scheme (HEX values) based on content characteristics **Color rules**: 60-30-10 rule (primary 60%, secondary 30%, accent 10%); text contrast ratio >= 4.5:1; no more than 4 colors per page. +**Lock the full neutral set the visual style implies** — not just primary / secondary / accent / border. Predict the extra neutral tiers the locked `visual_style` (§d Layer 2) needs and lock them now; `spec_lock.colors` must be complete before generation, and the Executor draws only from it (never invents a tone mid-deck). + +| Style trait | Extra neutral tiers to lock | +|---|---| +| Layers panels / charts (e.g. `data-journalism`, `swiss-minimal`) | `surface` (panel lift), `grid` (hairline, lighter than dividers) | +| Text over imagery / dark field (e.g. `photo-editorial`, `glassmorphism`, `dark-tech`) | `scrim` / `overlay` for legibility | +| Print / hand-drawn fills (e.g. `chalkboard`, `zine`) | `block-shade`, one step off the field | + ### f. Icon Usage Confirmation | Option | Approach | Suitable Scenarios | diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md index e795158a3..acadc31fe 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md @@ -41,7 +41,7 @@ pip install PyMuPDF Hybrid converter: pure-Python for the common formats, pandoc fallback for the rest. Native path (no external binary required): -- `.docx` — via `mammoth` +- `.docx` — via `mammoth`; OMML / Office Math equations (Word-native or MathType "Convert to Office Math") are rewritten to inline LaTeX. Classic MathType OLE objects carry no OMML and are kept only as their preview image. - `.html` / `.htm` — via `markdownify` + `beautifulsoup4` - `.epub` — via `ebooklib` + `markdownify` - `.ipynb` — via `nbconvert` diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py index 36c9c3567..a36b14af2 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py @@ -3,7 +3,7 @@ Document to Markdown Converter (hybrid Python + Pandoc fallback) Primary formats (pure Python, no external tools required): - .docx → mammoth + .docx → mammoth (OMML/Office Math equations rewritten to inline LaTeX) .html → markdownify + BeautifulSoup .epub → ebooklib + markdownify .ipynb → nbconvert @@ -27,6 +27,7 @@ import shutil import subprocess import sys import tempfile +import uuid import zipfile from pathlib import Path from urllib.parse import unquote, urlparse @@ -64,8 +65,24 @@ DOCX_NS = { "v": "urn:schemas-microsoft-com:vml", "o": "urn:schemas-microsoft-com:office:office", "mc": "http://schemas.openxmlformats.org/markup-compatibility/2006", + "m": "http://schemas.openxmlformats.org/officeDocument/2006/math", } EMU_PER_INCH = 914400 +MATH_NS = DOCX_NS["m"] +W_NS = DOCX_NS["w"] +XML_SPACE_ATTR = "{http://www.w3.org/XML/1998/namespace}space" + +# OMML n-ary operator chars (m:nary/m:naryPr/m:chr) → LaTeX command. +NARY_OPS = { + "∑": r"\sum", "∏": r"\prod", "∐": r"\coprod", + "∫": r"\int", "∬": r"\iint", "∭": r"\iiint", "∮": r"\oint", + "⋃": r"\bigcup", "⋂": r"\bigcap", "⋁": r"\bigvee", "⋀": r"\bigwedge", +} +# OMML accent chars (m:acc/m:accPr/m:chr) → LaTeX command. +ACCENT_CMDS = { + "̂": r"\hat", "̃": r"\tilde", "̄": r"\bar", "→": r"\vec", "⃗": r"\vec", + "̇": r"\dot", "̈": r"\ddot", "̌": r"\check", "́": r"\acute", "̀": r"\grave", +} # ───────────────────────────────────────────────────────────── @@ -378,6 +395,226 @@ def _manifest_entry( return entry +# ───────────────────────────────────────────────────────────── +# OMML (Office Math) → LaTeX +# ───────────────────────────────────────────────────────────── +# +# mammoth drops all math content, so Word-native equations and MathType +# formulas saved as Office Math (OMML) vanish from the output. This pure-Python +# converter rewrites each into inline `$...$` LaTeX before mammoth +# runs, so formulas survive into the Markdown in document order. +# +# Scope: OMML only. Classic MathType OLE objects (Equation.DSMT4 / MTEF binary) +# carry no OMML — they expose only a WMF/EMF preview image, which mammoth still +# emits as a picture. Decoding MTEF is out of scope. + +def _m_child(elem: ET.Element, name: str) -> ET.Element | None: + """Return the first OMML child with the given local name.""" + for child in elem: + if _local_name(child) == name: + return child + return None + + +def _m_pr_val(elem: ET.Element, prop: str) -> str | None: + """Return m:val of a property inside the element's *Pr block (e.g. chr).""" + for child in elem: + if not _local_name(child).endswith("Pr"): + continue + for sub in child: + if _local_name(sub) == prop: + return sub.get(f"{{{MATH_NS}}}val") + return None + + +def _brace(latex: str) -> str: + """Wrap multi-char LaTeX in braces so it binds as one super/subscript arg.""" + return latex if len(latex) <= 1 else "{" + latex + "}" + + +def _omml_part(elem: ET.Element, name: str) -> str: + """Convert a named OMML child (e/num/den/sup/sub/...) to LaTeX.""" + child = _m_child(elem, name) + return _omml_to_latex(child) if child is not None else "" + + +def _omml_run(elem: ET.Element) -> str: + """Concatenate text from an OMML run, skipping property children.""" + return "".join( + c.text or "" for c in elem if _local_name(c) == "t" + ) + + +def _omml_children(elem: ET.Element) -> str: + """Convert all non-property children in order (default/passthrough rule).""" + return "".join( + _omml_to_latex(c) for c in elem if not _local_name(c).endswith("Pr") + ) + + +def _omml_matrix(elem: ET.Element, *, environment: str) -> str: + """Convert a matrix (m:m) or equation array (m:eqArr) to a LaTeX env.""" + rows: list[str] = [] + for row in elem: + if _local_name(row) not in ("mr", "e"): + continue + if _local_name(row) == "e": # eqArr stores rows as bare + rows.append(_omml_to_latex(row)) + continue + cells = [_omml_to_latex(cell) for cell in row if _local_name(cell) == "e"] + rows.append(" & ".join(cells)) + body = r" \\ ".join(rows) + return rf"\begin{{{environment}}} {body} \end{{{environment}}}" + + +def _omml_to_latex(elem: ET.Element) -> str: + """Recursively convert one OMML element subtree to a LaTeX string. + + Unknown elements degrade to a concatenation of their children rather than + being dropped, so rare constructs lose markup but never lose content. + """ + local = _local_name(elem) + + if local == "t": + return elem.text or "" + if local == "r": + return _omml_run(elem) + if local in ("oMath", "oMathPara", "e", "num", "den", "sup", "sub", + "deg", "fName", "lim", "box", "borderBox"): + return _omml_children(elem) + if local == "sSup": + return _brace(_omml_part(elem, "e")) + "^" + _brace(_omml_part(elem, "sup")) + if local == "sSub": + return _brace(_omml_part(elem, "e")) + "_" + _brace(_omml_part(elem, "sub")) + if local == "sSubSup": + return (_brace(_omml_part(elem, "e")) + + "_" + _brace(_omml_part(elem, "sub")) + + "^" + _brace(_omml_part(elem, "sup"))) + if local == "sPre": + return ("{}_" + _brace(_omml_part(elem, "sub")) + + "^" + _brace(_omml_part(elem, "sup")) + + _brace(_omml_part(elem, "e"))) + if local == "f": + return r"\frac{" + _omml_part(elem, "num") + "}{" + _omml_part(elem, "den") + "}" + if local == "rad": + deg = _m_child(elem, "deg") + body = _omml_part(elem, "e") + deg_latex = _omml_to_latex(deg) if deg is not None and len(deg) else "" + return rf"\sqrt[{deg_latex}]{{{body}}}" if deg_latex else rf"\sqrt{{{body}}}" + if local == "d": + beg = _m_pr_val(elem, "begChr") + end = _m_pr_val(elem, "endChr") + beg = "(" if beg is None else (beg or ".") + end = ")" if end is None else (end or ".") + inner = "".join(_omml_to_latex(c) for c in elem if _local_name(c) == "e") + return rf"\left{beg}{inner}\right{end}" + if local == "nary": + chr_ = _m_pr_val(elem, "chr") or "∫" + op = NARY_OPS.get(chr_, chr_) + sub, sup = _m_child(elem, "sub"), _m_child(elem, "sup") + out = op + if sub is not None and len(sub): + out += "_" + _brace(_omml_to_latex(sub)) + if sup is not None and len(sup): + out += "^" + _brace(_omml_to_latex(sup)) + return out + _brace(_omml_part(elem, "e")) + if local == "func": + return "\\" + _omml_part(elem, "fName").strip() + _brace(_omml_part(elem, "e")) + if local == "limLow": + return _brace(_omml_part(elem, "e")) + "_" + _brace(_omml_part(elem, "lim")) + if local == "limUpp": + return _brace(_omml_part(elem, "e")) + "^" + _brace(_omml_part(elem, "lim")) + if local == "bar": + cmd = r"\underline" if _m_pr_val(elem, "pos") == "bot" else r"\overline" + return cmd + "{" + _omml_part(elem, "e") + "}" + if local == "acc": + cmd = ACCENT_CMDS.get(_m_pr_val(elem, "chr") or "̂", r"\hat") + return cmd + "{" + _omml_part(elem, "e") + "}" + if local == "groupChr": + return _omml_part(elem, "e") + if local == "m": + return _omml_matrix(elem, environment="matrix") + if local == "eqArr": + return _omml_matrix(elem, environment="aligned") + + return _omml_children(elem) + + +def _make_text_run(text: str) -> ET.Element: + """Build a text element.""" + run = ET.Element(f"{{{W_NS}}}r") + t = ET.SubElement(run, f"{{{W_NS}}}t") + t.set(XML_SPACE_ATTR, "preserve") + t.text = text + return run + + +def _docx_inject_math_latex( + input_file: Path, +) -> tuple[Path, dict[str, str]] | None: + """Replace OMML equations with alphanumeric placeholders in a temp DOCX. + + Returns ``(temp_file, {placeholder: latex})`` or None when the document has + no OMML math. Placeholders are plain ``[A-Za-z0-9]`` tokens so mammoth never + markdown-escapes the LaTeX; the caller swaps each token for its `$...$` value + after mammoth has produced the Markdown. + """ + try: + with zipfile.ZipFile(input_file) as docx: + document_xml = docx.read("word/document.xml") + except (KeyError, zipfile.BadZipFile, OSError): + return None + try: + root = ET.fromstring(document_xml) + except ET.ParseError: + return None + + parent_map = {child: parent for parent in root.iter() for child in parent} + targets: list[tuple[ET.Element, bool]] = [] + for elem in root.iter(): + local = _local_name(elem) + if local == "oMathPara": + targets.append((elem, True)) + elif local == "oMath": + parent = parent_map.get(elem) + if parent is None or _local_name(parent) != "oMathPara": + targets.append((elem, False)) + if not targets: + return None + + token_base = uuid.uuid4().hex + replacements: dict[str, str] = {} + for index, (elem, display) in enumerate(targets): + parent = parent_map.get(elem) + if parent is None: + continue + latex = _omml_to_latex(elem).strip() + position = list(parent).index(elem) + parent.remove(elem) + if latex: + token = f"MATHEQ{token_base}{index:04d}" + delim = "$$" if display else "$" + replacements[token] = f"{delim}{latex}{delim}" + parent.insert(position, _make_text_run(token)) + if not replacements: + return None + + for prefix, uri in DOCX_NS.items(): + if prefix != "rel": + ET.register_namespace(prefix, uri) + patched_xml = ET.tostring(root, encoding="utf-8", xml_declaration=True) + + tmp = tempfile.NamedTemporaryFile(suffix=".docx", delete=False) + tmp.close() + out_path = Path(tmp.name) + with zipfile.ZipFile(input_file) as zin, \ + zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zout: + for item in zin.infolist(): + data = patched_xml if item.filename == "word/document.xml" else zin.read(item.filename) + zout.writestr(item, data) + return out_path, replacements + + # ───────────────────────────────────────────────────────────── # DOCX → Markdown (mammoth) # ───────────────────────────────────────────────────────────── @@ -434,13 +671,31 @@ def _convert_docx(input_file: Path, out_file: Path) -> str: )) return {"src": f"{rel_media_dir}/{filename}"} - with input_file.open("rb") as f: - result = mammoth.convert_to_markdown( - f, - convert_image=mammoth.images.img_element(_save_image), - ) + # Rewrite OMML equations to LaTeX placeholders before mammoth (which would + # otherwise drop them); the placeholders are swapped back below. + math_injection = _docx_inject_math_latex(input_file) + if math_injection is not None: + math_file, math_replacements = math_injection + else: + math_file, math_replacements = None, {} + mammoth_source = math_file or input_file + try: + with mammoth_source.open("rb") as f: + result = mammoth.convert_to_markdown( + f, + convert_image=mammoth.images.img_element(_save_image), + ) + finally: + if math_file is not None: + try: + math_file.unlink() + except OSError: + pass - markdown = _html_img_to_md(result.value) + markdown = result.value + for token, latex in math_replacements.items(): + markdown = markdown.replace(token, latex) + markdown = _html_img_to_md(markdown) out_file.write_text(markdown, encoding="utf-8") if manifest: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/embed_icons.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/embed_icons.py index b915c11f2..e6bd56775 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/embed_icons.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/embed_icons.py @@ -182,6 +182,13 @@ def parse_use_element(use_match: str) -> dict[str, str | float]: if fill_match: attrs['fill'] = fill_match.group(1) + # Stroke-style icons may be authored with natural SVG semantics: + # fill="none" stroke="#HEX". Keep accepting fill as the canonical color + # carrier, but preserve stroke so outline icons do not collapse to none. + stroke_match = re.search(r'stroke="([^"]+)"', use_match) + if stroke_match: + attrs['stroke'] = stroke_match.group(1) + # Live preview direct edits may write an absolute transform matrix back to # the placeholder. Preserve it so the expanded icon matches the edited # browser geometry instead of falling back to the original x/y placement. @@ -198,6 +205,25 @@ def parse_use_element(use_match: str) -> dict[str, str | float]: return attrs +def resolve_icon_color(attrs: dict[str, str | float], style: str) -> str: + """Resolve the caller-provided color for fill or stroke icon libraries.""" + fill = str(attrs.get('fill', '')).strip() + stroke = str(attrs.get('stroke', '')).strip() + + if style == 'stroke': + if fill and fill != 'none': + return fill + if stroke and stroke != 'none': + return stroke + return '#000000' + + if fill: + return fill + if stroke and stroke != 'none': + return stroke + return '#000000' + + def generate_icon_group(attrs: dict[str, str | float], elements: list[str], style: str, base_size: float) -> str: """ Generate the icon's element. @@ -215,7 +241,7 @@ def generate_icon_group(attrs: dict[str, str | float], elements: list[str], styl y = attrs.get('y', 0) width = attrs.get('width', base_size) height = attrs.get('height', base_size) - color = attrs.get('fill', '#000000') + color = resolve_icon_color(attrs, style) icon_name = attrs.get('icon', 'unknown') scale_x = width / base_size @@ -290,8 +316,8 @@ def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, ver continue icon_path, _ = resolve_icon_path(str(icon_name), icons_dir) - color = str(attrs.get('fill', '#000000')) - elements, style, base_size = extract_paths_from_icon(icon_path, color) + elements, style, base_size = extract_paths_from_icon(icon_path) + color = resolve_icon_color(attrs, style) if not elements: print(f"[WARN] Icon not found: {icon_name} (in {svg_path.name})") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py index 94e0401cf..c5c4bbc6e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py @@ -55,6 +55,13 @@ SVG_NS = "http://www.w3.org/2000/svg" RAMP_MIN_RATIO = 0.5 RAMP_MAX_RATIO = 5.0 +# Modes / visual styles that legitimately use unbounded hero / poster type +# (huge cover numerals, act dividers, single-number reveals). For these the +# size-drift upper bound is dropped — the oversize is the design, not Executor +# drift. The lower bound still applies. +POSTER_SIZE_MODES = {'showcase'} +POSTER_SIZE_STYLES = {'zine'} + def _design_spec_is_brand(spec_path: Path) -> bool: """Return True when a design_spec.md frontmatter declares ``kind: brand``. @@ -735,7 +742,7 @@ class SVGQualityChecker: if typo: default_font = typo.get('font_family', '').strip() if default_font: - allowed_fonts.add(default_font) + allowed_fonts.add(self._normalize_font_stack(default_font)) for k, v in typo.items(): if k == 'font_family' or not k.endswith('_family'): continue @@ -743,7 +750,7 @@ class SVGQualityChecker: # Skip placeholder text like "same as body (omit if identical)" if not v_clean or v_clean.lower().startswith('same as'): continue - allowed_fonts.add(v_clean) + allowed_fonts.add(self._normalize_font_stack(v_clean)) # Sizes: declared slots are anchors; body is the ramp baseline. allowed_sizes = set() @@ -768,22 +775,30 @@ class SVGQualityChecker: color_drifts.add(val) font_drifts = set() - for m in re.finditer(r'font-family\s*=\s*["\']([^"\']+)["\']', content): - val = m.group(1).strip() - if allowed_fonts and val not in allowed_fonts: + # Capture to the matching delimiter (group 1) so a double-quoted stack + # containing single-quoted family names is not truncated at the inner quote. + for m in re.finditer(r'font-family\s*=\s*(["\'])(.*?)\1', content): + val = m.group(2).strip() + if allowed_fonts and self._normalize_font_stack(val) not in allowed_fonts: font_drifts.add(val) + # Poster / showcase contexts use unbounded hero type — drop the ceiling. + mode = (lock.get('mode', {}).get('mode') or '').strip().lower() + vstyle = (lock.get('visual_style', {}).get('visual_style') or '').strip().lower() + max_ratio = (float('inf') if mode in POSTER_SIZE_MODES or vstyle in POSTER_SIZE_STYLES + else RAMP_MAX_RATIO) + size_drifts = set() for m in re.finditer(r'font-size\s*=\s*["\']([^"\']+)["\']', content): val = self._normalize_size(m.group(1)) if not allowed_sizes or val in allowed_sizes: continue # Intermediate values are allowed when they sit inside the ramp - # envelope (ratio to body within [RAMP_MIN_RATIO, RAMP_MAX_RATIO]). + # envelope (ratio to body within [RAMP_MIN_RATIO, max_ratio]). if body_px and body_px > 0: try: ratio = float(val) / body_px - if RAMP_MIN_RATIO <= ratio <= RAMP_MAX_RATIO: + if RAMP_MIN_RATIO <= ratio <= max_ratio: continue except ValueError: pass @@ -886,6 +901,15 @@ class SVGQualityChecker: v = v[:-2].strip() return v + @staticmethod + def _normalize_font_stack(stack: str) -> str: + """Normalize a font-family stack for comparison: split on commas, strip + quotes / whitespace, lowercase, rejoin. Collapses cosmetic differences + (comma spacing, single vs double quotes, case) so that + `Consolas,'Courier New',monospace` matches `Consolas, "Courier New", monospace`.""" + parts = [p.strip().strip('"\'').lower() for p in stack.split(',')] + return ','.join(p for p in parts if p) + def _categorize_issue(self, error_msg: str) -> str: """Categorize issue type""" if 'Invalid XML' in error_msg: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/analyzer.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/analyzer.py index 03e32632a..d42960994 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/analyzer.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/analyzer.py @@ -143,6 +143,29 @@ def _canvas_px(pres_root: ET.Element) -> dict[str, int | None]: } +def _fill_risk(tables: list[dict[str, Any]], charts: list[dict[str, Any]]) -> dict[str, Any] | None: + """Return a fill_risk descriptor when the slide has non-text content that text-fill cannot replace. + + Only tables and charts are checked; no new dependencies are introduced. + Returns None when the slide has no such content. + """ + kinds: list[str] = [] + if tables: + kinds.append("table") + if charts: + kinds.append("chart") + if not kinds: + return None + kind_str = "/".join(kinds) + return { + "has_non_text_content": True, + "reason": ( + f"has non-text content ({kind_str}) that text-fill cannot replace; " + "provide table_edits/chart_edits or pick another source slide" + ), + } + + def analyze_pptx(pptx_path: Path) -> dict[str, Any]: """Extract a slide library with text replacement slots.""" with zipfile.ZipFile(pptx_path) as zf: @@ -180,16 +203,18 @@ def analyze_pptx(pptx_path: Path) -> dict[str, Any]: tables = _analyze_tables(slide_root, slide_ref.index) charts = _analyze_charts(zf, slide_root, slide_ref) slide_text = "\n".join(slot["text"] for slot in slots if slot["text"]) - slides.append( - { - "slide_index": slide_ref.index, - "page_type": _classify_page_type(slide_ref.index, len(slide_refs), slide_text, slots), - "text_summary": slide_text[:500], - "slots": slots, - "tables": tables, - "charts": charts, - } - ) + slide: dict[str, Any] = { + "slide_index": slide_ref.index, + "page_type": _classify_page_type(slide_ref.index, len(slide_refs), slide_text, slots), + "text_summary": slide_text[:500], + "slots": slots, + "tables": tables, + "charts": charts, + } + risk = _fill_risk(tables, charts) + if risk is not None: + slide["fill_risk"] = risk + slides.append(slide) return { "schema": "template_fill_pptx_library.v1", diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py index 901846be5..5d452fe56 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py @@ -203,6 +203,11 @@ def _capacity_for_report( return _display_width(max(capacity, old_width)) +def _library_slide_index(library: dict[str, Any]) -> dict[int, dict[str, Any]]: + """Build a mapping from slide_index to slide dict for O(1) lookup.""" + return {int(s.get("slide_index", 0)): s for s in library.get("slides", [])} + + def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: """Compare fill replacements against source slot capacity.""" lookup = _slot_lookup(library) @@ -427,6 +432,85 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: "message": "chart edit target and data shape are valid", } ) + # --- Guardrail 2: source slides with non-text content not covered by edits --- + # For each plan slide, if the source slide has tables/charts in the library + # but the plan slide provides no matching table_edits/chart_edits, warn that + # text-fill will silently leave the original template content in place. + lib_slides = _library_slide_index(library) + for plan_slide_index, slide in enumerate(plan.get("slides", []), start=1): + source_slide = int(slide.get("source_slide", 0)) + lib_slide = lib_slides.get(source_slide) + if lib_slide is None: + continue + lib_tables = lib_slide.get("tables", []) + lib_charts = lib_slide.get("charts", []) + if not lib_tables and not lib_charts: + continue + # Check whether the plan slide provides edits covering the non-text content. + has_table_edits = bool(slide.get("table_edits")) + has_chart_edits = bool(slide.get("chart_edits")) + uncovered_kinds: list[str] = [] + if lib_tables and not has_table_edits: + uncovered_kinds.append("table") + if lib_charts and not has_chart_edits: + uncovered_kinds.append("chart") + if not uncovered_kinds: + continue + kind_str = "/".join(uncovered_kinds) + summary["warn"] += 1 + results.append( + { + "status": "WARN", + "plan_slide": plan_slide_index, + "source_slide": source_slide, + "message": ( + f"source slide {source_slide} has non-text content ({kind_str}) " + "with no matching edits in the plan; text-fill leaves it untouched " + "and original template content may show through " + "(add table_edits/chart_edits, or pick another source slide)" + ), + } + ) + + # --- Guardrail 1: same source slide reused too many times while unused layouts exist --- + # Use a relative condition rather than an absolute threshold: only warn when + # (a) a source slide is reused >= REUSE_WARN_THRESHOLD times, AND + # (b) there are library slides that the plan never uses at all. + # Rationale: a small template where every layout is referenced is fine even at + # high per-slide reuse; "15-page template where only 1 page is ever cloned and + # the rest sit idle" is the real smell we want to surface. + # Threshold of 3: any source appearing 3+ times in a plan is meaningful reuse + # (cover / TOC / ending typically appear at most twice), so >= 3 is a practical + # signal without being overly sensitive. + REUSE_WARN_THRESHOLD = 3 + source_use_counts: dict[int, int] = {} + for slide in plan.get("slides", []): + src = int(slide.get("source_slide", 0)) + if src: + source_use_counts[src] = source_use_counts.get(src, 0) + 1 + all_lib_indices = {int(s.get("slide_index", 0)) for s in library.get("slides", []) if s.get("slide_index")} + used_lib_indices = set(source_use_counts.keys()) + unused_lib_indices = sorted(all_lib_indices - used_lib_indices) + if unused_lib_indices: + for src, count in sorted(source_use_counts.items()): + if count >= REUSE_WARN_THRESHOLD: + unused_list = ", ".join(str(i) for i in unused_lib_indices) + summary["warn"] += 1 + results.append( + { + "status": "WARN", + "source_slide": src, + "reuse_count": count, + "unused_source_slides": unused_lib_indices, + "message": ( + f"source slide {src} is reused {count} times while " + f"{len(unused_lib_indices)} source layout(s) are never used " + f"(indices: {unused_list}); " + "consider using other layouts for more variety" + ), + } + ) + return {"schema": "template_fill_pptx_check.v1", "summary": summary, "results": results} @@ -441,10 +525,13 @@ def print_check_report(report: dict[str, Any]) -> None: "{status} P{plan_slide:02d} source={source_slide} {slot_id} " "{role} old={old_len} new={new_len} ratio={ratio}: {message}".format(**item) ) - else: + elif "plan_slide" in item: target = item.get("slot_id") or item.get("selector") or "" line = ( f"{item['status']} P{item['plan_slide']:02d} " f"source={item['source_slide']} {target}: {item['message']}".strip() ) + else: + # Guardrail 1 WARNs are source-level (no plan_slide); print source + message. + line = f"{item['status']} source={item.get('source_slide', '?')}: {item['message']}" print(line) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/refine-spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/refine-spec.md new file mode 100644 index 000000000..c8fec3a80 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/refine-spec.md @@ -0,0 +1,58 @@ +--- +description: Opt-in spec-refinement loop between the Eight Confirmations and Image/Executor. Triggered only when the user explicitly asks; the Strategist produces the full design spec, then HARD STOPS so the user can review and revise any part of it before the pipeline continues. +--- + +# Refine Spec Workflow + +> Standalone, **opt-in** spec-review pass. The default pipeline writes `design_spec.md` + `spec_lock.md` and auto-proceeds. When the user explicitly asks to refine the spec, the Strategist produces the full spec first, then **stops** — the user reviews and revises any part of it (outline, color, typography, layout, image strategy, page rhythm, …) before any image generation or SVG work begins. + +This workflow is **conditional**, same shape as the split-mode choice: it never fires on its own and the default path is unchanged. The Eight Confirmations settle design directions up front as abstract recommendations; this pass lets the user revise the **concrete spec** the Strategist produced from them. It is most valuable for a zero-background user, who can judge a finished spec far better than the up-front recommendations — and the spec's content outline (`§IX`) is usually what they most want to adjust. + +## When to Run + +The user **explicitly asks** to refine / review / revise the spec before generation. Recognize any of: + +| Pattern | Example | +|---|---| +| "refine the spec / review the spec first" | "produce the spec first, let me review before slides" | +| "let me revise the spec, then continue" | "send me the spec to confirm, I'll edit it" | +| Any request to inspect/iterate the design spec before generation | "draft the full plan, I want to adjust it, then generate" | + +**Default is OFF.** Strategist surfaces this option as one short opt-in line inside the Eight Confirmations bundle (see SKILL.md Step 4). No request → the spec is written in one go and the pipeline auto-proceeds as usual; this workflow never starts. + +**Prerequisite**: the Eight Confirmations are settled (mode + visual style + the rest). This pass revises the spec the confirmations produced; it does not re-open the confirmation bundle itself. + +--- + +## Step 1: Produce the full spec + +Run the default Strategist output exactly as SKILL.md Step 4 specifies: write `design_spec.md` (§I–XI) and `spec_lock.md`. Read the relevant `sources/` files so the content outline (`§IX`) carries real facts, not skeleton points. Nothing special here — this is the normal spec, just produced under the knowledge that the user is about to review it. + +--- + +## Step 2: ⛔ HARD STOP — present, discuss, and revise + +Present the produced spec to the user and **wait for explicit revision or approval before doing anything else**. This is a conditional BLOCKING point that exists only on this opt-in path; the default pipeline keeps its "auto-proceed after the Eight Confirmations" discipline untouched. + +The user may revise **any part of the spec**, not just the outline — content outline, color, typography, layout, icon plan, image strategy, page rhythm. Discuss in **prose**; do not emit a scored rubric or per-axis grades (mechanical scorecards are against project convention). When useful, point out things worth a second look — but let the user drive. + +**Reference — review lenses, not a checklist or score**: raise these in plain language to surface what is worth discussing. They name a *direction*, never a number — never convert any into HEX values, px sizes, ratios, page quotas, or grades. + +- *Outline*: logical clarity (do the points build on each other), information density (right amount per page — nothing padded or crammed), focus (each page lands one idea), register (spoken vs formal, matched to the audience), emotional resonance (a hook to open, a payoff to close), chapter balance (page budget not lopsided). +- *Color*: does the scheme fit the content's mood and audience, and is there enough hierarchy and contrast to read comfortably — not which exact HEX. +- *Typography*: do title and body form a clear contrast or a clean concord, is the size hierarchy legible, does the type character match the visual style — not which px. +- *Layout*: does structure follow each page's information weight, or does it fall back to one uniform symmetric grid (the "AI-generated" look). +- *Icon / image*: one consistent icon character throughout; images that serve the content (hero / atmosphere used on purpose) rather than decorate. +- *Page rhythm*: do `anchor` / `dense` / `breathing` track the narrative, or is everything flatly dense. + +These overlap with what the locked `mode`, visual style, and §6.1 already shape — treat them as discussion angles to surface what is worth talking about, not a second pass to redo. + +**Keep both files in sync on every change.** Any revision the user approves must land in both `design_spec.md` and `spec_lock.md`; on divergence `spec_lock.md` wins (see [`strategist.md`](../references/strategist.md) §6.2). Iterate as many rounds as the user wants. The loop ends only when the user explicitly approves the spec. + +--- + +## Step 3: Hand back + +Once the user approves, `design_spec.md` and `spec_lock.md` both reflect the final, revised state. Return to SKILL.md and continue normally: Step 5 (Image Acquisition, if any `ai` / `web` rows) or Step 6 (Executor). + +> Note: this workflow does NOT duplicate Strategist content. It only inserts a review-and-revise checkpoint between spec production and the rest of the pipeline. `strategist.md` / SKILL.md remain authoritative for how the spec is written.