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.
This commit is contained in:
KeyInfo Bot
2026-06-15 00:01:16 +08:00
parent 3f3384b292
commit 708d620e7d
20 changed files with 622 additions and 68 deletions
+12 -12
View File
@@ -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": {
@@ -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",
@@ -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"
}
@@ -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}`);
@@ -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
<what this research does not decide>
### Handoff
<planning/execution/test implications>
<planning/execution/test implications; name the next workflow — `$ralplan` for planning, `$ultragoal`/`$team`/`executor` for execution — and note that this skill stops here unless the user explicitly switches workflows>
```
## 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}}
+9 -3
View File
@@ -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)
<p align="center">
<sub>This project is kept free and open source with the support of <a href="https://www.packyapi.com/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fun/register?aff=PPT-MASTER">APIKEY.FUN</a> and other sponsors.</sub>
<sub>This project is kept free and open source with the support of <a href="https://www.packyapi.com/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fun/register?aff=PPT-MASTER">APIKEY.FUN</a>, <a href="https://runapi.co/register?aff=WMLJ">RunAPI</a> and other sponsors.</sub>
</p>
<table>
@@ -20,6 +20,10 @@ English | [中文](./README_CN.md)
<td width="180"><a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>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 <strong>7% of official rates</strong>. Register through <a href="https://apikey.fun/register?aff=PPT-MASTER">our dedicated link</a> for an exclusive perk: <strong>up to 5% off on top-ups, permanently</strong>.</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co/register?aff=WMLJ"><img src="docs/assets/sponsors/runapi.png" alt="RunAPI" width="150"></a></td>
<td>Thanks 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 <strong>10% of official rates</strong>, 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 <a href="https://runapi.co/register?aff=WMLJ">our dedicated link</a> to claim <strong>¥7 in free credit</strong>.</td>
</tr>
</table>
> [!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
&nbsp;
<a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" height="40" /></a>
&nbsp;
<a href="https://runapi.co/register?aff=WMLJ"><img src="docs/assets/sponsors/runapi.png" alt="RunAPI" height="40" /></a>
&nbsp;
<a href="https://m.do.co/c/547f129aabe1"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" alt="Powered by DigitalOcean" height="40" /></a>
**Individual support**
@@ -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"
}
@@ -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 <project_path>/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
@@ -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 `<linearGradient>` / `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:
@@ -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.
@@ -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 |
@@ -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`
@@ -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 <m:oMath> 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 <m:e>
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 <w:r><w:t xml:space="preserve">text</w:t></w:r> 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:
@@ -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 <g> 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})")
@@ -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:
@@ -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",
@@ -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)
@@ -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` (§IXI) 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.