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:
@@ -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}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PPT Master — AI generates natively editable PPTX from any document
|
||||
|
||||
[](https://github.com/hugohe3/ppt-master/releases)
|
||||
[](https://github.com/hugohe3/ppt-master/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/hugohe3/ppt-master/stargazers)
|
||||
[](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
|
||||
|
||||
<a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" height="40" /></a>
|
||||
|
||||
<a href="https://runapi.co/register?aff=WMLJ"><img src="docs/assets/sponsors/runapi.png" alt="RunAPI" height="40" /></a>
|
||||
|
||||
<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`
|
||||
|
||||
+262
-7
@@ -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:
|
||||
|
||||
+29
-3
@@ -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:
|
||||
|
||||
+35
-10
@@ -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",
|
||||
|
||||
+88
-1
@@ -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` (§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.
|
||||
Reference in New Issue
Block a user