Claude Skill

md2ppt

Use when the user wants to turn a Markdown report into a presentation-quality .pptx via interactive design decisions and a reusable hand-coded build script. Drives pre-analysis, global style choices, optional per-slide layout dialogue, python-pptx composition, and optional LibreO

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download kerberosclaw-kc_ai_skills-md2ppt-ad005ac.zip · 24 KB
Part of kerberosclaw/kc_ai_skills — 25 skills

Install

skills CLI npx skills add https://github.com/KerberosClaw/kc_ai_skills/tree/main/md2ppt
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install kerberosclaw-kc-ai-skills@llmmart
Git git clone https://github.com/KerberosClaw/kc_ai_skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole kerberosclaw/kc_ai_skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

md2ppt

You are a senior presentation designer working interactively with the user to turn a Markdown report into a polished .pptx.

You do NOT auto-convert — generic markdown → pptx auto-conversion produces low-quality decks. Instead, you:

  1. Pre-analyze the input markdown
  2. Run a numbered-list quiz to lock global design decisions
  3. Walk through each slide, proposing layout, asking user when ambiguous
  4. Compose a hand-coded build script using scripts/pptx_helpers.py
  5. Render → preview → iterate per-slide patches
  6. Save the build script for future content updates

Trigger

/md2ppt path/to/report.md
/md2ppt path/to/report.md path/to/output.pptx

If output path omitted, default to same dir as input with .pptx extension.

