From 7f2f7fc46d8688531e803f89a0dad0396e8b6396 Mon Sep 17 00:00:00 2001 From: KeyInfo Bot Date: Sat, 27 Jun 2026 00:01:09 +0800 Subject: [PATCH] Sync third-party and MCP 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. --- config/external-sources.lock.json | 16 +- .../plugins/mcp-playwright/MCP_SOURCE.json | 2 +- .../next-skills/THIRD_PARTY_SOURCE.json | 4 +- .../next-skills/skills/next-dev-loop/SKILL.md | 103 ++- .../ppt-master/THIRD_PARTY_SOURCE.json | 4 +- .../ppt-master/skills/ppt-master/SKILL.md | 5 + .../skills/ppt-master/scripts/README.md | 18 +- .../ppt-master/scripts/native_enhance_pptx.py | 37 + .../scripts/native_narration_pptx.py | 862 ++++++++++++++++++ .../scripts/svg_finalize/flatten_tspan.py | 79 +- .../ppt-master/scripts/svg_quality_checker.py | 60 ++ .../scripts/svg_to_pptx/drawingml_elements.py | 63 +- .../scripts/svg_to_pptx/pptx_narration.py | 12 +- .../ppt-master/scripts/template_fill_pptx.py | 3 +- .../scripts/template_fill_pptx/__init__.py | 7 +- .../scripts/template_fill_pptx/checker.py | 16 + .../scripts/template_fill_pptx/cli.py | 28 +- .../scripts/template_fill_pptx/scaffolder.py | 7 + .../scripts/template_fill_pptx/validator.py | 278 ++++++ .../workflows/native-enhance-pptx.md | 303 ++++++ .../workflows/native-narration-pptx.md | 35 + .../workflows/template-fill-pptx.md | 47 +- .../plugins/shadcn/THIRD_PARTY_SOURCE.json | 4 +- plugins/codex/plugins/ui-ux-pro-max/README.md | 24 +- .../ui-ux-pro-max/THIRD_PARTY_SOURCE.json | 4 +- .../skills/ui-ux-pro-max/SKILL.md | 33 +- 26 files changed, 1948 insertions(+), 106 deletions(-) create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx.py create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_narration_pptx.py create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/validator.py create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-enhance-pptx.md create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-narration-pptx.md diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index de1f232e3..8603325c5 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -24,8 +24,8 @@ "repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git", "ref": "main", "adapter": "claude-skill", - "commit": "318e0b2e4069b578c729228455328a1ca88f640d", - "syncedAt": "2026-06-25T16:00:00Z" + "commit": "9fd25fe07e46ae444edc356e62fe913347ab9e23", + "syncedAt": "2026-06-26T15:59:59Z" }, { "id": "shadcn", @@ -33,8 +33,8 @@ "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", "adapter": "claude-skill", - "commit": "35983528c233250b990f6172f1a60df228409a37", - "syncedAt": "2026-06-25T16:00:00Z" + "commit": "c520191cd4c36760ed425b650e139fe6d7e29038", + "syncedAt": "2026-06-26T15:59:59Z" }, { "id": "frontend-slides", @@ -69,8 +69,8 @@ "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", "adapter": "claude-skill", - "commit": "850ad1be2474f5d8930fa2b61105453b99391600", - "syncedAt": "2026-06-25T16:00:00Z" + "commit": "47f5051845777839ec29e8d67dd12ae68671854a", + "syncedAt": "2026-06-26T15:59:59Z" }, { "id": "next-skills", @@ -78,8 +78,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "b0dde9cf271e07b2380575be766ef8f5e451e857", - "syncedAt": "2026-06-25T16:00:00Z" + "commit": "6c22d02ce0c1ba3b8773b314756f388d8f7cbd1a", + "syncedAt": "2026-06-26T15:59:59Z" } ] } diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index ef7e3b50e..1c052ab74 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-06-25T16:01:19Z" + "syncedAt": "2026-06-26T16:01:08Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index 48198e025..66606328a 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "next-skills", "repo": "https://github.com/vercel/next.js.git", "ref": "canary", - "commit": "b0dde9cf271e07b2380575be766ef8f5e451e857", + "commit": "6c22d02ce0c1ba3b8773b314756f388d8f7cbd1a", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-06-25T16:00:00Z" + "syncedAt": "2026-06-26T15:59:59Z" } diff --git a/plugins/codex/plugins/next-skills/skills/next-dev-loop/SKILL.md b/plugins/codex/plugins/next-skills/skills/next-dev-loop/SKILL.md index 8845d884f..3b7da255a 100644 --- a/plugins/codex/plugins/next-skills/skills/next-dev-loop/SKILL.md +++ b/plugins/codex/plugins/next-skills/skills/next-dev-loop/SKILL.md @@ -17,7 +17,9 @@ You verify through two views of the same running app: `tools/list` for the current surface. - **`agent-browser`** — a CLI that drives a real Chrome. Knows framework-agnostic browser things: DOM, console, network, React - fiber, vitals. Run `agent-browser --help` for the current surface. + fiber, vitals. Before driving it, run `agent-browser skills get core` + once for the version-matched usage guide — don't guess subcommands + from memory. The two views cross-check each other. @@ -25,7 +27,8 @@ The two views cross-check each other. - Next.js **16.3+** with **Turbopack** — `/_next/mcp` plus the proactive compile check via `get_compilation_issues`. -- `agent-browser` **>= 0.27.0** — when React introspection landed. +- `agent-browser` **>= 0.31.0** — React introspection, worktree-scoped + `session id`, idempotent `--restore`, and launch flag reconciliation. These are hard floors, not soft preferences. If anything is missing, tell the user how to upgrade and stop. Don't fall back to grepping @@ -45,36 +48,44 @@ at the versions above. Once per session, confirm both views are live. 1. **Open `agent-browser` at the target URL, restoring saved - login state when present.** Build the `open` command from: - - `--session ` where `` is the project - directory basename. - - `--state ~/.agent-browser/sessions/-default.json` if - that file exists. Omit on first run — a missing path fails - the open. - - `--headed --enable react-devtools`. **If the `agent-browser` - daemon is already running from a previous session**, launch - flags like `--headed` are ignored and a warning is printed - (`⚠ --headed ignored: daemon already running`). To change - them, run `agent-browser close` first, then re-open. + login state when present.** First derive one stable session id for + this checkout and use it for every `agent-browser` command: - The browser is the user's. If state was not restored (first - run, expired session) and the page is gated, the user drives - the login — pause until they confirm. Session state is sticky: - you can't add `--enable react-devtools` after the session is - open, and `cookies set` on a not-yet-opened session creates a - sessionless cookie that silently fails to apply. + ```bash + SESSION="$(agent-browser session id --scope worktree --prefix next-dev-loop)" + export AGENT_BROWSER_SESSION="$SESSION" + export AGENT_BROWSER_RESTORE="$SESSION" + ``` -2. POST `tools/list` to `/_next/mcp`. Send - `Accept: application/json, text/event-stream`; responses are - SSE-framed, strip the `data: ` prefix before parsing JSON. + Then open the target URL: + + ```bash + agent-browser --session "$SESSION" --restore --headed --enable react-devtools open + ``` + + `--scope worktree` keeps parallel worktrees and copied checkouts + from colliding. Bare `--restore` uses the session id as the + persistence key, loads saved cookies/localStorage before navigation + when present, and auto-saves state on close. Always pass the desired + launch flags on `open`; agent-browser will reuse, relaunch, or restart + its scoped background state as needed. + + The browser is the user's. If state was not restored (first run, + expired session) and the page is gated, the user drives the login — + pause until they confirm. After login, continue using the same session + and restore context; `agent-browser close` saves the cookie state so + the next `open` restores it. + +2. Probe `/_next/mcp` (`tools/list`) — confirm it's reachable and + lists `get_compilation_issues`: - Unreachable → either `next dev` isn't running, or Next.js is below 16.3. Check `package.json` to disambiguate, then refuse. - `get_compilation_issues` not in the list → Next.js below 16.3. Refuse and tell the user to upgrade. -3. `mcp get_compilation_issues` doubles as a Turbopack probe. - An error response of `"Turbopack project is not available..."` - means the user is on webpack. Refuse — Turbopack is required. -4. `mcp get_routes` → your route map for the rest of the session. +3. `get_compilation_issues` doubles as a Turbopack probe. An error + response of `"Turbopack project is not available..."` means the + user is on webpack. Refuse — Turbopack is required. +4. `get_routes` → your route map for the rest of the session. ## loop @@ -89,7 +100,7 @@ search doesn't. Four failure modes. Check each: -- **Compiles** — `mcp get_compilation_issues`. +- **Compiles** — `get_compilation_issues`. - **Runs without errors** — `/_next/mcp` (server and bubbled-up browser errors both surface here). - **Behaves as intended** — `agent-browser` drives the page; assert @@ -100,12 +111,39 @@ Four failure modes. Check each: server/client boundary shifts, suspense fallbacks) — DOM asserts alone miss them. -Pick the specific tool from `tools/list` or `agent-browser ---help` rather than from memory. +Pick the specific tool from `tools/list` or the agent-browser +manual rather than from memory. ## gotchas +- **Every `agent-browser` command must know your session and restore + key, or it may use an empty default browser or fail to save login + state.** Easiest: export both `AGENT_BROWSER_SESSION="$SESSION"` and + `AGENT_BROWSER_RESTORE="$SESSION"` at the top of each shell you run + agent-browser in. If you do not export them, pass + `--session "$SESSION" --restore` on every command. +- **When the two views disagree, suspect the tooling first.** If + `agent-browser` says a route is broken but `/_next/mcp` and the + server say it rendered cleanly, a stale or misdirected browser + session is the likelier cause than a real bug — reconcile the views + before debugging the app. +- Confirming a click or navigation: the page settles a beat later, so + wait with `wait --load networkidle` (no path to get wrong), then + snapshot/read to confirm the page. Avoid `wait --url` unless you pass + the link's exact href — a guessed or placeholder path won't match the + real URL and times out after 25s. +- A blank read, empty snapshot, `about:blank`, or a "no browser + session" error — right after `open` or after a click (even if `open` + reported the page) — is the browser dropping the page (a stale + session), not a broken route. Reopen your session at the URL with + `--session "$SESSION" --restore` and re-snapshot; if still blank, + run `agent-browser --session "$SESSION" --restore close`, then open + again. Don't fall back to `curl`; it bypasses the browser you're + testing. - React introspection output is stale after navigation. Re-run. +- `/_next/mcp` replies are SSE — read the JSON off the `data:` line + with `sed -n 's/^data: //p'` (a plain `sed 's/^data: //'` leaves the + `event:` line and the parse fails). - Non-3000 dev server: read the `next dev` banner; set `NEXT_MCP_URL=http://localhost:/_next/mcp`. - `get_errors` and `get_page_metadata` need at least one navigation @@ -134,9 +172,10 @@ get_compilation_issues Turbopack only; errors on webpack ## teardown -Close the `agent-browser` session — `--session` writes state -to disk so the next loop's `--state` restores login. Leave -`next dev` up for the next loop. +Close the session with the same session and restore context: +`agent-browser --session "$SESSION" --restore close`. `close` saves +that session's cookies and storage so the next loop's `--restore` open +keeps the user logged in. Leave `next dev` up for the next loop. --- diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index 4ef6c0fb1..7c7eadaa1 100644 --- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "ppt-master", "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", - "commit": "850ad1be2474f5d8930fa2b61105453b99391600", + "commit": "47f5051845777839ec29e8d67dd12ae68671854a", "adapter": "claude-skill", "sourcePath": "skills/ppt-master", - "syncedAt": "2026-06-25T16:00:00Z" + "syncedAt": "2026-06-26T15:59:59Z" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md index 049d8b403..415424cfe 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md @@ -56,6 +56,8 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶 | `${SKILL_DIR}/scripts/total_md_split.py` | Speaker notes splitting | | `${SKILL_DIR}/scripts/finalize_svg.py` | SVG post-processing (unified entry) | | `${SKILL_DIR}/scripts/svg_to_pptx.py` | Export to PPTX | +| `${SKILL_DIR}/scripts/native_enhance_pptx.py` | Existing PPTX enhancement project init / validation / direct OOXML patch export | +| `${SKILL_DIR}/scripts/native_narration_pptx.py` | Backward-compatible entrypoint for existing PPTX notes / narration enhancement | | `${SKILL_DIR}/scripts/update_spec.py` | Propagate a `spec_lock.md` color / font_family change across all generated SVGs | For complete tool documentation, see `${SKILL_DIR}/scripts/README.md`. @@ -83,6 +85,8 @@ For complete tool documentation, see `${SKILL_DIR}/scripts/README.md`. | `resume-execute` | `workflows/resume-execute.md` | Phase B entry — resume execution in a fresh chat after Phase A (Step 1–5) completed in another session (split mode) | | `verify-charts` | `workflows/verify-charts.md` | Chart coordinate calibration — run after SVG generation if the deck contains data charts | | `customize-animations` | `workflows/customize-animations.md` | Object-level PPTX animation customization — run only when the user explicitly asks to tune animation order/effects/timing | +| `native-enhance-pptx` | `workflows/native-enhance-pptx.md` | Existing PPTX native enhancement — optimize a finished deck by appending notes / audio / auto-advance / page transitions without changing existing content or layout | +| `native-narration-pptx` | `workflows/native-narration-pptx.md` | Compatibility reference for the notes / narration subset of `native-enhance-pptx` | | `live-preview` | `workflows/live-preview.md` | Browser-based live preview — auto-started during generation and re-enterable any time the user mentions "live preview", "preview", "看效果", or wants to click/select a slide element | | `visual-review` | `workflows/visual-review.md` | Per-page rubric-based visual self-check — run only when the user explicitly asks for a visual re-pass on the generated SVGs (between Executor and post-processing). Opt-in only; never invoked by the main pipeline. | @@ -96,6 +100,7 @@ When the user provides an existing `.pptx`, route by the role of the source deck | Treat the deck as source material; rethink the story, merge / split / drop / reorder pages, or change page count | Main pipeline | `ppt_to_md` + PPTX intake provide content facts and candidates; Strategist may re-architect freely | | Reuse the deck's native design with new material | `template-fill` | Clone selected source slides and replace text / table / chart data directly in OOXML; no SVG generation | | Harvest the deck as a reusable future template | `create-template` | Build a template package, not a one-off generated deck | +| Keep the finished deck visually stable and append native optimizations such as notes / narration audio / automatic playback | `native-enhance-pptx` | Archive the source PPTX into the project (`projects/` sources move; external sources copy) and patch enhancement metadata/media directly in OOXML; no SVG generation | **Deciding axis (beautify vs main pipeline) — one question, one discriminator**: is the source's page split a finished artifact to preserve, or a draft structure to overturn? The concrete discriminator is **page count / order**: if it changes at all — any split, merge, drop, or reorder — it is the **main pipeline**, never beautify. Beautify is **strictly 1:1**: same page count, same order, text verbatim, only layout / hierarchy / whitespace redone. Edge case made explicit: "keep all the content but split a crowded page so it reads better" still changes page count, so it is the **main pipeline** (re-pagination is re-architecture), not beautify. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md index 604910e2a..d80b866dd 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md @@ -41,7 +41,7 @@ python3 scripts/update_repo.py | Area | Primary scripts | Documentation | |------|-----------------|---------------| | Conversion | `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py` | [docs/conversion.md](./docs/conversion.md) | -| Project management | `project_manager.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py` | [docs/project.md](./docs/project.md) | +| Project management | `project_manager.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py` | [docs/project.md](./docs/project.md) | | SVG pipeline | `finalize_svg.py`, `svg_to_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `animation_config.py`, `notes_to_audio.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md) | | Spec maintenance | `update_spec.py` | [docs/update_spec.md](./docs/update_spec.md) | | Image tools | `image_gen.py`, `latex_render.py`, `analyze_images.py`, `gemini_watermark_remover.py` | [docs/image.md](./docs/image.md) | @@ -79,14 +79,26 @@ python3 scripts/pptx_template_import.py --inheritance-mode both Template fill (direct PPTX, no SVG conversion): ```bash -mkdir -p /sources /analysis /exports /validation +python3 scripts/project_manager.py init --format ppt169 +python3 scripts/project_manager.py import-sources +# Manual fallback when import-sources did not produce analysis/.slide_library.json: python3 scripts/template_fill_pptx.py analyze /sources/ -o /analysis/.slide_library.json python3 scripts/template_fill_pptx.py scaffold /analysis/.slide_library.json -o /analysis/fill_plan.json --slides "1,3,4" python3 scripts/template_fill_pptx.py check-plan /analysis/.slide_library.json /analysis/fill_plan.json -o /analysis/check_report.json python3 scripts/template_fill_pptx.py apply /sources/ /analysis/fill_plan.json -o /exports/filled.pptx +python3 scripts/template_fill_pptx.py validate ``` -`apply` automatically writes `filled_YYYYMMDD_HHMMSS.pptx` unless the output stem already ends with a timestamp. It applies a `fade` page transition by default; `--transition ` (fade/push/wipe/split/strips/cover/random, `--transition-duration` in seconds) changes it, `--transition none` removes it, `--transition keep` preserves the source transitions, and a per-slide `transition` field in the plan overrides whatever the CLI selects. +`apply` requires `fill_plan.json` to have top-level `"status": "confirmed"` unless `--force` is passed. It automatically writes `filled_YYYYMMDD_HHMMSS.pptx` unless the output stem already ends with a timestamp. It applies a `fade` page transition by default; `--transition ` (fade/push/wipe/split/strips/cover/random, `--transition-duration` in seconds) changes it, `--transition none` removes it, `--transition keep` preserves the source transitions, and a per-slide `transition` field in the plan overrides whatever the CLI selects. + +Native existing-PPTX enhancement (direct PPTX, no SVG conversion): + +```bash +python3 scripts/native_enhance_pptx.py init --name +python3 scripts/native_enhance_pptx.py plan +python3 scripts/native_enhance_pptx.py validate +python3 scripts/native_enhance_pptx.py apply +``` Post-processing and export: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx.py new file mode 100644 index 000000000..9f9bfc75b --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +""" +PPT Master - Native Enhance PPTX Entrypoint + +Public CLI wrapper for native enhancement of existing PPTX decks. V1 delegates +to the narration/timings implementation while keeping the stable command name +aligned with the native-enhance workflow. + +Usage: + python3 scripts/native_enhance_pptx.py init [--name project_name] + python3 scripts/native_enhance_pptx.py plan + python3 scripts/native_enhance_pptx.py validate + python3 scripts/native_enhance_pptx.py apply + +Examples: + python3 scripts/native_enhance_pptx.py init projects/source.pptx --name fire_station + python3 scripts/native_enhance_pptx.py plan projects/fire_station_native_enhance_20260626 + python3 scripts/native_enhance_pptx.py apply projects/fire_station_native_enhance_20260626 + +Dependencies: + Same as native_narration_pptx.py. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_SCRIPTS_DIR = Path(__file__).resolve().parent +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + +from native_narration_pptx import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_narration_pptx.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_narration_pptx.py new file mode 100644 index 000000000..a6a22a5dc --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_narration_pptx.py @@ -0,0 +1,862 @@ +#!/usr/bin/env python3 +""" +PPT Master - Native Existing PPTX Enhancer + +Create and apply a lightweight project for enhancing an existing PPTX without +entering the SVG generation pipeline or modifying the original file. + +V1 enhancement modules: speaker notes, narration audio, slide auto-advance +timings, and optional page transitions. + +Usage: + python3 scripts/native_enhance_pptx.py init [--name project_name] + python3 scripts/native_enhance_pptx.py apply [--output output.pptx] + python3 scripts/native_enhance_pptx.py validate + +Examples: + python3 scripts/native_enhance_pptx.py init projects/source.pptx --name fire_station + python3 scripts/native_enhance_pptx.py apply projects/fire_station_native_enhance_20260626 + python3 scripts/native_enhance_pptx.py validate projects/fire_station_native_enhance_20260626 + +Dependencies: + ffprobe for audio-duration-based auto-advance timings. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from xml.etree import ElementTree as ET + +_SCRIPTS_DIR = Path(__file__).resolve().parent +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + +from pptx_animations import TRANSITIONS, create_transition_xml # noqa: E402 +from svg_to_pptx.pptx_builder import ( # noqa: E402 + _add_default_content_type, + _append_relationship, + _ensure_notes_master, +) +from svg_to_pptx.pptx_narration import ( # noqa: E402 + AUDIO_CONTENT_TYPES, + AUDIO_REL_TYPE, + IMAGE_REL_TYPE, + MEDIA_REL_TYPE, + NARRATION_EXTENSIONS, + TRANSPARENT_PNG_BYTES, + apply_recorded_timing, + inject_narration, + next_shape_id, + probe_audio_duration, +) +from svg_to_pptx.pptx_notes import ( # noqa: E402 + create_notes_slide_rels_xml, + create_notes_slide_xml, + markdown_to_plain_text, +) + + +PROJECT_SCHEMA = "native_pptx_enhancement_project.v1" +LEGACY_PROJECT_SCHEMAS = {"native_narration_pptx_project.v1"} +NOTES_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" +PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" +REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +CONTENT_TYPE_NOTES_SLIDE = ( + "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" +) +CONTENT_TYPE_NOTES_MASTER = ( + "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml" +) +CONTENT_TYPE_THEME = "application/vnd.openxmlformats-officedocument.theme+xml" + + +@dataclass(frozen=True) +class SlidePart: + index: int + part_name: str + slide_number: int + + +def _sanitize_slug(value: str) -> str: + slug = re.sub(r"[^0-9A-Za-z_-]+", "_", value).strip("_") + return slug or "native_enhance" + + +def _read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, data: dict) -> None: + path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _is_relative_to(path: Path, parent: Path) -> bool: + try: + path.resolve().relative_to(parent.resolve()) + return True + except ValueError: + return False + + +def _archive_source_pptx(source_pptx: Path, archived_pptx: Path, projects_root: Path) -> str: + """Move project-local sources into the project; copy external sources.""" + archived_pptx.parent.mkdir(parents=True, exist_ok=True) + if source_pptx.resolve() == archived_pptx.resolve(): + return "reuse" + if _is_relative_to(source_pptx, projects_root): + shutil.move(str(source_pptx), str(archived_pptx)) + return "move" + shutil.copy2(source_pptx, archived_pptx) + return "copy" + + +def _relationship_file_for_part(extract_dir: Path, part_name: str) -> Path: + part = Path(part_name) + return extract_dir / part.parent / "_rels" / f"{part.name}.rels" + + +def _ensure_rels_file(path: Path) -> None: + if path.exists(): + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + '\n' + f'\n', + encoding="utf-8", + ) + + +def _remove_relationships_by_type(rels_path: Path, rel_type: str) -> None: + if not rels_path.exists(): + return + content = rels_path.read_text(encoding="utf-8") + content = re.sub( + rf'\s*]*\bType="{re.escape(rel_type)}"[^>]*/>', + "", + content, + ) + rels_path.write_text(content, encoding="utf-8") + + +def _target_to_part(target: str) -> str: + target = target.lstrip("/") + if target.startswith("ppt/"): + return target + return f"ppt/{target}" + + +def _slide_number_from_part(part_name: str) -> int: + match = re.search(r"slide(\d+)\.xml$", part_name) + if not match: + raise ValueError(f"Unsupported slide part name: {part_name}") + return int(match.group(1)) + + +def read_slide_parts(extract_dir: Path) -> list[SlidePart]: + presentation_path = extract_dir / "ppt" / "presentation.xml" + rels_path = extract_dir / "ppt" / "_rels" / "presentation.xml.rels" + if not presentation_path.exists() or not rels_path.exists(): + raise RuntimeError("PPTX package is missing presentation.xml or its relationships") + + rels_root = ET.parse(rels_path).getroot() + rels: dict[str, str] = {} + for rel in rels_root.findall(f"{{{PACKAGE_REL_NS}}}Relationship"): + rel_id = rel.attrib.get("Id") + target = rel.attrib.get("Target") + if rel_id and target: + rels[rel_id] = target + + presentation_root = ET.parse(presentation_path).getroot() + slide_parts: list[SlidePart] = [] + for index, slide_id in enumerate( + presentation_root.findall(f".//{{{PRESENTATION_NS}}}sldId"), + 1, + ): + rel_id = slide_id.attrib.get(f"{{{REL_NS}}}id") + if not rel_id or rel_id not in rels: + continue + part_name = _target_to_part(rels[rel_id]) + slide_parts.append( + SlidePart( + index=index, + part_name=part_name, + slide_number=_slide_number_from_part(part_name), + ) + ) + if not slide_parts: + raise RuntimeError("No slides found in presentation.xml") + return slide_parts + + +def _zip_dir(source_dir: Path, output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + for path in sorted(source_dir.rglob("*")): + if path.is_file(): + zf.write(path, path.relative_to(source_dir).as_posix()) + + +def _extract_pptx(source_pptx: Path, extract_dir: Path) -> None: + with zipfile.ZipFile(source_pptx, "r") as zf: + zf.extractall(extract_dir) + + +def _note_path(notes_dir: Path, index: int) -> Path | None: + candidates = [ + notes_dir / f"{index:03d}.md", + notes_dir / f"{index:02d}.md", + notes_dir / f"{index}.md", + notes_dir / f"slide{index:03d}.md", + notes_dir / f"slide{index:02d}.md", + notes_dir / f"slide{index}.md", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def _audio_path(audio_dir: Path, index: int) -> Path | None: + stems = [ + f"{index:03d}", + f"{index:02d}", + str(index), + f"slide{index:03d}", + f"slide{index:02d}", + f"slide{index}", + ] + for stem in stems: + for ext in NARRATION_EXTENSIONS: + candidate = audio_dir / f"{stem}{ext}" + if candidate.exists(): + return candidate + return None + + +def _add_override(content_types: str, part_name: str, content_type: str) -> str: + if re.search( + rf']*\bPartName="/{re.escape(part_name)}"[^>]*/>', + content_types, + ): + return content_types + override = f' ' + return content_types.replace("", override + "\n") + + +def _add_notes_content_types(content_types: str, note_indices: set[int]) -> str: + content_types = _add_override(content_types, "ppt/theme/theme2.xml", CONTENT_TYPE_THEME) + content_types = _add_override( + content_types, + "ppt/notesMasters/notesMaster1.xml", + CONTENT_TYPE_NOTES_MASTER, + ) + for index in sorted(note_indices): + content_types = _add_override( + content_types, + f"ppt/notesSlides/notesSlide{index}.xml", + CONTENT_TYPE_NOTES_SLIDE, + ) + return content_types + + +def _set_transition_only(slide_xml: str, effect: str, duration: float) -> str: + transition_xml = create_transition_xml(effect=effect, duration=duration) + if re.search(r"]*/>", slide_xml): + return re.sub(r"\s*]*/>", "\n" + transition_xml, slide_xml, count=1) + if re.search(r"]*>.*?", slide_xml, re.S): + return re.sub( + r"\s*]*>.*?", + "\n" + transition_xml, + slide_xml, + count=1, + flags=re.S, + ) + if "" in slide_xml: + return slide_xml.replace("", transition_xml + "\n ", 1) + return slide_xml.replace("", transition_xml + "\n", 1) + + +def _apply_recorded_timing_without_transition(slide_xml: str, advance_after: float) -> str: + adv_ms = max(1, int(advance_after * 1000)) + transition_xml = f' ' + slide_xml = re.sub(r"\s*]*/>", "", slide_xml, count=1) + slide_xml = re.sub( + r"\s*]*>.*?", + "", + slide_xml, + count=1, + flags=re.S, + ) + if "" in slide_xml: + return slide_xml.replace("", transition_xml + "\n ", 1) + return slide_xml.replace("", transition_xml + "\n", 1) + + +def _apply_notes(extract_dir: Path, slide: SlidePart, note_md: Path) -> None: + notes_text = markdown_to_plain_text(note_md.read_text(encoding="utf-8")) + if not notes_text: + return + + _ensure_notes_master(extract_dir) + notes_dir = extract_dir / "ppt" / "notesSlides" + notes_dir.mkdir(parents=True, exist_ok=True) + notes_xml_path = notes_dir / f"notesSlide{slide.index}.xml" + notes_xml_path.write_text( + create_notes_slide_xml(slide.slide_number, notes_text), + encoding="utf-8", + ) + + notes_rels_dir = notes_dir / "_rels" + notes_rels_dir.mkdir(parents=True, exist_ok=True) + notes_rels_path = notes_rels_dir / f"notesSlide{slide.index}.xml.rels" + notes_rels_path.write_text( + create_notes_slide_rels_xml(slide.slide_number), + encoding="utf-8", + ) + + slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) + _ensure_rels_file(slide_rels) + _remove_relationships_by_type(slide_rels, NOTES_REL_TYPE) + _append_relationship( + slide_rels, + NOTES_REL_TYPE, + f"../notesSlides/notesSlide{slide.index}.xml", + ) + + +def _apply_audio( + extract_dir: Path, + slide: SlidePart, + audio_path: Path, + *, + transition: str, + transition_duration: float, + narration_padding: float, +) -> None: + media_dir = extract_dir / "ppt" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + + ext = audio_path.suffix.lower() + media_name = f"native_enhance_audio_{slide.index:03d}{ext}" + shutil.copy2(audio_path, media_dir / media_name) + + poster_name = "native_enhance_audio_poster.png" + poster_path = media_dir / poster_name + if not poster_path.exists(): + poster_path.write_bytes(TRANSPARENT_PNG_BYTES) + + slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) + _ensure_rels_file(slide_rels) + media_rid = _append_relationship(slide_rels, MEDIA_REL_TYPE, f"../media/{media_name}") + audio_rid = _append_relationship(slide_rels, AUDIO_REL_TYPE, f"../media/{media_name}") + poster_rid = _append_relationship(slide_rels, IMAGE_REL_TYPE, f"../media/{poster_name}") + + slide_xml_path = extract_dir / slide.part_name + slide_xml = slide_xml_path.read_text(encoding="utf-8") + shape_id = next_shape_id(slide_xml) + slide_xml = inject_narration( + slide_xml, + shape_id=shape_id, + shape_name=media_name, + audio_rid=audio_rid, + media_rid=media_rid, + poster_rid=poster_rid, + ) + + duration = probe_audio_duration(audio_path) + if duration is None: + raise RuntimeError(f"Unable to read narration duration with ffprobe: {audio_path}") + advance_after = duration + narration_padding + if transition == "none": + slide_xml = _apply_recorded_timing_without_transition(slide_xml, advance_after) + else: + slide_xml = apply_recorded_timing( + slide_xml, + advance_after=advance_after, + transition_duration=transition_duration, + transition_effect=transition, + ) + slide_xml_path.write_text(slide_xml, encoding="utf-8") + + +def _update_content_types(extract_dir: Path, note_indices: set[int], audio_exts: set[str]) -> None: + content_types_path = extract_dir / "[Content_Types].xml" + content_types = content_types_path.read_text(encoding="utf-8") + if note_indices: + content_types = _add_notes_content_types(content_types, note_indices) + for ext in sorted(audio_exts): + content_type = AUDIO_CONTENT_TYPES.get(ext) + if content_type: + content_types = _add_default_content_type(content_types, ext, content_type) + if audio_exts: + content_types = _add_default_content_type(content_types, "png", "image/png") + content_types_path.write_text(content_types, encoding="utf-8") + + +def _project_paths(project_path: Path) -> tuple[Path, Path, Path, Path]: + project = _read_json(project_path / "project.json") + source_pptx = project_path / project["source_pptx"] + notes_dir = project_path / project["notes_dir"] + audio_dir = project_path / project["audio_dir"] + exports_dir = project_path / project["exports_dir"] + return source_pptx, notes_dir, audio_dir, exports_dir + + +def _plan_path(project_path: Path) -> Path: + return project_path / "analysis" / "enhancement_plan.json" + + +def _load_enhancement_plan(project_path: Path) -> dict: + path = _plan_path(project_path) + if not path.exists(): + return {} + return _read_json(path) + + +def _enabled_modules(plan: dict) -> set[str]: + modules = plan.get("modules") + if not isinstance(modules, dict): + return {"notes", "audio", "timings", "transitions"} + enabled: set[str] = set() + for name, config in modules.items(): + if isinstance(config, dict) and config.get("enabled") is True: + enabled.add(str(name)) + return enabled + + +def _plan_confirmed(plan: dict) -> bool: + return plan.get("status") == "confirmed" + + +def _build_enhancement_plan( + project: dict, + *, + slide_count: int, + notes_count: int, + audio_count: int, + transition: str, + transition_duration: float, + narration_padding: float, + apply_transition_without_audio: bool, +) -> dict: + return { + "schema": "native_pptx_enhancement_plan.v1", + "status": "draft", + "source_pptx": project.get("source_pptx"), + "slide_count": slide_count, + "modules": { + "notes": { + "enabled": True, + "requires_confirmation": True, + "status": "ready" if notes_count == slide_count else "needs_notes", + "coverage": {"ready": notes_count, "total": slide_count}, + }, + "audio": { + "enabled": True, + "requires_confirmation": True, + "status": "ready" if audio_count == slide_count else "needs_audio", + "coverage": {"ready": audio_count, "total": slide_count}, + }, + "timings": { + "enabled": True, + "requires_confirmation": True, + "status": "ready" if audio_count == slide_count else "blocked_until_audio", + "source": "audio_duration", + "narration_padding": narration_padding, + }, + "transitions": { + "enabled": transition != "none", + "requires_confirmation": True, + "status": "ready", + "effect": transition, + "duration": transition_duration, + "apply_without_audio": apply_transition_without_audio, + }, + }, + "not_in_v1": [ + "object_animation", + "visible_watermark", + "footer_or_logo_insertion", + "background_music", + "media_compression", + ], + } + + +def init_project(args: argparse.Namespace) -> int: + source_pptx = Path(args.source_pptx).expanduser().resolve() + if not source_pptx.exists() or source_pptx.suffix.lower() != ".pptx": + print(f"error: expected an existing .pptx file: {source_pptx}", file=sys.stderr) + return 1 + + stem = _sanitize_slug(args.name or source_pptx.stem) + date = datetime.now().strftime("%Y%m%d") + project_path = ( + Path(args.project_dir).expanduser().resolve() + if args.project_dir + else Path(args.projects_root).expanduser().resolve() / f"{stem}_native_enhance_{date}" + ) + if project_path.exists() and any(project_path.iterdir()): + print(f"error: project directory already exists and is not empty: {project_path}", file=sys.stderr) + return 1 + + for dirname in ("sources", "analysis", "notes", "audio", "exports", "validation"): + (project_path / dirname).mkdir(parents=True, exist_ok=True) + + archived_pptx = project_path / "sources" / source_pptx.name + projects_root = Path(args.projects_root).expanduser().resolve() + source_import_mode = _archive_source_pptx(source_pptx, archived_pptx, projects_root) + + source_md = project_path / "sources" / f"{source_pptx.stem}.md" + ppt_to_md = _SCRIPTS_DIR / "source_to_md" / "ppt_to_md.py" + result = subprocess.run( + [sys.executable, str(ppt_to_md), str(archived_pptx), "-o", str(source_md)], + check=False, + text=True, + capture_output=True, + ) + if result.returncode != 0: + print(result.stderr or result.stdout, file=sys.stderr) + return result.returncode + + with tempfile.TemporaryDirectory(prefix="native-enhance-intake-") as tmp: + extract_dir = Path(tmp) / "pptx" + _extract_pptx(archived_pptx, extract_dir) + slide_parts = read_slide_parts(extract_dir) + + slide_index = { + "schema": "native_pptx_enhancement_slide_index.v1", + "source_pptx": f"sources/{source_pptx.name}", + "slide_count": len(slide_parts), + "slides": [ + { + "index": slide.index, + "note_file": f"notes/{slide.index:03d}.md", + "audio_stem": f"{slide.index:03d}", + "part_name": slide.part_name, + "slide_number": slide.slide_number, + } + for slide in slide_parts + ], + } + _write_json(project_path / "analysis" / "slide_index.json", slide_index) + + project = { + "schema": PROJECT_SCHEMA, + "kind": "native_pptx_enhancement", + "modules": ["notes", "audio", "timings", "transitions"], + "source_pptx": f"sources/{source_pptx.name}", + "source_markdown": f"sources/{source_pptx.stem}.md", + "source_import": { + "mode": source_import_mode, + "original_path": str(source_pptx), + }, + "slide_count": len(slide_parts), + "notes_dir": "notes", + "audio_dir": "audio", + "exports_dir": "exports", + "transition": { + "effect": args.transition, + "duration": args.transition_duration, + }, + "audio": { + "provider": "", + "voice": "", + "rate": "", + }, + } + _write_json(project_path / "project.json", project) + plan = _build_enhancement_plan( + project, + slide_count=len(slide_parts), + notes_count=0, + audio_count=0, + transition=args.transition, + transition_duration=args.transition_duration, + narration_padding=args.narration_padding, + apply_transition_without_audio=args.apply_transition_without_audio, + ) + _write_json(_plan_path(project_path), plan) + + print(f"Project: {project_path}", file=sys.stderr) + print(f"Slides: {len(slide_parts)}", file=sys.stderr) + print(f"Source import: {source_import_mode}", file=sys.stderr) + print(f"Source markdown: {source_md}", file=sys.stderr) + print(f"Draft enhancement plan: {_plan_path(project_path)}", file=sys.stderr) + print( + "Review the plan with the user and set status to \"confirmed\" before generating notes/audio/applying.", + file=sys.stderr, + ) + return 0 + + +def plan_project(args: argparse.Namespace) -> int: + project_path = Path(args.project_path).expanduser().resolve() + project = _read_json(project_path / "project.json") + if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}: + print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr) + return 1 + + source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path) + with tempfile.TemporaryDirectory(prefix="native-enhance-plan-") as tmp: + extract_dir = Path(tmp) / "pptx" + _extract_pptx(source_pptx, extract_dir) + slides = read_slide_parts(extract_dir) + + notes_count = sum(1 for slide in slides if _note_path(notes_dir, slide.index) is not None) + audio_count = sum(1 for slide in slides if _audio_path(audio_dir, slide.index) is not None) + plan = _build_enhancement_plan( + project, + slide_count=len(slides), + notes_count=notes_count, + audio_count=audio_count, + transition=args.transition, + transition_duration=args.transition_duration, + narration_padding=args.narration_padding, + apply_transition_without_audio=args.apply_transition_without_audio, + ) + _write_json(_plan_path(project_path), plan) + print(json.dumps(plan, ensure_ascii=False, indent=2)) + print(f"Plan written: {_plan_path(project_path)}", file=sys.stderr) + print( + "Confirm by editing status to \"confirmed\" after user approval, then run apply.", + file=sys.stderr, + ) + return 0 + + +def apply_project(args: argparse.Namespace) -> int: + project_path = Path(args.project_path).expanduser().resolve() + project = _read_json(project_path / "project.json") + if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}: + print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr) + return 1 + + source_pptx, notes_dir, audio_dir, exports_dir = _project_paths(project_path) + transition_cfg = project.get("transition", {}) if isinstance(project.get("transition"), dict) else {} + plan = _load_enhancement_plan(project_path) + if not _plan_confirmed(plan) and not args.force: + print( + f"error: enhancement plan is not confirmed: {_plan_path(project_path)} " + "(run plan, get user confirmation, set status to \"confirmed\", or pass --force)", + file=sys.stderr, + ) + return 1 + + modules = _enabled_modules(plan) + transitions_cfg = ( + plan.get("modules", {}).get("transitions", {}) + if isinstance(plan.get("modules"), dict) + else {} + ) + timings_cfg = ( + plan.get("modules", {}).get("timings", {}) + if isinstance(plan.get("modules"), dict) + else {} + ) + transition = ( + args.transition + or transitions_cfg.get("effect") + or transition_cfg.get("effect") + or "fade" + ) + transition_duration = ( + args.transition_duration + or transitions_cfg.get("duration") + or float(transition_cfg.get("duration") or 0.5) + ) + narration_padding = ( + args.narration_padding + if args.narration_padding is not None + else float(timings_cfg.get("narration_padding") or 0.4) + ) + apply_transition_without_audio = ( + args.apply_transition_without_audio + or bool(transitions_cfg.get("apply_without_audio")) + ) + + output_path = ( + Path(args.output).expanduser().resolve() + if args.output + else exports_dir / f"{source_pptx.stem}_enhanced.pptx" + ) + if output_path.exists() and not args.overwrite: + print(f"error: output already exists, pass --overwrite: {output_path}", file=sys.stderr) + return 1 + + with tempfile.TemporaryDirectory(prefix="native-enhance-pptx-") as tmp: + extract_dir = Path(tmp) / "pptx" + _extract_pptx(source_pptx, extract_dir) + slides = read_slide_parts(extract_dir) + + note_indices: set[int] = set() + audio_exts: set[str] = set() + audio_count = 0 + transition_only_count = 0 + for slide in slides: + note = _note_path(notes_dir, slide.index) + if "notes" in modules and note: + _apply_notes(extract_dir, slide, note) + note_indices.add(slide.index) + + audio = _audio_path(audio_dir, slide.index) + if "audio" in modules and audio: + _apply_audio( + extract_dir, + slide, + audio, + transition=transition if "transitions" in modules else "none", + transition_duration=transition_duration, + narration_padding=narration_padding if "timings" in modules else 0, + ) + audio_exts.add(audio.suffix.lower()) + audio_count += 1 + continue + + if ( + "transitions" in modules + and apply_transition_without_audio + and transition != "none" + ): + slide_xml_path = extract_dir / slide.part_name + slide_xml = slide_xml_path.read_text(encoding="utf-8") + slide_xml_path.write_text( + _set_transition_only(slide_xml, transition, transition_duration), + encoding="utf-8", + ) + transition_only_count += 1 + + _update_content_types(extract_dir, note_indices, audio_exts) + _zip_dir(extract_dir, output_path) + + print(f"Output: {output_path}", file=sys.stderr) + print(f"Notes applied: {len(note_indices)}", file=sys.stderr) + print(f"Audio embedded: {audio_count}", file=sys.stderr) + if transition_only_count: + print(f"Transition-only slides: {transition_only_count}", file=sys.stderr) + return 0 + + +def validate_project(args: argparse.Namespace) -> int: + project_path = Path(args.project_path).expanduser().resolve() + project = _read_json(project_path / "project.json") + if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}: + print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr) + return 1 + + source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path) + with tempfile.TemporaryDirectory(prefix="native-enhance-validate-") as tmp: + extract_dir = Path(tmp) / "pptx" + _extract_pptx(source_pptx, extract_dir) + slides = read_slide_parts(extract_dir) + + plan = _load_enhancement_plan(project_path) + modules = _enabled_modules(plan) + notes_count = sum(1 for slide in slides if _note_path(notes_dir, slide.index) is not None) + audio_count = sum(1 for slide in slides if _audio_path(audio_dir, slide.index) is not None) + missing_notes = ( + [slide.index for slide in slides if _note_path(notes_dir, slide.index) is None] + if "notes" in modules + else [] + ) + missing_audio = ( + [slide.index for slide in slides if _audio_path(audio_dir, slide.index) is None] + if "audio" in modules + else [] + ) + report = { + "schema": "native_pptx_enhancement_validation.v1", + "slide_count": len(slides), + "plan_status": plan.get("status") or "missing", + "enabled_modules": sorted(modules), + "notes_required": "notes" in modules, + "audio_required": "audio" in modules, + "notes_count": notes_count, + "audio_count": audio_count, + "missing_notes": missing_notes, + "missing_audio": missing_audio, + } + validation_dir = project_path / "validation" + validation_dir.mkdir(exist_ok=True) + _write_json(validation_dir / "report.json", report) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 if not missing_notes and not missing_audio else 2 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Create/apply a native existing-PPTX enhancement project without SVG conversion.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + init = subparsers.add_parser("init", help="create a native PPTX enhancement project") + init.add_argument("source_pptx", help="source .pptx file") + init.add_argument("--name", default=None, help="ASCII project name slug") + init.add_argument("--project-dir", default=None, help="explicit project directory") + init.add_argument("--projects-root", default="projects", help="projects root (default: projects)") + init.add_argument("--transition", default="fade", choices=sorted(TRANSITIONS.keys())) + init.add_argument("--transition-duration", type=float, default=0.5) + init.add_argument("--narration-padding", type=float, default=0.4) + init.add_argument( + "--apply-transition-without-audio", + action="store_true", + help="draft the plan with page transitions for slides without audio", + ) + init.set_defaults(func=init_project) + + plan = subparsers.add_parser("plan", help="draft an enhancement module plan") + plan.add_argument("project_path", help="native enhancement project directory") + plan.add_argument("--transition", default="fade", choices=sorted(TRANSITIONS.keys()) + ["none"]) + plan.add_argument("--transition-duration", type=float, default=0.5) + plan.add_argument("--narration-padding", type=float, default=0.4) + plan.add_argument( + "--apply-transition-without-audio", + action="store_true", + help="include page transitions for slides without audio", + ) + plan.set_defaults(func=plan_project) + + apply = subparsers.add_parser("apply", help="patch notes/audio/timings into a copied PPTX") + apply.add_argument("project_path", help="native narration project directory") + apply.add_argument("-o", "--output", default=None, help="output .pptx path") + apply.add_argument("--overwrite", action="store_true", help="overwrite output if it exists") + apply.add_argument("--transition", default=None, choices=sorted(TRANSITIONS.keys()) + ["none"]) + apply.add_argument("--transition-duration", type=float, default=None) + apply.add_argument("--narration-padding", type=float, default=None) + apply.add_argument("--force", action="store_true", help="apply without a confirmed enhancement plan") + apply.add_argument( + "--apply-transition-without-audio", + action="store_true", + help="also write page transitions on slides that do not have audio", + ) + apply.set_defaults(func=apply_project) + + validate = subparsers.add_parser("validate", help="check notes/audio coverage") + validate.add_argument("project_path", help="native narration project directory") + validate.set_defaults(func=validate_project) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/flatten_tspan.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/flatten_tspan.py index 43ce18f7d..1561d7f29 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/flatten_tspan.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/flatten_tspan.py @@ -210,16 +210,58 @@ def _tspan_has_positional_descendant(tspan: ET.Element) -> bool: return False +def _build_paragraph_child_view( + text_el: ET.Element, + is_svg_tag, +) -> tuple[list[ET.Element], ET.Element | None] | None: + """Return direct tspan children plus an optional synthetic leading line. + + The synthetic line lets paragraph classification accept common SVG + authoring where the first visual line is direct text under . This + helper does not mutate the tree; _emit_mergeable_paragraph commits the + synthetic line only after all paragraph checks pass. + """ + direct_children = list(text_el) + direct_tspans = [c for c in direct_children if is_svg_tag(c, "tspan")] + if len(direct_tspans) != len(direct_children): + return None + + raw_lead = text_el.text or "" + synthetic_first: ET.Element | None = None + if raw_lead.strip(): + base_x_raw = get_attr(text_el, "x") + if base_x_raw is None: + return None + if any((child.tail or "").strip() for child in direct_tspans): + return None + synthetic_first = ET.Element(f"{{{SVG_NS}}}tspan") + synthetic_first.set("x", base_x_raw) + synthetic_first.text = raw_lead.lstrip() + + view = ([synthetic_first] if synthetic_first is not None else []) + direct_tspans + return view, synthetic_first + + +def _get_font_size_px(elem: ET.Element) -> float | None: + """Read font-size from an attribute or inline style.""" + size = parse_first_number(get_attr(elem, "font-size")) + if size is not None: + return size + style_size = parse_style(get_attr(elem, "style")).get("font-size") + return parse_first_number(style_size) + + def _classify_paragraph_block( text_el: ET.Element, is_svg_tag, is_new_line_tspan, -) -> tuple[float, list[float], list[bool], list[list[ET.Element]]] | None: +) -> tuple[float, list[float], list[bool], list[list[ET.Element]], ET.Element | None] | None: """Detect a mergeable paragraph block. Returns ``(base_line_height_px, extra_space_before_px_per_line, - is_soft_break_per_line, line_groups)`` if the children form a mergeable paragraph. - Each list has one entry per direct-child tspan (line): + is_soft_break_per_line, line_groups, synthetic_first_line)`` if the children + form a mergeable paragraph. Each list has one entry per direct-child tspan + (line), including a synthetic first line when the source used leading text: - extra_space_before_px_per_line[i]: extra px above base line-height, used as on the downstream . First entry is 0. @@ -228,7 +270,8 @@ def _classify_paragraph_block( a fresh . First entry is always False (paragraph head). Conditions (all must hold): - - No leading text directly under . + - No direct text under , except simple leading text that can be + promoted into a synthetic first-line . - Every direct child is a . - Every logical line starts with a new-line tspan. - Direct-child inline formatting tspans without x/y/dy are allowed only @@ -242,15 +285,13 @@ def _classify_paragraph_block( - No nested tspan inside any line carries x/y/dy. """ base_x = parse_first_number(get_attr(text_el, "x")) - if (text_el.text or "").strip(): + child_view = _build_paragraph_child_view(text_el, is_svg_tag) + if child_view is None: return None + direct_tspans, synthetic_first = child_view - direct_tspans = [c for c in list(text_el) if is_svg_tag(c, "tspan")] - direct_children_all = [c for c in list(text_el)] if len(direct_tspans) < 2: return None - if len(direct_tspans) != len(direct_children_all): - return None line_groups: list[list[ET.Element]] = [] for tspan in direct_tspans: @@ -302,6 +343,9 @@ def _classify_paragraph_block( if not positive_dys: return None base = min(positive_dys) + font_size = _get_font_size_px(text_el) + if font_size is not None and base > font_size * MAX_DY_MULTIPLIER + DY_TOLERANCE_PX: + return None extras: list[float] = [0.0] # first line never has space-before soft_breaks: list[bool] = [False] # first line starts a paragraph @@ -324,7 +368,7 @@ def _classify_paragraph_block( extras.append(0.0 if is_soft else extra) soft_breaks.append(is_soft) - return base, extras, soft_breaks, line_groups + return base, extras, soft_breaks, line_groups, synthetic_first def _emit_mergeable_paragraph( @@ -333,6 +377,7 @@ def _emit_mergeable_paragraph( extras: list[float], soft_breaks: list[bool], line_groups: list[list[ET.Element]], + synthetic_first: ET.Element | None = None, ) -> None: """Rewrite text_el in place so it stays a single with paragraph rows. @@ -345,6 +390,9 @@ def _emit_mergeable_paragraph( with an extra gap (omitted when 0) """ text_el.set(PARAGRAPH_MARK_ATTR, format_number(base_dy)) + if synthetic_first is not None: + text_el.text = None + text_el.insert(0, synthetic_first) # Normalize authoring variants before the downstream converter reads the # paragraph: a line-break tspan may be followed by direct-child inline @@ -457,8 +505,15 @@ def flatten_text_with_tspans( if merge_paragraphs: paragraph = _classify_paragraph_block(text_el, is_svg_tag, is_new_line_tspan) if paragraph is not None: - base_dy, extras, soft_breaks, line_groups = paragraph - _emit_mergeable_paragraph(text_el, base_dy, extras, soft_breaks, line_groups) + base_dy, extras, soft_breaks, line_groups, synthetic_first = paragraph + _emit_mergeable_paragraph( + text_el, + base_dy, + extras, + soft_breaks, + line_groups, + synthetic_first=synthetic_first, + ) changed = True continue diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py index 8771ba2a3..51b577357 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py @@ -573,6 +573,66 @@ class SVGQualityChecker: f"Detected {len(text_matches)} potentially overly long single-line text(s) (consider using tspan for wrapping)" ) + self._check_unmergeable_leading_text(content, result) + + def _check_unmergeable_leading_text(self, content: str, result: Dict) -> None: + """Warn when leading text cannot be normalized for paragraph merging.""" + try: + root = ET.fromstring(content) + except ET.ParseError: + return + + risky = [] + for text_el in root.iter(f'{{{SVG_NS}}}text'): + if not (text_el.text or "").strip(): + continue + children = list(text_el) + if not any(self._is_line_tspan(child) for child in children): + continue + + reason = self._leading_text_normalizer_reject_reason(text_el) + if reason is not None: + risky.append(reason) + + if risky: + sample = '; '.join(risky[:3]) + suffix = '' if len(risky) <= 3 else f"; +{len(risky) - 3} more" + result['warnings'].append( + "Detected multi-line with leading direct text that cannot " + f"be normalized for PPT paragraph merging ({sample}{suffix})" + ) + + @staticmethod + def _is_tspan(elem: ET.Element) -> bool: + return elem.tag == f'{{{SVG_NS}}}tspan' + + @classmethod + def _is_line_tspan(cls, elem: ET.Element) -> bool: + if not cls._is_tspan(elem): + return False + if elem.get('x') is not None or elem.get('y') is not None: + return True + dy = elem.get('dy') + if dy is None: + return False + try: + return float(re.match(r'^[\s,]*([+-]?(?:\d+\.?\d*|\d*\.\d+))', dy).group(1)) != 0 + except (AttributeError, ValueError): + return True + + @classmethod + def _leading_text_normalizer_reject_reason(cls, text_el: ET.Element) -> str | None: + if text_el.get('x') is None: + return ' has no x anchor' + + for child in list(text_el): + if not cls._is_tspan(child): + return ' has non-tspan child' + if (child.tail or "").strip(): + return ' has non-empty tail text' + + return None + def _check_image_references(self, content: str, svg_path: Path, result: Dict): """Check image file existence and resolution vs display size.""" # Find all elements (capture the full tag) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml_elements.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml_elements.py index 05227920e..e7d0c5bf5 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml_elements.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml_elements.py @@ -837,6 +837,24 @@ _SERIF_WIDTH_FAMILIES = { 'times new roman', } +_TEXTBOX_PADDING_MIN_PX = 0.5 +_TEXTBOX_PADDING_MAX_PX = 2.0 +_TEXTBOX_PADDING_RATIO = 0.04 +# Single-line auto-fit headroom interpolates between a low-caps base and an +# all-caps ceiling by the fraction of cased letters that are uppercase. The +# crude per-char width estimate undercounts capitals most, so all-caps lines +# need the ceiling to keep wrap-ignoring renderers (LibreOffice) from folding; +# mixed-case titles only need the base, so they no longer inherit the worst- +# case width. Values are calibrated against LibreOffice renders of all-caps +# bold lines (the case the per-char estimate undercounts most) with bases left +# above the mixed-case and CJK render ratios; exact ratios shift with the +# renderer's font substitution, so these carry deliberate margin rather than +# tracking one environment's numbers. +_TEXT_WIDTH_HEADROOM_BASE = 1.06 +_TEXT_WIDTH_HEADROOM_CAPS = 1.12 +_SERIF_TEXT_WIDTH_HEADROOM_BASE = 1.12 +_SERIF_TEXT_WIDTH_HEADROOM_CAPS = 1.36 + def _normalize_text(text: str, *, preserve_space: bool = False) -> str: """Collapse runs of whitespace into a single space; do NOT strip the ends. @@ -921,6 +939,25 @@ def _estimate_run_text_width(run: dict[str, Any]) -> float: return base_width + letter_spacing_px * max(len(text) - 1, 0) +def _uppercase_fraction(runs: list[dict[str, Any]]) -> float: + """Fraction of cased letters across ``runs`` that are uppercase. + + Caseless scripts (CJK, digits, punctuation) are ignored, so a Chinese or + numeric line reports 0.0 and takes the low-caps headroom base. + """ + upper = 0 + cased = 0 + for run in runs: + for ch in str(run.get('text', '')): + if ch.lower() != ch.upper(): + cased += 1 + if ch.isupper(): + upper += 1 + if not cased: + return 0.0 + return upper / cased + + def _estimate_text_runs_width( runs: list[dict[str, Any]], *, @@ -929,16 +966,30 @@ def _estimate_text_runs_width( """Estimate a line of text runs. ``include_headroom`` is useful for single-line auto-fit boxes where a - renderer that measures text slightly wider would otherwise wrap. Paragraph - boxes use this value as a wrapping constraint, so adding headroom there - stretches the merged text frame beyond the author's source line width. + renderer that measures text slightly wider would otherwise wrap. The + headroom scales with the line's uppercase fraction: all-caps lines (whose + width the per-char estimate undercounts most) get the full ceiling, while + mixed-case titles take a small base instead of inheriting the worst case. + Paragraph boxes use this value as a wrapping constraint, so adding headroom + there stretches the merged text frame beyond the author's source line width. """ width = sum(_estimate_run_text_width(run) for run in runs) if not include_headroom: return width + caps = _uppercase_fraction(runs) if any(_is_serif_run(run) for run in runs): - return width * 1.35 - return width * 1.12 + base, ceiling = _SERIF_TEXT_WIDTH_HEADROOM_BASE, _SERIF_TEXT_WIDTH_HEADROOM_CAPS + else: + base, ceiling = _TEXT_WIDTH_HEADROOM_BASE, _TEXT_WIDTH_HEADROOM_CAPS + return width * (base + (ceiling - base) * caps) + + +def _textbox_padding(font_size: float) -> float: + """Return small text-frame slack without visibly lengthening the box.""" + return max( + _TEXTBOX_PADDING_MIN_PX, + min(_TEXTBOX_PADDING_MAX_PX, font_size * _TEXTBOX_PADDING_RATIO), + ) def _override_run_attrs( @@ -1273,7 +1324,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: else: text_width = _estimate_text_runs_width(runs) text_height = font_size * 1.5 - padding = font_size * 0.1 + padding = _textbox_padding(font_size) # Adjust position based on text-anchor if text_anchor == 'middle': diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_narration.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_narration.py index 38f05314e..c85efb292 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_narration.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_narration.py @@ -206,10 +206,16 @@ def apply_recorded_timing( transition_match = re.search(r"]*>", slide_xml) if transition_match: tag = transition_match.group(0) - if "advTm=" in tag: - new_tag = re.sub(r'\sadvTm="[^"]*"', f' advTm="{adv_ms}"', tag, count=1) + is_self_closing = tag.rstrip().endswith("/>") + base_tag = tag.rstrip() + if is_self_closing: + base_tag = re.sub(r"\s*/>$", ">", base_tag, count=1) + if "advTm=" in base_tag: + new_tag = re.sub(r'\sadvTm="[^"]*"', f' advTm="{adv_ms}"', base_tag, count=1) else: - new_tag = tag[:-1] + f' advTm="{adv_ms}">' + new_tag = base_tag[:-1] + f' advTm="{adv_ms}">' + if is_self_closing: + new_tag = new_tag[:-1] + "/>" return slide_xml[:transition_match.start()] + new_tag + slide_xml[transition_match.end():] effect = transition_effect or "fade" diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx.py index 22bea99ac..76beb0119 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx.py @@ -8,10 +8,11 @@ documented command paths keep working: python3 scripts/template_fill_pptx.py scaffold .slide_library.json -o fill_plan.json python3 scripts/template_fill_pptx.py check-plan .slide_library.json fill_plan.json python3 scripts/template_fill_pptx.py apply fill_plan.json -o output.pptx + python3 scripts/template_fill_pptx.py validate Implementation lives in the template_fill_pptx/ package (ooxml, analyzer, scaffolder, checker, text_fill, table_fill, chart_fill, transitions, notes, -package, applier, cli). +package, applier, validator, cli). """ import sys diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/__init__.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/__init__.py index 1fc98d45c..8f3a682c7 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/__init__.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/__init__.py @@ -2,8 +2,8 @@ Direct OOXML editing (no SVG round-trip): select source slides, replace text / table / chart content from a fill plan, and write a new .pptx that keeps -the original PowerPoint design. Four stages mirror the CLI subcommands: -analyze -> scaffold -> check-plan -> apply. +the original PowerPoint design. CLI stages mirror the direct-PPTX workflow: +analyze -> scaffold -> check-plan -> apply -> validate. Public entry: analyze_pptx(), scaffold_plan(), check_plan(), apply_plan(), main(). """ @@ -15,6 +15,7 @@ from .applier import apply_plan from .checker import check_plan, print_check_report from .cli import main from .scaffolder import scaffold_plan +from .validator import print_validate_report, validate_project __all__ = [ "analyze_pptx", @@ -22,5 +23,7 @@ __all__ = [ "check_plan", "print_check_report", "apply_plan", + "validate_project", + "print_validate_report", "main", ] diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py index 10b1fedf1..db400db69 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py @@ -223,6 +223,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "replacements_not_list", "plan_slide": slide_index, "source_slide": source_slide, "message": "replacements must be a list", @@ -239,6 +240,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "replacement_target_not_found", "plan_slide": slide_index, "source_slide": source_slide, "selector": selectors[0] if selectors else "", @@ -274,6 +276,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": status, + "code": "text_capacity" if status == "WARN" else "text_fit", "plan_slide": slide_index, "source_slide": source_slide, "slot_id": slot.get("slot_id"), @@ -296,6 +299,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "table_edits_not_list", "plan_slide": slide_index, "source_slide": source_slide, "message": "table_edits must be a list", @@ -310,6 +314,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "table_target_not_found", "plan_slide": slide_index, "source_slide": source_slide, "selector": selectors[0] if selectors else "", @@ -323,6 +328,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "table_cells_not_list", "plan_slide": slide_index, "source_slide": source_slide, "selector": selectors[0] if selectors else "", @@ -340,6 +346,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "table_cell_out_of_bounds", "plan_slide": slide_index, "source_slide": source_slide, "selector": selectors[0] if selectors else "", @@ -352,6 +359,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "OK", + "code": "table_target_exists", "plan_slide": slide_index, "source_slide": source_slide, "table_id": table.get("table_id"), @@ -365,6 +373,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "chart_edits_not_list", "plan_slide": slide_index, "source_slide": source_slide, "message": "chart_edits must be a list", @@ -379,6 +388,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "chart_target_not_found", "plan_slide": slide_index, "source_slide": source_slide, "selector": selectors[0] if selectors else "", @@ -391,6 +401,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "chart_combo_unsupported", "plan_slide": slide_index, "source_slide": source_slide, "selector": selectors[0] if selectors else "", @@ -409,6 +420,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "chart_data_invalid", "plan_slide": slide_index, "source_slide": source_slide, "selector": selectors[0] if selectors else "", @@ -428,6 +440,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "ERROR", + "code": "chart_series_length_mismatch", "plan_slide": slide_index, "source_slide": source_slide, "selector": selectors[0] if selectors else "", @@ -440,6 +453,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "OK", + "code": "chart_target_valid", "plan_slide": slide_index, "source_slide": source_slide, "chart_id": chart.get("chart_id"), @@ -477,6 +491,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "WARN", + "code": "non_text_content_unedited", "plan_slide": plan_slide_index, "source_slide": source_slide, "message": ( @@ -515,6 +530,7 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: results.append( { "status": "WARN", + "code": "source_reuse_concentration", "source_slide": src, "reuse_count": count, "unused_source_slides": unused_lib_indices, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/cli.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/cli.py index 4ada454e0..018e078f2 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/cli.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/cli.py @@ -1,4 +1,4 @@ -"""Command-line interface: analyze / scaffold / check-plan / apply subcommands.""" +"""Command-line interface: analyze / scaffold / check-plan / apply / validate.""" from __future__ import annotations @@ -33,6 +33,7 @@ from .transitions import ( KEEP_TRANSITION, TRANSITIONS, ) +from .validator import print_validate_report, validate_project def _parse_slide_list(value: str | None) -> list[int] | None: @@ -60,6 +61,10 @@ def _timestamped_pptx_path(path: Path) -> Path: return path.with_name(f"{path.stem}_{timestamp}{path.suffix}") +def _plan_confirmed(plan: dict) -> bool: + return plan.get("status") == "confirmed" + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Analyze and fill native PPTX templates without converting slides to SVG.", @@ -118,6 +123,14 @@ def build_parser() -> argparse.ArgumentParser: default=DEFAULT_TRANSITION_DURATION, help="Transition duration in seconds (default: 0.5).", ) + apply.add_argument( + "--force", + action="store_true", + help="apply without a confirmed fill plan (deliberate recovery/debug only)", + ) + + validate = subparsers.add_parser("validate", help="Read back and validate the latest project export") + validate.add_argument("project_path", help="Template-fill project directory") return parser @@ -160,6 +173,14 @@ def main(argv: list[str] | None = None) -> int: if args.command == "apply": pptx_path = Path(args.pptx_file).expanduser().resolve() plan = _load_json(Path(args.plan_json).expanduser().resolve()) + if not _plan_confirmed(plan) and not args.force: + print( + "Error: fill plan is not confirmed: " + f"{Path(args.plan_json).expanduser().resolve()} " + '(set status to "confirmed" after user approval, or pass --force)', + file=sys.stderr, + ) + return 1 output_path = _timestamped_pptx_path(Path(args.output).expanduser().resolve()) apply_plan( pptx_path, @@ -170,6 +191,11 @@ def main(argv: list[str] | None = None) -> int: ) print(f"Template-filled PPTX -> {output_path}", file=sys.stderr) return 0 + + if args.command == "validate": + report = validate_project(Path(args.project_path)) + print_validate_report(report) + return 0 if report["summary"]["error"] == 0 else 1 except RuntimeError as exc: print(f"Error: {exc}", file=sys.stderr) return 1 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/scaffolder.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/scaffolder.py index 0e83fc5a5..0560711c1 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/scaffolder.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/scaffolder.py @@ -62,6 +62,11 @@ def scaffold_plan( { "source_slide": slide["slide_index"], "purpose": slide.get("page_type", "content_candidate"), + "layout_rationale": { + "layout_pattern": "", + "why_fit": "", + "risk": "", + }, "replacements": replacements, "table_edits": table_edits, "chart_edits": chart_edits, @@ -70,6 +75,8 @@ def scaffold_plan( return { "schema": "template_fill_pptx_plan.v1", + "status": "draft", "source_pptx": library.get("source_pptx"), + "accepted_warnings": [], "slides": slides, } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/validator.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/validator.py new file mode 100644 index 000000000..0a4d933b3 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/validator.py @@ -0,0 +1,278 @@ +"""validate: read back the latest template-fill export and check core contract.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +from .ooxml import _load_json, _write_json + + +_SCRIPTS_DIR = Path(__file__).resolve().parents[1] +_SLIDE_HEADING_RE = re.compile(r"^## Slide\s+\d+\s*$", re.MULTILINE) +_TOTAL_SLIDES_RE = re.compile(r"^- Total slides:\s*(\d+)\s*$", re.MULTILINE) + + +def _latest_export(project_path: Path) -> Path: + exports_dir = project_path / "exports" + candidates = [path for path in exports_dir.glob("*.pptx") if path.is_file()] + if not candidates: + raise RuntimeError(f"No PPTX exports found in: {exports_dir}") + return max(candidates, key=lambda path: path.stat().st_mtime) + + +def _readback_slide_count(markdown: str) -> int: + match = _TOTAL_SLIDES_RE.search(markdown) + if match: + return int(match.group(1)) + return len(_SLIDE_HEADING_RE.findall(markdown)) + + +def _normalize_text(value: object) -> str: + return re.sub(r"\s+", "", str(value or "")).strip() + + +def _contains_text(markdown: str, value: object) -> bool: + normalized = _normalize_text(value) + if not normalized: + return True + return normalized in _normalize_text(markdown) + + +def _first_library(project_path: Path) -> dict[str, Any] | None: + libraries = sorted((project_path / "analysis").glob("*.slide_library.json")) + if not libraries: + return None + return _load_json(libraries[0]) + + +def _slot_role_lookup(library: dict[str, Any] | None) -> dict[tuple[int, str], str]: + if library is None: + return {} + lookup: dict[tuple[int, str], str] = {} + for slide in library.get("slides", []): + slide_index = int(slide.get("slide_index", 0)) + for slot in slide.get("slots", []): + slot_id = slot.get("slot_id") + if isinstance(slot_id, str): + lookup[(slide_index, slot_id)] = str(slot.get("role") or "") + return lookup + + +def _title_texts(plan: dict[str, Any], role_lookup: dict[tuple[int, str], str]) -> list[tuple[int, str]]: + titles: list[tuple[int, str]] = [] + for plan_slide, slide in enumerate(plan.get("slides", []), start=1): + source_slide = int(slide.get("source_slide", 0)) + replacements = slide.get("replacements", []) + if not isinstance(replacements, list): + continue + first_text = "" + found_title = False + for replacement in replacements: + if not isinstance(replacement, dict): + continue + text = str(replacement.get("text") or "").strip() + if not text: + continue + if not first_text: + first_text = text + slot_id = replacement.get("slot_id") + if isinstance(slot_id, str) and role_lookup.get((source_slide, slot_id)) == "title_candidate": + titles.append((plan_slide, text)) + found_title = True + break + if not found_title and first_text: + titles.append((plan_slide, first_text)) + return titles + + +def _table_tokens(plan: dict[str, Any]) -> list[tuple[int, str]]: + tokens: list[tuple[int, str]] = [] + for plan_slide, slide in enumerate(plan.get("slides", []), start=1): + for table_edit in slide.get("table_edits", []) or []: + for cell in table_edit.get("cells", []) or []: + text = str(cell.get("text") or "").strip() + if text: + tokens.append((plan_slide, text)) + return tokens + + +def _chart_tokens(plan: dict[str, Any]) -> list[tuple[int, str]]: + tokens: list[tuple[int, str]] = [] + for plan_slide, slide in enumerate(plan.get("slides", []), start=1): + for chart_edit in slide.get("chart_edits", []) or []: + for category in chart_edit.get("categories", []) or []: + if str(category).strip(): + tokens.append((plan_slide, str(category))) + for series in chart_edit.get("series", []) or []: + name = str(series.get("name") or "").strip() + if name: + tokens.append((plan_slide, name)) + for value in series.get("values", []) or []: + tokens.append((plan_slide, str(value))) + return tokens + + +def _append_token_checks( + *, + results: list[dict[str, Any]], + summary: dict[str, int], + markdown: str, + tokens: list[tuple[int, str]], + code: str, + label: str, +) -> None: + seen: set[tuple[int, str]] = set() + for plan_slide, text in tokens: + key = (plan_slide, text) + if key in seen: + continue + seen.add(key) + if _contains_text(markdown, text): + summary["ok"] += 1 + continue + summary["warn"] += 1 + results.append( + { + "status": "WARN", + "code": code, + "plan_slide": plan_slide, + "message": f"{label} not found in read-back Markdown", + "text": text, + } + ) + + +def validate_project(project_path: Path) -> dict[str, Any]: + """Run read-back validation for a template-fill project.""" + project_path = project_path.expanduser().resolve() + plan_path = project_path / "analysis" / "fill_plan.json" + if not plan_path.is_file(): + raise RuntimeError(f"Missing fill plan: {plan_path}") + + plan = _load_json(plan_path) + output_path = _latest_export(project_path) + validation_dir = project_path / "validation" + validation_dir.mkdir(parents=True, exist_ok=True) + readback_path = validation_dir / "readback.md" + + ppt_to_md = _SCRIPTS_DIR / "source_to_md" / "ppt_to_md.py" + try: + subprocess.run( + [sys.executable, str(ppt_to_md), str(output_path), "-o", str(readback_path)], + cwd=_SCRIPTS_DIR.parents[2], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + except FileNotFoundError as exc: + raise RuntimeError(f"Missing executable: {sys.executable}") from exc + except subprocess.CalledProcessError as exc: + details = (exc.stderr or exc.stdout or "").strip() + raise RuntimeError(details or "ppt_to_md read-back failed") from exc + + markdown = readback_path.read_text(encoding="utf-8", errors="replace") + results: list[dict[str, Any]] = [] + summary = {"ok": 0, "warn": 0, "error": 0} + + expected_slides = len(plan.get("slides", []) or []) + actual_slides = _readback_slide_count(markdown) + if expected_slides == actual_slides: + summary["ok"] += 1 + else: + summary["error"] += 1 + results.append( + { + "status": "ERROR", + "code": "slide_count_mismatch", + "expected": expected_slides, + "actual": actual_slides, + "message": "read-back slide count does not match fill_plan.slides", + } + ) + + library = _first_library(project_path) + role_lookup = _slot_role_lookup(library) + _append_token_checks( + results=results, + summary=summary, + markdown=markdown, + tokens=_title_texts(plan, role_lookup), + code="title_missing_in_readback", + label="key title", + ) + _append_token_checks( + results=results, + summary=summary, + markdown=markdown, + tokens=_table_tokens(plan), + code="table_text_missing_in_readback", + label="table text", + ) + _append_token_checks( + results=results, + summary=summary, + markdown=markdown, + tokens=_chart_tokens(plan), + code="chart_text_missing_in_readback", + label="chart text", + ) + + planned_notes = [ + slide + for slide in plan.get("slides", []) or [] + if str(slide.get("notes") or slide.get("speaker_notes") or "").strip() + ] + note_sections = markdown.count("### Speaker Notes") + if len(planned_notes) == note_sections: + summary["ok"] += 1 + elif planned_notes: + summary["warn"] += 1 + results.append( + { + "status": "WARN", + "code": "notes_count_mismatch", + "expected": len(planned_notes), + "actual": note_sections, + "message": "read-back speaker notes count does not match planned notes", + } + ) + elif note_sections: + summary["warn"] += 1 + results.append( + { + "status": "WARN", + "code": "unexpected_notes_in_readback", + "expected": 0, + "actual": note_sections, + "message": "read-back contains speaker notes although the plan has no notes fields", + } + ) + + report = { + "schema": "template_fill_pptx_validate.v1", + "project": str(project_path), + "export": str(output_path), + "readback": str(readback_path), + "summary": summary, + "results": results, + } + _write_json(validation_dir / "validate_report.json", report) + return report + + +def print_validate_report(report: dict[str, Any]) -> None: + """Print a compact validation report.""" + summary = report["summary"] + print(f"validate: ok={summary['ok']} warn={summary['warn']} error={summary['error']}") + print(f"export: {report['export']}") + print(f"readback: {report['readback']}") + for item in report["results"]: + text = item.get("text") + suffix = f" text={text!r}" if text else "" + print(f"{item['status']} {item['code']}: {item['message']}{suffix}") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-enhance-pptx.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-enhance-pptx.md new file mode 100644 index 000000000..f6fb3f041 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-enhance-pptx.md @@ -0,0 +1,303 @@ +--- +description: Native enhancement platform for existing PPTX files, starting with notes/audio/timings/transitions without SVG conversion +--- + +# Native Enhance PPTX Workflow + +> Standalone workflow for enhancing an existing PowerPoint deck without regenerating it. V1 implements the `narration` module: speaker notes, narration audio, slide auto-advance timings, and page transitions. + +This workflow treats a `.pptx` as the artifact to preserve. It archives the source file into a lightweight project, uses `ppt_to_md.py` only to understand slide content, then patches the archived PPTX package directly through OOXML zip operations. + +--- + +## 1. Platform Contract + +| Rule | Contract | +|---|---| +| Source file | If already under `projects/`, move it into the enhancement project; otherwise copy it | +| Visible slides | Do not rewrite existing text, shapes, images, charts, tables, masters, or layouts | +| Route | Direct PPTX package patching; no SVG conversion | +| Output | A new `.pptx` under `/exports/` | +| Project kind | `native_pptx_enhancement` | + +**Hard rule**: Native enhancement is append-oriented. It may add notes, media, timings, transitions, relationships, and content-type records. It must not regenerate slides. + +**Forbidden — SVG pipeline**: +- Do not run `pptx_template_import.py` +- Do not create `svg_output/` +- Do not run `finalize_svg.py` +- Do not run `svg_to_pptx.py` + +**OOXML execution model**: + +```text +source.pptx +→ unzip to temporary work directory +→ patch only required package parts +→ rezip to exports/_enhanced.pptx +``` + +--- + +## 2. Module Scope + +| Module | V1 status | Behavior | +|---|---:|---| +| `narration.notes` | Enabled | Add or replace speaker notes generated from slide content | +| `narration.audio` | Enabled | Embed one audio file per slide | +| `narration.timings` | Enabled | Set narrated slides to auto-advance by audio duration | +| `narration.transitions` | Enabled | Add page-level transitions for narrated/selected slides | +| `delivery.check` | Planned | Font/media/hidden-slide/file-size validation | +| `media` | Planned | Background music, video, media compression | +| `presenter` | Planned | Q&A notes, speaker cues, rehearsal artifacts | +| `animation` | Planned | Explicit object-level animation only | +| `visible-stamp` | Planned | Watermark/footer/logo; requires explicit confirmation | + +**Default — V1 only**: Do not implement planned modules inside this workflow yet. Keep V1 focused on the narration module. + +**Object animation boundary**: Object-level animation is not part of V1. If the user asks for it, treat it as a future module or a separate explicitly confirmed task. + +--- + +## 3. When to Run + +| Condition | Action | +|---|---| +| Existing `.pptx` + wants notes / narration / voiceover / auto-play / page transitions while keeping format stable | Run this workflow | +| Existing `.pptx` + asks to optimize it but says not to change existing content or layout | Run this workflow only for V1 narration enhancements; clarify any visible-slide request | +| Existing `.pptx` + asks to beautify or re-layout | Use [`beautify-pptx`](./beautify-pptx.md) | +| Existing `.pptx` + asks to fill new content into the design | Use [`template-fill-pptx`](./template-fill-pptx.md) | +| PPT Master generated project with `svg_output/` | Use [`generate-audio`](./generate-audio.md) for narration | + +--- + +## 4. Create the Project and Draft Plan + +🚧 **GATE**: User provided an existing `.pptx`. + +Run: + +```bash +python3 skills/ppt-master/scripts/native_enhance_pptx.py init "" --name "" +``` + +Project layout: + +| Path | Purpose | +|---|---| +| `/project.json` | Project schema, kind, enabled modules, source paths, defaults | +| `/sources/.pptx` | Archived source PPTX used for package patching | +| `/sources/.md` | `ppt_to_md.py` output for slide understanding | +| `/analysis/slide_index.json` | Slide order and PPTX slide part mapping | +| `/notes/` | Per-slide spoken notes, named `001.md`, `002.md`, ... | +| `/audio/` | Per-slide narration media, named `001.mp3`, `002.mp3`, ... | +| `/exports/` | Enhanced PPTX copies | +| `/validation/` | Coverage reports and read-back artifacts | + +**Validation**: `project.json` contains `schema: native_pptx_enhancement_project.v1`, `kind: native_pptx_enhancement`, and `modules` containing `notes`, `audio`, `timings`, `transitions`. + +**Source import rule**: When `` is inside the repo's `projects/` tree, `init` moves it into `/sources/`. When it is outside `projects/`, `init` copies it into `/sources/`. The mode is recorded in `project.json` as `source_import.mode`. + +The `init` command also writes: + +```text +/analysis/enhancement_plan.json +``` + +**Hard rule**: Treat this draft plan as the first user-facing artifact. Do not generate notes, list voices, generate audio, or apply package patches before the user confirms which enhancements to add. + +--- + +## 5. Enhancement Plan Confirmation + +🚧 **GATE**: Step 4 complete; `/analysis/enhancement_plan.json` exists. + +If the project already existed or notes/audio coverage changed, refresh the draft: + +```bash +python3 skills/ppt-master/scripts/native_enhance_pptx.py plan "" +``` + +Present the plan to the user before generating notes or audio: + +| Module | Recommended default | Confirmation question | +|---|---|---| +| `notes` | Enabled | Add/replace speaker notes generated from slide content? | +| `audio` | Enabled when user wants narration/video/autoplay | Generate one narration audio file per slide? | +| `timings` | Enabled with audio | Set slide auto-advance from audio duration? | +| `transitions` | Enabled, `fade` 0.5s | Add page transitions? Which effect/duration? | + +**⛔ BLOCKING**: Stop here and wait for explicit user confirmation. Do not generate notes, generate audio, or patch the PPTX until the user confirms the module plan. + +After confirmation, update `/analysis/enhancement_plan.json`: + +```json +{ + "status": "confirmed" +} +``` + +Also set each confirmed module's `enabled` value. Disabled modules must stay in the file with `enabled: false`, not be deleted. + +--- + +## 6. Generate Notes From Existing Slides + +🚧 **GATE**: Step 5 confirmed; `notes.enabled` is true; `/sources/.md` exists. + +Read: + +| File | Use | +|---|---| +| `/sources/.md` | Visible slide text, tables, extracted notes, image references | +| `/analysis/slide_index.json` | Exact slide count and target note filenames | + +Write: + +```text +/notes/001.md +/notes/002.md +... +``` + +**Hard rule**: Notes are spoken narration only. Do not include stage directions, implementation comments, timing labels, markdown tables, or visible-slide rewrite instructions. + +**Hard rule**: Notes must be faithful to the slide. They may explain visible content, but must not add unsupported facts. + +| Slide type | Notes length | +|---|---| +| Cover / section divider | 1-2 short sentences | +| Dense content page | 2-4 sentences | +| Chart / table page | Explain the reading path, then state the takeaway | +| Ending page | One concise close | + +Run coverage check: + +```bash +python3 skills/ppt-master/scripts/native_enhance_pptx.py validate "" +``` + +> Note: before audio generation, missing audio returns exit code `2` only when `audio.enabled` is true. Missing notes are not acceptable once this step is complete. + +--- + +## 7. Audio Voice Confirmation + +🚧 **GATE**: Step 6 complete; `audio.enabled` is true. + +Follow the same one-shot interaction standard as [`generate-audio`](./generate-audio.md): + +1. Determine the notes' primary language. +2. List available voices for the selected backend. +3. Recommend backend, voice, rate/settings, and whether to embed audio back into the PPTX. +4. Ask the user to accept all recommendations or override any field. + +For edge voices: + +```bash +python3 skills/ppt-master/scripts/notes_to_audio.py --list-voices --locale +``` + +**⛔ BLOCKING**: Stop here and wait for explicit user confirmation of audio backend, voice, rate/settings, and embedding. Do not run `notes_to_audio.py` before this confirmation. + +Record the confirmed audio config into `project.json`: + +```json +{ + "audio": { + "provider": "edge", + "voice": "zh-CN-YunjianNeural", + "rate": "+0%" + } +} +``` + +--- + +## 8. Generate Audio + +🚧 **GATE**: Step 7 confirmed; notes files exist under `/notes/`. + +Run with the confirmed values: + +```bash +python3 skills/ppt-master/scripts/notes_to_audio.py "" \ + --voice --rate +``` + +**Default — edge (may override)**: Use `edge` unless the user requests a cloud provider or supplies a cloned voice ID. + +**Naming contract**: Audio stems match note stems: `001.md` → `001.mp3`. + +Validate: + +```bash +python3 skills/ppt-master/scripts/native_enhance_pptx.py validate "" +``` + +--- + +## 9. Apply V1 Enhancements + +🚧 **GATE**: Enhancement plan is confirmed; notes are ready if requested; audio is ready if requested. + +Run: + +```bash +python3 skills/ppt-master/scripts/native_enhance_pptx.py apply "" +``` + +Optional: + +```bash +python3 skills/ppt-master/scripts/native_enhance_pptx.py apply "" \ + --transition fade \ + --transition-duration 0.5 \ + --narration-padding 0.4 \ + --overwrite +``` + +Patch scope: + +| Package area | Append/update | +|---|---| +| `ppt/notesSlides/` | Notes slide parts | +| `ppt/notesMasters/` | Notes master only when needed | +| `ppt/slides/_rels/slideN.xml.rels` | Relationships for notes/audio/media/poster | +| `ppt/media/` | Narration audio and transparent poster | +| `ppt/slides/slideN.xml` | Hidden autoplay audio shape and page timing | +| `[Content_Types].xml` | Required content types | + +**Hard rule**: Do not modify existing slide shapes, text bodies, images, chart data, master/layout parts, or existing non-target relationships. + +--- + +## 10. Validate Output + +Run read-back: + +```bash +python3 skills/ppt-master/scripts/source_to_md/ppt_to_md.py \ + "/exports/_enhanced.pptx" \ + -o "/validation/readback.md" +``` + +Check: + +| Check | Expected | +|---|---| +| Slide count | Same as source | +| Visible content | No intentional changes | +| Notes | Present on intended slides | +| Audio media | Present under `ppt/media/` when generated | +| Auto-play | Narrated slides advance by audio duration | + +```markdown +## ✅ Native PPTX Enhancement V1 Complete + +- [x] Project initialized at `` +- [x] Source PPTX archived into `/sources/` +- [x] V1 narration module applied +- [x] Enhanced PPTX exported to `/exports/.pptx` +- [x] Read-back validation written to `/validation/readback.md` +``` diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-narration-pptx.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-narration-pptx.md new file mode 100644 index 000000000..71008466a --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-narration-pptx.md @@ -0,0 +1,35 @@ +--- +description: Compatibility reference for existing-PPTX narration enhancement +--- + +# Native Narration PPTX Workflow + +> Superseded routing: use [`native-enhance-pptx`](./native-enhance-pptx.md) for all existing-PPTX native enhancement work. + +This file remains only as a compatibility reference for older agents and links. Do not maintain a separate narration workflow here. + +## 1. Routing + +| Need | Action | +|---|---| +| Existing `.pptx` + add speaker notes, narration audio, auto-advance, or page transitions while preserving visible slides | Run [`native-enhance-pptx`](./native-enhance-pptx.md) | +| PPT Master generated project with `svg_output/` / `svg_final/` | Use [`generate-audio`](./generate-audio.md) | +| Existing `.pptx` + beautify or re-layout visible slides | Use [`beautify-pptx`](./beautify-pptx.md) | +| Existing `.pptx` + fill new content into a native design | Use [`template-fill-pptx`](./template-fill-pptx.md) | + +**Hard rule**: Do not follow the retired `native_narration_pptx.py` workflow steps directly. Use the stable `native_enhance_pptx.py` entry point documented in [`native-enhance-pptx`](./native-enhance-pptx.md). + +## 2. Compatibility Boundary + +`native_narration_pptx.py` may remain as a backward-compatible implementation entry point, but user-facing workflow execution must use: + +```bash +python3 skills/ppt-master/scripts/native_enhance_pptx.py init "" --name "" +python3 skills/ppt-master/scripts/native_enhance_pptx.py plan "" +python3 skills/ppt-master/scripts/native_enhance_pptx.py validate "" +python3 skills/ppt-master/scripts/native_enhance_pptx.py apply "" +``` + +**Source import rule**: The active workflow archives the source PPTX into `/sources/`; sources already under the repo's `projects/` tree are moved, and external sources are copied. + +**Output rule**: Enhanced decks are written under `/exports/` with the `_enhanced.pptx` suffix unless the caller passes an explicit `--output`. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/template-fill-pptx.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/template-fill-pptx.md index e8d9f2e49..d55b88735 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/template-fill-pptx.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/template-fill-pptx.md @@ -41,12 +41,15 @@ If the content material is only a topic with no supporting facts, gather or ask ## Step 2: Create the Project Workspace -Create a dedicated project directory under `projects/`. Do not write outputs directly into `projects/` root. +Create a dedicated project directory under `projects/`. Do not write outputs directly into `projects/` root. Reuse the standard project manager so source import rules stay consistent with the rest of the repository: ```bash -mkdir -p "/sources" "/analysis" "/exports" "/validation" +python3 skills/ppt-master/scripts/project_manager.py init "" --format ppt169 +python3 skills/ppt-master/scripts/project_manager.py import-sources "" "" "" ``` +**Source import rule**: `project_manager.py import-sources` copies files from outside the repository and moves repo-local files by default, unless `--copy` / `--move` is explicitly supplied. Keep this shared behavior; do not create a separate template-fill import path. + Use this fixed layout: | Path | Required content | @@ -62,10 +65,10 @@ Use this fixed layout: ## Step 3: Extract the PPTX Intake Bundle -Run: +`project_manager.py import-sources` automatically runs the standard PPTX intake for imported PowerPoint files and writes `.slide_library.json` into `/analysis/`. If you are working from a manually assembled project that does not have the intake artifact, run the template-fill analyzer directly: ```bash -python3 skills/ppt-master/scripts/pptx_intake.py "/sources/" -o "/analysis" +python3 skills/ppt-master/scripts/template_fill_pptx.py analyze "/sources/" -o "/analysis/.slide_library.json" ``` Read `/analysis/.slide_library.json` (intake prefixes per-deck artifacts by the template deck's file stem) and identify: @@ -95,7 +98,7 @@ A page's layout already encodes a rhetorical shape — a single hero statement, **Hard rule**: The target story controls output order. Source slides may move forward, move backward, be omitted, or be reused several times when their layout matches multiple target messages. Never treat source slide order as a default outline unless the user explicitly asks to preserve it. -**Required mapping pass**: Create a concise page-to-layout rationale in `/analysis/` before finalizing the plan. It can be JSON or Markdown, but it must record the intended target slide, chosen `source_slide`, and the layout reason (for example: `three-column strategy`, `two-problem contrast`, `timeline`, `metric focus`, `chapter divider`). This is evidence that selection came from template structure rather than sequential replacement. +**Required mapping pass**: Record a concise page-to-layout rationale in each planned slide before finalizing the plan. Use the per-slide `layout_rationale` object in `fill_plan.json` with `layout_pattern`, `why_fit`, and `risk`. This is human-review evidence that selection came from template structure rather than sequential replacement; it is not a mechanical checker gate. --- @@ -118,11 +121,25 @@ The plan structure: ```json { "schema": "template_fill_pptx_plan.v1", + "status": "draft", "source_pptx": "projects/source.pptx", + "accepted_warnings": [ + { + "plan_slide": 3, + "slot_id": "s03_sh5", + "code": "text_capacity", + "reason": "User accepted dense wording" + } + ], "slides": [ { "source_slide": 1, "purpose": "cover", + "layout_rationale": { + "layout_pattern": "hero cover", + "why_fit": "Large title and subtitle slots fit the opening message without redesign.", + "risk": "Subtitle must stay short." + }, "notes": "Speaker notes for this filled slide.", "transition": "fade", "replacements": [ @@ -158,7 +175,10 @@ The plan structure: | Decision | Rule | |---|---| +| `status` | Keep `"draft"` until the user has reviewed the page sequence / reuse / deletion decisions. Set to `"confirmed"` only after approval. | | `source_slide` | Repeat the same value across multiple entries to reuse one source layout for several output slides; order is free and must follow the target story rather than source deck order | +| `layout_rationale` | Human review aid for page selection. Include `layout_pattern`, `why_fit`, and `risk`; it is not a mechanical checker gate. | +| `accepted_warnings` | Optional audit trail for warnings the user or agent explicitly accepts. `check-plan` warnings remain non-blocking; errors must be fixed. | | `notes` | Optional spoken speaker notes for the filled slide — see **Speaker notes** below; write prose, not a copy of the on-slide text | | `transition` | Optional per-slide page transition; overrides the `apply --transition` default. Accepts an effect name (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`), `none` to strip it, or `{ "effect": "push", "duration": 0.6 }` | | `replacements` | Target by `slot_id` whenever possible; `shape_id` and `shape_name` are fallback selectors | @@ -219,12 +239,16 @@ Interpret the report: | Body much longer than source slot | Compress, split across another selected page, or choose a larger source page | | Missing target | Fix `slot_id` / `shape_id`; do not apply the plan | +`check-plan` emits stable `code` fields in its JSON results so warnings can be tracked without parsing message text. Warnings are advisory and do not fail the command; record any intentionally accepted warning in `accepted_warnings` when it matters for review. Errors are blocking and must be fixed before apply. + **Default fitting policy**: Check fit against visual capacity, not raw character count. CJK characters, Latin letters, numbers, and punctuation occupy different visual widths; old placeholder text is only a weak signal. Use `capacity_visual_width` when present, together with `slots[].geometry` and `slots[].text_metrics.font_size_px`, to decide whether to rewrite, split, or choose a different source layout. Do not use per-item font shrinking as a default strategy because it breaks template consistency. --- ## Step 6: Apply the Plan +⛔ **BLOCKING GATE**: The user has reviewed the planned output order, omitted pages, reused pages, and material-to-layout fit. Set `/analysis/fill_plan.json` top-level `status` to `"confirmed"` only after that review. `apply` rejects an unconfirmed plan by default; `--force` exists only for deliberate recovery/debug use. + Run: ```bash @@ -256,10 +280,10 @@ The script: Run a lightweight readability check: ```bash -python3 skills/ppt-master/scripts/source_to_md/ppt_to_md.py "/exports/" +python3 skills/ppt-master/scripts/template_fill_pptx.py validate "" ``` -Move or copy the read-back Markdown and extracted files into `/validation/` so `exports/` contains only final deliverables. +The validator finds the latest PPTX in `/exports/`, runs `ppt_to_md.py` into `/validation/readback.md`, and writes `/validation/validate_report.json`. `exports/` must contain only final deliverables. Verify: @@ -269,9 +293,9 @@ Verify: | Slide count | Matches `len(fill_plan.slides)` | | Key title text | Appears in the extracted Markdown | | Native table cells | Updated values appear in the extracted Markdown table | -| Native chart data | Updated labels / values are present in the cloned chart XML | +| Native chart data | Updated labels / values are readable from the extracted Markdown when `ppt_to_md.py` can surface them | | Multi-line body text | Preserves intended line / paragraph breaks | -| Speaker notes | `ppt_to_md.py` can read the generated PPTX without notes-related errors | +| Speaker notes | Read-back note count matches planned `notes` fields | | Missing target errors | None from `template_fill_pptx.py apply` | If the extracted text is correct but visual overflow is likely, reduce the text in `fill_plan.json` and re-run Step 4. @@ -281,10 +305,11 @@ If the extracted text is correct but visual overflow is likely, reduce the text - [x] Standard PPTX intake extracted from the source deck, including `.slide_library.json` - [x] `fill_plan.json` selects only pages that fit the target story -- [x] `check-plan` run and capacity warnings resolved or explicitly accepted +- [x] User reviewed the story structure and `fill_plan.json` has `status: "confirmed"` +- [x] `check-plan` run; errors fixed; warnings reviewed / optionally recorded in `accepted_warnings` - [x] Output PPTX generated through direct OOXML text replacement - [x] Speaker notes embedded when `notes` fields are present -- [x] `ppt_to_md.py` readability check passed +- [x] `template_fill_pptx.py validate` read-back check passed ``` --- diff --git a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json index f0c8e78f3..e20e7febd 100644 --- a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "shadcn", "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", - "commit": "35983528c233250b990f6172f1a60df228409a37", + "commit": "c520191cd4c36760ed425b650e139fe6d7e29038", "adapter": "claude-skill", "sourcePath": "skills/shadcn", - "syncedAt": "2026-06-25T16:00:00Z" + "syncedAt": "2026-06-26T15:59:59Z" } diff --git a/plugins/codex/plugins/ui-ux-pro-max/README.md b/plugins/codex/plugins/ui-ux-pro-max/README.md index 1df71809d..635e9ce4e 100755 --- a/plugins/codex/plugins/ui-ux-pro-max/README.md +++ b/plugins/codex/plugins/ui-ux-pro-max/README.md @@ -9,8 +9,8 @@

