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.
This commit is contained in:
KeyInfo Bot
2026-06-27 00:01:09 +08:00
parent 979f593eb7
commit 7f2f7fc46d
26 changed files with 1948 additions and 106 deletions
+8 -8
View File
@@ -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"
}
]
}
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-06-25T16:01:19Z"
"syncedAt": "2026-06-26T16:01:08Z"
}
@@ -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"
}
@@ -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 <name>` where `<name>` is the project
directory basename.
- `--state ~/.agent-browser/sessions/<name>-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 <url>
```
`--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:<port>/_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.
---
@@ -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"
}
@@ -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 15) 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.
@@ -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 <template.pptx> --inheritance-mode both
Template fill (direct PPTX, no SVG conversion):
```bash
mkdir -p <project_path>/sources <project_path>/analysis <project_path>/exports <project_path>/validation
python3 scripts/project_manager.py init <project_name> --format ppt169
python3 scripts/project_manager.py import-sources <project_path> <source.pptx> <material...>
# Manual fallback when import-sources did not produce analysis/<stem>.slide_library.json:
python3 scripts/template_fill_pptx.py analyze <project_path>/sources/<source.pptx> -o <project_path>/analysis/<stem>.slide_library.json
python3 scripts/template_fill_pptx.py scaffold <project_path>/analysis/<stem>.slide_library.json -o <project_path>/analysis/fill_plan.json --slides "1,3,4"
python3 scripts/template_fill_pptx.py check-plan <project_path>/analysis/<stem>.slide_library.json <project_path>/analysis/fill_plan.json -o <project_path>/analysis/check_report.json
python3 scripts/template_fill_pptx.py apply <project_path>/sources/<source.pptx> <project_path>/analysis/fill_plan.json -o <project_path>/exports/filled.pptx
python3 scripts/template_fill_pptx.py validate <project_path>
```
`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 <effect>` (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 <effect>` (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 <source.pptx> --name <project_slug>
python3 scripts/native_enhance_pptx.py plan <project_path>
python3 scripts/native_enhance_pptx.py validate <project_path>
python3 scripts/native_enhance_pptx.py apply <project_path>
```
Post-processing and export:
@@ -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 <source.pptx> [--name project_name]
python3 scripts/native_enhance_pptx.py plan <project_path>
python3 scripts/native_enhance_pptx.py validate <project_path>
python3 scripts/native_enhance_pptx.py apply <project_path>
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())
@@ -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 <source.pptx> [--name project_name]
python3 scripts/native_enhance_pptx.py apply <project_path> [--output output.pptx]
python3 scripts/native_enhance_pptx.py validate <project_path>
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(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
f'<Relationships xmlns="{PACKAGE_REL_NS}">\n</Relationships>',
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*<Relationship\b[^>]*\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'<Override\b[^>]*\bPartName="/{re.escape(part_name)}"[^>]*/>',
content_types,
):
return content_types
override = f' <Override PartName="/{part_name}" ContentType="{content_type}"/>'
return content_types.replace("</Types>", override + "\n</Types>")
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"<p:transition\b[^>]*/>", slide_xml):
return re.sub(r"\s*<p:transition\b[^>]*/>", "\n" + transition_xml, slide_xml, count=1)
if re.search(r"<p:transition\b[^>]*>.*?</p:transition>", slide_xml, re.S):
return re.sub(
r"\s*<p:transition\b[^>]*>.*?</p:transition>",
"\n" + transition_xml,
slide_xml,
count=1,
flags=re.S,
)
if "<p:timing>" in slide_xml:
return slide_xml.replace("<p:timing>", transition_xml + "\n <p:timing>", 1)
return slide_xml.replace("</p:sld>", transition_xml + "\n</p:sld>", 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' <p:transition advTm="{adv_ms}"/>'
slide_xml = re.sub(r"\s*<p:transition\b[^>]*/>", "", slide_xml, count=1)
slide_xml = re.sub(
r"\s*<p:transition\b[^>]*>.*?</p:transition>",
"",
slide_xml,
count=1,
flags=re.S,
)
if "<p:timing>" in slide_xml:
return slide_xml.replace("<p:timing>", transition_xml + "\n <p:timing>", 1)
return slide_xml.replace("</p:sld>", transition_xml + "\n</p:sld>", 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())
@@ -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 <text>. 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 <a:spcBef> on the downstream <a:p>. First entry is 0.
@@ -228,7 +270,8 @@ def _classify_paragraph_block(
a fresh <a:p>. First entry is always False (paragraph head).
Conditions (all must hold):
- No leading text directly under <text>.
- No direct text under <text>, except simple leading text that can be
promoted into a synthetic first-line <tspan>.
- Every direct child is a <tspan>.
- 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 <text> 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
@@ -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 <text> 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 '<text> has no x anchor'
for child in list(text_el):
if not cls._is_tspan(child):
return '<text> has non-tspan child'
if (child.tail or "").strip():
return '<tspan> 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 <image ...> elements (capture the full tag)
@@ -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':
@@ -206,10 +206,16 @@ def apply_recorded_timing(
transition_match = re.search(r"<p:transition\b[^>]*>", 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"
@@ -8,10 +8,11 @@ documented command paths keep working:
python3 scripts/template_fill_pptx.py scaffold <stem>.slide_library.json -o fill_plan.json
python3 scripts/template_fill_pptx.py check-plan <stem>.slide_library.json fill_plan.json
python3 scripts/template_fill_pptx.py apply <deck.pptx> fill_plan.json -o output.pptx
python3 scripts/template_fill_pptx.py validate <project>
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
@@ -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",
]
@@ -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,
@@ -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
@@ -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,
}
@@ -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}")
@@ -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 `<project>/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/<source>_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 "<source.pptx>" --name "<project_slug>"
```
Project layout:
| Path | Purpose |
|---|---|
| `<project>/project.json` | Project schema, kind, enabled modules, source paths, defaults |
| `<project>/sources/<source>.pptx` | Archived source PPTX used for package patching |
| `<project>/sources/<source>.md` | `ppt_to_md.py` output for slide understanding |
| `<project>/analysis/slide_index.json` | Slide order and PPTX slide part mapping |
| `<project>/notes/` | Per-slide spoken notes, named `001.md`, `002.md`, ... |
| `<project>/audio/` | Per-slide narration media, named `001.mp3`, `002.mp3`, ... |
| `<project>/exports/` | Enhanced PPTX copies |
| `<project>/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 `<source.pptx>` is inside the repo's `projects/` tree, `init` moves it into `<project>/sources/`. When it is outside `projects/`, `init` copies it into `<project>/sources/`. The mode is recorded in `project.json` as `source_import.mode`.
The `init` command also writes:
```text
<project>/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; `<project>/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 "<project>"
```
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 `<project>/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; `<project>/sources/<source>.md` exists.
Read:
| File | Use |
|---|---|
| `<project>/sources/<source>.md` | Visible slide text, tables, extracted notes, image references |
| `<project>/analysis/slide_index.json` | Exact slide count and target note filenames |
Write:
```text
<project>/notes/001.md
<project>/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 "<project>"
```
> 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 <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 `<project>/notes/`.
Run with the confirmed values:
```bash
python3 skills/ppt-master/scripts/notes_to_audio.py "<project>" \
--voice <chosen-ShortName> --rate <chosen-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 "<project>"
```
---
## 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 "<project>"
```
Optional:
```bash
python3 skills/ppt-master/scripts/native_enhance_pptx.py apply "<project>" \
--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 \
"<project>/exports/<source>_enhanced.pptx" \
-o "<project>/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 `<project>`
- [x] Source PPTX archived into `<project>/sources/`
- [x] V1 narration module applied
- [x] Enhanced PPTX exported to `<project>/exports/<file>.pptx`
- [x] Read-back validation written to `<project>/validation/readback.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 "<source.pptx>" --name "<project_slug>"
python3 skills/ppt-master/scripts/native_enhance_pptx.py plan "<project>"
python3 skills/ppt-master/scripts/native_enhance_pptx.py validate "<project>"
python3 skills/ppt-master/scripts/native_enhance_pptx.py apply "<project>"
```
**Source import rule**: The active workflow archives the source PPTX into `<project>/sources/`; sources already under the repo's `projects/` tree are moved, and external sources are copied.
**Output rule**: Enhanced decks are written under `<project>/exports/` with the `_enhanced.pptx` suffix unless the caller passes an explicit `--output`.
@@ -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 "<project_dir>/sources" "<project_dir>/analysis" "<project_dir>/exports" "<project_dir>/validation"
python3 skills/ppt-master/scripts/project_manager.py init "<project_name>" --format ppt169
python3 skills/ppt-master/scripts/project_manager.py import-sources "<project_dir>" "<source.pptx>" "<material...>"
```
**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 `<stem>.slide_library.json` into `<project_dir>/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 "<project_dir>/sources/<source.pptx>" -o "<project_dir>/analysis"
python3 skills/ppt-master/scripts/template_fill_pptx.py analyze "<project_dir>/sources/<source.pptx>" -o "<project_dir>/analysis/<stem>.slide_library.json"
```
Read `<project_dir>/analysis/<stem>.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 `<project_dir>/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 `<project_dir>/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 "<project_dir>/exports/<output.pptx>"
python3 skills/ppt-master/scripts/template_fill_pptx.py validate "<project_dir>"
```
Move or copy the read-back Markdown and extracted files into `<project_dir>/validation/` so `exports/` contains only final deliverables.
The validator finds the latest PPTX in `<project_dir>/exports/`, runs `ppt_to_md.py` into `<project_dir>/validation/readback.md`, and writes `<project_dir>/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 `<stem>.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
```
---
@@ -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"
}
+13 -11
View File
@@ -9,8 +9,8 @@
</p>
<p align="center">
<a href="https://www.npmjs.com/package/uipro-cli"><img src="https://img.shields.io/npm/v/uipro-cli?style=flat-square&logo=npm&label=CLI" alt="npm"></a>
<a href="https://www.npmjs.com/package/uipro-cli"><img src="https://img.shields.io/npm/dm/uipro-cli?style=flat-square&label=downloads" alt="npm downloads"></a>
<a href="https://www.npmjs.com/package/ui-ux-pro-max-cli"><img src="https://img.shields.io/npm/v/ui-ux-pro-max-cli?style=flat-square&logo=npm&label=CLI" alt="npm"></a>
<a href="https://www.npmjs.com/package/ui-ux-pro-max-cli"><img src="https://img.shields.io/npm/dm/ui-ux-pro-max-cli?style=flat-square&label=downloads" alt="npm downloads"></a>
<a href="https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/stargazers"><img src="https://img.shields.io/github/stars/nextlevelbuilder/ui-ux-pro-max-skill?style=flat-square&logo=github" alt="GitHub stars"></a>
<a href="https://paypal.me/uiuxpromax"><img src="https://img.shields.io/badge/PayPal-Support%20Development-00457C?style=flat-square&logo=paypal&logoColor=white" alt="PayPal"></a>
</p>
@@ -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 <platform>
```
@@ -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
@@ -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"
}
@@ -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 "<keyword>" --domain <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 "<keyword>" --stack react-native
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack <your-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 "<keyword>" --stack react-native
### Available Stacks
Run `ls <skill>/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 "<keyword>" --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