For brand-template integration (套公版 / inheriting an existing .pptx's theme + chrome), see "Brand template (ad-hoc, optional)" near end. Brand integration is not part of the default workflow — every template is unique and prescribing a generic workflow produces wrong layout choices. Handle via direct LLM-user dialogue using the helper primitives.

Prerequisites Check

MANDATORY before anything else:

# Check or create shared venv (~/.venv_pptx)
test -d ~/.venv_pptx || python3 -m venv ~/.venv_pptx
~/.venv_pptx/bin/pip install -q python-pptx pillow

# Check mmdc (for mermaid rendering, optional but recommended)
which mmdc || echo "mmdc missing — install: npm install -g @mermaid-js/mermaid-cli"

# Check soffice (for Step 6.5 self-check, optional)
SOFFICE="$(which soffice 2>/dev/null || ls /Applications/LibreOffice.app/Contents/MacOS/soffice 2>/dev/null)"
test -n "$SOFFICE" || echo "soffice missing — install: brew install --cask libreoffice (optional, enables Step 6.5 visual self-check)"
  • If mmdc missing: ask user install (recommended) or skip mermaid → all diagrams as ASCII monospace.
  • If soffice missing: skip Step 6.5 self-check silently; user does manual review only.

Workflow

Step 1: Read input.md + pre-analyze

Use scripts/md_analyze.py:

~/.venv_pptx/bin/python ~/.claude/skills/md2ppt/scripts/md_analyze.py <input.md>

Output:

H1 (cover):    <title or "MISSING">
H2 sections:   N
H3 subsections: M
Tables:        K (largest: R rows × C cols)
Code blocks:   X (ASCII art: A, mermaid: B, plain code: C)
Bullet lists:  Y
Estimated slides: Z (1 cover + N H2 + 1 Q&A)

Show summary to user. DO NOT proceed without user seeing this.

Step 2: Global design quiz (numbered list)

Ask the user the following — one question per turn, wait for answer before next:

Q1. Slide grouping. Default: 1 H2 = 1 slide + cover + Q&A. Show estimated slide list. User can: (a) accept default (b) merge sections (which → which) (c) split a heavy section into 2-3 slides

Q2. Style preset. Pick: (a) corporate_blue (深藍標題 + PingFang TC + 紅強調 + 綠 OK,商務風) (b) minimal_dark (黑底白字 + 簡潔) (c) custom (user 提供 hex 色碼 + 字型)

Q3. Cover + Q&A slides. Yes / no / 只要 cover / 只要 Q&A

Q4. Diagram rendering strategy. For mermaid blocks + ASCII art blocks found in step 1: (a) all mermaid → PNG; ASCII art stay monospace (b) all → mermaid PNG (convert ASCII art too — agent attempts conversion, asks user to confirm each) (c) all → ASCII monospace (no mmdc dependency) (d) per-block decide (ask each)

Q5. Per-slide layout granularity. (a) auto (helpers pick best layout per slide based on content type) (b) walk-through (ask user for each slide — recommended for important deck)

If user answers Q5(b), proceed to Step 3. If Q5(a), skip to Step 4.

Step 3: Per-slide walk-through (if Q5 = b)

For each slide group:

  • Show slide draft as text outline (title / subtitle / blocks summary)
  • Propose layout from this decision table:
Content type Suggested layout
Single mermaid / image Title + image fit + center align
Single table (≤ 6 rows) Title + table full width
Single table (> 6 rows) Split 2 slides OR shrink font + col widths
Bullets only Title + bullet textbox
Bullets + small table Two-column (bullets left + table right)
ASCII art (flow / topology) Title + monospace textbox + colored highlights
Bar chart data Title + python-pptx native bars (helper add_log_bar)
Mixed (bullets + image + para) Confirm layout with user — too ambiguous

User can override each. Lock final layout for this slide.

Step 4: Compose build script

Generate ONE Python build script that:

  1. Imports helpers from ~/.claude/skills/md2ppt/scripts/pptx_helpers.py
  2. Imports style preset constants
  3. Renders any mermaid blocks via scripts/render_mermaid.sh to _assets/ next to output
  4. Hand-codes each slide — one slide = one section of # ============== Slide N ============== block + helper calls

CRITICAL: Do not write a generic loop over markdown blocks. Each slide is a hand-coded composition because layout choices made in Step 2/3 are slide-specific.

Save script to a project-local build dir.

Path discovery (in order):

  1. If a previous md2ppt build dir already exists under input.md's project root, use it:
    • drafts/ppt/ (recommended convention)
    • or any dir containing existing build_*.py produced by md2ppt
  2. Else if input.md is under a project root (detected by .git, CLAUDE.md, pyproject.toml, or similar marker), recommend creating <project_root>/drafts/ppt/
  3. Else save next to input.md (./build_<basename>.py)

Always confirm path with user before writing. Show the resolved path and ask "save build script to <path>/build_<basename>.py? [Y/n / 改其他路徑]".

Path is project convention, not skill-prescribed. Recommend drafts/ppt/ (or whatever the project uses for deck artifacts). Do NOT save the build script into the md2ppt skill folder — skill folder is generic tooling, build scripts contain project-specific content.

See examples/build_quarterly_review.py for reference structure.

Step 5: Render

~/.venv_pptx/bin/python <build_script_path>

Should print OK → <output.pptx> and slide count.

Step 6.5: Self-check (optional, requires LibreOffice)

Skip silently if soffice not installed. This step is a fast filter before user manual review — it catches obvious issues (overflow, tiny fonts, misaligned content) so user doesn't waste review cycles on them.

Render preview PNGs

SOFFICE="$(which soffice 2>/dev/null || echo /Applications/LibreOffice.app/Contents/MacOS/soffice)"
PREVIEW_DIR="/tmp/md2ppt_preview_$$"
mkdir -p "$PREVIEW_DIR"
"$SOFFICE" --headless --convert-to pdf "<output.pptx>" --outdir "$PREVIEW_DIR" >/dev/null 2>&1
# Then convert PDF → PNG per page (sips on macOS, pdftoppm on Linux)
cd "$PREVIEW_DIR" && for p in *.pdf; do
    sips -s format png "$p" --out "${p%.pdf}.png" >/dev/null 2>&1 \
        || pdftoppm -png -r 100 "$p" "${p%.pdf}"
done
ls "$PREVIEW_DIR"/*.png

(Alternative: soffice --headless --convert-to png directly, but PDF intermediate gives more reliable per-page splitting.)

Read each PNG and check

For each slide PNG, use Read tool. Check for these patterns:

Issue Visual signal Fix
Text overflow (off slide bounds) Text cut off at edge / extends past visible area Reduce font size OR split slide OR shorten text
Tiny font (< 12pt rendered) Text barely readable at typical projector zoom Bump size= in helper call
Emoji visible ❌ ✅ 🔴 ⚠️ characters present Grep build script + replace with text/color
Table col widths wrong One column squeezed, others huge whitespace Set col_widths=[Inches(N), ...] explicitly
Picture overflows or cropped Image extends past slide OR has visible white border Use add_picture_fit(... max_height=) or vertical_center_in
Excessive bottom whitespace More than 30% of slide is empty after content vertical_center_in OR scale content up OR remove blank space
Layout placeholder + hand-coded overlap Two title-like elements visible (placeholder default text shows through) Pick layout with no placeholders OR explicitly clear placeholders
Template chrome hidden by white background No logo / page number on slides that should have them Remove any full-slide white rect; helpers should not add background fill

Fix loop

For each finding:

  1. Identify slide # + helper call in build script
  2. Propose specific patch (with exact Edit old_string / new_string)
  3. Apply via Edit
  4. Re-render pptx
  5. Re-render preview PNG
  6. Re-check the affected slide

Maximum 3 self-check retries per file. After 3 retries, stop auto-fix and hand off to user (Step 6).

Report to user

Before Step 6 manual review, report:

  • Total slides checked: N
  • Issues auto-fixed: X (list per slide)
  • Issues remaining after max retries: Y (list per slide, suggested manual action)
  • Self-check is a filter, not authoritative — user manual review still required.

Cleanup

rm -rf "$PREVIEW_DIR"

Step 6: Preview + iterate

Tell user the output path. Ask: open and review, report back per-slide issues.

For each issue user reports:

  • Identify which slide # (use slide content to locate the helper call in build script)
  • Propose specific patch (font size up, picture fit center, table col widths, remove emoji, etc.)
  • Apply via Edit to the build script
  • Re-render

Maximum 5 iterations before stopping and asking user for higher-level redesign.

Common patches user requests:

User feedback Patch
「字體太小」 bump size= in add_textbox / add_bullets / add_table from 12-14 → 14-16
「emoji 拔掉」 grep build script for ❌ ✅ ⚠️ 🔴 etc, replace with text
「圖太大跑版」 switch to add_picture_fit(... vertical_center_in=(top, bottom))
「表格欄寬不對」 set explicit tbl.columns[i].width = Inches(N) after table creation
「下面留白太空」 use vertical_center_in OR add filler textbox OR scale image up
「拼字錯誤」 direct edit to that string in build script

Step 7: Persist + (optional) lock to deliverables

Build script stays in drafts/ppt/ (or wherever user invoked from).

Ask user: ready to lock into deliverables/?

  • Yes → mv .md and .pptx to deliverables/YYYY-MM-DD_<topic>.{md,pptx} (per project naming convention)
  • No → leave in drafts

Build script always stays in drafts/ppt/ — regenerable via python build_<topic>.py after content edits.

Style Presets

Available in scripts/pptx_helpers.py constants:

corporate_blue (default)

FONT       = "PingFang TC"
FONT_MONO  = "Menlo"
COLOR_TITLE  = RGBColor(0x1F, 0x3A, 0x5F)   # 深藍
COLOR_TEXT   = RGBColor(0x21, 0x21, 0x21)
COLOR_ACCENT = RGBColor(0xC0, 0x39, 0x2B)   # 紅
COLOR_OK     = RGBColor(0x27, 0xAE, 0x60)
COLOR_WARN   = RGBColor(0xE6, 0x7E, 0x22)
COLOR_MUTED  = RGBColor(0x7F, 0x8C, 0x8D)
COLOR_BAR    = RGBColor(0x34, 0x98, 0xDB)

Slide size: 16:9 (13.333 × 7.5 inches).

minimal_dark

(Future) — black background, single accent color, larger font.

Decision Frameworks

When to use mermaid PNG vs ASCII monospace

Diagram Choose
Sequence diagram mermaid PNG (rendering > ASCII)
Linear flowchart (≤ 5 nodes) ASCII OK (compact + readable in monospace)
Linear flowchart (> 5 nodes, LR) mermaid PNG
Hierarchical / nested boxes mermaid PNG
State machine mermaid PNG
Directory tree ASCII (tree structure native to monospace)
Single arrow chain A → B → C ASCII inline (no need for diagram)

When to split a slide

A slide is too packed if any:

  • 12 bullets at top level

  • Table > 8 rows OR > 5 columns at default font
  • Image height > 5.5 inches AND has supporting text
  • 3 distinct content blocks (image + table + para + bullets)

Split strategy:

  • For tables: split rows by category (e.g. "受影響" / "不受影響" → 2 slides)
  • For long bullets: group into 2-3 themed slides
  • For image + supporting text: 1 slide image-only + 1 slide text-only

Anti-patterns

  • ❌ Generic markdown parser that loops over blocks — produces ugly, unbalanced slides. Each slide deserves hand-coded composition.
  • ❌ Skipping Step 2 quiz — lock global decisions BEFORE composing slides
  • ❌ Emoji in slide text (❌ ✅ 🔴 ⚠️) — looks unprofessional in business decks. Use text labels ("受影響" / "不受影響") or color (red / green) instead.
  • ❌ ASCII art > 20 lines — too small to read in projector. Convert to mermaid PNG or split.
  • ❌ Inheriting markdown frontmatter into the deck — frontmatter is meta, not content.
  • ❌ Auto-grouping unrelated H3 subsections into one slide just because parent H2 — re-evaluate per H3.
  • ❌ Setting width=Inches(N) on add_picture without height= — vertical-aspect images blow past slide bottom.
  • ❌ Using markdown → arrow as standalone — pptx renders fine but proportional fonts make alignment off. Use full-width → only in monospace context.
  • ❌ Forgetting tbl.columns[i].width = Inches(N) after add_table — default equal-width often wrong (e.g. # column should be narrow).

Self-check anti-patterns

  • ❌ Treating Step 6.5 as final approval. LibreOffice render isn't identical to PowerPoint/Keynote — chrome, fonts, color may differ. User manual review (Step 6) is always the last gate.
  • ❌ Letting self-check retry > 3 times. Beyond that, the issue is probably structural and needs user direction, not more auto-fixes.
  • ❌ Forgetting to cleanup $PREVIEW_DIR — /tmp fills up over many runs.

Important Rules

  1. Hand-code per-slide. No generic auto-conversion. The build script is a deliberate composition.
  2. Always quiz user in Step 2 before composing. No silent default choices.
  3. add_picture_fit over raw add_picture. Always pass max_width and max_height to prevent overflow.
  4. vertical_center_in for slides with one centered figure. Avoids bottom whitespace.
  5. Set table columns[i].width explicitly. Default equal-width tables look bad with mixed col content.
  6. Strip frontmatter, dates, personal attribution from content if user said this is for external/公開 audience (apply the project's publication-sanitization rules, if any).
  7. Build script lives in the invoking project's drafts area (e.g. drafts/ppt/), NEVER in the md2ppt skill folder. Deliverables dir is for the rendered .pptx artifact only — build script stays in drafts.
  8. Re-render is fast. Iterate freely with user — don't over-think the first pass.
  9. Mermaid LR over TD for multi-step flows — slide 16:9 favors horizontal.
  10. Stop iterating after 5 rounds. If still not satisfied, ask user for higher-level redesign or accept current state and ship.

Self-check (Step 6.5) additional rules

  1. Self-check is a filter, not authoritative. Final visual approval always rests with user manual review (Step 6). LibreOffice render fidelity isn't 100% identical to PowerPoint/Keynote — fonts and template chrome may differ slightly.
  2. Skip silently if soffice missing. Don't block on the optional dependency; suggest install once then move on.
  3. Max 3 self-check retries. Beyond that, hand off remaining issues to user with suggested actions.
  4. Cleanup preview PNGs after Step 6.5. Don't pollute /tmp; remove $PREVIEW_DIR before user review starts.

Output schema

<output_dir>/
├── <input>.pptx              ← rendered deck
├── _assets/                  ← mermaid PNGs (if any)
│   ├── diag_<hash>.mmd
│   └── diag_<hash>.png
└── build_<basename>.py       ← reusable build script

Reported to user:

  • pptx file path
  • slide count
  • mermaid PNGs generated count (if any)
  • build script path

Brand template (ad-hoc, optional)

If user wants the deck to inherit a brand template's theme / chrome / layout (e.g. company-issued .pptx with logo + page numbers + section divider style), handle it as direct LLM-user dialogue, not as a prescribed workflow.

Why no prescribed workflow: every brand template's layout naming, chrome placement, placeholder structure, and design intent differs. Auto-mapping "cover slide → standard layout" / "content slide → blank layout" produces wrong choices that need 4-5 rounds to fix. LLM + user inspecting the template together is faster and more correct.

Helper primitives available in scripts/pptx_helpers.py:

  • init_deck_from_template(path) — open template, strip its existing slides, return a Presentation that inherits theme + masters + layouts
  • list_template_layouts(path) — print all layouts (useful for inspection before writing build script)
  • add_blank_from_template(prs, layout_name="空白") — add slide using a specific template layout. Default 空白 is just a hint; pass any layout name from list_template_layouts output. Clears placeholder default text.
  • add_cover_from_template(prs, layout_name=..., title=..., subtitle=...) — cover-style slide, fills first 2 placeholders with title + subtitle
  • add_section_divider_from_template(prs, layout_name=..., title=...) — section divider, fills first placeholder with title

Recommended ad-hoc dialogue:

  1. User asks to apply brand template
  2. LLM runs list_template_layouts(<path>) to inspect, shows output to user
  3. User identifies which layout matches their cover / section divider / content / Q&A
  4. LLM writes a fresh build script using init_deck_from_template + the chosen layout names per slide type
  5. Iterate per-slide as needed (chrome overlap with hand-coded textboxes is the most common issue)

examples/build_quarterly_review_branded.py is a reference build script showing the helpers in use (with placeholder template path).

Always:

  • Pass template path via os.environ.get('MD2PPT_BRAND_TEMPLATE', '<relative-path>') or CLI arg — never hardcoded
  • Skip the helper white-rect-background trap: the helpers do NOT add a white rectangle, so template chrome shows through
  • Beware placeholder default text ("按一下以新增標題") — clear with add_blank_from_template (default behavior) or skip layouts that have placeholders for content slides (typically 空白 or similar layouts have 0 placeholders)

References

  • scripts/pptx_helpers.py — all helpers (add_blank_slide / add_textbox / add_title_bar / add_bullets / add_table / add_picture_fit / add_log_bar / add_mono_block / init_deck_from_template / add_cover_from_template / add_section_divider_from_template / add_blank_from_template / list_template_layouts)
  • scripts/render_mermaid.sh — mmdc wrapper with caching
  • scripts/md_analyze.py — pre-analyze input.md
  • examples/build_quarterly_review.py — reference build script (abstract content, default flow)
  • examples/build_quarterly_review_branded.py — reference build script (ad-hoc brand template integration)
  • docs/DESIGN.md — design rationale + history
Files (kc_ai_skills)
  • docs
    • DESIGN.md 9.3 KB
      # md2ppt — 為什麼不做 generic markdown→pptx 自動轉換
      
      > **English summary:** Design doc for md2ppt, an interactive pptx builder that drives a per-slide layout dialogue and emits a hand-coded build script. Built on python-pptx + mmdc, with optional LibreOffice headless for self-check. Generic markdown→pptx auto-converters (Marp, pandoc) produce slides that are syntactically correct but visually unbalanced — slide layout decisions need content meaning, not just markdown structure. Brand-template integration was prototyped as a prescribed workflow in v0.2, then pulled back to ad-hoc helper primitives in v0.4 after testing on a real template took 5 rounds of debugging to find the right layout names.
      
      ## 這東西為什麼存在
      
      把 markdown 報告轉 pptx 簡報這件事,聽起來簡單。
      
      ```bash
      # 你以為可以這樣
      pandoc report.md -o deck.pptx
      marp report.md --pptx
      ```
      
      是會出來,但**長得很醜**:
      
      - 一張 slide 一個 H2,但 H2 內容多寡天差地遠 — 短的擠在頂端剩白,長的爆出 slide
      - Table 全用 default 等寬欄位,`#` 那欄佔三分之一,內容欄被擠扁
      - Mermaid 渲染進去當 image,但常常太大或太小,沒對齊
      - ASCII art code block 用比例字體 render,排版整個垮掉
      - 字型 default 那種一看就「不像簡報」
      
      **問題不在工具不夠強,在 layout 是 design decision 不是 syntactic transform**。一張 markdown table 4 row 是一張 slide;同樣 table 12 row 該拆兩 slide;30 row 該丟附錄。Bullet list 4 條一張 slide,14 條該分主題拆 3 張。Generic parser 不知道哪一條是「重點 punchline」哪一條是「補充 caveat」,所以視覺權重都一樣。
      
      ## 設計思路
      
      ### 互動式對話取代 generic parser
      
      skill 跑流程:
      
      ```
      1. Pre-analyze input.md(統計 H2/H3/table/code block/bullet)
      2. Quiz user (5 題:slide grouping / style / cover / diagram strategy / per-slide granularity)
      3. (optional) per-slide walk-through(逐張 layout 提案)
      4. 寫 hand-coded build script(每張 slide 一段 # ====== Slide N ====== + helper calls)
      5. Render pptx
      6. (optional) self-check via LibreOffice → 看 PNG 找跑版
      7. User manual review → iterate per-slide patch
      ```
      
      LLM 取代了 generic parser 的 hard-coded heuristic — 跟 user 對話 5 分鐘,得到的 quality 接近完全 hand-code,但 user 不用會 python-pptx。
      
      ### 為什麼是 python-pptx 不是 Marp / pandoc
      
      | | Marp | pandoc | python-pptx (我們) |
      |---|---|---|---|
      | 主要輸出 | HTML / PDF | 各種 doc 格式 | 直接 pptx |
      | pptx 質感 | 普通(theme engine 受限)| 粗糙(忽略多數 layout intent)| 完全自控 |
      | 互動性 | 寫死在 markdown | 寫死在 markdown | 跟 user 對話決策 |
      | Mermaid | plugin support | lua filter | mmdc 渲染 PNG 嵌入 |
      | Iterate cost | 改 .md re-export | 改 .md re-export | 改 .py re-render(同樣快)|
      
      關鍵點:**python-pptx 給的是 primitive,不是 template**。我們可以決定「這張 slide 標題 30pt 深藍 + 底下放 12 inch 寬 table + 配 5 條 14pt bullet」,沒 theme engine 限制。
      
      ### Mermaid PNG vs ASCII monospace 怎麼選
      
      Mermaid 渲染 PNG 不是萬能解,大多數 case 用 ASCII 還更可讀:
      
      | 圖類型 | 推薦 |
      |---|---|
      | Sequence diagram | mermaid PNG(時序視覺化 > ASCII)|
      | 線性 flowchart ≤ 5 nodes | ASCII OK(monospace 對齊讀得清楚)|
      | 線性 flowchart > 5 nodes 且 LR layout | mermaid PNG |
      | 嵌套 / 階層 box | mermaid PNG(ASCII 嵌套對齊很煩)|
      | 狀態機 | mermaid PNG |
      | Directory tree | ASCII(tree 是 monospace native)|
      | 單純箭頭鏈 `A → B → C` | ASCII inline(整段 textbox 不需要圖)|
      
      skill 在 Step 2 Q4 quiz user 全 mermaid / 全 ASCII / mixed / 個別決定,根據 case 來。
      
      ## Build script 是 source of truth
      
      每份 deck 對應一份 `build_<topic>.py`,大概 200-400 行。**這個 .py 是 deck 真正的 source code,.pptx 是 build 出來的 binary**:
      
      - 數據要更新(typo / 新數字)→ 改 .py 一行 → re-run 5 秒
      - 加一張新 slide → 加一段 `# ====== Slide N ======` block → re-run
      - 換配色 → 改 style 常數 → re-run
      
      不維護 build script,只留 .pptx 的 cost:下次小改要在 PowerPoint 手改,容易跑版,失去原本對齊的 layout。
      
      build script 跟 input.md + `_assets/*.png` cache 一起放專案的 `drafts/ppt/`(慣例)。.pptx 出貨後存 `deliverables/`。
      
      ## Style preset 為什麼選 corporate_blue 為 default
      
      簡報 95% 場景是內部 review / 主管簡報 / stakeholder 對齊,所以 default 走「保守商務風」:
      
      - **深藍 (`#1F3A5F`)** 標題 — 不會太刺眼也夠 contrast
      - **PingFang TC** 中文字型 — macOS native,字重對比清楚
      - **Menlo** monospace — code / ASCII art 用
      - **紅色 (`#C0392B`)** 強調 — 重點 / warning
      - **綠色 (`#27AE60`)** OK / positive
      - **灰 (`#7F8C8D`)** 副標 / muted text
      - **藍 (`#3498DB`)** 中性 bar / 次要強調
      
      完全不用 emoji。簡報投影出來 ❌ ✅ 🔴 ⚠️ 看起來很業餘 — 用顏色或文字 label("受影響" vs "不受影響")替代。
      
      ## brand mode 試過又退掉(v0.4.0 故事)
      
      v0.2.0 加了「brand mode」prescribed workflow:user 提供公版 .pptx,skill 兩階段產出 content 版 + branded 版,自動 mapping cover / section / Q&A 到 template layout。聽起來合理。
      
      實測下來 5 個 round:
      
      ```
      Round 1: helper 加白底 rectangle 蓋住 template logo → 修
      Round 2: cover 用「標題投影片」layout,結果 user 說公版第 1 頁不是這個 style → 改用「章節標題」
      Round 3: content slide 用「標題投影片」layout,placeholder default 「按一下以新增標題」跑出來疊在我們手刻 textbox 上 → user catch,改用「空白」layout
      Round 4: 「空白」layout 在 helper 內被白底 rect 蓋住沒 chrome → 跟 round 1 同 root cause,加 clear_placeholders
      Round 5: user 說 page 2-12 應該套公版 page 7,結果發現 page 7 用「標題投影片」(剛剛踩過的)→ 真正 content 頁是 page 8 用「空白」
      ```
      
      每個 template 的 layout 命名 / chrome 設計 / placeholder 結構完全不同。**沒辦法寫出 generic「cover_layout / content_layout」抽象**,每次都要 user 跟 LLM 一起 inspect template 才能決定。所謂 brand mode 不過是把這個 dialogue 包成假 workflow。
      
      v0.4.0 退回 ad-hoc:helpers 還在(`init_deck_from_template` / `add_blank_from_template` / `add_cover_from_template` / `list_template_layouts`),user 要套公版時跟 LLM 對話走 helper。SKILL.md 寫了一段「Brand template (ad-hoc, optional)」列踩過的坑(白底 / placeholder / env var)。
      
      教訓:**有些 workflow 不該變成 prescribed,變成抽象反而把問題推到難處理的時機才爆**。
      
      ## Self-check loop:LibreOffice headless
      
      Step 6.5 optional,如果有裝 `soffice`(macOS:`brew install --cask libreoffice`),把 .pptx render 成 PDF → 切 PNG 一張一張看,8 種 visual issue pattern grep:
      
      | Issue | Visual signal | Fix |
      |---|---|---|
      | Text 跑出 slide | 邊緣截斷 | 縮字 / 拆 slide |
      | 字太小 | 看不清 | bump `size=` |
      | Emoji 出現 | ❌ ✅ 等 | grep + replace |
      | Table 欄寬怪 | 一欄擠一欄空 | 設 `col_widths=` |
      | 圖跑版 | 超出邊界 / 變形 | `add_picture_fit` 加 max_height |
      | 下方留白多 | 30%+ 空 | `vertical_center_in` 或補內容 |
      | placeholder 疊到 | 兩個 title 重疊 | 換 layout / 清 placeholder |
      | Chrome 被蓋 | logo 不見 | 移除全 slide 白底 rect |
      
      Max 3 次 retry auto-fix,搞不定再交還 user manual review。沒裝 soffice 就 silent skip,純人工 review 也 OK。
      
      self-check 不是 authoritative — LibreOffice 跟 PowerPoint / Keynote render 不完全一致,字體 / chrome 可能略差。它只是 first-pass filter,user 視覺 review 仍是最後 gate。
      
      ## 已知 anti-patterns
      
      幾個踩過或看別人踩過的:
      
      - ❌ Generic markdown loop — 每張 slide 都長一樣,不平衡
      - ❌ 跳過 Step 2 quiz 直接寫 build script — 設計決策沒鎖,user 反饋會反覆改
      - ❌ Slide 內 emoji — 投影機看醜,改用顏色 / text label
      - ❌ ASCII art > 20 行 — 投影看不清,改 mermaid 或拆 slide
      - ❌ markdown frontmatter 滲進 deck — frontmatter 是 meta 不是 content
      - ❌ `add_picture(width=N)` 沒帶 height → 縱向圖爆出 slide bottom
      - ❌ Table 不設 `col_widths=` → default 等寬經常很醜
      - ❌ 把 template 既有 slide 留在 branded build script — strip 掉只留 theme/masters
      
      ## File structure
      
      ```
      md2ppt/
      ├── SKILL.md                            主 workflow + decision frameworks + anti-patterns
      ├── scripts/
      │   ├── pptx_helpers.py                 全部 helpers + 配色常數
      │   ├── render_mermaid.sh               mmdc wrapper + content-hash cache
      │   └── md_analyze.py                   Step 1 pre-analyze
      ├── docs/
      │   └── DESIGN.md                       本檔
      └── examples/
          ├── build_quarterly_review.py          reference build script (default flow)
          └── build_quarterly_review_branded.py  reference build script (ad-hoc brand)
      ```
      
      ## 限制 / 未來
      
      - 字型 fallback 假設 PingFang TC(macOS),其他 OS 開的 deck 字型可能跑掉
      - LibreOffice render 跟真實 PowerPoint 視覺有差異,self-check 不是萬能
      - 還沒做的:speaker notes / 多語言版本 / outline-only mode / 自動 theme-aware 字型 fallback
      - v0.5 可能加:把 design decisions 存進 `~/.cache/md2ppt/<input-hash>.json`,re-run 同 input.md 時可以「沿用上次決策」跳過 quiz
      
  • examples
    • build_quarterly_review.py 5.6 KB
      #!/usr/bin/env python3
      """md2ppt — example build script (abstract content).
      
      Reference for what a hand-coded build script looks like after going
      through the md2ppt skill workflow. Subject is fictional ("Q4 product
      metrics review") to demonstrate the helper API without leaking any
      real-world domain context.
      
      Run:
          ~/.venv_pptx/bin/python build_quarterly_review.py
      
      Output:
          quarterly_review.pptx (in current dir)
      """
      
      import sys
      from pathlib import Path
      
      # Adjust this path to wherever the skill helpers live (symlinked into ~/.claude/skills/)
      sys.path.insert(0, str(Path.home() / ".claude/skills/md2ppt/scripts"))
      
      from pptx_helpers import (
          init_deck, add_blank_slide, add_textbox, add_title_bar,
          add_bullets, add_table, add_mono_block, add_picture_fit,
          add_log_bar, save,
          Inches, Pt, PP_ALIGN,
          COLOR_TITLE, COLOR_TEXT, COLOR_ACCENT, COLOR_OK, COLOR_WARN, COLOR_MUTED, COLOR_BAR,
      )
      
      prs = init_deck()
      
      
      # ============================================================
      # Slide 1 — Cover
      # ============================================================
      s = add_blank_slide(prs)
      add_textbox(s, Inches(0.8), Inches(2.4), Inches(11.7), Inches(1.2),
                  "Q4 Product Metrics Review",
                  size=46, bold=True, color=COLOR_TITLE)
      add_textbox(s, Inches(0.8), Inches(3.6), Inches(11.7), Inches(0.7),
                  "Adoption + Reliability + Roadmap Signals",
                  size=22, color=COLOR_TEXT)
      add_textbox(s, Inches(0.8), Inches(6.1), Inches(11.7), Inches(0.5),
                  "Quarterly review deck — example artifact",
                  size=14, color=COLOR_MUTED)
      
      
      # ============================================================
      # Slide 2 — Headline
      # ============================================================
      s = add_blank_slide(prs)
      add_title_bar(s, "Headline", "Three signals to act on this quarter")
      
      add_bullets(s, Inches(0.8), Inches(2.0), Inches(12.0), Inches(2.5), [
          "Active users grew 38% QoQ; activation rate flat — onboarding is the choke point",
          "p95 API latency drifted from 180ms to 420ms after the v3 deploy — not regressed yet",
          "Customer support tickets dominated by a single feature gap (export to CSV)",
      ], size=20)
      
      add_textbox(s, Inches(0.6), Inches(5.5), Inches(12.1), Inches(1.5),
                  "Recommendation: ship CSV export + invest one cycle in onboarding redesign\nbefore opening top-of-funnel further.",
                  size=18, bold=True, color=COLOR_ACCENT, line_spacing=1.4)
      
      
      # ============================================================
      # Slide 3 — Adoption table
      # ============================================================
      s = add_blank_slide(prs)
      add_title_bar(s, "Adoption Metrics", "QoQ comparison, signed-up vs activated")
      
      add_table(s, Inches(0.5), Inches(1.9), Inches(12.3), Inches(3.8), [
          ["Metric",            "Q3",       "Q4",       "Delta"],
          ["Sign-ups",          "12,400",   "17,100",   "+38%"],
          ["Activated (D7)",    "4,090",    "5,300",    "+30%"],
          ["Activation rate",   "33.0%",    "31.0%",    "-2 pp"],
          ["WAU peak",          "8,250",    "11,400",   "+38%"],
          ["DAU/MAU",           "27%",      "26%",      "-1 pp"],
      ], font_size=14, col_widths=[Inches(3.5), Inches(2.8), Inches(2.8), Inches(3.2)])
      
      
      # ============================================================
      # Slide 4 — Throughput bar comparison (log scale)
      # ============================================================
      s = add_blank_slide(prs)
      add_title_bar(s, "API Throughput by Endpoint", "p50 RPS — log scale visualization")
      
      add_textbox(s, Inches(0.6), Inches(1.9), Inches(12.0), Inches(0.5),
                  "Higher = better. Search dominates traffic; export is rarely used.",
                  size=14, color=COLOR_MUTED)
      
      bars = [
          ("/search",       8500, "8,500 rps", COLOR_OK),
          ("/list",         3200, "3,200 rps", COLOR_OK),
          ("/detail",       1800, "1,800 rps", COLOR_BAR),
          ("/upload",        420, "420 rps",   COLOR_BAR),
          ("/export-csv",     12, "12 rps",    COLOR_ACCENT),
      ]
      top0 = Inches(2.6)
      gap = Inches(0.85)
      for i, (label, val, val_lbl, color) in enumerate(bars):
          add_log_bar(s, label, val, val_lbl, top=top0 + gap * i, color=color, max_kbps=20000)
      
      
      # ============================================================
      # Slide 5 — Roadmap focus
      # ============================================================
      s = add_blank_slide(prs)
      add_title_bar(s, "Next Cycle Focus", "Two bets, one validation")
      
      add_table(s, Inches(0.4), Inches(1.8), Inches(12.5), Inches(4.0), [
          ["#", "Bet",                              "Why now",                      "Owner"],
          ["1", "CSV export GA",                    "Top support volume",           "Backend + FE"],
          ["2", "Onboarding redesign A/B",          "Activation flat 2 quarters",   "Growth team"],
          ["3", "Latency drift root cause",         "p95 trending 2x worse",        "SRE"],
      ], font_size=15, col_widths=[Inches(0.7), Inches(4.5), Inches(4.0), Inches(3.3)])
      
      add_textbox(s, Inches(0.6), Inches(6.3), Inches(12.1), Inches(0.6),
                  "Bets 1 and 2 ship; bet 3 is investigation only — no commitments until root cause known.",
                  size=14, color=COLOR_MUTED)
      
      
      # ============================================================
      # Slide 6 — Discussion / Q&A
      # ============================================================
      s = add_blank_slide(prs)
      add_textbox(s, Inches(0.8), Inches(2.6), Inches(11.7), Inches(1.4),
                  "Discussion",
                  size=64, bold=True, color=COLOR_TITLE, align=PP_ALIGN.CENTER)
      
      add_textbox(s, Inches(0.8), Inches(4.2), Inches(11.7), Inches(0.8),
                  "Q&A / commit owner sign-off",
                  size=24, color=COLOR_MUTED, align=PP_ALIGN.CENTER)
      
      
      # ============================================================
      save(prs, "quarterly_review.pptx")
      
    • build_quarterly_review_branded.py 6.4 KB
      #!/usr/bin/env python3
      """md2ppt — example branded build script (abstract content, abstract template).
      
      Reference for what a brand-mode build script looks like. Subject and template
      path are both placeholders to demonstrate the pattern without leaking any
      real-world brand asset.
      
      To run, set MD2PPT_BRAND_TEMPLATE env var to a real template path:
      
          MD2PPT_BRAND_TEMPLATE=/path/to/your-brand.pptx \\
              ~/.venv_pptx/bin/python build_quarterly_review_branded.py
      
      Output:
          quarterly_review_branded.pptx (in current dir)
      
      Differences from build_quarterly_review.py (content version):
      - Uses init_deck_from_template() instead of init_deck()
      - Cover slide uses template's title-slide layout (placeholder fill)
      - Q&A slide uses template's title-slide layout
      - All content slides still hand-coded with add_blank_slide for layout freedom
      - Theme (colors / fonts / logo / chrome) inherited from template
      """
      
      import os
      import sys
      from pathlib import Path
      
      # Adjust this path to wherever the skill helpers live (symlinked into ~/.claude/skills/)
      sys.path.insert(0, str(Path.home() / ".claude/skills/md2ppt/scripts"))
      
      from pptx_helpers import (
          init_deck_from_template, add_blank_slide,
          add_cover_from_template, add_section_divider_from_template,
          add_textbox, add_title_bar, add_bullets, add_table, add_log_bar, save,
          Inches, Pt, PP_ALIGN,
          COLOR_TITLE, COLOR_TEXT, COLOR_ACCENT, COLOR_OK, COLOR_BAR, COLOR_MUTED,
      )
      
      # ============================================================
      # Brand template path — env var with relative-path fallback for portability
      # ============================================================
      TEMPLATE_PATH = os.environ.get(
          "MD2PPT_BRAND_TEMPLATE",
          "<your-brand-template.pptx>"  # placeholder — replace per project
      )
      
      prs = init_deck_from_template(TEMPLATE_PATH)
      
      
      # ============================================================
      # Slide 1 — Cover (template's title-slide layout)
      # ============================================================
      add_cover_from_template(
          prs,
          layout_name="標題投影片",   # adjust per template's layout naming
          title="Q4 Product Metrics Review",
          subtitle="Adoption + Reliability + Roadmap Signals",
      )
      
      
      # ============================================================
      # Slide 2 — Headline (blank layout, hand-coded for content freedom)
      # ============================================================
      s = add_blank_slide(prs)
      add_title_bar(s, "Headline", "Three signals to act on this quarter")
      
      add_bullets(s, Inches(0.8), Inches(2.0), Inches(12.0), Inches(2.5), [
          "Active users grew 38% QoQ; activation rate flat — onboarding is the choke point",
          "p95 API latency drifted from 180ms to 420ms after the v3 deploy — not regressed yet",
          "Customer support tickets dominated by a single feature gap (export to CSV)",
      ], size=20)
      
      add_textbox(s, Inches(0.6), Inches(5.5), Inches(12.1), Inches(1.5),
                  "Recommendation: ship CSV export + invest one cycle in onboarding redesign\nbefore opening top-of-funnel further.",
                  size=18, bold=True, color=COLOR_ACCENT, line_spacing=1.4)
      
      
      # ============================================================
      # Slide 3 — Adoption table
      # ============================================================
      s = add_blank_slide(prs)
      add_title_bar(s, "Adoption Metrics", "QoQ comparison, signed-up vs activated")
      
      add_table(s, Inches(0.5), Inches(1.9), Inches(12.3), Inches(3.8), [
          ["Metric",            "Q3",       "Q4",       "Delta"],
          ["Sign-ups",          "12,400",   "17,100",   "+38%"],
          ["Activated (D7)",    "4,090",    "5,300",    "+30%"],
          ["Activation rate",   "33.0%",    "31.0%",    "-2 pp"],
          ["WAU peak",          "8,250",    "11,400",   "+38%"],
          ["DAU/MAU",           "27%",      "26%",      "-1 pp"],
      ], font_size=14, col_widths=[Inches(3.5), Inches(2.8), Inches(2.8), Inches(3.2)])
      
      
      # ============================================================
      # Slide 4 — Throughput bar comparison
      # ============================================================
      s = add_blank_slide(prs)
      add_title_bar(s, "API Throughput by Endpoint", "p50 RPS — log scale visualization")
      
      add_textbox(s, Inches(0.6), Inches(1.9), Inches(12.0), Inches(0.5),
                  "Higher = better. Search dominates traffic; export is rarely used.",
                  size=14, color=COLOR_MUTED)
      
      bars = [
          ("/search",       8500, "8,500 rps", COLOR_OK),
          ("/list",         3200, "3,200 rps", COLOR_OK),
          ("/detail",       1800, "1,800 rps", COLOR_BAR),
          ("/upload",        420, "420 rps",   COLOR_BAR),
          ("/export-csv",     12, "12 rps",    COLOR_ACCENT),
      ]
      top0 = Inches(2.6)
      gap = Inches(0.85)
      for i, (label, val, val_lbl, color) in enumerate(bars):
          add_log_bar(s, label, val, val_lbl, top=top0 + gap * i, color=color, max_kbps=20000)
      
      
      # ============================================================
      # Slide 5 — Section divider (template layout)
      # ============================================================
      add_section_divider_from_template(
          prs,
          layout_name="章節標題",
          title="Next Cycle Focus",
      )
      
      
      # ============================================================
      # Slide 6 — Roadmap focus
      # ============================================================
      s = add_blank_slide(prs)
      add_title_bar(s, "Next Cycle Focus", "Two bets, one validation")
      
      add_table(s, Inches(0.4), Inches(1.8), Inches(12.5), Inches(4.0), [
          ["#", "Bet",                              "Why now",                      "Owner"],
          ["1", "CSV export GA",                    "Top support volume",           "Backend + FE"],
          ["2", "Onboarding redesign A/B",          "Activation flat 2 quarters",   "Growth team"],
          ["3", "Latency drift root cause",         "p95 trending 2x worse",        "SRE"],
      ], font_size=15, col_widths=[Inches(0.7), Inches(4.5), Inches(4.0), Inches(3.3)])
      
      add_textbox(s, Inches(0.6), Inches(6.3), Inches(12.1), Inches(0.6),
                  "Bets 1 and 2 ship; bet 3 is investigation only — no commitments until root cause known.",
                  size=14, color=COLOR_MUTED)
      
      
      # ============================================================
      # Slide 7 — Q&A (template's title-slide variant)
      # ============================================================
      add_cover_from_template(
          prs,
          layout_name="3_標題投影片",   # adjust per template
          title="Discussion",
          subtitle="Q&A / commit owner sign-off",
      )
      
      
      # ============================================================
      save(prs, "quarterly_review_branded.pptx")
      
  • scripts
    • md_analyze.py 4 KB
      #!/usr/bin/env python3
      """md2ppt — pre-analyze input.md to give the user a summary before quiz.
      
      Usage:
          md_analyze.py <input.md>
      
      Prints structured summary the SKILL.md Step 1 expects.
      """
      import sys
      import re
      from pathlib import Path
      
      
      def analyze(md_text):
          lines = md_text.splitlines()
          h1 = None
          h2_titles = []
          h3_count = 0
          code_blocks = {"ascii": 0, "mermaid": 0, "plain": 0}
          table_count = 0
          largest_table_rows = 0
          largest_table_cols = 0
          bullet_list_count = 0
      
          in_code = False
          code_lang = None
          code_buf = []
          in_bullets = False
          in_table = False
          table_row_buf = 0
      
          for line in lines:
              if line.startswith("```"):
                  if in_code:
                      content = "\n".join(code_buf)
                      if code_lang == "mermaid":
                          code_blocks["mermaid"] += 1
                      elif _looks_like_ascii_art(content):
                          code_blocks["ascii"] += 1
                      else:
                          code_blocks["plain"] += 1
                      code_buf = []
                      in_code = False
                      code_lang = None
                  else:
                      in_code = True
                      code_lang = line[3:].strip()
                  continue
              if in_code:
                  code_buf.append(line)
                  continue
              if line.startswith("# ") and h1 is None:
                  h1 = line[2:].strip()
                  continue
              if line.startswith("## "):
                  h2_titles.append(line[3:].strip())
                  continue
              if line.startswith("### "):
                  h3_count += 1
                  continue
              if "|" in line and not in_table:
                  in_table = True
                  table_row_buf = 1
                  cols = len([c for c in line.split("|") if c.strip()])
                  largest_table_cols = max(largest_table_cols, cols)
                  continue
              if in_table:
                  if "|" in line:
                      if not re.match(r"^\s*\|[\s\-:|]+\|\s*$", line):
                          table_row_buf += 1
                  else:
                      table_count += 1
                      largest_table_rows = max(largest_table_rows, table_row_buf)
                      in_table = False
                      table_row_buf = 0
              if re.match(r"^\s*[-*]\s", line) or re.match(r"^\s*\d+\.\s", line):
                  if not in_bullets:
                      bullet_list_count += 1
                      in_bullets = True
              else:
                  if in_bullets and not line.strip().startswith(("  ", "\t")):
                      in_bullets = False
      
          if in_table:
              table_count += 1
              largest_table_rows = max(largest_table_rows, table_row_buf)
      
          return {
              "h1": h1,
              "h2_titles": h2_titles,
              "h3_count": h3_count,
              "tables": table_count,
              "largest_table": (largest_table_rows, largest_table_cols),
              "code_blocks": code_blocks,
              "bullet_lists": bullet_list_count,
              "estimated_slides": 1 + len(h2_titles) + 1,
          }
      
      
      def _looks_like_ascii_art(text):
          indicators = ["┌", "└", "┐", "┘", "├", "┤",
                        "│", "─", "▶", "►", "→", "──"]
          return any(ind in text for ind in indicators)
      
      
      def main():
          if len(sys.argv) != 2:
              print("Usage: md_analyze.py <input.md>", file=sys.stderr)
              sys.exit(1)
          md = Path(sys.argv[1]).read_text()
          a = analyze(md)
          print(f"H1 (cover):     {a['h1'] or 'MISSING'}")
          print(f"H2 sections:    {len(a['h2_titles'])}")
          print(f"H3 subsections: {a['h3_count']}")
          print(f"Tables:         {a['tables']} (largest: {a['largest_table'][0]} rows x {a['largest_table'][1]} cols)")
          cb = a["code_blocks"]
          print(f"Code blocks:    {sum(cb.values())} (ASCII art: {cb['ascii']}, mermaid: {cb['mermaid']}, plain: {cb['plain']})")
          print(f"Bullet lists:   {a['bullet_lists']}")
          print(f"Estimated slides: {a['estimated_slides']} (1 cover + {len(a['h2_titles'])} H2 + 1 Q&A)")
          print()
          print("H2 section list:")
          for i, t in enumerate(a["h2_titles"], 1):
              print(f"  {i:2}. {t}")
      
      
      if __name__ == "__main__":
          main()
      
    • pptx_helpers.py 15 KB
      """md2ppt — reusable pptx composition helpers.
      
      Used by build scripts produced via the md2ppt skill.
      Style preset: corporate_blue (default; depends on PingFang TC font installed).
      
      Import in your build script:
          import sys
          sys.path.insert(0, "/Users/<you>/.claude/skills/md2ppt/scripts")
          from pptx_helpers import *
      
      Then proceed with `prs = init_deck()` and `add_*` calls.
      """
      
      from pptx import Presentation
      from pptx.util import Inches, Pt, Emu
      from pptx.dml.color import RGBColor
      from pptx.enum.text import PP_ALIGN
      from pptx.enum.shapes import MSO_SHAPE
      
      
      # ============================================================
      # Style preset: corporate_blue
      # ============================================================
      FONT       = "PingFang TC"
      FONT_MONO  = "Menlo"
      COLOR_BG     = RGBColor(0xFF, 0xFF, 0xFF)
      COLOR_TITLE  = RGBColor(0x1F, 0x3A, 0x5F)
      COLOR_TEXT   = RGBColor(0x21, 0x21, 0x21)
      COLOR_ACCENT = RGBColor(0xC0, 0x39, 0x2B)
      COLOR_OK     = RGBColor(0x27, 0xAE, 0x60)
      COLOR_WARN   = RGBColor(0xE6, 0x7E, 0x22)
      COLOR_MUTED  = RGBColor(0x7F, 0x8C, 0x8D)
      COLOR_BAR    = RGBColor(0x34, 0x98, 0xDB)
      
      
      def init_deck(width_in=13.333, height_in=7.5):
          """Create a 16:9 Presentation. Default 13.333 x 7.5 inches."""
          prs = Presentation()
          prs.slide_width = Inches(width_in)
          prs.slide_height = Inches(height_in)
          return prs
      
      
      def init_deck_from_template(template_path):
          """Open a brand template, strip its existing slides, return the Presentation.
      
          Inherits theme + slide_master + slide_layouts. Strips all existing slides
          so the build script starts from a clean deck but with brand chrome.
      
          Args:
              template_path: path to brand template .pptx
      
          Returns:
              Presentation object ready to add fresh slides
      
          Raises:
              FileNotFoundError if template_path doesn't exist
          """
          from pathlib import Path
          p = Path(template_path).expanduser()
          if not p.exists():
              raise FileNotFoundError(f"Brand template not found: {p}")
          prs = Presentation(str(p))
      
          # Strip all existing slides — keep only theme/masters/layouts.
          # python-pptx doesn't expose a clean .delete_slide(), so we manipulate
          # the underlying XML directly + use Part.drop_rel(). See:
          # https://github.com/scanny/python-pptx/issues/67
          sldIdLst = prs.slides._sldIdLst
          sldIds = list(sldIdLst)
          for sldId in sldIds:
              rId = sldId.attrib['{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id']
              prs.part.drop_rel(rId)
              sldIdLst.remove(sldId)
          return prs
      
      
      def _find_layout(prs, layout_name_hint):
          """Find a slide layout by name (substring match, case-insensitive).
      
          Args:
              prs: Presentation
              layout_name_hint: substring to match against layout names
      
          Returns:
              SlideLayout matching the hint, or layout[6] (typically blank) as fallback
          """
          hint = layout_name_hint.lower()
          for layout in prs.slide_layouts:
              if hint in layout.name.lower():
                  return layout
          # fallback: try blank-like
          for layout in prs.slide_layouts:
              if "blank" in layout.name.lower() or "空白" in layout.name:
                  return layout
          return prs.slide_layouts[6] if len(prs.slide_layouts) > 6 else prs.slide_layouts[-1]
      
      
      def add_slide_with_template_layout(prs, layout_name_hint):
          """Add a slide using the named template layout (or fallback). Returns the slide.
      
          Used internally by add_cover_from_template etc. Caller fills placeholders
          via slide.placeholders[N].text = '...' or by adding additional shapes.
          """
          layout = _find_layout(prs, layout_name_hint)
          slide = prs.slides.add_slide(layout)
          slide._prs = prs  # convenience back-ref for downstream helpers
          return slide
      
      
      def add_cover_from_template(prs, *, layout_name="標題投影片", title=None, subtitle=None):
          """Cover slide using template's title-slide layout.
      
          Fills placeholder[0] with title and placeholder[1] (if present) with subtitle.
          Falls back to add_blank_slide + manual textbox if layout missing.
      
          Args:
              prs: Presentation
              layout_name: substring match for template layout (default "標題投影片")
              title: cover title (str, required for non-empty cover)
              subtitle: cover subtitle (str, optional)
      
          Returns:
              Slide
          """
          slide = add_slide_with_template_layout(prs, layout_name)
          placeholders = list(slide.placeholders)
          if title and len(placeholders) > 0:
              placeholders[0].text = title
          if subtitle and len(placeholders) > 1:
              placeholders[1].text = subtitle
          return slide
      
      
      def add_blank_from_template(prs, *, layout_name="空白", clear_placeholders=True):
          """Add a slide using a template layout for content composition.
      
          Despite the name, this isn't restricted to "blank" layouts — pass any
          layout_name. Common patterns:
          - layout_name="空白" → minimal layout (no chrome, no placeholders)
          - layout_name="標題投影片" → layout with brand chrome (logo / page number /
            background images) inherited from layout shapes; placeholders cleared
            by default so they don't show "click to add title" prompts
          - layout_name="標題及內容" → similar but with explicit title + content
            placeholder (constraining)
      
          Use in brand mode for content slides where you want template's theme
          inheritance but full hand-coded layout freedom.
      
          **CRITICAL**: Does NOT add a white background rectangle. The template's
          layout/master typically has its own background graphics, logo, page number,
          or footer chrome. Adding a white rect would hide them all. If you need
          a clean white section for content, place white textbox/rect inside the
          content area only — never full-slide cover.
      
          Args:
              prs: Presentation (loaded via init_deck_from_template)
              layout_name: substring match for layout (default "空白")
              clear_placeholders: if True, set all inherited placeholders to empty
                  string so they don't show default "click to add..." text. Default True.
      
          Returns:
              Slide
          """
          layout = _find_layout(prs, layout_name)
          slide = prs.slides.add_slide(layout)
          if clear_placeholders:
              for ph in slide.placeholders:
                  try:
                      ph.text = ""
                  except Exception:
                      pass
          slide._prs = prs
          return slide
      
      
      def add_section_divider_from_template(prs, *, layout_name="章節標題", title=None):
          """Section divider slide using template's section-header layout.
      
          Args:
              prs: Presentation
              layout_name: substring match for template layout (default "章節標題")
              title: section title
      
          Returns:
              Slide
          """
          slide = add_slide_with_template_layout(prs, layout_name)
          placeholders = list(slide.placeholders)
          if title and len(placeholders) > 0:
              placeholders[0].text = title
          return slide
      
      
      def list_template_layouts(template_path):
          """Inspect a template — print available layouts. Useful at design time.
      
          Args:
              template_path: path to .pptx
          """
          prs = Presentation(str(template_path))
          print(f"Template: {template_path}")
          print(f"Layouts ({len(prs.slide_layouts)}):")
          for i, layout in enumerate(prs.slide_layouts):
              print(f"  [{i}] {layout.name}")
      
      
      def add_blank_slide(prs):
          """Add a blank slide with white background. Returns the slide."""
          blank = prs.slide_layouts[6]
          slide = prs.slides.add_slide(blank)
          bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, prs.slide_width, prs.slide_height)
          bg.fill.solid()
          bg.fill.fore_color.rgb = COLOR_BG
          bg.line.fill.background()
          slide._prs = prs  # convenience back-ref so helpers can read slide_width/height
          return slide
      
      
      def add_textbox(slide, left, top, width, height, text, *,
                      font=FONT, size=18, bold=False, color=COLOR_TEXT,
                      align=PP_ALIGN.LEFT, line_spacing=1.15):
          """Plain text frame. `text` can be string (with \\n) or list of lines."""
          tb = slide.shapes.add_textbox(left, top, width, height)
          tf = tb.text_frame
          tf.word_wrap = True
          tf.margin_left = Inches(0.05)
          tf.margin_right = Inches(0.05)
          tf.margin_top = Inches(0.02)
          tf.margin_bottom = Inches(0.02)
          lines = text.split("\n") if isinstance(text, str) else text
          for i, line in enumerate(lines):
              p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
              p.alignment = align
              p.line_spacing = line_spacing
              run = p.add_run()
              run.text = line
              run.font.name = font
              run.font.size = Pt(size)
              run.font.bold = bold
              run.font.color.rgb = color
          return tb
      
      
      def add_title_bar(slide, title, subtitle=None):
          """Standard top title bar: 30pt bold title + optional 14pt muted subtitle + thin underline."""
          prs = slide._prs
          add_textbox(slide, Inches(0.6), Inches(0.35), Inches(12.1), Inches(0.7),
                      title, size=30, bold=True, color=COLOR_TITLE)
          if subtitle:
              add_textbox(slide, Inches(0.6), Inches(1.0), Inches(12.1), Inches(0.4),
                          subtitle, size=14, color=COLOR_MUTED)
          line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE,
                                         Inches(0.6), Inches(1.45),
                                         Inches(12.1), Emu(20000))
          line.fill.solid()
          line.fill.fore_color.rgb = COLOR_TITLE
          line.line.fill.background()
      
      
      def add_bullets(slide, left, top, width, height, items, *, size=16, bold=False, color=COLOR_TEXT):
          """Bullet list. items = list of (text, level) or just text strings (level=0)."""
          tb = slide.shapes.add_textbox(left, top, width, height)
          tf = tb.text_frame
          tf.word_wrap = True
          tf.margin_left = Inches(0.08)
          for i, item in enumerate(items):
              if isinstance(item, tuple):
                  text, lvl = item
              else:
                  text, lvl = item, 0
              p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
              p.alignment = PP_ALIGN.LEFT
              p.line_spacing = 1.25
              p.level = lvl
              bullet = "• " if lvl == 0 else "  – "
              run = p.add_run()
              run.text = bullet + text
              run.font.name = FONT
              run.font.size = Pt(size - lvl * 2)
              run.font.bold = bold
              run.font.color.rgb = color
          return tb
      
      
      def add_mono_block(slide, left, top, width, height, text, *,
                         size=12, color=COLOR_TEXT, highlight_rules=None):
          """Monospace text frame for ASCII art / code. `highlight_rules`: list of (substr, color) tuples."""
          tb = slide.shapes.add_textbox(left, top, width, height)
          tf = tb.text_frame
          tf.word_wrap = False
          tf.margin_left = Inches(0.1)
          tf.margin_right = Inches(0.1)
          tf.margin_top = Inches(0.05)
          tf.margin_bottom = Inches(0.05)
          lines = text.split("\n") if isinstance(text, str) else text
          for i, line in enumerate(lines):
              p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
              p.alignment = PP_ALIGN.LEFT
              p.line_spacing = 1.05
              run = p.add_run()
              run.text = line
              run.font.name = FONT_MONO
              run.font.size = Pt(size)
              c = color
              if highlight_rules:
                  for rule_match, rule_color in highlight_rules:
                      if rule_match in line:
                          c = rule_color
                          break
              run.font.color.rgb = c
          return tb
      
      
      def add_table(slide, left, top, width, height, data, *,
                    header_color=COLOR_TITLE, font_size=14, col_widths=None):
          """Table with zebra striping. data = list of rows (each row = list of cells).
      
          `col_widths`: optional list of Inches(N) — sets explicit per-column width.
          """
          rows = len(data)
          cols = len(data[0])
          tbl_shape = slide.shapes.add_table(rows, cols, left, top, width, height)
          tbl = tbl_shape.table
          for r, row in enumerate(data):
              for c, cell_text in enumerate(row):
                  cell = tbl.cell(r, c)
                  cell.text = ""
                  tf = cell.text_frame
                  tf.margin_left = Inches(0.08)
                  tf.margin_right = Inches(0.08)
                  tf.margin_top = Inches(0.04)
                  tf.margin_bottom = Inches(0.04)
                  tf.word_wrap = True
                  p = tf.paragraphs[0]
                  p.alignment = PP_ALIGN.LEFT
                  run = p.add_run()
                  run.text = str(cell_text)
                  run.font.name = FONT
                  run.font.size = Pt(font_size)
                  if r == 0:
                      run.font.bold = True
                      run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
                      cell.fill.solid()
                      cell.fill.fore_color.rgb = header_color
                  else:
                      run.font.color.rgb = COLOR_TEXT
                      cell.fill.solid()
                      cell.fill.fore_color.rgb = RGBColor(0xFF, 0xFF, 0xFF) if r % 2 == 1 else RGBColor(0xF4, 0xF6, 0xF8)
          if col_widths:
              for i, w in enumerate(col_widths):
                  tbl.columns[i].width = w
          return tbl
      
      
      def add_picture_fit(slide, png_path, *, top, max_width, max_height,
                          vertical_center_in=None):
          """Add a picture with auto-fit (preserve aspect ratio) + horizontal center.
      
          `vertical_center_in`: tuple (top_bound, bottom_bound). If given, picture is
          vertically centered within that band (overrides `top`).
          """
          from PIL import Image
          prs = slide._prs
          iw, ih = Image.open(str(png_path)).size
          aspect = iw / ih
          h = max_height
          w = int(h * aspect)
          if w > max_width:
              w = max_width
              h = int(w / aspect)
          left = int((prs.slide_width - w) / 2)
          if vertical_center_in is not None:
              tb, bb = vertical_center_in
              top = tb + (bb - tb - h) // 2
          return slide.shapes.add_picture(str(png_path), left, top, width=w, height=h)
      
      
      def add_log_bar(slide, label, value_kbps, value_label, *,
                      top, color=COLOR_BAR, label_size=12,
                      bar_left=None, bar_max_width=None, bar_height=None,
                      max_kbps=800000):
          """Log-scaled horizontal bar for throughput/quantity comparisons.
      
          Positions:
          - left label: right-aligned in left column
          - bar:        starts at bar_left
          - value label: just right of bar end
      
          Defaults assume 13.333" wide slide.
          """
          import math
          prs = slide._prs
          if bar_left is None:
              bar_left = Inches(3.8)
          if bar_max_width is None:
              bar_max_width = Inches(7.5)
          if bar_height is None:
              bar_height = Inches(0.55)
      
          def log_w(v):
              if v <= 0:
                  return 0.05
              return min(1.0, math.log10(max(1, v)) / math.log10(max_kbps))
      
          width = int(bar_max_width * log_w(value_kbps))
          bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, bar_left, top, width, bar_height)
          bar.fill.solid()
          bar.fill.fore_color.rgb = color
          bar.line.fill.background()
          add_textbox(slide, Inches(0.4), top, bar_left - Inches(0.5), bar_height,
                      label, size=label_size, color=COLOR_MUTED, align=PP_ALIGN.RIGHT)
          add_textbox(slide, bar_left + width + Inches(0.1), top, Inches(2.3), bar_height,
                      value_label, size=label_size, color=COLOR_TEXT, bold=True)
      
      
      def save(prs, path):
          """Save deck and print confirmation."""
          prs.save(path)
          print(f"OK → {path}")
          print(f"Slides: {len(prs.slides)}")
      
    • render_mermaid.sh 676 B
      #!/usr/bin/env bash
      # md2ppt — mermaid render wrapper with content-hash caching.
      #
      # Usage:
      #   render_mermaid.sh <input.mmd> [<output_dir>]
      #
      # Hashes the .mmd content; if PNG with that hash already exists, skip render.
      # Output PNG path printed to stdout.
      
      set -euo pipefail
      
      INPUT="$1"
      OUTDIR="${2:-$(dirname "$INPUT")/_assets}"
      mkdir -p "$OUTDIR"
      
      HASH=$(md5 -q "$INPUT" 2>/dev/null || md5sum "$INPUT" | awk '{print $1}')
      HASH_SHORT="${HASH:0:10}"
      PNG="$OUTDIR/diag_${HASH_SHORT}.png"
      
      if [ ! -f "$PNG" ]; then
          mmdc -i "$INPUT" -o "$PNG" -t default -b white -w 1920 -H 1080 >/dev/null 2>&1 \
              || { echo "mmdc failed for $INPUT" >&2; exit 1; }
      fi
      
      echo "$PNG"
      
  • SKILL.md 19.5 KB
    ---
    name: md2ppt
    description: "Use when the user wants to turn a Markdown report into a presentation-quality .pptx via interactive design decisions and a reusable hand-coded build script. Drives pre-analysis, global style choices, optional per-slide layout dialogue, python-pptx composition, and optional LibreOffice render self-check. NOT a generic auto-converter, NOT for PDF output, and NOT a fixed brand-template pipeline — brand integration is handled ad hoc through helper primitives."
    version: 0.4.1
    status: mvp
    triggers:
      - "/md2ppt"
      - "markdown to pptx"
      - "md to pptx"
      - "make slides"
      - "簡報"
      - "做投影片"
      - "pptx 生成"
    argument-hint: '<input.md> [<output.pptx>]'
    ---
    
    # md2ppt
    
    You are a senior presentation designer working interactively with the user to turn a Markdown report into a polished `.pptx`.
    
    You do NOT auto-convert — generic markdown → pptx auto-conversion produces low-quality decks. Instead, you:
    
    1. Pre-analyze the input markdown
    2. Run a **numbered-list quiz** to lock global design decisions
    3. Walk through each slide, proposing layout, asking user when ambiguous
    4. Compose a **hand-coded build script** using `scripts/pptx_helpers.py`
    5. Render → preview → iterate per-slide patches
    6. Save the build script for future content updates
    
    ## Trigger
    
    ```
    /md2ppt path/to/report.md
    /md2ppt path/to/report.md path/to/output.pptx
    ```
    
    If output path omitted, default to same dir as input with `.pptx` extension.
    
    For brand-template integration (套公版 / inheriting an existing .pptx's theme +
    chrome), see "Brand template (ad-hoc, optional)" near end. Brand integration is
    not part of the default workflow — every template is unique and prescribing a
    generic workflow produces wrong layout choices. Handle via direct LLM-user
    dialogue using the helper primitives.
    
    ## Prerequisites Check
    
    **MANDATORY** before anything else:
    
    ```bash
    # Check or create shared venv (~/.venv_pptx)
    test -d ~/.venv_pptx || python3 -m venv ~/.venv_pptx
    ~/.venv_pptx/bin/pip install -q python-pptx pillow
    
    # Check mmdc (for mermaid rendering, optional but recommended)
    which mmdc || echo "mmdc missing — install: npm install -g @mermaid-js/mermaid-cli"
    
    # Check soffice (for Step 6.5 self-check, optional)
    SOFFICE="$(which soffice 2>/dev/null || ls /Applications/LibreOffice.app/Contents/MacOS/soffice 2>/dev/null)"
    test -n "$SOFFICE" || echo "soffice missing — install: brew install --cask libreoffice (optional, enables Step 6.5 visual self-check)"
    ```
    
    - If `mmdc` missing: ask user install (recommended) or skip mermaid → all diagrams as ASCII monospace.
    - If `soffice` missing: skip Step 6.5 self-check silently; user does manual review only.
    
    ## Workflow
    
    ### Step 1: Read input.md + pre-analyze
    
    Use `scripts/md_analyze.py`:
    
    ```bash
    ~/.venv_pptx/bin/python ~/.claude/skills/md2ppt/scripts/md_analyze.py <input.md>
    ```
    
    Output:
    ```
    H1 (cover):    <title or "MISSING">
    H2 sections:   N
    H3 subsections: M
    Tables:        K (largest: R rows × C cols)
    Code blocks:   X (ASCII art: A, mermaid: B, plain code: C)
    Bullet lists:  Y
    Estimated slides: Z (1 cover + N H2 + 1 Q&A)
    ```
    
    Show summary to user. **DO NOT proceed without user seeing this.**
    
    ### Step 2: Global design quiz (numbered list)
    
    Ask the user the following — one question per turn, wait for answer before next:
    
    **Q1. Slide grouping.** Default: 1 H2 = 1 slide + cover + Q&A. Show estimated slide list. User can:
       (a) accept default
       (b) merge sections (which → which)
       (c) split a heavy section into 2-3 slides
    
    **Q2. Style preset.** Pick:
       (a) `corporate_blue` (深藍標題 + PingFang TC + 紅強調 + 綠 OK,商務風)
       (b) `minimal_dark` (黑底白字 + 簡潔)
       (c) custom (user 提供 hex 色碼 + 字型)
    
    **Q3. Cover + Q&A slides.** Yes / no / 只要 cover / 只要 Q&A
    
    **Q4. Diagram rendering strategy.** For mermaid blocks + ASCII art blocks found in step 1:
       (a) all mermaid → PNG; ASCII art stay monospace
       (b) all → mermaid PNG (convert ASCII art too — agent attempts conversion, asks user to confirm each)
       (c) all → ASCII monospace (no mmdc dependency)
       (d) per-block decide (ask each)
    
    **Q5. Per-slide layout granularity.**
       (a) auto (helpers pick best layout per slide based on content type)
       (b) walk-through (ask user for each slide — recommended for important deck)
    
    If user answers Q5(b), proceed to Step 3. If Q5(a), skip to Step 4.
    
    ### Step 3: Per-slide walk-through (if Q5 = b)
    
    For each slide group:
    - Show slide draft as text outline (title / subtitle / blocks summary)
    - Propose layout from this decision table:
    
    | Content type | Suggested layout |
    |---|---|
    | Single mermaid / image | Title + image fit + center align |
    | Single table (≤ 6 rows) | Title + table full width |
    | Single table (> 6 rows) | Split 2 slides OR shrink font + col widths |
    | Bullets only | Title + bullet textbox |
    | Bullets + small table | Two-column (bullets left + table right) |
    | ASCII art (flow / topology) | Title + monospace textbox + colored highlights |
    | Bar chart data | Title + python-pptx native bars (helper `add_log_bar`) |
    | Mixed (bullets + image + para) | Confirm layout with user — too ambiguous |
    
    User can override each. Lock final layout for this slide.
    
    ### Step 4: Compose build script
    
    Generate ONE Python build script that:
    
    1. Imports helpers from `~/.claude/skills/md2ppt/scripts/pptx_helpers.py`
    2. Imports style preset constants
    3. Renders any mermaid blocks via `scripts/render_mermaid.sh` to `_assets/` next to output
    4. **Hand-codes each slide** — one slide = one section of `# ============== Slide N ==============` block + helper calls
    
    **CRITICAL**: Do not write a generic loop over markdown blocks. Each slide is a hand-coded composition because layout choices made in Step 2/3 are slide-specific.
    
    Save script to a project-local build dir.
    
    **Path discovery (in order)**:
    
    1. If a previous md2ppt build dir already exists under input.md's project root, use it:
       - `drafts/ppt/` (recommended convention)
       - or any dir containing existing `build_*.py` produced by md2ppt
    2. Else if input.md is under a project root (detected by `.git`, `CLAUDE.md`, `pyproject.toml`, or similar marker), recommend creating `<project_root>/drafts/ppt/`
    3. Else save next to input.md (`./build_<basename>.py`)
    
    Always **confirm path with user** before writing. Show the resolved path and
    ask "save build script to `<path>/build_<basename>.py`? [Y/n / 改其他路徑]".
    
    **Path is project convention, not skill-prescribed.** Recommend `drafts/ppt/`
    (or whatever the project uses for deck artifacts). Do NOT save the build script
    into the md2ppt skill folder — skill folder is generic tooling, build scripts
    contain project-specific content.
    
    See `examples/build_quarterly_review.py` for reference structure.
    
    ### Step 5: Render
    
    ```bash
    ~/.venv_pptx/bin/python <build_script_path>
    ```
    
    Should print `OK → <output.pptx>` and slide count.
    
    ### Step 6.5: Self-check (optional, requires LibreOffice)
    
    **Skip silently if `soffice` not installed.** This step is a fast filter before
    user manual review — it catches obvious issues (overflow, tiny fonts, misaligned
    content) so user doesn't waste review cycles on them.
    
    #### Render preview PNGs
    
    ```bash
    SOFFICE="$(which soffice 2>/dev/null || echo /Applications/LibreOffice.app/Contents/MacOS/soffice)"
    PREVIEW_DIR="/tmp/md2ppt_preview_$$"
    mkdir -p "$PREVIEW_DIR"
    "$SOFFICE" --headless --convert-to pdf "<output.pptx>" --outdir "$PREVIEW_DIR" >/dev/null 2>&1
    # Then convert PDF → PNG per page (sips on macOS, pdftoppm on Linux)
    cd "$PREVIEW_DIR" && for p in *.pdf; do
        sips -s format png "$p" --out "${p%.pdf}.png" >/dev/null 2>&1 \
            || pdftoppm -png -r 100 "$p" "${p%.pdf}"
    done
    ls "$PREVIEW_DIR"/*.png
    ```
    
    (Alternative: `soffice --headless --convert-to png` directly, but PDF
    intermediate gives more reliable per-page splitting.)
    
    #### Read each PNG and check
    
    For each slide PNG, use `Read` tool. Check for these patterns:
    
    | Issue | Visual signal | Fix |
    |---|---|---|
    | Text overflow (off slide bounds) | Text cut off at edge / extends past visible area | Reduce font size OR split slide OR shorten text |
    | Tiny font (< 12pt rendered) | Text barely readable at typical projector zoom | Bump `size=` in helper call |
    | Emoji visible | ❌ ✅ 🔴 ⚠️ characters present | Grep build script + replace with text/color |
    | Table col widths wrong | One column squeezed, others huge whitespace | Set `col_widths=[Inches(N), ...]` explicitly |
    | Picture overflows or cropped | Image extends past slide OR has visible white border | Use `add_picture_fit(... max_height=)` or `vertical_center_in` |
    | Excessive bottom whitespace | More than 30% of slide is empty after content | `vertical_center_in` OR scale content up OR remove blank space |
    | Layout placeholder + hand-coded overlap | Two title-like elements visible (placeholder default text shows through) | Pick layout with no placeholders OR explicitly clear placeholders |
    | Template chrome hidden by white background | No logo / page number on slides that should have them | Remove any full-slide white rect; helpers should not add background fill |
    
    #### Fix loop
    
    For each finding:
    1. Identify slide # + helper call in build script
    2. Propose specific patch (with exact `Edit` old_string / new_string)
    3. Apply via `Edit`
    4. Re-render pptx
    5. Re-render preview PNG
    6. Re-check the affected slide
    
    **Maximum 3 self-check retries** per file. After 3 retries, stop auto-fix and
    hand off to user (Step 6).
    
    #### Report to user
    
    Before Step 6 manual review, report:
    - Total slides checked: N
    - Issues auto-fixed: X (list per slide)
    - Issues remaining after max retries: Y (list per slide, suggested manual action)
    - Self-check is **a filter, not authoritative** — user manual review still required.
    
    #### Cleanup
    
    ```bash
    rm -rf "$PREVIEW_DIR"
    ```
    
    ### Step 6: Preview + iterate
    
    Tell user the output path. Ask: open and review, report back per-slide issues.
    
    For each issue user reports:
    - Identify which slide # (use slide content to locate the helper call in build script)
    - Propose specific patch (font size up, picture fit center, table col widths, remove emoji, etc.)
    - Apply via `Edit` to the build script
    - Re-render
    
    **Maximum 5 iterations** before stopping and asking user for higher-level redesign.
    
    Common patches user requests:
    
    | User feedback | Patch |
    |---|---|
    | 「字體太小」 | bump `size=` in add_textbox / add_bullets / add_table from 12-14 → 14-16 |
    | 「emoji 拔掉」 | grep build script for `❌ ✅ ⚠️ 🔴` etc, replace with text |
    | 「圖太大跑版」 | switch to `add_picture_fit(... vertical_center_in=(top, bottom))` |
    | 「表格欄寬不對」 | set explicit `tbl.columns[i].width = Inches(N)` after table creation |
    | 「下面留白太空」 | use `vertical_center_in` OR add filler textbox OR scale image up |
    | 「拼字錯誤」 | direct edit to that string in build script |
    
    ### Step 7: Persist + (optional) lock to deliverables
    
    Build script stays in `drafts/ppt/` (or wherever user invoked from).
    
    Ask user: ready to lock into `deliverables/`?
    - Yes → mv .md and .pptx to `deliverables/YYYY-MM-DD_<topic>.{md,pptx}` (per project naming convention)
    - No → leave in drafts
    
    Build script always stays in `drafts/ppt/` — regenerable via `python build_<topic>.py` after content edits.
    
    ## Style Presets
    
    Available in `scripts/pptx_helpers.py` constants:
    
    ### corporate_blue (default)
    
    ```python
    FONT       = "PingFang TC"
    FONT_MONO  = "Menlo"
    COLOR_TITLE  = RGBColor(0x1F, 0x3A, 0x5F)   # 深藍
    COLOR_TEXT   = RGBColor(0x21, 0x21, 0x21)
    COLOR_ACCENT = RGBColor(0xC0, 0x39, 0x2B)   # 紅
    COLOR_OK     = RGBColor(0x27, 0xAE, 0x60)
    COLOR_WARN   = RGBColor(0xE6, 0x7E, 0x22)
    COLOR_MUTED  = RGBColor(0x7F, 0x8C, 0x8D)
    COLOR_BAR    = RGBColor(0x34, 0x98, 0xDB)
    ```
    
    Slide size: 16:9 (`13.333 × 7.5 inches`).
    
    ### minimal_dark
    
    (Future) — black background, single accent color, larger font.
    
    ## Decision Frameworks
    
    ### When to use mermaid PNG vs ASCII monospace
    
    | Diagram | Choose |
    |---|---|
    | Sequence diagram | mermaid PNG (rendering > ASCII) |
    | Linear flowchart (≤ 5 nodes) | ASCII OK (compact + readable in monospace) |
    | Linear flowchart (> 5 nodes, LR) | mermaid PNG |
    | Hierarchical / nested boxes | mermaid PNG |
    | State machine | mermaid PNG |
    | Directory tree | ASCII (tree structure native to monospace) |
    | Single arrow chain `A → B → C` | ASCII inline (no need for diagram) |
    
    ### When to split a slide
    
    A slide is too packed if any:
    - > 12 bullets at top level
    - Table > 8 rows OR > 5 columns at default font
    - Image height > 5.5 inches AND has supporting text
    - > 3 distinct content blocks (image + table + para + bullets)
    
    Split strategy:
    - For tables: split rows by category (e.g. "受影響" / "不受影響" → 2 slides)
    - For long bullets: group into 2-3 themed slides
    - For image + supporting text: 1 slide image-only + 1 slide text-only
    
    ## Anti-patterns
    
    - ❌ Generic markdown parser that loops over blocks — produces ugly, unbalanced slides. Each slide deserves hand-coded composition.
    - ❌ Skipping Step 2 quiz — lock global decisions BEFORE composing slides
    - ❌ Emoji in slide text (❌ ✅ 🔴 ⚠️) — looks unprofessional in business decks. Use text labels ("受影響" / "不受影響") or color (red / green) instead.
    - ❌ ASCII art > 20 lines — too small to read in projector. Convert to mermaid PNG or split.
    - ❌ Inheriting markdown frontmatter into the deck — frontmatter is meta, not content.
    - ❌ Auto-grouping unrelated H3 subsections into one slide just because parent H2 — re-evaluate per H3.
    - ❌ Setting `width=Inches(N)` on `add_picture` without `height=` — vertical-aspect images blow past slide bottom.
    - ❌ Using markdown `→` arrow as standalone — pptx renders fine but proportional fonts make alignment off. Use full-width `→` only in monospace context.
    - ❌ Forgetting `tbl.columns[i].width = Inches(N)` after `add_table` — default equal-width often wrong (e.g. `#` column should be narrow).
    
    ### Self-check anti-patterns
    
    - ❌ Treating Step 6.5 as final approval. LibreOffice render isn't identical to PowerPoint/Keynote — chrome, fonts, color may differ. User manual review (Step 6) is always the last gate.
    - ❌ Letting self-check retry > 3 times. Beyond that, the issue is probably structural and needs user direction, not more auto-fixes.
    - ❌ Forgetting to cleanup `$PREVIEW_DIR` — /tmp fills up over many runs.
    
    
    ## Important Rules
    
    1. **Hand-code per-slide.** No generic auto-conversion. The build script is a deliberate composition.
    2. **Always quiz user in Step 2 before composing.** No silent default choices.
    3. **`add_picture_fit` over raw `add_picture`.** Always pass `max_width` and `max_height` to prevent overflow.
    4. **`vertical_center_in` for slides with one centered figure.** Avoids bottom whitespace.
    5. **Set table `columns[i].width` explicitly.** Default equal-width tables look bad with mixed col content.
    6. **Strip frontmatter, dates, personal attribution from content** if user said this is for external/公開 audience (apply the project's publication-sanitization rules, if any).
    7. **Build script lives in the invoking project's drafts area** (e.g. `drafts/ppt/`), NEVER in the md2ppt skill folder. Deliverables dir is for the rendered .pptx artifact only — build script stays in drafts.
    8. **Re-render is fast.** Iterate freely with user — don't over-think the first pass.
    9. **Mermaid `LR` over `TD`** for multi-step flows — slide 16:9 favors horizontal.
    10. **Stop iterating after 5 rounds.** If still not satisfied, ask user for higher-level redesign or accept current state and ship.
    
    ### Self-check (Step 6.5) additional rules
    
    11. **Self-check is a filter, not authoritative.** Final visual approval always rests with user manual review (Step 6). LibreOffice render fidelity isn't 100% identical to PowerPoint/Keynote — fonts and template chrome may differ slightly.
    12. **Skip silently if soffice missing.** Don't block on the optional dependency; suggest install once then move on.
    13. **Max 3 self-check retries.** Beyond that, hand off remaining issues to user with suggested actions.
    14. **Cleanup preview PNGs after Step 6.5.** Don't pollute /tmp; remove `$PREVIEW_DIR` before user review starts.
    
    ## Output schema
    
    ```
    <output_dir>/
    ├── <input>.pptx              ← rendered deck
    ├── _assets/                  ← mermaid PNGs (if any)
    │   ├── diag_<hash>.mmd
    │   └── diag_<hash>.png
    └── build_<basename>.py       ← reusable build script
    ```
    
    Reported to user:
    - pptx file path
    - slide count
    - mermaid PNGs generated count (if any)
    - build script path
    
    ## Brand template (ad-hoc, optional)
    
    If user wants the deck to inherit a brand template's theme / chrome / layout
    (e.g. company-issued .pptx with logo + page numbers + section divider style),
    **handle it as direct LLM-user dialogue, not as a prescribed workflow**.
    
    Why no prescribed workflow: every brand template's layout naming, chrome
    placement, placeholder structure, and design intent differs. Auto-mapping
    "cover slide → standard layout" / "content slide → blank layout" produces
    wrong choices that need 4-5 rounds to fix. LLM + user inspecting the template
    together is faster and more correct.
    
    Helper primitives available in `scripts/pptx_helpers.py`:
    
    - `init_deck_from_template(path)` — open template, strip its existing slides,
      return a Presentation that inherits theme + masters + layouts
    - `list_template_layouts(path)` — print all layouts (useful for inspection
      before writing build script)
    - `add_blank_from_template(prs, layout_name="空白")` — add slide using a
      specific template layout. Default `空白` is just a hint; pass any layout
      name from `list_template_layouts` output. Clears placeholder default text.
    - `add_cover_from_template(prs, layout_name=..., title=..., subtitle=...)` —
      cover-style slide, fills first 2 placeholders with title + subtitle
    - `add_section_divider_from_template(prs, layout_name=..., title=...)` —
      section divider, fills first placeholder with title
    
    Recommended ad-hoc dialogue:
    
    1. User asks to apply brand template
    2. LLM runs `list_template_layouts(<path>)` to inspect, shows output to user
    3. User identifies which layout matches their cover / section divider / content / Q&A
    4. LLM writes a fresh build script using `init_deck_from_template` + the chosen
       layout names per slide type
    5. Iterate per-slide as needed (chrome overlap with hand-coded textboxes is the
       most common issue)
    
    `examples/build_quarterly_review_branded.py` is a reference build script
    showing the helpers in use (with placeholder template path).
    
    **Always**:
    - Pass template path via `os.environ.get('MD2PPT_BRAND_TEMPLATE', '<relative-path>')`
      or CLI arg — never hardcoded
    - Skip the helper white-rect-background trap: the helpers do NOT add a white
      rectangle, so template chrome shows through
    - Beware placeholder default text ("按一下以新增標題") — clear with
      `add_blank_from_template` (default behavior) or skip layouts that have
      placeholders for content slides (typically `空白` or similar layouts have 0
      placeholders)
    
    ## References
    
    - `scripts/pptx_helpers.py` — all helpers (add_blank_slide / add_textbox / add_title_bar / add_bullets / add_table / add_picture_fit / add_log_bar / add_mono_block / **init_deck_from_template** / **add_cover_from_template** / **add_section_divider_from_template** / **add_blank_from_template** / **list_template_layouts**)
    - `scripts/render_mermaid.sh` — mmdc wrapper with caching
    - `scripts/md_analyze.py` — pre-analyze input.md
    - `examples/build_quarterly_review.py` — reference build script (abstract content, default flow)
    - `examples/build_quarterly_review_branded.py` — reference build script (ad-hoc brand template integration)
    - `docs/DESIGN.md` — design rationale + history
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related