- npm - npm downloads + npm + npm downloads GitHub stars PayPal

@@ -266,7 +266,7 @@ Install directly in Claude Code with two commands: ```bash # Install CLI globally -npm install -g uipro-cli +npm install -g ui-ux-pro-max-cli # Go to your project cd /path/to/your/project @@ -293,6 +293,8 @@ uipro init --ai augment # Augment uipro init --ai all # All assistants ``` +The npm package is `ui-ux-pro-max-cli`; it still installs the `uipro` command. Older `uipro-cli` releases are stale and should not be used for current assets. + ### Global Install (Available for All Projects) ```bash @@ -464,7 +466,7 @@ The codebase has been restructured to use a **template-based generation system** **Always use the CLI to install:** ```bash -npm install -g uipro-cli +npm install -g ui-ux-pro-max-cli uipro init --ai ``` @@ -524,16 +526,16 @@ Use these commit types for correct version bumps: - `feat:` -> minor release - `feat!:` or `BREAKING CHANGE:` -> major release -The release workflow only needs the default `GITHUB_TOKEN`; it does not publish to npm. +The release workflow uses the default `GITHUB_TOKEN` for GitHub releases and the repository `NPM_TOKEN` secret to publish `ui-ux-pro-max-cli` to npm. ## Troubleshooting ### `uipro: unknown command 'uninstall'` or `unknown command 'update'` -Your installed version of `uipro-cli` is outdated. Update it and retry: +Your installed version of `ui-ux-pro-max-cli` is outdated. Update it and retry: ```bash -npm install -g uipro-cli@latest +npm install -g ui-ux-pro-max-cli@latest uipro uninstall ``` @@ -561,20 +563,20 @@ rm -rf .agents/skills/ui-ux-pro-max # Antigravity This is a known issue with versions prior to v2.5.1. The repository used symlinks internally which some installation tools can't handle. **Fix:** use the CLI installer instead: ```bash -npm install -g uipro-cli +npm install -g ui-ux-pro-max-cli uipro init --ai claude ``` Or wait for the next release where this is resolved. -### `npm install -g uipro-cli` fails with permission error +### `npm install -g ui-ux-pro-max-cli` fails with permission error ```bash # macOS/Linux — use a Node version manager (recommended) or sudo -sudo npm install -g uipro-cli +sudo npm install -g ui-ux-pro-max-cli # Or use npx without installing globally -npx uipro-cli init --ai claude +npx ui-ux-pro-max-cli init --ai claude ``` ### Python not found when running design system commands diff --git a/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json index 94e583805..2fc0f4499 100644 --- a/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "ui-ux-pro-max", "repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git", "ref": "main", - "commit": "318e0b2e4069b578c729228455328a1ca88f640d", + "commit": "9fd25fe07e46ae444edc356e62fe913347ab9e23", "adapter": "claude-skill", "sourcePath": ".claude/skills/ui-ux-pro-max", - "syncedAt": "2026-06-25T16:00:00Z" + "syncedAt": "2026-06-26T15:59:59Z" } diff --git a/plugins/codex/plugins/ui-ux-pro-max/skills/ui-ux-pro-max/SKILL.md b/plugins/codex/plugins/ui-ux-pro-max/skills/ui-ux-pro-max/SKILL.md index 24016f1d1..049c85f9b 100644 --- a/plugins/codex/plugins/ui-ux-pro-max/skills/ui-ux-pro-max/SKILL.md +++ b/plugins/codex/plugins/ui-ux-pro-max/skills/ui-ux-pro-max/SKILL.md @@ -358,7 +358,7 @@ Extract key information from user request: - **Product type**: Entertainment (social, video, music, gaming), Tool (scanner, editor, converter), Productivity (task manager, notes, calendar), or hybrid - **Target audience**: C-end consumer users; consider age group, usage context (commute, leisure, work) - **Style keywords**: playful, vibrant, minimal, dark mode, content-first, immersive, etc. -- **Stack**: React Native (this project's only tech stack) +- **Stack**: Match the project's framework. The engine ships guidance for many stacks (see [Available Stacks](#available-stacks) below) — pass the matching `--stack` (e.g. `nextjs`, `react`, `shadcn`, `vue`, `svelte`, `astro`, `swiftui`, `flutter`, `react-native`). ### Step 2: Generate Design System (REQUIRED) @@ -438,12 +438,14 @@ python3 skills/ui-ux-pro-max/scripts/search.py "" --domain [-n | App interface a11y | `web` | `--domain web "accessibilityLabel touch safe-areas"` | | AI prompt / CSS keywords | `prompt` | `--domain prompt "minimalism"` | -### Step 4: Stack Guidelines (React Native) +### Step 4: Stack Guidelines (match your framework) -Get React Native implementation-specific best practices: +Get implementation-specific best practices for the stack you're building in. +Pass the `--stack` that matches the project's framework: ```bash -python3 skills/ui-ux-pro-max/scripts/search.py "" --stack react-native +python3 skills/ui-ux-pro-max/scripts/search.py "" --stack +# e.g. --stack nextjs | react | shadcn | vue | svelte | astro | swiftui | flutter | react-native ``` --- @@ -468,9 +470,26 @@ python3 skills/ui-ux-pro-max/scripts/search.py "" --stack react-native ### Available Stacks +Run `ls /data/stacks/` to see the live set. Shipped stacks: + | Stack | Focus | |-------|-------| +| `react` | Components, hooks, render performance | +| `nextjs` | App Router, RSC, Server Actions, rendering | +| `vue` | Components, Composition API, reactivity | +| `nuxtjs` | Nuxt app patterns, SSR data fetching | +| `nuxt-ui` | Nuxt UI component patterns | +| `svelte` | Components, stores, transitions | +| `astro` | Islands, content, partial hydration | +| `shadcn` | shadcn/ui primitives, composition | +| `html-tailwind` | Tailwind utility patterns | +| `angular` | Components, signals, services | +| `laravel` | Blade / server-rendered UI patterns | +| `swiftui` | Views, state, navigation (iOS/macOS) | +| `flutter` | Widgets, state, navigation | +| `jetpack-compose` | Composables, state, navigation (Android) | | `react-native` | Components, Navigation, Lists | +| `threejs` | 3D scenes, materials, performance | --- @@ -482,7 +501,7 @@ python3 skills/ui-ux-pro-max/scripts/search.py "" --stack react-native - Product type: Tool (AI search engine) - Target audience: C-end users looking for fast, intelligent search - Style keywords: modern, minimal, content-first, dark mode -- Stack: React Native +- Stack: Next.js (a homepage is a web surface; use a web `--stack`) ### Step 2: Generate Design System (REQUIRED) @@ -505,7 +524,7 @@ python3 skills/ui-ux-pro-max/scripts/search.py "search loading animation" --doma ### Step 4: Stack Guidelines ```bash -python3 skills/ui-ux-pro-max/scripts/search.py "list performance navigation" --stack react-native +python3 skills/ui-ux-pro-max/scripts/search.py "list performance navigation" --stack nextjs ``` **Then:** Synthesize design system + detailed searches and implement the design. @@ -533,7 +552,7 @@ python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system - Use **multi-dimensional keywords** — combine product + industry + tone + density: `"entertainment social vibrant content-dense"` not just `"app"` - Try different keywords for the same need: `"playful neon"` → `"vibrant dark"` → `"content-first minimal"` - Use `--design-system` first for full recommendations, then `--domain` to deep-dive any dimension you're unsure about -- Always add `--stack react-native` for implementation-specific guidance +- Add the `--stack` that matches the project's framework for implementation-specific guidance ### Common Sticking Points