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:
@@ -172,18 +172,6 @@
|
||||
},
|
||||
"category": "多媒体与生成"
|
||||
},
|
||||
{
|
||||
"name": "superpowers",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/superpowers"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "开发工具"
|
||||
},
|
||||
{
|
||||
"name": "ui-ux-pro-max",
|
||||
"source": {
|
||||
@@ -280,6 +268,18 @@
|
||||
},
|
||||
"category": "知识库与检索"
|
||||
},
|
||||
{
|
||||
"name": "superpowers",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/superpowers"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "开发工具"
|
||||
},
|
||||
{
|
||||
"name": "shadcn",
|
||||
"source": {
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-06-18T16:01:22Z"
|
||||
"syncedAt": "2026-06-19T16:01:21Z"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "7249902c363f0b33dee9a9f7112fb258ae9c3bf7",
|
||||
"commit": "ae6d9096d64d0291933c7a3e6bba2ae4eb533741",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-06-18T16:00:00Z"
|
||||
"syncedAt": "2026-06-19T16:00:00Z"
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
|
||||
| `${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py` | PowerPoint to Markdown |
|
||||
| `${SKILL_DIR}/scripts/source_to_md/web_to_md.py` | Web page to Markdown (supports WeChat via `curl_cffi`) |
|
||||
| `${SKILL_DIR}/scripts/project_manager.py` | Project init / validate / manage |
|
||||
| `${SKILL_DIR}/scripts/icon_sync.py` | Copy chosen library icons into `<project>/icons/` at selection time; missing names reported + non-zero (re-pick gate) |
|
||||
| `${SKILL_DIR}/scripts/analyze_images.py` | Image analysis |
|
||||
| `${SKILL_DIR}/scripts/latex_render.py` | LaTeX formula rendering (manifest-driven PNG assets) |
|
||||
| `${SKILL_DIR}/scripts/image_gen.py` | AI image generation (multi-provider) |
|
||||
@@ -75,6 +76,7 @@ For complete tool documentation, see `${SKILL_DIR}/scripts/README.md`.
|
||||
|----------|------|---------|
|
||||
| `topic-research` | `workflows/topic-research.md` | Pre-pipeline — gather web sources when the user supplies only a topic with no source files |
|
||||
| `template-fill` | `workflows/template-fill-pptx.md` | Give a native PPTX template deck plus source material; select fitting pages (a page may be reused for several output slides) and fill text back without SVG conversion |
|
||||
| `beautify` | `workflows/beautify-pptx.md` | Re-layout an existing PPTX through the SVG pipeline — preserve its text verbatim, inherit its palette/fonts as truth, redo only layout; mirror of `template-fill` |
|
||||
| `create-template` | `workflows/create-template.md` | Standalone layout template creation workflow |
|
||||
| `create-brand` | `workflows/create-brand.md` | Standalone brand-only template creation (identity preset; no SVG page roster) |
|
||||
| `resume-execute` | `workflows/resume-execute.md` | Phase B entry — resume execution in a fresh chat after Phase A (Step 1–5) completed in another session (split mode) |
|
||||
@@ -291,7 +293,7 @@ Read references/strategist.md
|
||||
|
||||
**Confirm UI Auto-Launch (Mandatory — default visual confirmation surface)**: by default the Eight Confirmations are presented through an interactive local page (color swatches, live font previews, candidate picks); the chat path is the always-valid fallback. Steps:
|
||||
|
||||
1. Write the recommendations to `<project_path>/confirm_ui/recommendations.json` (full schema + field mapping: [`scripts/docs/confirm_ui.md`](scripts/docs/confirm_ui.md)). Two kinds of field: **enumerable** (canvas / mode / visual_style / icons / formula policy / generation mode; plus image usage with a Custom path; plus AI source only when image usage may include `ai`) — the page lists common options from `confirm_ui/static/catalogs.json`, so you only name the recommended canonical `id` in a `recommend` block (canvas may be a catalog id like `ppt169` or a custom size/prose; style = `mode` + `visual_style`, two independent picks; icon ids are real libraries such as `tabler-outline`, or `emoji` for system emoji; image usage uses `ai` / `web` / `provided` / `placeholder` / `none`, or a custom prose plan when several sources must be combined; never write bare `"custom"` for image usage — write the actual mixed plan, e.g. "AI cover + user product assets + web industry images"; write `image_ai_path` only when recommending `image_usage: "ai"` or a custom plan that includes AI); **generative** (color, typography, generated-image style) — author a few **candidates** (color: user-facing core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks, plus `body_size` as the body baseline px; when recommending generated images, `image_strategy.candidates` with rendering × palette combinations from strategist h.5). `page_count` / `audience` are plain values. Only open fields show a Custom box: `canvas`, `mode`, `visual_style`, `icons`, `image_usage`, and typography custom text. Closed fields (`image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec`) stay finite. Set `lang` to the page language; visible candidate text should match `lang`, or provide bilingual `name_zh` / `name_en` and `note_zh` / `note_en` fields. Reuse the same candidate thinking as strategist h.5.
|
||||
1. Write the recommendations to `<project_path>/confirm_ui/recommendations.json` (full schema + field mapping: [`scripts/docs/confirm_ui.md`](scripts/docs/confirm_ui.md)). Two kinds of field: **enumerable** (canvas / mode / visual_style / icons / formula policy / generation mode; plus image usage with a Custom path; plus AI source only when image usage may include `ai`) — the page lists common options from `confirm_ui/static/catalogs.json`, so you only name the recommended canonical `id` in a `recommend` block (canvas may be a catalog id like `ppt169` or a custom size/prose; style = `mode` + `visual_style`, two independent picks; icon ids are real libraries such as `tabler-outline`, or `emoji` for system emoji; image usage uses `ai` / `web` / `provided` / `placeholder` / `none`, or a custom prose plan when several sources must be combined; never write bare `"custom"` for image usage — write the actual mixed plan, e.g. "AI cover + user product assets + web industry images"; write `image_ai_path` only when recommending `image_usage: "ai"` or a custom plan that includes AI); **generative** (color, typography, generated-image style) — author **≥3 candidates** each (creative recommendations always offer real choice, never a single silent option — same rule as strategist h.5; fewer than 3 only on the honest-shortfall exception, with a stated reason) (color: user-facing core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks, plus `body_size` as the body baseline px; when recommending generated images, `image_strategy.candidates` with rendering × palette combinations from strategist h.5). `page_count` / `audience` are plain values. Only open fields show a Custom box: `canvas`, `mode`, `visual_style`, `icons`, `image_usage`, and typography custom text. Closed fields (`image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec`) stay finite. Set `lang` to the page language; visible candidate text should match `lang`, or provide bilingual `name_zh` / `name_en` and `note_zh` / `note_en` fields. Reuse the same candidate thinking as strategist h.5.
|
||||
2. Launch the page **in the background and wait for the browser confirmation** (the child server runs detached; the parent command returns after `result.json` is freshly written). **Run this command with a long tool timeout — 600000 ms** — so the `--wait` (≈590 s budget) can complete:
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
|
||||
@@ -540,16 +542,16 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
|
||||
> layout-tight page must keep every dy-stacked line as its own text frame. The
|
||||
> merge detector is conservative; mixed-layout text falls back to per-line frames.
|
||||
|
||||
**Optional animation flags** (the defaults already enable rich entrance animations — adjust only when the user asks for something different):
|
||||
**Optional animation flags** (page transitions are on by default; per-element entrance is off by default — turn it on only when the user asks for it):
|
||||
- `-t <effect>` — page transition. Default `fade`. Options: `fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random` / `none`.
|
||||
- `-a <effect>` — per-element entrance animation. Default `auto` (map effect from group id: chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade; image-like ids `hero` / `figure-` / `image` / `img-` / `kpi` cycle a richer pool — zoom / dissolve / circle / box / diamond / wheel — so multiple images vary across the deck). Pass `none` to disable, a specific effect like `fade`, or `mixed` for the legacy 16-effect cycle. Requires top-level `<g id="...">` groups (already required by Executor).
|
||||
- `-a <effect>` — per-element entrance animation. **Default `none`** — pages appear as a whole, no auto-firing element builds (the unsolicited cascade reads as the "AI deck" tell). Opt in with `auto` (map effect from group id: chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade; image-like ids `hero` / `figure-` / `image` / `img-` / `kpi` cycle a richer pool — zoom / dissolve / circle / box / diamond / wheel — so multiple images vary across the deck), a specific effect like `fade`, or `mixed` for the legacy 16-effect cycle. Requires top-level `<g id="...">` groups (already required by Executor).
|
||||
- `--animation-trigger {on-click,with-previous,after-previous}` — Start mode (matches PowerPoint's animation-pane Start dropdown). Default `after-previous` (click-free cascade; pace via `--animation-stagger`). Use `on-click` for presenter-paced reveals, or `with-previous` for all-at-once.
|
||||
- `--animation-config <path>` — optional object-level sidecar. Default: `<project_path>/animations.json` when present.
|
||||
- `--auto-advance <seconds>` — kiosk-style auto-play.
|
||||
|
||||
**Optional custom animations** (only when the user asks to tune animation order/effects/timing for specific objects):
|
||||
|
||||
Run the standalone [`customize-animations`](workflows/customize-animations.md) workflow. Default export already has global entrance animation; do not create `animations.json` unless object-level customization was requested.
|
||||
Run the standalone [`customize-animations`](workflows/customize-animations.md) workflow. Default export applies page transitions but no per-element entrance animation; create `animations.json` (or pass `-a auto`) only when the user asks for element animation or object-level customization.
|
||||
|
||||
**Optional recorded narration** (only when the user asks for narrated/video export):
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ PPT Master's exported PPTX supports **page transitions** (slide-to-slide) and **
|
||||
| Layer | Default | Why |
|
||||
|---|---|---|
|
||||
| Page transition | `fade`, 0.4s | Calm baseline that suits most decks |
|
||||
| Per-element animation | `auto` effect + `after-previous` trigger, 0.4s duration + 0.5s stagger | Effects are mapped from group id (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like ids (`hero` / `figure-` / `image` / `img-` / `kpi`) cycle a richer visual pool (zoom / dissolve / circle / box / diamond / wheel) so multiple images vary across the deck; unmatched ids cycle a small fade/wipe/fly/zoom pool. Cascades automatically on slide entry — zero interaction |
|
||||
| Per-element animation | **`none` (off)** | A page appears as a whole. Auto-firing element builds are an unsolicited "AI deck" tell, so element entrance is opt-in. Turn it on with `-a auto` (or another effect): effects map from group id (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like ids (`hero` / `figure-` / `image` / `img-` / `kpi`) cycle a richer visual pool (zoom / dissolve / circle / box / diamond / wheel) so multiple images vary across the deck; unmatched ids cycle a small fade/wipe/fly/zoom pool |
|
||||
|
||||
To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the same `svg_output/` (or `svg_final/`) — no need to rerun the LLM. To turn per-element animation off entirely, pass `-a none`.
|
||||
To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the same `svg_output/` (or `svg_final/`) — no need to rerun the LLM. To turn per-element animation on for the whole deck, pass `-a auto`.
|
||||
|
||||
## Custom Object-Level Animation
|
||||
|
||||
Default animation is global. When a deck needs specific object timing — for example title first, chart second, annotation last — use the optional `animations.json` sidecar. The SVG remains static visual source; the sidecar only controls PPTX export behavior.
|
||||
Per-element animation is off by default. To enable it deck-wide, pass `-a auto` at export (no config needed). When a deck instead needs specific object timing — for example title first, chart second, annotation last — use the optional `animations.json` sidecar. The SVG remains static visual source; the sidecar only controls PPTX export behavior.
|
||||
|
||||
Run the standalone [`customize-animations`](../workflows/customize-animations.md) workflow when the user asks to tune animation order, effects, timing, or object-level reveals.
|
||||
|
||||
@@ -79,24 +79,24 @@ Flags:
|
||||
|
||||
## Per-Element Animations
|
||||
|
||||
Enabled by default (`auto` effect + `after-previous` trigger). Three Start modes are available — these mirror PowerPoint's animation-pane "Start" dropdown:
|
||||
Off by default — enable deck-wide with `-a auto` (or another effect). Once enabled, three Start modes are available — these mirror PowerPoint's animation-pane "Start" dropdown:
|
||||
|
||||
- **`on-click`** — entering a slide → first click reveals the first semantic group; each subsequent click reveals the next group in z-order. Suits live presentations where the speaker paces reveals. Forbidden with `--recorded-narration` because video-ready exports need click-free playback.
|
||||
- **`with-previous`** — all groups start together on slide entry, playing their entrance animation in parallel. Stagger ignored.
|
||||
- **`after-previous`** (default) — first group fires on slide entry, subsequent groups cascade after the previous one finishes, with `--animation-stagger` extra spacing. Suits kiosk playback, recorded walkthroughs, or anyone who wants visual flow without clicking.
|
||||
|
||||
```bash
|
||||
# Default behavior (no flags needed): auto effect + after-previous cascade
|
||||
# Default behavior (no flags): page transitions only, no per-element builds
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project>
|
||||
|
||||
# Disable per-element animation entirely
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a none
|
||||
# Enable per-element animation deck-wide (auto effect + after-previous cascade)
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a auto
|
||||
|
||||
# Use a single effect (still cascades via the default after-previous trigger)
|
||||
# Enable with a single effect (cascades via the after-previous trigger)
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation fade
|
||||
|
||||
# Switch to on-click for live presentations (presenter controls pacing)
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation-trigger on-click
|
||||
# Enable and switch to on-click for live presentations (presenter controls pacing)
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a auto --animation-trigger on-click
|
||||
|
||||
# Custom pacing
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation mixed \
|
||||
@@ -108,7 +108,7 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation-trigger w
|
||||
|
||||
22 single effects: `appear`, `fade`, `fly`, `cut`, `zoom`, `wipe`, `split`, `blinds`, `checkerboard`, `dissolve`, `random_bars`, `peek`, `wheel`, `box`, `circle`, `diamond`, `plus`, `strips`, `wedge`, `stretch`, `expand`, `swivel`. Plus three auto-vary modes:
|
||||
|
||||
- `auto` (default) — map effect from the group's SVG id. Information-dense elements get a single stable effect: `chart` / `table` / `legend` / `timeline` / `track` → `wipe`; `card-*` / `pillar-*` / `item-*` / `step-*` / `stage-*` / `tier-*` / `principle-*` → `fly`; `title` / `chapter-*` / `section-*` / `cover-*` / `tagline` / `subtitle` → `fade`; `takeaway` / `callout` / `quote` / `source` / `conclusion` / `note` → `fade`. Image-like ids `hero` / `figure-*` / `image` / `img-*` / `kpi` instead cycle a richer visual pool (`zoom` / `dissolve` / `circle` / `box` / `diamond` / `wheel`) so multiple images vary across the deck. Unmatched ids cycle through `fade` / `wipe` / `fly` / `zoom`.
|
||||
- `auto` (recommended when enabling) — map effect from the group's SVG id. Information-dense elements get a single stable effect: `chart` / `table` / `legend` / `timeline` / `track` → `wipe`; `card-*` / `pillar-*` / `item-*` / `step-*` / `stage-*` / `tier-*` / `principle-*` → `fly`; `title` / `chapter-*` / `section-*` / `cover-*` / `tagline` / `subtitle` → `fade`; `takeaway` / `callout` / `quote` / `source` / `conclusion` / `note` → `fade`. Image-like ids `hero` / `figure-*` / `image` / `img-*` / `kpi` instead cycle a richer visual pool (`zoom` / `dissolve` / `circle` / `box` / `diamond` / `wheel`) so multiple images vary across the deck. Unmatched ids cycle through `fade` / `wipe` / `fly` / `zoom`.
|
||||
- `mixed` (legacy) — deterministic. The first animated group on each slide uses `fade`; later groups cycle through a 16-effect pool (`blinds` / `checkerboard` / `dissolve` / `fly` / `cut` / `random_bars` / `box` / `split` / `strips` / `wedge` / `wheel` / `wipe` / `expand` / `fade` / `swivel` / `zoom`) across the deck. Kept for backward compatibility.
|
||||
- `random` — samples from the legacy 16-effect pool.
|
||||
|
||||
@@ -116,7 +116,7 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation-trigger w
|
||||
|
||||
Flags:
|
||||
|
||||
- `-a/--animation` — effect name, `auto`, `mixed`, `random`, or `none`. Default: `auto`.
|
||||
- `-a/--animation` — effect name, `auto`, `mixed`, `random`, or `none`. Default: `none` (per-element animation off; pass `auto` to enable).
|
||||
- `--animation-trigger` — Start mode (matches PowerPoint): `on-click`, `with-previous`, or `after-previous` (default).
|
||||
- `--animation-duration` — per-element entrance seconds, default `0.4`.
|
||||
- `--animation-stagger` — gap between elements in `after-previous` mode (seconds, default `0.5`). Ignored otherwise.
|
||||
|
||||
@@ -62,6 +62,8 @@ Content purpose?
|
||||
| Story | — | Middle 1500px | Top safe zone 120px, bottom 180px |
|
||||
| WeChat Article Header | Center/left-aligned 48-72px | — | Image on right or as background |
|
||||
|
||||
> **Body font baseline scales with canvas height** — a baseline tuned for 16:9 (18–24px) is far too small on tall canvases (Xiaohongshu / Story / A4). Pick the baseline from the confirmed canvas, not the recommended one; see the per-canvas anchors in [`strategist.md`](strategist.md) §g "Font Size Ramp".
|
||||
|
||||
## ViewBox Examples
|
||||
|
||||
```xml
|
||||
|
||||
@@ -215,6 +215,8 @@ Examples: `01_封面.svg` / `02_目录.svg` / `03_核心优势.svg`; `01_cover.s
|
||||
|
||||
Strategist chooses the library and inventory; Executor only implements. Library details and one-library rule: [`../templates/icons/README.md`](../templates/icons/README.md). This section defines placeholder syntax.
|
||||
|
||||
> **Resolution is project-first.** Strategist copied the chosen icons into `<project_path>/icons/<lib>/` (via `icon_sync.py`); `finalize_svg.py embed-icons` embeds from there, falling back to the global library per-icon. **Custom icons**: drop an `.svg` into `<project_path>/icons/<lib>/` (any `<lib>`, e.g. `custom/`) and reference it as `data-icon="<lib>/<name>"` — it embeds like any other. Reference only icons in the `spec_lock.md` inventory.
|
||||
|
||||
**Built-in icons — Placeholder method (recommended)**:
|
||||
|
||||
```xml
|
||||
|
||||
@@ -314,7 +314,7 @@ python3 scripts/svg_to_pptx.py <project_path>
|
||||
|
||||
**Optional animation flags** (only when the user asks):
|
||||
- `-t <effect>` — page transition (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random` / `none`; default `fade`)
|
||||
- `-a <effect>` — per-element entrance animation (`fade` / `auto` / `mixed` / `random` / one of 22 named effects / `none`; default `auto`, maps effect from group id — image-like ids cycle zoom/dissolve/circle/box/diamond/wheel, other matches map to a single effect, unmatched ids cycle fade/wipe/fly/zoom). Anchors on top-level `<g id="...">` groups.
|
||||
- `-a <effect>` — per-element entrance animation (`fade` / `auto` / `mixed` / `random` / one of 22 named effects / `none`; **default `none`** — pages appear as a whole, no auto element builds; opt in with `auto`, which maps effect from group id — image-like ids cycle zoom/dissolve/circle/box/diamond/wheel, other matches map to a single effect, unmatched ids cycle fade/wipe/fly/zoom). Anchors on top-level `<g id="...">` groups.
|
||||
- `--animation-trigger {on-click,with-previous,after-previous}` — Start mode matching PowerPoint's animation-pane Start dropdown. Default `after-previous` (cascade on slide entry; pace via `--animation-stagger <seconds>`); `on-click` advances per click; `with-previous` plays all groups together.
|
||||
- `--animation-config <path>` — optional object-level animation sidecar. Default: `<project>/animations.json` when present.
|
||||
- `--auto-advance <seconds>` — kiosk-style auto-play
|
||||
|
||||
@@ -28,7 +28,7 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
|
||||
>
|
||||
> **One opt-in exception**: present the spec-refinement line alongside the split-mode note (SKILL.md Step 4). It is OFF by default — the above discipline holds unchanged. Only when the user *explicitly* asks to refine the spec do you hand off to the [refine-spec](../workflows/refine-spec.md) workflow, which produces the full spec first and stops for user review/revision of any part before generation. Never enter it unprompted.
|
||||
|
||||
> **Default presentation surface — Confirm UI.** Deliver the bundled package through the interactive page: write your recommendations to `<project>/confirm_ui/recommendations.json`, then launch per [SKILL.md Step 4](../SKILL.md). You still author everything — enumerable fields name a recommended `id`; generative fields (color `palette`, CJK + Latin typography, generated-image style) carry a few **candidates**, same thinking as h.5. **Always also print the recommendations + URL in chat** as the always-valid fallback. On confirm, read `<project>/confirm_ui/result.json` (`generation_mode: "split"` / `refine_spec: true` are explicit user choices). Skip the page if the user wants chat-only. Full launch flow, field rules, and JSON schema live in [SKILL.md Step 4](../SKILL.md) + [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md) — don't restate them here. The page is a confirmation surface only.
|
||||
> **Default presentation surface — Confirm UI.** Deliver the bundled package through the interactive page: write your recommendations to `<project>/confirm_ui/recommendations.json`, then launch per [SKILL.md Step 4](../SKILL.md). You still author everything — enumerable fields name a recommended `id`; generative fields (color `palette`, CJK + Latin typography, generated-image style) each carry **≥3 distinct candidates** — creative recommendations always offer real choice, never a single silent option, same hard rule and thinking as h.5. Honest-shortfall exception (mirrors h.5): if the constraints genuinely cannot yield 3 non-conflicting options, present the smaller set and say why — never pad with duplicates or known-conflicting fillers. **Always also print the recommendations + URL in chat** as the always-valid fallback. On confirm, read `<project>/confirm_ui/result.json` (`generation_mode: "split"` / `refine_spec: true` are explicit user choices). Skip the page if the user wants chat-only. Full launch flow, field rules, and JSON schema live in [SKILL.md Step 4](../SKILL.md) + [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md) — don't restate them here. The page is a confirmation surface only.
|
||||
|
||||
### a. Canvas Format Confirmation
|
||||
|
||||
@@ -56,6 +56,7 @@ The deck's **narrative + persuasion skeleton** — how the argument is organized
|
||||
|
||||
**Source**:
|
||||
- User supplied their own outline / structure → it is authoritative. Transcribe it into `§IX` as given (page order + titles preserved); still lock a mode, but for register / voice and page-internal treatment, **not** to reshape — never reorder the user's pages or rewrite their given titles. Note in `design_spec.md` that the structure is user-authored. `briefing` imposes the least if no particular "讲法" is intended.
|
||||
- Beautify / re-layout workflow ([`beautify-pptx.md`](../workflows/beautify-pptx.md)) → the extracted source content is authoritative and **verbatim**, one step stricter than the user-outline case above. Each source slide becomes exactly one `§IX` page in source order; transcribe every content block word-for-word — never reshape / re-primary / condense / merge / split / reword. Lock `mode: briefing`; color (e) and typography (g) are whatever the user confirmed in the beautify plan — the source identity (theme or observed) by default, or a content / brand-aware alternative the beautify plan offered and the user picked — locked as truth (the beautify plan already ran the recommendation through the confirm UI, so do not re-recommend here). Charts / tables / images are regenerated from their extracted data in the inherited style (route chart/table data to §VII, pictures to §VIII) — data values stay frozen, the rendering is the deck's own; never carried over verbatim. Layout, hierarchy, rhythm, and visual rendering are what gets redesigned.
|
||||
- A bespoke direction the five don't give — a nameable cadence (dialectic 正反合, myth-vs-reality, countdown, Socratic), a multi-act fusion of modes, or the user's own feel (confrontational here, detached there). Either the user asks, **or you recommend it** when a fusion / bespoke direction genuinely serves the deck better than a single preset (a recommendation the user confirms, like every lock). The *kind* doesn't matter → `mode: custom` + a `mode_behavior:` paragraph that **crystallizes the intent** (act sequence or posture shifts, title voice, page rhythm, register) concretely enough for the Executor to follow per page; it reads only `spec_lock.md`, never the chat. One deck locks **one** value — a fusion is one `custom` describing the acts, never several modes. Avoid only the *dodge*: don't default to `custom` when a preset genuinely fits, and prefer a dominant mode + page-level variation when one mode leads.
|
||||
- No user structure or cadence → recommend by the index's auto-selection table (content / audience signal → mode) plus the deck's stated purpose; the mode does the structural lifting. Present as a recommendation; the user may override.
|
||||
|
||||
@@ -139,7 +140,10 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
|
||||
> 3. Enumerate the concepts the deck actually needs (home, chart, users, …) based on the confirmed outline.
|
||||
> 4. Search for each concept's filename in the chosen library: `ls skills/ppt-master/templates/icons/<chosen-library>/ | grep <keyword>`
|
||||
> 5. Use the verified filename (without `.svg`) as the icon name; always include the library prefix (e.g., `chunk-filled/home`).
|
||||
> 6. List the final icon inventory and chosen library in `design_spec.md` §VI; record the same in `spec_lock.md icons` (including `stroke_width` for stroke-style libraries). Executor may only use icons from this list.
|
||||
> 6. **Copy each chosen icon into the project as you confirm it** — `python3 skills/ppt-master/scripts/icon_sync.py <project_path> <lib/name> [<lib/name> …]`. This populates `<project>/icons/<lib>/` (the set the Executor embeds from) and, more importantly, **validates existence on the spot**.
|
||||
> 7. List the final icon inventory and chosen library in `design_spec.md` §VI; record the same in `spec_lock.md icons` (including `stroke_width` for stroke-style libraries). Executor may only use icons from this list.
|
||||
>
|
||||
> 🚧 **GATE — missing icon = re-pick now**: if `icon_sync.py` reports any name as missing (non-zero exit), that icon is not in the library — re-pick a real filename via `ls … | grep`, fix `§VI` / `spec_lock.md`, and re-run until it exits clean. Never carry a missing icon forward to generation. Over-copying candidates is harmless — finalize embeds only the icons actually referenced by `<use data-icon>`.
|
||||
>
|
||||
> **Do NOT preload any index file** — when the inventory step arrives, use `ls | grep` to search on demand with zero token cost.
|
||||
|
||||
@@ -211,7 +215,17 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
|
||||
|
||||
> **Ramp, not a fixed menu.** All sizes derive from the `body` baseline as a ratio. `spec_lock.md typography` declares `body` plus the slots this deck uses (`title` / `subtitle` / `annotation` by default; add `cover_title` / `hero_number` / `chart_annotation` as needed). Executor may pick any intermediate px within a role's ratio band.
|
||||
|
||||
Baseline choice follows **content density**, not style. Common: `18px` (dense) / `24px` (relaxed). Other integers are fine — `16px` for chart-heavy, `20-22px` for medium, `28-32px` for poster/cover.
|
||||
Baseline scales with **canvas height first, then content density** — the `18`/`24px` figures below anchor to a 720-tall 16:9 canvas (≈2.5–3.3% of height); hold that proportion on taller canvases, never carrying a 16:9 number onto a larger one. Within a canvas, density picks the point in its band (chart-heavy low · medium mid · relaxed / poster / cover high), relative to that canvas's anchor, not a fixed 16:9 px.
|
||||
|
||||
| Canvas | Height | Body baseline (dense–relaxed) |
|
||||
|---|---|---|
|
||||
| PPT 16:9 / 4:3 | 720 / 768 | 18–24 |
|
||||
| Xiaohongshu | 1242×1660 | 40–55 |
|
||||
| WeChat / IG 1:1 | 1080×1080 | 27–36 |
|
||||
| Story / Portrait | 1080×1920 | 48–64 |
|
||||
| A4 | 1240×1754 | 44–58 |
|
||||
|
||||
> **Canvas drives the baseline — re-derive on change, never carry.** The body baseline is a function of the *confirmed* canvas (item a), not the recommended one. If the user overrides the recommended canvas in confirmation, recompute the baseline (and therefore the whole ramp) for the new canvas before writing `spec_lock.md`: the `body_size` in `recommendations.json` was sized for the recommended canvas and is stale the moment the canvas changes.
|
||||
|
||||
| Common recommendation | Points per Page | Body Baseline | Suitable Scenarios |
|
||||
|----------------|----------------|---------------|-------------------|
|
||||
|
||||
@@ -42,7 +42,7 @@ python3 scripts/update_repo.py
|
||||
|------|-----------------|---------------|
|
||||
| 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` | [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) |
|
||||
| SVG pipeline | `finalize_svg.py`, `svg_to_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `animation_config.py`, `notes_to_audio.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.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) |
|
||||
| Repo maintenance | `update_repo.py` | README install/update section |
|
||||
@@ -91,6 +91,7 @@ python3 scripts/template_fill_pptx.py apply <project_path>/sources/<source.pptx>
|
||||
Post-processing and export:
|
||||
|
||||
```bash
|
||||
python3 scripts/extract_svg_assets.py <svg_dir> --icons-dir <icons_dir> --inplace --id-prefix <prefix> # optional: shrink imported/reference SVGs before AI review
|
||||
python3 scripts/total_md_split.py <project_path>
|
||||
python3 scripts/finalize_svg.py <project_path>
|
||||
python3 scripts/svg_to_pptx.py <project_path>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Beautify Identity Extractor
|
||||
|
||||
Extract a source deck's visual identity as JSON for the beautify-pptx workflow:
|
||||
the declared `theme` (palette + major/minor fonts) plus `observed` usage
|
||||
(run-level fonts incl. CJK `ea`, and frequent explicit fill colors) sampled
|
||||
across slides — so the workflow can recommend theme vs actual-usage identity
|
||||
and let the user confirm. Pure read: reuses the pptx_to_svg resolver, writes
|
||||
no PPTX.
|
||||
|
||||
Usage:
|
||||
python3 scripts/beautify_identity.py <source.pptx> [-o identity.json]
|
||||
|
||||
Examples:
|
||||
python3 scripts/beautify_identity.py projects/x/sources/deck.pptx
|
||||
python3 scripts/beautify_identity.py deck.pptx -o projects/x/analysis/identity.json
|
||||
|
||||
Dependencies:
|
||||
None beyond the standard library (reuses scripts/pptx_to_svg/).
|
||||
|
||||
See workflows/beautify-pptx.md for how the emitted identity is consumed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from pptx_to_svg.color_resolver import ColorPalette # noqa: E402
|
||||
from pptx_to_svg.emu_units import NS # noqa: E402
|
||||
from pptx_to_svg.ooxml_loader import OoxmlPackage # noqa: E402
|
||||
|
||||
|
||||
def _font_pair(theme_root, font_tag: str) -> dict[str, str]:
|
||||
"""Read one <a:majorFont> / <a:minorFont> into {latin, ea} (skip empties)."""
|
||||
out: dict[str, str] = {}
|
||||
font = theme_root.find(f".//a:fontScheme/a:{font_tag}", NS)
|
||||
if font is None:
|
||||
return out
|
||||
for slot, key in (("a:latin", "latin"), ("a:ea", "ea"), ("a:cs", "cs")):
|
||||
elem = font.find(slot, NS)
|
||||
if elem is not None:
|
||||
face = (elem.attrib.get("typeface") or "").strip()
|
||||
if face:
|
||||
out[key] = face
|
||||
return out
|
||||
|
||||
|
||||
def _rank(counter: dict[str, int], limit: int) -> list[dict]:
|
||||
"""Frequency-ranked [{value, count}, ...], most common first."""
|
||||
ranked = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
return [{"value": v, "count": n} for v, n in ranked[:limit]]
|
||||
|
||||
|
||||
def _sample_observed(pkg: "OoxmlPackage") -> dict:
|
||||
"""Aggregate run-level fonts + explicit (non-theme) fill colors across slides.
|
||||
|
||||
Theme extraction reports the *declared* identity; a hand-edited deck often
|
||||
overrides it per shape / run. This is a frequency sample of run-level usage
|
||||
(not a full style resolution — it misses schemeClr + master/layout
|
||||
inheritance, and counts chart/gradient fills), enough for the workflow to
|
||||
recommend theme vs observed.
|
||||
"""
|
||||
latin: dict[str, int] = {}
|
||||
ea: dict[str, int] = {}
|
||||
colors: dict[str, int] = {}
|
||||
for slide in pkg.iter_slides():
|
||||
root = slide.part.xml
|
||||
for tag, bucket in (("a:latin", latin), ("a:ea", ea)):
|
||||
for elem in root.iterfind(f".//{tag}", NS):
|
||||
face = (elem.attrib.get("typeface") or "").strip()
|
||||
if face and not face.startswith("+"): # skip +mj-*/+mn-* theme refs
|
||||
bucket[face] = bucket.get(face, 0) + 1
|
||||
for elem in root.iterfind(".//a:srgbClr", NS):
|
||||
val = (elem.attrib.get("val") or "").strip().upper()
|
||||
if val:
|
||||
colors[f"#{val}"] = colors.get(f"#{val}", 0) + 1
|
||||
return {
|
||||
"fonts": {"latin": _rank(latin, 5), "ea": _rank(ea, 5)},
|
||||
"colors": _rank(colors, 8),
|
||||
}
|
||||
|
||||
|
||||
def extract_identity(pptx_path: Path) -> dict:
|
||||
"""Resolve the deck's theme + observed-usage identity, plus canvas."""
|
||||
with OoxmlPackage(pptx_path) as pkg:
|
||||
first = pkg.get_slide(1)
|
||||
master = first.master if first else None
|
||||
theme = pkg.resolve_theme(master)
|
||||
palette_resolver = ColorPalette(master, theme)
|
||||
|
||||
# Presentation-level scheme names; ColorPalette applies clrMap + aliases.
|
||||
scheme = {
|
||||
"background": palette_resolver.resolve_scheme("bg1"),
|
||||
"background_alt": palette_resolver.resolve_scheme("bg2"),
|
||||
"text": palette_resolver.resolve_scheme("tx1"),
|
||||
"text_alt": palette_resolver.resolve_scheme("tx2"),
|
||||
"hyperlink": palette_resolver.resolve_scheme("hlink"),
|
||||
}
|
||||
accents = {
|
||||
f"accent{i}": palette_resolver.resolve_scheme(f"accent{i}")
|
||||
for i in range(1, 7)
|
||||
}
|
||||
palette = {
|
||||
k: (f"#{v}" if v else None)
|
||||
for k, v in {**scheme, **accents}.items()
|
||||
}
|
||||
# accent1 is the conventional primary.
|
||||
palette["primary"] = palette.get("accent1")
|
||||
|
||||
fonts = {}
|
||||
if theme is not None:
|
||||
fonts = {
|
||||
"title": _font_pair(theme.xml, "majorFont"),
|
||||
"body": _font_pair(theme.xml, "minorFont"),
|
||||
}
|
||||
|
||||
w, h = pkg.slide_size_px
|
||||
canvas = {
|
||||
"width_px": round(w),
|
||||
"height_px": round(h),
|
||||
"aspect": round(w / h, 4) if h else None,
|
||||
}
|
||||
|
||||
return {
|
||||
"source": str(pptx_path),
|
||||
"slide_count": pkg.slide_count,
|
||||
"canvas": canvas,
|
||||
"theme": {"palette": palette, "fonts": fonts},
|
||||
"observed": _sample_observed(pkg),
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract a source deck's theme palette + fonts + canvas as JSON.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("source", help="Source .pptx file")
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
help="Write JSON here (default: stdout)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
src = Path(args.source)
|
||||
if not src.is_file():
|
||||
print(f"[ERROR] source not found: {src}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
identity = extract_identity(src)
|
||||
except (RuntimeError, KeyError, ValueError) as exc:
|
||||
print(f"[ERROR] failed to extract identity: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
payload = json.dumps(identity, ensure_ascii=False, indent=2)
|
||||
if args.output:
|
||||
out = Path(args.output)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(payload + "\n", encoding="utf-8")
|
||||
print(f"[OK] identity written to: {out}", file=sys.stderr)
|
||||
else:
|
||||
print(payload)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Beautify Inventory Builder
|
||||
|
||||
Mechanically merge a source deck's extracts into one per-slide ledger for the
|
||||
beautify-pptx workflow: text blocks + tables + charts (from a
|
||||
`template_fill_pptx.py analyze` slide_library.json) joined with the images
|
||||
bound to each slide (from a `ppt_to_md.py` image_manifest.json). The deterministic
|
||||
join only — `ignored` and `needs_confirmation` are emitted empty for the agent
|
||||
to fill with judgment (hidden shapes, combo charts, overcrowded pages, ...).
|
||||
|
||||
Usage:
|
||||
python3 scripts/beautify_inventory.py <slide_library.json> [--images <image_manifest.json>] [-o inventory.json]
|
||||
|
||||
Examples:
|
||||
python3 scripts/beautify_inventory.py projects/x/analysis/slide_library.json \
|
||||
--images projects/x/images/image_manifest.json -o projects/x/analysis/beautify_inventory.json
|
||||
|
||||
Dependencies:
|
||||
None (standard library only).
|
||||
|
||||
See workflows/beautify-pptx.md Step 4 for how the inventory is consumed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _images_by_slide(manifest: list) -> dict[int, list[dict]]:
|
||||
"""Map slide_index -> [image entries on that slide], from ppt_to_md occurrences."""
|
||||
by_slide: dict[int, list[dict]] = {}
|
||||
for entry in manifest:
|
||||
filename = entry.get("filename")
|
||||
for occ in entry.get("occurrences", []):
|
||||
idx = occ.get("slide_index")
|
||||
if idx is None:
|
||||
continue
|
||||
by_slide.setdefault(idx, []).append({
|
||||
"filename": filename,
|
||||
"shape_name": occ.get("shape_name"),
|
||||
"pixel_width": entry.get("pixel_width"),
|
||||
"pixel_height": entry.get("pixel_height"),
|
||||
"display_ratio": occ.get("display_ratio"),
|
||||
"display_left_emu": occ.get("display_left_emu"),
|
||||
"display_top_emu": occ.get("display_top_emu"),
|
||||
"display_width_emu": occ.get("display_width_emu"),
|
||||
"display_height_emu": occ.get("display_height_emu"),
|
||||
"usage_count": entry.get("usage_count"),
|
||||
})
|
||||
return by_slide
|
||||
|
||||
|
||||
def _table_cells(table: dict) -> list[list[str]]:
|
||||
"""Row-major 2D grid of cell text from a slide_library table."""
|
||||
grid = []
|
||||
for row in table.get("rows", []):
|
||||
grid.append([c.get("text", "") for c in row.get("cells", [])])
|
||||
return grid
|
||||
|
||||
|
||||
def build_inventory(slide_library: dict, images_by_slide: dict[int, list[dict]]) -> dict:
|
||||
"""Join slide_library slides with per-slide images into one ledger."""
|
||||
slides_out = []
|
||||
for slide in slide_library.get("slides", []):
|
||||
idx = slide.get("slide_index")
|
||||
text_blocks = [
|
||||
{
|
||||
"slot_id": s.get("slot_id"),
|
||||
"role": s.get("role"),
|
||||
"text": s.get("text", ""),
|
||||
"paragraph_count": s.get("paragraph_count"),
|
||||
"geometry": s.get("geometry"),
|
||||
}
|
||||
for s in slide.get("slots", [])
|
||||
]
|
||||
tables = [
|
||||
{
|
||||
"table_id": t.get("table_id"),
|
||||
"row_count": t.get("row_count"),
|
||||
"column_count": t.get("column_count"),
|
||||
"cells": _table_cells(t), # 2D text grid — convenient for diff
|
||||
"rows": t.get("rows", []), # raw row/col cells — preserves merged / multi-header fidelity
|
||||
}
|
||||
for t in slide.get("tables", [])
|
||||
]
|
||||
charts = [
|
||||
{
|
||||
"chart_id": c.get("chart_id"),
|
||||
"chart_type": c.get("chart_type"),
|
||||
"category_count": c.get("category_count"),
|
||||
"series_count": c.get("series_count"),
|
||||
"categories": c.get("categories", []), # frozen data
|
||||
"series": c.get("series", []), # frozen data (name + values)
|
||||
}
|
||||
for c in slide.get("charts", [])
|
||||
]
|
||||
slides_out.append({
|
||||
"slide_index": idx,
|
||||
"page_type": slide.get("page_type"),
|
||||
"text_blocks": text_blocks,
|
||||
"tables": tables,
|
||||
"charts": charts,
|
||||
"images": images_by_slide.get(idx, []),
|
||||
"ignored": [], # agent fills: hidden shapes, master-only text, image crop/rotation
|
||||
"needs_confirmation": [], # agent fills: combo/dual-axis charts, merged-cell tables, overcrowded pages
|
||||
})
|
||||
|
||||
return {
|
||||
"schema": "beautify_inventory.v1",
|
||||
"source": slide_library.get("source_pptx"),
|
||||
"slide_count": slide_library.get("slide_count", len(slides_out)),
|
||||
"canvas_px": slide_library.get("canvas_px"),
|
||||
"slides": slides_out,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Merge slide_library.json + image_manifest.json into a per-slide beautify inventory.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("slide_library", help="slide_library.json from `template_fill_pptx.py analyze`")
|
||||
parser.add_argument("--images", help="image_manifest.json from `ppt_to_md.py` (optional)")
|
||||
parser.add_argument("-o", "--output", help="Write JSON here (default: stdout)")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
|
||||
lib_path = Path(args.slide_library)
|
||||
if not lib_path.is_file():
|
||||
print(f"[ERROR] slide_library not found: {lib_path}", file=sys.stderr)
|
||||
return 1
|
||||
slide_library = json.loads(lib_path.read_text(encoding="utf-8"))
|
||||
|
||||
manifest: list = []
|
||||
if args.images:
|
||||
img_path = Path(args.images)
|
||||
if not img_path.is_file():
|
||||
print(f"[ERROR] image manifest not found: {img_path}", file=sys.stderr)
|
||||
return 1
|
||||
manifest = json.loads(img_path.read_text(encoding="utf-8"))
|
||||
|
||||
inventory = build_inventory(slide_library, _images_by_slide(manifest))
|
||||
|
||||
payload = json.dumps(inventory, ensure_ascii=False, indent=2)
|
||||
if args.output:
|
||||
out = Path(args.output)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(payload + "\n", encoding="utf-8")
|
||||
print(f"[OK] inventory written to: {out}", file=sys.stderr)
|
||||
else:
|
||||
print(payload)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+75
-6
@@ -2,7 +2,7 @@
|
||||
* Finite/enumerable fields (canvas, mode, visual style, icons, image usage,
|
||||
* AI source, formula policy, generation mode) list ALL options from
|
||||
* /static/catalogs.json with the AI's recommendation marked. Open/generative
|
||||
* fields (color, typography, generated-image style) show a few AI candidates. Open fields also expose
|
||||
* fields (color, typography, generated-image style) show >=3 AI candidates. Open fields also expose
|
||||
* Custom controls. On confirm the page saves result.json and closes.
|
||||
*/
|
||||
(function () {
|
||||
@@ -52,8 +52,11 @@
|
||||
font_body: "Body",
|
||||
font_body_size: "Body baseline size",
|
||||
font_body_size_hint: "All type sizes derive from this body baseline.",
|
||||
body_size_hint_canvas: "This canvas suggests ~{lo}–{hi}px (scales with canvas height).",
|
||||
custom_typography: "Custom typography",
|
||||
custom_typography_placeholder: "Type your font plan, e.g. Heading: Georgia + KaiTi; Body: Microsoft YaHei + Arial…",
|
||||
custom_color: "Custom color",
|
||||
custom_color_placeholder: "Describe your colors in words, e.g. deep navy primary, warm orange accent, white background — or paste HEX values…",
|
||||
role_background: "bg",
|
||||
role_secondary_bg: "2nd bg",
|
||||
role_primary: "primary",
|
||||
@@ -117,8 +120,11 @@
|
||||
font_body: "正文",
|
||||
font_body_size: "正文基准字号",
|
||||
font_body_size_hint: "所有字号按这个正文基准推导。",
|
||||
body_size_hint_canvas: "当前画布建议 ~{lo}–{hi}px(随画布高度缩放)。",
|
||||
custom_typography: "自定义字体方案",
|
||||
custom_typography_placeholder: "输入字体方案,如:标题用楷体;正文用微软雅黑…",
|
||||
custom_color: "自定义配色",
|
||||
custom_color_placeholder: "用文字描述配色,如:深蓝主色、暖橙强调、白色背景——或直接粘贴 HEX 值…",
|
||||
role_background: "背景",
|
||||
role_secondary_bg: "次级背景",
|
||||
role_primary: "主色",
|
||||
@@ -472,7 +478,8 @@
|
||||
function renderCanvas(host) {
|
||||
var sec = section(1, "sec_canvas");
|
||||
enumField(sec, CAT.canvas, recOrFirst("canvas", CAT.canvas),
|
||||
function () { return STATE.canvas; }, function (v) { STATE.canvas = v; }, { allowCustom: true });
|
||||
function () { return STATE.canvas; },
|
||||
function (v) { STATE.canvas = v; refreshBodySizeHint(); }, { allowCustom: true });
|
||||
host.appendChild(sec);
|
||||
}
|
||||
|
||||
@@ -523,6 +530,18 @@
|
||||
// Replaced when the combined color+typography preview mounts; the color and
|
||||
// typography sections call it after every change so the preview stays live.
|
||||
var refreshStylePreview = function () {};
|
||||
// Replaced when the typography section mounts; the canvas section calls it so
|
||||
// the body-size hint tracks the chosen canvas height.
|
||||
var refreshBodySizeHint = function () {};
|
||||
|
||||
// Canvas height (viewBox user units) parsed from a catalog `dim` like
|
||||
// "1242×1660" or from a custom canvas string containing WxH; null if unknown.
|
||||
function canvasHeight(canvasVal) {
|
||||
var dim = null;
|
||||
(CAT.canvas || []).forEach(function (o) { if (o.id === canvasVal) dim = o.dim; });
|
||||
var m = String(dim || canvasVal || "").match(/(\d{2,5})\s*[×xX*]\s*(\d{2,5})/);
|
||||
return m ? parseInt(m[2], 10) : null;
|
||||
}
|
||||
|
||||
function renderColor(host) {
|
||||
var cands = (REC.color && REC.color.candidates) || [];
|
||||
@@ -544,15 +563,31 @@
|
||||
if (hexSwatches[role]) paintSwatch(hexSwatches[role], pal[role]);
|
||||
});
|
||||
}
|
||||
var customInput = el("textarea", "text-input custom-color-input");
|
||||
customInput.rows = 2;
|
||||
customInput.placeholder = t("custom_color_placeholder");
|
||||
customInput.style.display = "none";
|
||||
|
||||
function selectCard(idx) {
|
||||
var c = cands[idx] || {};
|
||||
selectedIdx = idx;
|
||||
STATE.color = { name: c.name || "", palette: Object.assign({}, normPalette(c)) };
|
||||
grid.querySelectorAll(".color-card").forEach(function (card, i) { card.classList.toggle("selected", i === idx); });
|
||||
customInput.style.display = "none";
|
||||
applyHexInputs(STATE.color.palette);
|
||||
refreshStylePreview();
|
||||
}
|
||||
|
||||
function selectCustomColor() {
|
||||
selectedIdx = -1;
|
||||
STATE.color = { name: "custom", custom: customInput.value || "", palette: {} };
|
||||
grid.querySelectorAll(".color-card").forEach(function (card) { card.classList.remove("selected"); });
|
||||
customCard.classList.add("selected");
|
||||
customInput.style.display = "block";
|
||||
customInput.focus();
|
||||
refreshStylePreview();
|
||||
}
|
||||
|
||||
cands.forEach(function (c, idx) {
|
||||
var pal = normPalette(c);
|
||||
var refs = {};
|
||||
@@ -573,7 +608,17 @@
|
||||
card.addEventListener("click", function () { selectCard(idx); });
|
||||
grid.appendChild(card);
|
||||
});
|
||||
var customCard = el("div", "color-card color-card-custom");
|
||||
customCard.appendChild(el("div", "color-name", t("custom_color")));
|
||||
customCard.addEventListener("click", selectCustomColor);
|
||||
grid.appendChild(customCard);
|
||||
sec.appendChild(grid);
|
||||
customInput.addEventListener("input", function () {
|
||||
if (!STATE.color || STATE.color.name !== "custom") selectCustomColor();
|
||||
STATE.color.custom = customInput.value;
|
||||
refreshStylePreview();
|
||||
});
|
||||
sec.appendChild(customInput);
|
||||
|
||||
var override = el("div", "hex-override");
|
||||
override.appendChild(el("div", "subfield-label", t("hex_override")));
|
||||
@@ -607,9 +652,17 @@
|
||||
host.appendChild(sec);
|
||||
|
||||
var selIdx = -1;
|
||||
if (STATE.color && STATE.color.name) cands.forEach(function (c, i) { if (c.name === STATE.color.name) selIdx = i; });
|
||||
if (selIdx >= 0) selectCard(selIdx);
|
||||
else applyHexInputs((STATE.color && STATE.color.palette) || {});
|
||||
if (STATE.color && STATE.color.name && STATE.color.name !== "custom") {
|
||||
cands.forEach(function (c, i) { if (c.name === STATE.color.name) selIdx = i; });
|
||||
}
|
||||
if (STATE.color && STATE.color.name === "custom") {
|
||||
customInput.value = STATE.color.custom || "";
|
||||
selectCustomColor();
|
||||
} else if (selIdx >= 0) {
|
||||
selectCard(selIdx);
|
||||
} else {
|
||||
applyHexInputs((STATE.color && STATE.color.palette) || {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderIcons(host) {
|
||||
@@ -722,7 +775,22 @@
|
||||
refreshStylePreview();
|
||||
});
|
||||
sizeRow.appendChild(sizeInput);
|
||||
sizeRow.appendChild(el("div", "toggle-desc", t("font_body_size_hint")));
|
||||
var sizeHint = el("div", "toggle-desc");
|
||||
sizeRow.appendChild(sizeHint);
|
||||
// Suggest a canvas-appropriate baseline (≈2.5–3.3% of canvas height) so
|
||||
// a 16:9 default is not silently kept on a tall canvas. Hint only — the
|
||||
// user's value is never overwritten; downstream §g re-derives if ignored.
|
||||
refreshBodySizeHint = function () {
|
||||
var h = canvasHeight(STATE.canvas);
|
||||
var txt = t("font_body_size_hint");
|
||||
if (h) {
|
||||
txt += " " + t("body_size_hint_canvas")
|
||||
.replace("{lo}", Math.round(h * 0.025))
|
||||
.replace("{hi}", Math.round(h * 0.033));
|
||||
}
|
||||
sizeHint.textContent = txt;
|
||||
};
|
||||
refreshBodySizeHint();
|
||||
sizeField.appendChild(sizeRow);
|
||||
sec.appendChild(sizeField);
|
||||
|
||||
@@ -930,6 +998,7 @@
|
||||
// re-render: color/typography auto-select would otherwise call it and
|
||||
// write to now-detached nodes until renderStylePreview remounts it.
|
||||
refreshStylePreview = function () {};
|
||||
refreshBodySizeHint = function () {};
|
||||
renderCanvas(host);
|
||||
renderPages(host);
|
||||
renderAudience(host);
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "Enumerable (finite) option universe for the confirm page. Served by Flask static at /static/catalogs.json; the front-end prefers /api/catalogs when the server provides it. These fields list ALL options and the AI marks one as recommended via recommendations.json `recommend`. Open/generative fields (color, typography, generated-image style) are NOT here — the AI authors a few candidates for those. 'canvas' mirrors scripts/config.py CANVAS_FORMATS — keep in sync. User-facing catalog text supports label_zh/label_en, desc_zh/desc_en, group_zh/group_en.",
|
||||
"_comment": "Enumerable (finite) option universe for the confirm page. Served by Flask static at /static/catalogs.json; the front-end prefers /api/catalogs when the server provides it. These fields list ALL options and the AI marks one as recommended via recommendations.json `recommend`. Open/generative fields (color, typography, generated-image style) are NOT here — the AI authors >=3 candidates each for those (creative recommendations always offer real choice). 'canvas' mirrors scripts/config.py CANVAS_FORMATS — keep in sync. User-facing catalog text supports label_zh/label_en, desc_zh/desc_en, group_zh/group_en.",
|
||||
"canvas": [
|
||||
{
|
||||
"id": "ppt169",
|
||||
|
||||
@@ -174,6 +174,7 @@ body {
|
||||
.font-sample-heading { font-size: 24px; font-weight: 700; line-height: 1.3; }
|
||||
.font-sample-body { font-size: 14px; color: #333; margin-top: 4px; }
|
||||
.custom-typography-input { margin-top: 8px; min-height: 58px; resize: vertical; }
|
||||
.custom-color-input { margin-top: 8px; min-height: 58px; resize: vertical; }
|
||||
.font-size-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.font-size-input { width: 96px; }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Confirm UI — Eight Confirmations Page
|
||||
|
||||
> The interactive, visual surface for SKILL.md Step 4 (the Eight Confirmations). Enumerable fields list **all** options from a catalog with the AI's recommendation badged; generative fields (color, typography, generated-image style) show a few AI candidates. Fields whose universe is open (canvas, mode, visual style, icons, image usage) also get a **Custom** box; fully closed fields (AI source when applicable, formula policy, generation mode, refine spec) do not. The AI writes its recommendation to `recommendations.json`; the user's final choices are written back to `result.json` for the AI to read. On confirm the page saves the result and shuts the server down (auto-close). The chat path is always a valid fallback — if the browser cannot open (remote / headless / web host), the AI presents the same confirmations in chat.
|
||||
> The interactive, visual surface for SKILL.md Step 4 (the Eight Confirmations). Enumerable fields list **all** options from a catalog with the AI's recommendation badged; generative fields (color, typography, generated-image style) show **≥3** AI candidates (creative recommendations always offer real choice — same rule as the h.5 image strategy; fewer only on the honest-shortfall exception, with a stated reason). Fields whose universe is open (canvas, mode, visual style, icons, image usage) also get a **Custom** box; fully closed fields (AI source when applicable, formula policy, generation mode, refine spec) do not. The AI writes its recommendation to `recommendations.json`; the user's final choices are written back to `result.json` for the AI to read. On confirm the page saves the result and shuts the server down (auto-close). The chat path is always a valid fallback — if the browser cannot open (remote / headless / web host), the AI presents the same confirmations in chat.
|
||||
|
||||
## `confirm_ui/server.py`
|
||||
|
||||
@@ -31,7 +31,7 @@ pip install flask
|
||||
|
||||
- **Enumerable + custom** — canvas / mode / visual_style / icons / image usage. The page lists common options from `static/catalogs.json`, badges the AI's recommendation, and still offers a Custom box for edge cases (custom canvas size, bespoke narrative mode, mixed image plan, self-provided icon system, etc.).
|
||||
- **Closed enumerable** — formula policy / generation mode / refine spec, plus AI source only when image usage may include `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option. Use pipeline vocabulary: icon ids are actual library ids such as `tabler-outline`, or `emoji` for system emoji; image usage labels mirror Strategist terminology: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. Use custom prose only when several sources are mixed.
|
||||
- **Generative (open)** — color, typography, generated-image style. No finite catalog; the AI authors a few **candidates** the page renders as cards. `page_count` and `audience` are free inputs.
|
||||
- **Generative (open)** — color, typography, generated-image style. No finite catalog; the AI authors **≥3 candidates** the page renders as cards (never a single option — creative fields must offer real choice; fewer than 3 only on the honest-shortfall exception). `page_count` and `audience` are free inputs.
|
||||
|
||||
**Custom box** appears only on fields whose universe is genuinely open — `canvas`, `mode`, `visual_style`, `icons`, and `image_usage`. Fully closed sets — `image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec` — have **no** Custom box; an out-of-catalog value there is snapped back to the recommended option.
|
||||
|
||||
@@ -101,12 +101,14 @@ Both files live under `<project_path>/confirm_ui/`.
|
||||
}
|
||||
```
|
||||
|
||||
> Each `candidates` array above shows **one** entry for brevity — the creative fields (`color`, `typography`, `image_strategy`) must each carry **≥3** in a real file (see the rule above); `selected` indexes the recommended default.
|
||||
|
||||
- `recommend.*` names the recommended `id` for each enumerable field (must match a `catalogs.json` id, or be a free string for a recommended custom value). The page badges and pre-selects it. **Guarantee**: if a `recommend.*` is omitted, the page falls back to the first catalog option so every enumerable field always shows one badged recommendation — but the AI should still set them for a meaningful default. Legacy aliases are accepted for old files (`line` → `tabler-outline`, `filled` → `tabler-filled`, `monochrome` → `chunk-filled`, `search` → `web`, `default` → `auto`, `builtin` → `host-native`), but new files should write canonical ids. For `recommend.image_usage`, do not write bare `"custom"`; if several image sources are mixed, write the concrete prose plan directly, such as `"封面用 AI 生成,产品页用用户素材,行业页用网络来源"` / `"AI cover + user product assets + web industry images"`.
|
||||
- When `recommend.image_usage` is `ai` or a custom plan that includes AI, also set `recommend.image_ai_path` to one of `auto` / `api` / `host-native` / `manual`; the page presents these as explicit choices.
|
||||
- For a custom image plan, the page treats "may include AI" as true only when the recommendation includes `recommend.image_ai_path` or `image_strategy.candidates`; a custom plan without those signals is handled as non-AI and omits AI controls / fields.
|
||||
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders labelled swatches and offers per-role override inputs. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers later when writing `design_spec.md` / `spec_lock.md`; those are not user-facing confirmation choices.
|
||||
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders labelled swatches and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** (parallel to the custom typography box) — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "<text>" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers later when writing `design_spec.md` / `spec_lock.md`; those are not user-facing confirmation choices.
|
||||
- **Candidate display text may be bilingual**: color / typography candidates can provide `name_zh` / `name_en` and `note_zh` / `note_en`; the page falls back to legacy `name` / `note`.
|
||||
- **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the fallback preview `font-family` stack. The page previews CJK sample text with `cjk + css` and Latin sample text with `latin + css`, so the two script choices are visible independently. Each candidate should also include `body_size` (the body baseline in px; common values are 18 and 24, but any reasonable integer is valid). The page exposes `body_size` as an editable numeric field, and also offers a custom typography text box so the user is not limited to the proposed candidates.
|
||||
- **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the fallback preview `font-family` stack. The page previews CJK sample text with `cjk + css` and Latin sample text with `latin + css`, so the two script choices are visible independently. Each candidate should also include `body_size` (the body baseline in px; common values are 18 and 24, but any reasonable integer is valid). The page exposes `body_size` as an editable numeric field whose hint shows a canvas-appropriate range (≈2.5–3.3% of the confirmed canvas height, parsed from the canvas `dim`), updated when the canvas changes — a hint only, never an override, so a 16:9 default is not silently kept on a tall canvas (Xiaohongshu / Story / A4). It also offers a custom typography text box so the user is not limited to the proposed candidates.
|
||||
- **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections.
|
||||
- **Generated image style candidates** live in `image_strategy.candidates` and are shown only when `image_usage` is `ai` or a custom image plan may include generated images. Each candidate records `rendering`, `palette`, and short `visual` / `color` / `mood` lines from Strategist h.5. The chosen value is written to `result.json.image_strategy`; it is omitted when generated images are not part of the plan.
|
||||
- `recommend.generation_mode` and `refine_spec` mirror the two mandatory notes in SKILL.md Step 4. Confirmed `generation_mode: "split"` / `refine_spec: true` are explicit user choices, equivalent to opting in through chat.
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Large Vector Asset Extractor
|
||||
|
||||
Factor large inline vector groups (complex illustrations) out of working SVGs
|
||||
into project icon assets, leaving a one-line `<use data-icon="id"/>`
|
||||
placeholder behind — so the working SVG stays readable (structure, not a wall of
|
||||
`<path>`). Visually lossless and reversible: the existing icon embedding path
|
||||
re-inlines each asset before export, so the exported PPTX remains native shapes,
|
||||
not an embedded picture.
|
||||
|
||||
Because re-inlining restores the extracted vector subtree, the detection
|
||||
threshold is a readability convenience — it changes which blobs are factored
|
||||
out, not whether the export stays editable.
|
||||
|
||||
Usage:
|
||||
python3 scripts/extract_svg_assets.py <svg_dir> [--icons-dir <icons_dir>] [--min-drawables N] [--min-bytes N] [--min-decoration-bytes N] [--inplace] [--clean-stale]
|
||||
|
||||
Examples:
|
||||
python3 scripts/extract_svg_assets.py import_ws/svg --icons-dir import_ws/icons --inplace --id-prefix layered --clean-stale
|
||||
python3 scripts/extract_svg_assets.py project/svg_output --inplace --min-drawables 40
|
||||
|
||||
Dependencies:
|
||||
None (standard library only).
|
||||
|
||||
See workflows/create-template.md and svg_finalize/embed_icons.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
DRAWABLE = {"path", "polygon", "polyline", "rect", "circle", "ellipse", "line"}
|
||||
SEMANTIC_CONTENT = {"text", "tspan", "foreignObject"}
|
||||
DEFAULT_MIN_DRAWABLES = 20
|
||||
DEFAULT_MIN_BYTES = 3000
|
||||
DEFAULT_MIN_DECORATION_BYTES = 3000
|
||||
URL_REF_RE = re.compile(r"url\(\s*(['\"]?)#([^)'\"]\S*?)\1\s*\)")
|
||||
|
||||
|
||||
def _local(tag: object) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
|
||||
|
||||
|
||||
def _drawable_count(elem: ET.Element) -> int:
|
||||
return sum(1 for e in elem.iter() if _local(e.tag) in DRAWABLE)
|
||||
|
||||
|
||||
def _xml_size(elem: ET.Element) -> int:
|
||||
return len(ET.tostring(elem, encoding="utf-8"))
|
||||
|
||||
|
||||
def _large_enough(elem: ET.Element, min_drawables: int, min_bytes: int) -> bool:
|
||||
return _drawable_count(elem) >= min_drawables or _xml_size(elem) >= min_bytes
|
||||
|
||||
|
||||
def _has_semantic_content(elem: ET.Element) -> bool:
|
||||
"""Text-bearing groups must stay readable/editable in the working SVG."""
|
||||
return any(_local(e.tag) in SEMANTIC_CONTENT for e in elem.iter())
|
||||
|
||||
|
||||
def _is_existing_placeholder(elem: ET.Element) -> bool:
|
||||
return _local(elem.tag) == "use" and elem.get("data-icon") is not None
|
||||
|
||||
|
||||
def _is_extractable_subtree(elem: ET.Element) -> bool:
|
||||
"""Pure vector subtrees can be moved; semantic content must stay inline."""
|
||||
if _is_existing_placeholder(elem) or _is_chart_group(elem) or _has_semantic_content(elem):
|
||||
return False
|
||||
return _drawable_count(elem) > 0
|
||||
|
||||
|
||||
def _is_chart_group(elem: ET.Element) -> bool:
|
||||
"""Charts are handled separately (data + calibration) — never extract them."""
|
||||
gid = (elem.get("id") or "").lower()
|
||||
if "chart" in gid:
|
||||
return True
|
||||
return any("chart" in (e.get("id") or "").lower() for e in elem.iter())
|
||||
|
||||
|
||||
def _tag_histogram(elem: ET.Element) -> dict[str, int]:
|
||||
hist: dict[str, int] = {}
|
||||
for e in elem.iter():
|
||||
name = _local(e.tag)
|
||||
if name in DRAWABLE:
|
||||
hist[name] = hist.get(name, 0) + 1
|
||||
return hist
|
||||
|
||||
|
||||
def _is_descendant(container: ET.Element, candidate: ET.Element) -> bool:
|
||||
return any(elem is candidate for elem in container.iter())
|
||||
|
||||
|
||||
def _id_index(root: ET.Element) -> dict[str, ET.Element]:
|
||||
return {elem_id: elem for elem in root.iter() if (elem_id := elem.get("id"))}
|
||||
|
||||
|
||||
def _referenced_ids(elem: ET.Element) -> set[str]:
|
||||
refs: set[str] = set()
|
||||
for item in elem.iter():
|
||||
for attr_name, value in item.attrib.items():
|
||||
refs.update(match.group(2) for match in URL_REF_RE.finditer(value))
|
||||
if _local(attr_name) == "href" and value.startswith("#") and len(value) > 1:
|
||||
refs.add(value[1:])
|
||||
return refs
|
||||
|
||||
|
||||
def _dependency_elements(root: ET.Element, asset_group: ET.Element) -> list[ET.Element]:
|
||||
"""
|
||||
Return external definition elements referenced by the extracted subtree.
|
||||
|
||||
Gradients, patterns, filters, clip paths, and markers often live in the
|
||||
source SVG root <defs>. The extracted asset must carry these dependencies
|
||||
itself; otherwise the standalone icon and later re-inline can lose styling.
|
||||
"""
|
||||
by_id = _id_index(root)
|
||||
dependencies: list[ET.Element] = []
|
||||
seen: set[str] = set()
|
||||
queue = list(_referenced_ids(asset_group))
|
||||
|
||||
while queue:
|
||||
ref_id = queue.pop(0)
|
||||
if ref_id in seen:
|
||||
continue
|
||||
seen.add(ref_id)
|
||||
|
||||
target = by_id.get(ref_id)
|
||||
if target is None or _is_descendant(asset_group, target):
|
||||
continue
|
||||
|
||||
dependencies.append(target)
|
||||
for nested_ref in sorted(_referenced_ids(target)):
|
||||
if nested_ref not in seen:
|
||||
queue.append(nested_ref)
|
||||
|
||||
return dependencies
|
||||
|
||||
|
||||
def _collect_id_mapping(asset_id: str, group: ET.Element, dependencies: list[ET.Element]) -> dict[str, str]:
|
||||
mapping: dict[str, str] = {}
|
||||
for root in [group, *dependencies]:
|
||||
for elem in root.iter():
|
||||
elem_id = elem.get("id")
|
||||
if elem_id and elem_id not in mapping:
|
||||
mapping[elem_id] = f"{asset_id}_{elem_id}"
|
||||
return mapping
|
||||
|
||||
|
||||
def _rewrite_references(elem: ET.Element, id_mapping: dict[str, str]) -> None:
|
||||
def rewrite_url(match: re.Match[str]) -> str:
|
||||
quote, ref_id = match.group(1), match.group(2)
|
||||
new_id = id_mapping.get(ref_id, ref_id)
|
||||
return f"url({quote}#{new_id}{quote})"
|
||||
|
||||
for item in elem.iter():
|
||||
elem_id = item.get("id")
|
||||
if elem_id in id_mapping:
|
||||
item.set("id", id_mapping[elem_id])
|
||||
|
||||
for attr_name, value in list(item.attrib.items()):
|
||||
rewritten = URL_REF_RE.sub(rewrite_url, value)
|
||||
if _local(attr_name) == "href" and value.startswith("#") and value[1:] in id_mapping:
|
||||
rewritten = f"#{id_mapping[value[1:]]}"
|
||||
if rewritten != value:
|
||||
item.set(attr_name, rewritten)
|
||||
|
||||
|
||||
def _find_extractable(root: ET.Element, min_drawables: int, min_bytes: int) -> list[ET.Element]:
|
||||
"""Outermost <g> groups whose drawable count clears the threshold (no nesting)."""
|
||||
found: list[ET.Element] = []
|
||||
|
||||
def walk(elem: ET.Element) -> None:
|
||||
for child in list(elem):
|
||||
if _local(child.tag) != "g":
|
||||
walk(child)
|
||||
continue
|
||||
if (
|
||||
not _is_chart_group(child)
|
||||
and not _has_semantic_content(child)
|
||||
and child.get("data-icon") is None
|
||||
and _large_enough(child, min_drawables, min_bytes)
|
||||
):
|
||||
found.append(child) # outermost qualifying — do not descend
|
||||
else:
|
||||
walk(child)
|
||||
|
||||
walk(root)
|
||||
return found
|
||||
|
||||
|
||||
def _find_extractable_runs(
|
||||
root: ET.Element,
|
||||
min_drawables: int,
|
||||
min_bytes: int,
|
||||
min_decoration_bytes: int,
|
||||
) -> list[tuple[ET.Element, list[ET.Element]]]:
|
||||
"""
|
||||
Consecutive pure-vector children inside mixed groups.
|
||||
|
||||
PPT exports often flatten text and large vector decorations as siblings
|
||||
under the same parent. Whole-group extraction would hide text, so only the
|
||||
contiguous vector runs are factored out.
|
||||
"""
|
||||
found: list[tuple[ET.Element, list[ET.Element]]] = []
|
||||
|
||||
def flush(parent: ET.Element, run: list[ET.Element]) -> None:
|
||||
byte_threshold = min_decoration_bytes if _has_semantic_content(parent) else min_bytes
|
||||
if run and (
|
||||
sum(_drawable_count(child) for child in run) >= min_drawables
|
||||
or sum(_xml_size(child) for child in run) >= byte_threshold
|
||||
):
|
||||
found.append((parent, list(run)))
|
||||
|
||||
def walk(elem: ET.Element) -> None:
|
||||
run: list[ET.Element] = []
|
||||
for child in list(elem):
|
||||
if _is_extractable_subtree(child):
|
||||
run.append(child)
|
||||
continue
|
||||
flush(elem, run)
|
||||
run = []
|
||||
walk(child)
|
||||
flush(elem, run)
|
||||
|
||||
walk(root)
|
||||
return found
|
||||
|
||||
|
||||
def _asset_svg(
|
||||
group: ET.Element,
|
||||
dependencies: list[ET.Element],
|
||||
view_box: str | None,
|
||||
width: str | None,
|
||||
height: str | None,
|
||||
) -> bytes:
|
||||
"""Standalone, independently-viewable SVG carrying the group in page coords."""
|
||||
svg = ET.Element(f"{{{SVG_NS}}}svg")
|
||||
svg.set("data-icon-style", "preserve-color")
|
||||
if view_box:
|
||||
svg.set("viewBox", view_box)
|
||||
if width:
|
||||
svg.set("width", width)
|
||||
if height:
|
||||
svg.set("height", height)
|
||||
if dependencies:
|
||||
defs = ET.SubElement(svg, f"{{{SVG_NS}}}defs")
|
||||
for dependency in dependencies:
|
||||
defs.append(dependency)
|
||||
svg.append(group)
|
||||
return ET.tostring(svg, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _asset_group(nodes: list[ET.Element]) -> ET.Element:
|
||||
group = ET.Element(f"{{{SVG_NS}}}g")
|
||||
for node in nodes:
|
||||
group.append(node)
|
||||
return group
|
||||
|
||||
|
||||
def _asset_id(svg_path: Path, index: int, id_prefix: str) -> str:
|
||||
prefix = f"{id_prefix}_" if id_prefix else ""
|
||||
return f"{prefix}{svg_path.stem}_ill{index:02d}"
|
||||
|
||||
|
||||
def _generated_asset_re(svg_stems: list[str], id_prefix: str) -> re.Pattern[str] | None:
|
||||
if not svg_stems:
|
||||
return None
|
||||
prefix = f"{re.escape(id_prefix)}_" if id_prefix else ""
|
||||
stems = "|".join(re.escape(stem) for stem in svg_stems)
|
||||
return re.compile(rf"^{prefix}(?:{stems})_ill\d+\.svg$")
|
||||
|
||||
|
||||
def _clean_stale_assets(icons_dir: Path, svg_paths: list[Path], id_prefix: str, keep_assets: set[str]) -> list[str]:
|
||||
pattern = _generated_asset_re([path.stem for path in svg_paths], id_prefix)
|
||||
if pattern is None:
|
||||
return []
|
||||
|
||||
removed: list[str] = []
|
||||
for asset_path in sorted(icons_dir.glob("*.svg")):
|
||||
if asset_path.name in keep_assets or not pattern.match(asset_path.name):
|
||||
continue
|
||||
asset_path.unlink()
|
||||
removed.append(asset_path.name)
|
||||
return removed
|
||||
|
||||
|
||||
def _referenced_icon_assets(svg_paths: list[Path]) -> set[str]:
|
||||
assets: set[str] = set()
|
||||
for svg_path in svg_paths:
|
||||
try:
|
||||
root = ET.parse(svg_path).getroot()
|
||||
except ET.ParseError:
|
||||
continue
|
||||
for elem in root.iter():
|
||||
if _local(elem.tag) != "use":
|
||||
continue
|
||||
icon_name = elem.get("data-icon")
|
||||
if icon_name and "/" not in icon_name:
|
||||
assets.add(f"{icon_name}.svg")
|
||||
return assets
|
||||
|
||||
|
||||
def _existing_placeholder_entries(
|
||||
svg_paths: list[Path],
|
||||
icons_dir: Path,
|
||||
known_assets: set[str],
|
||||
) -> list[dict]:
|
||||
by_asset: dict[str, dict] = {}
|
||||
for svg_path in svg_paths:
|
||||
try:
|
||||
root = ET.parse(svg_path).getroot()
|
||||
except ET.ParseError:
|
||||
continue
|
||||
|
||||
for elem in root.iter():
|
||||
if _local(elem.tag) != "use":
|
||||
continue
|
||||
icon_name = elem.get("data-icon")
|
||||
if not icon_name or "/" in icon_name:
|
||||
continue
|
||||
|
||||
asset = f"{icon_name}.svg"
|
||||
if asset in known_assets:
|
||||
continue
|
||||
|
||||
entry = by_asset.setdefault(
|
||||
asset,
|
||||
{
|
||||
"svg": svg_path.name,
|
||||
"svgs": [],
|
||||
"id": icon_name,
|
||||
"icon": icon_name,
|
||||
"asset": asset,
|
||||
"source": "existing-placeholder",
|
||||
"asset_exists": (icons_dir / asset).exists(),
|
||||
},
|
||||
)
|
||||
if svg_path.name not in entry["svgs"]:
|
||||
entry["svgs"].append(svg_path.name)
|
||||
|
||||
entries: list[dict] = []
|
||||
for asset, entry in sorted(by_asset.items()):
|
||||
asset_path = icons_dir / asset
|
||||
if asset_path.exists():
|
||||
try:
|
||||
root = ET.parse(asset_path).getroot()
|
||||
except ET.ParseError:
|
||||
pass
|
||||
else:
|
||||
entry["drawable_count"] = _drawable_count(root)
|
||||
entry["byte_count"] = _xml_size(root)
|
||||
entry["elements"] = _tag_histogram(root)
|
||||
entry["dependencies"] = sorted(
|
||||
elem_id
|
||||
for elem in root.iter()
|
||||
if _local(elem.tag) == "defs"
|
||||
for child in elem
|
||||
if (elem_id := child.get("id"))
|
||||
)
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def _rewritten_path(svg_path: Path, rewritten_dir: Path | None, inplace: bool) -> Path:
|
||||
if inplace:
|
||||
return svg_path
|
||||
if rewritten_dir is None:
|
||||
return svg_path.parent.parent / f"{svg_path.parent.name}-rewritten" / svg_path.name
|
||||
return rewritten_dir / svg_path.name
|
||||
|
||||
|
||||
def extract_file(
|
||||
svg_path: Path,
|
||||
icons_dir: Path,
|
||||
min_drawables: int,
|
||||
min_bytes: int,
|
||||
min_decoration_bytes: int,
|
||||
inplace: bool,
|
||||
id_prefix: str = "",
|
||||
rewritten_dir: Path | None = None,
|
||||
) -> list[dict]:
|
||||
"""Extract qualifying groups from one SVG. Returns inventory entries."""
|
||||
ET.register_namespace("", SVG_NS)
|
||||
tree = ET.parse(svg_path)
|
||||
root = tree.getroot()
|
||||
view_box = root.get("viewBox")
|
||||
width = root.get("width")
|
||||
height = root.get("height")
|
||||
|
||||
targets: list[tuple[ET.Element, list[ET.Element]]] = []
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
group_targets = _find_extractable(root, min_drawables, min_bytes)
|
||||
selected_groups = set(group_targets)
|
||||
|
||||
def inside_selected_group(elem: ET.Element) -> bool:
|
||||
current = elem
|
||||
while current in parents:
|
||||
current = parents[current]
|
||||
if current in selected_groups:
|
||||
return True
|
||||
return False
|
||||
|
||||
for group in group_targets:
|
||||
parent = parents.get(group)
|
||||
if parent is not None:
|
||||
targets.append((parent, [group]))
|
||||
|
||||
for parent, run in _find_extractable_runs(root, min_drawables, min_bytes, min_decoration_bytes):
|
||||
if (
|
||||
parent not in selected_groups
|
||||
and not inside_selected_group(parent)
|
||||
and not any(child in selected_groups for child in run)
|
||||
and all(child in parent for child in run)
|
||||
):
|
||||
targets.append((parent, run))
|
||||
|
||||
if not targets:
|
||||
if not inplace:
|
||||
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
|
||||
rewritten.parent.mkdir(parents=True, exist_ok=True)
|
||||
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
|
||||
return []
|
||||
|
||||
icons_dir.mkdir(parents=True, exist_ok=True)
|
||||
entries = []
|
||||
|
||||
for index, (parent, nodes) in enumerate(targets, start=1):
|
||||
if not nodes or not all(node in parent for node in nodes):
|
||||
continue
|
||||
asset_id = _asset_id(svg_path, index, id_prefix)
|
||||
pos = list(parent).index(nodes[0])
|
||||
group = nodes[0] if len(nodes) == 1 and _local(nodes[0].tag) == "g" else _asset_group(nodes)
|
||||
|
||||
dependencies = [copy.deepcopy(elem) for elem in _dependency_elements(root, group)]
|
||||
dependency_source_ids = sorted({
|
||||
elem_id
|
||||
for dependency in dependencies
|
||||
for elem in dependency.iter()
|
||||
if (elem_id := elem.get("id"))
|
||||
})
|
||||
id_mapping = _collect_id_mapping(asset_id, group, dependencies)
|
||||
_rewrite_references(group, id_mapping)
|
||||
for dependency in dependencies:
|
||||
_rewrite_references(dependency, id_mapping)
|
||||
|
||||
# Asset keeps the group in original page coordinates and carries its defs.
|
||||
asset_bytes = _asset_svg(group, dependencies, view_box, width, height)
|
||||
(icons_dir / f"{asset_id}.svg").write_bytes(asset_bytes)
|
||||
|
||||
placeholder = ET.Element(f"{{{SVG_NS}}}use")
|
||||
placeholder.set("data-icon", asset_id)
|
||||
for node in nodes:
|
||||
if node in parent:
|
||||
parent.remove(node)
|
||||
parent.insert(pos, placeholder)
|
||||
|
||||
entries.append({
|
||||
"svg": svg_path.name,
|
||||
"id": asset_id,
|
||||
"icon": asset_id,
|
||||
"asset": f"{asset_id}.svg",
|
||||
"drawable_count": _drawable_count(group),
|
||||
"byte_count": _xml_size(group),
|
||||
"dependencies": [id_mapping.get(elem_id, elem_id) for elem_id in dependency_source_ids],
|
||||
"elements": _tag_histogram(group),
|
||||
})
|
||||
|
||||
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
|
||||
rewritten.parent.mkdir(parents=True, exist_ok=True)
|
||||
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
|
||||
return entries
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Factor large inline vector groups out of SVGs into reusable assets.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("svg_dir", help="Directory of working SVGs (e.g. import_ws/svg or project/svg_output)")
|
||||
parser.add_argument("-o", "--output", dest="icons_dir", help="Project icon dir (default: <svg_dir>/../icons)")
|
||||
parser.add_argument("--icons-dir", dest="icons_dir", help="Project icon dir (default: <svg_dir>/../icons)")
|
||||
parser.add_argument(
|
||||
"--rewritten-dir",
|
||||
help="Directory for rewritten SVGs when not using --inplace (default: <svg_dir>/../<svg_dir-name>-rewritten)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--inventory",
|
||||
help="Inventory JSON path (default: <svg_dir>/../<svg_dir-name>_vector_asset_inventory.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--id-prefix",
|
||||
default="",
|
||||
help="Optional prefix for generated asset IDs, useful when processing layered and flat SVG dirs into one icons dir",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-drawables", type=int, default=DEFAULT_MIN_DRAWABLES,
|
||||
help=f"Min drawable elements for a group to be extracted (default: {DEFAULT_MIN_DRAWABLES})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-bytes", type=int, default=DEFAULT_MIN_BYTES,
|
||||
help=f"Min XML bytes for a pure-vector group/run to be extracted (default: {DEFAULT_MIN_BYTES})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-decoration-bytes", type=int, default=DEFAULT_MIN_DECORATION_BYTES,
|
||||
help=(
|
||||
"Min XML bytes for pure-vector decoration runs inside text-bearing groups "
|
||||
f"(default: {DEFAULT_MIN_DECORATION_BYTES})"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--inplace", action="store_true",
|
||||
help="Rewrite the source SVGs in place instead of writing to --rewritten-dir",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean-stale",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Remove stale generated assets for the current svg filenames/id prefix "
|
||||
"that are not referenced by this run's inventory"
|
||||
),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
svg_dir = Path(args.svg_dir)
|
||||
if not svg_dir.is_dir():
|
||||
print(f"[ERROR] svg_dir not found: {svg_dir}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
icons_dir = Path(args.icons_dir) if args.icons_dir else svg_dir.parent / "icons"
|
||||
icons_dir.mkdir(parents=True, exist_ok=True)
|
||||
rewritten_dir = Path(args.rewritten_dir) if args.rewritten_dir else None
|
||||
inventory_path = Path(args.inventory) if args.inventory else svg_dir.parent / f"{svg_dir.name}_vector_asset_inventory.json"
|
||||
svg_paths = sorted(svg_dir.glob("*.svg"))
|
||||
|
||||
inventory: list[dict] = []
|
||||
for svg_path in svg_paths:
|
||||
try:
|
||||
inventory.extend(
|
||||
extract_file(
|
||||
svg_path,
|
||||
icons_dir,
|
||||
args.min_drawables,
|
||||
args.min_bytes,
|
||||
args.min_decoration_bytes,
|
||||
args.inplace,
|
||||
args.id_prefix,
|
||||
rewritten_dir,
|
||||
)
|
||||
)
|
||||
except ET.ParseError as exc:
|
||||
print(f"[WARN] skip unparseable {svg_path.name}: {exc}", file=sys.stderr)
|
||||
|
||||
extracted_count = len(inventory)
|
||||
known_assets = {str(entry["asset"]) for entry in inventory}
|
||||
inventory.extend(_existing_placeholder_entries(svg_paths, icons_dir, known_assets))
|
||||
|
||||
stale_removed: list[str] = []
|
||||
if args.clean_stale:
|
||||
keep_assets = {str(entry["asset"]) for entry in inventory}
|
||||
keep_assets.update(_referenced_icon_assets(svg_paths))
|
||||
stale_removed = _clean_stale_assets(
|
||||
icons_dir,
|
||||
svg_paths,
|
||||
args.id_prefix,
|
||||
keep_assets,
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"schema": "vector_asset_inventory.v1",
|
||||
"svg_dir": str(svg_dir),
|
||||
"icons_dir": str(icons_dir),
|
||||
"rewritten_dir": None if args.inplace else str(_rewritten_path(svg_dir / "_sample.svg", rewritten_dir, False).parent),
|
||||
"min_drawables": args.min_drawables,
|
||||
"min_bytes": args.min_bytes,
|
||||
"min_decoration_bytes": args.min_decoration_bytes,
|
||||
"extracted_count": extracted_count,
|
||||
"asset_count": len(inventory),
|
||||
"stale_removed": stale_removed,
|
||||
"assets": inventory,
|
||||
}
|
||||
inventory_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
inventory_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"[OK] extracted {extracted_count} new asset(s), inventoried {len(inventory)} asset(s) -> {icons_dir}", file=sys.stderr)
|
||||
if stale_removed:
|
||||
print(f"[OK] removed {len(stale_removed)} stale generated asset(s)", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -130,7 +130,12 @@ def finalize_project(
|
||||
"""
|
||||
svg_output = project_dir / 'svg_output'
|
||||
svg_final = project_dir / 'svg_final'
|
||||
icons_dir = Path(__file__).parent.parent / 'templates' / 'icons'
|
||||
# Project-first: embed from the deck's own icons/ (synced library icons +
|
||||
# any custom icons), falling back to the global library per-icon.
|
||||
global_icons_dir = Path(__file__).parent.parent / 'templates' / 'icons'
|
||||
project_icons_dir = project_dir / 'icons'
|
||||
icons_dir = project_icons_dir if project_icons_dir.is_dir() else global_icons_dir
|
||||
icons_fallback_dir = global_icons_dir if icons_dir != global_icons_dir else None
|
||||
|
||||
# Check if svg_output exists
|
||||
if not svg_output.exists():
|
||||
@@ -166,7 +171,7 @@ def finalize_project(
|
||||
safe_print("[1/4] Embedding icons...")
|
||||
icons_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
count = embed_icons_in_file(svg_file, icons_dir, dry_run=False, verbose=False)
|
||||
count = embed_icons_in_file(svg_file, icons_dir, dry_run=False, verbose=False, fallback_dir=icons_fallback_dir)
|
||||
icons_count += count
|
||||
if not quiet:
|
||||
if icons_count > 0:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Icon Sync
|
||||
|
||||
Copy chosen library icons into a project's own `icons/` folder at the moment they
|
||||
are selected. Run it with the icon names you are picking; each is copied from the
|
||||
global library into `<project>/icons/<lib>/`. Any name the library does not have
|
||||
is reported on the spot and the command exits non-zero — so you re-pick a valid
|
||||
icon then, not at export time. Over-copying candidates is fine: finalize only
|
||||
embeds the icons actually referenced by `<use data-icon>`, the rest sit unused.
|
||||
|
||||
Custom icons you place in `<project>/icons/<lib>/` yourself are honored too — a
|
||||
name already present in the project is treated as satisfied, not missing.
|
||||
|
||||
Usage:
|
||||
python3 scripts/icon_sync.py <project_path> <lib/name> [<lib/name> ...]
|
||||
|
||||
Examples:
|
||||
python3 scripts/icon_sync.py projects/deck chunk-filled/home tabler-outline/chart
|
||||
python3 scripts/icon_sync.py projects/deck simple-icons/github
|
||||
|
||||
Dependencies:
|
||||
None (standard library only).
|
||||
|
||||
See references/executor-base.md §4 and templates/icons/README.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_LIB_ALIASES = {"chunk": "chunk-filled"}
|
||||
_GLOBAL_ICONS_DIR = Path(__file__).resolve().parent.parent / "templates" / "icons"
|
||||
|
||||
|
||||
def _split_name(icon_name: str) -> tuple[str, str]:
|
||||
"""`lib/name` -> (lib, name), applying the chunk→chunk-filled alias."""
|
||||
if "/" not in icon_name:
|
||||
# legacy un-prefixed names live in chunk-filled/
|
||||
return "chunk-filled", icon_name
|
||||
lib, name = icon_name.split("/", 1)
|
||||
return _LIB_ALIASES.get(lib, lib), name
|
||||
|
||||
|
||||
def sync_icons(project_path: Path, icon_names: list[str], global_dir: Path = _GLOBAL_ICONS_DIR) -> tuple[list[str], list[str]]:
|
||||
"""Copy each `lib/name` from the global library into `<project>/icons/`.
|
||||
|
||||
Returns (copied, missing). A name already present in the project (e.g. a
|
||||
custom icon) counts as satisfied, not missing.
|
||||
"""
|
||||
project_icons = project_path / "icons"
|
||||
copied: list[str] = []
|
||||
missing: list[str] = []
|
||||
|
||||
for raw in icon_names:
|
||||
lib, name = _split_name(raw)
|
||||
src = global_dir / lib / f"{name}.svg"
|
||||
dst = project_icons / lib / f"{name}.svg"
|
||||
if src.is_file():
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
copied.append(f"{lib}/{name}")
|
||||
elif dst.is_file():
|
||||
copied.append(f"{lib}/{name} (already in project)")
|
||||
else:
|
||||
missing.append(f"{lib}/{name}")
|
||||
|
||||
return copied, missing
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Copy chosen library icons into a project's icons/ folder; report missing ones.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("project_path", help="Project directory")
|
||||
parser.add_argument("icons", nargs="+", help="Icon names to copy, e.g. chunk-filled/home")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
project = Path(args.project_path)
|
||||
if not project.is_dir():
|
||||
print(f"[ERROR] project not found: {project}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
copied, missing = sync_icons(project, args.icons)
|
||||
|
||||
if copied:
|
||||
print(f"[OK] {len(copied)} icon(s) in {project / 'icons'}:", file=sys.stderr)
|
||||
for c in copied:
|
||||
print(f" + {c}", file=sys.stderr)
|
||||
|
||||
if missing:
|
||||
print(f"\n[MISSING] {len(missing)} icon(s) not in the library — re-pick before continuing:", file=sys.stderr)
|
||||
for m in missing:
|
||||
lib = m.split("/", 1)[0]
|
||||
print(f" ✗ {m} (search: ls {_GLOBAL_ICONS_DIR}/{lib}/ | grep <keyword>)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -143,6 +143,7 @@ class ProjectManager:
|
||||
"svg_output",
|
||||
"svg_final",
|
||||
"images",
|
||||
"icons",
|
||||
"notes",
|
||||
"templates",
|
||||
SOURCE_DIRNAME,
|
||||
@@ -161,6 +162,7 @@ class ProjectManager:
|
||||
"- `svg_output/`: raw SVG output\n"
|
||||
"- `svg_final/`: finalized SVG output\n"
|
||||
"- `images/`: presentation assets\n"
|
||||
"- `icons/`: project icon set — selected library icons copied in (via icon_sync.py) plus any custom icons you add; embedded from here at export\n"
|
||||
"- `notes/`: speaker notes\n"
|
||||
"- `templates/`: project templates\n"
|
||||
"- `sources/`: source materials and normalized markdown\n"
|
||||
|
||||
+177
-7
@@ -7,8 +7,18 @@ Open XML PowerPoint files into Markdown.
|
||||
|
||||
Primary use case: PPTX source decks -> Markdown for PPT generation input.
|
||||
|
||||
Hyperlinks present in the source deck are preserved: run-level external URLs
|
||||
and slide-internal jumps are emitted as ``[text](url)`` / ``[text](#slide-N)``,
|
||||
with a shape-level ``click_action`` fallback.
|
||||
|
||||
Dependency:
|
||||
pip install python-pptx
|
||||
|
||||
API stability note:
|
||||
Detecting slide-internal jumps (``ppaction://hlinksldjump``) reads
|
||||
``run._r`` (the CT_TextRun lxml element) because python-pptx exposes no
|
||||
public API to distinguish an internal jump from an external URL. This
|
||||
access pattern is stable across python-pptx 0.6.x; pin python-pptx<0.7.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,8 +32,10 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.enum.action import PP_ACTION
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
from pptx.oxml.ns import qn
|
||||
|
||||
@@ -43,6 +55,11 @@ IMAGE_EXT_BY_CONTENT_TYPE = {
|
||||
}
|
||||
LEGACY_GENERATED_IMAGE_RE = re.compile(r"^slide_\d{2}_image_\d{2}\.[A-Za-z0-9]+$")
|
||||
|
||||
# Hyperlink schemes dropped during extraction (a blacklist of known-dangerous
|
||||
# schemes). PowerPoint also rejects unrecognized schemes at open time, so the
|
||||
# residual risk from schemes not listed here is low.
|
||||
UNSUPPORTED_URL_SCHEMES = ("javascript:", "data:", "vbscript:", "file:")
|
||||
|
||||
|
||||
SUPPORTED_FORMATS = {
|
||||
".pptx": "PowerPoint Presentation",
|
||||
@@ -135,12 +152,164 @@ def iter_leaf_shapes(shapes: object) -> list[LeafShape]:
|
||||
return items
|
||||
|
||||
|
||||
def text_frame_to_markdown(text_frame: object) -> str:
|
||||
"""Convert a PowerPoint text frame into Markdown."""
|
||||
paragraphs = []
|
||||
def _is_supported_url(url: str) -> bool:
|
||||
"""Reject empty URLs and the known-dangerous schemes."""
|
||||
return bool(url) and not any(
|
||||
url.lower().startswith(scheme) for scheme in UNSUPPORTED_URL_SCHEMES
|
||||
)
|
||||
|
||||
|
||||
def _escape_md_link_text(text: str) -> str:
|
||||
"""Backslash-escape characters that would break a Markdown link label.
|
||||
|
||||
A stray ``]`` in anchor text would otherwise close the ``[...]`` early.
|
||||
"""
|
||||
return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||
|
||||
|
||||
def _encode_md_url(url: str) -> str:
|
||||
"""Percent-encode a URL so Markdown link syntax stays unambiguous.
|
||||
|
||||
Notably encodes ``(`` / ``)`` to ``%28`` / ``%29`` so a parenthesised URL
|
||||
does not terminate the ``[text](url)`` form early.
|
||||
"""
|
||||
return quote(url, safe="/:?=&%#@!$'*+,;")
|
||||
|
||||
|
||||
def _resolve_internal_jump(run: object, shape: object) -> str | None:
|
||||
"""Return ``#slide-N`` for a run carrying a slide-internal jump, else None.
|
||||
|
||||
Reads ``run._r`` (private python-pptx API) because the public
|
||||
``run.hyperlink.address`` cannot tell an internal jump apart from an
|
||||
external URL — see the module docstring's API stability note.
|
||||
"""
|
||||
r_id = ""
|
||||
try:
|
||||
rpr = run._r.find(qn("a:rPr"))
|
||||
if rpr is None:
|
||||
return None
|
||||
hlink = rpr.find(qn("a:hlinkClick"))
|
||||
if hlink is None or "hlinksldjump" not in (hlink.get("action", "") or ""):
|
||||
return None
|
||||
r_id = hlink.get(qn("r:id"), "")
|
||||
if not r_id:
|
||||
return None
|
||||
target_slide = shape.part.related_part(r_id).slide
|
||||
prs = shape.part.slide.part.package.presentation_part.presentation
|
||||
return f"#slide-{list(prs.slides).index(target_slide) + 1}"
|
||||
except (KeyError, ValueError, AttributeError):
|
||||
print(f"[WARN] ppt_to_md: could not resolve slide jump rId={r_id}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def _run_url(run: object, shape: object) -> str | None:
|
||||
"""Resolve a run's hyperlink target to a markdown-ready URL, or None."""
|
||||
if shape is not None:
|
||||
internal = _resolve_internal_jump(run, shape)
|
||||
if internal:
|
||||
return internal
|
||||
try:
|
||||
addr = run.hyperlink.address
|
||||
except AttributeError:
|
||||
return None
|
||||
if _is_supported_url(addr or ""):
|
||||
return _encode_md_url(addr)
|
||||
return None
|
||||
|
||||
|
||||
def _paragraph_to_markdown(paragraph: object, shape: object) -> str:
|
||||
"""Render one paragraph, merging consecutive runs that share a URL.
|
||||
|
||||
Run text is concatenated verbatim — including the spaces between runs — and
|
||||
normalized only once over the assembled paragraph, so a link in the middle
|
||||
of a sentence does not swallow its surrounding spaces. A link group's own
|
||||
leading / trailing whitespace is kept outside the ``[...]`` so it separates
|
||||
words rather than padding the anchor text.
|
||||
"""
|
||||
parts = []
|
||||
current_url = None
|
||||
current_text = ""
|
||||
|
||||
def flush():
|
||||
if not current_text:
|
||||
return
|
||||
if current_url is None:
|
||||
parts.append(current_text)
|
||||
return
|
||||
lead = current_text[: len(current_text) - len(current_text.lstrip())]
|
||||
trail = current_text[len(current_text.rstrip()):]
|
||||
core = current_text.strip()
|
||||
display = _escape_md_link_text(core) if core else current_url
|
||||
parts.append(f"{lead}[{display}]({current_url}){trail}")
|
||||
|
||||
has_run_hyperlink = False
|
||||
for run in paragraph.runs:
|
||||
url = _run_url(run, shape)
|
||||
if url:
|
||||
has_run_hyperlink = True
|
||||
if url != current_url:
|
||||
flush()
|
||||
current_text = ""
|
||||
current_url = url
|
||||
current_text += run.text or ""
|
||||
flush()
|
||||
|
||||
text = normalize_text("".join(parts))
|
||||
|
||||
# Shape-level click_action only matters when no run carried its own link.
|
||||
if not has_run_hyperlink and shape is not None:
|
||||
text = _apply_shape_click_action(text, shape)
|
||||
return text
|
||||
|
||||
|
||||
def _apply_shape_click_action(text: str, shape: object) -> str:
|
||||
"""Wrap paragraph text in a link from the shape's click_action, if any."""
|
||||
try:
|
||||
action = shape.click_action
|
||||
if action.action == PP_ACTION.HYPERLINK:
|
||||
url = action.hyperlink.address or ""
|
||||
if _is_supported_url(url):
|
||||
return f"[{_escape_md_link_text(text)}]({_encode_md_url(url)})"
|
||||
elif action.action == PP_ACTION.NAMED_SLIDE:
|
||||
target = action.target_slide
|
||||
if target is not None:
|
||||
prs = shape.part.slide.part.package.presentation_part.presentation
|
||||
idx = list(prs.slides).index(target) + 1
|
||||
return f"[{_escape_md_link_text(text)}](#slide-{idx})"
|
||||
except (AttributeError, ValueError):
|
||||
print("[WARN] ppt_to_md: could not process shape click_action", file=sys.stderr)
|
||||
return text
|
||||
|
||||
|
||||
def _paragraph_has_hyperlink(paragraph: object) -> bool:
|
||||
"""True if any run carries an external URL or an internal slide jump."""
|
||||
for run in paragraph.runs:
|
||||
try:
|
||||
if run.hyperlink.address:
|
||||
return True
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
rpr = run._r.find(qn("a:rPr"))
|
||||
if rpr is not None and rpr.find(qn("a:hlinkClick")) is not None:
|
||||
return True
|
||||
except AttributeError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def text_frame_to_markdown(text_frame: object, shape: object = None) -> str:
|
||||
"""Convert a PowerPoint text frame into Markdown, preserving hyperlinks.
|
||||
|
||||
Run-level external URLs and slide-internal jumps are emitted as
|
||||
``[text](url)`` / ``[text](#slide-N)``; consecutive runs sharing a URL are
|
||||
merged. When no run carries a link, the shape's ``click_action`` is used as
|
||||
a paragraph-level fallback. Pass ``shape`` to enable hyperlink extraction;
|
||||
without it the frame degrades to plain text.
|
||||
"""
|
||||
visible_paragraphs = [
|
||||
paragraph for paragraph in text_frame.paragraphs
|
||||
if normalize_text(paragraph.text)
|
||||
if normalize_text(paragraph.text) or _paragraph_has_hyperlink(paragraph)
|
||||
]
|
||||
if not visible_paragraphs:
|
||||
return ""
|
||||
@@ -149,8 +318,9 @@ def text_frame_to_markdown(text_frame: object) -> str:
|
||||
if not list_like:
|
||||
list_like = len(visible_paragraphs) > 1
|
||||
|
||||
paragraphs = []
|
||||
for paragraph in visible_paragraphs:
|
||||
text = normalize_text(paragraph.text)
|
||||
text = _paragraph_to_markdown(paragraph, shape)
|
||||
if not text:
|
||||
continue
|
||||
if list_like:
|
||||
@@ -434,7 +604,7 @@ def extract_notes(slide: object) -> str:
|
||||
shape = item.shape
|
||||
if not getattr(shape, "has_text_frame", False):
|
||||
continue
|
||||
text = text_frame_to_markdown(shape.text_frame)
|
||||
text = text_frame_to_markdown(shape.text_frame, shape)
|
||||
if text:
|
||||
blocks.append(text)
|
||||
|
||||
@@ -534,7 +704,7 @@ def convert_presentation_to_markdown(
|
||||
continue
|
||||
|
||||
if getattr(shape, "has_text_frame", False):
|
||||
text_md = text_frame_to_markdown(shape.text_frame)
|
||||
text_md = text_frame_to_markdown(shape.text_frame, shape)
|
||||
if text_md:
|
||||
blocks.append(text_md)
|
||||
continue
|
||||
|
||||
+112
-28
@@ -9,6 +9,7 @@ Placeholder syntax (new SVGs must include a library prefix):
|
||||
<use data-icon="tabler-filled/home" x="100" y="200" width="48" height="48" fill="#0076A8"/>
|
||||
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8"/>
|
||||
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8" stroke-width="3"/>
|
||||
<use data-icon="layered_slide_06_ill01"/>
|
||||
|
||||
Legacy compatibility accepted by the resolver:
|
||||
<use data-icon="rocket" .../> -> chunk-filled/rocket
|
||||
@@ -29,6 +30,7 @@ Icon libraries (subdirectories of templates/icons/):
|
||||
tabler-outline/ - 5000+ stroke icons, 24x24 viewBox (use prefix: tabler-outline/name)
|
||||
phosphor-duotone/ - 1200+ duotone icons, 256x256 viewBox (single color + 0.2-opacity backplate)
|
||||
simple-icons/ - 3400+ brand logos, 24x24 viewBox (brand-inset library — used alongside the chosen primary library, NOT as a standalone library for generic icons)
|
||||
<asset_id>.svg - project-local extracted vector illustrations with data-icon-style="preserve-color"; preserve source colors and natural viewBox aspect ratio
|
||||
|
||||
Usage:
|
||||
python3 scripts/svg_finalize/embed_icons.py <svg_file> [svg_file2] ...
|
||||
@@ -40,6 +42,8 @@ Options:
|
||||
--verbose Show detailed information
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -61,6 +65,7 @@ ICON_BASE_SIZES = {
|
||||
'simple-icons': 24,
|
||||
}
|
||||
DEFAULT_ICON_BASE_SIZE = 24
|
||||
BaseGeometry = float | tuple[float, float, float, float]
|
||||
|
||||
|
||||
def _get_viewbox_size(content: str) -> float:
|
||||
@@ -71,6 +76,47 @@ def _get_viewbox_size(content: str) -> float:
|
||||
return 0
|
||||
|
||||
|
||||
def _get_viewbox_geometry(content: str) -> tuple[float, float, float, float] | None:
|
||||
"""Extract full viewBox geometry as (min_x, min_y, width, height)."""
|
||||
match = re.search(r'viewBox=["\']([^"\']+)["\']', content)
|
||||
if not match:
|
||||
return None
|
||||
parts = re.split(r'[\s,]+', match.group(1).strip())
|
||||
if len(parts) < 4:
|
||||
return None
|
||||
try:
|
||||
min_x, min_y, width, height = [float(part) for part in parts[:4]]
|
||||
except ValueError:
|
||||
return None
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return min_x, min_y, width, height
|
||||
|
||||
|
||||
def _format_number(value: object) -> str:
|
||||
"""Format SVG numeric values compactly without losing meaningful precision."""
|
||||
if isinstance(value, float):
|
||||
return f'{value:g}'
|
||||
return str(value)
|
||||
|
||||
|
||||
def _base_geometry(base_size: BaseGeometry) -> tuple[float, float, float, float]:
|
||||
"""Normalize legacy square icon size and full viewBox geometry."""
|
||||
if isinstance(base_size, tuple):
|
||||
return base_size
|
||||
return 0.0, 0.0, float(base_size), float(base_size)
|
||||
|
||||
|
||||
def _is_preserve_color_asset(content: str) -> bool:
|
||||
"""Project illustrations are vector assets, not recolorable monochrome icons.
|
||||
|
||||
The `data-icon-style="preserve-color"` marker is stamped by
|
||||
extract_svg_assets.py and is the single source of truth — hand-authored
|
||||
multi-color assets must carry it to keep their colors and aspect ratio.
|
||||
"""
|
||||
return 'data-icon-style="preserve-color"' in content
|
||||
|
||||
|
||||
def _detect_icon_style(content: str) -> str:
|
||||
"""Detect whether an icon is fill-based or stroke-based."""
|
||||
# stroke="currentColor" with fill="none" → stroke style
|
||||
@@ -79,6 +125,15 @@ def _detect_icon_style(content: str) -> str:
|
||||
return 'fill'
|
||||
|
||||
|
||||
def _extract_svg_body(content: str) -> list[str]:
|
||||
"""Return the root SVG body for preserve-color assets without editing attrs."""
|
||||
match = re.search(r'<svg\b[^>]*>(.*)</svg>\s*$', content, re.DOTALL)
|
||||
if not match:
|
||||
return []
|
||||
body = match.group(1).strip()
|
||||
return [body] if body else []
|
||||
|
||||
|
||||
def _extract_shape_elements(content: str, color: str) -> list[str]:
|
||||
"""
|
||||
Extract all drawable shape elements from an icon SVG, replacing
|
||||
@@ -103,19 +158,8 @@ def _extract_shape_elements(content: str, color: str) -> list[str]:
|
||||
return elements
|
||||
|
||||
|
||||
def resolve_icon_path(icon_name: str, icons_dir: Path) -> tuple[Path, float]:
|
||||
"""
|
||||
Resolve icon name to file path and base size.
|
||||
|
||||
Supports:
|
||||
- "chunk-filled/home" → icons_dir/chunk-filled/home.svg
|
||||
- "chunk/home" → icons_dir/chunk-filled/home.svg (backward compat alias)
|
||||
- "tabler-filled/home" → icons_dir/tabler-filled/home.svg
|
||||
- "tabler-outline/home" → icons_dir/tabler-outline/home.svg
|
||||
- "home" (no prefix) → falls back to icons_dir/chunk-filled/home.svg (legacy compat only)
|
||||
|
||||
Returns (path, base_size). base_size=0 means not found.
|
||||
"""
|
||||
def _resolve_in_dir(icon_name: str, icons_dir: Path) -> tuple[Path, float]:
|
||||
"""Resolve `icon_name` against a single icons dir (no fallback)."""
|
||||
# Backward compat: 'chunk/name' → 'chunk-filled/name'
|
||||
_LIB_ALIASES = {'chunk': 'chunk-filled'}
|
||||
|
||||
@@ -135,19 +179,43 @@ def resolve_icon_path(icon_name: str, icons_dir: Path) -> tuple[Path, float]:
|
||||
return icon_path, base_size
|
||||
|
||||
|
||||
def extract_paths_from_icon(icon_path: Path, target_color: str = '#000000') -> tuple[list[str], str, float]:
|
||||
def resolve_icon_path(icon_name: str, icons_dir: Path, fallback_dir: Path | None = None) -> tuple[Path, float]:
|
||||
"""
|
||||
Resolve icon name to file path and base size, e.g. "chunk-filled/home" →
|
||||
icons_dir/chunk-filled/home.svg. "chunk/" is a backward-compat alias; an
|
||||
un-prefixed name falls back to chunk-filled/ then a legacy flat layout.
|
||||
|
||||
Resolution is project-first: if the icon is absent under ``icons_dir`` and a
|
||||
``fallback_dir`` (the global library) is given, the fallback's path is
|
||||
returned instead. Returns (path, base_size); the path may not exist when
|
||||
neither dir has the icon.
|
||||
"""
|
||||
icon_path, base_size = _resolve_in_dir(icon_name, icons_dir)
|
||||
if fallback_dir is not None and not icon_path.exists():
|
||||
fb_path, fb_size = _resolve_in_dir(icon_name, fallback_dir)
|
||||
if fb_path.exists():
|
||||
return fb_path, fb_size
|
||||
return icon_path, base_size
|
||||
|
||||
|
||||
def extract_paths_from_icon(icon_path: Path, target_color: str = '#000000') -> tuple[list[str], str, BaseGeometry]:
|
||||
"""
|
||||
Extract drawable elements from an icon SVG file.
|
||||
|
||||
Returns:
|
||||
(elements, style, base_size)
|
||||
style: 'fill' or 'stroke'
|
||||
base_size: detected from viewBox
|
||||
style: 'fill', 'stroke', or 'preserve'
|
||||
base_size: square icon size, or full viewBox geometry for preserve assets
|
||||
"""
|
||||
if not icon_path.exists():
|
||||
return [], 'fill', 16
|
||||
|
||||
content = icon_path.read_text(encoding='utf-8')
|
||||
if _is_preserve_color_asset(content):
|
||||
geometry = _get_viewbox_geometry(content) or (0.0, 0.0, DEFAULT_ICON_BASE_SIZE, DEFAULT_ICON_BASE_SIZE)
|
||||
elements = _extract_svg_body(content)
|
||||
return elements, 'preserve', geometry
|
||||
|
||||
style = _detect_icon_style(content)
|
||||
base_size = _get_viewbox_size(content) or 16
|
||||
elements = _extract_shape_elements(content, target_color)
|
||||
@@ -207,6 +275,9 @@ def parse_use_element(use_match: str) -> dict[str, str | float]:
|
||||
|
||||
def resolve_icon_color(attrs: dict[str, str | float], style: str) -> str:
|
||||
"""Resolve the caller-provided color for fill or stroke icon libraries."""
|
||||
if style == 'preserve':
|
||||
return 'preserve'
|
||||
|
||||
fill = str(attrs.get('fill', '')).strip()
|
||||
stroke = str(attrs.get('stroke', '')).strip()
|
||||
|
||||
@@ -224,42 +295,55 @@ def resolve_icon_color(attrs: dict[str, str | float], style: str) -> str:
|
||||
return '#000000'
|
||||
|
||||
|
||||
def generate_icon_group(attrs: dict[str, str | float], elements: list[str], style: str, base_size: float) -> str:
|
||||
def generate_icon_group(attrs: dict[str, str | float], elements: list[str], style: str, base_size: BaseGeometry) -> str:
|
||||
"""
|
||||
Generate the icon's <g> element.
|
||||
|
||||
Args:
|
||||
attrs: Attributes of the use element
|
||||
elements: List of drawable SVG elements
|
||||
style: 'fill' or 'stroke'
|
||||
base_size: Icon's natural size (viewBox width)
|
||||
style: 'fill', 'stroke', or 'preserve'
|
||||
base_size: Icon's natural size, or full viewBox geometry for preserve assets
|
||||
|
||||
Returns:
|
||||
Complete <g> element string
|
||||
"""
|
||||
min_x, min_y, base_width, base_height = _base_geometry(base_size)
|
||||
x = attrs.get('x', 0)
|
||||
y = attrs.get('y', 0)
|
||||
width = attrs.get('width', base_size)
|
||||
height = attrs.get('height', base_size)
|
||||
width = attrs.get('width', base_width)
|
||||
height = attrs.get('height', base_height)
|
||||
color = resolve_icon_color(attrs, style)
|
||||
icon_name = attrs.get('icon', 'unknown')
|
||||
|
||||
scale_x = width / base_size
|
||||
scale_y = height / base_size
|
||||
scale_x = float(width) / base_width
|
||||
scale_y = float(height) / base_height
|
||||
|
||||
if attrs.get('transform'):
|
||||
# This transform is authoritative: the editor computes it from the
|
||||
# expanded <g>, so composing it with x/y would apply placement twice.
|
||||
transform = str(attrs['transform'])
|
||||
elif abs(scale_x - 1) < 1e-6 and abs(scale_y - 1) < 1e-6:
|
||||
transform = f'translate({x}, {y})'
|
||||
transform = f'translate({_format_number(x)}, {_format_number(y)})'
|
||||
elif abs(scale_x - scale_y) < 1e-6:
|
||||
transform = f'translate({x}, {y}) scale({scale_x})'
|
||||
transform = f'translate({_format_number(x)}, {_format_number(y)}) scale({_format_number(scale_x)})'
|
||||
else:
|
||||
transform = f'translate({x}, {y}) scale({scale_x}, {scale_y})'
|
||||
transform = (
|
||||
f'translate({_format_number(x)}, {_format_number(y)}) '
|
||||
f'scale({_format_number(scale_x)}, {_format_number(scale_y)})'
|
||||
)
|
||||
|
||||
elements_str = '\n '.join(elements)
|
||||
|
||||
if style == 'preserve':
|
||||
if min_x or min_y:
|
||||
inner_transform = f'translate({_format_number(-min_x)}, {_format_number(-min_y)})'
|
||||
elements_str = f'<g transform="{inner_transform}">\n {elements_str}\n </g>'
|
||||
return f'''<!-- icon: {icon_name} -->
|
||||
<g transform="{transform}">
|
||||
{elements_str}
|
||||
</g>'''
|
||||
|
||||
if style == 'stroke':
|
||||
# Default to 2 — matches the source stroke-width baked into tabler-outline
|
||||
# (and any other stroke library) so omitting the attribute reproduces
|
||||
@@ -275,7 +359,7 @@ def generate_icon_group(attrs: dict[str, str | float], elements: list[str], styl
|
||||
</g>'''
|
||||
|
||||
|
||||
def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, verbose: bool = False) -> int:
|
||||
def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, verbose: bool = False, fallback_dir: Path | None = None) -> int:
|
||||
"""
|
||||
Process a single SVG file, replacing all icon placeholders.
|
||||
|
||||
@@ -315,7 +399,7 @@ def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, ver
|
||||
if not icon_name:
|
||||
continue
|
||||
|
||||
icon_path, _ = resolve_icon_path(str(icon_name), icons_dir)
|
||||
icon_path, _ = resolve_icon_path(str(icon_name), icons_dir, fallback_dir)
|
||||
elements, style, base_size = extract_paths_from_icon(icon_path)
|
||||
color = resolve_icon_color(attrs, style)
|
||||
|
||||
|
||||
@@ -201,11 +201,12 @@ Recorded narration:
|
||||
parser.add_argument('-a', '--animation', type=str, choices=animation_choices,
|
||||
default=None,
|
||||
help='Per-element entrance animation (native shapes mode '
|
||||
'only). Pick a single effect, "auto" (default; map '
|
||||
'effect from group id — image-like ids cycle a richer '
|
||||
'pool for visual variation, fallback cycles fade/wipe/'
|
||||
'fly/zoom), "mixed" (legacy 16-effect pool), "random", '
|
||||
'or "none" to disable.')
|
||||
'only). Default "none" (no auto element builds; page '
|
||||
'transitions still apply). Pick a single effect, "auto" '
|
||||
'(map effect from group id — image-like ids cycle a '
|
||||
'richer pool for visual variation, fallback cycles fade/'
|
||||
'wipe/fly/zoom), "mixed" (legacy 16-effect pool), or '
|
||||
'"random".')
|
||||
parser.add_argument('--animation-duration', type=non_negative_float, default=None,
|
||||
help='Per-element entrance duration in seconds (default: 0.4)')
|
||||
parser.add_argument('--animation-trigger', type=str,
|
||||
@@ -456,7 +457,10 @@ Recorded narration:
|
||||
animation_effect = (
|
||||
animation_arg
|
||||
if animation_arg is not None
|
||||
else animation_defaults.get('effect', 'auto')
|
||||
# Per-element entrance is opt-in by default: auto-firing element builds
|
||||
# read as the "AI deck" tell and were unsolicited. Page transitions stay
|
||||
# on (see transition default above). Re-enable with -a auto / animations.json.
|
||||
else animation_defaults.get('effect', 'none')
|
||||
)
|
||||
animation = None if animation_effect == 'none' else animation_effect
|
||||
animation_duration = (
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@
|
||||
### Color Scheme
|
||||
|
||||
> Strategist: determine values from project content, industry, brand colors.
|
||||
> Step 4 Confirm UI: present a few color candidates, each with a user-facing core `palette` (background / secondary_bg / primary / accent / secondary_accent / body_text), in `confirm_ui/recommendations.json`; the confirmed candidate from `result.json` seeds this table. Strategist derives the remaining text, border, state, and style-neutral colors when writing this full scheme. Schema: [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md).
|
||||
> Step 4 Confirm UI: present **≥3** color candidates (creative recommendations always offer real choice — same rule as h.5; fewer only on the honest-shortfall exception, with a stated reason), each with a user-facing core `palette` (background / secondary_bg / primary / accent / secondary_accent / body_text), in `confirm_ui/recommendations.json`; the confirmed candidate from `result.json` seeds this table. Strategist derives the remaining text, border, state, and style-neutral colors when writing this full scheme. Schema: [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md).
|
||||
|
||||
| Role | HEX | Purpose |
|
||||
| ---- | --- | ------- |
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
**Typography direction**: [Fill in one phrase, e.g., "modern CJK sans" / "academic serif" / "brand-specific: McKinsey Bower (requires font install)"]
|
||||
|
||||
> Step 4 Confirm UI: present a few typography candidates, each splitting CJK + Latin for `heading` and `body` (with `css` preview stacks) and declaring `body_size` as the body baseline px, in `confirm_ui/recommendations.json`; the confirmed candidate from `result.json` seeds the plan below. Schema: [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md).
|
||||
> Step 4 Confirm UI: present **≥3** typography candidates (creative recommendations always offer real choice — same rule as h.5; fewer only on the honest-shortfall exception, with a stated reason), each splitting CJK + Latin for `heading` and `body` (with `css` preview stacks) and declaring `body_size` as the body baseline px, in `confirm_ui/recommendations.json`; the confirmed candidate from `result.json` seeds the plan below. Schema: [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md).
|
||||
|
||||
Two views on the same font decisions — fill both, keep them consistent:
|
||||
|
||||
|
||||
@@ -14,6 +14,18 @@ This directory provides **11,600+ high-quality SVG icons** across five libraries
|
||||
|
||||
---
|
||||
|
||||
## Per-project icons folder
|
||||
|
||||
This directory is the **global library**. At selection time the Strategist copies the chosen icons into the deck's own `<project>/icons/<lib>/` with `icon_sync.py`:
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/icon_sync.py <project_path> chunk-filled/home tabler-outline/bulb
|
||||
```
|
||||
|
||||
A name the library does not have is reported and the command exits non-zero — re-pick a real one then, not at export. `finalize_svg.py embed-icons` embeds **project-first** (from `<project>/icons/`), falling back to this global library per-icon.
|
||||
|
||||
**Custom icons**: drop your own `.svg` into `<project>/icons/<lib>/` (any `<lib>`, e.g. `custom/`) and reference it as `data-icon="<lib>/<name>"` — it embeds like any library icon.
|
||||
|
||||
## Usage
|
||||
|
||||
Use placeholder syntax **during SVG generation**:
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
description: Content-faithful PPT beautification — re-layout an existing deck while preserving its text verbatim and inheriting its visual identity, so regenerated elements share the original's palette/fonts and blend with it when pasted back.
|
||||
---
|
||||
|
||||
# Beautify PPTX (Re-layout) Workflow
|
||||
|
||||
> Mirror of [`template-fill-pptx.md`](./template-fill-pptx.md): template-fill reuses a deck's design and swaps in new content; beautify keeps a deck's content and redoes its layout.
|
||||
|
||||
Re-lays-out an existing `.pptx`: the text is preserved **verbatim**, the source deck's visual identity (palette / fonts) is **inherited as truth**, and only layout, hierarchy, and whitespace are redesigned. Output is a brand-new native deck generated through the standard SVG pipeline — not a patch over the original.
|
||||
|
||||
**Trigger**: the user supplies a `.pptx` and asks to beautify / re-layout / 重新排版 / 美化 while keeping the content. Explicit intent + a provided file only; never auto-infer.
|
||||
|
||||
---
|
||||
|
||||
## 1. When to Run
|
||||
|
||||
| Pattern | Example |
|
||||
|---|---|
|
||||
| Existing `.pptx` + beautify intent | "把这份 PPT 美化一下" / "make this deck look better" |
|
||||
| Existing `.pptx` + re-layout intent | "重新排版这份 PPT,内容别动" / "re-layout this, keep the wording" |
|
||||
| Existing `.pptx` + paste-back intent | "重排后我要把元素贴回原来的模板" |
|
||||
|
||||
**Hard rule — content is frozen**: every text string from the source is preserved exactly (no add / remove / reword / reorder). Beautification freedom lives only in layout, hierarchy, spacing, and visual rhythm.
|
||||
|
||||
**Hard rule — not a patch, not a fill**: this regenerates a native deck through Strategist → Executor → export (SKILL.md Steps 4–7). It does **not** edit the source file in place, and it is **not** [`template-fill-pptx`](./template-fill-pptx.md) (which clones source slides and replaces text). It also does not parse an arbitrary third-party template for text-only substitution (the rejected #53 direction) — it builds every page from scratch.
|
||||
|
||||
**Distinct from mirror templates**: `replication_mode: mirror` (executor §1.1) keeps layout + visuals verbatim and edits text. Beautify is the inverse — content verbatim, layout redone, identity inherited.
|
||||
|
||||
**When this is the wrong route — re-architecture belongs to the main pipeline**: beautify preserves the source's page count and page order 1:1. It is for "keep this deck, just lay it out better". When the user instead wants the original page breakdown reconsidered — merge / split / reorder pages, re-outline the structure, build a *better deck* from the same content rather than a prettier version of the same pages — that is not beautify. Convert the deck with [`ppt_to_md`](../scripts/source_to_md/ppt_to_md.py) and run the main SKILL.md pipeline, where the Strategist re-architects the outline freely from the extracted content. The deciding question: is the source's page split information to preserve, or just the previous author's structure to improve? Preserve → beautify (here); improve → `ppt_to_md` + main pipeline.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inputs
|
||||
|
||||
🚧 **GATE**: the user has provided:
|
||||
|
||||
| Input | Required | Notes |
|
||||
|---|---:|---|
|
||||
| Source PPTX | Yes | The deck to re-lay-out |
|
||||
| Beautify scope | Optional | Density / emphasis preference — never content rewrites, and never page drops (v1 is strict 1:1) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Create the Project Workspace
|
||||
|
||||
Match the canvas to the source so 1:1 pages and paste-back align. Determine the source aspect first — before the project exists, run `beautify_identity.py <source.pptx>` to **stdout** and read `canvas.aspect` (the formal `analysis/identity.json` is written in Step 4, after `init`) — then `init` with the matching format:
|
||||
|
||||
| Source aspect | Format |
|
||||
|---|---|
|
||||
| ≈1.778 (16:9) | `ppt169` |
|
||||
| ≈1.333 (4:3) | `ppt43` |
|
||||
| other | nearest format in [`canvas-formats.md`](../references/canvas-formats.md); record the source pixel size in the spec |
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> --format <format>
|
||||
python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <source.pptx> --move
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Extract Identity and Data; Assemble Inventory
|
||||
|
||||
Two reads off the source PPTX (text + images already exist from Step 3). Neither rewrites the deck.
|
||||
|
||||
**Content + images — already produced by Step 3.** `import-sources` ran `ppt_to_md` on the deck, so the **frozen content contract** is `sources/<stem>.md` (one source slide per block, in order). If the source deck contains pictures, they are already propagated to `images/` with per-slide binding in `images/image_manifest.json` (`occurrences[].slide_index`). Do **not** re-run `ppt_to_md` — it would duplicate the conversion and write images to `analysis/<stem>_files/` instead of `images/`.
|
||||
|
||||
**Visual identity (theme + observed sample + canvas)**:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/beautify_identity.py <project_path>/sources/<source.pptx> -o <project_path>/analysis/identity.json
|
||||
```
|
||||
|
||||
| Field | Use |
|
||||
|---|---|
|
||||
| `theme.palette.background` / `text` / `primary` / `accent1..6` | the deck's *declared* colors |
|
||||
| `theme.fonts.title` / `body` (`ea` = CJK, `latin`) | the deck's *declared* fonts |
|
||||
| `observed.colors` / `observed.fonts` (`latin` / `ea`, frequency-ranked) | a usage **sample / frequency hint** — run-level fonts + explicit `srgbClr` fills across slides |
|
||||
| `canvas.aspect` | drives the Step 3 format choice |
|
||||
|
||||
> Note: `theme` is what the deck declares; `observed` is a frequency sample of run-level overrides (not a complete style resolution — it misses `schemeClr` and master/layout inheritance, and counts chart/gradient fills). A hand-edited deck can diverge from `theme` — Step 5 recommends which to inherit and the user confirms.
|
||||
|
||||
**Chart + table data (for regeneration)**: read the source's chart and table *data* so they can be redrawn natively in the inherited style:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/template_fill_pptx.py analyze <project_path>/sources/<source.pptx> -o <project_path>/analysis/slide_library.json
|
||||
```
|
||||
|
||||
| `slide_library.json` field | Use |
|
||||
|---|---|
|
||||
| `slides[].charts[]` (`chart_type` / `categories` / `series[].values`) | regenerate as a native SVG chart via the `§VII` `templates/charts/` path |
|
||||
| `slides[].tables[]` (`row_count` / `column_count` / cell text) | regenerate as a native SVG table |
|
||||
|
||||
**Hard rule — regenerate visuals, do not carry them over**: charts / tables / images are rebuilt from their data in the inherited style, never spliced in byte-for-byte. This keeps the deck style-consistent and natively editable. **Data values are frozen** (categories / series / cell text / numbers unchanged); only their rendering is the deck's own. Pictures (`ppt_to_md`-extracted files) are reused but re-laid-out — position / crop / size follow the new layout, not the source slot. A user who wants an original element verbatim copies it across themselves.
|
||||
|
||||
**Optional source-SVG visual reference**: when the source deck has complex vector decoration, distinctive page chrome, or a visual language that cannot be captured by `identity.json` colors/fonts alone, create a read-only SVG reference package under `analysis/`. This is for understanding style only; it is not a carry-over asset path.
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/pptx_to_svg.py <project_path>/sources/<source.pptx> -o <project_path>/analysis/source_svg_import
|
||||
python3 ${SKILL_DIR}/scripts/extract_svg_assets.py <project_path>/analysis/source_svg_import/svg-flat \
|
||||
--icons-dir <project_path>/analysis/source_svg_import/icons \
|
||||
--inplace --id-prefix source_flat --min-decoration-bytes 3000 --clean-stale
|
||||
```
|
||||
|
||||
Use the cleaned `analysis/source_svg_import/svg-flat/slide_*.svg` files plus `analysis/source_svg_import/svg-flat_vector_asset_inventory.json` in Step 5/Strategist. Extraction is required for inspection when complex vectors exist: it creates a candidate pool the AI can index, compare, and judge for possible reuse without reading every heavy vector body. Read an individual `analysis/source_svg_import/icons/*.svg` only when the cleaned page and inventory indicate that candidate may be promoted or materially affects the style decision. These candidates are analysis artifacts first, not automatic output assets.
|
||||
|
||||
Default: do **not** copy these candidates into the project `icons/`, do **not** list them as reusable output assets, and do **not** preserve original vector decorations byte-for-byte in the beautified deck. The Executor still regenerates fresh native shapes from the confirmed plan.
|
||||
|
||||
Optional reuse gate: if a candidate is a non-text brand/logo/motif/decorative asset that should survive the beautification, list it in the Step 5 plan with source slide, candidate filename, intended reuse, and dependency notes from the inventory. Wait for user confirmation. Only confirmed candidates may be promoted into `<project_path>/icons/` and referenced from generated SVGs with `<use data-icon="..."/>`; `finalize_svg.py` then re-inlines them as native shapes. Never promote text-bearing groups, charts/tables, source page layouts, or dense slide composites as reusable assets.
|
||||
|
||||
**Assemble the inventory** — the deterministic join into one per-slide ledger, `analysis/beautify_inventory.json`, the contract Step 5 confirms and Step 7 verifies against:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/beautify_inventory.py <project_path>/analysis/slide_library.json \
|
||||
--images <project_path>/images/image_manifest.json -o <project_path>/analysis/beautify_inventory.json
|
||||
```
|
||||
|
||||
If `images/image_manifest.json` does not exist because the source deck has no extracted pictures, omit `--images`. The script joins per slide: `text_blocks` (slot text + geometry), `tables` (cell grid) / `charts` (categories + series values) — the **frozen data values inlined**, so the inventory is a self-contained contract, not a pointer back to `slide_library.json` — and `images` (bound via `image_manifest` `occurrences[].slide_index`, with geometry / `usage_count`). It emits `ignored` and `needs_confirmation` as **empty arrays** — fill them with judgment before Step 5:
|
||||
|
||||
| Field | Fill with |
|
||||
|---|---|
|
||||
| `ignored` | hidden slides / shapes, master-only text, image crop / opacity / rotation / mask (not captured upstream) |
|
||||
| `needs_confirmation` | combo / dual-axis / waterfall charts (only the first plot type is captured), merged-cell or multi-header tables, density-outlier pages — **either** overcrowded **or** near-empty / title-only (e.g. a divider page with a heading and no body) |
|
||||
|
||||
```markdown
|
||||
## ✅ Extraction Complete
|
||||
|
||||
- [x] `sources/<stem>.md` (from Step 3) holds every source slide's text, in order; extracted pictures, if any, are in `images/` + `images/image_manifest.json`
|
||||
- [x] `analysis/identity.json` has theme + observed identity + canvas aspect
|
||||
- [x] `analysis/slide_library.json` holds chart + table data for regeneration
|
||||
- [x] `analysis/beautify_inventory.json` ledgers per-slide text / images / data + ignored + needs-confirmation
|
||||
- [ ] **Next**: Step 5 — Beautify Plan (recommend & confirm)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Beautify Plan — Recommend & Confirm
|
||||
|
||||
⛔ **BLOCKING**: the scope is not hard-coded — same spirit as the Eight Confirmations. Recommend each item below from what the deck actually contains (the Step 4 inventory), present the plan, and **wait for the user to confirm or adjust** before writing any spec. Chat is the canonical channel; the confirm UI below is the visual convenience surface over it for the palette + typography review (its result is honored identically to a chat reply).
|
||||
|
||||
This step has two halves:
|
||||
- **Visual re-confirm via the confirm UI** — the **full** Step 4 confirm page (below), seeded from the source so every targeted-confirmation field (canvas, mode, visual style, palette, icons, typography incl. body baseline, image strategy, generation mode) is **pre-filled with the inherited / source-derived default and left editable**. Beautify *recommends* keeping the source's identity, but never removes the user's place to override any field — you may choose not to change a value, but you must not deny the place to change it. This is also where the deck's text size is set: `identity.json` carries fonts and palette but **no body font size** (source decks inherit sizes from master placeholders), so the body baseline is undetermined and must be chosen here, not silently defaulted to a small dense value.
|
||||
- **Structural scope** — the inventory-driven list decisions below (ignored, reuse, needs-confirmation, verification level) stay in **chat**; they have no confirm-UI widget.
|
||||
|
||||
| Plan item | Recommend from | Default lean |
|
||||
|---|---|---|
|
||||
| Identity source | `identity.json` `theme` vs `observed` | present **both as color / typography candidates in the confirm UI** so the user picks the one that looks right (theme first when the deck is theme-driven; observed first when slides override heavily) — recommend a default ordering and say why |
|
||||
| Preserve scope | inventory `text_blocks` / `images` / `charts` / `tables` | all text verbatim; data values frozen; pictures reused |
|
||||
| Ignored | inventory `ignored` | name them so the user sees what drops (hidden / master-only text / image crop / rotation) |
|
||||
| Needs confirmation | inventory `needs_confirmation` | flag complex charts + overcrowded pages explicitly; ask how to handle |
|
||||
| Verification level | deck size / risk | recommend the Step 7 per-page checks; user sets strictness |
|
||||
|
||||
**Hard rule — content is frozen, not the scope decisions**: text strings and chart/table/table-cell data values are non-negotiable (verbatim). *Which* identity to inherit, what to ignore, and how to treat flagged items are recommend-then-confirm, never silently decided.
|
||||
|
||||
**Recommend honestly — name the v1 ceiling**:
|
||||
|
||||
| Item | What v1 delivers |
|
||||
|---|---|
|
||||
| Overcrowded source page | layout / hierarchy / whitespace improve **within the page as-is** — v1 does **not** relieve information overload (that needs re-pagination / rewrite, deferred). Flag such pages; the user may accept or note them for manual split |
|
||||
| Paste-back into the original | regenerated elements share the inherited palette + fonts, so they **blend visually** when pasted. v1 does **not** guarantee a seamless coordinate-level drop-in (slide coordinates, master placeholders, font availability are the original deck's, not ours) |
|
||||
| Complex charts / merged-cell tables | best-effort from the captured data; combo / dual-axis / waterfall lose the un-captured plots — flagged for the user |
|
||||
|
||||
**Visual re-confirm — full confirm UI seeded from the source**:
|
||||
|
||||
Write `<project_path>/confirm_ui/recommendations.json` and launch the same confirm server SKILL.md Step 4 uses. Do **not** hide fields: seed **every** targeted-confirmation field with the inherited / source-derived default so the user sees the recommendation and keeps the place to change it. Schema → [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md).
|
||||
|
||||
```json
|
||||
{
|
||||
"recommend": {
|
||||
"canvas": "<step3-canvas-id>",
|
||||
"mode": "briefing",
|
||||
"visual_style": "<closest visual-style id to the source look>",
|
||||
"icons": "<sensible default icon library>",
|
||||
"image_usage": "provided"
|
||||
},
|
||||
"page_count": <source-slide-count>,
|
||||
"audience": "<carry over from the deck's apparent audience, or leave blank>",
|
||||
"color": { "selected": 0, "candidates": [
|
||||
{ "name_zh": "复刻源 PPT(推荐)", "name_en": "Source replica (recommended)", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } },
|
||||
{ "name_zh": "实际用色(observed)", "name_en": "Observed palette", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } },
|
||||
{ "name_zh": "备选配色 A", "name_en": "Alternative palette A", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } }
|
||||
] },
|
||||
"typography": { "selected": 0, "candidates": [
|
||||
{ "name_zh": "复刻源 PPT(推荐)", "name_en": "Source replica (recommended)", "heading": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body_size": <canvas-appropriate baseline> },
|
||||
{ "name_zh": "实际字体(observed)", "name_en": "Observed fonts", "heading": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body_size": <canvas-appropriate baseline> },
|
||||
{ "name_zh": "备选字体 A", "name_en": "Alternative pairing A", "heading": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body": { "cjk": "...", "latin": "...", "css": "<PPT-safe stack>" }, "body_size": <canvas-appropriate baseline> }
|
||||
] }
|
||||
}
|
||||
```
|
||||
|
||||
- **Recommend keep, allow override**: pre-fill canvas / mode / visual style / icons / image strategy with the source-faithful default (canvas = Step 3 format, mode = `briefing`, image_usage = `provided` since pictures are reused). Enumerable fields already list every catalog option with the source-faithful one badged, so the user can switch. Beautify's only true non-choices are the frozen text and the strict 1:1 page count (changing those means routing to the main pipeline instead — see CLAUDE.md).
|
||||
- **Our recommendation is the pre-selected default = the source replica**: for color and typography, author **several candidates** like the from-scratch flow. The pre-selected default (`selected: 0`, the first card) is what beautify recommends — the candidate that **best replicates the source deck's style** (the truest reading of `theme` / `observed`). Replicate-by-default.
|
||||
- **Judge the other alternatives exactly as the from-scratch flow does — fonts as much as colors**: don't invent a beautify-specific rule. Author each non-replica candidate with the **same content-driven judgment the Strategist uses when generating from scratch** (color §e, typography §g), applied to the material this project provides — the source document's content and subject, the company's own theme colors, and any brand signal. Pick the palette **and** the font pairing by what fits *this* deck's content; fonts are chosen by content fit, not just defaulted to a safe face. Reach **≥3 candidates total** (PPT-safe stacks; the same creative-choice rule used elsewhere) so a user who departs from the replica still lands on a considered, content-fitting direction — depart-by-choice.
|
||||
- **`body_size` is the load-bearing field**: seed it from a canvas-appropriate baseline (the page hints ≈2.5–3.3% of canvas height) — for a presentation / projection deck lean toward the relaxed end (e.g. `24` → 18pt), for a dense document deck the compact end (e.g. `18` → 13.5pt). The user sets the final value here; this is what prevents the deck from exporting at an unintentionally small size.
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
|
||||
```
|
||||
|
||||
Read the confirmed canvas + palette + typography (incl. `body_size`) and any other overrides from `<project_path>/confirm_ui/result.json`. Chat is the canonical fallback when the page cannot open (remote / headless) — present the same fields in chat and honor the reply identically. Always run `--shutdown` on exit (page-confirm or chat-fallback) so port 5050 is free for Step 6 live preview.
|
||||
|
||||
On confirmation, enter SKILL.md Step 4 as Strategist with the plan pre-resolved. The two beautify invariants always hold: the content-faithful clause ([`strategist.md`](../references/strategist.md) §d Layer 1) and page count = source slide count (strict 1:1). Everything else comes from the **confirmed** `result.json` — `mode` (recommended `briefing`), canvas, `visual_style`, color (e) + typography (g) incl. `body_size` (the reviewed values; skip both recommendation flows) — honoring whatever the user kept or overrode. §VII = chart/table data → `templates/charts/`, §VIII = source pictures for re-layout.
|
||||
|
||||
**Hard rule — §IX is verbatim and 1:1**: each source slide becomes exactly one page, in source order, its text transcribed word-for-word from `sources/<stem>.md`. Do not merge, split, drop, or rewrite. Write `design_spec.md` + `spec_lock.md` per `strategist.md` §6, then hand off to the Executor.
|
||||
|
||||
---
|
||||
|
||||
## 6. Executor + Export
|
||||
|
||||
Run the standard pipeline (SKILL.md Steps 6–7). The Executor re-lays-out each page — hierarchy, spacing, alignment, page rhythm — using **only** the inherited palette + fonts from `spec_lock.md`, regenerates charts / tables as native SVG from the extracted data, and re-lays-out the source pictures.
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>
|
||||
python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Validate Output
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py <project_path>/exports/<output.pptx>
|
||||
```
|
||||
|
||||
| Check | Expected |
|
||||
|---|---|
|
||||
| Text fidelity | every source text string appears in the output, unaltered |
|
||||
| Data fidelity | chart categories / series / table cells match the source exactly |
|
||||
| Page count | output slide count equals the source slide count |
|
||||
| Regenerated visuals | charts / tables are native SVG re-themed to the inherited palette |
|
||||
| Identity | generated text / shapes use only `identity.json` colors + fonts |
|
||||
| Paste-back | copying a beautified element into the original deck looks native |
|
||||
|
||||
```markdown
|
||||
## ✅ Beautify Complete
|
||||
|
||||
- [x] Content + data values verbatim (read-back Markdown matches the source)
|
||||
- [x] 1:1 page count preserved
|
||||
- [x] Source colors + fonts inherited as locked truth
|
||||
- [x] Charts / tables regenerated as native SVG in the inherited style
|
||||
- [x] Native PPTX exported to `exports/`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current Boundary
|
||||
|
||||
| Capability | Status |
|
||||
|---|---|
|
||||
| Re-layout with verbatim text | Supported |
|
||||
| Inherit source palette / fonts as truth | Supported |
|
||||
| Strict 1:1 page mapping | Supported |
|
||||
| Regenerate charts / tables as native SVG from extracted data | Supported |
|
||||
| Re-lay-out source pictures | Supported |
|
||||
| Re-pagination (split dense / merge sparse) | Not in v1 |
|
||||
| Carry source charts / tables / images over byte-for-byte | Out of scope — user copies originals manually if wanted |
|
||||
| Free visual-style application / cleanup deviating from source identity | Not in v1 |
|
||||
| Batch / multi-deck beautification | Not in v1 |
|
||||
@@ -81,13 +81,27 @@ Import fidelity rules:
|
||||
|
||||
It is a reconstruction aid, not a final direct template conversion.
|
||||
|
||||
**Vector illustration readability pass**:
|
||||
|
||||
Before the Template_Designer reads imported SVGs, factor large decorative vector groups into project icon assets so the working SVGs stay readable while export remains native shapes. Run this on the SVG view that will feed the selected replication path:
|
||||
|
||||
```bash
|
||||
# standard / fidelity analysis path
|
||||
python3 skills/ppt-master/scripts/extract_svg_assets.py "<import_workspace>/svg" --icons-dir "<import_workspace>/icons" --inplace --id-prefix layered --min-decoration-bytes 3000 --clean-stale
|
||||
|
||||
# mirror path
|
||||
python3 skills/ppt-master/scripts/extract_svg_assets.py "<import_workspace>/svg-flat" --icons-dir "<import_workspace>/icons" --inplace --id-prefix flat --min-decoration-bytes 3000 --clean-stale
|
||||
```
|
||||
|
||||
The source SVGs in `<import_workspace>/svg/` / `<import_workspace>/svg-flat/` are rewritten in place with compact `<use data-icon="..."/>` placeholders. Extracted assets live directly under `<import_workspace>/icons/`; `icons/` must contain only icon/vector assets, not rewritten page SVGs or inventories. The inventory is written beside the processed SVG directory (for example `<import_workspace>/svg_vector_asset_inventory.json`). The existing icon embedding path re-inlines the extracted assets before final export, preserving multi-color artwork and non-square viewBox geometry as native SVG shapes. Text-bearing groups are never extracted; text must stay readable/editable in the working SVG. Extraction triggers on either many drawable elements or a large pure-vector XML block, so long single-path illustrations are factored out too. Pure-vector decoration runs inside text-bearing groups use a lower size threshold, allowing card borders and decorative paths to be extracted without hiding text. Referenced defs (`gradient` / `pattern` / `filter` / `clipPath` / `marker`) are copied into each asset and namespaced so the asset is self-contained after re-inline. If both layered and flat views are processed into the same icon directory, keep distinct `--id-prefix` values to avoid asset ID collisions. `--clean-stale` removes only stale generated assets for the current SVG filenames and prefix; it is safe in this import workspace but should not be used against a shared hand-curated icon directory without a specific prefix.
|
||||
|
||||
**Read order during analysis** (read everything below before composing Step 2):
|
||||
|
||||
1. `manifest.json` (factual metadata: slide size, theme, assets, layouts, masters, slide page-types)
|
||||
2. `svg/master_*.svg` and `svg/layout_*.svg` — read these **before** any slide SVG; they show the deck's shared visual language (background, headers, footers, decorative bars). This is what the new template's fixed structure should adapt from.
|
||||
2. cleaned `svg/master_*.svg` and `svg/layout_*.svg` — read these **before** any slide SVG; they show the deck's shared visual language (background, headers, footers, decorative bars). This is what the new template's fixed structure should adapt from.
|
||||
3. `svg/inheritance.json` — confirms which slide uses which layout/master
|
||||
4. exported `assets/`
|
||||
5. cleaned slide SVG references `svg/slide_NN.svg` — content unique to each slide; consult after the master/layout language is understood
|
||||
5. cleaned slide SVG references from `svg/` — content unique to each slide; consult after the master/layout language is understood
|
||||
6. `summary.md` only as a quick orientation aid
|
||||
7. user-provided screenshots or the original PPTX only for visual cross-checking
|
||||
|
||||
@@ -96,26 +110,33 @@ Interpretation rule (carries forward into Steps 2 and 4):
|
||||
- `manifest.json` is the source of truth for slide size, theme colors, fonts, background inheritance, reusable asset inventory, unique layout/master structure, and slide reuse relationships
|
||||
- `summary.md` is a quick scan; never treat it as the canonical fact source — go back to `manifest.json` if anything is unclear
|
||||
- exported `assets/` are the canonical reusable image pool — `<image>` references in `svg/` already point at these files directly
|
||||
- `svg/master_*.svg` / `svg/layout_*.svg` are the **primary source for fixed structural design** — recurring backgrounds, page chrome, decorative motifs that the template should preserve. The new template's `01_cover` / `02_chapter` / `03_content` / `04_ending` typically inherit elements from these layers.
|
||||
- `svg/slide_NN.svg` shows page-specific content — useful for judging composition rhythm and content density, not for fixed structure. Read every slide regardless of count.
|
||||
- `svg-flat/slide_NN.svg` is for human preview and screenshot comparison; do not treat duplicated master/layout chrome inside flat slides as separate reusable template structure.
|
||||
- exported `icons/*.svg` are the canonical reusable vector illustration pool, but they are **not** part of the default read set. Read the cleaned SVGs and `*_vector_asset_inventory.json` first; open a specific icon SVG only when the cleaned page or inventory shows that the extracted asset is relevant to the current design decision. This is what makes the SVG work surface smaller.
|
||||
- cleaned `svg/master_*.svg` / `svg/layout_*.svg` are the **primary source for fixed structural design** — recurring backgrounds, page chrome, decorative motifs that the template should preserve. The new template's `01_cover` / `02_chapter` / `03_content` / `04_ending` typically inherit elements from these layers.
|
||||
- cleaned `svg/slide_NN.svg` shows page-specific content — useful for judging composition rhythm and content density, not for fixed structure. Read every slide regardless of count.
|
||||
- cleaned `svg-flat/slide_NN.svg` is for human preview and screenshot comparison; do not treat duplicated master/layout chrome inside flat slides as separate reusable template structure.
|
||||
- screenshots remain useful for judging composition and style, but should not override extracted factual metadata unless the import result is clearly incomplete
|
||||
|
||||
**Hard read gate** (`standard` / `fidelity` modes — `mirror` differs, see below):
|
||||
|
||||
- The agent MUST finish reading every `svg/master_*.svg`, `svg/layout_*.svg`, and `svg/slide_*.svg` file under `<import_workspace>/svg/` before moving on to Step 2
|
||||
- The agent MUST finish reading every cleaned `master_*.svg`, `layout_*.svg`, and `slide_*.svg` file from the layered `svg/` view before moving on to Step 2
|
||||
- The agent MUST list the read master / layout / slide filenames inside the Step 2 brief proposal as proof of the gate
|
||||
|
||||
Do **not** treat the imported PPTX or exported slide SVGs as direct final template assets — Step 4 reconstructs them as a clean, maintainable PPT Master template package, not a 1:1 shape translation.
|
||||
|
||||
> **Mirror-mode fast path** — when the user has indicated mirror replication (verbatim copy of every source slide):
|
||||
> - Read **only** `svg-flat/slide_*.svg` (the self-contained, what-PowerPoint-shows view) and `manifest.json` (for theme colors, fonts, asset inventory).
|
||||
> - Run the vector illustration readability pass on `svg-flat/`, then read **only** the cleaned flat `slide_*.svg` files (the self-contained, what-PowerPoint-shows view) and `manifest.json` (for theme colors, fonts, asset inventory).
|
||||
> - Skip `svg/master_*.svg` / `svg/layout_*.svg` / `svg/inheritance.json` — chrome / content separation is irrelevant in mirror mode (no placeholder insertion happens).
|
||||
> - Mirror is explicitly a verbatim copy flow — every slide becomes a template page as-is. The "reconstruct, don't translate" rule applies to `standard` / `fidelity` only.
|
||||
> - Mirror is explicitly a visual-verbatim copy flow: every slide becomes a template page as-is, except that large vector illustrations may be factored into `icons/` placeholders for maintainability. The "reconstruct, don't translate" rule applies to `standard` / `fidelity` only.
|
||||
|
||||
### 1B. Existing SVG assets
|
||||
|
||||
`ls` the directory and `Read` every `*.svg` to extract:
|
||||
If the source SVG directory contains complex vector blobs, first copy the SVG files into a throwaway analysis workspace and run the same readability pass there. Do **not** rewrite the user's original source directory in place.
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/extract_svg_assets.py "<svg_analysis_workspace>/svg" --icons-dir "<svg_analysis_workspace>/icons" --inplace --id-prefix source --min-decoration-bytes 3000 --clean-stale
|
||||
```
|
||||
|
||||
Then `ls` the analysis workspace and `Read` every cleaned `*.svg` to extract:
|
||||
|
||||
- canvas size (`viewBox` on the root `<svg>`)
|
||||
- recurring colors (`fill` / `stroke` values; identify the dominant 2–4 hex codes as candidate theme colors)
|
||||
@@ -123,6 +144,8 @@ Do **not** treat the imported PPTX or exported slide SVGs as direct final templa
|
||||
- placeholder usage (existing `{{...}}` strings, if any)
|
||||
- structural decoration (recurring `<rect>` bars, `<path>` motifs, embedded `<image>` references)
|
||||
|
||||
Read the generated `*_vector_asset_inventory.json` before opening individual `<svg_analysis_workspace>/icons/*.svg`; do not bulk-read extracted icons unless a specific asset affects a design decision or is selected for mirror preservation.
|
||||
|
||||
If a `design_spec.md` or `spec_lock.md` accompanies the SVGs, `Read` it too — it is a higher-confidence source than re-deriving from the SVG alone. Record the equivalent of a `manifest.json`'s factual fields in your own analysis notes (no actual file written) so Step 2 can label them `[fact]`.
|
||||
|
||||
### 1C. Image / visual references
|
||||
@@ -172,7 +195,7 @@ Items to surface:
|
||||
|
||||
For type A, also include in this message:
|
||||
|
||||
- the exact `svg/master_*.svg`, `svg/layout_*.svg`, `svg/slide_*.svg` filenames you read (proof of the hard read gate)
|
||||
- the exact cleaned `master_*.svg`, `layout_*.svg`, `slide_*.svg` filenames you read from the layered `svg/` view (proof of the hard read gate)
|
||||
- a one-line summary of the master / layout structure you extracted
|
||||
|
||||
The user replies with corrections, additions, or "all good".
|
||||
@@ -226,10 +249,11 @@ If the input source is type A, pass the following internal package to the role:
|
||||
- `manifest.json`
|
||||
- `summary.md` (orientation only)
|
||||
- exported `assets/`
|
||||
- cleaned slide SVG references from `svg/`
|
||||
- `*_vector_asset_inventory.json`, when the vector readability pass extracted assets; do not bulk-read `icons/*.svg`
|
||||
- cleaned SVG references from `svg/`
|
||||
- optional screenshots, if available
|
||||
|
||||
For type B, pass the SVG file list, any companion `design_spec.md` / `spec_lock.md`, and the analysis notes.
|
||||
For type B, pass the cleaned SVG file list from the analysis workspace, `*_vector_asset_inventory.json` if extraction ran, any companion `design_spec.md` / `spec_lock.md`, and the analysis notes. Do not bulk-read extracted icons; open individual `icons/*.svg` only when needed.
|
||||
For type C, pass the image file list and the visual analysis notes.
|
||||
For type D, pass only the finalized brief.
|
||||
|
||||
@@ -241,18 +265,19 @@ The role uses the analysis bundle to anchor objective facts such as theme colors
|
||||
|
||||
**Mirror-mode override** (type A or B): when `Replication mode: mirror`, this step is a **verbatim copy** rather than a reconstruction. The Template_Designer role:
|
||||
|
||||
1. **Copies the source pages** into the template directory **without any modification** — no placeholder insertion, no decorative simplification, no chrome/content reorganization. The SVG that ships in the template is byte-for-byte the source page (modulo filename change and asset path rewrites).
|
||||
- Type A: source is `<import_workspace>/svg-flat/slide_NN.svg`
|
||||
- Type B: source is each `*.svg` in the input directory (already self-contained)
|
||||
1. **Copies the cleaned source pages** into the template directory **without reconstruction** — no content placeholder insertion, no decorative simplification, no chrome/content reorganization. The SVG that ships in the template is the cleaned source page, modulo filename changes, asset path rewrites, and the vector illustration placeholders created by the readability pass.
|
||||
- Type A: source is the cleaned flat `<import_workspace>/svg-flat/slide_NN.svg`
|
||||
- Type B: source is each cleaned `*.svg` in the analysis workspace when extraction ran; otherwise each `*.svg` in the input directory (already self-contained)
|
||||
2. **Renames each file** using the source-order-first convention `<NNN>_<page_type>.svg`, where `<NNN>` is the source-order index zero-padded to 3 digits and `<page_type>` is typically `cover` / `toc` / `chapter` / `content` / `ending` (fall back to `content` when the type cannot be confidently classified). Examples: `001_cover.svg`, `002_toc.svg`, `003_content.svg`, ..., `050_ending.svg`.
|
||||
- Type A: derive `<page_type>` from `manifest.json.pageTypeCandidates`
|
||||
- Type B: derive `<page_type>` from the source filename when it follows the PPT Master convention (`01_cover.svg` → `cover`, `03a_content_two_col.svg` → `content`); otherwise infer from page content or fall back to `content`
|
||||
3. **Copies bundled assets** into the template directory and rewrites the `<image href="...">` paths inside each copied SVG to point at the local copies. Asset filenames may be renamed to semantic names (`brand_emblem.png` instead of `image3.png`) when it improves readability — but the rewrite must be consistent across every page.
|
||||
- Type A: assets come from `<import_workspace>/assets/`
|
||||
- Type B: resolve relative paths in source `<image href="...">` against the source SVG location and copy each unique asset; if the source already follows PPT Master conventions (assets co-located with SVGs in the same directory), copy the whole asset set and then rewrite paths
|
||||
4. Writes `design_spec.md` per [template-designer.md](../references/template-designer.md) §1 — the **§V Page Roster description per page is the load-bearing artifact** because mirror has no placeholders to advertise the per-page contract; downstream Strategist selects pages purely from these descriptions.
|
||||
4. **Copies `icons/` when present** and preserves every extracted `<use data-icon="..."/>` reference. Do not inline these assets manually in the template working SVGs; the shared icon embedding path owns re-inlining before export.
|
||||
5. Writes `design_spec.md` per [template-designer.md](../references/template-designer.md) §1 — the **§V Page Roster description per page is the load-bearing artifact** because mirror has no placeholders to advertise the per-page contract; downstream Strategist selects pages purely from these descriptions.
|
||||
|
||||
Mirror mode does **not** invoke the "reconstruct into clean SVG" pathway. The sprite-sheet preservation rule still applies (because the flat SVGs already contain the original sprite wrappers — do not flatten them when copying).
|
||||
Mirror mode does **not** invoke the "reconstruct into clean SVG" pathway. The sprite-sheet preservation rule still applies (because the flat SVGs already contain the original sprite wrappers — do not flatten them when copying). Vector illustration placeholders are the only allowed maintainability rewrite.
|
||||
|
||||
**Expected outputs from this step** (full spec → [template-designer.md](../references/template-designer.md)):
|
||||
|
||||
@@ -292,8 +317,9 @@ python3 skills/ppt-master/scripts/svg_quality_checker.py "skills/ppt-master/temp
|
||||
- [ ] SVG viewBox matches the chosen canvas format (for `ppt169`: `0 0 1280 720`)
|
||||
- [ ] Placeholder names follow the canonical convention where applicable; templates with intentionally different vocabularies (e.g. `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`) should declare a `placeholders:` frontmatter block to silence advisory warnings
|
||||
- [ ] Asset files referenced by SVGs actually exist in the template package
|
||||
- [ ] If any SVG references an extracted vector `data-icon`, the corresponding SVG asset exists directly under the package's `icons/` directory; do not add a separate illustration embedding script
|
||||
- [ ] For `fidelity` mode: every sprite-sheet asset retains its nested `<svg viewBox=...>` crop wrapper; no image whose file aspect differs from its on-page aspect was flattened to a bare `<image>`
|
||||
- [ ] For `mirror` mode: file count equals source page count (type A: `ls templates/layouts/<id>/*_*.svg | wc -l` matches `<import_workspace>/svg-flat/slide_*.svg | wc -l`; type B: matches the source SVG count); filenames follow the `<NNN>_<page_type>.svg` convention; **no `{{...}}` placeholder strings appear in any copied SVG** (`grep -l "{{" templates/layouts/<id>/*.svg` should return nothing — if the type B source itself contains placeholders, the user should be in `standard` mode, not `mirror`); §V Page Roster in `design_spec.md` lists every emitted file with a one-line description of what the page contains and what content slot it suits
|
||||
- [ ] For `mirror` mode: file count equals source page count (type A: `ls templates/layouts/<id>/*_*.svg | wc -l` matches the cleaned flat `<import_workspace>/svg-flat/slide_*.svg | wc -l`; type B: matches the source SVG count); filenames follow the `<NNN>_<page_type>.svg` convention; **no `{{...}}` placeholder strings appear in any copied SVG** (`grep -l "{{" templates/layouts/<id>/*.svg` should return nothing — if the type B source itself contains placeholders, the user should be in `standard` mode, not `mirror`); §V Page Roster in `design_spec.md` lists every emitted file with a one-line description of what the page contains and what content slot it suits
|
||||
|
||||
This step is a **hard gate**. Do not register the template into the library index until validation passes.
|
||||
|
||||
|
||||
+3
-2
@@ -4,14 +4,15 @@ description: Customize default PPTX animations with per-slide and per-object tim
|
||||
|
||||
# Customize Animations Workflow
|
||||
|
||||
> Standalone post-generation step. Run when the user asks to tune animation order, effects, timing, or object-level reveals. Default PPTX export already has global animations; this workflow only creates `animations.json` overrides when the user wants finer control.
|
||||
> Standalone post-generation step. Run when the user asks to tune animation order, effects, timing, or object-level reveals — including simply turning per-element entrance animation on (it is off by default; page transitions still apply). This workflow creates `animations.json` overrides when the user wants element animation or finer control.
|
||||
|
||||
## When to Run
|
||||
|
||||
| Condition | Action |
|
||||
|---|---|
|
||||
| User asks for object-level animation, reveal order, timing, or effect changes | Run this workflow |
|
||||
| User only wants the default animated deck | Do not run; normal `svg_to_pptx.py` export is enough |
|
||||
| User only wants the default deck (page transitions, no element builds) | Do not run; normal `svg_to_pptx.py` export is enough |
|
||||
| User just wants per-element entrance animation back on, deck-wide | No config needed — export with `svg_to_pptx.py -a auto`; run this workflow only for per-slide/per-object control |
|
||||
| `svg_output/*.svg` is missing | Complete the main Executor phase first |
|
||||
| Existing `animations.json` is present | Validate and edit it; do not overwrite unless the user asks |
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "shadcn",
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"commit": "c2ddedf5d24e0cee05936c69e1f199a9975a07b2",
|
||||
"commit": "3ffd3e1c7c801f5af5aa72f7c70e21ace3b356bc",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/shadcn",
|
||||
"syncedAt": "2026-06-18T16:00:00Z"
|
||||
"syncedAt": "2026-06-19T16:00:00Z"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "superpowers",
|
||||
"version": "6.0.2",
|
||||
"version": "6.0.3",
|
||||
"description": "面向编码 Agent 的规划、TDD、调试、代码评审和交付工作流集合。",
|
||||
"author": {
|
||||
"name": "Jesse Vincent",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "superpowers",
|
||||
"repo": "https://github.com/obra/superpowers.git",
|
||||
"ref": "main",
|
||||
"commit": "b62616fc12f6a007c6fd5118146821d748da0d33",
|
||||
"commit": "896224c4b1879920ab573417e68fd51d2ccc9072",
|
||||
"adapter": "codex-plugin",
|
||||
"sourcePath": ".",
|
||||
"syncedAt": "2026-06-17T07:05:29Z"
|
||||
"syncedAt": "2026-06-19T16:00:00Z"
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ sequences — the single most expensive failure observed. Track progress in
|
||||
a ledger file, not only in todos.
|
||||
|
||||
- At skill start, check for a ledger:
|
||||
`cat "$(git rev-parse --git-path sdd)/progress.md"`. Tasks listed there
|
||||
`cat "$(git rev-parse --show-toplevel)/.superpowers/sdd/progress.md"`. Tasks listed there
|
||||
as complete are DONE — do not re-dispatch them; resume at the first task
|
||||
not marked complete.
|
||||
- When a task's review comes back clean, append one line to the ledger in
|
||||
@@ -260,6 +260,8 @@ a ledger file, not only in todos.
|
||||
- The ledger is your recovery map: the commits it names exist in git even
|
||||
when your context no longer remembers creating them. After compaction,
|
||||
trust the ledger and `git log` over your own recollection.
|
||||
- `git clean -fdx` will destroy the ledger (it's git-ignored scratch); if
|
||||
that happens, recover from `git log`.
|
||||
|
||||
## Prompt Templates
|
||||
|
||||
|
||||
+3
-6
@@ -5,9 +5,8 @@
|
||||
# tasks intact.
|
||||
#
|
||||
# Usage: review-package BASE HEAD [OUTFILE]
|
||||
# Default OUTFILE: <git-dir>/sdd/review-<base7>..<head7>.diff — unique per
|
||||
# repo instance and per range, so concurrent sessions cannot collide and a
|
||||
# re-review after fixes always gets a distinctly named fresh file.
|
||||
# Default OUTFILE: <repo-root>/.superpowers/sdd/review-<base7>..<head7>.diff
|
||||
# (named per range, so a re-review after fixes gets a distinct fresh file).
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 2 ] || [ $# -gt 3 ]; then
|
||||
@@ -24,9 +23,7 @@ git rev-parse --verify --quiet "$head" >/dev/null || { echo "bad HEAD: $head" >&
|
||||
if [ $# -eq 3 ]; then
|
||||
out=$3
|
||||
else
|
||||
dir=$(git rev-parse --git-path sdd)
|
||||
mkdir -p "$dir"
|
||||
dir=$(cd "$dir" && pwd)
|
||||
dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace")
|
||||
out="$dir/review-$(git rev-parse --short "$base")..$(git rev-parse --short "$head").diff"
|
||||
fi
|
||||
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resolve and ensure the working-tree directory SDD uses for its short-lived
|
||||
# artifacts: task briefs, implementer reports, review packages, and the
|
||||
# progress ledger. Print the directory's absolute path.
|
||||
#
|
||||
# The workspace lives in the working tree (not under .git/) because Claude Code
|
||||
# treats .git/ as a protected path and denies agent writes there — which blocks
|
||||
# an implementer subagent from writing its report file. A self-ignoring
|
||||
# .gitignore keeps the workspace out of `git status` and out of accidental
|
||||
# commits without modifying any tracked file.
|
||||
#
|
||||
# Single source of truth for the workspace location, so task-brief and
|
||||
# review-package cannot drift to different directories.
|
||||
#
|
||||
# Usage: sdd-workspace
|
||||
set -euo pipefail
|
||||
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
dir="$root/.superpowers/sdd"
|
||||
mkdir -p "$dir"
|
||||
printf '*\n' > "$dir/.gitignore"
|
||||
cd "$dir" && pwd
|
||||
+3
-5
@@ -4,8 +4,8 @@
|
||||
# through the controller's context.
|
||||
#
|
||||
# Usage: task-brief PLAN_FILE TASK_NUMBER [OUTFILE]
|
||||
# Default OUTFILE: <git-dir>/sdd/task-<N>-brief.md — unique per repo
|
||||
# instance, so concurrent sessions cannot collide.
|
||||
# Default OUTFILE: <repo-root>/.superpowers/sdd/task-<N>-brief.md
|
||||
# (per worktree; concurrent runs in the same working tree share it).
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 2 ] || [ $# -gt 3 ]; then
|
||||
@@ -20,9 +20,7 @@ n=$2
|
||||
if [ $# -eq 3 ]; then
|
||||
out=$3
|
||||
else
|
||||
dir=$(git rev-parse --git-path sdd)
|
||||
mkdir -p "$dir"
|
||||
dir=$(cd "$dir" && pwd)
|
||||
dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace")
|
||||
out="$dir/task-${n}-brief.md"
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user