PowerPoint Automation AI-powered PPTX generation using Orchestrator-Workers pattern. When to Use - Web 記事やブログをスライド化したいとき - 既存 PPTX を翻訳・再構成したいとき - 開いている PPTX を COM Automation で直接編集したいとき - テンプレートベースで PPTX を生成したいとき - content.json を SSOT にして、抽出・翻訳・生成・レビューを分離したいとき Quick Start From Web Article From Existing PPTX Edit Open PPTX with COM Workflow | Phase | Main Actor | Purpose | | ------- | ------------------------- | --------------------------------------------------------- | | EXTRACT | | Source - content.json (+ for images) | | BUILD | | content.json - PPTX | | REVIEW | PPTX Reviewer | Overflow /…

, ''\n curl -s -o \"images/${base}/$('{0:D2}' -f $i)_image$ext\" $imgUrl\n $i++\n}\n```\n\n### Code Block Extraction\n\nCode blocks in articles go in content.json `code` field:\n\n```json\n{\n \"type\": \"content\",\n \"title\": \"Example\",\n \"items\": [\"Point 1\", \"Point 2\"],\n \"code\": \"\u003cbutton hx-get=\\\"/api/data\\\">Fetch\u003c/button>\"\n}\n```\n\n**Support Status:**\n\n| Method | Code Block Support | Notes |\n| ---------------------------- | ------------------ | ----------------------- |\n| `create_from_template.py` | ✅ Supported | Dark bg + Consolas font |\n| `create_pptx.js` (pptxgenjs) | ✅ Supported | Native support |\n| `create_ja_pptx.py` | ❌ Not supported | `code` field is ignored |\n\n---\n\n## Template New Generation Flow\n\n```powershell\n# Dynamically get first template\n$template = (Get-ChildItem -Path \"assets\" -Filter \"*.pptx\" | Select-Object -First 1).BaseName\n$base = \"20251212_example_blog\"\n\n# 1. Analyze if layouts.json doesn't exist\nif (-not (Test-Path \"output_manifest/${template}_layouts.json\")) {\n python scripts/analyze_template.py \"assets/${template}.pptx\"\n}\n\n# 2. Get images (for web sources)\nNew-Item -ItemType Directory -Path \"images/${base}\" -Force\ncurl -s -o \"images/${base}/01_diagram.png\" \"{extracted_image_url}\"\n\n# 3. Create content.json (include image paths)\n\n# 4. Generate PPTX\npython scripts/create_from_template.py \"assets/${template}.pptx\" \"output_manifest/${base}_content.json\" \"output_ppt/${base}.pptx\"\n```\n\n---\n\n## Web Source PPTX Generation Flow (All Methods)\n\n```\n1. Fetch article (API or fetch)\n ↓\n2. ★ Extract image URLs & download (images/{base}/)\n ↓\n3. Calculate slides: base count + image count\n ↓\n4. Create content.json (place images with type: \"photo\")\n ↓\n5. Generate PPTX\n```\n\n**Compliance Required**: Do not skip images and add later.\n\n---\n\n## Diagram + Template Workflow ★ Recommended\n\n**To use template design (colors, fonts) while creating diagrams with shapes:**\n\n```powershell\n$base = \"20251216_azure_diagram\"\n$template = \"assets/template.pptx\"\n\n# 1. Generate diagram with pptxgenjs (shapes, arrows)\nnode scripts/create_pptx.js \"output_manifest/${base}_content.json\" \"output_ppt/${base}_diagram.pptx\"\n\n# 2. Merge into template (inherit master)\npython scripts/merge_slides.py $template \"output_ppt/${base}_diagram.pptx\" \"output_ppt/${base}.pptx\" --clear-template\n\n# 3. Verify in PowerPoint\nStart-Process \"output_ppt/${base}.pptx\"\n```\n\n**merge_slides.py Options:**\n\n| Option | Effect |\n| ---------------------- | ---------------------------------------------- |\n| `--clear-template` | Delete template slides (inherit master only) ★ |\n| `--position 0` | Insert at beginning |\n| `--position -1` | Append at end (default) |\n| `--keep-source-master` | Keep source colors |\n\n### ⚠️ pptxgenjs Size Note (★ Important)\n\npptxgenjs `LAYOUT_16x9` is **10\" × 5.625\"**, different from template (usually 13.33\" × 7.5\").\n\n**Recommended Steps:**\n\n1. Get template size\n2. Define same size with `defineLayout()`\n3. Calculate all coordinates using SLIDE_WIDTH/SLIDE_HEIGHT variables\n\n```powershell\n# Get template size\n$templateSize = python -c \"from pptx import Presentation; p=Presentation('$template'); print(f'{p.slide_width.inches},{p.slide_height.inches}')\"\n$sizes = $templateSize -split ','\n$slideWidth = [double]$sizes[0]\n$slideHeight = [double]$sizes[1]\n```\n\n---\n\n## PREPARE_TEMPLATE Phase (★ Required)\n\nWhen using external templates (especially English PPTX), **always** execute these steps. Skipping causes layout issues and background duplication.\n\n| Issue | Symptom | Solution |\n| ------------------------- | ----------------------------------- | --------------------------------- |\n| Master background images | Mountain scenery overlays on slides | Delete PICTURE shapes |\n| Picture Placeholder blip | \"Image cannot be displayed\" | Delete blip reference or PH |\n| Missing embedded fonts | Font substitution warning | Specify fallback fonts |\n| Broken external links | Link error | Delete external references |\n| viewProps.xml inheritance | Opens in master view | Auto-normalization (at BUILD) |\n| content+image overlap | Image covers text | Add content_with_image to layouts |\n\n**Diagnosis & Cleaning Steps:**\n\n```powershell\n$base = \"20251214_example\"\n$input = \"input/template.pptx\"\n\n# 1. Diagnose template\npython scripts/diagnose_template.py $input\n\n# 2. Clean if issues found\npython scripts/clean_template.py $input \"output_manifest/${base}_clean_template.pptx\"\n$template = \"output_manifest/${base}_clean_template.pptx\"\n\n# 3. Analyze layout\npython scripts/analyze_template.py $template\n\n# 4. Add content_with_image mapping to layouts.json\n# 📖 See template-advanced.instructions.md for details (SSOT)\n```\n\n**Recommended layouts.json Mapping:**\n\n```json\n{\n \"layout_mapping\": {\n \"content_with_image\": 6\n }\n}\n```\n\n> 💡 Without `content_with_image`, `type: \"content\"` + `image` slides will have image overlapping text\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10050,"content_sha256":"664311838e0315ee5758f6e74281e74b37310705d46a87fbf3dca4456b750835"},{"filename":"references/schemas/classification.schema.json","content":"{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": \"https://github.com/Ag-ppt-create/classification.schema.json\",\n \"version\": \"1.0.0\",\n \"title\": \"Input Classification Schema\",\n \"description\": \"Output schema for classify_input.py - defines input classification passed to Orchestrator\",\n \"type\": \"object\",\n \"required\": [\"input_type\", \"base_name\", \"recommended_method\"],\n \"properties\": {\n \"input_type\": {\n \"type\": \"string\",\n \"enum\": [\n \"pptx_en\",\n \"pptx_ja\",\n \"pptx_mixed\",\n \"url\",\n \"markdown\",\n \"json\",\n \"text\",\n \"image\",\n \"unknown\"\n ],\n \"description\": \"Detected input type\"\n },\n \"source\": {\n \"type\": \"string\",\n \"description\": \"URL source (for url input_type)\"\n },\n \"source_file\": {\n \"type\": \"string\",\n \"description\": \"File path (for file input types)\"\n },\n \"platform\": {\n \"type\": \"string\",\n \"enum\": [\"qiita\", \"zenn\", \"github\", \"microsoft\", \"web\"],\n \"description\": \"Platform name for URL sources\"\n },\n \"language\": {\n \"type\": \"string\",\n \"enum\": [\"en\", \"ja\", \"mixed\", \"unknown\"],\n \"description\": \"Detected language for PPTX files\"\n },\n \"language_confidence\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 1,\n \"description\": \"Confidence score for language detection\"\n },\n \"slide_count\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"Number of slides in PPTX\"\n },\n \"recommended_method\": {\n \"type\": \"string\",\n \"enum\": [\"reconstruct\", \"analyze_only\", \"create_new\", \"manual\"],\n \"description\": \"Recommended processing method\"\n },\n \"output_format\": {\n \"type\": \"string\",\n \"enum\": [\"template\", \"python-pptx\", \"pptxgenjs\", \"unknown\"],\n \"description\": \"Recommended output generation format\"\n },\n \"reasoning\": {\n \"type\": \"string\",\n \"description\": \"Explanation for method recommendation\"\n },\n \"workflow\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"Ordered list of scripts/steps to execute\"\n },\n \"base_name\": {\n \"type\": \"string\",\n \"pattern\": \"^[0-9]{8}_[a-z0-9_]+_[a-z]+$\",\n \"description\": \"Generated base name following {YYYYMMDD}_{keyword}_{purpose} convention\"\n },\n \"user_confirmation_required\": {\n \"type\": \"boolean\",\n \"default\": true,\n \"description\": \"Whether user confirmation is required before processing\"\n },\n \"confirmation_prompt\": {\n \"type\": \"string\",\n \"description\": \"Message to display to user for confirmation\"\n },\n \"confidence\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 1,\n \"description\": \"Overall confidence score for classification\"\n },\n \"error\": {\n \"type\": \"string\",\n \"description\": \"Error message if classification failed\"\n }\n },\n \"allOf\": [\n {\n \"if\": {\n \"properties\": { \"input_type\": { \"const\": \"url\" } }\n },\n \"then\": {\n \"required\": [\"source\", \"platform\"]\n }\n },\n {\n \"if\": {\n \"properties\": { \"input_type\": { \"pattern\": \"^pptx_\" } }\n },\n \"then\": {\n \"required\": [\"source_file\", \"language\", \"slide_count\"]\n }\n }\n ],\n \"examples\": [\n {\n \"input_type\": \"pptx_en\",\n \"source_file\": \"input/BRK252_presentation.pptx\",\n \"language\": \"en\",\n \"language_confidence\": 0.85,\n \"slide_count\": 45,\n \"recommended_method\": \"reconstruct\",\n \"output_format\": \"template\",\n \"reasoning\": \"English PPTX detected - use reconstruct method to preserve slide master\",\n \"workflow\": [\n \"analyze_template.py\",\n \"extract_images.py\",\n \"reconstruct_analyzer.py\",\n \"translate_content.py\",\n \"create_from_template.py\"\n ],\n \"base_name\": \"20251214_brk252_report\",\n \"user_confirmation_required\": true,\n \"confidence\": 0.85\n },\n {\n \"input_type\": \"url\",\n \"source\": \"https://qiita.com/example/items/abc123\",\n \"platform\": \"qiita\",\n \"recommended_method\": \"create_new\",\n \"output_format\": \"template\",\n \"reasoning\": \"Web content from qiita - create new PPTX from content\",\n \"workflow\": [\n \"fetch_article (curl/API)\",\n \"extract_images (download)\",\n \"create content.json\",\n \"create_from_template.py\"\n ],\n \"base_name\": \"20251214_example_blog\",\n \"user_confirmation_required\": true,\n \"confidence\": 0.8\n }\n ]\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":4498,"content_sha256":"e2475f4f4d392d2c80d86cfd75a4f2be31c0b7b99034be849fff7462ac68d44a"},{"filename":"references/schemas/content.schema.json","content":"{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": \"https://github.com/aktsmm/Agent-Skills/tree/main/powerpoint-automation/references/schemas/content.schema.json\",\n \"version\": \"1.0.0\",\n \"title\": \"PPTX Content IR Schema\",\n \"description\": \"Intermediate Representation (IR) schema for PPTX generation workflow\",\n \"type\": \"object\",\n \"required\": [\"slides\"],\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"description\": \"Presentation title\"\n },\n \"subtitle\": {\n \"type\": \"string\",\n \"description\": \"Presentation subtitle\"\n },\n \"schema_version\": {\n \"type\": \"string\",\n \"description\": \"Schema version for compatibility check\",\n \"default\": \"1.0.0\"\n },\n \"slides\": {\n \"type\": \"array\",\n \"description\": \"Array of slide definitions\",\n \"minItems\": 1,\n \"items\": {\n \"$ref\": \"#/definitions/slide\"\n }\n }\n },\n \"definitions\": {\n \"slide\": {\n \"type\": \"object\",\n \"required\": [\"type\"],\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"enum\": [\n \"title\",\n \"section\",\n \"agenda\",\n \"content\",\n \"two_column\",\n \"photo\",\n \"summary\",\n \"closing\",\n \"code\",\n \"blank\"\n ],\n \"description\": \"Slide layout type\"\n },\n \"title\": {\n \"type\": \"string\",\n \"description\": \"Slide title text\"\n },\n \"subtitle\": {\n \"type\": \"string\",\n \"description\": \"Slide subtitle text\"\n },\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"description\": \"Bullet point items\"\n },\n \"left_items\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"Left column items for two_column type\"\n },\n \"left_title\": {\n \"type\": \"string\",\n \"description\": \"Left column title for two_column type\"\n },\n \"right_items\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"Right column items for two_column type\"\n },\n \"right_title\": {\n \"type\": \"string\",\n \"description\": \"Right column title for two_column type\"\n },\n \"notes\": {\n \"type\": \"string\",\n \"description\": \"Speaker notes\"\n },\n \"image\": {\n \"$ref\": \"#/definitions/image\"\n },\n \"code\": {\n \"type\": \"string\",\n \"description\": \"Code block content for code type slides\"\n },\n \"language\": {\n \"type\": \"string\",\n \"description\": \"Programming language for code highlighting\"\n },\n \"_source\": {\n \"$ref\": \"#/definitions/source\"\n },\n \"_skip\": {\n \"type\": \"boolean\",\n \"description\": \"Flag to skip this slide during processing\"\n },\n \"_reason\": {\n \"type\": \"string\",\n \"description\": \"Reason for skipping the slide\"\n }\n },\n \"allOf\": [\n {\n \"if\": {\n \"properties\": { \"type\": { \"const\": \"title\" } }\n },\n \"then\": {\n \"required\": [\"title\"]\n }\n },\n {\n \"if\": {\n \"properties\": { \"type\": { \"const\": \"section\" } }\n },\n \"then\": {\n \"required\": [\"title\"]\n }\n },\n {\n \"if\": {\n \"properties\": { \"type\": { \"const\": \"content\" } }\n },\n \"then\": {\n \"anyOf\": [{ \"required\": [\"items\"] }, { \"required\": [\"image\"] }]\n }\n },\n {\n \"if\": {\n \"properties\": { \"type\": { \"const\": \"agenda\" } }\n },\n \"then\": {\n \"required\": [\"items\"]\n }\n },\n {\n \"if\": {\n \"properties\": { \"type\": { \"const\": \"summary\" } }\n },\n \"then\": {\n \"required\": [\"items\"]\n }\n },\n {\n \"if\": {\n \"properties\": { \"type\": { \"const\": \"two_column\" } }\n },\n \"then\": {\n \"anyOf\": [\n { \"required\": [\"items\"] },\n {\n \"required\": [\"left_items\", \"right_items\"]\n }\n ]\n }\n },\n {\n \"if\": {\n \"properties\": { \"type\": { \"const\": \"photo\" } }\n },\n \"then\": {\n \"required\": [\"image\"]\n }\n },\n {\n \"if\": {\n \"properties\": { \"type\": { \"const\": \"code\" } }\n },\n \"then\": {\n \"required\": [\"code\"]\n }\n }\n ]\n },\n \"image\": {\n \"type\": \"object\",\n \"description\": \"Image configuration for the slide\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\",\n \"description\": \"Local file path to the image\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\",\n \"description\": \"Remote URL to fetch the image from\"\n },\n \"position\": {\n \"type\": \"string\",\n \"enum\": [\"right\", \"left\", \"bottom\", \"center\", \"full\"],\n \"default\": \"right\",\n \"description\": \"Image position on the slide\"\n },\n \"width_percent\": {\n \"type\": \"number\",\n \"minimum\": 10,\n \"maximum\": 100,\n \"description\": \"Image width as percentage of slide width\"\n },\n \"height_percent\": {\n \"type\": \"number\",\n \"minimum\": 10,\n \"maximum\": 100,\n \"description\": \"Image height as percentage of slide height\"\n },\n \"alt\": {\n \"type\": \"string\",\n \"description\": \"Alternative text for the image\"\n }\n },\n \"oneOf\": [{ \"required\": [\"path\"] }, { \"required\": [\"url\"] }]\n },\n \"source\": {\n \"type\": \"object\",\n \"description\": \"Source tracking information from original PPTX\",\n \"properties\": {\n \"original_index\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"Original slide index (0-based)\"\n },\n \"original_layout\": {\n \"type\": \"string\",\n \"description\": \"Original slide layout name\"\n },\n \"has_chart\": {\n \"type\": \"boolean\",\n \"description\": \"Whether the original slide had a chart\"\n },\n \"has_table\": {\n \"type\": \"boolean\",\n \"description\": \"Whether the original slide had a table\"\n },\n \"has_diagram\": {\n \"type\": \"boolean\",\n \"description\": \"Whether the original slide had a diagram\"\n }\n }\n }\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":6722,"content_sha256":"367fdc66c144256e8d30d235b89721a3b0900fd851bbfc0e2577ff528e35efca"},{"filename":"references/schemas/proposal.schema.json","content":"{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"Proposal Schema\",\n \"description\": \"Brainstormer Agent が生成するスライド提案書のスキーマ\",\n \"type\": \"object\",\n \"required\": [\"metadata\", \"outline\"],\n \"properties\": {\n \"metadata\": {\n \"type\": \"object\",\n \"required\": [\"title\", \"duration_minutes\", \"audience\", \"goal\"],\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"description\": \"スライドのタイトル\"\n },\n \"presenter\": {\n \"type\": \"string\",\n \"description\": \"発表者名(オプション)\"\n },\n \"date\": {\n \"type\": \"string\",\n \"format\": \"date\",\n \"description\": \"発表日(YYYY-MM-DD)\"\n },\n \"duration_minutes\": {\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 120,\n \"description\": \"発表時間(分)\"\n },\n \"audience\": {\n \"type\": \"object\",\n \"required\": [\"role\"],\n \"properties\": {\n \"role\": {\n \"type\": \"string\",\n \"description\": \"聴衆の役職・職種\"\n },\n \"tech_level\": {\n \"type\": \"string\",\n \"enum\": [\"初級\", \"中級\", \"上級\", \"混合\"],\n \"description\": \"技術レベル\"\n },\n \"size\": {\n \"type\": \"string\",\n \"description\": \"聴衆の人数(目安)\"\n }\n }\n },\n \"goal\": {\n \"type\": \"string\",\n \"description\": \"プレゼンのゴール(何を達成したいか)\"\n },\n \"cta\": {\n \"type\": \"string\",\n \"description\": \"Call to Action(聴衆に取ってほしいアクション)\"\n },\n \"tone\": {\n \"type\": \"string\",\n \"enum\": [\"カジュアル\", \"フォーマル\", \"情熱的\", \"落ち着いた\"],\n \"description\": \"プレゼンのトーン\"\n },\n \"source_url\": {\n \"type\": \"string\",\n \"format\": \"uri\",\n \"description\": \"元記事・参考URLがある場合\"\n },\n \"source_file\": {\n \"type\": \"string\",\n \"description\": \"入力ファイルパス(Markdown/PPTX等)\"\n }\n }\n },\n \"outline\": {\n \"type\": \"array\",\n \"minItems\": 3,\n \"description\": \"スライドのアウトライン(セクション単位)\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"section\", \"title\", \"type\"],\n \"properties\": {\n \"section\": {\n \"type\": \"integer\",\n \"minimum\": 1,\n \"description\": \"セクション番号\"\n },\n \"title\": {\n \"type\": \"string\",\n \"description\": \"セクション/スライドタイトル\"\n },\n \"type\": {\n \"type\": \"string\",\n \"enum\": [\n \"title\",\n \"agenda\",\n \"section\",\n \"content\",\n \"two_column\",\n \"photo\",\n \"demo\",\n \"summary\",\n \"closing\"\n ],\n \"description\": \"スライドタイプ\"\n },\n \"key_message\": {\n \"type\": \"string\",\n \"description\": \"このセクションで伝えたい核心メッセージ\"\n },\n \"talking_points\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"話すポイント(箇条書き)\"\n },\n \"items\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"スライドに表示する項目(agenda等で使用)\"\n },\n \"notes\": {\n \"type\": \"string\",\n \"description\": \"補足・演出メモ\"\n },\n \"estimated_slides\": {\n \"type\": \"integer\",\n \"minimum\": 1,\n \"description\": \"このセクションの推定スライド数\"\n },\n \"materials_needed\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"必要な素材(画像・コード等)\"\n }\n }\n }\n },\n \"materials\": {\n \"type\": \"object\",\n \"description\": \"必要な素材リスト\",\n \"properties\": {\n \"images\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"必要な画像・図の説明\"\n },\n \"code_samples\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"必要なコードサンプルの説明\"\n },\n \"demos\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"デモの内容\"\n },\n \"data\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"必要なデータ・統計\"\n }\n }\n },\n \"constraints\": {\n \"type\": \"object\",\n \"description\": \"制約事項\",\n \"properties\": {\n \"avoid_topics\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"避けるべきトピック\"\n },\n \"required_branding\": {\n \"type\": \"string\",\n \"description\": \"ブランディング要件\"\n },\n \"accessibility\": {\n \"type\": \"string\",\n \"description\": \"アクセシビリティ要件\"\n }\n }\n },\n \"next_steps\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"Orchestrator への引継ぎ時の推奨アクション\"\n }\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":5686,"content_sha256":"ea2f86ec1b27735c7436d67597d2a673b4f46f8758c2745e4d04653b9ed7e02d"},{"filename":"references/SCRIPTS.md","content":"# Script Reference\n\nComplete documentation of all scripts in the PowerPoint Automation pipeline.\n\n## Content Extraction\n\n### `classify_input.py`\n\n**Purpose**: Detect input type and determine appropriate workflow\n\n**Usage**:\n\n```powershell\npython scripts/classify_input.py input/file_or_url --output output_manifest/{base}_classification.json\n```\n\n**Output**: JSON to stdout, or the `--output` file. Other options: `--purpose report|lt|incident|blog|custom`, `--detect-language`, `--validate \u003cclassification.json>`\n\n### `extract_images.py`\n\n**Purpose**: Extract images from PPTX or web sources\n\n**Usage**:\n\n```powershell\npython scripts/extract_images.py input/presentation.pptx images/{base}/\n```\n\n**Output**: Image files in `images/{base}/`\n\n### `reconstruct_analyzer.py`\n\n**Purpose**: Convert PPTX to content.json (IR format)\n\n**Usage**:\n\n```powershell\npython scripts/reconstruct_analyzer.py input/presentation.pptx output_manifest/{base}_content.json\n```\n\n**Output**: `output_manifest/{base}_content.json`\n\n**Features**:\n\n- Automatic slide type detection (title, content, section, etc.)\n- Speaker notes extraction\n- Image path mapping\n- Source slide tracking\n\n---\n\n## Template Management\n\n### `analyze_template.py`\n\n**Purpose**: Analyze template layouts and generate mapping configuration\n\n**Usage**:\n\n```powershell\npython scripts/analyze_template.py assets/template.pptx [output.json]\n```\n\n**Output**: `output_manifest/{template}_layouts.json`\n\n**When to Use**: First time using a new template\n\n### `diagnose_template.py`\n\n**Purpose**: Detect quality issues in templates\n\n**Usage**:\n\n```powershell\npython scripts/diagnose_template.py assets/template.pptx [--json]\n```\n\n**Detects**:\n\n- Background images in master slides\n- Broken image references\n- Narrow placeholders\n- Dark backgrounds (contrast issues)\n\n### `clean_template.py`\n\n**Purpose**: Remove problematic elements from templates\n\n**Usage**:\n\n```powershell\npython scripts/clean_template.py input/template.pptx output/clean_template.pptx [--keep-first-master] [--keep-metadata]\n```\n\n**Actions**:\n\n- Remove background images\n- Fix broken image references\n- Normalize viewProps.xml\n\n### `create_clean_template.py`\n\n**Purpose**: Generate clean template from source PPTX\n\n**Usage**:\n\n```powershell\npython scripts/create_clean_template.py input/presentation.pptx output/template.pptx --all\n```\n\n**Options**:\n\n- `--remove-backgrounds`: Remove background images\n- `--remove-decorations`: Remove decorative shapes\n- `--fix-placeholders`: Optimize placeholder positions\n- `--all`: Apply all fixes (recommended)\n- `--analyze`: Analysis only, no changes\n\n---\n\n## PPTX Generation\n\n### `create_from_template.py` ⭐ **Recommended**\n\n**Purpose**: Generate PPTX from content.json using template\n\n**Usage**:\n\n```powershell\npython scripts/create_from_template.py assets/template.pptx output_manifest/{base}_content.json output_ppt/{base}.pptx\n```\n\nOptions: `--config \u003clayouts.json>`, `--list-layouts`, `--force`, `--auto-clean`, `--no-signature`.\n\n**Features**:\n\n- Template master inheritance (colors, fonts, backgrounds)\n- Automatic layout selection\n- Image positioning and sizing\n- Code block support\n- AutoFit control\n- Empty placeholder cleanup\n\n**Configuration**: Uses `--config`, otherwise auto-reads `output_manifest/{template}_layouts.json` if present\n\n### `create_ja_pptx.py`\n\n**Purpose**: Generate PPTX from scratch using python-pptx\n\n**Usage**:\n\n```powershell\npython scripts/create_ja_pptx.py output_manifest/{base}_content.json output_ppt/{base}.pptx\n```\n\n**When to Use**: When no template is needed (minimal design)\n\n### `create_pptx.js`\n\n**Purpose**: Generate PPTX with custom diagrams using pptxgenjs\n\n**Usage**:\n\n```bash\nnode scripts/create_pptx.js output_manifest/{base}_content.json output_ppt/{base}_diagrams.pptx [--no-signature]\n```\n\n**When to Use**:\n\n- Architecture diagrams with shapes and arrows\n- Custom layouts not available in templates\n- Full control over positioning and styling\n\n---\n\n## Advanced Operations\n\n### `merge_slides.py`\n\n**Purpose**: Merge slides from source PPTX into template\n\n**Usage**:\n\n```powershell\npython scripts/merge_slides.py template.pptx source.pptx output.pptx [--clear-template] [--position N]\n```\n\n**Options**:\n\n- `--clear-template`: Remove existing slides, keep master only\n- `--position N`: Insert at position N (-1 = append)\n- `--keep-source-master`: Preserve source colors\n\n**Use Case**: Combine pptxgenjs diagrams with template design\n\n### `insert_diagram_slides.py`\n\n**Purpose**: Insert diagram slides at specific positions with layout selection\n\n**Usage**:\n\n```powershell\npython scripts/insert_diagram_slides.py base.pptx diagrams.pptx output.pptx --config config.json\n```\n\n**Config Format**:\n\n```json\n{\n \"insertions\": [\n {\n \"source_index\": 0,\n \"target_position\": 4,\n \"layout_name\": \"Title and Content\"\n }\n ]\n}\n```\n\n### `summarize_content.py`\n\n**Purpose**: Helper for the Summarizer agent (the script does not summarize by itself)\n\n**Usage**:\n\n```powershell\npython scripts/summarize_content.py analyze output_manifest/{base}_content.json [--json]\n# Summarizer agent writes {base}_content_summary.json\npython scripts/summarize_content.py validate output_manifest/{base}_content_summary.json\n```\n\n**When to Use**: Converting long presentations to shorter versions\n\n---\n\n## Quality Assurance\n\n### `validate_content.py`\n\n**Purpose**: Validate content.json against schema\n\n**Usage**:\n\n```powershell\npython scripts/validate_content.py output_manifest/{base}_content.json\n```\n\n**Checks**:\n\n- Schema compliance\n- Empty slides detection\n- Image path existence\n- Items format validation\n\n**Exit Codes**:\n\n- 0 = PASS\n- 1 = FAIL (schema violation)\n- 2 = WARN (quality issues)\n\n### `validate_pptx.py`\n\n**Purpose**: Detect PPTX quality issues\n\n**Usage**:\n\n```powershell\npython scripts/validate_pptx.py output_ppt/{base}.pptx output_manifest/{base}_content.json\n```\n\n**Checks**:\n\n- Slide count mismatch\n- Text overflow (>800 chars, >15 paragraphs)\n- Missing speaker notes\n- Source-only notes (lacking content)\n\n### `review_pptx.py`\n\n**Purpose**: Extract PPTX content for AI review\n\n**Usage**:\n\n```powershell\npython scripts/review_pptx.py output_ppt/{base}.pptx\n```\n\n**Output**: Markdown report with slide content for AI analysis\n\n---\n\n## Utility Scripts\n\n### `reorder_slides.py`\n\n**Purpose**: Reorder and duplicate slides\n\n**Usage**:\n\n```powershell\npython scripts/reorder_slides.py input.pptx output.pptx 0,1,1,2,5\n```\n\n### `extract_shapes.py`\n\n**Purpose**: Extract shape inventory from PPTX\n\n**Usage**:\n\n```powershell\npython scripts/extract_shapes.py input.pptx output_manifest/{base}_inventory.json [--issues-only]\n```\n\n**Use Case**: Analysis for text replacement workflows\n\n### `apply_content.py`\n\n**Purpose**: Apply text replacements to PPTX shapes\n\n**Usage**:\n\n```powershell\npython scripts/apply_content.py input.pptx output_manifest/{base}_replacements.json output.pptx\n```\n\n**Note**: Experimental, use reconstruct method instead\n\n### `extract_parallel.ps1`\n\n**Purpose**: Run EXTRACT phase in parallel (analyze + extract + reconstruct)\n\n**Usage**:\n\n```powershell\n.\\scripts\\extract_parallel.ps1 -InputPptx \"input/presentation.pptx\" -Base \"20251218_example\"\n```\n\n### `workflow_tracer.py`\n\n**Purpose**: Generate workflow trace logs (Python module; running the file directly only writes a demo trace)\n\n**Usage**:\n\n```python\nfrom workflow_tracer import WorkflowTracer\ntracer = WorkflowTracer(\"{base}\")\ntracer.start_phase(\"EXTRACT\")\ntracer.end_phase(\"EXTRACT\", status=\"success\")\ntracer.save()\n```\n\n**Output**: `output_manifest/{base}_trace.jsonl`\n\n### `resume_workflow.py`\n\n**Purpose**: Resume workflow from specific phase after manual fixes\n\n**Usage**:\n\n```powershell\npython scripts/resume_workflow.py {base} --from REVIEW_JSON [--skip-validation] [--template \u003ctemplate.pptx>]\n```\n\n`--from`: INIT, PLAN, EXTRACT, TRANSLATE, REVIEW_JSON, BUILD, REVIEW_PPTX, DONE.\n\n---\n\n## Script Dependencies\n\nPackages: `scripts/requirements.txt`, `scripts/package.json`. Script-to-script dependencies: [script-dependencies.instructions.md](instructions/script-dependencies.instructions.md).\n\n### Installation\n\n```powershell\n# Python\npip install -r scripts/requirements.txt\n\n# Node.js\nnpm install --prefix scripts\n```\n\n---\n\n## Common Workflows\n\n### Web Article → PPTX\n\n```powershell\n# 1. Extract (manual content creation)\n# 2. Translate (Localizer agent)\n# 3. Generate\npython scripts/create_from_template.py assets/template.pptx output_manifest/{base}_content_ja.json output_ppt/{base}.pptx\n```\n\n### English PPTX → Japanese\n\n```powershell\n# 1. Extract\npython scripts/extract_images.py input/en.pptx images/{base}/\npython scripts/reconstruct_analyzer.py input/en.pptx output_manifest/{base}_content.json\n\n# 2. Translate (Localizer agent)\n\n# 3. Generate\npython scripts/create_from_template.py input/en.pptx output_manifest/{base}_content_ja.json output_ppt/{base}.pptx\n```\n\n### Diagram + Template\n\n```powershell\n# 1. Generate diagrams\nnode scripts/create_pptx.js output_manifest/{base}_content.json diagrams.pptx\n\n# 2. Merge\npython scripts/merge_slides.py assets/template.pptx diagrams.pptx output_ppt/{base}.pptx --clear-template\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9133,"content_sha256":"a63b01f55f29da1c0a8361d5b5d2cecede1fa304fb5ead297ec20adad4433bcf"},{"filename":"references/USE_CASES.md","content":"# Common Use Cases\n\nDetailed examples of how to use PowerPoint Automation for various scenarios.\n\n## 1. Tech Blog → Presentation\n\n**Scenario**: Convert a Zenn or Qiita technical article into a presentation\n\n**Input**:\n\n- URL: `https://zenn.dev/example/articles/azure-copilot-guide`\n- Article length: 3000 words, 5 images, 3 code blocks\n\n**User Request**:\n\n```\n\"Create a 15-slide presentation about Azure Copilot from this Zenn article:\nhttps://zenn.dev/example/articles/azure-copilot-guide\"\n```\n\n**Workflow**:\n\n```\nTRIAGE → User provides URL\n ↓\nPLAN → System proposes: 15 slides, template D, standard detail\n ↓\nEXTRACT → Fetch article content + download images\n ↓\nTRANSLATE → (Skip if Japanese source)\n ↓\nBUILD → create_from_template.py\n ↓\nREVIEW → PPTX Reviewer checks quality\n ↓\nDONE → Open PowerPoint\n```\n\n**Output**:\n\n- 15-slide PPTX with:\n - Title slide\n - Agenda (3 sections)\n - 10 content slides (with code blocks)\n - 3 image slides (screenshots)\n - Summary slide\n\n---\n\n## 2. English PPTX → Japanese Translation\n\n**Scenario**: Translate Microsoft Ignite session slides to Japanese\n\n**Input**:\n\n- File: `input/BRK252_DataSecurity.pptx` (120 slides, English)\n- Requirement: Maintain original design and layout\n\n**User Request**:\n\n```\n\"Translate this Microsoft presentation to Japanese, keeping the same design\"\n```\n\n**Workflow**:\n\n```\nTRIAGE → Detect PPTX input\n ↓\nPLAN → User confirms: Full translation (120 slides), method A (template inheritance)\n ↓\nPREPARE_TEMPLATE → Diagnose + clean template\n ↓\nEXTRACT → reconstruct_analyzer.py (PPTX → content.json)\n → extract_images.py (save all images)\n ↓\nTRANSLATE → Localizer agent (content.json → content_ja.json)\n ↓\nBUILD → create_from_template.py (inherit master from original)\n ↓\nREVIEW → JSON Reviewer (translation quality)\n → PPTX Reviewer (layout, overflow, notes)\n ↓\nDONE → 120-slide Japanese PPTX\n```\n\n**Output**:\n\n- Japanese PPTX maintaining:\n - Original colors and fonts\n - Slide master design\n - Image positions\n - Speaker notes (translated)\n\n**Key Features**:\n\n- Template inheritance (method A)\n- Automatic layout selection\n- AutoFit control (prevent text spacing issues)\n- Position adjustment for Japanese text\n\n---\n\n## 3. Report Generation from Notes\n\n**Scenario**: Create quarterly report presentation from Markdown notes\n\n**Input**:\n\n- Markdown notes with bullet points\n- 5 charts/graphs (PNG images)\n- Goal: Formal business report\n\n**User Request**:\n\n```\n\"Create a quarterly report presentation from my notes.\nInclude these 5 charts and make it professional.\"\n```\n\n**Workflow**:\n\n```\nTRIAGE → Detect mixed input (text + images)\n ↓\nBRAINSTORM → Interactive Q&A\n → \"Who's the audience?\" → \"Executives\"\n → \"Duration?\" → \"30 minutes\"\n → \"Tone?\" → \"Formal\"\n ↓\n proposal.json generated\n ↓\nPLAN → User confirms: 25 slides, template (business style)\n ↓\nEXTRACT → Convert notes to content.json\n → Map images to slides\n ↓\nBUILD → create_from_template.py\n ↓\nREVIEW → PPTX Reviewer\n ↓\nDONE → Business report PPTX\n```\n\n**Output**:\n\n- 25-slide report with:\n - Executive summary\n - 5 sections with charts\n - Key metrics and insights\n - Action items and next steps\n\n**Brainstormer Interaction**:\nThe Brainstormer agent asks:\n\n1. Who's the audience?\n2. Presentation duration?\n3. Key message/goal?\n4. Tone (formal/casual)?\n5. Any topics to avoid?\n\n---\n\n## 4. Technical Documentation → Training Material\n\n**Scenario**: Convert API documentation into training slides\n\n**Input**:\n\n- API reference (Markdown/HTML)\n- Code examples (JSON, cURL)\n- Architecture diagram\n\n**User Request**:\n\n```\n\"Create training material for our REST API.\nInclude authentication, endpoints, and code examples.\"\n```\n\n**Workflow**:\n\n```\nTRIAGE → Detect documentation input\n ↓\nPLAN → User confirms: 20 slides, method B (pptxgenjs for code blocks)\n ↓\nEXTRACT → Parse documentation\n → Extract code snippets\n → Convert architecture diagram\n ↓\nBUILD → create_pptx.js (pptxgenjs)\n ↓\nREVIEW → PPTX Reviewer (code readability)\n ↓\nDONE → Training material PPTX\n```\n\n**Output**:\n\n- 20-slide training with:\n - API overview\n - Authentication flow (diagram)\n - Endpoint reference (with code)\n - Error handling examples\n - Best practices\n\n**Why pptxgenjs (Method B)?**\n\n- Better code block formatting (monospace, syntax highlighting)\n- Custom diagram positioning\n- Fine-grained control over layout\n\n---\n\n## 5. Architecture Diagram + Presentation\n\n**Scenario**: Add custom architecture diagrams to existing template\n\n**Input**:\n\n- Existing presentation template\n- Architecture components (boxes, arrows, labels)\n\n**User Request**:\n\n```\n\"Create an Azure architecture diagram and insert it into slide 5 of this template\"\n```\n\n**Workflow**:\n\n```\nPLAN → User confirms: Insert diagram at slide 5\n ↓\nDIAGRAM → create_pptx.js (generate diagram with shapes)\n → Output: diagrams.pptx\n ↓\nMERGE → insert_diagram_slides.py\n → Insert diagrams.pptx[0] at position 5\n → Select layout: \"Title and Content\"\n ↓\nDONE → Combined PPTX\n```\n\nPptxGenJS option pitfalls: [IMPLEMENTATION_PATTERNS.md](IMPLEMENTATION_PATTERNS.md#pptxgenjs-hardening).\n\n---\n\n## 6. Slide Count Reduction (Summarization)\n\n**Scenario**: Reduce 60-slide presentation to 20 slides for shorter session\n\n**Input**:\n\n- Original PPTX (60 slides)\n- Target: 20 slides (1/3 compression)\n\n**User Request**:\n\n```\n\"Reduce this 60-slide presentation to 20 slides for a 15-minute talk\"\n```\n\n**Workflow**:\n\n```\nEXTRACT → reconstruct_analyzer.py\n ↓\nSUMMARIZE → summarize_content.py analyze\n → Summarizer agent writes content_summary.json\n → summarize_content.py validate\n ↓\nBUILD → create_from_template.py\n ↓\nREVIEW → Check for information loss\n ↓\nDONE → 20-slide condensed version\n```\n\n**Summarization Strategy**:\n\n- Merge similar content slides\n- Keep all section dividers\n- Preserve key images\n- Combine related bullet points\n- Maintain logical flow\n\n---\n\n## 7. Multilingual Presentations\n\n**Scenario**: Create same presentation in 3 languages (EN, JA, ZH)\n\n**Input**:\n\n- Original content.json (English)\n\n**User Request**:\n\n```\n\"Create this presentation in English, Japanese, and Chinese\"\n```\n\n**Workflow**:\n\n```\nEXTRACT → content.json (English)\n ↓\nTRANSLATE → Localizer (EN → JA) → content_ja.json\n ↓\nTRANSLATE → Localizer (EN → ZH) → content_zh.json\n ↓\nBUILD → create_from_template.py × 3\n → output_ppt/{base}_en.pptx\n → output_ppt/{base}_ja.pptx\n → output_ppt/{base}_zh.pptx\n ↓\nDONE → 3 language versions\n```\n\n**Font Handling**:\n\n- English: Arial, Calibri\n- Japanese: Meiryo, Yu Gothic\n- Chinese: Microsoft YaHei, SimHei\n\n---\n\n## Quick Reference\n\n| Use Case | Input | Method | Output |\n| ---------------- | -------------- | --------------------------- | ------------ |\n| **Blog → PPTX** | URL | EXTRACT → BUILD | 15-20 slides |\n| **EN → JA** | PPTX | EXTRACT → TRANSLATE → BUILD | Same design |\n| **Report** | Notes + Images | BRAINSTORM → BUILD | 25 slides |\n| **Training** | Docs + Code | EXTRACT → BUILD (pptxgenjs) | 20 slides |\n| **Diagram** | Components | DIAGRAM → MERGE | 1 diagram |\n| **Summarize** | 60 slides | EXTRACT → SUMMARIZE → BUILD | 20 slides |\n| **Multilingual** | content.json | TRANSLATE × N → BUILD × N | N versions |\n\n---\n\n## Tips\n\n- Blog: standard detail (option 2), include article images, method D (first template)\n- Translation: method A (template inheritance) to keep design\n- Code-heavy: pptxgenjs (method B), max 10 code lines per slide\n- Diagrams: match template colors read from the master; place at \"right\" or \"bottom\" to keep text space\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8059,"content_sha256":"8b30de3b65af964d4899b1d28826684b511b388792e5820ecb30b89604992534"},{"filename":"scripts/analyze_template.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nAnalyze PowerPoint template and generate layout mapping configuration.\n\nThis script inspects the slide master layouts in a template and creates\na JSON configuration file that maps slide types to appropriate layouts.\n\nUsage:\n python scripts/analyze_template.py \u003ctemplate.pptx> [output.json]\n\nExamples:\n python scripts/analyze_template.py templates/sample-ppf.pptx\n python scripts/analyze_template.py templates/sample-ppf.pptx output_manifest/sample-ppf_layouts.json\n\"\"\"\n\nfrom pptx import Presentation\nfrom pptx.enum.shapes import MSO_SHAPE_TYPE\nimport json\nimport sys\nfrom pathlib import Path\n\n\ndef analyze_layout(layout, idx: int) -> dict:\n \"\"\"Analyze a single slide layout and return its characteristics.\"\"\"\n name = layout.name or f'Layout_{idx}'\n \n # Collect placeholder info\n placeholders = []\n has_title = False\n has_subtitle = False\n has_body = False\n has_content = False\n has_picture = False\n body_count = 0\n \n for shape in layout.placeholders:\n ph_type = str(shape.placeholder_format.type)\n ph_idx = shape.placeholder_format.idx\n \n ph_info = {\n 'idx': ph_idx,\n 'type': ph_type.replace('PLACEHOLDER_TYPE.', ''),\n }\n placeholders.append(ph_info)\n \n if 'TITLE' in ph_type or 'CENTER_TITLE' in ph_type:\n has_title = True\n if 'SUBTITLE' in ph_type:\n has_subtitle = True\n if 'BODY' in ph_type:\n has_body = True\n body_count += 1\n if 'OBJECT' in ph_type or 'CONTENT' in ph_type:\n has_content = True\n if 'PICTURE' in ph_type:\n has_picture = True\n \n # Determine layout category - order matters! More specific patterns first\n category = 'unknown'\n name_lower = name.lower()\n \n # Explicit closing patterns (most specific first)\n if 'closing' in name_lower:\n category = 'closing'\n # Title slide patterns (before general title checks)\n elif 'title slide' in name_lower or 'タイトル スライド' in name_lower or 'タイトルスライド' in name_lower:\n category = 'title'\n # Section patterns\n elif 'section' in name_lower or 'セクション' in name_lower or 'divider' in name_lower:\n category = 'section'\n # Agenda patterns\n elif 'agenda' in name_lower or 'アジェンダ' in name_lower:\n category = 'agenda'\n # Content patterns\n elif 'title and content' in name_lower or 'タイトルとコンテンツ' in name_lower:\n category = 'content'\n # Two column\n elif 'two column' in name_lower or '2列' in name_lower or '2 column' in name_lower:\n category = 'two_column'\n # Three column\n elif 'three column' in name_lower or '3列' in name_lower or '3 column' in name_lower:\n category = 'three_column'\n # Code/developer layouts\n elif 'code' in name_lower or 'developer' in name_lower:\n category = 'code'\n # Quote layouts\n elif 'quote' in name_lower:\n category = 'quote'\n # Photo/image layouts\n elif 'photo' in name_lower or 'picture' in name_lower or 'image' in name_lower or '50/50' in name_lower:\n category = 'photo'\n elif 'blank' in name_lower or '白紙' in name_lower:\n category = 'blank'\n elif 'title only' in name_lower:\n category = 'title_only'\n elif has_title and (has_body or has_content):\n category = 'content'\n elif has_title and has_subtitle and not has_body:\n category = 'title'\n \n return {\n 'index': idx,\n 'name': name,\n 'category': category,\n 'has_title': has_title,\n 'has_subtitle': has_subtitle,\n 'has_body': has_body,\n 'has_content': has_content,\n 'has_picture': has_picture,\n 'body_count': body_count,\n 'placeholders': placeholders,\n }\n\n\ndef generate_layout_mapping(layouts_info: list) -> dict:\n \"\"\"Generate recommended layout mapping based on analysis.\"\"\"\n mapping = {\n 'title': None,\n 'content': None,\n 'section': None,\n 'agenda': None,\n 'summary': None,\n 'closing': None,\n 'two_column': None,\n 'code': None,\n 'photo': None,\n 'blank': None,\n 'title_only': None, # Added: for slides with title but no content\n }\n \n # Find best match for each category\n for layout in layouts_info:\n cat = layout['category']\n idx = layout['index']\n \n # Prefer first match for each category\n if cat == 'title' and mapping['title'] is None:\n mapping['title'] = idx\n elif cat == 'content' and mapping['content'] is None:\n mapping['content'] = idx\n elif cat == 'section' and mapping['section'] is None:\n mapping['section'] = idx\n elif cat == 'agenda' and mapping['agenda'] is None:\n mapping['agenda'] = idx\n elif cat == 'closing' and mapping['closing'] is None:\n mapping['closing'] = idx\n elif cat == 'two_column' and mapping['two_column'] is None:\n mapping['two_column'] = idx\n elif cat == 'code' and mapping['code'] is None:\n mapping['code'] = idx\n elif cat == 'photo' and mapping['photo'] is None:\n mapping['photo'] = idx\n elif cat == 'blank' and mapping['blank'] is None:\n mapping['blank'] = idx\n elif cat == 'title_only' and mapping['title_only'] is None:\n mapping['title_only'] = idx\n \n # Set fallbacks\n if mapping['content'] is None:\n mapping['content'] = 1 # Default fallback\n if mapping['title'] is None:\n mapping['title'] = 0\n if mapping['summary'] is None:\n mapping['summary'] = mapping['content']\n if mapping['section'] is None:\n mapping['section'] = mapping['content']\n if mapping['agenda'] is None:\n mapping['agenda'] = mapping['content']\n if mapping['closing'] is None:\n mapping['closing'] = mapping['section']\n if mapping['two_column'] is None:\n mapping['two_column'] = mapping['content']\n if mapping['code'] is None:\n mapping['code'] = mapping['content']\n if mapping['photo'] is None:\n mapping['photo'] = mapping['content']\n if mapping['blank'] is None:\n mapping['blank'] = mapping['content']\n if mapping['title_only'] is None:\n # Fallback: prefer blank, then section, then content\n mapping['title_only'] = mapping['blank'] or mapping['section'] or mapping['content']\n \n return mapping\n\n\ndef analyze_template(template_path: str) -> dict:\n \"\"\"Analyze a PowerPoint template and return full analysis.\"\"\"\n prs = Presentation(template_path)\n \n result = {\n 'template': str(Path(template_path).name),\n 'template_path': str(template_path),\n 'slide_width_inches': round(prs.slide_width.inches, 2),\n 'slide_height_inches': round(prs.slide_height.inches, 2),\n 'aspect_ratio': '16:9' if abs(prs.slide_width.inches / prs.slide_height.inches - 16/9) \u003c 0.1 else '4:3',\n 'masters': [],\n 'layouts': [],\n 'layout_mapping': {},\n }\n \n all_layouts = []\n \n for master_idx, master in enumerate(prs.slide_masters):\n master_info = {\n 'index': master_idx,\n 'name': master.name or f'Master_{master_idx}',\n 'layout_count': len(master.slide_layouts),\n }\n result['masters'].append(master_info)\n \n # Analyze all layouts (from first master)\n for idx, layout in enumerate(prs.slide_layouts):\n layout_info = analyze_layout(layout, idx)\n all_layouts.append(layout_info)\n \n result['layouts'] = all_layouts\n result['layout_mapping'] = generate_layout_mapping(all_layouts)\n \n return result\n\n\ndef print_analysis(analysis: dict) -> None:\n \"\"\"Print analysis in human-readable format.\"\"\"\n print(f\"\\n{'='*60}\")\n print(f\"Template: {analysis['template']}\")\n print(f\"Size: {analysis['slide_width_inches']}\\\" x {analysis['slide_height_inches']}\\\" ({analysis['aspect_ratio']})\")\n print(f\"{'='*60}\\n\")\n \n print(\"📋 Recommended Layout Mapping:\")\n print(\"-\" * 40)\n for slide_type, idx in analysis['layout_mapping'].items():\n if idx is not None:\n layout_name = analysis['layouts'][idx]['name'] if idx \u003c len(analysis['layouts']) else '?'\n print(f\" {slide_type:15} → [{idx:2}] {layout_name}\")\n print()\n \n print(\"📊 All Layouts by Category:\")\n print(\"-\" * 40)\n \n # Group by category\n by_category = {}\n for layout in analysis['layouts']:\n cat = layout['category']\n if cat not in by_category:\n by_category[cat] = []\n by_category[cat].append(layout)\n \n for cat in sorted(by_category.keys()):\n print(f\"\\n [{cat}]\")\n for layout in by_category[cat][:5]: # Show max 5 per category\n flags = []\n if layout['has_title']:\n flags.append('T')\n if layout['has_body']:\n flags.append(f\"B{layout['body_count']}\")\n if layout['has_content']:\n flags.append('C')\n if layout['has_picture']:\n flags.append('P')\n flag_str = ','.join(flags) if flags else '-'\n print(f\" [{layout['index']:2}] {layout['name'][:40]:40} ({flag_str})\")\n if len(by_category[cat]) > 5:\n print(f\" ... and {len(by_category[cat]) - 5} more\")\n \n print()\n\n\ndef main():\n if len(sys.argv) \u003c 2:\n print(__doc__)\n sys.exit(1)\n \n template_path = sys.argv[1]\n output_path = sys.argv[2] if len(sys.argv) > 2 else None\n \n # Auto-generate output path if not specified\n if output_path is None:\n template_stem = Path(template_path).stem\n output_path = f\"output_manifest/{template_stem}_layouts.json\"\n \n # Analyze\n print(f\"Analyzing: {template_path}\")\n analysis = analyze_template(template_path)\n \n # Print summary\n print_analysis(analysis)\n \n # Save JSON\n Path(output_path).parent.mkdir(parents=True, exist_ok=True)\n with open(output_path, 'w', encoding='utf-8') as f:\n json.dump(analysis, f, ensure_ascii=False, indent=2)\n \n print(f\"✅ Saved: {output_path}\")\n print(f\"\\nTo use this mapping, either:\")\n print(f\" 1. Edit the JSON and pass to create_from_template.py --config {output_path}\")\n print(f\" 2. Copy layout_mapping values to your content JSON\")\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10872,"content_sha256":"b8edaed2eb955eb132c8dbb34e316141e5a9210337075f452f297d2ce90a4770"},{"filename":"scripts/apply_content.py","content":"#!/usr/bin/env python3\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nApply text replacements and slide management to PowerPoint presentation.\n\nThis module provides functionality to:\n- Replace text content in PowerPoint presentations based on JSON configuration\n- Delete slides (via slides_to_delete or slides_to_keep)\n- Preserve formatting and support bullet lists, fonts, colors, paragraph styling\n\nUsage:\n python apply_content.py input.pptx replacements.json output.pptx\n\nJSON Structure:\n {\n \"slides_to_keep\": [0, 1, 5, 10], // Optional: keep only these slides\n \"slides_to_delete\": [2, 3, 4], // Optional: delete these slides\n \"slide-0\": { // Text replacements\n \"shape-0\": {\n \"paragraphs\": [{\"text\": \"New text\"}]\n }\n }\n }\n\nNote: slides_to_keep takes precedence over slides_to_delete if both are specified.\n\nIMPORTANT:\n This script must use the same shape extraction and sorting logic as\n extract_shapes.py to ensure shape-id consistency. If you modify the shape\n extraction logic in either file, update the other file as well.\n\nAuthor: aktsmm\nLicense: CC BY-NC 4.0\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional, Tuple\n\nfrom pptx import Presentation\nfrom pptx.dml.color import RGBColor\nfrom pptx.enum.dml import MSO_THEME_COLOR\nfrom pptx.enum.text import PP_ALIGN\nfrom pptx.oxml.ns import qn\nfrom pptx.util import Pt\n\n\ndef get_luminance(r: int, g: int, b: int) -> float:\n \"\"\"Calculate relative luminance of a color (WCAG formula).\n \n Args:\n r, g, b: RGB values (0-255)\n \n Returns:\n Relative luminance (0.0 to 1.0)\n \"\"\"\n def channel_luminance(c):\n c = c / 255.0\n return c / 12.92 if c \u003c= 0.03928 else ((c + 0.055) / 1.055) ** 2.4\n \n return 0.2126 * channel_luminance(r) + 0.7152 * channel_luminance(g) + 0.0722 * channel_luminance(b)\n\n\ndef is_dark_background(r: int, g: int, b: int, threshold: float = 0.5) -> bool:\n \"\"\"Determine if a background color is dark.\n \n Args:\n r, g, b: RGB values (0-255)\n threshold: Luminance threshold (default 0.5)\n \n Returns:\n True if background is dark, False if light\n \"\"\"\n return get_luminance(r, g, b) \u003c threshold\n\n\ndef get_contrast_color(bg_r: int, bg_g: int, bg_b: int) -> Tuple[int, int, int]:\n \"\"\"Get optimal text color for given background.\n \n Args:\n bg_r, bg_g, bg_b: Background RGB values (0-255)\n \n Returns:\n Tuple of (r, g, b) for optimal text color\n \"\"\"\n if is_dark_background(bg_r, bg_g, bg_b):\n return (255, 255, 255) # White text for dark backgrounds\n else:\n return (51, 51, 51) # Dark gray (#333333) for light backgrounds\n\n\ndef get_slide_background_color(slide) -> Optional[Tuple[int, int, int]]:\n \"\"\"Extract background color from a slide.\n \n Args:\n slide: PowerPoint slide object\n \n Returns:\n Tuple of (r, g, b) or None if cannot determine\n \"\"\"\n try:\n # Try to get background fill\n background = slide.background\n fill = background.fill\n \n if fill.type is not None:\n # Solid fill\n if hasattr(fill, 'fore_color') and fill.fore_color.type is not None:\n color = fill.fore_color\n if color.rgb:\n return (color.rgb[0], color.rgb[1], color.rgb[2])\n \n # Try slide layout background\n if hasattr(slide, 'slide_layout') and slide.slide_layout:\n layout_bg = slide.slide_layout.background\n if layout_bg and layout_bg.fill:\n fill = layout_bg.fill\n if hasattr(fill, 'fore_color') and fill.fore_color.type is not None:\n color = fill.fore_color\n if color.rgb:\n return (color.rgb[0], color.rgb[1], color.rgb[2])\n \n # Try slide master background\n if hasattr(slide, 'slide_layout') and slide.slide_layout:\n master = slide.slide_layout.slide_master\n if master and master.background and master.background.fill:\n fill = master.background.fill\n if hasattr(fill, 'fore_color') and fill.fore_color.type is not None:\n color = fill.fore_color\n if color.rgb:\n return (color.rgb[0], color.rgb[1], color.rgb[2])\n \n except Exception:\n pass\n \n return None\n\n\ndef detect_background_from_existing_text(slide) -> Optional[Tuple[int, int, int]]:\n \"\"\"Detect likely background color from existing text colors.\n \n If most text is white, background is probably dark.\n If most text is dark, background is probably light.\n \n Args:\n slide: PowerPoint slide object\n \n Returns:\n Estimated background RGB or None\n \"\"\"\n white_count = 0\n dark_count = 0\n \n try:\n for shape in slide.shapes:\n if not hasattr(shape, 'text_frame') or not shape.has_text_frame:\n continue\n \n for para in shape.text_frame.paragraphs:\n for run in para.runs:\n if run.font.color and run.font.color.rgb:\n rgb = run.font.color.rgb\n lum = get_luminance(rgb[0], rgb[1], rgb[2])\n if lum > 0.8: # White-ish text\n white_count += 1\n elif lum \u003c 0.3: # Dark text\n dark_count += 1\n except Exception:\n pass\n \n if white_count > dark_count and white_count > 0:\n # Most text is white -> dark background\n return (30, 30, 60) # Dark blue-ish (common in presentations)\n elif dark_count > white_count and dark_count > 0:\n # Most text is dark -> light background\n return (240, 240, 245) # Light gray-ish\n \n return None\n\n\ndef get_optimal_text_color_for_slide(slide) -> Tuple[int, int, int]:\n \"\"\"Get optimal text color for a slide based on its background.\n \n Args:\n slide: PowerPoint slide object\n \n Returns:\n Tuple of (r, g, b) for optimal text color\n \"\"\"\n # First try to get actual background color\n bg_color = get_slide_background_color(slide)\n \n # If not found, try to infer from existing text colors\n if bg_color is None:\n bg_color = detect_background_from_existing_text(slide)\n \n # If still not found, default to assuming dark background\n # (common in corporate presentations like Microsoft Ignite)\n if bg_color is None:\n return (51, 51, 51) # Default to dark text (safe for most backgrounds)\n \n return get_contrast_color(*bg_color)\n\n\ndef parse_arguments() -> argparse.Namespace:\n \"\"\"Parse command line arguments.\n \n Returns:\n Parsed command line arguments.\n \"\"\"\n parser = argparse.ArgumentParser(\n description=\"Apply text replacements and slide management to PowerPoint presentation.\",\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n python apply_content.py template.pptx replacements.json output.pptx\n Applies text replacements from JSON to create output presentation\n\n=== SLIDE CREATION MODES ===\n\nThis script is part of a multi-mode PPTX generation system:\n\n 1. EXISTING PPTX (this script)\n - Use when: Localizing/translating existing presentations\n - Input: Original .pptx + replacements.json\n - Preserves: All original styles, layouts, animations\n - Command: python apply_content.py input.pptx replacements.json output.pptx\n\n 2. TEMPLATE-BASED (extract_shapes.py + this script)\n - Use when: Creating content with corporate templates\n - Input: Template .pptx + inventory.json + replacements.json\n - First: python extract_shapes.py template.pptx inventory.json\n - Then: python apply_content.py template.pptx replacements.json output.pptx\n\n 3. AUTO-SELECT (Orchestrator Agent)\n - Use when: \"おまかせ\" mode - let AI choose best approach\n - The agent analyzes content and selects optimal method\n\n 4. HTML-BASED (convert_html.js / convert_html_multi.js)\n - Use when: Custom designs, code blocks, complex layouts\n - Input: HTML files in slides/ directory\n - Command: node convert_html_multi.js slides/ output.pptx\n - Offers: Full design freedom, syntax highlighting\n\n=== JSON STRUCTURE ===\n\n{\n \"slides_to_keep\": [0, 1, 5, 10], // Optional: keep only these slides (0-indexed)\n \"slides_to_delete\": [2, 3, 4], // Optional: delete these slides (0-indexed)\n \"slide-0\": {\n \"shape-0\": {\n \"paragraphs\": [\n {\"text\": \"Title text\", \"font_size\": 32.0},\n {\"text\": \"Bullet item\", \"bullet\": true, \"level\": 0}\n ]\n }\n }\n}\n\nNote: \n - slides_to_keep takes precedence over slides_to_delete\n - Text replacements are applied BEFORE slide deletion\n - Slide indices refer to the ORIGINAL slide positions\n \"\"\",\n )\n parser.add_argument(\"input\", help=\"Input PowerPoint file (.pptx)\")\n parser.add_argument(\"replacements\", help=\"JSON file with replacement data\")\n parser.add_argument(\"output\", help=\"Output PowerPoint file (.pptx)\")\n parser.add_argument(\"--no-auto-shrink\", action=\"store_true\",\n help=\"Disable automatic font size shrinking for overflow prevention\")\n return parser.parse_args()\n\n\ndef load_replacements(json_path: Path) -> Dict[str, Any]:\n \"\"\"Load replacement data from JSON file.\n \n Args:\n json_path: Path to the JSON file.\n \n Returns:\n Dictionary containing replacement data.\n \"\"\"\n with open(json_path, \"r\", encoding=\"utf-8\") as f:\n return json.load(f)\n\n\ndef clear_paragraph_formatting(paragraph) -> None:\n \"\"\"Remove bullet formatting from a paragraph.\n \n Args:\n paragraph: The PowerPoint paragraph object.\n \"\"\"\n pPr = paragraph._element.get_or_add_pPr()\n \n # Remove existing bullet elements\n ns_a = \"{http://schemas.openxmlformats.org/drawingml/2006/main}\"\n for tag in [\"buChar\", \"buNone\", \"buAutoNum\", \"buFont\", \"buSzPct\", \"buSzPts\"]:\n for child in list(pPr):\n if child.tag == f\"{ns_a}{tag}\":\n pPr.remove(child)\n\n\ndef create_bullet_char(pPr, char: str = \"•\") -> None:\n \"\"\"Add bullet character formatting to paragraph.\n \n Args:\n pPr: The paragraph properties element.\n char: The bullet character to use.\n \"\"\"\n from lxml import etree\n \n ns_a = \"http://schemas.openxmlformats.org/drawingml/2006/main\"\n buChar = etree.SubElement(pPr, f\"{{{ns_a}}}buChar\")\n buChar.set(\"char\", char)\n\n\ndef create_no_bullet(pPr) -> None:\n \"\"\"Add no-bullet formatting to paragraph.\n \n Args:\n pPr: The paragraph properties element.\n \"\"\"\n from lxml import etree\n \n ns_a = \"http://schemas.openxmlformats.org/drawingml/2006/main\"\n buNone = etree.SubElement(pPr, f\"{{{ns_a}}}buNone\")\n\n\ndef apply_paragraph_formatting(paragraph, para_data: Dict[str, Any]) -> None:\n \"\"\"Apply formatting properties to a paragraph.\n \n Args:\n paragraph: The PowerPoint paragraph object.\n para_data: Dictionary containing paragraph formatting data.\n \"\"\"\n text = para_data.get(\"text\", \"\")\n # Clean text: remove vertical tabs (soft line breaks) that cause \"_x000B_\" display\n text = text.replace('\\x0b', ' ').replace('\\u000b', ' ')\n \n # Clear existing formatting\n clear_paragraph_formatting(paragraph)\n pPr = paragraph._element.get_or_add_pPr()\n \n # Handle bullet formatting\n if para_data.get(\"bullet\", False):\n level = para_data.get(\"level\", 0)\n paragraph.level = level\n \n # Calculate indentation based on font size\n font_size = para_data.get(\"font_size\", 18.0)\n level_indent_emu = int((font_size * (1.6 + level * 1.6)) * 12700)\n hanging_indent_emu = int(-font_size * 0.8 * 12700)\n \n # Set indentation\n pPr.attrib[\"marL\"] = str(level_indent_emu)\n pPr.attrib[\"indent\"] = str(hanging_indent_emu)\n \n # Add bullet character\n create_bullet_char(pPr)\n \n # Default to left alignment for bullets\n if \"alignment\" not in para_data:\n paragraph.alignment = PP_ALIGN.LEFT\n else:\n # Remove indentation for non-bullet text\n pPr.attrib[\"marL\"] = \"0\"\n pPr.attrib[\"indent\"] = \"0\"\n create_no_bullet(pPr)\n \n # Apply alignment\n if \"alignment\" in para_data:\n alignment_map = {\n \"LEFT\": PP_ALIGN.LEFT,\n \"CENTER\": PP_ALIGN.CENTER,\n \"RIGHT\": PP_ALIGN.RIGHT,\n \"JUSTIFY\": PP_ALIGN.JUSTIFY,\n }\n if para_data[\"alignment\"] in alignment_map:\n paragraph.alignment = alignment_map[para_data[\"alignment\"]]\n \n # Apply spacing\n if \"space_before\" in para_data:\n paragraph.space_before = Pt(para_data[\"space_before\"])\n if \"space_after\" in para_data:\n paragraph.space_after = Pt(para_data[\"space_after\"])\n \n # Set text on run\n if paragraph.runs:\n run = paragraph.runs[0]\n run.text = text\n else:\n run = paragraph.add_run()\n run.text = text\n \n # Apply font properties\n apply_font_properties(run, para_data)\n\n\ndef apply_font_properties(run, para_data: Dict[str, Any]) -> None:\n \"\"\"Apply font properties to a text run.\n \n Args:\n run: The PowerPoint run object.\n para_data: Dictionary containing font property data.\n \"\"\"\n font = run.font\n \n if \"font_size\" in para_data:\n font.size = Pt(para_data[\"font_size\"])\n if \"font_name\" in para_data:\n font.name = para_data[\"font_name\"]\n if \"bold\" in para_data:\n font.bold = para_data[\"bold\"]\n if \"italic\" in para_data:\n font.italic = para_data[\"italic\"]\n if \"underline\" in para_data:\n font.underline = para_data[\"underline\"]\n \n # Apply color - prefer RGB, fall back to theme_color\n if \"color\" in para_data:\n color_hex = para_data[\"color\"].lstrip(\"#\")\n if len(color_hex) == 6:\n r = int(color_hex[0:2], 16)\n g = int(color_hex[2:4], 16)\n b = int(color_hex[4:6], 16)\n font.color.rgb = RGBColor(r, g, b)\n elif \"theme_color\" in para_data:\n theme_name = para_data[\"theme_color\"]\n try:\n font.color.theme_color = getattr(MSO_THEME_COLOR, theme_name)\n except AttributeError:\n print(f\" WARNING: Unknown theme color '{theme_name}'\")\n\n\ndef emu_to_inches(emu: int) -> float:\n \"\"\"Convert EMUs (English Metric Units) to inches.\n \n Args:\n emu: Value in EMUs.\n \n Returns:\n Value in inches.\n \"\"\"\n return emu / 914400\n\n\ndef estimate_text_width(text: str, font_size: float) -> float:\n \"\"\"Estimate text width in inches based on character count and font size.\n \n Japanese characters are wider than English characters.\n \n Args:\n text: The text to measure.\n font_size: Font size in points.\n \n Returns:\n Estimated width in inches.\n \"\"\"\n import re\n \n # Count Japanese characters (Hiragana, Katakana, Kanji)\n japanese_pattern = r'[\\u3040-\\u309F\\u30A0-\\u30FF\\u4E00-\\u9FFF]'\n ja_chars = len(re.findall(japanese_pattern, text))\n \n # Count other characters (including spaces, numbers, etc.)\n other_chars = len(text) - ja_chars\n \n # Approximate character widths relative to font size\n # Japanese: ~1.0 em width, English: ~0.5 em width\n # 1 em ≈ font_size in points, 72 points = 1 inch\n ja_width = ja_chars * (font_size / 72) * 0.9 # Japanese chars ~90% of em\n other_width = other_chars * (font_size / 72) * 0.5 # English chars ~50% of em\n \n return ja_width + other_width\n\n\ndef calculate_optimal_font_size(text: str, shape_width_inches: float, \n original_font_size: float,\n min_font_size: float = 12.0) -> float:\n \"\"\"Calculate optimal font size to fit text within shape width.\n \n Args:\n text: The text to fit.\n shape_width_inches: Available width in inches.\n original_font_size: Original font size in points.\n min_font_size: Minimum allowed font size.\n \n Returns:\n Optimal font size in points.\n \"\"\"\n # Handle multi-line text - check longest line\n lines = text.split('\\n')\n max_line = max(lines, key=len) if lines else text\n \n # Estimate width at original font size\n estimated_width = estimate_text_width(max_line, original_font_size)\n \n # If it fits, return original size\n if estimated_width \u003c= shape_width_inches * 0.95: # 5% margin\n return original_font_size\n \n # Calculate reduction ratio needed\n ratio = (shape_width_inches * 0.95) / estimated_width\n new_font_size = original_font_size * ratio\n \n # Enforce minimum font size\n return max(new_font_size, min_font_size)\n\n\ndef check_overflow_warning(text: str, shape_width_inches: float, \n font_size: float, slide_key: str, shape_key: str) -> Optional[str]:\n \"\"\"Check if text might overflow and return warning message.\n \n Args:\n text: The text to check.\n shape_width_inches: Shape width in inches.\n font_size: Font size in points.\n slide_key: Slide identifier for the warning message.\n shape_key: Shape identifier for the warning message.\n \n Returns:\n Warning message if overflow detected, None otherwise.\n \"\"\"\n lines = text.split('\\n')\n max_line = max(lines, key=len) if lines else text\n estimated_width = estimate_text_width(max_line, font_size)\n \n if estimated_width > shape_width_inches * 1.0:\n return (f\" ⚠️ Overflow risk: {slide_key}.{shape_key} \"\n f\"(text: {len(max_line)} chars, estimated: {estimated_width:.1f}in > width: {shape_width_inches:.1f}in)\")\n \n return None\n\n\ndef extract_shapes_from_slide(slide) -> List[Any]:\n \"\"\"Extract all shapes from a slide, handling groups recursively.\n \n Uses the same logic as extract_shapes.py to ensure shape-id consistency.\n \n Args:\n slide: The PowerPoint slide object.\n \n Returns:\n List of shapes with text frames, sorted by visual position.\n \"\"\"\n def get_shapes_recursive(shapes, offset_left: int = 0, offset_top: int = 0):\n result = []\n for shape in shapes:\n if shape.shape_type == 6: # GROUP\n group_left = (shape.left or 0) + offset_left\n group_top = (shape.top or 0) + offset_top\n result.extend(get_shapes_recursive(\n shape.shapes, \n group_left,\n group_top\n ))\n elif shape.has_text_frame:\n # Skip empty text frames\n if not shape.text_frame.text.strip():\n continue\n \n # Skip slide numbers and footers\n if shape.is_placeholder:\n ph_type = str(shape.placeholder_format.type) if shape.placeholder_format.type else \"\"\n if \"SLIDE_NUMBER\" in ph_type or \"FOOTER\" in ph_type or \"DATE\" in ph_type:\n continue\n \n abs_left = (shape.left or 0) + offset_left\n abs_top = (shape.top or 0) + offset_top\n result.append((shape, abs_left, abs_top))\n return result\n \n shapes_with_pos = get_shapes_recursive(slide.shapes)\n \n # Sort by visual position (top to bottom, left to right) - SAME as extract_shapes.py\n shapes_with_pos.sort(key=lambda x: (emu_to_inches(x[2]), emu_to_inches(x[1])))\n \n # Return just the shapes\n return [shape for shape, _, _ in shapes_with_pos]\n\n\ndef apply_replacements(prs: Presentation, replacements: Dict[str, Any], \n auto_shrink: bool = True) -> Tuple[int, List[str]]:\n \"\"\"Apply all replacements to the presentation.\n \n Args:\n prs: The Presentation object.\n replacements: Dictionary of replacements by slide and shape.\n auto_shrink: Whether to automatically shrink font to prevent overflow.\n \n Returns:\n Tuple of (number of shapes modified, list of warnings).\n \"\"\"\n modified_count = 0\n all_warnings = []\n \n for slide_key, shapes_data in replacements.items():\n # Parse slide index from key (e.g., \"slide-0\" -> 0)\n if not slide_key.startswith(\"slide-\"):\n continue\n \n try:\n slide_idx = int(slide_key.split(\"-\")[1])\n except (IndexError, ValueError):\n print(f\" WARNING: Invalid slide key '{slide_key}'\")\n continue\n \n if slide_idx >= len(prs.slides):\n print(f\" WARNING: Slide index {slide_idx} out of range\")\n continue\n \n slide = prs.slides[slide_idx]\n \n # Get shapes in position order\n shapes = extract_shapes_from_slide(slide)\n \n for shape_key, shape_data in shapes_data.items():\n # Parse shape index from key (e.g., \"shape-0\" -> 0)\n if not shape_key.startswith(\"shape-\"):\n continue\n \n try:\n shape_idx = int(shape_key.split(\"-\")[1])\n except (IndexError, ValueError):\n print(f\" WARNING: Invalid shape key '{shape_key}'\")\n continue\n \n if shape_idx >= len(shapes):\n print(f\" WARNING: Shape index {shape_idx} out of range on {slide_key}\")\n continue\n \n shape = shapes[shape_idx]\n \n # Apply paragraph replacements\n if \"paragraphs\" in shape_data:\n warnings = apply_shape_text(\n shape, shape_data[\"paragraphs\"],\n slide_key=slide_key, shape_key=shape_key,\n auto_shrink=auto_shrink\n )\n all_warnings.extend(warnings)\n modified_count += 1\n \n return modified_count, all_warnings\n\n\ndef apply_shape_text(shape, paragraphs: List[Dict[str, Any]], \n slide_key: str = \"\", shape_key: str = \"\",\n auto_shrink: bool = True) -> List[str]:\n \"\"\"Replace all text in a shape with new paragraphs.\n \n Args:\n shape: The PowerPoint shape object.\n paragraphs: List of paragraph data dictionaries.\n slide_key: Slide identifier for logging.\n shape_key: Shape identifier for logging.\n auto_shrink: Whether to automatically shrink font to prevent overflow.\n \n Returns:\n List of warning messages (empty if no warnings).\n \"\"\"\n warnings = []\n text_frame = shape.text_frame\n \n # Get shape width for overflow detection\n shape_width_inches = emu_to_inches(shape.width) if shape.width else 10.0\n \n # Clear existing paragraphs\n for para in list(text_frame.paragraphs):\n p = para._element\n p.getparent().remove(p)\n \n # Add new paragraphs\n for i, para_data in enumerate(paragraphs):\n if i == 0:\n # First paragraph already exists (empty)\n para = text_frame.paragraphs[0] if text_frame.paragraphs else text_frame.add_paragraph()\n else:\n para = text_frame.add_paragraph()\n \n # Check for overflow and auto-adjust font size if needed\n text = para_data.get(\"text\", \"\")\n original_font_size = para_data.get(\"font_size\", 18.0)\n \n if auto_shrink and text:\n optimal_font_size = calculate_optimal_font_size(\n text, shape_width_inches, original_font_size\n )\n \n if optimal_font_size \u003c original_font_size:\n warnings.append(\n f\" 📏 Auto-shrink: {slide_key}.{shape_key} para[{i}] \"\n f\"({original_font_size:.0f}pt → {optimal_font_size:.0f}pt)\"\n )\n # Update para_data with new font size\n para_data = para_data.copy()\n para_data[\"font_size\"] = optimal_font_size\n else:\n # Still check for overflow warning even if not shrinking\n warning = check_overflow_warning(\n text, shape_width_inches, original_font_size, slide_key, shape_key\n )\n if warning:\n warnings.append(warning)\n \n apply_paragraph_formatting(para, para_data)\n \n return warnings\n\n\ndef delete_slides(prs: Presentation, data: Dict[str, Any]) -> int:\n \"\"\"Delete slides from presentation based on configuration.\n \n Supports two modes:\n - slides_to_keep: Keep only the specified slides (delete all others)\n - slides_to_delete: Delete only the specified slides\n \n slides_to_keep takes precedence if both are specified.\n \n Args:\n prs: The Presentation object.\n data: Dictionary containing slides_to_keep or slides_to_delete.\n \n Returns:\n Number of slides deleted.\n \"\"\"\n total_slides = len(prs.slides)\n \n # Determine which slides to delete\n slides_to_delete = set()\n \n if \"slides_to_keep\" in data:\n # Keep only specified slides, delete all others\n slides_to_keep = set(data[\"slides_to_keep\"])\n slides_to_delete = set(range(total_slides)) - slides_to_keep\n print(f\" Mode: Keep {len(slides_to_keep)} slides, delete {len(slides_to_delete)} slides\")\n elif \"slides_to_delete\" in data:\n # Delete specified slides\n slides_to_delete = set(data[\"slides_to_delete\"])\n print(f\" Mode: Delete {len(slides_to_delete)} specified slides\")\n else:\n return 0\n \n # Validate slide indices\n valid_indices = set(range(total_slides))\n invalid_indices = slides_to_delete - valid_indices\n if invalid_indices:\n print(f\" WARNING: Skipping invalid slide indices: {sorted(invalid_indices)}\")\n slides_to_delete = slides_to_delete & valid_indices\n \n if not slides_to_delete:\n return 0\n \n # Delete slides in reverse order to maintain indices\n # python-pptx doesn't have direct slide delete, use XML manipulation\n deleted_count = 0\n for slide_idx in sorted(slides_to_delete, reverse=True):\n try:\n # Get the slide's rId by finding the relationship\n slide = prs.slides[slide_idx]\n slide_part = slide.part\n \n # Find rId by iterating relationships\n rId = None\n for rel in prs.part.rels.values():\n if rel.target_part == slide_part:\n rId = rel.rId\n break\n \n if rId is None:\n print(f\" WARNING: Could not find rId for slide {slide_idx}\")\n continue\n \n # Remove from presentation.xml sldIdLst first\n sldIdLst = prs.part._element.find(\n \"{http://schemas.openxmlformats.org/presentationml/2006/main}sldIdLst\"\n )\n if sldIdLst is not None:\n for sldId in list(sldIdLst):\n if sldId.get(\"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id\") == rId:\n sldIdLst.remove(sldId)\n break\n \n # Remove the relationship\n prs.part.drop_rel(rId)\n \n deleted_count += 1\n except Exception as e:\n print(f\" WARNING: Failed to delete slide {slide_idx}: {e}\")\n \n return deleted_count\n\n\ndef add_summary_slide(prs: Presentation, summary_data: Dict[str, Any], insert_position: int = 1) -> bool:\n \"\"\"Add a summary/agenda slide at the specified position.\n \n Args:\n prs: The Presentation object.\n summary_data: Dictionary containing 'title', 'items' list, and optional 'color'.\n If 'color' is 'auto', automatically detect optimal color.\n insert_position: Position to insert the slide (1-indexed, default: after title slide).\n \n Returns:\n True if successful, False otherwise.\n \"\"\"\n if not summary_data:\n return False\n \n title = summary_data.get(\"title\", \"アジェンダ\")\n items = summary_data.get(\"items\", [])\n text_color = summary_data.get(\"color\", \"auto\") # Default to auto-detect\n \n if not items:\n return False\n \n try:\n # Use Title and Content layout (index 1) or first available layout with content placeholder\n slide_layout = None\n for layout in prs.slide_layouts:\n # Look for layout with title and body placeholders\n has_title = False\n has_body = False\n for placeholder in layout.placeholders:\n if placeholder.placeholder_format.type == 1: # Title\n has_title = True\n elif placeholder.placeholder_format.type == 2: # Body\n has_body = True\n if has_title and has_body:\n slide_layout = layout\n break\n \n if slide_layout is None:\n # Fallback to layout index 1\n slide_layout = prs.slide_layouts[1] if len(prs.slide_layouts) > 1 else prs.slide_layouts[0]\n \n # Add the slide\n slide = prs.slides.add_slide(slide_layout)\n \n # Determine text color\n if text_color == \"auto\":\n # Auto-detect optimal color based on slide background\n r, g, b = get_optimal_text_color_for_slide(slide)\n color_rgb = RGBColor(r, g, b)\n print(f\" Auto-detected text color: #{r:02X}{g:02X}{b:02X}\")\n else:\n # Parse specified color\n try:\n r = int(text_color[0:2], 16)\n g = int(text_color[2:4], 16)\n b = int(text_color[4:6], 16)\n color_rgb = RGBColor(r, g, b)\n except (ValueError, IndexError):\n color_rgb = RGBColor(0x33, 0x33, 0x33) # Default dark gray\n \n # Set title\n if slide.shapes.title:\n slide.shapes.title.text = title\n # Apply color\n for para in slide.shapes.title.text_frame.paragraphs:\n for run in para.runs:\n run.font.color.rgb = color_rgb\n \n # Find body placeholder and add items\n for shape in slide.shapes:\n if shape.has_text_frame and shape != slide.shapes.title:\n tf = shape.text_frame\n tf.clear() # Clear existing content\n \n for i, item in enumerate(items):\n if i == 0:\n para = tf.paragraphs[0]\n else:\n para = tf.add_paragraph()\n \n para.text = item\n para.level = 0\n \n # Apply color\n for run in para.runs:\n run.font.color.rgb = color_rgb\n run.font.size = Pt(18)\n \n break\n \n # Move slide to the specified position\n # Note: python-pptx adds slides at the end, we need to reorder\n if insert_position > 0 and insert_position \u003c len(prs.slides):\n # Get the slide's XML element\n slide_id = prs.slides._sldIdLst[-1] # Last added slide\n prs.slides._sldIdLst.remove(slide_id)\n prs.slides._sldIdLst.insert(insert_position, slide_id)\n \n print(f\" Added summary slide at position {insert_position + 1}\")\n return True\n \n except Exception as e:\n print(f\" WARNING: Failed to add summary slide: {e}\")\n return False\n\n\ndef main() -> None:\n \"\"\"Main entry point for command-line usage.\"\"\"\n args = parse_arguments()\n \n # Validate input file\n input_path = Path(args.input)\n if not input_path.exists():\n print(f\"Error: Input file not found: {args.input}\")\n sys.exit(1)\n \n # Validate replacements file\n replacements_path = Path(args.replacements)\n if not replacements_path.exists():\n print(f\"Error: Replacements file not found: {args.replacements}\")\n sys.exit(1)\n \n try:\n print(f\"Loading presentation: {args.input}\")\n prs = Presentation(input_path)\n \n print(f\"Loading replacements: {args.replacements}\")\n with open(replacements_path, \"r\", encoding=\"utf-8\") as f:\n data = json.load(f)\n \n # Extract slide management options\n slides_to_keep = data.pop(\"slides_to_keep\", None)\n slides_to_delete = data.pop(\"slides_to_delete\", None)\n add_summary = data.pop(\"add_summary_slide\", False)\n summary_data = data.pop(\"summary_slide\", None)\n \n # Remaining data is the replacements\n replacements = {}\n for key, value in data.items():\n if key.startswith(\"slide-\"):\n replacements[key] = value\n \n print(\"Applying replacements...\")\n auto_shrink = not args.no_auto_shrink\n modified, warnings = apply_replacements(prs, replacements, auto_shrink=auto_shrink)\n \n # Print warnings\n if warnings:\n print(f\"\\n=== Auto-adjustment Report ({len(warnings)} items) ===\")\n for warning in warnings:\n print(warning)\n print(\"=\" * 50 + \"\\n\")\n \n # Delete slides if specified (AFTER applying replacements)\n deleted = 0\n if slides_to_keep is not None or slides_to_delete is not None:\n print(\"Processing slide deletion...\")\n delete_data = {}\n if slides_to_keep is not None:\n delete_data[\"slides_to_keep\"] = slides_to_keep\n if slides_to_delete is not None:\n delete_data[\"slides_to_delete\"] = slides_to_delete\n deleted = delete_slides(prs, delete_data)\n print(f\" Deleted {deleted} slides\")\n \n # Add summary slide if requested (AFTER slide deletion)\n if add_summary and summary_data:\n print(\"Adding summary slide...\")\n add_summary_slide(prs, summary_data, insert_position=1)\n \n # Save output\n output_path = Path(args.output)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n prs.save(output_path)\n \n total_shapes = sum(len(shapes) for shapes in replacements.values())\n print(f\"Modified {modified} shapes across {len(replacements)} slides\")\n if deleted > 0:\n print(f\"Deleted {deleted} slides (final: {len(prs.slides)} slides)\")\n print(f\"Output saved to: {args.output}\")\n \n except json.JSONDecodeError as e:\n print(f\"Error parsing JSON: {e}\")\n sys.exit(1)\n except Exception as e:\n print(f\"Error processing presentation: {e}\")\n import traceback\n traceback.print_exc()\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":35501,"content_sha256":"96ad6169a2f37c1b2382d2dab550a11e16682919609ab9819dd093e474648610"},{"filename":"scripts/classify_input.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nClassify input and determine optimal processing method.\n\nThis is a pure function script that replaces the Router agent.\nNo AI judgment needed - deterministic classification based on input characteristics.\n\nUsage:\n python scripts/classify_input.py \u003cinput_path> [--output \u003cclassification.json>]\n python scripts/classify_input.py --detect-language \u003cpptx_path>\n python scripts/classify_input.py --validate \u003cclassification.json>\n\nExamples:\n python scripts/classify_input.py input/presentation.pptx\n python scripts/classify_input.py \"https://qiita.com/example/items/xxx\"\n python scripts/classify_input.py input/presentation.pptx --output output_manifest/classification.json\n python scripts/classify_input.py --validate output_manifest/classification.json\n\nOutput:\n JSON with input classification, recommended method, and base name.\n Follows workspace/classification.schema.json schema.\n\nI/O Contract:\n Input: File path (PPTX/MD/JSON/TXT) or URL\n Output: classification.json following classification.schema.json\n Exit codes:\n 0: Success\n 1: Unknown input type or validation failed\n 2: File not found\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nimport io\nimport re\nfrom pathlib import Path\nfrom datetime import datetime\nfrom typing import Optional, Dict, Any, Tuple\n\n# Fix Windows console encoding issues\nsys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')\nsys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')\n\n# Language detection thresholds\nENGLISH_RATIO_THRESHOLD = 0.7 # >= 70% English characters = English PPTX\n\n# Keyword mappings for base name generation\nKEYWORD_MAPPINGS = {\n # Japanese to English\n \"売上\": \"sales\",\n \"報告\": \"report\",\n \"進捗\": \"progress\",\n \"企画\": \"proposal\",\n \"提案\": \"proposal\",\n \"障害\": \"incident\",\n \"ブランチ\": \"branch\",\n \"ツール\": \"tool\",\n \"紹介\": \"intro\",\n \"新機能\": \"new_feature\",\n \"入門\": \"intro\",\n \"比較\": \"comparison\",\n \"やってみた\": \"trying\",\n \"まとめ\": \"summary\",\n \"解説\": \"explanation\",\n \"セキュリティ\": \"security\",\n \"データ\": \"data\",\n \"分析\": \"analysis\",\n \"設計\": \"design\",\n \"実装\": \"implementation\",\n}\n\n\ndef detect_language_ratio(text: str) -> float:\n \"\"\"\n Calculate the ratio of English characters in the text.\n \n Returns:\n Float between 0 and 1 representing English character ratio.\n \"\"\"\n if not text:\n return 0.0\n \n # Count ASCII letters vs total letters\n ascii_letters = sum(1 for c in text if c.isascii() and c.isalpha())\n total_letters = sum(1 for c in text if c.isalpha())\n \n if total_letters == 0:\n return 0.0\n \n return ascii_letters / total_letters\n\n\ndef detect_pptx_language(pptx_path: str) -> Tuple[str, float]:\n \"\"\"\n Detect the primary language of a PPTX file.\n \n Returns:\n Tuple of (language_code, confidence)\n language_code: 'en' for English, 'ja' for Japanese, 'unknown'\n \"\"\"\n try:\n from pptx import Presentation\n except ImportError:\n print(\"Warning: python-pptx not installed. Cannot detect PPTX language.\")\n return (\"unknown\", 0.0)\n \n prs = Presentation(pptx_path)\n all_text = []\n \n # Extract text from all slides\n for slide in prs.slides:\n for shape in slide.shapes:\n if hasattr(shape, \"text\") and shape.text:\n all_text.append(shape.text)\n \n combined_text = \" \".join(all_text)\n english_ratio = detect_language_ratio(combined_text)\n \n if english_ratio >= ENGLISH_RATIO_THRESHOLD:\n return (\"en\", english_ratio)\n elif english_ratio \u003c 0.3:\n return (\"ja\", 1.0 - english_ratio)\n else:\n return (\"mixed\", 0.5)\n\n\ndef extract_keyword_from_title(title: str) -> str:\n \"\"\"\n Extract English keyword from title for base name generation.\n \"\"\"\n # Check for known Japanese mappings\n for jp_word, en_word in KEYWORD_MAPPINGS.items():\n if jp_word in title:\n return en_word\n \n # Try to extract English words\n english_words = re.findall(r'[A-Za-z][a-z]+', title)\n if english_words:\n # Use first 2-3 significant words\n keywords = [w.lower() for w in english_words if len(w) > 2][:3]\n if keywords:\n return \"_\".join(keywords)\n \n # Fallback to generic\n return \"presentation\"\n\n\ndef detect_input_type(input_path: str) -> Dict[str, Any]:\n \"\"\"\n Detect the type of input and return classification.\n \n Args:\n input_path: File path or URL\n \n Returns:\n Dictionary with input_type, source, and metadata\n \"\"\"\n # URL detection\n if input_path.startswith(('http://', 'https://')):\n # Detect specific platforms\n if 'qiita.com' in input_path:\n return {\"input_type\": \"url\", \"platform\": \"qiita\", \"source\": input_path}\n elif 'zenn.dev' in input_path:\n return {\"input_type\": \"url\", \"platform\": \"zenn\", \"source\": input_path}\n elif 'github.com' in input_path or 'github.io' in input_path:\n return {\"input_type\": \"url\", \"platform\": \"github\", \"source\": input_path}\n elif 'microsoft.com' in input_path or 'azure.com' in input_path:\n return {\"input_type\": \"url\", \"platform\": \"microsoft\", \"source\": input_path}\n else:\n return {\"input_type\": \"url\", \"platform\": \"web\", \"source\": input_path}\n \n # File detection\n path = Path(input_path)\n if not path.exists():\n return {\"input_type\": \"unknown\", \"error\": f\"File not found: {input_path}\"}\n \n suffix = path.suffix.lower()\n \n if suffix == '.pptx':\n lang, confidence = detect_pptx_language(input_path)\n return {\n \"input_type\": \"pptx_en\" if lang == \"en\" else \"pptx_ja\" if lang == \"ja\" else \"pptx_mixed\",\n \"source_file\": str(path),\n \"language\": lang,\n \"language_confidence\": confidence,\n \"slide_count\": get_slide_count(input_path)\n }\n elif suffix == '.md':\n return {\"input_type\": \"markdown\", \"source_file\": str(path)}\n elif suffix == '.json':\n return {\"input_type\": \"json\", \"source_file\": str(path)}\n elif suffix in ['.txt', '.text']:\n return {\"input_type\": \"text\", \"source_file\": str(path)}\n elif suffix in ['.png', '.jpg', '.jpeg', '.gif', '.webp']:\n return {\"input_type\": \"image\", \"source_file\": str(path)}\n else:\n return {\"input_type\": \"unknown\", \"source_file\": str(path), \"extension\": suffix}\n\n\ndef get_slide_count(pptx_path: str) -> int:\n \"\"\"Get the number of slides in a PPTX file.\"\"\"\n try:\n from pptx import Presentation\n prs = Presentation(pptx_path)\n return len(prs.slides)\n except Exception:\n return 0\n\n\ndef determine_method(input_info: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"\n Determine the recommended processing method based on input classification.\n \n Returns:\n Dictionary with recommended_method, output_format, and reasoning\n \"\"\"\n input_type = input_info.get(\"input_type\", \"unknown\")\n \n if input_type == \"pptx_en\":\n return {\n \"recommended_method\": \"reconstruct\",\n \"output_format\": \"template\",\n \"reasoning\": \"English PPTX detected - use reconstruct method to preserve slide master\",\n \"workflow\": [\n \"analyze_template.py\",\n \"extract_images.py\", \n \"reconstruct_analyzer.py\",\n \"Localizer agent (translation)\",\n \"create_from_template.py\"\n ]\n }\n \n elif input_type == \"pptx_ja\":\n return {\n \"recommended_method\": \"analyze_only\",\n \"output_format\": \"template\",\n \"reasoning\": \"Japanese PPTX detected - may only need analysis or minor edits\",\n \"workflow\": [\n \"analyze_template.py\",\n \"reconstruct_analyzer.py\"\n ]\n }\n \n elif input_type == \"url\":\n platform = input_info.get(\"platform\", \"web\")\n return {\n \"recommended_method\": \"create_new\",\n \"output_format\": \"template\",\n \"reasoning\": f\"Web content from {platform} - create new PPTX from content\",\n \"workflow\": [\n \"fetch_article (curl/API)\",\n \"extract_images (download)\",\n \"create content.json\",\n \"create_from_template.py\"\n ]\n }\n \n elif input_type == \"markdown\":\n return {\n \"recommended_method\": \"create_new\",\n \"output_format\": \"template\",\n \"reasoning\": \"Markdown input - convert to PPTX\",\n \"workflow\": [\n \"parse markdown\",\n \"create content.json\",\n \"create_from_template.py\"\n ]\n }\n \n else:\n return {\n \"recommended_method\": \"manual\",\n \"output_format\": \"unknown\",\n \"reasoning\": f\"Unknown input type: {input_type}\",\n \"workflow\": []\n }\n\n\ndef generate_base_name(input_info: Dict[str, Any], purpose: str = \"report\") -> str:\n \"\"\"\n Generate base name following the convention: {YYYYMMDD}_{keyword}_{purpose}\n \"\"\"\n today = datetime.now().strftime(\"%Y%m%d\")\n \n # Extract keyword from source\n source = input_info.get(\"source_file\") or input_info.get(\"source\", \"\")\n \n if source:\n path = Path(source.split('?')[0]) # Remove URL query params\n name = path.stem\n \n # Clean up the name\n keyword = extract_keyword_from_title(name)\n else:\n keyword = \"presentation\"\n \n # Sanitize keyword (max 30 chars, only a-z, 0-9, _)\n keyword = re.sub(r'[^a-z0-9_]', '_', keyword.lower())\n keyword = re.sub(r'_+', '_', keyword).strip('_')[:30]\n \n return f\"{today}_{keyword}_{purpose}\"\n\n\ndef classify_input(input_path: str, purpose: str = \"report\") -> Dict[str, Any]:\n \"\"\"\n Main classification function.\n \n Returns complete context for Orchestrator.\n \"\"\"\n # Step 1: Detect input type\n input_info = detect_input_type(input_path)\n \n # Step 2: Determine method\n method_info = determine_method(input_info)\n \n # Step 3: Generate base name\n base_name = generate_base_name(input_info, purpose)\n \n # Step 4: Build context\n context = {\n **input_info,\n **method_info,\n \"base_name\": base_name,\n \"user_confirmation_required\": True,\n \"confidence\": input_info.get(\"language_confidence\", 0.8),\n }\n \n # Add confirmation prompt for PPTX\n if input_info.get(\"input_type\", \"\").startswith(\"pptx\"):\n slide_count = input_info.get(\"slide_count\", 0)\n context[\"confirmation_prompt\"] = f\"\"\"\n英語 PPTX を検出しました ({slide_count} スライド)\n\n処理方式を選択してください:\n1. template(最推奨): テンプレート継承で生成 ⭐⭐⭐⭐⭐\n2. python-pptx: シンプルできれい ⭐⭐⭐⭐\n3. pptxgenjs: コード向け ⭐⭐⭐⭐\n\"\"\"\n \n return context\n\n\ndef validate_classification(classification_path: str) -> bool:\n \"\"\"\n Validate a classification.json file against the schema.\n \n Returns True if valid, False otherwise.\n \"\"\"\n try:\n import jsonschema\n except ImportError:\n print(\"Warning: jsonschema not installed. Skipping validation.\")\n return True\n \n # Load schema\n schema_path = Path(__file__).parent.parent / \"workspace\" / \"classification.schema.json\"\n if not schema_path.exists():\n print(f\"Warning: Schema not found at {schema_path}\")\n return True\n \n with open(schema_path, \"r\", encoding=\"utf-8\") as f:\n schema = json.load(f)\n \n # Load classification\n with open(classification_path, \"r\", encoding=\"utf-8\") as f:\n classification = json.load(f)\n \n # Validate\n try:\n jsonschema.validate(instance=classification, schema=schema)\n print(f\"✅ Classification validation passed: {classification_path}\")\n return True\n except jsonschema.ValidationError as e:\n print(f\"❌ Classification validation failed: {e.message}\")\n print(f\" Path: {'.'.join(str(p) for p in e.absolute_path)}\")\n return False\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Classify input and determine processing method\"\n )\n parser.add_argument(\"input_path\", nargs=\"?\", help=\"Input file path or URL\")\n parser.add_argument(\"--output\", \"-o\", help=\"Output JSON file path\")\n parser.add_argument(\"--purpose\", \"-p\", default=\"report\",\n choices=[\"report\", \"lt\", \"incident\", \"blog\", \"custom\"],\n help=\"Purpose of the presentation\")\n parser.add_argument(\"--detect-language\", action=\"store_true\",\n help=\"Only detect language of PPTX\")\n parser.add_argument(\"--validate\", metavar=\"CLASSIFICATION_JSON\",\n help=\"Validate a classification.json file against schema\")\n \n args = parser.parse_args()\n \n # Validate mode\n if args.validate:\n valid = validate_classification(args.validate)\n sys.exit(0 if valid else 1)\n \n # Require input_path for other modes\n if not args.input_path:\n parser.error(\"input_path is required\")\n \n if args.detect_language:\n lang, conf = detect_pptx_language(args.input_path)\n print(f\"Language: {lang} (confidence: {conf:.2%})\")\n return\n \n # Classify input\n context = classify_input(args.input_path, args.purpose)\n \n # Output\n output_json = json.dumps(context, indent=2, ensure_ascii=False)\n \n if args.output:\n output_path = Path(args.output)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n with open(output_path, \"w\", encoding=\"utf-8\") as f:\n f.write(output_json)\n print(f\"Classification saved to: {args.output}\")\n \n # Validate output\n validate_classification(args.output)\n else:\n print(output_json)\n \n # Return appropriate exit code\n if context.get(\"input_type\") == \"unknown\":\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":14611,"content_sha256":"c9afaae7c29ead9a0e0a79f3da2393cad55e18019accb8bd0bc121de94de60e3"},{"filename":"scripts/clean_template.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nClean PPTX template by removing problematic elements.\n\nUsage:\n python scripts/clean_template.py \u003cinput.pptx> \u003coutput.pptx> [--keep-first-master] [--keep-metadata]\n\nRemoves / sanitizes:\n - Background images (PICTURE shapes) from slide masters\n - Blip references from Picture Placeholders in layouts\n - docProps/custom.xml (MIP labels, tenant IDs, SharePoint columns,\n colleague identities) and cp:lastModifiedBy / cp:revision in\n docProps/core.xml, so the check-in artifact does not carry the\n metadata that PowerPoint attaches when the file is opened from\n OneDrive/SharePoint. Use --keep-metadata only for tenant-internal\n private repos where the labels are intentional.\n - Optionally keeps only the first slide master\n\nThis prepares external templates (e.g., from Microsoft Ignite) for use\nwithout decorative background images interfering with generated content.\n\"\"\"\n\nfrom pptx import Presentation\nfrom lxml import etree\nimport sys\nimport zipfile\nfrom pathlib import Path\nimport shutil\n\n\ndef remove_master_background_images(prs) -> int:\n \"\"\"Remove PICTURE shapes from slide masters.\"\"\"\n removed = 0\n for master in prs.slide_masters:\n shapes_to_remove = []\n for shape in master.shapes:\n if 'PICTURE' in str(shape.shape_type):\n shapes_to_remove.append(shape)\n \n for shape in shapes_to_remove:\n try:\n sp = shape._element\n sp.getparent().remove(sp)\n removed += 1\n except Exception as e:\n print(f\" [!] Could not remove shape: {e}\")\n \n return removed\n\n\ndef remove_placeholder_blips(prs) -> int:\n \"\"\"Remove blip elements from Picture Placeholders in layouts.\"\"\"\n removed = 0\n ns = {\n 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',\n 'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'\n }\n \n for master in prs.slide_masters:\n for layout in master.slide_layouts:\n for shape in layout.shapes:\n if hasattr(shape, 'is_placeholder') and shape.is_placeholder:\n try:\n ph_type = str(shape.placeholder_format.type)\n if 'PICTURE' in ph_type:\n # Find and remove blip elements\n pic = shape._element\n blip_elements = pic.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/main}blip')\n for blip in blip_elements:\n blip.getparent().remove(blip)\n removed += 1\n except (ValueError, AttributeError):\n pass\n \n return removed\n\n\ndef sanitize_pptx_metadata(pptx_path: str) -> dict:\n \"\"\"Strip PII from docProps in a saved pptx (in-place).\n\n - docProps/custom.xml -> empty \u003cProperties/> element (removes MIP /\n MSIP labels, SharePoint columns, tenant IDs, colleague emails).\n - docProps/core.xml -> blank cp:lastModifiedBy and cp:revision.\n\n PowerPoint re-instruments docProps on every save from OneDrive /\n SharePoint by a labeled account, so this must run on the check-in\n artifact whenever the template is regenerated, not once per skill.\n \"\"\"\n EMPTY_CUSTOM = (\n b'\u003c?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\\n'\n b'\u003cProperties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/custom-properties\" '\n b'xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes\"/>'\n )\n CORE_NS = {\n 'cp': 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties',\n }\n\n stats = {'custom_stripped': False, 'core_neutralized': False}\n src = Path(pptx_path)\n tmp = src.with_suffix(src.suffix + '.sanitize-tmp')\n\n try:\n with zipfile.ZipFile(src, 'r') as zin, zipfile.ZipFile(\n tmp, 'w', compression=zipfile.ZIP_DEFLATED\n ) as zout:\n for item in zin.infolist():\n data = zin.read(item.filename)\n if item.filename == 'docProps/custom.xml':\n if data.strip() and data != EMPTY_CUSTOM:\n stats['custom_stripped'] = True\n data = EMPTY_CUSTOM\n elif item.filename == 'docProps/core.xml':\n try:\n root = etree.fromstring(data)\n changed = False\n for xp in ('cp:lastModifiedBy', 'cp:revision'):\n el = root.find(xp, CORE_NS)\n if el is not None and (el.text or '').strip():\n el.text = ''\n changed = True\n if changed:\n stats['core_neutralized'] = True\n data = etree.tostring(\n root, xml_declaration=True,\n encoding='UTF-8', standalone=True\n )\n except etree.XMLSyntaxError:\n pass\n zout.writestr(item, data)\n shutil.move(str(tmp), str(src))\n finally:\n if tmp.exists():\n try:\n tmp.unlink()\n except OSError:\n pass\n\n return stats\n\n\ndef clean_template(input_path: str, output_path: str, keep_first_master_only: bool = False, sanitize_metadata: bool = True) -> dict:\n \"\"\"\n Clean PPTX template and save to output path.\n \n Args:\n input_path: Path to input PPTX\n output_path: Path to output PPTX\n keep_first_master_only: If True, remove all but the first slide master\n sanitize_metadata: If True (default), strip docProps PII (MIP labels,\n SharePoint fields, cp:lastModifiedBy, cp:revision) after save.\n \n Returns:\n dict with cleaning statistics\n \"\"\"\n prs = Presentation(input_path)\n \n stats = {\n 'input': Path(input_path).name,\n 'output': Path(output_path).name,\n 'masters_before': len(prs.slide_masters),\n 'background_images_removed': 0,\n 'placeholder_blips_removed': 0,\n 'masters_removed': 0\n }\n \n print(f\"\\n🔧 Cleaning template: {input_path}\")\n print(f\" Masters: {stats['masters_before']}\")\n \n # Step 1: Remove background images from masters\n bg_removed = remove_master_background_images(prs)\n stats['background_images_removed'] = bg_removed\n print(f\" Removed {bg_removed} background image(s) from masters\")\n \n # Step 2: Remove blip references from placeholders\n blip_removed = remove_placeholder_blips(prs)\n stats['placeholder_blips_removed'] = blip_removed\n print(f\" Removed {blip_removed} blip reference(s) from placeholders\")\n \n # Step 3: Optionally remove extra masters (not fully implemented - complex)\n # This would require careful handling of slide relationships\n if keep_first_master_only and len(prs.slide_masters) > 1:\n print(f\" [i] Note: --keep-first-master not fully implemented yet\")\n \n # Save cleaned template\n prs.save(output_path)\n \n total_removed = bg_removed + blip_removed\n stats['total_removed'] = total_removed\n \n print(f\"\\n✅ Saved clean template: {output_path}\")\n print(f\" Total elements removed: {total_removed}\")\n\n # Strip PII from docProps unless the caller opted out\n if sanitize_metadata:\n meta = sanitize_pptx_metadata(output_path)\n stats['metadata_custom_stripped'] = meta['custom_stripped']\n stats['metadata_core_neutralized'] = meta['core_neutralized']\n if meta['custom_stripped'] or meta['core_neutralized']:\n print(\n f\" Sanitized docProps: custom.xml={meta['custom_stripped']}, \"\n f\"core.xml={meta['core_neutralized']}\"\n )\n\n return stats\n\n\ndef main():\n if len(sys.argv) \u003c 3:\n print(\"Usage: python scripts/clean_template.py \u003cinput.pptx> \u003coutput.pptx> [--keep-first-master] [--keep-metadata]\")\n print()\n print(\"Options:\")\n print(\" --keep-first-master Remove all but the first slide master (experimental)\")\n print(\" --keep-metadata Keep docProps (MIP labels, cp:lastModifiedBy, etc.).\")\n print(\" Default: strip PII. Use only for tenant-internal repos.\")\n sys.exit(1)\n \n input_path = sys.argv[1]\n output_path = sys.argv[2]\n keep_first_master = '--keep-first-master' in sys.argv\n sanitize_metadata = '--keep-metadata' not in sys.argv\n \n if not Path(input_path).exists():\n print(f\"Error: File not found: {input_path}\")\n sys.exit(1)\n \n # Create output directory if needed\n output_dir = Path(output_path).parent\n output_dir.mkdir(parents=True, exist_ok=True)\n \n stats = clean_template(input_path, output_path, keep_first_master, sanitize_metadata)\n \n # Exit code: 0 on success\n sys.exit(0)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9484,"content_sha256":"07904d04aecfe73f657d5fca11094e9bde72bdb9f7248d9b92ef2b63e8f94fcf"},{"filename":"scripts/create_clean_template.py","content":"# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n#\n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nCreate a clean template from an existing PPTX file.\n\nThis script extracts the slide master and layouts from a source PPTX,\nremoves problematic elements, and creates a clean template for reuse.\n\nUsage:\n python scripts/create_clean_template.py \u003csource.pptx> \u003coutput_template.pptx> [options]\n\nOptions:\n --remove-backgrounds Remove background images from layouts\n --fix-placeholders Adjust placeholder positions for consistency\n --remove-decorations Remove decorative shapes (rectangles, lines, etc.)\n --analyze Analyze template without creating (dry run)\n --all Apply all fixes (default)\n\nExample:\n python scripts/create_clean_template.py input/BRK252.pptx templates/brk252_clean.pptx --all\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nfrom pathlib import Path\nfrom typing import Optional, List, Dict, Any\nfrom copy import deepcopy\n\ntry:\n from pptx import Presentation\n from pptx.util import Inches, Pt\n from pptx.enum.shapes import MSO_SHAPE_TYPE, PP_PLACEHOLDER\n from pptx.dml.color import RGBColor\n from lxml import etree\nexcept ImportError as e:\n print(f\"Error: Required package not found: {e}\")\n print(\"Install with: pip install python-pptx lxml\")\n sys.exit(1)\n\n\ndef analyze_layout(layout, slide_height: float) -> Dict[str, Any]:\n \"\"\"Analyze a single layout for potential issues.\"\"\"\n issues = []\n info = {\n \"name\": layout.name,\n \"placeholders\": [],\n \"background_images\": [],\n \"decorative_shapes\": [],\n \"issues\": issues\n }\n \n # Check placeholders\n for ph in layout.placeholders:\n ph_info = {\n \"idx\": ph.placeholder_format.idx,\n \"type\": str(ph.placeholder_format.type),\n \"left\": ph.left.inches,\n \"top\": ph.top.inches,\n \"width\": ph.width.inches,\n \"height\": ph.height.inches,\n \"top_percent\": ph.top.inches / slide_height * 100\n }\n info[\"placeholders\"].append(ph_info)\n \n # Check for problematic positioning\n if ph_info[\"top_percent\"] > 70:\n issues.append(f\"Placeholder {ph_info['type']} is positioned very low ({ph_info['top_percent']:.0f}%)\")\n if ph_info[\"width\"] \u003c 5 and \"TITLE\" in ph_info[\"type\"]:\n issues.append(f\"Title placeholder is narrow ({ph_info['width']:.1f} inches)\")\n \n # Check non-placeholder shapes\n for shape in layout.shapes:\n if shape.is_placeholder:\n continue\n \n shape_info = {\n \"name\": shape.name,\n \"type\": str(shape.shape_type),\n \"left\": shape.left.inches,\n \"width\": shape.width.inches\n }\n \n # Background images\n if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:\n info[\"background_images\"].append(shape_info)\n issues.append(f\"Background image found: {shape.name}\")\n \n # Decorative shapes (rectangles, lines at edges)\n elif shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE:\n if shape.left.inches \u003c 0.5 or shape.width.inches > 10:\n info[\"decorative_shapes\"].append(shape_info)\n issues.append(f\"Decorative shape at edge: {shape.name}\")\n \n return info\n\n\ndef analyze_template(prs: Presentation) -> Dict[str, Any]:\n \"\"\"Analyze entire template for issues.\"\"\"\n slide_height = prs.slide_height.inches\n slide_width = prs.slide_width.inches\n \n analysis = {\n \"slide_size\": f\"{slide_width:.2f} x {slide_height:.2f} inches\",\n \"layout_count\": len(prs.slide_layouts),\n \"layouts\": [],\n \"summary\": {\n \"total_issues\": 0,\n \"layouts_with_background_images\": 0,\n \"layouts_with_decorations\": 0,\n \"layouts_with_position_issues\": 0\n }\n }\n \n for i, layout in enumerate(prs.slide_layouts):\n layout_info = analyze_layout(layout, slide_height)\n layout_info[\"index\"] = i\n analysis[\"layouts\"].append(layout_info)\n \n # Update summary\n if layout_info[\"background_images\"]:\n analysis[\"summary\"][\"layouts_with_background_images\"] += 1\n if layout_info[\"decorative_shapes\"]:\n analysis[\"summary\"][\"layouts_with_decorations\"] += 1\n if any(\"position\" in issue.lower() for issue in layout_info[\"issues\"]):\n analysis[\"summary\"][\"layouts_with_position_issues\"] += 1\n analysis[\"summary\"][\"total_issues\"] += len(layout_info[\"issues\"])\n \n return analysis\n\n\ndef remove_background_images(prs: Presentation) -> int:\n \"\"\"Remove background images from all layouts.\"\"\"\n removed_count = 0\n \n for layout in prs.slide_layouts:\n shapes_to_remove = []\n for shape in layout.shapes:\n if not shape.is_placeholder and shape.shape_type == MSO_SHAPE_TYPE.PICTURE:\n shapes_to_remove.append(shape)\n \n for shape in shapes_to_remove:\n sp = shape._element\n sp.getparent().remove(sp)\n removed_count += 1\n \n # Also check slide masters\n for master in prs.slide_masters:\n shapes_to_remove = []\n for shape in master.shapes:\n if not shape.is_placeholder and shape.shape_type == MSO_SHAPE_TYPE.PICTURE:\n shapes_to_remove.append(shape)\n \n for shape in shapes_to_remove:\n sp = shape._element\n sp.getparent().remove(sp)\n removed_count += 1\n \n return removed_count\n\n\ndef remove_decorative_shapes(prs: Presentation) -> int:\n \"\"\"Remove decorative shapes (edge rectangles, lines, out-of-bounds textboxes, etc.).\"\"\"\n removed_count = 0\n slide_width = prs.slide_width.inches\n \n for layout in prs.slide_layouts:\n shapes_to_remove = []\n for shape in layout.shapes:\n if shape.is_placeholder:\n continue\n \n # Remove shapes outside slide bounds (causes vertical text issues)\n if shape.left.inches > slide_width:\n shapes_to_remove.append(shape)\n continue\n \n # Remove textboxes at edges that might cause issues\n if shape.shape_type == MSO_SHAPE_TYPE.TEXT_BOX:\n # TextBox outside visible area\n if shape.left.inches > slide_width * 0.95:\n shapes_to_remove.append(shape)\n continue\n # Very narrow textbox at left edge (vertical text issue)\n if shape.left.inches \u003c 0.3 and shape.width.inches \u003c 0.5:\n shapes_to_remove.append(shape)\n continue\n \n # Remove decorative auto-shapes at edges\n if shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE:\n # Left edge decorations (vertical bars, etc.)\n if shape.left.inches \u003c 0.3 and shape.width.inches \u003c 1.0:\n shapes_to_remove.append(shape)\n # Full-width decorations\n elif shape.width.inches > slide_width * 0.9:\n shapes_to_remove.append(shape)\n \n for shape in shapes_to_remove:\n sp = shape._element\n sp.getparent().remove(sp)\n removed_count += 1\n \n return removed_count\n\n\ndef has_dark_background(layout) -> bool:\n \"\"\"Check if a layout has a dark background (image or dark fill).\"\"\"\n for shape in layout.shapes:\n if not shape.is_placeholder:\n # Check for background images (usually dark in presentation templates)\n if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:\n return True\n \n # Check background fill\n try:\n bg = layout.background\n if bg.fill.type is not None:\n # Check if it's a solid dark fill\n if hasattr(bg.fill, 'fore_color') and bg.fill.fore_color.rgb:\n rgb = bg.fill.fore_color.rgb\n # Calculate luminance (simple approximation)\n luminance = (int(str(rgb)[0:2], 16) * 0.299 + \n int(str(rgb)[2:4], 16) * 0.587 + \n int(str(rgb)[4:6], 16) * 0.114)\n if luminance \u003c 128:\n return True\n except:\n pass\n \n return False\n\n\ndef fix_text_contrast(prs: Presentation) -> int:\n \"\"\"Fix text colors for better contrast on dark/light backgrounds.\"\"\"\n fixed_count = 0\n \n for layout in prs.slide_layouts:\n is_dark = has_dark_background(layout)\n \n if is_dark:\n # Dark background: ensure text is light colored\n for ph in layout.placeholders:\n if ph.has_text_frame:\n for para in ph.text_frame.paragraphs:\n # Check if font color is dark (needs fixing)\n try:\n if para.font.color.rgb:\n rgb = para.font.color.rgb\n luminance = (int(str(rgb)[0:2], 16) * 0.299 + \n int(str(rgb)[2:4], 16) * 0.587 + \n int(str(rgb)[4:6], 16) * 0.114)\n if luminance \u003c 180: # Dark text on dark background\n para.font.color.rgb = RGBColor(255, 255, 255)\n fixed_count += 1\n except:\n pass\n \n return fixed_count\n\n\ndef fix_placeholder_positions(prs: Presentation) -> int:\n \"\"\"Adjust placeholder positions for better layout.\n \n Conservative approach: only fix clearly problematic positions,\n don't change layouts that might be intentionally designed.\n \"\"\"\n fixed_count = 0\n slide_height = prs.slide_height.inches\n slide_width = prs.slide_width.inches\n \n for layout in prs.slide_layouts:\n layout_name = layout.name.lower()\n is_section_layout = 'section' in layout_name\n is_title_layout = layout_name.startswith('title') and 'only' not in layout_name\n \n for ph in layout.placeholders:\n ph_type = ph.placeholder_format.type\n \n # Fix title placeholders - ONLY for section/title layouts\n if ph_type in [PP_PLACEHOLDER.TITLE, PP_PLACEHOLDER.CENTER_TITLE]:\n # Only adjust section slide titles that are positioned oddly\n if is_section_layout:\n top_percent = ph.top.inches / slide_height\n # Only fix if really out of range (\u003c 10% or > 60%)\n if top_percent \u003c 0.10 or top_percent > 0.60:\n # IMPORTANT: Preserve width/height when adjusting top. See TROUBLESHOOTING.md #48.\n original_width = ph.width\n original_height = ph.height\n ph.top = Inches(slide_height * 0.35)\n ph.width = original_width\n ph.height = original_height\n fixed_count += 1\n \n # For title slides, ensure title is not too narrow\n if is_title_layout:\n if ph.width.inches \u003c slide_width * 0.5:\n ph.width = Inches(slide_width * 0.75)\n # Center the title\n ph.left = Inches((slide_width - ph.width.inches) / 2)\n fixed_count += 1\n \n # Fix subtitle placeholders - only if really far from title\n elif ph_type == PP_PLACEHOLDER.SUBTITLE:\n if ph.top.inches > slide_height * 0.7:\n # IMPORTANT: Preserve width/height when adjusting top. See TROUBLESHOOTING.md #48.\n original_width = ph.width\n original_height = ph.height\n ph.top = Inches(slide_height * 0.55)\n ph.width = original_width\n ph.height = original_height\n fixed_count += 1\n \n # Fix body/content placeholders - only negative positions\n elif ph_type in [PP_PLACEHOLDER.BODY, PP_PLACEHOLDER.OBJECT]:\n if ph.left.inches \u003c 0:\n ph.left = Inches(0.5)\n fixed_count += 1\n \n return fixed_count\n\n\ndef create_clean_template(\n source_path: str,\n output_path: str,\n remove_backgrounds: bool = False, # ★ Keep backgrounds by default\n remove_decorations: bool = True,\n fix_positions: bool = True,\n fix_contrast: bool = True # ★ NEW: Fix text contrast\n) -> Dict[str, Any]:\n \"\"\"Create a clean template from source PPTX.\"\"\"\n \n print(f\"Loading source: {source_path}\")\n prs = Presentation(source_path)\n \n results = {\n \"source\": source_path,\n \"output\": output_path,\n \"slide_size\": f\"{prs.slide_width.inches:.2f} x {prs.slide_height.inches:.2f}\",\n \"original_layouts\": len(prs.slide_layouts),\n \"actions\": []\n }\n \n # Remove existing slides (keep only layouts)\n while len(prs.slides) > 0:\n rId = prs.slides._sldIdLst[0].rId\n prs.part.drop_rel(rId)\n del prs.slides._sldIdLst[0]\n results[\"actions\"].append(\"Removed all existing slides\")\n \n # Remove background images\n if remove_backgrounds:\n count = remove_background_images(prs)\n if count > 0:\n results[\"actions\"].append(f\"Removed {count} background images\")\n \n # Remove decorative shapes\n if remove_decorations:\n count = remove_decorative_shapes(prs)\n if count > 0:\n results[\"actions\"].append(f\"Removed {count} decorative shapes\")\n \n # Fix placeholder positions\n if fix_positions:\n count = fix_placeholder_positions(prs)\n if count > 0:\n results[\"actions\"].append(f\"Fixed {count} placeholder positions\")\n \n # Fix text contrast for dark backgrounds\n if fix_contrast:\n count = fix_text_contrast(prs)\n if count > 0:\n results[\"actions\"].append(f\"Fixed {count} text colors for better contrast\")\n \n # Save the clean template\n output_dir = Path(output_path).parent\n output_dir.mkdir(parents=True, exist_ok=True)\n prs.save(output_path)\n \n results[\"success\"] = True\n print(f\"\\n✅ Created clean template: {output_path}\")\n \n return results\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Create a clean template from an existing PPTX file.',\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n # Analyze template without changes\n python scripts/create_clean_template.py input/BRK252.pptx --analyze\n\n # Create clean template with all fixes\n python scripts/create_clean_template.py input/BRK252.pptx templates/brk252_clean.pptx --all\n\n # Create template with specific fixes\n python scripts/create_clean_template.py input/BRK252.pptx templates/out.pptx --remove-backgrounds\n \"\"\"\n )\n \n parser.add_argument('source', help='Source PPTX file')\n parser.add_argument('output', nargs='?', help='Output template path')\n parser.add_argument('--analyze', action='store_true', help='Analyze only (dry run)')\n parser.add_argument('--remove-backgrounds', action='store_true', help='Remove background images')\n parser.add_argument('--remove-decorations', action='store_true', help='Remove decorative shapes')\n parser.add_argument('--fix-placeholders', action='store_true', help='Fix placeholder positions')\n parser.add_argument('--all', action='store_true', help='Apply all fixes (default)')\n parser.add_argument('--json', action='store_true', help='Output analysis as JSON')\n \n args = parser.parse_args()\n \n # Check source exists\n if not Path(args.source).exists():\n print(f\"Error: Source file not found: {args.source}\")\n sys.exit(1)\n \n # Analyze mode\n if args.analyze:\n prs = Presentation(args.source)\n analysis = analyze_template(prs)\n \n if args.json:\n print(json.dumps(analysis, indent=2, ensure_ascii=False))\n else:\n print(f\"\\n{'='*60}\")\n print(f\"Template Analysis: {args.source}\")\n print(f\"{'='*60}\")\n print(f\"Slide size: {analysis['slide_size']}\")\n print(f\"Total layouts: {analysis['layout_count']}\")\n print(f\"\\nSummary:\")\n print(f\" Total issues: {analysis['summary']['total_issues']}\")\n print(f\" Layouts with background images: {analysis['summary']['layouts_with_background_images']}\")\n print(f\" Layouts with decorations: {analysis['summary']['layouts_with_decorations']}\")\n print(f\" Layouts with position issues: {analysis['summary']['layouts_with_position_issues']}\")\n \n # Show problematic layouts\n print(f\"\\nProblematic layouts:\")\n for layout in analysis['layouts']:\n if layout['issues']:\n print(f\"\\n [{layout['index']}] {layout['name']}:\")\n for issue in layout['issues']:\n print(f\" ⚠️ {issue}\")\n \n sys.exit(0)\n \n # Create mode - require output path\n if not args.output:\n print(\"Error: Output path required (or use --analyze)\")\n sys.exit(1)\n \n # Determine which fixes to apply\n if args.all:\n # --all: apply decorations removal and position fixes (NOT backgrounds)\n remove_backgrounds = False # ★ Keep backgrounds by default\n remove_decorations = True\n fix_positions = True\n elif not (args.remove_backgrounds or args.remove_decorations or args.fix_placeholders):\n # No specific options: apply safe defaults\n remove_backgrounds = False\n remove_decorations = True\n fix_positions = True\n else:\n remove_backgrounds = args.remove_backgrounds\n remove_decorations = args.remove_decorations\n fix_positions = args.fix_placeholders\n \n # Create clean template\n results = create_clean_template(\n args.source,\n args.output,\n remove_backgrounds=remove_backgrounds,\n remove_decorations=remove_decorations,\n fix_positions=fix_positions\n )\n \n # Print results\n print(f\"\\nActions performed:\")\n for action in results['actions']:\n print(f\" ✓ {action}\")\n \n print(f\"\\nNext steps:\")\n print(f\" 1. Analyze layouts: python scripts/analyze_template.py {args.output}\")\n print(f\" 2. Generate PPTX: python scripts/create_from_template.py {args.output} content.json output.pptx\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":19038,"content_sha256":"168e8ef6a9617057b0729edcb0833d5da35e24ce69c35e89afaf48880905874f"},{"filename":"scripts/create_from_template.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Agent-Skills/powerpoint-automation - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Agent-Skills/tree/main/powerpoint-automation\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nCreate PPTX from template using slide master layouts.\nInherits design (colors, fonts, backgrounds) from template.\n\nUsage:\n python scripts/create_from_template.py \u003ctemplate.pptx> \u003ccontent.json> \u003coutput.pptx> [--config \u003clayouts.json>]\n python scripts/create_from_template.py \u003ctemplate.pptx> --list-layouts\n\nOptions:\n --config \u003clayouts.json> Use pre-analyzed layout mapping file\n (Generated by: python scripts/analyze_template.py \u003ctemplate.pptx>)\n --no-signature Disable auto-signature in speaker notes\n\nSupports image embedding:\n \"image\": {\"path\": \"images/foo.png\", \"position\": \"right\", \"width_percent\": 45}\n \"image\": {\"url\": \"https://...\", \"position\": \"bottom\", \"height_percent\": 50}\n\nAuto-fixes applied:\n - Section slides: title/subtitle overlap detection and dynamic repositioning\n - Section slides: title position preserved unless out of range (20%-60%)\n - Section slides: subtitle font size 24pt (larger for readability)\n - Section slides: title width extended to prevent text wrapping\n - Two Column layouts: empty placeholders removed automatically\n - Images: size capped to 150% of natural size to prevent blurry enlargement\n - Images: height limited to prevent overflow on center-positioned images\n\nExamples:\n # Analyze template first (recommended):\n python scripts/analyze_template.py templates/sample-ppf.pptx\n\n # Then use the generated config:\n python scripts/create_from_template.py templates/sample-ppf.pptx content.json output.pptx --config output_manifest/sample-ppf_layouts.json\n\n # Or let the script auto-detect layouts:\n python scripts/create_from_template.py templates/sample.pptx content.json output.pptx\n\"\"\"\n\nfrom pptx import Presentation\nfrom pptx.util import Inches, Pt\nfrom pptx.enum.text import PP_ALIGN\nfrom pptx.dml.color import RGBColor\nfrom pptx.oxml.ns import qn\nfrom lxml import etree\nimport json\nimport sys\nimport argparse\nimport urllib.request\nimport tempfile\nimport subprocess\nfrom pathlib import Path\nfrom typing import Optional\n\ntry:\n from PIL import Image\n PIL_AVAILABLE = True\nexcept ImportError:\n PIL_AVAILABLE = False\n\n# Base directory for resolving relative image paths (set in create_pptx_from_template)\nBASE_DIR: Optional[Path] = None\n\n# Repository signature\nREPO_URL = \"https://github.com/aktsmm/Agent-Skills/tree/main/powerpoint-automation\"\nSIGNATURE_FIRST = f\"📌 Generated by: {REPO_URL}\"\nSIGNATURE_LAST = f\"---\\n🔧 This presentation was created using Agent-Skills/powerpoint-automation\\n{REPO_URL}\"\n\n# Text colors for light/dark backgrounds\nTEXT_COLOR_LIGHT_BG = RGBColor(51, 51, 51) # Dark gray for light backgrounds\nTEXT_COLOR_DARK_BG = RGBColor(255, 255, 255) # White for dark backgrounds\n\n\ndef is_dark_color(rgb_color) -> bool:\n \"\"\"\n Determine if a color is dark (needs white text).\n Uses luminance formula: 0.299*R + 0.587*G + 0.114*B\n \n Args:\n rgb_color: Can be RGBColor, tuple (r,g,b), or hex string\n \n Returns:\n True if color is dark (luminance \u003c 128)\n \"\"\"\n try:\n if rgb_color is None:\n return False\n \n if hasattr(rgb_color, '__iter__') and len(rgb_color) == 3:\n r, g, b = rgb_color\n elif isinstance(rgb_color, str):\n hex_color = rgb_color.replace('#', '')\n r = int(hex_color[0:2], 16)\n g = int(hex_color[2:4], 16)\n b = int(hex_color[4:6], 16)\n elif hasattr(rgb_color, '__str__'):\n # RGBColor has format like 'RRGGBB'\n hex_str = str(rgb_color)\n if len(hex_str) == 6:\n r = int(hex_str[0:2], 16)\n g = int(hex_str[2:4], 16)\n b = int(hex_str[4:6], 16)\n else:\n return False\n else:\n return False\n \n luminance = 0.299 * r + 0.587 * g + 0.114 * b\n return luminance \u003c 128\n except Exception:\n return False\n\n\ndef get_layout_background_color(slide):\n \"\"\"\n Get the effective background color of a slide's layout.\n Checks slide, layout, and master backgrounds.\n \n Returns:\n RGBColor or None\n \"\"\"\n try:\n # Check slide background\n if hasattr(slide, 'background') and slide.background.fill:\n fill = slide.background.fill\n if fill.type is not None and hasattr(fill, 'fore_color') and fill.fore_color:\n try:\n return fill.fore_color.rgb\n except Exception:\n pass\n \n # Check layout background\n layout = slide.slide_layout\n if hasattr(layout, 'background') and layout.background.fill:\n fill = layout.background.fill\n if fill.type is not None and hasattr(fill, 'fore_color') and fill.fore_color:\n try:\n return fill.fore_color.rgb\n except Exception:\n pass\n \n # Check master background\n master = layout.slide_master\n if hasattr(master, 'background') and master.background.fill:\n fill = master.background.fill\n if fill.type is not None and hasattr(fill, 'fore_color') and fill.fore_color:\n try:\n return fill.fore_color.rgb\n except Exception:\n pass\n except Exception:\n pass\n \n return None\n\n\ndef has_dark_background(slide, slide_data: dict = None) -> bool:\n \"\"\"\n Determine if a slide has a dark background.\n Checks: explicit flag, layout background color, layout name hints.\n \n Args:\n slide: The PPTX slide object\n slide_data: Optional slide data dict with dark_background flag\n \n Returns:\n True if slide has dark background\n \"\"\"\n # Check explicit flag in content.json\n if slide_data:\n if slide_data.get('dark_background') or slide_data.get('darkBackground'):\n return True\n \n # Check layout background color\n bg_color = get_layout_background_color(slide)\n if bg_color and is_dark_color(bg_color):\n return True\n \n # Check layout name for hints (common dark layout names)\n try:\n layout_name = slide.slide_layout.name.lower()\n dark_hints = ['dark', 'black', 'navy', 'section', 'セクション']\n for hint in dark_hints:\n if hint in layout_name:\n return True\n except Exception:\n pass\n \n return False\n\n\ndef get_text_color_for_slide(slide, slide_data: dict = None) -> RGBColor:\n \"\"\"\n Get appropriate text color based on slide background.\n \n Returns:\n White for dark backgrounds, dark gray for light backgrounds\n \"\"\"\n if has_dark_background(slide, slide_data):\n return TEXT_COLOR_DARK_BG\n return TEXT_COLOR_LIGHT_BG\n\n\ndef get_repo_info() -> str:\n \"\"\"Get repository URL from git remote (fallback to default).\"\"\"\n try:\n result = subprocess.run(\n ['git', 'remote', 'get-url', 'origin'],\n capture_output=True, text=True, timeout=5\n )\n if result.returncode == 0:\n return result.stdout.strip()\n except Exception:\n pass\n return REPO_URL\n\n\ndef _normalize_view_settings(pptx_path: str) -> None:\n \"\"\"\n Normalize PPTX view settings to ensure it opens in Normal view.\n \n Fixes: Template saved with Slide Master view (lastView=\"sldMasterView\")\n will cause generated PPTX to open in Master view.\n \n This function modifies viewProps.xml inside the PPTX ZIP to set\n lastView=\"sldView\" (Normal view).\n \"\"\"\n import zipfile\n import shutil\n import os\n \n temp_path = pptx_path + '.tmp'\n modified = False\n \n try:\n with zipfile.ZipFile(pptx_path, 'r') as z_in:\n with zipfile.ZipFile(temp_path, 'w', zipfile.ZIP_DEFLATED) as z_out:\n for item in z_in.namelist():\n content = z_in.read(item)\n if item == 'ppt/viewProps.xml':\n # Replace any non-normal view setting with sldView\n original = content\n content = content.replace(b'lastView=\"sldMasterView\"', b'lastView=\"sldView\"')\n content = content.replace(b'lastView=\"sldSorterView\"', b'lastView=\"sldView\"')\n content = content.replace(b'lastView=\"notesView\"', b'lastView=\"sldView\"')\n content = content.replace(b'lastView=\"outlineView\"', b'lastView=\"sldView\"')\n if content != original:\n modified = True\n z_out.writestr(item, content)\n \n # Replace original with modified file\n os.remove(pptx_path)\n shutil.move(temp_path, pptx_path)\n \n if modified:\n print(\" 🔧 View settings normalized to Normal view\")\n except Exception as e:\n # Clean up temp file if it exists\n if os.path.exists(temp_path):\n os.remove(temp_path)\n # Silently continue - view settings are not critical\n\n\ndef list_layouts(template_path: str) -> None:\n \"\"\"List all slide layouts in the template with their placeholders.\"\"\"\n prs = Presentation(template_path)\n \n print(f\"\\n{'='*60}\")\n print(f\"Template: {template_path}\")\n print(f\"Slide Size: {prs.slide_width.inches:.2f}\\\" x {prs.slide_height.inches:.2f}\\\"\")\n print(f\"{'='*60}\\n\")\n \n print(f\"Found {len(prs.slide_masters)} slide master(s)\\n\")\n \n for master_idx, master in enumerate(prs.slide_masters):\n print(f\"━━━ Slide Master {master_idx} ━━━\")\n if master.name:\n print(f\" Name: {master.name}\")\n print(f\" Layouts: {len(master.slide_layouts)}\")\n print()\n \n for layout_idx, layout in enumerate(master.slide_layouts):\n print(f\" [{layout_idx}] {layout.name or '(unnamed)'}\")\n \n # Show placeholders\n placeholders = []\n for shape in layout.placeholders:\n ph_type = str(shape.placeholder_format.type).replace('PLACEHOLDER_TYPE.', '')\n placeholders.append(f\"{shape.placeholder_format.idx}:{ph_type}\")\n \n if placeholders:\n print(f\" Placeholders: {', '.join(placeholders)}\")\n else:\n print(f\" Placeholders: (none - blank layout)\")\n print()\n \n # Show existing slides\n if prs.slides:\n print(f\"━━━ Existing Slides ({len(prs.slides)}) ━━━\")\n for slide_idx, slide in enumerate(prs.slides):\n layout_name = slide.slide_layout.name or '(unnamed)'\n print(f\" Slide {slide_idx + 1}: Layout = {layout_name}\")\n print()\n \n print(\"Usage tip:\")\n print(' In content.json, specify \"layout\": \u003cindex> to use a specific layout.')\n print(' Use \"layout\": 0 for title slides, \"layout\": 1 for content slides (typical).')\n print()\n\n\ndef find_placeholder(slide, ph_type_names: list):\n \"\"\"Find a placeholder by type name(s).\"\"\"\n for shape in slide.placeholders:\n ph_type = str(shape.placeholder_format.type)\n for name in ph_type_names:\n if name in ph_type:\n return shape\n return None\n\n\ndef resolve_image_path(image_config: dict, content_path: str) -> Optional[str]:\n \"\"\"\n Resolve image path from config. Supports local path or URL.\n Returns the resolved local file path (downloads URL if needed).\n Also tries alternate extensions (.png, .jpg, .jpeg) if exact match not found.\n \"\"\"\n if 'url' in image_config:\n # Download from URL to temp file\n url = image_config['url']\n try:\n suffix = Path(url.split('?')[0]).suffix or '.png'\n with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:\n print(f\" 📥 Downloading image: {url[:50]}...\")\n urllib.request.urlretrieve(url, tmp.name)\n return tmp.name\n except Exception as e:\n print(f\" [!] Failed to download image: {e}\")\n return None\n elif 'path' in image_config:\n # Resolve local path\n path = image_config['path']\n base_dir = Path(content_path).parent.parent if content_path else Path('.')\n \n # Try exact path first\n candidates = [\n base_dir / path,\n Path(path),\n Path(content_path).parent / path if content_path else None,\n ]\n for candidate in candidates:\n if candidate and candidate.exists():\n return str(candidate)\n \n # Try alternate extensions (.png, .jpg, .jpeg)\n orig_path = Path(path)\n alt_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp']\n for ext in alt_extensions:\n if ext == orig_path.suffix:\n continue # Skip same extension\n alt_path = orig_path.with_suffix(ext)\n alt_candidates = [\n base_dir / alt_path,\n Path(alt_path),\n ]\n for candidate in alt_candidates:\n if candidate and candidate.exists():\n return str(candidate)\n \n print(f\" [!] Image not found: {path}\")\n return None\n return None\n\n\ndef get_image_size(image_path: str) -> tuple:\n \"\"\"\n Get image dimensions in pixels.\n Returns (width, height) or (None, None) if unable to read.\n \"\"\"\n if not PIL_AVAILABLE:\n return (None, None)\n try:\n with Image.open(image_path) as img:\n return img.size # (width, height)\n except Exception:\n return (None, None)\n\n\ndef calculate_max_width_percent(image_path: str, slide_width_inches: float = 13.333) -> int:\n \"\"\"\n Calculate maximum width_percent based on original image size.\n Prevents small images from being stretched too large.\n \n Returns adjusted max width_percent (10-100).\n \"\"\"\n img_width, img_height = get_image_size(image_path)\n if img_width is None:\n return 100 # No limit if can't read\n \n # Assume 96 DPI for typical screen images\n dpi = 96\n img_width_inches = img_width / dpi\n \n # Calculate what percentage of slide width the image naturally fills\n natural_pct = (img_width_inches / slide_width_inches) * 100\n \n # Allow up to 1.5x natural size (150% scale), but cap at 100%\n max_pct = min(int(natural_pct * 1.5), 100)\n \n # Minimum 10% to avoid tiny images\n return max(max_pct, 10)\n\n\ndef remove_empty_picture_placeholders(slide) -> int:\n \"\"\"\n Remove empty Picture Placeholders from slide.\n These show \"image cannot be displayed\" if left empty after adding a separate image.\n \n Returns:\n Number of placeholders removed\n \"\"\"\n removed = 0\n shapes_to_remove = []\n \n for shape in slide.shapes:\n # Check if it's a Picture Placeholder without actual image content\n # Use shape.is_placeholder to safely check\n if hasattr(shape, 'is_placeholder') and shape.is_placeholder:\n try:\n ph_type = str(shape.placeholder_format.type)\n if 'PICTURE' in ph_type:\n # Check if placeholder has no image (blip)\n from lxml import etree\n xml = etree.tostring(shape._element, encoding='unicode')\n if 'blip' not in xml or 'r:embed' not in xml:\n shapes_to_remove.append(shape)\n except (ValueError, AttributeError):\n # Not a valid placeholder, skip\n pass\n except Exception:\n pass\n \n for shape in shapes_to_remove:\n try:\n sp = shape._element\n sp.getparent().remove(sp)\n removed += 1\n except Exception as e:\n print(f\" [!] Could not remove placeholder: {e}\")\n \n return removed\n\n\ndef remove_empty_body_placeholders(slide) -> int:\n \"\"\"\n Remove empty Body/Content Placeholders from slide.\n These cause excess whitespace when layout has more placeholders than content.\n \n Returns:\n Number of placeholders removed\n \"\"\"\n removed = 0\n shapes_to_remove = []\n \n for shape in slide.shapes:\n if hasattr(shape, 'is_placeholder') and shape.is_placeholder:\n try:\n ph_type = str(shape.placeholder_format.type)\n # Target BODY, OBJECT, CONTENT placeholders\n if any(t in ph_type for t in ['BODY', 'OBJECT', 'CONTENT']):\n # Check if text frame is empty or has only whitespace\n if hasattr(shape, 'has_text_frame') and shape.has_text_frame:\n tf = shape.text_frame\n text = ''.join(p.text for p in tf.paragraphs).strip()\n if not text:\n shapes_to_remove.append(shape)\n else:\n # No text frame at all - remove\n shapes_to_remove.append(shape)\n except (ValueError, AttributeError):\n pass\n except Exception:\n pass\n \n for shape in shapes_to_remove:\n try:\n sp = shape._element\n sp.getparent().remove(sp)\n removed += 1\n except Exception as e:\n print(f\" [!] Could not remove body placeholder: {e}\")\n \n return removed\n\n\ndef remove_empty_textboxes(slide) -> int:\n \"\"\"\n Remove empty TextBox shapes from slide.\n Textboxes added programmatically may end up empty if no content is assigned.\n These are unnecessary and should be removed.\n \n Returns:\n Number of textboxes removed\n \"\"\"\n from pptx.enum.shapes import MSO_SHAPE_TYPE\n \n removed = 0\n shapes_to_remove = []\n \n for shape in slide.shapes:\n # Check if it's a TextBox (not a placeholder)\n try:\n if shape.shape_type == MSO_SHAPE_TYPE.TEXT_BOX:\n # Check if text frame is empty or has only whitespace\n if hasattr(shape, 'has_text_frame') and shape.has_text_frame:\n tf = shape.text_frame\n text = ''.join(p.text for p in tf.paragraphs).strip()\n if not text:\n shapes_to_remove.append(shape)\n else:\n # No text frame at all - remove\n shapes_to_remove.append(shape)\n except Exception:\n pass\n \n for shape in shapes_to_remove:\n try:\n sp = shape._element\n sp.getparent().remove(sp)\n removed += 1\n except Exception as e:\n print(f\" [!] Could not remove empty textbox: {e}\")\n \n return removed\n\n\ndef is_icon_or_logo(image_path: str, min_content_size: int = 400) -> tuple:\n \"\"\"\n Detect if image is likely an icon/logo.\n \n Criteria:\n - Square aspect ratio (0.9-1.1) AND small size (\u003c= 800px)\n - Very small size (\u003c min_content_size on any dimension)\n \n Returns:\n Tuple of (is_icon_logo: bool, suggested_width_percent: int)\n If is_icon_logo is True, suggested_width_percent is a small appropriate size.\n \"\"\"\n img_width, img_height = get_image_size(image_path)\n if img_width is None or img_height is None:\n return False, 45\n \n # Very small images (\u003c 400px) are likely icons - use small size\n if img_width \u003c min_content_size or img_height \u003c min_content_size:\n # Suggest 15-20% for icons\n suggested = min(20, max(10, int(img_width / 13.333 / 96 * 100 * 1.2)))\n return True, suggested\n \n # Square images under 800px are likely logos - use small size\n aspect_ratio = img_width / img_height if img_height > 0 else 1\n if 0.9 \u003c= aspect_ratio \u003c= 1.1 and max(img_width, img_height) \u003c= 800:\n # Suggest 15-25% for logos\n suggested = min(25, max(15, int(img_width / 13.333 / 96 * 100 * 1.2)))\n return True, suggested\n \n return False, 45\n\n\ndef add_image_to_slide(slide, prs, image_config: dict, content_path: str) -> dict:\n \"\"\"\n Add image to slide based on configuration.\n \n Args:\n slide: pptx slide object\n prs: Presentation object (for slide dimensions)\n image_config: {\"path\": ..., \"position\": \"right|bottom|full|center\", \"width_percent\": 45}\n content_path: Path to content.json for resolving relative paths\n \n Returns:\n Content area dict with adjusted dimensions, or None for full image\n \"\"\"\n image_path = resolve_image_path(image_config, content_path)\n # Get slide dimensions early for default return\n default_width = prs.slide_width.inches - 1.0 # 0.5 margin on each side\n default_height = prs.slide_height.inches - 2.0 # top/bottom margins\n \n if not image_path:\n return {'left': Inches(0.5), 'top': Inches(1.5), 'width': Inches(default_width), 'height': Inches(default_height)}\n \n position = image_config.get('position', 'right')\n width_pct = image_config.get('width_percent', 45)\n \n # For icons/logos, use appropriate small size instead of skipping\n is_icon, suggested_pct = is_icon_or_logo(image_path)\n if is_icon:\n img_w, img_h = get_image_size(image_path)\n print(f\" [i] Icon/logo detected ({img_w}x{img_h}px) - using size: {suggested_pct}%\")\n width_pct = min(width_pct, suggested_pct)\n height_pct = image_config.get('height_percent', 50)\n \n # Get actual slide dimensions from presentation\n slide_width_inches = prs.slide_width.inches\n slide_height_inches = prs.slide_height.inches\n content_width = slide_width_inches - 1.0 # margin on both sides\n \n # Limit width_percent based on original image size to prevent over-stretching\n max_width_pct = calculate_max_width_percent(image_path, slide_width_inches)\n if width_pct > max_width_pct:\n print(f\" [i] Image size limited: {width_pct}% → {max_width_pct}% (original image is small)\")\n width_pct = max_width_pct\n \n try:\n if position == 'full' or position == 'center':\n # Full slide image (centered below title bar)\n title_height = 1.5 # Reserved for title\n available_height = slide_height_inches - title_height - 0.3 # 0.3 bottom margin\n \n img_left = Inches(0.5)\n img_top = Inches(title_height)\n \n if position == 'center':\n # Calculate width first\n img_width_inches = content_width * width_pct / 100\n \n # Get image aspect ratio to calculate height\n img_w, img_h = get_image_size(image_path)\n if img_w and img_h:\n aspect_ratio = img_w / img_h\n calculated_height = img_width_inches / aspect_ratio\n \n # If height exceeds available space, limit by height instead\n max_height = available_height * 0.95 # 95% of available\n if calculated_height > max_height:\n print(f\" [i] Image height limited: {calculated_height:.1f}\\\" → {max_height:.1f}\\\" (fits slide)\")\n # Recalculate width based on limited height\n img_width_inches = max_height * aspect_ratio\n \n img_width = Inches(img_width_inches)\n img_left = Inches((slide_width_inches - img_width_inches) / 2)\n else:\n img_width = Inches(content_width)\n \n slide.shapes.add_picture(image_path, img_left, img_top, width=img_width)\n return None # No text area\n \n elif position == 'right':\n # Image on right, text on left\n # Ensure image fits within slide with proper margin\n margin = 0.5 # inches\n \n img_width = Inches(slide_width_inches * width_pct / 100)\n # Position image with right margin\n img_left = Inches(slide_width_inches - margin) - img_width\n img_top = Inches(1.5)\n \n # Ensure img_left doesn't go negative or overlap with left content\n min_text_width = slide_width_inches * 0.35 # Minimum 35% for text\n if img_left \u003c Inches(min_text_width + margin):\n # Reduce image width to fit\n available_width = slide_width_inches - min_text_width - margin * 2\n img_width = Inches(available_width)\n img_left = Inches(slide_width_inches - margin) - img_width\n print(f\" [i] Image width adjusted to fit slide\")\n \n slide.shapes.add_picture(image_path, img_left, img_top, width=img_width)\n return {\n 'left': Inches(margin),\n 'top': Inches(1.5),\n 'width': img_left - Inches(margin * 2), # Text area width\n 'height': Inches(slide_height_inches - 2.0) # Dynamic height\n }\n \n elif position == 'bottom':\n # Image at bottom, text on top\n text_height = Inches(slide_height_inches * (100 - height_pct - 10) / 100)\n img_height = Inches(slide_height_inches * height_pct / 100)\n img_top = Inches(slide_height_inches - 0.5) - img_height\n img_left = Inches(0.5)\n slide.shapes.add_picture(image_path, img_left, img_top, height=img_height)\n return {\n 'left': Inches(0.5),\n 'top': Inches(1.5),\n 'width': Inches(12.333),\n 'height': text_height\n }\n \n except Exception as e:\n print(f\" [!] Failed to add image: {e}\")\n \n # Default content area (using slide dimensions)\n return {'left': Inches(0.5), 'top': Inches(1.5), 'width': Inches(default_width), 'height': Inches(default_height)}\n\n\ndef add_slide_from_layout(prs, layout_idx: int, title: str, items: Optional[list] = None, \n subtitle: Optional[str] = None, image_config: Optional[dict] = None,\n content_path: Optional[str] = None, code: Optional[str] = None,\n slide_type: Optional[str] = None) -> None:\n \"\"\"Add a slide using the specified layout from the template, with optional image.\n \n Features:\n - Section slides: auto-adjusts title width, height, and position for proper display\n - Two Column layouts: removes empty placeholders after content is set\n - Images: supports right/center/bottom positions with auto-sizing\n - Code blocks: styled with dark background and Consolas font\n \n Args:\n prs: Presentation object\n layout_idx: Layout index to use\n title: Slide title\n items: List of bullet items (strings)\n subtitle: Optional subtitle (for section/title slides)\n image_config: Image configuration dict with path, position, width_percent\n content_path: Base path for resolving relative image paths\n code: Code block content\n slide_type: Slide type ('section', 'content', etc.) for type-specific handling\n \"\"\"\n # Get layout (with fallback)\n try:\n layout = prs.slide_layouts[layout_idx]\n except IndexError:\n print(f\" Warning: Layout {layout_idx} not found, using layout 0\")\n layout = prs.slide_layouts[0]\n \n slide = prs.slides.add_slide(layout)\n \n # Get slide dimensions\n slide_width = prs.slide_width.inches\n slide_height = prs.slide_height.inches\n \n # Try to set title\n title_placeholder = find_placeholder(slide, ['TITLE', 'CENTER_TITLE'])\n if title_placeholder and title_placeholder.has_text_frame:\n title_placeholder.text_frame.paragraphs[0].text = title\n \n # For section slides, adjust title for better display\n if slide_type == 'section':\n # Extend title placeholder width to almost full slide width\n title_placeholder.width = Inches(slide_width - 1.5)\n # Disable word wrap to keep title on one line\n title_placeholder.text_frame.word_wrap = False\n # Disable autofit to prevent character spacing changes\n title_placeholder.text_frame.auto_size = None # MSO_AUTO_SIZE.NONE\n \n # Fix height if it's reported as 0 (causes display issues)\n if title_placeholder.height.inches \u003c 0.5:\n title_placeholder.height = Inches(1.0)\n \n # For section slides, always position title at 35% from top\n # This leaves room for subtitle below and looks balanced\n target_top = slide_height * 0.35\n original_top = title_placeholder.top.inches\n if abs(original_top - target_top) > 0.3: # Only adjust if difference > 0.3\"\n title_placeholder.top = Inches(target_top)\n print(f\" [i] Section title repositioned to 35% (was {original_top:.2f}\\\")\")\n \n # Try to set subtitle (for title slides and section slides)\n if subtitle:\n subtitle_placeholder = find_placeholder(slide, ['SUBTITLE'])\n if subtitle_placeholder and subtitle_placeholder.has_text_frame:\n subtitle_placeholder.text_frame.paragraphs[0].text = subtitle\n \n # Dynamic positioning: check subtitle distance from title\n if title_placeholder:\n title_bottom = title_placeholder.top.inches + max(title_placeholder.height.inches, 0.8)\n subtitle_top = subtitle_placeholder.top.inches\n gap = subtitle_top - title_bottom\n \n # Maximum gap: 25% of slide height (or 1.5 inches, whichever is smaller)\n max_gap = min(slide_height * 0.25, 1.5)\n # Ideal gap: 0.3-0.5 inches\n ideal_gap = 0.4\n \n if gap \u003c 0.2:\n # Overlapping: move subtitle below title\n new_top = title_bottom + ideal_gap\n # IMPORTANT: python-pptx doesn't preserve width/height when only 'top' is set.\n # This causes XML \u003ca:xfrm> to have \u003ca:off> without \u003ca:ext>, making text render\n # as single-column vertical. See TROUBLESHOOTING.md #48 for details.\n original_left = subtitle_placeholder.left\n original_width = subtitle_placeholder.width\n original_height = subtitle_placeholder.height\n subtitle_placeholder.top = Inches(new_top)\n subtitle_placeholder.left = original_left # Restore left position\n subtitle_placeholder.width = original_width # Restore width\n subtitle_placeholder.height = original_height # Restore height\n print(f\" [i] Subtitle repositioned to avoid overlap (was {subtitle_top:.2f}, now {new_top:.2f})\")\n elif gap > max_gap:\n # Too far apart: bring subtitle closer to title\n new_top = title_bottom + ideal_gap\n # IMPORTANT: Same fix as above - preserve all dimensions. See #48.\n original_left = subtitle_placeholder.left\n original_width = subtitle_placeholder.width\n original_height = subtitle_placeholder.height\n subtitle_placeholder.top = Inches(new_top)\n subtitle_placeholder.left = original_left # Restore left position\n subtitle_placeholder.width = original_width # Restore width\n subtitle_placeholder.height = original_height # Restore height\n print(f\" [i] Subtitle repositioned to reduce gap (was {subtitle_top:.2f}, now {new_top:.2f}, gap was {gap:.2f})\")\n \n # Ensure subtitle font is readable (24pt for section slides)\n if slide_type == 'section':\n for para in subtitle_placeholder.text_frame.paragraphs:\n para.font.size = Pt(24)\n else:\n # Fallback: add textbox for subtitle if no placeholder exists\n # Calculate subtitle position dynamically based on title position\n \n if title_placeholder:\n title_top = title_placeholder.top.inches\n title_height = title_placeholder.height.inches\n \n # Some placeholders report height=0, use minimum of 0.8 inches\n if title_height \u003c 0.5:\n title_height = 0.8\n \n # Position subtitle below title with small margin (0.15\" for tighter grouping)\n subtitle_top = title_top + title_height + 0.15\n else:\n # No title placeholder: use 45% of slide height as default\n subtitle_top = slide_height * 0.45\n \n subtitle_box = slide.shapes.add_textbox(\n Inches(0.64), # Match title left margin\n Inches(subtitle_top), # Position below title\n Inches(slide_width - 1.5), # Almost full width\n Inches(0.8) # Fixed height\n )\n tf = subtitle_box.text_frame\n tf.word_wrap = False # Keep on one line\n p = tf.paragraphs[0]\n p.text = subtitle\n p.font.size = Pt(24) # Larger subtitle for section slides\n p.font.color.rgb = RGBColor(100, 100, 100) # Gray color\n p.alignment = PP_ALIGN.LEFT\n \n # Add image if configured (before content, so image appears behind text if overlapping)\n content_area = None\n if image_config:\n content_area = add_image_to_slide(slide, prs, image_config, content_path or \"\")\n # Remove empty Picture Placeholders to avoid \"image cannot be displayed\" issue\n removed = remove_empty_picture_placeholders(slide)\n if removed > 0:\n print(f\" [i] Removed {removed} empty picture placeholder(s)\")\n if content_area is None:\n # Full image slide - skip content\n return slide\n \n # Try to set content/body\n if items:\n body_placeholder = find_placeholder(slide, ['BODY', 'OBJECT', 'CONTENT'])\n if body_placeholder and body_placeholder.has_text_frame:\n tf = body_placeholder.text_frame\n tf.clear() # Clear existing content\n \n for i, item in enumerate(items):\n if i > 0:\n p = tf.add_paragraph()\n else:\n p = tf.paragraphs[0] if tf.paragraphs else tf.add_paragraph()\n \n # Handle different item formats\n if isinstance(item, dict):\n p.text = item.get('text', str(item))\n p.level = item.get('level', 0)\n else:\n p.text = str(item)\n p.level = 0\n else:\n # Fallback: add textbox if no body placeholder\n # Use adjusted content_area if image was added\n if content_area:\n content_box = slide.shapes.add_textbox(\n content_area['left'], content_area['top'],\n content_area['width'], content_area['height']\n )\n else:\n content_box = slide.shapes.add_textbox(\n Inches(0.5), Inches(1.5), \n prs.slide_width - Inches(1), Inches(5.5)\n )\n tf = content_box.text_frame\n tf.word_wrap = True\n \n for i, item in enumerate(items):\n if i > 0:\n p = tf.add_paragraph()\n else:\n p = tf.paragraphs[0]\n \n if isinstance(item, dict):\n p.text = f\"• {item.get('text', str(item))}\"\n else:\n p.text = f\"• {str(item)}\"\n p.font.size = Pt(18)\n \n # Add code block if provided\n if code:\n # Get slide dimensions for dynamic positioning\n slide_height = prs.slide_height.inches\n slide_width = prs.slide_width.inches\n \n # Determine code block position (below content or standalone)\n if items:\n # Position code below the content area\n # For smaller slides, adjust proportionally\n code_top = Inches(slide_height * 0.6) # 60% down from top\n code_height = Inches(slide_height * 0.32) # 32% of slide height\n else:\n # Code-only slide - larger code area\n code_top = Inches(1.5)\n code_height = Inches(slide_height - 2.0) # Leave margin at bottom\n \n # Calculate code block width (consider image if present)\n if image_config and image_config.get('position') == 'right':\n width_pct = image_config.get('width_percent', 40)\n code_width = prs.slide_width * (1 - width_pct / 100) - Inches(0.7)\n else:\n code_width = prs.slide_width - Inches(1.0)\n \n code_box = slide.shapes.add_textbox(\n Inches(0.5), code_top,\n code_width, code_height\n )\n tf = code_box.text_frame\n tf.word_wrap = True\n \n # Style as code block\n p = tf.paragraphs[0]\n p.text = code\n p.font.name = 'Consolas'\n p.font.size = Pt(11)\n \n # Add dark background to code box\n code_box.fill.solid()\n code_box.fill.fore_color.rgb = RGBColor(40, 44, 52) # Dark background\n p.font.color.rgb = RGBColor(171, 178, 191) # Light gray text\n \n # Final cleanup: remove empty placeholders and textboxes\n # This catches empty body/content placeholders in Two Column layouts\n # where only one column is used (e.g., content + image)\n removed_body = remove_empty_body_placeholders(slide)\n if removed_body > 0:\n print(f\" [i] Removed {removed_body} empty body placeholder(s)\")\n \n removed_textboxes = remove_empty_textboxes(slide)\n if removed_textboxes > 0:\n print(f\" [i] Removed {removed_textboxes} empty textbox(es)\")\n \n return slide\n\n\ndef validate_and_fix_content(slides_data: list, strict: bool = False) -> tuple[list, bool]:\n \"\"\"\n Validate content and auto-fix common issues.\n - closing type with items → convert to content type\n - title type with items → keep items (subtitle might also exist)\n - content/agenda slides without items → ERROR (strict mode) or WARNING\n \n Args:\n slides_data: List of slide data dictionaries\n strict: If True, raise error on critical issues (empty content slides)\n \n Returns:\n tuple: (fixed slides_data, has_errors)\n \"\"\"\n fixed_count = 0\n errors = []\n warnings = []\n \n for i, slide in enumerate(slides_data):\n slide_type = slide.get('type', 'content')\n # Support multiple content key names: items, content, bullets\n items = slide.get('items', slide.get('content', slide.get('bullets', [])))\n title = slide.get('title', f'Slide {i + 1}')\n \n # Check: closing type should not have multiple items\n if slide_type == 'closing' and items and len(items) > 1:\n warnings.append(f\" [!] Warning: Slide {i + 1} '{title[:30]}...' has type='closing' with {len(items)} items\")\n warnings.append(f\" → Auto-converting to type='content' (closing is for short endings only)\")\n slide['type'] = 'content'\n fixed_count += 1\n \n # Check: agenda type should have items\n if slide_type == 'agenda' and not items:\n warnings.append(f\" [!] Warning: Slide {i + 1} '{title[:30]}...' has type='agenda' but no items\")\n \n # CRITICAL: content slides must have items\n if slide_type == 'content' and not items:\n errors.append(f\" [X] ERROR: Slide {i + 1} '{title[:30]}...' has type='content' but NO CONTENT!\")\n errors.append(f\" Check if 'items', 'content', or 'bullets' key exists in JSON\")\n \n # Check: title type with many items might be better as content\n if slide_type == 'title' and items and len(items) > 2:\n warnings.append(f\" ℹ️ Note: Slide {i + 1} '{title[:30]}...' has type='title' with {len(items)} items\")\n warnings.append(f\" Consider using type='content' for better display\")\n \n # Print warnings\n for w in warnings:\n print(w)\n \n # Print errors\n for e in errors:\n print(e)\n \n if fixed_count > 0:\n print(f\"\\n ✅ Auto-fixed {fixed_count} slide(s)\")\n \n has_errors = len(errors) > 0\n if has_errors:\n print(f\"\\n [!] Found {len(errors) // 2} critical error(s)!\")\n if strict:\n print(f\" [?] Hint: content.json may use wrong key name (try 'items' or 'bullets')\")\n \n print()\n return slides_data, has_errors\n\n\ndef create_pptx_from_template(template_path: str, content_path: str, output_path: str, config_path: Optional[str] = None, force: bool = False, add_signature: bool = True) -> None:\n \"\"\"Create a new PPTX from template and content JSON.\n \n Args:\n add_signature: Add repository signature to first/last slide notes (default: True)\n \"\"\"\n \n # Load template\n print(f\"\\nLoading template: {template_path}\")\n prs = Presentation(template_path)\n \n # Show layout info\n print(f\"\\n{'─'*50}\")\n print(\"Available Layouts:\")\n for idx, layout in enumerate(prs.slide_layouts):\n print(f\" [{idx}] {layout.name or '(unnamed)'}\")\n print(f\"{'─'*50}\\n\")\n \n # Remove existing slides (start fresh with just the master)\n while len(prs.slides) > 0:\n rId = prs.slides._sldIdLst[0].rId\n prs.part.drop_rel(rId)\n del prs.slides._sldIdLst[0]\n print(\"Cleared existing slides from template.\\n\")\n \n # Load content\n print(f\"Loading content: {content_path}\")\n with open(content_path, 'r', encoding='utf-8') as f:\n data = json.load(f)\n \n slides_data = data.get('slides', data) if isinstance(data, dict) else data\n if isinstance(slides_data, dict):\n slides_data = [slides_data]\n \n # Validate and auto-fix content issues\n print(\"[*] Validating content...\")\n slides_data, has_critical_errors = validate_and_fix_content(slides_data)\n \n if has_critical_errors and not force:\n print(\"\\n[!] CRITICAL: Content validation failed!\")\n print(\" Found slides with type='content' but no items/bullets.\")\n print(\" Please fix the content.json before generating PPTX.\")\n print(\" (Use --force to ignore this check)\")\n sys.exit(1)\n elif has_critical_errors and force:\n print(\"\\n[!] WARNING: Content validation found issues but continuing due to --force flag.\")\n \n print(f\"Creating {len(slides_data)} slides...\\n\")\n \n # Load layout mapping from config file if provided\n layout_map = None\n if config_path and Path(config_path).exists():\n print(f\"[*] Using layout config: {config_path}\")\n with open(config_path, 'r', encoding='utf-8') as f:\n config = json.load(f)\n layout_map = config.get('layout_mapping', {})\n print(f\" Loaded mappings: {list(layout_map.keys())}\\n\")\n \n # Auto-detect layouts if no config provided\n if not layout_map:\n print(\"[*] Auto-detecting layouts from template names...\")\n \n # Smart layout detection - find best matching layout by name\n def find_layout_by_keywords(layouts, keywords: list, fallback: int = 0) -> int:\n \"\"\"Find layout index by matching keywords in layout name.\"\"\"\n for keyword in keywords:\n for idx, layout in enumerate(layouts):\n name = (layout.name or '').lower()\n if keyword.lower() in name:\n return idx\n return fallback\n \n # Detect layout indices based on template\n title_layout = find_layout_by_keywords(prs.slide_layouts, \n ['title slide', 'タイトル スライド', 'タイトルスライド'], 0)\n content_layout = find_layout_by_keywords(prs.slide_layouts, \n ['title and content', 'タイトルとコンテンツ', 'content'], 1)\n section_layout = find_layout_by_keywords(prs.slide_layouts, \n ['section title', 'section', 'セクション'], content_layout)\n agenda_layout = find_layout_by_keywords(prs.slide_layouts, \n ['agenda', 'アジェンダ'], content_layout)\n closing_layout = find_layout_by_keywords(prs.slide_layouts, \n ['closing', 'クロージング', 'end'], section_layout)\n title_only_layout = find_layout_by_keywords(prs.slide_layouts, \n ['title only', 'タイトルのみ', 'blank'], section_layout)\n blank_layout = find_layout_by_keywords(prs.slide_layouts, \n ['blank', '白紙', 'empty'], title_only_layout)\n photo_layout = find_layout_by_keywords(prs.slide_layouts, \n ['picture', 'photo', '50/50', 'image'], content_layout)\n \n layout_map = {\n 'title': title_layout,\n 'content': content_layout,\n 'section': section_layout,\n 'section_title': section_layout,\n 'two_content': content_layout,\n 'two_column': content_layout,\n 'comparison': content_layout,\n 'blank': blank_layout,\n 'agenda': agenda_layout,\n 'summary': content_layout, # まとめ → コンテンツスタイル\n 'next_steps': content_layout, # 今後のステップ\n 'feature': content_layout,\n 'closing': closing_layout, # 最終スライド → クロージング\n 'code': content_layout,\n 'photo': photo_layout,\n 'title_only': title_only_layout, # コンテンツなしスライド用\n }\n \n print(f\" Auto-detected: title={title_layout}, content={content_layout}, section={section_layout}, title_only={title_only_layout}\\n\")\n print(\"[?] Tip: Run 'python scripts/analyze_template.py \u003ctemplate.pptx>' for accurate layout mapping.\\n\")\n \n # Create slides\n first_slide = None\n last_slide = None\n for i, slide_data in enumerate(slides_data):\n slide_type = slide_data.get('type', 'content')\n title = slide_data.get('title', f'Slide {i + 1}')\n \n # Support multiple content key names: items, content, bullets\n # Also merge left_items + right_items for two_column slides\n items = slide_data.get('items', slide_data.get('content', slide_data.get('bullets', [])))\n if not items:\n left_items = slide_data.get('left_items', [])\n right_items = slide_data.get('right_items', [])\n if left_items or right_items:\n items = left_items + right_items\n print(f\" [i] Merged left_items + right_items → {len(items)} items\")\n \n subtitle = slide_data.get('subtitle', '')\n has_image = slide_data.get('image') is not None\n \n # Determine layout index (priority: slide.layout > optimal selection > layout_map > fallback)\n layout_idx = slide_data.get('layout')\n if layout_idx is None:\n # Smart layout selection based on content amount\n base_layout = layout_map.get(slide_type, layout_map.get('content', 1))\n \n # If content-type slide has no items and no image, use simpler layout\n if slide_type == 'content' and not items and not has_image:\n # Prefer title_only or blank layout to avoid empty body placeholders\n simpler_layout = layout_map.get('title_only') or layout_map.get('blank')\n if simpler_layout is not None:\n layout_idx = simpler_layout\n print(f\" [i] No content → using simpler layout [{simpler_layout}]\")\n else:\n layout_idx = base_layout\n # Content slides with image should use two-column or content_with_image layout\n elif slide_type == 'content' and has_image:\n # Prefer content_with_image > two_column > content\n image_layout = layout_map.get('content_with_image') or layout_map.get('two_column')\n if image_layout is not None:\n layout_idx = image_layout\n print(f\" [i] Content + image → using layout [{layout_idx}]\")\n else:\n layout_idx = base_layout\n # Photo slides use photo layout for proper image placement\n elif slide_type == 'photo' and has_image:\n photo_layout = layout_map.get('photo')\n if photo_layout is not None:\n layout_idx = photo_layout\n else:\n layout_idx = base_layout\n else:\n layout_idx = base_layout\n \n # Ensure layout_idx is valid\n if layout_idx is None:\n layout_idx = 1\n layout_idx = min(layout_idx, len(prs.slide_layouts) - 1)\n \n layout_name = prs.slide_layouts[layout_idx].name or '(unnamed)'\n \n # Get optional image configuration\n image_config = slide_data.get('image')\n notes = slide_data.get('notes', '')\n code = slide_data.get('code', '')\n \n if image_config:\n print(f\" Slide {i + 1}: {title[:40]}... [Layout {layout_idx}: {layout_name}] 📷\")\n elif code:\n print(f\" Slide {i + 1}: {title[:40]}... [Layout {layout_idx}: {layout_name}] 💻\")\n else:\n print(f\" Slide {i + 1}: {title[:40]}... [Layout {layout_idx}: {layout_name}]\")\n \n slide = add_slide_from_layout(prs, layout_idx, title, items, subtitle, image_config, content_path, code, slide_type)\n \n # Add speaker notes if provided\n if notes and slide:\n notes_slide = slide.notes_slide\n notes_tf = notes_slide.notes_text_frame\n notes_tf.text = notes\n \n # Store first and last slides for signature\n if i == 0:\n first_slide = slide\n last_slide = slide\n \n # Add signature to first and last slides (unless --no-signature)\n if add_signature and len(prs.slides) > 0:\n repo_url = get_repo_info()\n sig_first = f\"📌 Generated by: {repo_url}\"\n sig_last = f\"---\\n🔧 This presentation was created using Ag-ppt-create\\n{repo_url}\"\n \n # First slide\n if first_slide:\n try:\n notes_slide = first_slide.notes_slide\n notes_tf = notes_slide.notes_text_frame\n existing = notes_tf.text or \"\"\n if sig_first not in existing:\n notes_tf.text = f\"{sig_first}\\n\\n{existing}\" if existing else sig_first\n except Exception:\n pass\n \n # Last slide\n if last_slide and (len(prs.slides) == 1 or last_slide != first_slide):\n try:\n notes_slide = last_slide.notes_slide\n notes_tf = notes_slide.notes_text_frame\n existing = notes_tf.text or \"\"\n if sig_last not in existing:\n notes_tf.text = f\"{existing}\\n\\n{sig_last}\" if existing else sig_last\n except Exception:\n pass\n \n print(f\" 📝 Signature added to speaker notes\")\n \n # Set view to Normal (slide view) instead of Slide Master view\n # This prevents PowerPoint from opening in Master view\n # Note: python-pptx doesn't fully support viewProps modification,\n # so we'll fix it after saving using ZIP manipulation\n \n # Save first\n Path(output_path).parent.mkdir(parents=True, exist_ok=True)\n prs.save(output_path)\n \n # Post-save: Fix viewProps.xml to ensure Normal view\n _normalize_view_settings(output_path)\n \n print(f\"\\n✅ Created: {output_path}\")\n print(f\" Total slides: {len(prs.slides)}\")\n\n\ndef _auto_clean_template(template_path: str) -> str:\n \"\"\"\n Automatically clean template if it has issues (dark backgrounds, decorations, etc.).\n Creates a cleaned version in output_manifest/ and returns the path.\n \n Args:\n template_path: Path to original template\n \n Returns:\n Path to cleaned template (or original if no cleaning needed)\n \"\"\"\n import subprocess\n \n template_stem = Path(template_path).stem\n clean_path = f\"output_manifest/{template_stem}_auto_clean.pptx\"\n \n # Run diagnose to check if cleaning is needed\n try:\n result = subprocess.run(\n [\"python\", \"scripts/diagnose_template.py\", template_path, \"--json\"],\n capture_output=True, text=True, timeout=60\n )\n \n if result.returncode == 0:\n import json\n diag = json.loads(result.stdout)\n issues = diag.get(\"total_issues\", 0)\n \n if issues == 0:\n print(f\"[i] Template is clean, no auto-cleaning needed\")\n return template_path\n \n print(f\"[!] Template has {issues} issues, auto-cleaning...\")\n except Exception as e:\n print(f\"[!] Diagnose failed ({e}), proceeding with auto-clean\")\n \n # Run clean_template\n try:\n result = subprocess.run(\n [\"python\", \"scripts/create_clean_template.py\", template_path, clean_path, \"--all\"],\n capture_output=True, text=True, timeout=120\n )\n \n if result.returncode == 0 and Path(clean_path).exists():\n print(f\"[+] Auto-cleaned template: {clean_path}\")\n \n # Also run analyze_template for the clean version\n subprocess.run(\n [\"python\", \"scripts/analyze_template.py\", clean_path],\n capture_output=True, text=True, timeout=60\n )\n \n return clean_path\n else:\n print(f\"[!] Auto-clean failed, using original template\")\n if result.stderr:\n print(f\" Error: {result.stderr[:200]}\")\n return template_path\n except Exception as e:\n print(f\"[!] Auto-clean failed ({e}), using original template\")\n return template_path\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Create PPTX from template using slide master layouts.',\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog='''\nExamples:\n # List available layouts in template:\n python scripts/create_from_template.py templates/sample.pptx --list-layouts\n\n # Analyze template and generate config (recommended):\n python scripts/analyze_template.py templates/sample-ppf.pptx\n\n # Create PPTX with layout config:\n python scripts/create_from_template.py templates/sample-ppf.pptx content.json output.pptx \\\\\n --config output_manifest/sample-ppf_layouts.json\n\n # Auto-clean template before generation (removes dark backgrounds, decorations):\n python scripts/create_from_template.py input/source.pptx content.json output.pptx --auto-clean\n\n # Auto-detect layouts (no config):\n python scripts/create_from_template.py templates/sample.pptx content.json output.pptx\n'''\n )\n \n parser.add_argument('template', help='Path to template PPTX file')\n parser.add_argument('content', nargs='?', help='Path to content JSON file')\n parser.add_argument('output', nargs='?', help='Path to output PPTX file')\n parser.add_argument('--config', '-c', help='Path to layout config JSON (from analyze_template.py)')\n parser.add_argument('--list-layouts', '-l', action='store_true', help='List available layouts and exit')\n parser.add_argument('--force', '-f', action='store_true', help='Force generation even if validation fails')\n parser.add_argument('--no-signature', action='store_true', help='Disable repository signature in speaker notes')\n parser.add_argument('--auto-clean', action='store_true', \n help='Auto-clean template (remove dark backgrounds, decorations, fix positions)')\n \n args = parser.parse_args()\n \n # List layouts mode\n if args.list_layouts or not args.content:\n list_layouts(args.template)\n return\n \n # Validate arguments\n if not args.output:\n parser.error(\"Output path is required\")\n \n # Auto-clean template if requested\n template_path = args.template\n if args.auto_clean:\n template_path = _auto_clean_template(args.template)\n \n # Auto-find config if not specified\n config_path = args.config\n if not config_path:\n # Try to find matching config file (check cleaned template first)\n template_stem = Path(template_path).stem\n auto_config = Path(f\"output_manifest/{template_stem}_layouts.json\")\n if auto_config.exists():\n config_path = str(auto_config)\n print(f\"[+] Auto-found config: {config_path}\")\n else:\n # Try original template name\n orig_stem = Path(args.template).stem\n auto_config = Path(f\"output_manifest/{orig_stem}_layouts.json\")\n if auto_config.exists():\n config_path = str(auto_config)\n print(f\"[+] Auto-found config: {config_path}\")\n \n create_pptx_from_template(\n template_path, args.content, args.output, config_path,\n force=args.force, add_signature=not args.no_signature\n )\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":58075,"content_sha256":"8a7ae9628c995595f2dafdf60ad2c5b1fba59e023250ce41e271270b14ffeaf4"},{"filename":"scripts/create_ja_pptx.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Agent-Skills/powerpoint-automation - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Agent-Skills/tree/main/powerpoint-automation\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nCreate Japanese summary PPTX from content JSON.\nUsage: python create_ja_pptx.py \u003ccontent.json> \u003coutput.pptx> [--no-signature]\n\nSupports image embedding:\n - \"image\": {\"path\": \"images/foo.png\", \"position\": \"right\", \"width_percent\": 45}\n - \"image\": {\"url\": \"https://...\", \"position\": \"bottom\", \"height_percent\": 50}\n - position: \"right\" | \"bottom\" | \"full\"\n\"\"\"\n\nfrom pptx import Presentation\nfrom pptx.util import Inches, Pt\nfrom pptx.dml.color import RGBColor\nfrom pptx.enum.text import PP_ALIGN, MSO_ANCHOR\nfrom pptx.enum.shapes import MSO_SHAPE\nimport json\nimport sys\nimport os\nimport urllib.request\nimport tempfile\nimport subprocess\nfrom pathlib import Path\n\n# Repository signature\nREPO_URL = \"https://github.com/aktsmm/Agent-Skills/tree/main/powerpoint-automation\"\n\n\ndef get_repo_info() -> str:\n \"\"\"Get repository URL from git remote (fallback to default).\"\"\"\n try:\n result = subprocess.run(\n ['git', 'remote', 'get-url', 'origin'],\n capture_output=True, text=True, timeout=5\n )\n if result.returncode == 0:\n return result.stdout.strip()\n except Exception:\n pass\n return REPO_URL\n\n\n# Parse arguments\nadd_signature = True\nif '--no-signature' in sys.argv:\n add_signature = False\n sys.argv.remove('--no-signature')\n\nif len(sys.argv) >= 3:\n input_json = sys.argv[1]\n output_pptx = sys.argv[2]\nelse:\n input_json = 'output_manifest/20251211_brk252_ja_summary_content.json'\n output_pptx = 'output_ppt/20251211_brk252_ja_summary_v2.pptx'\n\n# Load content\nwith open(input_json, 'r', encoding='utf-8') as f:\n data = json.load(f)\n\n\ndef validate_and_fix_content(slides_data: list) -> list:\n \"\"\"\n Validate content and auto-fix common issues.\n - closing type with items → convert to content type\n \"\"\"\n fixed_count = 0\n \n for i, slide in enumerate(slides_data):\n slide_type = slide.get('type', 'content')\n items = slide.get('items', slide.get('content', slide.get('content_ja', [])))\n title = slide.get('title', slide.get('title_ja', f'Slide {i + 1}'))\n \n # Check: closing type should not have multiple items\n if slide_type == 'closing' and items and len(items) > 1:\n print(f\" ⚠️ Warning: Slide {i + 1} '{title[:30]}...' has type='closing' with {len(items)} items\")\n print(f\" → Auto-converting to type='content' (closing is for short endings only)\")\n slide['type'] = 'content'\n fixed_count += 1\n \n if fixed_count > 0:\n print(f\" ✅ Auto-fixed {fixed_count} slide(s)\")\n \n return slides_data\n\n\n# Validate and fix content\ndata['slides'] = validate_and_fix_content(data['slides'])\n\n# Create presentation\nprs = Presentation()\nprs.slide_width = Inches(13.333) # 16:9\nprs.slide_height = Inches(7.5)\n\n# Colors\nPURPLE = RGBColor(0x5B, 0x5F, 0xC7) # Microsoft Purple\nDARK_GRAY = RGBColor(0x33, 0x33, 0x33)\nWHITE = RGBColor(0xFF, 0xFF, 0xFF)\nLIGHT_GRAY = RGBColor(0xF5, 0xF5, 0xF5)\n\n# Get base directory for resolving relative image paths\nBASE_DIR = Path(input_json).parent.parent # Assume content.json is in output_manifest/\n\n\ndef get_image_size(image_path: str) -> tuple:\n \"\"\"\n Get image dimensions using PIL.\n Returns (width, height) in pixels, or (None, None) if failed.\n \"\"\"\n try:\n from PIL import Image as PILImage\n with PILImage.open(image_path) as img:\n return img.size\n except Exception:\n return None, None\n\n\ndef is_icon_or_logo(image_path: str, min_content_size: int = 400) -> tuple:\n \"\"\"\n Detect if image is likely an icon/logo.\n \n Criteria:\n - Very small size (\u003c min_content_size on any dimension)\n - Square aspect ratio (0.9-1.1) AND small size (\u003c= 800px)\n \n Returns:\n Tuple of (is_icon_logo: bool, suggested_width_percent: int)\n \"\"\"\n img_width, img_height = get_image_size(image_path)\n if img_width is None or img_height is None:\n return False, 45\n \n # Very small images (\u003c 400px) are likely icons - use small size\n if img_width \u003c min_content_size or img_height \u003c min_content_size:\n suggested = min(20, max(10, int(img_width / 13.333 / 96 * 100 * 1.2)))\n return True, suggested\n \n # Square images under 800px are likely logos - use small size\n aspect_ratio = img_width / img_height if img_height > 0 else 1\n if 0.9 \u003c= aspect_ratio \u003c= 1.1 and max(img_width, img_height) \u003c= 800:\n suggested = min(25, max(15, int(img_width / 13.333 / 96 * 100 * 1.2)))\n return True, suggested\n \n return False, 45\n\n\ndef resolve_image_path(image_config: dict) -> str:\n \"\"\"\n Resolve image path from config. Supports local path or URL.\n Returns the resolved local file path (downloads URL if needed).\n \"\"\"\n if 'url' in image_config:\n # Download from URL to temp file\n url = image_config['url']\n try:\n suffix = Path(url.split('?')[0]).suffix or '.png'\n with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:\n print(f\" 📥 Downloading image: {url[:60]}...\")\n urllib.request.urlretrieve(url, tmp.name)\n return tmp.name\n except Exception as e:\n print(f\" ⚠️ Failed to download image: {e}\")\n return None\n elif 'path' in image_config:\n # Resolve local path\n path = image_config['path']\n # Try relative to BASE_DIR first, then as absolute\n candidates = [\n BASE_DIR / path,\n Path(path),\n Path(input_json).parent / path,\n ]\n for candidate in candidates:\n if candidate.exists():\n return str(candidate)\n print(f\" ⚠️ Image not found: {path}\")\n return None\n return None\n\n\ndef add_image_to_slide(slide, image_config: dict, content_area: dict):\n \"\"\"\n Add image to slide based on configuration.\n \n Args:\n slide: pptx slide object\n image_config: {\"path\": ..., \"position\": \"right|bottom|full\", \"width_percent\": 45}\n content_area: {\"left\": Inches, \"top\": Inches, \"width\": Inches, \"height\": Inches}\n \n Returns:\n Updated content_area for text placement\n \"\"\"\n image_path = resolve_image_path(image_config)\n if not image_path:\n return content_area\n \n position = image_config.get('position', 'right')\n width_pct = image_config.get('width_percent', 45)\n height_pct = image_config.get('height_percent', 50)\n \n # Detect icons/logos and limit their size\n is_icon, suggested_pct = is_icon_or_logo(image_path)\n if is_icon:\n img_w, img_h = get_image_size(image_path)\n print(f\" [i] Icon/logo detected ({img_w}x{img_h}px) - using size: {suggested_pct}%\")\n width_pct = min(width_pct, suggested_pct)\n \n slide_width = float(prs.slide_width)\n slide_height = float(prs.slide_height)\n \n try:\n if position == 'full':\n # Full slide image (centered, maintaining aspect ratio)\n img_left = Inches(0.5)\n img_top = content_area['top']\n img_width = Inches(12.333)\n slide.shapes.add_picture(image_path, img_left, img_top, width=img_width)\n # No text area when full image\n return None\n \n elif position == 'right':\n # Image on right, text on left\n img_width = Inches(13.333 * width_pct / 100)\n img_left = Inches(13.333 - 0.5) - img_width\n img_top = content_area['top']\n slide.shapes.add_picture(image_path, img_left, img_top, width=img_width)\n # Update content area to left side\n return {\n 'left': content_area['left'],\n 'top': content_area['top'],\n 'width': Inches(13.333 * (100 - width_pct - 5) / 100),\n 'height': content_area['height']\n }\n \n elif position == 'bottom':\n # Image at bottom, text on top\n text_height = Inches(7.5 * (100 - height_pct - 10) / 100)\n img_height = Inches(7.5 * height_pct / 100)\n img_top = Inches(7.5 - 0.5) - img_height\n img_left = Inches(0.5)\n slide.shapes.add_picture(image_path, img_left, img_top, height=img_height)\n # Update content area to top\n return {\n 'left': content_area['left'],\n 'top': content_area['top'],\n 'width': content_area['width'],\n 'height': text_height\n }\n \n elif position == 'center':\n # Image centered (for photo slides)\n # Get slide dimensions\n slide_width_inches = prs.slide_width.inches\n slide_height_inches = prs.slide_height.inches\n title_height = 1.5 # Reserved for title\n available_height = slide_height_inches - title_height - 0.3\n \n img_width_inches = slide_width_inches * width_pct / 100\n \n # Check if height will exceed available space\n try:\n from PIL import Image as PILImage\n with PILImage.open(image_path) as img:\n img_w, img_h = img.size\n aspect_ratio = img_w / img_h\n calculated_height = img_width_inches / aspect_ratio\n \n max_height = available_height * 0.95\n if calculated_height > max_height:\n print(f\" [i] Image height limited: {calculated_height:.1f}\\\" → {max_height:.1f}\\\" (fits slide)\")\n img_width_inches = max_height * aspect_ratio\n except Exception:\n pass # If PIL fails, use width as-is\n \n img_width = Inches(img_width_inches)\n img_left = (Inches(slide_width_inches) - img_width) / 2\n img_top = content_area['top']\n slide.shapes.add_picture(image_path, img_left, img_top, width=img_width)\n # No text area when center image\n return None\n \n except Exception as e:\n print(f\" ⚠️ Failed to add image: {e}\")\n \n return content_area\n\n\ndef add_title_slide(prs, title, subtitle=''):\n \"\"\"Add a title slide with purple background.\"\"\"\n slide_layout = prs.slide_layouts[6] # Blank\n slide = prs.slides.add_slide(slide_layout)\n \n # Background\n bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, prs.slide_width, prs.slide_height)\n bg.fill.solid()\n bg.fill.fore_color.rgb = PURPLE\n bg.line.fill.background()\n \n # Title\n title_box = slide.shapes.add_textbox(Inches(0.5), Inches(2.5), Inches(12.333), Inches(2))\n tf = title_box.text_frame\n tf.word_wrap = True\n p = tf.paragraphs[0]\n p.text = title\n p.font.size = Pt(44)\n p.font.bold = True\n p.font.color.rgb = WHITE\n p.alignment = PP_ALIGN.CENTER\n \n if subtitle:\n p2 = tf.add_paragraph()\n p2.text = subtitle\n p2.font.size = Pt(24)\n p2.font.color.rgb = WHITE\n p2.alignment = PP_ALIGN.CENTER\n \n return slide\n\n\ndef add_content_slide(prs, title, items, slide_type='feature', image_config=None):\n \"\"\"\n Add a content slide with title bar, bullet points, and optional image.\n \n Args:\n prs: Presentation object\n title: Slide title\n items: List of bullet points\n slide_type: Type of slide (for styling)\n image_config: Optional image configuration dict\n \"\"\"\n slide_layout = prs.slide_layouts[6] # Blank\n slide = prs.slides.add_slide(slide_layout)\n \n # Title bar\n bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, prs.slide_width, Inches(1.2))\n bar.fill.solid()\n bar.fill.fore_color.rgb = PURPLE\n bar.line.fill.background()\n \n # Title text\n title_box = slide.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(12.333), Inches(0.8))\n tf = title_box.text_frame\n p = tf.paragraphs[0]\n p.text = title\n p.font.size = Pt(32)\n p.font.bold = True\n p.font.color.rgb = WHITE\n \n # Default content area\n content_area = {\n 'left': Inches(0.5),\n 'top': Inches(1.5),\n 'width': Inches(12.333),\n 'height': Inches(5.5)\n }\n \n # Add image if configured (adjusts content_area)\n if image_config:\n content_area = add_image_to_slide(slide, image_config, content_area)\n \n # Content (skip if image takes full slide)\n if items and content_area:\n content_box = slide.shapes.add_textbox(\n content_area['left'],\n content_area['top'],\n content_area['width'],\n content_area['height']\n )\n tf = content_box.text_frame\n tf.word_wrap = True\n \n for i, item in enumerate(items):\n if i > 0:\n p = tf.add_paragraph()\n else:\n p = tf.paragraphs[0]\n \n if isinstance(item, dict):\n if 'stat' in item:\n desc = item.get('description', item.get('text', ''))\n p.text = f\"• {item.get('stat', '')} - {desc}\"\n elif 'label' in item:\n p.text = f\"• {item['label']} {item.get('text', '')}\"\n else:\n p.text = f\"• {item.get('text', str(item))}\"\n else:\n p.text = f\"• {item}\"\n \n p.font.size = Pt(24)\n p.font.color.rgb = DARK_GRAY\n p.space_after = Pt(12)\n \n return slide\n\n\ndef add_section_slide(prs, title, subtitle=''):\n \"\"\"Add a section divider slide with purple background.\"\"\"\n slide_layout = prs.slide_layouts[6] # Blank\n slide = prs.slides.add_slide(slide_layout)\n \n # Background\n bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, prs.slide_width, prs.slide_height)\n bg.fill.solid()\n bg.fill.fore_color.rgb = PURPLE\n bg.line.fill.background()\n \n # Title\n title_box = slide.shapes.add_textbox(Inches(0.5), Inches(2.8), Inches(12.333), Inches(1.5))\n tf = title_box.text_frame\n p = tf.paragraphs[0]\n p.text = title\n p.font.size = Pt(48)\n p.font.bold = True\n p.font.color.rgb = WHITE\n p.alignment = PP_ALIGN.CENTER\n \n if subtitle:\n p2 = tf.add_paragraph()\n p2.text = subtitle\n p2.font.size = Pt(28)\n p2.font.color.rgb = WHITE\n p2.alignment = PP_ALIGN.CENTER\n \n return slide\n\n\ndef add_two_column_slide(prs, title, left_title, left_items, right_title, right_items):\n \"\"\"Add a two-column comparison slide.\"\"\"\n slide_layout = prs.slide_layouts[6] # Blank\n slide = prs.slides.add_slide(slide_layout)\n \n # Title bar\n bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, prs.slide_width, Inches(1.2))\n bar.fill.solid()\n bar.fill.fore_color.rgb = PURPLE\n bar.line.fill.background()\n \n # Title text\n title_box = slide.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(12.333), Inches(0.8))\n tf = title_box.text_frame\n p = tf.paragraphs[0]\n p.text = title\n p.font.size = Pt(32)\n p.font.bold = True\n p.font.color.rgb = WHITE\n \n # Left column title\n if left_title:\n left_title_box = slide.shapes.add_textbox(Inches(0.5), Inches(1.5), Inches(5.8), Inches(0.6))\n tf = left_title_box.text_frame\n p = tf.paragraphs[0]\n p.text = left_title\n p.font.size = Pt(24)\n p.font.bold = True\n p.font.color.rgb = PURPLE\n \n # Left column content\n if left_items:\n left_box = slide.shapes.add_textbox(Inches(0.5), Inches(2.2), Inches(5.8), Inches(4.8))\n tf = left_box.text_frame\n tf.word_wrap = True\n for i, item in enumerate(left_items):\n if i > 0:\n p = tf.add_paragraph()\n else:\n p = tf.paragraphs[0]\n text = item.get('text', item) if isinstance(item, dict) else item\n p.text = f\"• {text}\"\n p.font.size = Pt(20)\n p.font.color.rgb = DARK_GRAY\n p.space_after = Pt(10)\n \n # Right column title\n if right_title:\n right_title_box = slide.shapes.add_textbox(Inches(7), Inches(1.5), Inches(5.8), Inches(0.6))\n tf = right_title_box.text_frame\n p = tf.paragraphs[0]\n p.text = right_title\n p.font.size = Pt(24)\n p.font.bold = True\n p.font.color.rgb = PURPLE\n \n # Right column content\n if right_items:\n right_box = slide.shapes.add_textbox(Inches(7), Inches(2.2), Inches(5.8), Inches(4.8))\n tf = right_box.text_frame\n tf.word_wrap = True\n for i, item in enumerate(right_items):\n if i > 0:\n p = tf.add_paragraph()\n else:\n p = tf.paragraphs[0]\n text = item.get('text', item) if isinstance(item, dict) else item\n p.text = f\"• {text}\"\n p.font.size = Pt(20)\n p.font.color.rgb = DARK_GRAY\n p.space_after = Pt(10)\n \n return slide\n\n\n# Create slides\nslides_created = []\nfor slide_data in data['slides']:\n slide_type = slide_data.get('type', 'feature')\n title = slide_data.get('title_ja', '') or slide_data.get('title', '')\n subtitle = slide_data.get('subtitle_ja', '') or slide_data.get('subtitle', '')\n content = slide_data.get('content_ja', []) or slide_data.get('content', []) or slide_data.get('items', [])\n image_config = slide_data.get('image') # Optional image configuration\n notes = slide_data.get('notes', '') # Speaker notes\n \n if slide_type in ['title', 'closing']:\n # If title/closing has image, use content slide with SMALL image\n if image_config:\n # Limit image size for title slides (e.g., presenter photos)\n small_image_config = image_config.copy()\n small_image_config['width_percent'] = min(image_config.get('width_percent', 25), 25)\n small_image_config['position'] = image_config.get('position', 'right')\n slide = add_content_slide(prs, title, content, slide_type, small_image_config)\n else:\n slide = add_title_slide(prs, title, subtitle)\n elif slide_type in ['section', 'section_title']:\n slide = add_section_slide(prs, title, subtitle)\n elif slide_type == 'two_column':\n left_title = slide_data.get('left_title', '')\n left_items = slide_data.get('left_items', [])\n right_title = slide_data.get('right_title', '')\n right_items = slide_data.get('right_items', [])\n slide = add_two_column_slide(prs, title, left_title, left_items, right_title, right_items)\n else:\n slide = add_content_slide(prs, title, content, slide_type, image_config)\n \n # Add speaker notes if provided\n if notes and slide:\n try:\n notes_slide = slide.notes_slide\n notes_tf = notes_slide.notes_text_frame\n notes_tf.text = notes\n except Exception:\n pass\n \n slides_created.append(slide)\n\n# Add signature to first and last slides\nif add_signature and len(slides_created) > 0:\n repo_url = get_repo_info()\n sig_first = f\"📌 Generated by: {repo_url}\"\n sig_last = f\"---\\n🔧 This presentation was created using Agent-Skills/powerpoint-automation\\n{repo_url}\"\n \n # First slide\n try:\n notes_slide = slides_created[0].notes_slide\n notes_tf = notes_slide.notes_text_frame\n existing = notes_tf.text or \"\"\n if sig_first not in existing:\n notes_tf.text = f\"{sig_first}\\n\\n{existing}\" if existing else sig_first\n except Exception:\n pass\n \n # Last slide (if different from first)\n if len(slides_created) > 1:\n try:\n notes_slide = slides_created[-1].notes_slide\n notes_tf = notes_slide.notes_text_frame\n existing = notes_tf.text or \"\"\n if sig_last not in existing:\n notes_tf.text = f\"{existing}\\n\\n{sig_last}\" if existing else sig_last\n except Exception:\n pass\n \n print(f'📝 Signature added to speaker notes')\n\n# Save\nprs.save(output_pptx)\nprint(f'保存完了: {output_pptx}')\nprint(f'スライド数: {len(prs.slides)}')\n","content_type":"text/x-python; charset=utf-8","language":"python","size":20674,"content_sha256":"9f7e999d677e1bea5c5662195fed0fd62dbdadc34f9818aa586fd342435d7453"},{"filename":"scripts/create_pptx.js","content":"// =============================================================================\n// Ag-ppt-create - AI-powered PPTX generation pipeline\n// https://github.com/aktsmm/Ag-ppt-create\n//\n// Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n// DO NOT MODIFY THIS HEADER BLOCK.\n// =============================================================================\n/**\n * create_pptx.js - Create PowerPoint from JSON content using pptxgenjs\n *\n * Usage:\n * node scripts/create_pptx.js \u003ccontent.json> \u003coutput.pptx>\n *\n * Features:\n * - Creates clean, professional slides from JSON\n * - Supports images (local path or URL)\n * - Image positions: right, bottom, full\n * - Great for code-heavy presentations\n *\n * Author: aktsmm\n * License: CC BY-NC 4.0\n */\n\nconst pptxgen = require(\"pptxgenjs\");\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst https = require(\"https\");\nconst http = require(\"http\");\nconst { addSignature } = require(\"./pptx-signature\");\n\n// Design constants\nconst COLORS = {\n purple: \"5B5FC7\", // Microsoft Purple\n darkGray: \"333333\",\n white: \"FFFFFF\",\n lightGray: \"F5F5F5\",\n darkBlue: \"1E3A5F\", // Dark background threshold reference\n};\n\nconst FONTS = {\n title: \"Yu Gothic UI\",\n body: \"Yu Gothic UI\",\n};\n\n/**\n * Determine if a background color is dark (needs white text)\n * @param {string} hexColor - Hex color without # (e.g., \"5B5FC7\")\n * @returns {boolean} True if background is dark\n */\nfunction isDarkColor(hexColor) {\n if (!hexColor) return false;\n const hex = hexColor.replace(\"#\", \"\");\n const r = parseInt(hex.substr(0, 2), 16);\n const g = parseInt(hex.substr(2, 2), 16);\n const b = parseInt(hex.substr(4, 2), 16);\n // Calculate relative luminance (perceived brightness)\n // Formula: 0.299*R + 0.587*G + 0.114*B\n const luminance = 0.299 * r + 0.587 * g + 0.114 * b;\n return luminance \u003c 128; // Dark if luminance \u003c 50%\n}\n\n/**\n * Get text color based on slide background\n * @param {Object} slideData - Slide data with optional background field\n * @param {string} defaultColor - Default color to use\n * @returns {string} Appropriate text color\n */\nfunction getTextColor(slideData, defaultColor) {\n // Check for explicit dark background flag or background image\n if (slideData.dark_background || slideData.darkBackground) {\n return COLORS.white;\n }\n // Check for background color\n if (slideData.background_color) {\n return isDarkColor(slideData.background_color)\n ? COLORS.white\n : defaultColor;\n }\n // Check for background image (usually dark in presentations)\n if (slideData.background_image || slideData.backgroundImage) {\n return COLORS.white;\n }\n return defaultColor;\n}\n\n// Slide dimensions (16:9)\nconst SLIDE_WIDTH = 13.333;\nconst SLIDE_HEIGHT = 7.5;\n\n/**\n * Get image dimensions from local file\n * @param {string} imagePath - Path to image file\n * @returns {{width: number, height: number}|null} Image dimensions in pixels\n */\nfunction getImageSize(imagePath) {\n try {\n // Try using image-size package if available\n const sizeOf = require(\"image-size\");\n const dimensions = sizeOf(imagePath);\n return { width: dimensions.width, height: dimensions.height };\n } catch {\n // Fallback: read PNG/JPEG headers manually\n try {\n const buffer = fs.readFileSync(imagePath);\n // PNG: width at bytes 16-19, height at bytes 20-23\n if (\n buffer[0] === 0x89 &&\n buffer[1] === 0x50 &&\n buffer[2] === 0x4e &&\n buffer[3] === 0x47\n ) {\n const width = buffer.readUInt32BE(16);\n const height = buffer.readUInt32BE(20);\n return { width, height };\n }\n // JPEG: search for SOF0 marker\n if (buffer[0] === 0xff && buffer[1] === 0xd8) {\n let offset = 2;\n while (offset \u003c buffer.length) {\n if (buffer[offset] !== 0xff) break;\n const marker = buffer[offset + 1];\n if (marker >= 0xc0 && marker \u003c= 0xc3) {\n const height = buffer.readUInt16BE(offset + 5);\n const width = buffer.readUInt16BE(offset + 7);\n return { width, height };\n }\n const segmentLength = buffer.readUInt16BE(offset + 2);\n offset += 2 + segmentLength;\n }\n }\n } catch {\n // Ignore errors\n }\n return null;\n }\n}\n\n/**\n * Detect if image is likely an icon/logo based on size\n * @param {string} imagePath - Path to image file\n * @param {number} minContentSize - Minimum size for content images (default 400px)\n * @returns {{isIcon: boolean, suggestedPct: number}} Detection result\n */\nfunction isIconOrLogo(imagePath, minContentSize = 400) {\n const size = getImageSize(imagePath);\n if (!size) {\n return { isIcon: false, suggestedPct: 45 };\n }\n\n const { width, height } = size;\n\n // Very small images (\u003c 400px) are likely icons\n if (width \u003c minContentSize || height \u003c minContentSize) {\n const suggested = Math.min(\n 20,\n Math.max(10, Math.round((width / SLIDE_WIDTH / 96) * 100 * 1.2))\n );\n return { isIcon: true, suggestedPct: suggested };\n }\n\n // Square images under 800px are likely logos\n const aspectRatio = width / height;\n if (\n aspectRatio >= 0.9 &&\n aspectRatio \u003c= 1.1 &&\n Math.max(width, height) \u003c= 800\n ) {\n const suggested = Math.min(\n 25,\n Math.max(15, Math.round((width / SLIDE_WIDTH / 96) * 100 * 1.2))\n );\n return { isIcon: true, suggestedPct: suggested };\n }\n\n return { isIcon: false, suggestedPct: 45 };\n}\n\n/**\n * Download image from URL and return as base64\n * @param {string} url - Image URL\n * @returns {Promise\u003cstring>} Base64 encoded image data\n */\nfunction downloadImage(url) {\n return new Promise((resolve, reject) => {\n const protocol = url.startsWith(\"https\") ? https : http;\n console.log(` 📥 Downloading: ${url.substring(0, 50)}...`);\n\n protocol\n .get(url, (response) => {\n // Handle redirects\n if (\n response.statusCode >= 300 &&\n response.statusCode \u003c 400 &&\n response.headers.location\n ) {\n return downloadImage(response.headers.location)\n .then(resolve)\n .catch(reject);\n }\n\n if (response.statusCode !== 200) {\n reject(new Error(`Failed to download: ${response.statusCode}`));\n return;\n }\n\n const chunks = [];\n response.on(\"data\", (chunk) => chunks.push(chunk));\n response.on(\"end\", () => {\n const buffer = Buffer.concat(chunks);\n const base64 = buffer.toString(\"base64\");\n const contentType = response.headers[\"content-type\"] || \"image/png\";\n resolve(`data:${contentType};base64,${base64}`);\n });\n response.on(\"error\", reject);\n })\n .on(\"error\", reject);\n });\n}\n\n/**\n * Resolve image path - local file or URL\n * @param {Object} imageConfig - Image configuration\n * @param {string} basePath - Base path for relative paths\n * @returns {Promise\u003cstring|null>} Image path or data URI\n */\nasync function resolveImage(imageConfig, basePath) {\n if (imageConfig.url) {\n try {\n return await downloadImage(imageConfig.url);\n } catch (err) {\n console.warn(` ⚠️ Failed to download image: ${err.message}`);\n return null;\n }\n }\n\n if (imageConfig.path) {\n const candidates = [\n path.resolve(basePath, \"..\", imageConfig.path),\n path.resolve(imageConfig.path),\n path.resolve(basePath, imageConfig.path),\n ];\n\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) {\n return candidate;\n }\n }\n console.warn(` ⚠️ Image not found: ${imageConfig.path}`);\n }\n\n return null;\n}\n\n/**\n * Add title slide\n * @param {pptxgen} pptx - Presentation object\n * @param {Object} slideData - Slide data\n */\nfunction addTitleSlide(pptx, slideData) {\n const slide = pptx.addSlide();\n slide.background = { color: COLORS.purple };\n\n // Title\n slide.addText(slideData.title || \"\", {\n x: 0.5,\n y: 2.2,\n w: \"92%\",\n h: 1.5,\n fontSize: 44,\n fontFace: FONTS.title,\n color: COLORS.white,\n bold: true,\n align: \"center\",\n valign: \"middle\",\n });\n\n // Subtitle\n if (slideData.subtitle) {\n slide.addText(slideData.subtitle, {\n x: 0.5,\n y: 3.8,\n w: \"92%\",\n h: 0.8,\n fontSize: 24,\n fontFace: FONTS.body,\n color: COLORS.white,\n align: \"center\",\n });\n }\n\n return slide;\n}\n\n/**\n * Add content slide with optional image\n * @param {pptxgen} pptx - Presentation object\n * @param {Object} slideData - Slide data\n * @param {string} basePath - Base path for images\n */\nasync function addContentSlide(pptx, slideData, basePath) {\n const slide = pptx.addSlide();\n\n // Detect if slide has dark background (for text color decisions)\n const hasDarkBg =\n slideData.dark_background ||\n slideData.darkBackground ||\n slideData.background_image ||\n slideData.backgroundImage ||\n (slideData.background_color && isDarkColor(slideData.background_color));\n const bodyTextColor = hasDarkBg ? COLORS.white : COLORS.darkGray;\n\n // Apply background if specified\n if (slideData.background_color) {\n slide.background = { color: slideData.background_color.replace(\"#\", \"\") };\n }\n\n // Title bar (only if not using full dark background)\n if (!hasDarkBg) {\n slide.addShape(\"rect\", {\n x: 0,\n y: 0,\n w: \"100%\",\n h: 1.0,\n fill: { color: COLORS.purple },\n });\n }\n\n // Title text\n slide.addText(slideData.title || \"\", {\n x: 0.5,\n y: 0.2,\n w: \"92%\",\n h: 0.7,\n fontSize: 28,\n fontFace: FONTS.title,\n color: COLORS.white,\n bold: true,\n });\n\n // Default content area\n let contentArea = { x: 0.5, y: 1.3, w: 12.3, h: 5.0 };\n\n // Handle image if present\n if (slideData.image) {\n const imagePath = await resolveImage(slideData.image, basePath);\n if (imagePath) {\n const position = slideData.image.position || \"right\";\n let widthPct = slideData.image.width_percent || 45;\n const heightPct = slideData.image.height_percent || 50;\n\n // Detect icons/logos and limit their size (only for local files)\n const isDataUri = imagePath.startsWith(\"data:\");\n if (!isDataUri) {\n const { isIcon, suggestedPct } = isIconOrLogo(imagePath);\n if (isIcon) {\n const size = getImageSize(imagePath);\n console.log(\n ` [i] Icon/logo detected (${size?.width}x${size?.height}px) - using size: ${suggestedPct}%`\n );\n widthPct = Math.min(widthPct, suggestedPct);\n }\n }\n\n try {\n // Build image options\n const imageOpts = isDataUri ? { data: imagePath } : { path: imagePath };\n\n if (position === \"full\") {\n // Full slide image\n slide.addImage({\n ...imageOpts,\n x: 0.5,\n y: 1.3,\n w: 12.3,\n h: 5.5,\n });\n return slide; // No text content for full image\n } else if (position === \"right\") {\n // Image on right - calculate height based on actual aspect ratio\n const imgW = (13.333 * widthPct) / 100;\n const imgY = 1.5;\n const imgX = 13.333 - 0.5 - imgW;\n const maxImgH = 5.0; // Maximum height to fit in content area\n\n // Get actual image dimensions to maintain aspect ratio\n let imgH = maxImgH; // Default\n if (!isDataUri) {\n const size = getImageSize(imagePath);\n if (size && size.width && size.height) {\n const aspectRatio = size.height / size.width;\n imgH = imgW * aspectRatio;\n // Cap height if too tall\n if (imgH > maxImgH) {\n imgH = maxImgH;\n }\n }\n }\n\n slide.addImage({\n ...imageOpts,\n x: imgX,\n y: imgY,\n w: imgW,\n h: imgH,\n });\n contentArea.w = (13.333 * (100 - widthPct - 5)) / 100;\n } else if (position === \"bottom\") {\n // Image at bottom\n const imgH = (7.5 * heightPct) / 100;\n slide.addImage({\n ...imageOpts,\n x: 0.5,\n y: 7.5 - 0.5 - imgH,\n w: 12.3,\n h: imgH,\n });\n contentArea.h = (7.5 * (100 - heightPct - 20)) / 100;\n } else if (position === \"center\") {\n // Image centered (for photo slides)\n // Note: Limit width to prevent tall images from exceeding slide bounds\n // Available height = 7.5 - 1.5 (title) - 0.3 (margin) = 5.7\"\n const maxWidth = 60; // Limit to 60% to prevent overflow with tall images\n const effectiveWidthPct = Math.min(widthPct, maxWidth);\n const imgW = (13.333 * effectiveWidthPct) / 100;\n const imgX = (13.333 - imgW) / 2;\n const availableHeight = 5.5; // Conservative estimate\n\n if (widthPct > maxWidth) {\n console.log(\n ` [i] Center image width limited: ${widthPct}% → ${maxWidth}% (to fit slide height)`\n );\n }\n\n slide.addImage({\n ...imageOpts,\n x: imgX,\n y: 1.5,\n w: imgW,\n h: availableHeight, // Let pptxgenjs scale proportionally if image is shorter\n });\n contentArea = null; // No content area for centered images\n }\n } catch (err) {\n console.warn(` ⚠️ Failed to add image: ${err.message}`);\n }\n }\n }\n\n // Content bullets (skip if content area was nullified by full/center image)\n if (!contentArea) {\n return slide;\n }\n const items = slideData.items || slideData.content || [];\n if (items.length > 0) {\n const textContent = items.map((item) => {\n if (typeof item === \"object\") {\n return { text: item.text || String(item), options: { bullet: true } };\n }\n return { text: String(item), options: { bullet: true } };\n });\n\n slide.addText(textContent, {\n x: contentArea.x,\n y: contentArea.y,\n w: contentArea.w,\n h: contentArea.h,\n fontSize: 20,\n fontFace: FONTS.body,\n color: bodyTextColor, // Dynamic: white on dark bg, dark gray on light bg\n valign: \"top\",\n paraSpaceAfter: 8,\n });\n }\n\n return slide;\n}\n\n/**\n * Add section slide\n * @param {pptxgen} pptx - Presentation object\n * @param {Object} slideData - Slide data\n */\nfunction addSectionSlide(pptx, slideData) {\n const slide = pptx.addSlide();\n slide.background = { color: COLORS.purple };\n\n slide.addText(slideData.title || \"\", {\n x: 0.5,\n y: 2.5,\n w: \"92%\",\n h: 1.2,\n fontSize: 40,\n fontFace: FONTS.title,\n color: COLORS.white,\n bold: true,\n align: \"center\",\n valign: \"middle\",\n });\n\n if (slideData.subtitle) {\n slide.addText(slideData.subtitle, {\n x: 0.5,\n y: 3.8,\n w: \"92%\",\n h: 0.6,\n fontSize: 22,\n fontFace: FONTS.body,\n color: COLORS.white,\n align: \"center\",\n });\n }\n\n return slide;\n}\n\n/**\n * Add two-column comparison slide\n * @param {pptxgen} pptx - Presentation object\n * @param {Object} slideData - Slide data\n */\nfunction addTwoColumnSlide(pptx, slideData) {\n const slide = pptx.addSlide();\n\n // Title bar\n slide.addShape(\"rect\", {\n x: 0,\n y: 0,\n w: \"100%\",\n h: 1.0,\n fill: { color: COLORS.purple },\n });\n\n // Title text\n slide.addText(slideData.title || \"\", {\n x: 0.5,\n y: 0.2,\n w: \"92%\",\n h: 0.7,\n fontSize: 28,\n fontFace: FONTS.title,\n color: COLORS.white,\n bold: true,\n });\n\n // Left column title\n if (slideData.left_title) {\n slide.addText(slideData.left_title, {\n x: 0.5,\n y: 1.3,\n w: 5.8,\n h: 0.5,\n fontSize: 22,\n fontFace: FONTS.title,\n color: COLORS.purple,\n bold: true,\n });\n }\n\n // Left column content\n const leftItems = slideData.left_items || [];\n if (leftItems.length > 0) {\n const leftContent = leftItems.map((item) => {\n const text =\n typeof item === \"object\" ? item.text || String(item) : String(item);\n return { text, options: { bullet: true } };\n });\n\n slide.addText(leftContent, {\n x: 0.5,\n y: 1.9,\n w: 5.8,\n h: 4.8,\n fontSize: 18,\n fontFace: FONTS.body,\n color: COLORS.darkGray,\n valign: \"top\",\n paraSpaceAfter: 6,\n });\n }\n\n // Right column title\n if (slideData.right_title) {\n slide.addText(slideData.right_title, {\n x: 7.0,\n y: 1.3,\n w: 5.8,\n h: 0.5,\n fontSize: 22,\n fontFace: FONTS.title,\n color: COLORS.purple,\n bold: true,\n });\n }\n\n // Right column content\n const rightItems = slideData.right_items || [];\n if (rightItems.length > 0) {\n const rightContent = rightItems.map((item) => {\n const text =\n typeof item === \"object\" ? item.text || String(item) : String(item);\n return { text, options: { bullet: true } };\n });\n\n slide.addText(rightContent, {\n x: 7.0,\n y: 1.9,\n w: 5.8,\n h: 4.8,\n fontSize: 18,\n fontFace: FONTS.body,\n color: COLORS.darkGray,\n valign: \"top\",\n paraSpaceAfter: 6,\n });\n }\n\n return slide;\n}\n\n/**\n * Validate and fix content issues\n * @param {Array} slides - Slides array\n * @returns {Array} Fixed slides array\n */\nfunction validateAndFixContent(slides) {\n let fixedCount = 0;\n\n slides.forEach((slide, i) => {\n const type = slide.type || \"content\";\n const items = slide.items || slide.content || [];\n const title = slide.title || `Slide ${i + 1}`;\n\n // closing type with items → convert to content\n if (type === \"closing\" && items.length > 1) {\n console.log(\n ` ⚠️ Warning: Slide ${i + 1} '${title.substring(\n 0,\n 30\n )}...' has type='closing' with ${items.length} items`\n );\n console.log(` → Auto-converting to type='content'`);\n slide.type = \"content\";\n fixedCount++;\n }\n });\n\n if (fixedCount > 0) {\n console.log(` ✅ Auto-fixed ${fixedCount} slide(s)\\n`);\n }\n\n return slides;\n}\n\n/**\n * Main function\n */\nasync function main() {\n const args = process.argv.slice(2);\n\n // Check for --no-signature flag\n const noSignature = args.includes(\"--no-signature\");\n const filteredArgs = args.filter((a) => a !== \"--no-signature\");\n\n if (filteredArgs.length \u003c 2) {\n console.log(\n \"Usage: node scripts/create_pptx.js \u003ccontent.json> \u003coutput.pptx> [--no-signature]\"\n );\n console.log(\"\");\n console.log(\"Options:\");\n console.log(\" --no-signature Disable repository signature in notes\");\n console.log(\"\");\n console.log(\"Example:\");\n console.log(\n \" node scripts/create_pptx.js output_manifest/content.json output_ppt/result.pptx\"\n );\n process.exit(1);\n }\n\n const [inputJson, outputPptx] = filteredArgs;\n\n // Load content\n console.log(`\\nLoading: ${inputJson}`);\n const content = JSON.parse(fs.readFileSync(inputJson, \"utf-8\"));\n let slides = content.slides || content;\n if (!Array.isArray(slides)) {\n slides = [slides];\n }\n\n // Validate content\n console.log(\"🔍 Validating content...\");\n slides = validateAndFixContent(slides);\n\n // Create presentation\n const pptx = new pptxgen();\n pptx.defineLayout({ name: \"CUSTOM\", width: 13.333, height: 7.5 }); // 16:9\n pptx.layout = \"CUSTOM\";\n\n console.log(`Creating ${slides.length} slides...\\n`);\n\n const basePath = path.dirname(path.resolve(inputJson));\n const createdSlides = []; // Track created slides for signature\n\n for (let i = 0; i \u003c slides.length; i++) {\n const slideData = slides[i];\n const type = slideData.type || \"content\";\n const title = slideData.title || `Slide ${i + 1}`;\n const hasImage = slideData.image ? \" 📷\" : \"\";\n\n console.log(\n ` Slide ${i + 1}: ${title.substring(0, 40)}... [${type}]${hasImage}`\n );\n\n let slide;\n switch (type) {\n case \"title\":\n case \"closing\":\n // If title/closing has image, use small image (avoid oversized presenter photos)\n if (slideData.image) {\n // Limit image size for title slides (e.g., presenter photos)\n const titleSlideData = { ...slideData };\n if (titleSlideData.image) {\n titleSlideData.image = {\n ...titleSlideData.image,\n width_percent: Math.min(\n titleSlideData.image.width_percent || 25,\n 25\n ),\n position: titleSlideData.image.position || \"right\",\n };\n }\n slide = await addContentSlide(pptx, titleSlideData, basePath);\n } else {\n slide = addTitleSlide(pptx, slideData);\n }\n break;\n case \"section\":\n case \"section_title\":\n slide = addSectionSlide(pptx, slideData);\n break;\n case \"two_column\":\n slide = addTwoColumnSlide(pptx, slideData);\n break;\n default:\n slide = await addContentSlide(pptx, slideData, basePath);\n }\n\n // Add speaker notes to each slide (first/last handled by addSignature)\n // Note: addNotes overwrites, so we add to all slides here\n // and addSignature will prepend/append signature to first/last\n if (slideData.notes) {\n slide.addNotes(slideData.notes);\n }\n\n createdSlides.push({ slide, notes: slideData.notes, index: i });\n }\n\n // Add repository signature to first and last slides\n // This overwrites notes with signature + original notes\n if (!noSignature && createdSlides.length > 0) {\n const first = createdSlides[0];\n const last = createdSlides[createdSlides.length - 1];\n addSignature(first.slide, last.slide, {\n firstNotes: first.notes,\n lastNotes: last.notes,\n });\n }\n\n // Ensure output directory exists\n const outputDir = path.dirname(outputPptx);\n if (outputDir && !fs.existsSync(outputDir)) {\n fs.mkdirSync(outputDir, { recursive: true });\n }\n\n // Save\n await pptx.writeFile({ fileName: outputPptx });\n console.log(`\\n✅ Created: ${outputPptx}`);\n console.log(` Total slides: ${slides.length}`);\n}\n\nmain().catch((err) => {\n console.error(\"Error:\", err.message);\n process.exit(1);\n});\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":22039,"content_sha256":"3f43e50414b48cee48fc7b7bbfed9542ea2a125b702ae15353551cb530c042b1"},{"filename":"scripts/diagnose_template.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nDiagnose PPTX template for potential issues before using it.\n\nUsage:\n python scripts/diagnose_template.py \u003ctemplate.pptx>\n\nChecks for:\n - Background images in slide masters (decorative graphics)\n - Picture Placeholders with embedded default images (blip references)\n - Embedded fonts that may cause substitution warnings\n - Broken external links\n - Excessive number of slide masters\n\nOutput:\n Prints diagnosis report to console.\n Returns exit code 0 if clean, 1 if issues found.\n\"\"\"\n\nfrom pptx import Presentation\nfrom lxml import etree\nimport sys\nfrom pathlib import Path\n\n\ndef count_master_background_images(prs) -> list:\n \"\"\"Count PICTURE shapes in slide masters (likely background images).\"\"\"\n issues = []\n for master_idx, master in enumerate(prs.slide_masters):\n bg_images = []\n for shape in master.shapes:\n if 'PICTURE' in str(shape.shape_type):\n bg_images.append(shape.name)\n if bg_images:\n issues.append({\n 'type': 'master_background',\n 'master_index': master_idx,\n 'master_name': master.name or f'Master_{master_idx}',\n 'images': bg_images,\n 'count': len(bg_images)\n })\n return issues\n\n\ndef count_layout_placeholder_blips(prs) -> list:\n \"\"\"Count Picture Placeholders with embedded blip references.\"\"\"\n issues = []\n ns = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}\n \n for master_idx, master in enumerate(prs.slide_masters):\n for layout_idx, layout in enumerate(master.slide_layouts):\n blip_count = 0\n for shape in layout.shapes:\n if hasattr(shape, 'is_placeholder') and shape.is_placeholder:\n try:\n ph_type = str(shape.placeholder_format.type)\n if 'PICTURE' in ph_type:\n # Check for blip element\n xml = etree.tostring(shape._element, encoding='unicode')\n if 'blip' in xml and 'r:embed' in xml:\n blip_count += 1\n except (ValueError, AttributeError):\n pass\n if blip_count > 0:\n issues.append({\n 'type': 'placeholder_blip',\n 'master_index': master_idx,\n 'layout_index': layout_idx,\n 'layout_name': layout.name or f'Layout_{layout_idx}',\n 'count': blip_count\n })\n return issues\n\n\ndef check_master_count(prs) -> list:\n \"\"\"Check if there are too many slide masters.\"\"\"\n issues = []\n master_count = len(prs.slide_masters)\n if master_count > 5:\n issues.append({\n 'type': 'excessive_masters',\n 'count': master_count,\n 'recommendation': 'Consider using only the first master or cleaning up unused masters'\n })\n return issues\n\n\ndef check_view_props(template_path: str) -> list:\n \"\"\"Check if template has non-standard view settings (e.g., sldMasterView).\"\"\"\n import zipfile\n issues = []\n \n try:\n with zipfile.ZipFile(template_path, 'r') as z:\n if 'ppt/viewProps.xml' in z.namelist():\n content = z.read('ppt/viewProps.xml').decode('utf-8')\n if 'lastView=\"sldMasterView\"' in content:\n issues.append({\n 'type': 'master_view_default',\n 'severity': 'warning',\n 'description': 'Template opens in Slide Master view instead of Normal view',\n 'recommendation': 'Generated PPTX will open in Master view. Will be auto-fixed during generation.'\n })\n elif 'lastView=\"sldSorterView\"' in content:\n issues.append({\n 'type': 'sorter_view_default',\n 'severity': 'info',\n 'description': 'Template opens in Slide Sorter view',\n 'recommendation': 'Consider if this is intentional'\n })\n except Exception as e:\n pass # Silently ignore if can't read viewProps\n \n return issues\n\n\ndef diagnose_template(template_path: str) -> dict:\n \"\"\"\n Diagnose PPTX template and return structured report.\n \n Returns:\n dict with 'issues', 'summary', 'clean' keys\n \"\"\"\n prs = Presentation(template_path)\n \n all_issues = []\n \n # Check for background images in masters\n master_bg_issues = count_master_background_images(prs)\n all_issues.extend(master_bg_issues)\n \n # Check for placeholder blips\n placeholder_issues = count_layout_placeholder_blips(prs)\n all_issues.extend(placeholder_issues)\n \n # Check master count\n master_count_issues = check_master_count(prs)\n all_issues.extend(master_count_issues)\n \n # Check view properties (viewProps.xml)\n view_prop_issues = check_view_props(template_path)\n all_issues.extend(view_prop_issues)\n \n # Calculate totals\n total_bg_images = sum(i['count'] for i in master_bg_issues)\n total_placeholder_blips = sum(i['count'] for i in placeholder_issues)\n \n summary = {\n 'template': Path(template_path).name,\n 'slide_masters': len(prs.slide_masters),\n 'total_layouts': sum(len(m.slide_layouts) for m in prs.slide_masters),\n 'background_images_in_masters': total_bg_images,\n 'placeholder_blips': total_placeholder_blips,\n 'issues_found': len(all_issues)\n }\n \n return {\n 'issues': all_issues,\n 'summary': summary,\n 'clean': len(all_issues) == 0\n }\n\n\ndef print_report(report: dict) -> None:\n \"\"\"Print diagnosis report to console.\"\"\"\n summary = report['summary']\n issues = report['issues']\n \n print(\"\\n\" + \"=\" * 60)\n print(\"📋 TEMPLATE DIAGNOSIS REPORT\")\n print(\"=\" * 60)\n print(f\"\\nTemplate: {summary['template']}\")\n print(f\"Slide Masters: {summary['slide_masters']}\")\n print(f\"Total Layouts: {summary['total_layouts']}\")\n print()\n \n if report['clean']:\n print(\"✅ Template is CLEAN - no issues found\")\n return\n \n print(f\"⚠️ ISSUES FOUND: {summary['issues_found']}\")\n print()\n \n # Group issues by type\n bg_issues = [i for i in issues if i['type'] == 'master_background']\n blip_issues = [i for i in issues if i['type'] == 'placeholder_blip']\n master_issues = [i for i in issues if i['type'] == 'excessive_masters']\n \n if bg_issues:\n print(\"🖼️ Background Images in Slide Masters:\")\n print(\"-\" * 40)\n for issue in bg_issues:\n print(f\" Master {issue['master_index']} ({issue['master_name']}): {issue['count']} image(s)\")\n for img in issue['images'][:5]: # Show first 5\n print(f\" - {img}\")\n if len(issue['images']) > 5:\n print(f\" ... and {len(issue['images']) - 5} more\")\n print(f\"\\n Total: {summary['background_images_in_masters']} background image(s)\")\n print(\" → These may overlap with your slide content\")\n print()\n \n if blip_issues:\n print(\"📎 Picture Placeholders with Embedded Images:\")\n print(\"-\" * 40)\n for issue in blip_issues:\n print(f\" Layout {issue['layout_index']} ({issue['layout_name']}): {issue['count']} placeholder(s)\")\n print(f\"\\n Total: {summary['placeholder_blips']} placeholder(s) with blip references\")\n print(\" → These may show 'image cannot be displayed' errors\")\n print()\n \n if master_issues:\n print(\"📊 Excessive Slide Masters:\")\n print(\"-\" * 40)\n for issue in master_issues:\n print(f\" Found {issue['count']} masters (recommended: ≤5)\")\n print(f\" → {issue['recommendation']}\")\n print()\n \n # View properties issues\n view_issues = [i for i in issues if i['type'] in ('master_view_default', 'sorter_view_default')]\n if view_issues:\n print(\"👁️ View Settings Issues:\")\n print(\"-\" * 40)\n for issue in view_issues:\n icon = \"⚠️\" if issue.get('severity') == 'warning' else \"ℹ️\"\n print(f\" {icon} {issue['description']}\")\n print(f\" → {issue['recommendation']}\")\n print()\n \n print(\"=\" * 60)\n print(\"💡 RECOMMENDATION: Run clean_template.py to fix these issues\")\n print(\" python scripts/clean_template.py \u003cinput.pptx> \u003coutput.pptx>\")\n print(\"=\" * 60)\n\n\ndef main():\n import argparse\n \n parser = argparse.ArgumentParser(description='Diagnose PPTX template for potential issues')\n parser.add_argument('template', help='Path to template PPTX file')\n parser.add_argument('--json', action='store_true', help='Output as JSON (for scripting)')\n \n args = parser.parse_args()\n \n if not Path(args.template).exists():\n if args.json:\n import json\n print(json.dumps({\"error\": f\"File not found: {args.template}\"}))\n else:\n print(f\"Error: File not found: {args.template}\")\n sys.exit(1)\n \n report = diagnose_template(args.template)\n \n if args.json:\n import json\n # Output JSON for scripting\n output = {\n \"clean\": report['clean'],\n \"total_issues\": report['summary']['total'],\n \"summary\": report['summary'],\n \"issues\": report['issues']\n }\n print(json.dumps(output))\n else:\n print_report(report)\n \n # Exit code: 0 if clean, 1 if issues found\n sys.exit(0 if report['clean'] else 1)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10125,"content_sha256":"006be1f367b6a672376eaa7ac0dfc90b17739902004168c61cb9e8bfeeec4af7"},{"filename":"scripts/extract_images.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nExtract images from PPTX slides.\n\nUsage:\n python scripts/extract_images.py \u003cinput.pptx> \u003coutput_dir> [--skip-icons]\n\nCreates images/slide_01.png, images/slide_02.png, etc. for each slide.\n\nOptions:\n --skip-icons Skip small images that are likely icons/logos (\u003c 400px or square \u003c 800px)\n\"\"\"\n\nimport sys\nimport zipfile\nfrom pathlib import Path\nfrom pptx import Presentation\nfrom pptx.enum.shapes import MSO_SHAPE_TYPE\nfrom pptx.util import Emu\n\n\ndef is_icon_or_logo(width_px: int, height_px: int, min_size: int = 400) -> bool:\n \"\"\"\n Detect if image is likely an icon/logo.\n \n Criteria:\n - Very small size (\u003c min_size on any dimension)\n - Square aspect ratio (0.9-1.1) AND small size (\u003c= 800px)\n \n Note: Icons/logos are now extracted but marked, not skipped.\n \"\"\"\n if width_px \u003c min_size or height_px \u003c min_size:\n return True\n \n aspect_ratio = width_px / height_px if height_px > 0 else 1\n if 0.9 \u003c= aspect_ratio \u003c= 1.1 and max(width_px, height_px) \u003c= 800:\n return True\n \n return False\n\n\ndef extract_images_from_pptx(pptx_path: str, output_dir: str, skip_icons: bool = False, mark_icons: bool = True) -> None:\n \"\"\"\n Extract the main image from each slide.\n \n Args:\n pptx_path: Path to the input PPTX file.\n output_dir: Directory to save extracted images.\n skip_icons: If True, skip icon/logo images entirely.\n mark_icons: If True, log when icons/logos are detected (but still extract them).\n \"\"\"\n # Prevent output to input/ directory\n out_path = Path(output_dir)\n if out_path.parts and out_path.parts[0] == 'input':\n print(\"Error: Cannot output to input/ directory. Use images/ or output_manifest/ instead.\")\n sys.exit(1)\n \n prs = Presentation(pptx_path)\n out_path.mkdir(parents=True, exist_ok=True)\n \n print(f\"\\nExtracting images from: {pptx_path}\")\n print(f\"Output directory: {output_dir}\")\n if skip_icons:\n print(\"Skipping icons/logos: YES\")\n print(\"=\" * 50)\n \n # Also extract from the zip structure for embedded images\n images_extracted = 0\n icons_skipped = 0\n \n with zipfile.ZipFile(pptx_path, 'r') as z:\n media_files = [f for f in z.namelist() if f.startswith('ppt/media/')]\n print(f\"Found {len(media_files)} embedded media files\")\n \n for slide_idx, slide in enumerate(prs.slides):\n slide_num = slide_idx + 1\n \n # Find images on this slide\n images_on_slide = []\n \n for shape in slide.shapes:\n if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:\n try:\n # Get image data\n image = shape.image\n ext = image.ext\n blob = image.blob\n \n images_on_slide.append({\n 'ext': ext,\n 'blob': blob,\n 'width': shape.width,\n 'height': shape.height,\n 'left': shape.left,\n 'top': shape.top\n })\n except Exception as e:\n print(f\" [!] Error extracting image from slide {slide_num}: {e}\")\n \n if images_on_slide:\n # Filter out icons/logos if requested\n if skip_icons:\n content_images = []\n for img in images_on_slide:\n # Convert EMU to pixels (1 inch = 914400 EMU, 96 DPI)\n width_px = int(img['width'] / 914400 * 96)\n height_px = int(img['height'] / 914400 * 96)\n \n if is_icon_or_logo(width_px, height_px):\n icons_skipped += 1\n print(f\" Slide {slide_num:3d}: Skipped icon/logo ({width_px}x{height_px}px)\")\n else:\n content_images.append(img)\n \n images_on_slide = content_images\n \n if not images_on_slide:\n continue\n \n # Save the largest image (main content image)\n # Prefer landscape/screenshot images (16:9 or similar)\n def image_score(img):\n \"\"\"Score images by size and aspect ratio (prefer 16:9 screenshots).\"\"\"\n width_px = int(img['width'] / 914400 * 96)\n height_px = int(img['height'] / 914400 * 96)\n area = width_px * height_px\n \n # Prefer 16:9 aspect ratio (1.7-1.8)\n aspect = width_px / height_px if height_px > 0 else 1\n aspect_bonus = 1.5 if 1.6 \u003c= aspect \u003c= 1.9 else 1.0\n \n return area * aspect_bonus\n \n main_image = max(images_on_slide, key=image_score)\n \n output_file = out_path / f\"slide_{slide_num:02d}.{main_image['ext']}\"\n with open(output_file, 'wb') as f:\n f.write(main_image['blob'])\n \n width_px = int(main_image['width'] / 914400 * 96)\n height_px = int(main_image['height'] / 914400 * 96)\n \n # Mark if image is icon/logo (for reference)\n is_icon = is_icon_or_logo(width_px, height_px)\n icon_marker = \" 🔹icon/logo\" if is_icon and mark_icons else \"\"\n \n print(f\" Slide {slide_num:3d}: Saved ({width_px}x{height_px}px) → {output_file.name}{icon_marker}\")\n images_extracted += 1\n \n print(\"=\" * 50)\n print(f\"✅ Extracted images from {images_extracted} slides to {output_dir}\")\n if skip_icons:\n print(f\" Skipped {icons_skipped} icon/logo images\")\n\n\ndef main():\n if len(sys.argv) \u003c 3:\n print(\"Usage: python extract_images.py \u003cinput.pptx> \u003coutput_dir> [--skip-icons]\")\n sys.exit(1)\n \n skip_icons = '--skip-icons' in sys.argv\n extract_images_from_pptx(sys.argv[1], sys.argv[2], skip_icons=skip_icons)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":6427,"content_sha256":"d4460a2d0b87d5b1ac56a9e13edc3cffde9be44fa65f6e276cf40d472896e598"},{"filename":"scripts/extract_shapes.py","content":"#!/usr/bin/env python3\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nExtract structured text content from PowerPoint presentations.\n\nThis module provides functionality to:\n- Extract all text content from PowerPoint shapes\n- Preserve paragraph formatting (alignment, bullets, fonts, spacing)\n- Handle nested GroupShapes recursively with correct absolute positions\n- Sort shapes by visual position on slides\n- Export to JSON with clean, structured data\n\nUsage:\n python extract_shapes.py input.pptx output.json [--issues-only]\n\nAuthor: aktsmm\nLicense: CC BY-NC 4.0\n\"\"\"\n\nimport argparse\nimport json\nimport platform\nimport sys\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional, Tuple, Union\n\nfrom pptx import Presentation\nfrom pptx.enum.text import PP_ALIGN\nfrom pptx.shapes.base import BaseShape\nfrom pptx.util import Emu, Inches\n\n# Constants for EMU (English Metric Units) conversion\nEMU_PER_INCH = 914400\n\n\ndef parse_arguments() -> argparse.Namespace:\n \"\"\"Parse command line arguments.\n \n Returns:\n Parsed command line arguments.\n \"\"\"\n parser = argparse.ArgumentParser(\n description=\"Extract text inventory from PowerPoint presentations.\",\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n python extract_shapes.py presentation.pptx inventory.json\n Extracts text inventory with positions for all shapes\n\n python extract_shapes.py presentation.pptx inventory.json --issues-only\n Extracts only text shapes that have overflow or overlap issues\n\nOutput JSON includes:\n - All text content organized by slide and shape\n - Visual position and size in inches\n - Paragraph properties and formatting\n - Issue detection: text overflow and shape overlaps\n \"\"\",\n )\n parser.add_argument(\"input\", help=\"Input PowerPoint file (.pptx)\")\n parser.add_argument(\"output\", help=\"Output JSON file for inventory\")\n parser.add_argument(\n \"--issues-only\",\n action=\"store_true\",\n help=\"Include only text shapes that have overflow or overlap issues\",\n )\n return parser.parse_args()\n\n\n@dataclass\nclass ParagraphData:\n \"\"\"Data structure for paragraph properties extracted from PowerPoint.\"\"\"\n \n text: str = \"\"\n bullet: bool = False\n level: int = 0\n alignment: Optional[str] = None\n space_before: Optional[float] = None\n space_after: Optional[float] = None\n line_spacing: Optional[float] = None\n font_size: Optional[float] = None\n font_name: Optional[str] = None\n bold: Optional[bool] = None\n italic: Optional[bool] = None\n underline: Optional[bool] = None\n color: Optional[str] = None\n theme_color: Optional[str] = None\n \n @classmethod\n def from_paragraph(cls, paragraph: Any) -> \"ParagraphData\":\n \"\"\"Create ParagraphData from a PowerPoint paragraph object.\n \n Args:\n paragraph: The PowerPoint paragraph object.\n \n Returns:\n ParagraphData instance with extracted properties.\n \"\"\"\n data = cls()\n # Clean text: remove vertical tabs (soft line breaks) and other control characters\n text = paragraph.text.strip()\n # Replace vertical tab (\\x0B) with space - this is PowerPoint's soft line break\n text = text.replace('\\x0b', ' ').replace('\\u000b', ' ')\n data.text = text\n \n # Extract bullet properties\n pPr = paragraph._p.get_or_add_pPr()\n buChar = pPr.find(\".//{http://schemas.openxmlformats.org/drawingml/2006/main}buChar\")\n buAutoNum = pPr.find(\".//{http://schemas.openxmlformats.org/drawingml/2006/main}buAutoNum\")\n \n if buChar is not None or buAutoNum is not None:\n data.bullet = True\n data.level = paragraph.level if paragraph.level else 0\n \n # Extract alignment\n if paragraph.alignment:\n alignment_map = {\n PP_ALIGN.LEFT: \"LEFT\",\n PP_ALIGN.CENTER: \"CENTER\",\n PP_ALIGN.RIGHT: \"RIGHT\",\n PP_ALIGN.JUSTIFY: \"JUSTIFY\",\n }\n data.alignment = alignment_map.get(paragraph.alignment)\n \n # Extract spacing\n if paragraph.space_before:\n data.space_before = paragraph.space_before.pt\n if paragraph.space_after:\n data.space_after = paragraph.space_after.pt\n if paragraph.line_spacing:\n data.line_spacing = paragraph.line_spacing\n \n # Extract font properties from first run\n if paragraph.runs:\n run = paragraph.runs[0]\n font = run.font\n \n if font.size:\n data.font_size = font.size.pt\n if font.name:\n data.font_name = font.name\n if font.bold is not None:\n data.bold = font.bold\n if font.italic is not None:\n data.italic = font.italic\n if font.underline is not None:\n data.underline = font.underline\n \n # Extract color (handle different color types safely)\n try:\n if font.color.type is not None:\n color_type = str(font.color.type)\n if \"RGB\" in color_type and font.color.rgb:\n data.color = str(font.color.rgb)\n elif font.color.theme_color:\n data.theme_color = str(font.color.theme_color).replace(\"MSO_THEME_COLOR.\", \"\")\n except AttributeError:\n # Color might be inherited or undefined\n pass\n \n return data\n \n def to_dict(self) -> Dict[str, Any]:\n \"\"\"Convert to dictionary for JSON serialization.\n \n Returns:\n Dictionary representation of the paragraph data.\n \"\"\"\n result: Dict[str, Any] = {\"text\": self.text}\n \n if self.bullet:\n result[\"bullet\"] = True\n result[\"level\"] = self.level\n if self.alignment:\n result[\"alignment\"] = self.alignment\n if self.space_before is not None:\n result[\"space_before\"] = self.space_before\n if self.space_after is not None:\n result[\"space_after\"] = self.space_after\n if self.line_spacing is not None:\n result[\"line_spacing\"] = self.line_spacing\n if self.font_size is not None:\n result[\"font_size\"] = self.font_size\n if self.font_name:\n result[\"font_name\"] = self.font_name\n if self.bold is not None:\n result[\"bold\"] = self.bold\n if self.italic is not None:\n result[\"italic\"] = self.italic\n if self.underline is not None:\n result[\"underline\"] = self.underline\n if self.color:\n result[\"color\"] = self.color\n if self.theme_color:\n result[\"theme_color\"] = self.theme_color\n \n return result\n\n\n@dataclass\nclass ShapeData:\n \"\"\"Data structure for shape properties extracted from PowerPoint.\"\"\"\n \n left: float = 0.0\n top: float = 0.0\n width: float = 0.0\n height: float = 0.0\n placeholder_type: Optional[str] = None\n default_font_size: Optional[float] = None\n paragraphs: List[ParagraphData] = field(default_factory=list)\n overlap: Optional[Dict[str, Any]] = None\n overflow_bottom: Optional[float] = None\n \n def to_dict(self) -> Dict[str, Any]:\n \"\"\"Convert to dictionary for JSON serialization.\n \n Returns:\n Dictionary representation of the shape data.\n \"\"\"\n result: Dict[str, Any] = {\n \"left\": round(self.left, 2),\n \"top\": round(self.top, 2),\n \"width\": round(self.width, 2),\n \"height\": round(self.height, 2),\n }\n \n if self.placeholder_type:\n result[\"placeholder_type\"] = self.placeholder_type\n if self.default_font_size is not None:\n result[\"default_font_size\"] = self.default_font_size\n if self.overlap:\n result[\"overlap\"] = self.overlap\n if self.overflow_bottom is not None:\n result[\"overflow_bottom\"] = round(self.overflow_bottom, 2)\n \n result[\"paragraphs\"] = [p.to_dict() for p in self.paragraphs]\n \n return result\n\n\ndef emu_to_inches(emu: int) -> float:\n \"\"\"Convert EMU (English Metric Units) to inches.\n \n Args:\n emu: Value in EMUs.\n \n Returns:\n Value in inches.\n \"\"\"\n return emu / EMU_PER_INCH\n\n\ndef get_placeholder_type_name(placeholder) -> Optional[str]:\n \"\"\"Get the placeholder type as a string.\n \n Args:\n placeholder: The placeholder format object.\n \n Returns:\n String name of the placeholder type, or None.\n \"\"\"\n if placeholder and placeholder.type:\n return str(placeholder.type).replace(\"PLACEHOLDER_TYPE.\", \"\")\n return None\n\n\ndef extract_shape_text(shape: BaseShape, offset_left: int = 0, offset_top: int = 0) -> Optional[ShapeData]:\n \"\"\"Extract text content from a shape.\n \n Args:\n shape: The PowerPoint shape object.\n offset_left: X offset for grouped shapes (in EMUs).\n offset_top: Y offset for grouped shapes (in EMUs).\n \n Returns:\n ShapeData if shape contains text, None otherwise.\n \"\"\"\n # Skip shapes without text frames\n if not shape.has_text_frame:\n return None\n \n text_frame = shape.text_frame\n \n # Skip empty text frames\n if not text_frame.text.strip():\n return None\n \n # Skip slide numbers and footer placeholders\n if shape.is_placeholder:\n placeholder = shape.placeholder_format\n ph_type = str(placeholder.type) if placeholder.type else \"\"\n if \"SLIDE_NUMBER\" in ph_type or \"FOOTER\" in ph_type or \"DATE\" in ph_type:\n return None\n \n # Calculate absolute position\n abs_left = (shape.left or 0) + offset_left\n abs_top = (shape.top or 0) + offset_top\n \n shape_data = ShapeData(\n left=emu_to_inches(abs_left),\n top=emu_to_inches(abs_top),\n width=emu_to_inches(shape.width or 0),\n height=emu_to_inches(shape.height or 0),\n )\n \n # Get placeholder type\n if shape.is_placeholder:\n shape_data.placeholder_type = get_placeholder_type_name(shape.placeholder_format)\n \n # Extract paragraphs\n for paragraph in text_frame.paragraphs:\n if paragraph.text.strip():\n para_data = ParagraphData.from_paragraph(paragraph)\n shape_data.paragraphs.append(para_data)\n \n # Track default font size\n if para_data.font_size and shape_data.default_font_size is None:\n shape_data.default_font_size = para_data.font_size\n \n return shape_data if shape_data.paragraphs else None\n\n\ndef extract_shapes_recursive(\n shapes, \n offset_left: int = 0, \n offset_top: int = 0\n) -> List[Tuple[ShapeData, int, int]]:\n \"\"\"Recursively extract text from shapes including group shapes.\n \n Args:\n shapes: Collection of shapes to process.\n offset_left: X offset for nested shapes (in EMUs).\n offset_top: Y offset for nested shapes (in EMUs).\n \n Returns:\n List of tuples containing (ShapeData, absolute_left, absolute_top).\n \"\"\"\n results: List[Tuple[ShapeData, int, int]] = []\n \n for shape in shapes:\n # Handle group shapes recursively\n if shape.shape_type == 6: # MSO_SHAPE_TYPE.GROUP\n group_left = (shape.left or 0) + offset_left\n group_top = (shape.top or 0) + offset_top\n results.extend(extract_shapes_recursive(\n shape.shapes, group_left, group_top\n ))\n else:\n shape_data = extract_shape_text(shape, offset_left, offset_top)\n if shape_data:\n abs_left = (shape.left or 0) + offset_left\n abs_top = (shape.top or 0) + offset_top\n results.append((shape_data, abs_left, abs_top))\n \n return results\n\n\ndef detect_overlaps(shapes: List[Tuple[ShapeData, int, int]]) -> None:\n \"\"\"Detect overlapping shapes and update their overlap property.\n \n Args:\n shapes: List of (ShapeData, left_emu, top_emu) tuples.\n \"\"\"\n for i, (shape1, left1, top1) in enumerate(shapes):\n overlaps: Dict[str, float] = {}\n \n for j, (shape2, left2, top2) in enumerate(shapes):\n if i == j:\n continue\n \n # Convert to inches for comparison\n s1_left = emu_to_inches(left1)\n s1_top = emu_to_inches(top1)\n s1_right = s1_left + shape1.width\n s1_bottom = s1_top + shape1.height\n \n s2_left = emu_to_inches(left2)\n s2_top = emu_to_inches(top2)\n s2_right = s2_left + shape2.width\n s2_bottom = s2_top + shape2.height\n \n # Check for overlap\n if (s1_left \u003c s2_right and s1_right > s2_left and\n s1_top \u003c s2_bottom and s1_bottom > s2_top):\n \n # Calculate overlap area\n overlap_left = max(s1_left, s2_left)\n overlap_right = min(s1_right, s2_right)\n overlap_top = max(s1_top, s2_top)\n overlap_bottom = min(s1_bottom, s2_bottom)\n \n overlap_width = overlap_right - overlap_left\n overlap_height = overlap_bottom - overlap_top\n overlap_area = overlap_width * overlap_height\n \n if overlap_area > 0:\n overlaps[f\"shape-{j}\"] = round(overlap_area, 2)\n \n if overlaps:\n shape1.overlap = {\"overlapping_shapes\": overlaps}\n\n\ndef extract_text_inventory(\n pptx_path: Path, \n issues_only: bool = False\n) -> Dict[str, Dict[str, Dict[str, Any]]]:\n \"\"\"Extract all text content from a PowerPoint presentation.\n \n Args:\n pptx_path: Path to the PowerPoint file.\n issues_only: If True, only include shapes with issues.\n \n Returns:\n Dictionary mapping slide IDs to shape IDs to shape data.\n \"\"\"\n prs = Presentation(pptx_path)\n inventory: Dict[str, Dict[str, Dict[str, Any]]] = {}\n \n for slide_idx, slide in enumerate(prs.slides):\n slide_key = f\"slide-{slide_idx}\"\n \n # Extract all text shapes from this slide\n shapes_with_positions = extract_shapes_recursive(slide.shapes)\n \n # Detect overlaps\n detect_overlaps(shapes_with_positions)\n \n # Sort by visual position (top to bottom, left to right)\n shapes_with_positions.sort(key=lambda x: (x[0].top, x[0].left))\n \n # Build slide inventory\n slide_shapes: Dict[str, Dict[str, Any]] = {}\n \n for shape_idx, (shape_data, _, _) in enumerate(shapes_with_positions):\n # Filter by issues if requested\n if issues_only:\n has_issues = (\n shape_data.overlap is not None or \n shape_data.overflow_bottom is not None\n )\n if not has_issues:\n continue\n \n shape_key = f\"shape-{shape_idx}\"\n slide_shapes[shape_key] = shape_data.to_dict()\n \n if slide_shapes:\n inventory[slide_key] = slide_shapes\n \n return inventory\n\n\ndef save_inventory(inventory: Dict[str, Dict[str, Dict[str, Any]]], output_path: Path) -> None:\n \"\"\"Save inventory data to JSON file.\n \n Args:\n inventory: The inventory data to save.\n output_path: Path for the output JSON file.\n \"\"\"\n with open(output_path, \"w\", encoding=\"utf-8\") as f:\n json.dump(inventory, f, indent=2, ensure_ascii=False)\n\n\ndef main() -> None:\n \"\"\"Main entry point for command-line usage.\"\"\"\n args = parse_arguments()\n \n input_path = Path(args.input)\n \n # Validate input file\n if not input_path.exists():\n print(f\"Error: Input file not found: {args.input}\")\n sys.exit(1)\n \n if not input_path.suffix.lower() == \".pptx\":\n print(\"Error: Input must be a PowerPoint file (.pptx)\")\n sys.exit(1)\n \n try:\n print(f\"Extracting text inventory from: {args.input}\")\n if args.issues_only:\n print(\"Filtering to include only text shapes with issues\")\n \n inventory = extract_text_inventory(input_path, issues_only=args.issues_only)\n \n output_path = Path(args.output)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n save_inventory(inventory, output_path)\n \n print(f\"Output saved to: {args.output}\")\n \n # Report statistics\n total_slides = len(inventory)\n total_shapes = sum(len(shapes) for shapes in inventory.values())\n \n if args.issues_only:\n if total_shapes > 0:\n print(f\"Found {total_shapes} text elements with issues in {total_slides} slides\")\n else:\n print(\"No issues discovered\")\n else:\n print(f\"Found text in {total_slides} slides with {total_shapes} text elements\")\n \n except Exception as e:\n print(f\"Error processing presentation: {e}\")\n import traceback\n traceback.print_exc()\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":17772,"content_sha256":"a826a41c8e6b9f49b28503a6394abb90f694c62c80ab4ad736206899be35cfaf"},{"filename":"scripts/insert_diagram_slides.py","content":"# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n#\n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\ninsert_diagram_slides.py - 図形スライドを正しい位置・正しいレイアウトで挿入\n\nUsage:\n python scripts/insert_diagram_slides.py \u003cbase.pptx> \u003cdiagrams.pptx> \u003coutput.pptx> --config \u003cconfig.json>\n\nconfig.json の形式:\n{\n \"insertions\": [\n {\"source_index\": 0, \"target_position\": 4, \"layout_name\": \"タイトルとコンテンツ\"},\n {\"source_index\": 1, \"target_position\": 7, \"layout_name\": \"タイトルとコンテンツ\"},\n {\"source_index\": 2, \"target_position\": 10, \"layout_name\": \"タイトルとコンテンツ\"}\n ]\n}\n\"\"\"\n\nimport argparse\nimport copy\nimport json\nimport sys\nfrom pathlib import Path\n\nfrom pptx import Presentation\nfrom pptx.util import Inches, Emu\n\n\ndef find_layout_by_name(prs, layout_name: str):\n \"\"\"レイアウト名でレイアウトを検索\"\"\"\n for slide_master in prs.slide_masters:\n for layout in slide_master.slide_layouts:\n if layout.name == layout_name:\n return layout\n return None\n\n\ndef find_content_layout(prs):\n \"\"\"コンテンツ系レイアウトを検索(フォールバック用)\"\"\"\n keywords = [\"コンテンツ\", \"Content\", \"content\", \"タイトルとコンテンツ\"]\n for slide_master in prs.slide_masters:\n for layout in slide_master.slide_layouts:\n for kw in keywords:\n if kw.lower() in layout.name.lower():\n return layout\n # 見つからない場合は最初のレイアウト\n return prs.slide_masters[0].slide_layouts[1] if len(prs.slide_masters[0].slide_layouts) > 1 else prs.slide_masters[0].slide_layouts[0]\n\n\ndef copy_shapes_to_slide(source_slide, target_slide, scale_x=1.0, scale_y=1.0, offset_x=0, offset_y=0):\n \"\"\"ソーススライドのシェイプをターゲットスライドにコピー\"\"\"\n copied = 0\n for shape in source_slide.shapes:\n try:\n sp = shape._element\n new_sp = copy.deepcopy(sp)\n \n # スケーリングとオフセットを適用\n if scale_x != 1.0 or scale_y != 1.0 or offset_x != 0 or offset_y != 0:\n # xfrm 要素を取得\n xfrm = new_sp.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}xfrm')\n if xfrm is not None:\n off = xfrm.find('{http://schemas.openxmlformats.org/drawingml/2006/main}off')\n ext = xfrm.find('{http://schemas.openxmlformats.org/drawingml/2006/main}ext')\n if off is not None:\n x = int(off.get('x', 0))\n y = int(off.get('y', 0))\n off.set('x', str(int(x * scale_x + offset_x)))\n off.set('y', str(int(y * scale_y + offset_y)))\n if ext is not None:\n cx = int(ext.get('cx', 0))\n cy = int(ext.get('cy', 0))\n ext.set('cx', str(int(cx * scale_x)))\n ext.set('cy', str(int(cy * scale_y)))\n \n target_slide.shapes._spTree.append(new_sp)\n copied += 1\n except Exception as e:\n print(f\" [!] Shape copy failed: {e}\")\n \n return copied\n\n\ndef insert_diagram_slides(base_path: str, diagrams_path: str, output_path: str, config: dict):\n \"\"\"\n 図形スライドを正しい位置に挿入\n \n Args:\n base_path: ベースPPTX(テンプレートから生成したもの)\n diagrams_path: 図形スライドPPTX(pptxgenjsで生成)\n output_path: 出力先\n config: 挿入設定\n \"\"\"\n print(f\"\\n📋 Insert Diagram Slides\")\n print(f\" Base: {base_path}\")\n print(f\" Diagrams: {diagrams_path}\")\n print(f\" Output: {output_path}\")\n print()\n \n # PPTXを読み込み\n base_prs = Presentation(base_path)\n diagrams_prs = Presentation(diagrams_path)\n \n # サイズを取得\n base_w = base_prs.slide_width.emu\n base_h = base_prs.slide_height.emu\n diag_w = diagrams_prs.slide_width.emu\n diag_h = diagrams_prs.slide_height.emu\n \n base_w_in = base_prs.slide_width.inches\n diag_w_in = diagrams_prs.slide_width.inches\n \n print(f\" Base size: {base_w_in:.2f}\\\" x {base_prs.slide_height.inches:.2f}\\\"\")\n print(f\" Diagrams size: {diag_w_in:.2f}\\\" x {diagrams_prs.slide_height.inches:.2f}\\\"\")\n \n # スケール計算\n scale_x = base_w / diag_w if diag_w != base_w else 1.0\n scale_y = base_h / diag_h if diag_h != base_h else 1.0\n \n if scale_x != 1.0 or scale_y != 1.0:\n print(f\" Scale: {scale_x:.2%} x {scale_y:.2%}\")\n print()\n \n # 挿入設定を位置でソート(後ろから挿入するため降順)\n insertions = sorted(config.get(\"insertions\", []), key=lambda x: x[\"target_position\"], reverse=True)\n \n print(f\" Insertions: {len(insertions)}\")\n for ins in reversed(insertions): # 表示は昇順\n print(f\" - Source[{ins['source_index']}] → Position {ins['target_position']} ({ins.get('layout_name', 'default')})\")\n print()\n \n # 後ろから挿入(インデックスがずれないように)\n for ins in insertions:\n source_idx = ins[\"source_index\"]\n target_pos = ins[\"target_position\"]\n layout_name = ins.get(\"layout_name\", \"タイトルとコンテンツ\")\n \n if source_idx >= len(diagrams_prs.slides):\n print(f\" [!] Source index {source_idx} out of range, skipping\")\n continue\n \n source_slide = diagrams_prs.slides[source_idx]\n \n # レイアウトを取得\n layout = find_layout_by_name(base_prs, layout_name)\n if layout is None:\n print(f\" [!] Layout '{layout_name}' not found, using fallback\")\n layout = find_content_layout(base_prs)\n \n # 新しいスライドを挿入\n # python-pptx では add_slide は末尾に追加するので、XML操作で位置を調整\n new_slide = base_prs.slides.add_slide(layout)\n \n # スライドの位置を調整\n slide_id = new_slide.slide_id\n sldIdLst = base_prs.part._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}sldIdLst')\n \n if sldIdLst is not None:\n # 最後に追加されたスライドを取得\n sld_ids = list(sldIdLst)\n if len(sld_ids) > 0:\n last_sld_id = sld_ids[-1]\n # 目的の位置に移動\n if target_pos \u003c len(sld_ids) - 1:\n target_sld_id = sld_ids[target_pos]\n sldIdLst.remove(last_sld_id)\n sldIdLst.insert(target_pos, last_sld_id)\n \n # プレースホルダーを削除(図形で上書きするため)\n for shape in list(new_slide.shapes):\n try:\n sp = shape._element\n sp.getparent().remove(sp)\n except:\n pass\n \n # シェイプをコピー\n copied = copy_shapes_to_slide(source_slide, new_slide, scale_x, scale_y)\n \n # ノートをコピー\n if source_slide.has_notes_slide:\n try:\n notes_text = source_slide.notes_slide.notes_text_frame.text\n if notes_text:\n if not new_slide.has_notes_slide:\n new_slide.notes_slide\n new_slide.notes_slide.notes_text_frame.text = notes_text\n except:\n pass\n \n print(f\" [Slide {target_pos + 1}] Inserted with {copied} shapes (Layout: {layout.name})\")\n \n # 保存\n base_prs.save(output_path)\n print()\n print(f\"✅ Created: {output_path}\")\n print(f\" Total slides: {len(base_prs.slides)}\")\n \n return len(base_prs.slides)\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Insert diagram slides at specific positions\")\n parser.add_argument(\"base\", help=\"Base PPTX (from template)\")\n parser.add_argument(\"diagrams\", help=\"Diagram slides PPTX (from pptxgenjs)\")\n parser.add_argument(\"output\", help=\"Output PPTX\")\n parser.add_argument(\"--config\", required=True, help=\"Config JSON file\")\n \n args = parser.parse_args()\n \n # 設定を読み込み\n with open(args.config, \"r\", encoding=\"utf-8\") as f:\n config = json.load(f)\n \n insert_diagram_slides(args.base, args.diagrams, args.output, config)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8839,"content_sha256":"4d1a6a0751319ee26885579b72653e382cefeae04ce613b22def606dab830056"},{"filename":"scripts/merge_slides.py","content":"# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n#\n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nmerge_slides.py - pptxgenjs で生成した構成図をテンプレートにマージ\n\nUsage:\n python scripts/merge_slides.py \u003ctemplate.pptx> \u003csource.pptx> \u003coutput.pptx>\n python scripts/merge_slides.py \u003ctemplate.pptx> \u003csource.pptx> \u003coutput.pptx> --position 0\n\nArguments:\n template.pptx : マスター継承元のテンプレート\n source.pptx : pptxgenjs 等で生成した構成図スライド\n output.pptx : 出力先\n\nOptions:\n --position N : 挿入位置(0=先頭, -1=末尾, default: -1)\n --clear-template : テンプレートの既存スライドを削除(マスターのみ継承)\n --keep-source-master : ソースのマスター色を維持(テンプレート色に変換しない)\n\nExamples:\n # 構成図をテンプレートの末尾にマージ\n python scripts/merge_slides.py templates/Mytemplate_MS.pptx output_ppt/diagram.pptx output_ppt/merged.pptx\n\n # テンプレートのスライドを削除してマスターのみ継承(★ 推奨)\n python scripts/merge_slides.py templates/Mytemplate_MS.pptx output_ppt/diagram.pptx output_ppt/merged.pptx --clear-template\n\n # 先頭に挿入\n python scripts/merge_slides.py templates/Mytemplate_MS.pptx output_ppt/diagram.pptx output_ppt/merged.pptx --position 0\n\"\"\"\n\nimport argparse\nimport copy\nimport sys\nfrom pathlib import Path\nfrom typing import Optional\n\nfrom pptx import Presentation\nfrom pptx.util import Inches, Pt, Emu\nfrom pptx.dml.color import RGBColor\nfrom pptx.enum.shapes import MSO_SHAPE_TYPE\nfrom pptx.oxml.ns import qn\nfrom lxml import etree\n\n\ndef copy_shape(shape, target_slide):\n \"\"\"\n シェイプを別のスライドにコピーする\n \n Args:\n shape: コピー元のシェイプ\n target_slide: コピー先のスライド\n \n Returns:\n コピーされたシェイプ(または None)\n \"\"\"\n try:\n # XML要素を直接コピー(最も確実な方法)\n sp = shape._element\n new_sp = copy.deepcopy(sp)\n target_slide.shapes._spTree.append(new_sp)\n return new_sp\n except Exception as e:\n print(f\" [!] Failed to copy shape: {shape.shape_type} - {e}\")\n return None\n\n\ndef merge_slides(\n template_path: str,\n source_path: str,\n output_path: str,\n position: int = -1,\n keep_source_master: bool = False,\n clear_template: bool = False\n) -> int:\n \"\"\"\n テンプレートに pptxgenjs で作成したスライドをマージする\n \n Args:\n template_path: テンプレート PPTX(マスター継承元)\n source_path: pptxgenjs で生成した構成図 PPTX\n output_path: 出力先\n position: 挿入位置(-1=末尾, 0=先頭, N=N番目の後)\n keep_source_master: True の場合、ソースの色を維持\n clear_template: True の場合、テンプレートの既存スライドを削除\n \n Returns:\n マージされたスライド数\n \"\"\"\n print(f\"\\n📋 Merge Slides\")\n print(f\" Template: {template_path}\")\n print(f\" Source: {source_path}\")\n print(f\" Output: {output_path}\")\n print(f\" Position: {'末尾' if position == -1 else f'{position}番目'}\")\n \n # ファイル存在確認\n if not Path(template_path).exists():\n print(f\"❌ Template not found: {template_path}\")\n return 0\n if not Path(source_path).exists():\n print(f\"❌ Source not found: {source_path}\")\n return 0\n \n # プレゼンテーションを開く\n template = Presentation(template_path)\n source = Presentation(source_path)\n \n print(f\"\\n Template slides: {len(template.slides)}\")\n print(f\" Source slides: {len(source.slides)}\")\n \n # テンプレートのスライドを削除(--clear-template オプション)\n if clear_template and len(template.slides) > 0:\n print(f\"\\n 🗑️ Clearing {len(template.slides)} template slides (keeping master only)...\")\n # スライドをすべて削除(逆順で削除)\n xml_slides = template.slides._sldIdLst\n slides_to_remove = list(xml_slides)\n for sldId in slides_to_remove:\n rId = sldId.rId\n template.part.drop_rel(rId)\n xml_slides.remove(sldId)\n print(f\" ✅ Template slides cleared\")\n \n # テンプレートのスライドサイズを取得\n template_width = template.slide_width\n template_height = template.slide_height\n source_width = source.slide_width\n source_height = source.slide_height\n \n # サイズ比率を計算(スケーリング用)\n width_ratio = template_width / source_width\n height_ratio = template_height / source_height\n scale_ratio = min(width_ratio, height_ratio) # アスペクト比維持\n \n if abs(scale_ratio - 1.0) > 0.01:\n print(f\"\\n ⚠️ Size difference detected:\")\n print(f\" Template: {template_width.inches:.2f}\\\" x {template_height.inches:.2f}\\\"\")\n print(f\" Source: {source_width.inches:.2f}\\\" x {source_height.inches:.2f}\\\"\")\n print(f\" Scale: {scale_ratio:.2%}\")\n \n # Blank レイアウトを探す(最後のレイアウトを使用)\n blank_layout = None\n for layout in template.slide_layouts:\n if \"blank\" in layout.name.lower() or layout.name == \"Blank\":\n blank_layout = layout\n break\n \n if blank_layout is None:\n # Blank が見つからない場合は最後のレイアウトを使用\n blank_layout = template.slide_layouts[-1]\n print(f\" Using layout: {blank_layout.name} (fallback)\")\n else:\n print(f\" Using layout: {blank_layout.name}\")\n \n # 挿入位置を計算\n if position == -1:\n insert_index = len(template.slides)\n elif position >= 0:\n insert_index = min(position, len(template.slides))\n else:\n insert_index = len(template.slides)\n \n merged_count = 0\n \n # 各スライドをコピー\n for slide_idx, slide in enumerate(source.slides):\n print(f\"\\n [Slide {slide_idx + 1}] Copying {len(slide.shapes)} shapes...\")\n \n # 新しいスライドを追加\n new_slide = template.slides.add_slide(blank_layout)\n \n # スライドを正しい位置に移動\n if insert_index \u003c len(template.slides) - 1:\n # スライドの順序を変更\n slide_id = new_slide.slide_id\n # XMLで順序を操作\n slides_part = template.slides._sldIdLst\n slide_elem = slides_part[-1] # 追加したスライド\n slides_part.remove(slide_elem)\n slides_part.insert(insert_index + merged_count, slide_elem)\n \n # シェイプをコピー\n shape_count = 0\n for shape in slide.shapes:\n # プレースホルダーはスキップ(空の場合)\n if shape.is_placeholder:\n continue\n \n result = copy_shape(shape, new_slide)\n if result is not None:\n shape_count += 1\n \n # スケーリングが必要な場合\n if abs(scale_ratio - 1.0) > 0.01:\n try:\n # 位置とサイズをスケーリング\n if hasattr(shape, 'left'):\n result.set(qn('p:x'), str(int(int(result.get(qn('p:x'), 0)) * scale_ratio)))\n if hasattr(shape, 'top'):\n result.set(qn('p:y'), str(int(int(result.get(qn('p:y'), 0)) * scale_ratio)))\n except:\n pass\n \n # ノートをコピー\n if slide.has_notes_slide:\n notes_text = slide.notes_slide.notes_text_frame.text\n if notes_text:\n notes_slide = new_slide.notes_slide\n notes_slide.notes_text_frame.text = notes_text\n print(f\" 📝 Notes copied\")\n \n print(f\" ✅ Copied {shape_count} shapes\")\n merged_count += 1\n \n # 保存\n Path(output_path).parent.mkdir(parents=True, exist_ok=True)\n template.save(output_path)\n \n print(f\"\\n✅ Merged {merged_count} slides\")\n print(f\" Output: {output_path}\")\n print(f\" Total slides: {len(template.slides)}\")\n \n return merged_count\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"pptxgenjs で生成した構成図をテンプレートにマージ\"\n )\n parser.add_argument(\"template\", help=\"テンプレート PPTX(マスター継承元)\")\n parser.add_argument(\"source\", help=\"pptxgenjs 等で生成した構成図 PPTX\")\n parser.add_argument(\"output\", help=\"出力先 PPTX\")\n parser.add_argument(\n \"--position\", type=int, default=-1,\n help=\"挿入位置(0=先頭, -1=末尾, N=N番目の後)\"\n )\n parser.add_argument(\n \"--clear-template\", action=\"store_true\",\n help=\"テンプレートの既存スライドを削除(マスターのみ継承)\"\n )\n parser.add_argument(\n \"--keep-source-master\", action=\"store_true\",\n help=\"ソースのマスター色を維持\"\n )\n \n args = parser.parse_args()\n \n count = merge_slides(\n args.template,\n args.source,\n args.output,\n args.position,\n args.keep_source_master,\n getattr(args, 'clear_template', False)\n )\n \n sys.exit(0 if count > 0 else 1)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9866,"content_sha256":"79287aed8db5f0bdd42549d6733e92c0d52d976c030b2493ce1032f52a33597d"},{"filename":"scripts/package.json","content":"{\n \"name\": \"pptx-tools\",\n \"version\": \"0.1.0\",\n \"description\": \"Utilities for PPTX generation\",\n \"main\": \"scripts/convert_html.js\",\n \"scripts\": {\n \"convert-html\": \"node scripts/convert_html.js\"\n },\n \"dependencies\": {\n \"playwright\": \"^1.41.2\",\n \"pptxgenjs\": \"^3.12.0\",\n \"sharp\": \"^0.33.2\"\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":312,"content_sha256":"ac9e2ea9b6f38c77537de0e4b9cce3b7fba347c119c6f9516c0488b5a3103739"},{"filename":"scripts/pptx-signature.js","content":"// =============================================================================\n// Agent-Skills/powerpoint-automation - AI-powered PPTX generation pipeline\n// https://github.com/aktsmm/Agent-Skills/tree/main/powerpoint-automation\n//\n// Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n// DO NOT MODIFY THIS HEADER BLOCK.\n// =============================================================================\n/**\n * pptx-signature.js - Common signature helper for pptxgenjs\n *\n * Adds repository attribution to generated PowerPoint files.\n * Used by all pptxgenjs-based scripts in this repository.\n *\n * Usage:\n * const { addSignature, getRepoUrl } = require('./pptx-signature');\n *\n * // After creating slides\n * addSignature(firstSlide, lastSlide);\n *\n * Author: aktsmm\n * License: CC BY-NC 4.0\n */\n\nconst { execSync } = require(\"child_process\");\n\n// Default repository URL (fallback)\nconst DEFAULT_REPO_URL =\n \"https://github.com/aktsmm/Agent-Skills/tree/main/powerpoint-automation\";\n\n/**\n * Get repository URL from git remote\n * @returns {string} Repository URL\n */\nfunction getRepoUrl() {\n try {\n const url = execSync(\"git remote get-url origin\", { encoding: \"utf-8\" })\n .trim()\n .replace(/\\.git$/, \"\");\n return url || DEFAULT_REPO_URL;\n } catch {\n return DEFAULT_REPO_URL;\n }\n}\n\n/**\n * Get signature text for first slide\n * @param {string} [repoUrl] - Optional custom repo URL\n * @returns {string} Signature text\n */\nfunction getFirstSlideSignature(repoUrl) {\n const url = repoUrl || getRepoUrl();\n return `📌 Generated by: ${url}`;\n}\n\n/**\n * Get signature text for last slide\n * @param {string} [repoUrl] - Optional custom repo URL\n * @returns {string} Signature text\n */\nfunction getLastSlideSignature(repoUrl) {\n const url = repoUrl || getRepoUrl();\n return `---\\n🔧 This presentation was created using Ag-ppt-create\\n${url}`;\n}\n\n/**\n * Add signature to first and last slides\n * Appends to existing notes if present\n *\n * @param {Object} firstSlide - First slide object from pptxgenjs\n * @param {Object} lastSlide - Last slide object from pptxgenjs\n * @param {Object} [options] - Options\n * @param {string} [options.repoUrl] - Custom repository URL\n * @param {string} [options.firstNotes] - Existing notes for first slide\n * @param {string} [options.lastNotes] - Existing notes for last slide\n */\nfunction addSignature(firstSlide, lastSlide, options = {}) {\n const repoUrl = options.repoUrl || getRepoUrl();\n\n // First slide signature\n const firstSig = getFirstSlideSignature(repoUrl);\n const firstNotes = options.firstNotes\n ? `${firstSig}\\n\\n${options.firstNotes}`\n : firstSig;\n firstSlide.addNotes(firstNotes);\n\n // Last slide signature (only if different from first)\n if (lastSlide !== firstSlide) {\n const lastSig = getLastSlideSignature(repoUrl);\n const lastNotes = options.lastNotes\n ? `${options.lastNotes}\\n\\n${lastSig}`\n : lastSig;\n lastSlide.addNotes(lastNotes);\n }\n\n console.log(`📝 Signature added: ${repoUrl}`);\n}\n\nmodule.exports = {\n addSignature,\n getRepoUrl,\n getFirstSlideSignature,\n getLastSlideSignature,\n DEFAULT_REPO_URL,\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":3133,"content_sha256":"9e5ae471fe3b2697230a6041adeee4f0df6b8af7e80aeb0cc99a95c3fa9be5d0"},{"filename":"scripts/reconstruct_analyzer.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nAnalyze English PPTX content and convert to IR (content.json) for reconstruction.\n\nThis script takes an English PPTX file, extracts its content using extract_shapes.py,\nanalyzes the slide structure, and generates a content.json that can be used with\ncreate_from_template.py to reconstruct the presentation using the original's\nslide master layouts.\n\nKey features:\n- Analyzes slide content to determine optimal layout type\n- Preserves text hierarchy (title, body, bullets)\n- Identifies images for extraction\n- Maps to appropriate slide master layouts\n\nUsage:\n python scripts/reconstruct_analyzer.py \u003cinput.pptx> \u003coutput_content.json> [--layouts \u003clayouts.json>]\n python scripts/reconstruct_analyzer.py \u003cinput.pptx> --analyze-only\n\nExamples:\n # Full pipeline: analyze and generate content.json\n python scripts/reconstruct_analyzer.py input/presentation.pptx output_manifest/content.json\n\n # With pre-analyzed layouts\n python scripts/reconstruct_analyzer.py input/presentation.pptx output_manifest/content.json --layouts output_manifest/custom_layouts.json\n\n # Just analyze structure without generating content.json\n python scripts/reconstruct_analyzer.py input/presentation.pptx --analyze-only\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nimport io\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional, Tuple\nfrom dataclasses import dataclass, field\n\n# Fix Windows console encoding issues\nsys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')\nsys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')\n\nfrom pptx import Presentation\nfrom pptx.enum.shapes import MSO_SHAPE_TYPE\n\n\n@dataclass\nclass SlideContent:\n \"\"\"Represents extracted content from a single slide.\"\"\"\n slide_index: int\n title: str = \"\"\n subtitle: str = \"\"\n body_items: List[str] = field(default_factory=list)\n bullet_levels: List[int] = field(default_factory=list)\n has_image: bool = False\n image_count: int = 0\n has_table: bool = False\n has_chart: bool = False\n has_diagram: bool = False\n shape_count: int = 0\n text_density: str = \"normal\" # low, normal, high\n layout_name: str = \"\"\n detected_type: str = \"content\" # title, section, content, two_column, photo, closing, etc.\n notes: str = \"\"\n\n\ndef analyze_slide_layout(slide) -> Tuple[str, List[Dict]]:\n \"\"\"Analyze the slide's current layout and extract placeholder info.\n \n Args:\n slide: PowerPoint slide object.\n \n Returns:\n Tuple of (layout_name, list of placeholder info dicts).\n \"\"\"\n layout_name = slide.slide_layout.name or \"Unknown\"\n placeholders = []\n \n for shape in slide.slide_layout.placeholders:\n ph_type = str(shape.placeholder_format.type).replace(\"PLACEHOLDER_TYPE.\", \"\")\n placeholders.append({\n \"idx\": shape.placeholder_format.idx,\n \"type\": ph_type,\n })\n \n return layout_name, placeholders\n\n\ndef extract_title_and_body(slide) -> Tuple[str, str, List[str], List[int]]:\n \"\"\"Extract title, subtitle, and body items from a slide.\n \n Args:\n slide: PowerPoint slide object.\n \n Returns:\n Tuple of (title, subtitle, body_items, bullet_levels).\n \"\"\"\n title = \"\"\n subtitle = \"\"\n body_items = []\n bullet_levels = []\n \n for shape in slide.shapes:\n if not shape.has_text_frame:\n continue\n \n # Check if this is a placeholder\n if shape.is_placeholder:\n ph_type = str(shape.placeholder_format.type)\n \n # Handle title placeholders\n if \"TITLE\" in ph_type or \"CENTER_TITLE\" in ph_type:\n text = shape.text_frame.text.strip()\n # Clean soft line breaks\n text = text.replace('\\x0b', ' ').replace('\\u000b', ' ')\n title = text\n continue\n \n # Handle subtitle\n if \"SUBTITLE\" in ph_type:\n text = shape.text_frame.text.strip()\n text = text.replace('\\x0b', ' ').replace('\\u000b', ' ')\n subtitle = text\n continue\n \n # Handle body/content placeholders\n if \"BODY\" in ph_type or \"OBJECT\" in ph_type or \"CONTENT\" in ph_type:\n for paragraph in shape.text_frame.paragraphs:\n text = paragraph.text.strip()\n if text:\n text = text.replace('\\x0b', ' ').replace('\\u000b', ' ')\n body_items.append(text)\n \n # Detect bullet level from paragraph properties\n level = paragraph.level if paragraph.level else 0\n bullet_levels.append(level)\n continue\n \n # Non-placeholder text shapes - add to body if substantial\n if shape.text_frame.text.strip():\n # Skip slide numbers, footers, etc. (typically small shapes at bottom)\n if shape.top and shape.height:\n # Check if it's in the footer area (bottom 15% of slide)\n # Standard slide height is ~5.625 inches (16:9) = ~5143500 EMU\n SLIDE_HEIGHT_EMU = 5143500\n if shape.top > SLIDE_HEIGHT_EMU * 0.85:\n continue\n \n for paragraph in shape.text_frame.paragraphs:\n text = paragraph.text.strip()\n if text and len(text) > 3: # Skip very short text\n text = text.replace('\\x0b', ' ').replace('\\u000b', ' ')\n # Only add if not already captured\n if text not in body_items and text != title and text != subtitle:\n body_items.append(text)\n bullet_levels.append(0)\n \n return title, subtitle, body_items, bullet_levels\n\n\ndef count_visual_elements(slide) -> Dict[str, int]:\n \"\"\"Count images, tables, charts, and diagrams in a slide.\n \n Args:\n slide: PowerPoint slide object.\n \n Returns:\n Dict with counts for each element type.\n \"\"\"\n counts = {\n \"images\": 0,\n \"tables\": 0,\n \"charts\": 0,\n \"diagrams\": 0, # SmartArt, grouped shapes\n \"shapes\": 0,\n }\n \n def count_recursive(shapes):\n for shape in shapes:\n # Group shapes\n if shape.shape_type == MSO_SHAPE_TYPE.GROUP:\n counts[\"diagrams\"] += 1\n # Don't recurse into groups - treat as single diagram\n continue\n \n # Pictures\n if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:\n counts[\"images\"] += 1\n continue\n \n # Tables\n if shape.has_table:\n counts[\"tables\"] += 1\n continue\n \n # Charts\n if shape.has_chart:\n counts[\"charts\"] += 1\n continue\n \n # SmartArt / Diagrams\n if hasattr(shape, 'ole_format') or 'SmartArt' in str(type(shape)):\n counts[\"diagrams\"] += 1\n continue\n \n # Other shapes (excluding placeholders and text boxes)\n if not shape.is_placeholder and not shape.has_text_frame:\n if shape.shape_type not in [MSO_SHAPE_TYPE.TEXT_BOX]:\n counts[\"shapes\"] += 1\n \n count_recursive(slide.shapes)\n return counts\n\n\ndef infer_title_from_notes(notes: str) -> str:\n \"\"\"Infer a slide title from speaker notes when title is missing.\n \n Args:\n notes: Speaker notes text.\n \n Returns:\n Inferred title or empty string.\n \"\"\"\n if not notes:\n return \"\"\n \n # Take first sentence or first line (up to 60 chars)\n lines = notes.strip().split('\\n')\n first_line = lines[0].strip()\n \n # If first line is a question or statement, use it\n if first_line and len(first_line) \u003c= 80:\n # Clean up common patterns\n if first_line.endswith((':',)):\n first_line = first_line[:-1]\n return first_line[:60] + ('...' if len(first_line) > 60 else '')\n \n # Try to extract key phrase from notes\n sentences = notes.replace('\\n', ' ').split('.')\n if sentences and len(sentences[0].strip()) \u003c= 60:\n return sentences[0].strip()\n \n return \"\"\n\n\ndef is_empty_slide(content: SlideContent) -> tuple[bool, str]:\n \"\"\"Check if a slide is empty (only notes or background).\n \n Args:\n content: SlideContent object with extracted data.\n \n Returns:\n Tuple of (is_empty, reason).\n \"\"\"\n has_title = bool(content.title and content.title.strip())\n has_body = bool(content.body_items)\n has_visual = content.has_image or content.has_chart or content.has_table or content.has_diagram\n has_notes = bool(content.notes and content.notes.strip())\n \n # Slide with ONLY speaker notes = empty (useless for presentation)\n if has_notes and not has_title and not has_body and not has_visual:\n return True, \"notes_only\"\n \n # Completely blank slide (no content at all)\n if not has_title and not has_body and not has_visual and not has_notes:\n return True, \"blank\"\n \n # Title-only slides are OK (section headers, etc.)\n return False, \"\"\n\n\ndef detect_slide_type(content: SlideContent, layout_name: str) -> str:\n \"\"\"Detect the appropriate slide type based on content analysis.\n \n Args:\n content: SlideContent object with extracted data.\n layout_name: Name of the current layout.\n \n Returns:\n Detected slide type string.\n \"\"\"\n layout_lower = layout_name.lower()\n title_lower = content.title.lower() if content.title else \"\"\n \n # Title slide detection\n if \"title slide\" in layout_lower or \"タイトル スライド\" in layout_lower:\n return \"title\"\n if content.subtitle and not content.body_items and not content.has_image:\n return \"title\"\n \n # Section header detection\n if \"section\" in layout_lower or \"セクション\" in layout_lower:\n return \"section\"\n if len(content.body_items) == 0 and content.title and not content.subtitle:\n # Just a title, likely a section divider\n if len(content.title) \u003c 50: # Short title\n return \"section\"\n \n # Closing slide detection\n if \"closing\" in layout_lower or \"end\" in layout_lower:\n return \"closing\"\n closing_keywords = [\"thank you\", \"thanks\", \"questions\", \"q&a\", \"contact\", \n \"ありがとう\", \"質問\", \"お問い合わせ\"]\n if any(kw in title_lower for kw in closing_keywords):\n return \"closing\"\n \n # Agenda detection\n agenda_keywords = [\"agenda\", \"outline\", \"contents\", \"目次\", \"アジェンダ\", \"概要\"]\n if any(kw in title_lower for kw in agenda_keywords):\n return \"agenda\"\n \n # Photo/image-heavy slide\n if content.has_image and content.image_count >= 1:\n if len(content.body_items) \u003c= 2:\n return \"photo\"\n \n # Two-column detection (based on layout name or content pattern)\n if \"two column\" in layout_lower or \"2列\" in layout_lower or \"comparison\" in layout_lower:\n return \"two_column\"\n \n # Summary detection\n summary_keywords = [\"summary\", \"まとめ\", \"conclusion\", \"key takeaway\", \"結論\"]\n if any(kw in title_lower for kw in summary_keywords):\n return \"summary\"\n \n # Default to content\n return \"content\"\n\n\ndef extract_slide_notes(slide) -> str:\n \"\"\"Extract speaker notes from a slide.\n \n Args:\n slide: PowerPoint slide object.\n \n Returns:\n Notes text or empty string.\n \"\"\"\n if slide.has_notes_slide:\n notes_frame = slide.notes_slide.notes_text_frame\n if notes_frame:\n return notes_frame.text.strip()\n return \"\"\n\n\ndef analyze_presentation(pptx_path: Path) -> List[SlideContent]:\n \"\"\"Analyze all slides in a presentation.\n \n Args:\n pptx_path: Path to the PowerPoint file.\n \n Returns:\n List of SlideContent objects.\n \"\"\"\n prs = Presentation(pptx_path)\n slides_content = []\n \n for idx, slide in enumerate(prs.slides):\n # Get layout info\n layout_name, placeholders = analyze_slide_layout(slide)\n \n # Extract text content\n title, subtitle, body_items, bullet_levels = extract_title_and_body(slide)\n \n # Count visual elements\n counts = count_visual_elements(slide)\n \n # Get notes\n notes = extract_slide_notes(slide)\n \n # Create content object\n content = SlideContent(\n slide_index=idx,\n title=title,\n subtitle=subtitle,\n body_items=body_items,\n bullet_levels=bullet_levels,\n has_image=counts[\"images\"] > 0,\n image_count=counts[\"images\"],\n has_table=counts[\"tables\"] > 0,\n has_chart=counts[\"charts\"] > 0,\n has_diagram=counts[\"diagrams\"] > 0,\n shape_count=sum(counts.values()),\n layout_name=layout_name,\n notes=notes,\n )\n \n # Detect text density\n total_text = len(title) + len(subtitle) + sum(len(item) for item in body_items)\n if total_text \u003c 50:\n content.text_density = \"low\"\n elif total_text > 300:\n content.text_density = \"high\"\n else:\n content.text_density = \"normal\"\n \n # Detect slide type\n content.detected_type = detect_slide_type(content, layout_name)\n \n # Check for empty slides (notes only, blank)\n is_empty, reason = is_empty_slide(content)\n if is_empty:\n if reason == \"notes_only\":\n print(f\" ⚠️ Slide {idx + 1}: EMPTY - only has speaker notes (no visible content)\")\n elif reason == \"blank\":\n print(f\" ⚠️ Slide {idx + 1}: BLANK - no content at all\")\n content.detected_type = \"_empty\" # Mark for filtering\n \n # If title is empty, try to infer from notes\n if not content.title and content.notes:\n inferred = infer_title_from_notes(content.notes)\n if inferred:\n content.title = inferred\n print(f\" Slide {idx + 1}: Inferred title from notes: '{inferred[:40]}...'\")\n \n slides_content.append(content)\n \n return slides_content\n\n\ndef generate_content_json(\n slides_content: List[SlideContent],\n layouts_mapping: Optional[Dict] = None\n) -> Dict[str, Any]:\n \"\"\"Generate content.json structure from analyzed slides.\n \n Args:\n slides_content: List of SlideContent objects.\n layouts_mapping: Optional layout mapping from analyze_template.py.\n \n Returns:\n Dict structure for content.json.\n \"\"\"\n result = {\n \"title\": \"\",\n \"subtitle\": \"\",\n \"slides\": [],\n }\n \n # Extract presentation title from first title slide\n for content in slides_content:\n if content.detected_type == \"title\":\n result[\"title\"] = content.title\n result[\"subtitle\"] = content.subtitle\n break\n \n # Process each slide\n for content in slides_content:\n slide_data = {\n \"type\": content.detected_type,\n \"title\": content.title,\n }\n \n # Add subtitle for title/section slides\n if content.subtitle and content.detected_type in [\"title\", \"section\"]:\n slide_data[\"subtitle\"] = content.subtitle\n \n # Add body items\n if content.body_items:\n # Check if we should use 'items' or 'bullets' format\n if content.detected_type in [\"content\", \"agenda\", \"summary\", \"two_column\"]:\n # Convert to items with bullet structure if needed\n if any(level > 0 for level in content.bullet_levels):\n # Has nested bullets - use bullets format\n slide_data[\"bullets\"] = []\n for item, level in zip(content.body_items, content.bullet_levels):\n slide_data[\"bullets\"].append({\n \"text\": item,\n \"level\": level,\n })\n else:\n # Flat list\n slide_data[\"items\"] = content.body_items\n \n # Add image placeholder if slide has images\n if content.has_image:\n slide_data[\"image\"] = {\n \"path\": f\"images/slide_{content.slide_index + 1:02d}.png\",\n \"position\": \"right\",\n \"width_percent\": 45,\n \"_note\": \"TODO: Extract actual image from original PPTX\"\n }\n \n # Add notes if present, with source reference\n source_ref = f\"[出典: 元スライド #{content.slide_index + 1}]\"\n if content.notes:\n slide_data[\"notes\"] = f\"{content.notes}\\n\\n---\\n{source_ref}\"\n else:\n slide_data[\"notes\"] = source_ref\n \n # Add metadata for debugging/review\n slide_data[\"_source\"] = {\n \"original_index\": content.slide_index,\n \"original_layout\": content.layout_name,\n \"has_chart\": content.has_chart,\n \"has_table\": content.has_table,\n \"has_diagram\": content.has_diagram,\n \"text_density\": content.text_density,\n }\n \n result[\"slides\"].append(slide_data)\n \n return result\n\n\ndef print_analysis_summary(slides_content: List[SlideContent]) -> None:\n \"\"\"Print human-readable analysis summary.\n \n Args:\n slides_content: List of SlideContent objects.\n \"\"\"\n print(f\"\\n{'='*60}\")\n print(f\"Slide Analysis Summary\")\n print(f\"{'='*60}\\n\")\n \n type_counts = {}\n for content in slides_content:\n t = content.detected_type\n type_counts[t] = type_counts.get(t, 0) + 1\n \n # Count empty slides\n empty_count = sum(1 for c in slides_content if c.detected_type == \"_empty\")\n \n print(f\"Total slides: {len(slides_content)}\")\n if empty_count > 0:\n print(f\"⚠️ Empty slides (notes-only or blank): {empty_count}\")\n print(f\" These should be EXCLUDED from output!\")\n \n print(f\"\\nSlide types detected:\")\n for t, count in sorted(type_counts.items()):\n if t == \"_empty\":\n print(f\" {t:15} : {count} ⚠️ EXCLUDE THESE\")\n else:\n print(f\" {t:15} : {count}\")\n \n print(f\"\\n{'─'*60}\")\n print(f\"{'#':>3} {'Type':12} {'Layout':25} Title\")\n print(f\"{'─'*60}\")\n \n for content in slides_content:\n title_preview = content.title[:35] + \"...\" if len(content.title) > 35 else content.title\n flags = []\n if content.has_image:\n flags.append(f\"🖼️{content.image_count}\")\n if content.has_chart:\n flags.append(\"📊\")\n if content.has_table:\n flags.append(\"📋\")\n if content.has_diagram:\n flags.append(\"🔷\")\n \n flag_str = \" \".join(flags)\n print(f\"{content.slide_index + 1:3} {content.detected_type:12} {content.layout_name[:25]:25} {title_preview} {flag_str}\")\n \n print(f\"{'─'*60}\\n\")\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Analyze English PPTX and generate content.json for reconstruction.\",\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=__doc__\n )\n parser.add_argument(\"input\", help=\"Input PowerPoint file (.pptx)\")\n parser.add_argument(\"output\", nargs=\"?\", help=\"Output content.json file\")\n parser.add_argument(\"--layouts\", help=\"Layout mapping JSON from analyze_template.py\")\n parser.add_argument(\"--context\", help=\"Context JSON from classify_input.py (for base name)\")\n parser.add_argument(\"--analyze-only\", action=\"store_true\", \n help=\"Only analyze, don't generate content.json\")\n parser.add_argument(\"--include-metadata\", action=\"store_true\",\n help=\"Include _source metadata in output (for debugging)\")\n \n args = parser.parse_args()\n \n input_path = Path(args.input)\n \n # Load context if provided\n context = None\n if args.context:\n try:\n with open(args.context, 'r', encoding='utf-8') as f:\n context = json.load(f)\n print(f\"📋 Loaded context: {context.get('base_name', 'N/A')}\")\n except Exception as e:\n print(f\"Warning: Could not load context: {e}\")\n \n # Validate input\n if not input_path.exists():\n print(f\"Error: Input file not found: {args.input}\")\n sys.exit(1)\n \n if not input_path.suffix.lower() == \".pptx\":\n print(\"Error: Input must be a PowerPoint file (.pptx)\")\n sys.exit(1)\n \n # Analyze presentation\n print(f\"Analyzing: {args.input}\")\n slides_content = analyze_presentation(input_path)\n \n # Print summary\n print_analysis_summary(slides_content)\n \n if args.analyze_only:\n print(\"Analysis complete (--analyze-only mode)\")\n return\n \n # Need output path\n if not args.output:\n # Auto-generate output path using context or input stem\n if context and context.get(\"base_name\"):\n base_name = context[\"base_name\"]\n else:\n base_name = input_path.stem\n output_path = Path(\"output_manifest\") / f\"{base_name}_content.json\"\n else:\n output_path = Path(args.output)\n \n # Prevent output to input/ directory\n if output_path.parts and output_path.parts[0] == 'input':\n print(\"Error: Cannot output to input/ directory. Use output_manifest/ instead.\")\n sys.exit(1)\n \n # Load layouts mapping if provided\n layouts_mapping = None\n if args.layouts:\n with open(args.layouts, 'r', encoding='utf-8') as f:\n layouts_data = json.load(f)\n layouts_mapping = layouts_data.get(\"layout_mapping\", {})\n \n # Generate content.json\n content = generate_content_json(slides_content, layouts_mapping)\n \n # Remove metadata if not requested\n if not args.include_metadata:\n for slide in content[\"slides\"]:\n if \"_source\" in slide:\n del slide[\"_source\"]\n if \"image\" in slide and \"_note\" in slide[\"image\"]:\n del slide[\"image\"][\"_note\"]\n \n # Save output\n output_path.parent.mkdir(parents=True, exist_ok=True)\n with open(output_path, 'w', encoding='utf-8') as f:\n json.dump(content, f, ensure_ascii=False, indent=2)\n \n print(f\"✅ Generated: {output_path}\")\n print(f\"\\nNext steps:\")\n print(f\" 1. Review and edit {output_path}\")\n print(f\" 2. Translate content (Localizer agent or manual)\")\n print(f\" 3. Extract images if needed\")\n print(f\" 4. Run: python scripts/create_from_template.py {args.input} {output_path} output.pptx\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":23445,"content_sha256":"4fa5457631b5aeb8051393842d1ea37250d01fd1175a67be318daefab214dbe8"},{"filename":"scripts/reorder_slides.py","content":"#!/usr/bin/env python3\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nRearrange PowerPoint slides based on a sequence of indices.\n\nThis module provides functionality to reorder and duplicate slides in a\nPowerPoint presentation. It uses python-pptx library to manipulate PPTX files.\n\nUsage:\n python reorder_slides.py template.pptx output.pptx 0,3,3,5,2\n\nThis will create output.pptx using slides from template.pptx in the specified order.\nSlides can be repeated (e.g., 3 appears twice).\n\nAuthor: aktsmm\nLicense: CC BY-NC 4.0\n\"\"\"\n\nimport argparse\nimport shutil\nimport sys\nfrom copy import deepcopy\nfrom pathlib import Path\nfrom typing import Dict, List, Set\n\nfrom pptx import Presentation\nfrom pptx.slide import Slide\n\n\ndef parse_arguments() -> argparse.Namespace:\n \"\"\"Parse command line arguments.\n \n Returns:\n Parsed command line arguments.\n \"\"\"\n parser = argparse.ArgumentParser(\n description=\"Rearrange PowerPoint slides based on a sequence of indices.\",\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n python reorder_slides.py template.pptx output.pptx 0,3,3,5,2\n Creates output.pptx using slides 0, 3 (twice), 5, and 2 from template.pptx\n\n python reorder_slides.py template.pptx output.pptx 5,3,1,2,4\n Creates output.pptx with slides reordered as specified\n\nNote: Slide indices are 0-based (first slide is 0, second is 1, etc.)\n \"\"\",\n )\n parser.add_argument(\"template\", help=\"Path to template PPTX file\")\n parser.add_argument(\"output\", help=\"Path for output PPTX file\")\n parser.add_argument(\n \"sequence\", help=\"Comma-separated sequence of slide indices (0-based)\"\n )\n return parser.parse_args()\n\n\ndef parse_slide_sequence(sequence_str: str) -> List[int]:\n \"\"\"Parse comma-separated slide indices string.\n \n Args:\n sequence_str: Comma-separated string of slide indices.\n \n Returns:\n List of slide indices as integers.\n \n Raises:\n ValueError: If sequence format is invalid.\n \"\"\"\n try:\n return [int(x.strip()) for x in sequence_str.split(\",\")]\n except ValueError as e:\n raise ValueError(\n \"Invalid sequence format. Use comma-separated integers (e.g., 0,3,3,5,2)\"\n ) from e\n\n\ndef validate_slide_indices(indices: List[int], total_slides: int) -> None:\n \"\"\"Validate that all slide indices are within valid range.\n \n Args:\n indices: List of slide indices to validate.\n total_slides: Total number of slides in the presentation.\n \n Raises:\n ValueError: If any index is out of range.\n \"\"\"\n for idx in indices:\n if idx \u003c 0 or idx >= total_slides:\n raise ValueError(\n f\"Slide index {idx} out of range (valid: 0-{total_slides - 1})\"\n )\n\n\ndef copy_slide_elements(source_slide: Slide, target_slide: Slide) -> None:\n \"\"\"Copy all elements from source slide to target slide.\n \n This function copies shapes including pictures, text boxes, and other elements\n while preserving their relationships (e.g., embedded images).\n \n Args:\n source_slide: The source slide to copy from.\n target_slide: The target slide to copy to.\n \"\"\"\n # Collect image relationships from source slide\n image_rels: Dict[str, any] = {}\n for rel_id, rel in source_slide.part.rels.items():\n if \"image\" in rel.reltype or \"media\" in rel.reltype:\n image_rels[rel_id] = rel\n\n # Clear existing placeholder shapes from target\n for shape in list(target_slide.shapes):\n sp = shape.element\n sp.getparent().remove(sp)\n\n # Copy all shapes from source to target\n for shape in source_slide.shapes:\n el = shape.element\n new_el = deepcopy(el)\n target_slide.shapes._spTree.insert_element_before(new_el, \"p:extLst\")\n\n # Handle image references - update blip embed IDs\n blips = new_el.xpath(\".//a:blip[@r:embed]\")\n for blip in blips:\n old_rId = blip.get(\n \"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed\"\n )\n if old_rId in image_rels:\n old_rel = image_rels[old_rId]\n new_rId = target_slide.part.rels.get_or_add(\n old_rel.reltype, old_rel._target\n )\n blip.set(\n \"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed\",\n new_rId,\n )\n\n # Ensure all image relationships are added to target\n for rel_id, rel in image_rels.items():\n try:\n target_slide.part.rels.get_or_add(rel.reltype, rel._target)\n except Exception:\n pass # Relationship might already exist\n\n\ndef duplicate_slide(prs: Presentation, source_index: int) -> Slide:\n \"\"\"Create a duplicate of the specified slide.\n \n Args:\n prs: The presentation object.\n source_index: Index of the slide to duplicate.\n \n Returns:\n The newly created duplicate slide.\n \"\"\"\n source = prs.slides[source_index]\n \n # Create new slide with same layout as source\n new_slide = prs.slides.add_slide(source.slide_layout)\n \n # Copy all elements from source to new slide\n copy_slide_elements(source, new_slide)\n \n return new_slide\n\n\ndef delete_slide(prs: Presentation, index: int) -> None:\n \"\"\"Delete a slide from the presentation.\n \n Args:\n prs: The presentation object.\n index: Index of the slide to delete.\n \"\"\"\n rId = prs.slides._sldIdLst[index].rId\n prs.part.drop_rel(rId)\n del prs.slides._sldIdLst[index]\n\n\ndef move_slide(prs: Presentation, from_index: int, to_index: int) -> None:\n \"\"\"Move a slide from one position to another.\n \n Args:\n prs: The presentation object.\n from_index: Current index of the slide.\n to_index: Target index for the slide.\n \"\"\"\n slides = prs.slides._sldIdLst\n slide_element = slides[from_index]\n slides.remove(slide_element)\n slides.insert(to_index, slide_element)\n\n\ndef rearrange_presentation(\n template_path: Path, \n output_path: Path, \n slide_sequence: List[int]\n) -> None:\n \"\"\"Create a new presentation with slides from template in specified order.\n \n Args:\n template_path: Path to the template PPTX file.\n output_path: Path for the output PPTX file.\n slide_sequence: List of slide indices (0-based) to include.\n \n Raises:\n ValueError: If any slide index is out of range.\n \"\"\"\n # Copy template to output path to preserve dimensions and theme\n if template_path != output_path:\n shutil.copy2(template_path, output_path)\n prs = Presentation(output_path)\n else:\n prs = Presentation(template_path)\n\n total_slides = len(prs.slides)\n \n # Validate all indices\n validate_slide_indices(slide_sequence, total_slides)\n\n # Track slide positions and duplicates\n slide_map: List[int] = []\n duplicated: Dict[int, List[int]] = {}\n\n print(f\"Processing {len(slide_sequence)} slides from template...\")\n \n # Phase 1: Create duplicates for repeated slides\n for i, template_idx in enumerate(slide_sequence):\n count = slide_sequence.count(template_idx)\n \n if template_idx in duplicated and duplicated[template_idx]:\n # Use existing duplicate\n slide_map.append(duplicated[template_idx].pop(0))\n print(f\" [{i}] Using duplicate of slide {template_idx}\")\n elif count > 1 and template_idx not in duplicated:\n # First occurrence of repeated slide - create all duplicates now\n slide_map.append(template_idx)\n duplicates = []\n duplicate_count = count - 1\n print(f\" [{i}] Using original slide {template_idx}, creating {duplicate_count} duplicate(s)\")\n \n for _ in range(duplicate_count):\n duplicate_slide(prs, template_idx)\n duplicates.append(len(prs.slides) - 1)\n duplicated[template_idx] = duplicates\n else:\n # Unique slide\n slide_map.append(template_idx)\n print(f\" [{i}] Using original slide {template_idx}\")\n\n # Phase 2: Delete unused slides (work backwards to maintain indices)\n slides_to_keep: Set[int] = set(slide_map)\n print(f\"\\nDeleting {len(prs.slides) - len(slides_to_keep)} unused slides...\")\n \n for i in range(len(prs.slides) - 1, -1, -1):\n if i not in slides_to_keep:\n delete_slide(prs, i)\n # Update indices in slide_map after deletion\n slide_map = [idx - 1 if idx > i else idx for idx in slide_map]\n\n # Phase 3: Reorder slides to final sequence\n print(f\"Reordering {len(slide_map)} slides to final sequence...\")\n \n for target_pos in range(len(slide_map)):\n current_pos = slide_map[target_pos]\n if current_pos != target_pos:\n move_slide(prs, current_pos, target_pos)\n # Update slide_map to reflect the move\n for i in range(len(slide_map)):\n if slide_map[i] > current_pos and slide_map[i] \u003c= target_pos:\n slide_map[i] -= 1\n elif slide_map[i] \u003c current_pos and slide_map[i] >= target_pos:\n slide_map[i] += 1\n slide_map[target_pos] = target_pos\n\n # Save the result\n prs.save(output_path)\n print(f\"\\nSaved rearranged presentation to: {output_path}\")\n print(f\"Final presentation has {len(prs.slides)} slides\")\n\n\ndef main() -> None:\n \"\"\"Main entry point for command-line usage.\"\"\"\n args = parse_arguments()\n\n # Parse slide sequence\n try:\n slide_sequence = parse_slide_sequence(args.sequence)\n except ValueError as e:\n print(f\"Error: {e}\")\n sys.exit(1)\n\n # Validate template exists\n template_path = Path(args.template)\n if not template_path.exists():\n print(f\"Error: Template file not found: {args.template}\")\n sys.exit(1)\n\n # Create output directory if needed\n output_path = Path(args.output)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n\n # Execute rearrangement\n try:\n rearrange_presentation(template_path, output_path, slide_sequence)\n except ValueError as e:\n print(f\"Error: {e}\")\n sys.exit(1)\n except Exception as e:\n print(f\"Error processing presentation: {e}\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10823,"content_sha256":"456bf17bc537caa3c66cdad8988d2aeb946520216c565ba7507e798ec72b5c4b"},{"filename":"scripts/requirements.txt","content":"python-pptx>=0.6.21\nPillow>=10.0.0\nsix>=1.16.0\njsonschema>=4.0.0\nopenai>=1.0.0","content_type":"text/plain; charset=utf-8","language":null,"size":78,"content_sha256":"2671d093f4c3a9561d7d567e48ecdeaa85aa1668a4235543b8ee59333e195b44"},{"filename":"scripts/resume_workflow.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nResume workflow from a specific phase after escalation.\n\nUsage:\n python scripts/resume_workflow.py \u003cbase_name> --from \u003cphase>\n python scripts/resume_workflow.py \u003cbase_name> --from BUILD --skip-validation\n\nExamples:\n # Resume from TRANSLATE phase\n python scripts/resume_workflow.py 20251214_purview_ignite --from TRANSLATE\n \n # Skip validation and go directly to BUILD\n python scripts/resume_workflow.py 20251214_purview_ignite --from BUILD --skip-validation\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport sys\nimport subprocess\nfrom pathlib import Path\nfrom datetime import datetime\n\nfrom workflow_tracer import WorkflowTracer\n\n\nPHASES = [\"INIT\", \"PLAN\", \"EXTRACT\", \"TRANSLATE\", \"REVIEW_JSON\", \"BUILD\", \"REVIEW_PPTX\", \"DONE\"]\n\n\ndef load_escalation(base_name: str) -> dict:\n \"\"\"Load escalation file if exists.\"\"\"\n escalation_file = Path(f\"output_manifest/{base_name}_escalation.json\")\n if escalation_file.exists():\n with open(escalation_file, \"r\", encoding=\"utf-8\") as f:\n return json.load(f)\n return {}\n\n\ndef find_files(base_name: str) -> dict:\n \"\"\"Find existing files for the workflow.\"\"\"\n files = {\n \"content\": None,\n \"content_ja\": None,\n \"pptx\": None,\n \"escalation\": None,\n \"trace\": None,\n }\n \n manifest_dir = Path(\"output_manifest\")\n output_dir = Path(\"output_ppt\")\n \n # Check for content files\n content_path = manifest_dir / f\"{base_name}_content.json\"\n if content_path.exists():\n files[\"content\"] = str(content_path)\n \n content_ja_path = manifest_dir / f\"{base_name}_content_ja.json\"\n if content_ja_path.exists():\n files[\"content_ja\"] = str(content_ja_path)\n \n # Check for PPTX\n pptx_path = output_dir / f\"{base_name}.pptx\"\n if pptx_path.exists():\n files[\"pptx\"] = str(pptx_path)\n \n # Check for escalation\n escalation_path = manifest_dir / f\"{base_name}_escalation.json\"\n if escalation_path.exists():\n files[\"escalation\"] = str(escalation_path)\n \n # Check for trace\n trace_path = manifest_dir / f\"{base_name}_trace.jsonl\"\n if trace_path.exists():\n files[\"trace\"] = str(trace_path)\n \n return files\n\n\ndef run_phase(phase: str, base_name: str, tracer: WorkflowTracer, \n skip_validation: bool = False, template: str = None) -> bool:\n \"\"\"\n Run a specific phase.\n \n Returns True if successful, False otherwise.\n \"\"\"\n files = find_files(base_name)\n \n if phase == \"TRANSLATE\":\n tracer.start_phase(\"TRANSLATE\")\n \n content_file = files.get(\"content\")\n if not content_file:\n tracer.end_phase(\"TRANSLATE\", status=\"failed\", \n error=\"content.json not found\")\n return False\n \n output_file = f\"output_manifest/{base_name}_content_ja.json\"\n \n # Translation requires Localizer agent (not automated script)\n tracer.end_phase(\"TRANSLATE\", status=\"failed\", \n error=\"TRANSLATE phase requires Localizer agent. \"\n \"Script-based translation is deprecated. \"\n \"Please use Localizer agent to translate content.json.\")\n print(\"\\n⚠️ TRANSLATE phase requires Localizer agent.\")\n print(f\" Input: {content_file}\")\n print(f\" Output: {output_file}\")\n print(\"\\nPlease invoke Localizer agent to translate, then resume from REVIEW_JSON:\")\n print(f\" python scripts/resume_workflow.py {base_name} --from REVIEW_JSON\")\n return False\n return True\n \n elif phase == \"REVIEW_JSON\":\n if skip_validation:\n tracer.log(\"REVIEW_JSON\", \"warning\", \"Validation skipped by user\")\n return True\n \n tracer.start_phase(\"REVIEW_JSON\")\n \n content_ja_file = files.get(\"content_ja\")\n if not content_ja_file:\n tracer.end_phase(\"REVIEW_JSON\", status=\"failed\",\n error=\"content_ja.json not found\")\n return False\n \n # Run validate_content.py\n cmd = [\"python\", \"scripts/validate_content.py\", content_ja_file]\n result = subprocess.run(cmd, capture_output=True, text=True)\n \n if result.returncode == 1: # FAIL\n tracer.end_phase(\"REVIEW_JSON\", status=\"failed\",\n error=\"Validation failed\")\n return False\n elif result.returncode == 2: # WARN\n tracer.end_phase(\"REVIEW_JSON\", status=\"warning\",\n message=\"Validation passed with warnings\")\n else:\n tracer.end_phase(\"REVIEW_JSON\", status=\"success\")\n \n return True\n \n elif phase == \"BUILD\":\n tracer.start_phase(\"BUILD\")\n \n content_ja_file = files.get(\"content_ja\")\n if not content_ja_file:\n tracer.end_phase(\"BUILD\", status=\"failed\",\n error=\"content_ja.json not found\")\n return False\n \n # Find template\n template_file = template\n if not template_file:\n # Try to find original PPTX in input/\n input_dir = Path(\"input\")\n pptx_files = list(input_dir.glob(\"*.pptx\"))\n if pptx_files:\n template_file = str(pptx_files[0])\n else:\n tracer.end_phase(\"BUILD\", status=\"failed\",\n error=\"No template PPTX found\")\n return False\n \n output_file = f\"output_ppt/{base_name}.pptx\"\n \n # Run create_from_template.py\n cmd = [\"python\", \"scripts/create_from_template.py\", \n template_file, content_ja_file, output_file]\n result = subprocess.run(cmd, capture_output=True, text=True)\n \n if result.returncode != 0:\n tracer.end_phase(\"BUILD\", status=\"failed\",\n error=result.stderr[:500])\n return False\n \n tracer.end_phase(\"BUILD\", status=\"success\", output_file=output_file)\n return True\n \n elif phase == \"DONE\":\n tracer.log(\"DONE\", \"success\", \"Workflow completed\")\n \n # Mark escalation as resolved\n escalation_file = Path(f\"output_manifest/{base_name}_escalation.json\")\n if escalation_file.exists():\n with open(escalation_file, \"r\", encoding=\"utf-8\") as f:\n escalation = json.load(f)\n escalation[\"status\"] = \"resolved\"\n escalation[\"resolved_at\"] = datetime.now().isoformat()\n with open(escalation_file, \"w\", encoding=\"utf-8\") as f:\n json.dump(escalation, f, indent=2, ensure_ascii=False)\n \n # Open PPTX\n pptx_file = files.get(\"pptx\")\n if pptx_file:\n print(f\"\\n🎉 PPTX generated: {pptx_file}\")\n print(f\" Opening in PowerPoint...\")\n # Use the OS file-association API directly instead of a shell\n # command, so pptx_file is never interpreted by a shell and\n # cannot be used for command injection.\n os.startfile(pptx_file)\n \n return True\n \n else:\n print(f\"Phase {phase} not implemented in resume script\")\n return False\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Resume workflow from a specific phase\")\n parser.add_argument(\"base_name\", help=\"Base name of the workflow\")\n parser.add_argument(\"--from\", dest=\"from_phase\", required=True,\n choices=PHASES, help=\"Phase to resume from\")\n parser.add_argument(\"--skip-validation\", action=\"store_true\",\n help=\"Skip REVIEW_JSON phase\")\n parser.add_argument(\"--template\", help=\"Template PPTX file path\")\n \n args = parser.parse_args()\n \n base_name = args.base_name\n from_phase = args.from_phase\n \n print(f\"\\n📋 Resuming workflow: {base_name}\")\n print(f\" From phase: {from_phase}\")\n \n # Check for escalation\n escalation = load_escalation(base_name)\n if escalation:\n print(f\" Previous escalation: {escalation.get('phase')} - {escalation.get('reason')}\")\n \n # Check existing files\n files = find_files(base_name)\n print(f\"\\n📁 Found files:\")\n for key, path in files.items():\n if path:\n print(f\" {key}: {path}\")\n \n # Initialize tracer\n tracer = WorkflowTracer(base_name)\n tracer.log(\"RESUME\", \"started\", f\"Resuming from {from_phase}\")\n \n # Determine phases to run\n start_idx = PHASES.index(from_phase)\n phases_to_run = PHASES[start_idx:]\n \n print(f\"\\n🚀 Phases to run: {' → '.join(phases_to_run)}\")\n \n # Run phases\n for phase in phases_to_run:\n if phase == \"INIT\" or phase == \"PLAN\" or phase == \"EXTRACT\":\n print(f\" Skipping {phase} (already completed)\")\n continue\n \n skip_val = args.skip_validation and phase == \"REVIEW_JSON\"\n success = run_phase(phase, base_name, tracer, skip_val, args.template)\n \n if not success:\n print(f\"\\n❌ Phase {phase} failed. Check trace for details.\")\n tracer.save()\n sys.exit(1)\n \n tracer.save()\n print(\"\\n✅ Workflow resumed and completed successfully!\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9701,"content_sha256":"4ebe2567a44d112b0d2dce2f903b56ce63e95df2e7a47234b6bb76b541e0f15d"},{"filename":"scripts/review_pptx.py","content":"# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n#\n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nreview_pptx.py - PPTX content extraction and review helper\n\nExtract slide content from PPTX for AI review or quick inspection.\nOutputs structured information including titles, body text, and notes.\n\nUsage:\n python scripts/review_pptx.py \u003cinput.pptx> [--json] [--output \u003cfile>]\n\nOptions:\n --json Output in JSON format for programmatic processing\n --output Save output to file instead of stdout\n --summary Show summary only (slide count, issues detected)\n\"\"\"\n\nfrom pptx import Presentation\nfrom pptx.util import Inches\nimport sys\nimport argparse\nimport json\nimport re\nfrom pathlib import Path\n\n\ndef extract_slide_content(slide, slide_num: int) -> dict:\n \"\"\"Extract content from a single slide.\n \n Args:\n slide: python-pptx Slide object\n slide_num: 1-based slide number\n \n Returns:\n Dictionary with slide content\n \"\"\"\n content = {\n \"slide_number\": slide_num,\n \"title\": None,\n \"body_texts\": [],\n \"notes\": None,\n \"issues\": []\n }\n \n # Extract title\n if slide.shapes.title and slide.shapes.title.text:\n content[\"title\"] = slide.shapes.title.text.strip()\n else:\n content[\"issues\"].append(\"no_title\")\n \n # Extract body text\n for shape in slide.shapes:\n if hasattr(shape, 'text') and shape.text:\n if shape != slide.shapes.title:\n text = shape.text.strip()\n if text:\n # Check if it's just a page number\n if re.match(r'^\\d{1,3}

PowerPoint Automation AI-powered PPTX generation using Orchestrator-Workers pattern. When to Use - Web 記事やブログをスライド化したいとき - 既存 PPTX を翻訳・再構成したいとき - 開いている PPTX を COM Automation で直接編集したいとき - テンプレートベースで PPTX を生成したいとき - content.json を SSOT にして、抽出・翻訳・生成・レビューを分離したいとき Quick Start From Web Article From Existing PPTX Edit Open PPTX with COM Workflow | Phase | Main Actor | Purpose | | ------- | ------------------------- | --------------------------------------------------------- | | EXTRACT | | Source - content.json (+ for images) | | BUILD | | content.json - PPTX | | REVIEW | PPTX Reviewer | Overflow /…

, text):\n content[\"issues\"].append(\"page_number_only\")\n else:\n content[\"body_texts\"].append(text)\n \n if not content[\"body_texts\"]:\n content[\"issues\"].append(\"empty_body\")\n \n # Extract notes\n if slide.has_notes_slide:\n notes_text = slide.notes_slide.notes_text_frame.text.strip()\n if notes_text:\n content[\"notes\"] = notes_text\n # Check if notes is source-only\n if re.match(r'^\\[出典:.*\\]

PowerPoint Automation AI-powered PPTX generation using Orchestrator-Workers pattern. When to Use - Web 記事やブログをスライド化したいとき - 既存 PPTX を翻訳・再構成したいとき - 開いている PPTX を COM Automation で直接編集したいとき - テンプレートベースで PPTX を生成したいとき - content.json を SSOT にして、抽出・翻訳・生成・レビューを分離したいとき Quick Start From Web Article From Existing PPTX Edit Open PPTX with COM Workflow | Phase | Main Actor | Purpose | | ------- | ------------------------- | --------------------------------------------------------- | | EXTRACT | | Source - content.json (+ for images) | | BUILD | | content.json - PPTX | | REVIEW | PPTX Reviewer | Overflow /…

, notes_text.strip()):\n content[\"issues\"].append(\"source_only_notes\")\n else:\n content[\"issues\"].append(\"empty_notes\")\n else:\n content[\"issues\"].append(\"no_notes\")\n \n return content\n\n\ndef review_pptx(path: str, output_format: str = \"text\") -> dict:\n \"\"\"Review PPTX content and extract structured information.\n \n Args:\n path: Path to PPTX file\n output_format: \"text\" or \"json\"\n \n Returns:\n Dictionary with review results\n \"\"\"\n prs = Presentation(path)\n \n result = {\n \"file\": path,\n \"slide_count\": len(prs.slides),\n \"size\": {\n \"width\": round(prs.slide_width.inches, 2),\n \"height\": round(prs.slide_height.inches, 2)\n },\n \"slides\": [],\n \"summary\": {\n \"empty_slides\": 0,\n \"missing_notes\": 0,\n \"source_only_notes\": 0,\n \"page_number_only\": 0\n }\n }\n \n for i, slide in enumerate(prs.slides):\n slide_content = extract_slide_content(slide, i + 1)\n result[\"slides\"].append(slide_content)\n \n # Update summary\n if \"empty_body\" in slide_content[\"issues\"]:\n result[\"summary\"][\"empty_slides\"] += 1\n if \"no_notes\" in slide_content[\"issues\"] or \"empty_notes\" in slide_content[\"issues\"]:\n result[\"summary\"][\"missing_notes\"] += 1\n if \"source_only_notes\" in slide_content[\"issues\"]:\n result[\"summary\"][\"source_only_notes\"] += 1\n if \"page_number_only\" in slide_content[\"issues\"]:\n result[\"summary\"][\"page_number_only\"] += 1\n \n return result\n\n\ndef print_text_report(result: dict):\n \"\"\"Print human-readable text report.\"\"\"\n print(f'ファイル: {result[\"file\"]}')\n print(f'スライド数: {result[\"slide_count\"]}')\n print(f'サイズ: {result[\"size\"][\"width\"]} x {result[\"size\"][\"height\"]} インチ')\n print()\n \n # Summary\n issues = []\n if result[\"summary\"][\"empty_slides\"] > 0:\n issues.append(f'空スライド: {result[\"summary\"][\"empty_slides\"]}枚')\n if result[\"summary\"][\"missing_notes\"] > 0:\n issues.append(f'ノート欠落: {result[\"summary\"][\"missing_notes\"]}枚')\n if result[\"summary\"][\"source_only_notes\"] > 0:\n issues.append(f'出典のみノート: {result[\"summary\"][\"source_only_notes\"]}枚')\n if result[\"summary\"][\"page_number_only\"] > 0:\n issues.append(f'ページ番号のみ: {result[\"summary\"][\"page_number_only\"]}枚')\n \n if issues:\n print(\"⚠️ 検出された問題:\")\n for issue in issues:\n print(f\" - {issue}\")\n print()\n else:\n print(\"✅ 問題なし\")\n print()\n \n # Slide details\n for slide in result[\"slides\"]:\n print(f'=== スライド {slide[\"slide_number\"]} ===')\n \n # Title\n if slide[\"title\"]:\n print(f'タイトル: {slide[\"title\"][:100]}')\n else:\n print('タイトル: (なし)')\n \n # Body text\n if slide[\"body_texts\"]:\n for text in slide[\"body_texts\"][:5]:\n display = text[:150] + '...' if len(text) > 150 else text\n display = display.replace('\\n', ' | ')\n print(f' {display}')\n \n # Notes\n if slide[\"notes\"]:\n notes_preview = slide[\"notes\"][:100]\n notes_preview = notes_preview.replace('\\n', ' ')\n print(f' [ノート] {notes_preview}...')\n \n # Issues\n if slide[\"issues\"]:\n issue_labels = {\n \"no_title\": \"⚠️ タイトルなし\",\n \"empty_body\": \"⚠️ 本文なし\",\n \"page_number_only\": \"⚠️ ページ番号のみ\",\n \"no_notes\": \"⚠️ ノートなし\",\n \"empty_notes\": \"⚠️ ノート空\",\n \"source_only_notes\": \"⚠️ 出典のみ\"\n }\n for issue in slide[\"issues\"]:\n if issue in issue_labels:\n print(f' {issue_labels[issue]}')\n \n print()\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Review PPTX content for AI review or inspection'\n )\n parser.add_argument('input', help='Input PPTX file path')\n parser.add_argument('--json', action='store_true', help='Output in JSON format')\n parser.add_argument('--output', '-o', help='Output file path')\n parser.add_argument('--summary', action='store_true', help='Show summary only')\n \n args = parser.parse_args()\n \n if not Path(args.input).exists():\n print(f\"Error: File not found: {args.input}\", file=sys.stderr)\n sys.exit(1)\n \n result = review_pptx(args.input)\n \n if args.json:\n output = json.dumps(result, ensure_ascii=False, indent=2)\n if args.output:\n Path(args.output).write_text(output, encoding='utf-8')\n print(f\"Output saved to: {args.output}\")\n else:\n print(output)\n elif args.summary:\n print(f'ファイル: {result[\"file\"]}')\n print(f'スライド数: {result[\"slide_count\"]}')\n print(f'空スライド: {result[\"summary\"][\"empty_slides\"]}')\n print(f'ノート欠落: {result[\"summary\"][\"missing_notes\"]}')\n print(f'出典のみノート: {result[\"summary\"][\"source_only_notes\"]}')\n \n # Exit code based on issues\n if result[\"summary\"][\"empty_slides\"] > 0:\n sys.exit(1)\n elif result[\"summary\"][\"missing_notes\"] > 3:\n sys.exit(2)\n else:\n if args.output:\n import io\n old_stdout = sys.stdout\n sys.stdout = io.StringIO()\n print_text_report(result)\n output = sys.stdout.getvalue()\n sys.stdout = old_stdout\n Path(args.output).write_text(output, encoding='utf-8')\n print(f\"Output saved to: {args.output}\")\n else:\n print_text_report(result)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8453,"content_sha256":"a3b3d63407c206746b741b304788adaac5fa462db3acac372ab812eed6aeb67b"},{"filename":"scripts/summarize_content.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nSummarize content.json by understanding full context and restructuring.\n\nUnlike extract_main_slides.py which mechanically filters slides,\nthis script is designed to work WITH an AI agent that:\n1. Reads ALL slides to understand the full narrative\n2. Identifies key sections and messages\n3. Restructures into a coherent summary with specified slide count\n\nThis script provides helper functions for the AI agent workflow.\n\nUsage:\n # Analyze mode: Output structure analysis for AI to process\n python scripts/summarize_content.py analyze \u003cinput_content.json>\n \n # Validate mode: Check if summary content.json is valid\n python scripts/summarize_content.py validate \u003csummary_content.json>\n\nAI Agent Workflow:\n 1. Run: python scripts/summarize_content.py analyze content.json\n 2. AI reads full content and analysis output\n 3. AI creates summarized content_summary.json (target slides)\n 4. Run: python scripts/summarize_content.py validate content_summary.json\n 5. Build PPTX from summary\n\"\"\"\n\nimport json\nimport sys\nimport io\nimport argparse\nfrom pathlib import Path\nfrom collections import Counter\n\n# Fix Windows console encoding issues\nsys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')\nsys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')\n\n\ndef analyze_content(input_path: str) -> dict:\n \"\"\"\n Analyze content.json and output structure for AI agent.\n \n Returns a summary of:\n - Total slides and types\n - Section structure\n - Key themes/topics from titles and notes\n - Recommended summary structure\n \"\"\"\n with open(input_path, \"r\", encoding=\"utf-8\") as f:\n content = json.load(f)\n \n slides = content.get(\"slides\", [])\n total = len(slides)\n \n # Count slide types\n type_counts = Counter(slide.get(\"type\", \"unknown\") for slide in slides)\n \n # Identify sections\n sections = []\n current_section = {\"name\": \"Introduction\", \"slides\": [], \"start\": 0}\n \n for i, slide in enumerate(slides):\n slide_type = slide.get(\"type\", \"\")\n title = slide.get(\"title\", \"\")\n \n # Section break detection\n if slide_type == \"section\" or (slide_type == \"title\" and i > 0):\n if current_section[\"slides\"]:\n current_section[\"end\"] = i - 1\n current_section[\"count\"] = len(current_section[\"slides\"])\n sections.append(current_section)\n current_section = {\n \"name\": title or f\"Section {len(sections) + 1}\",\n \"slides\": [],\n \"start\": i\n }\n \n current_section[\"slides\"].append({\n \"index\": i + 1,\n \"type\": slide_type,\n \"title\": title[:50] + \"...\" if len(title) > 50 else title,\n \"has_notes\": bool(slide.get(\"notes\")),\n \"has_image\": bool(slide.get(\"image\")),\n \"items_count\": len(slide.get(\"items\", []))\n })\n \n # Add last section\n if current_section[\"slides\"]:\n current_section[\"end\"] = total - 1\n current_section[\"count\"] = len(current_section[\"slides\"])\n sections.append(current_section)\n \n # Extract key topics from titles\n all_titles = [s.get(\"title\", \"\") for s in slides if s.get(\"title\")]\n \n # Calculate recommended summary sizes\n recommendations = {\n \"executive_summary\": max(7, total // 20), # ~5% of slides\n \"short_summary\": max(15, total // 8), # ~12% of slides\n \"standard_summary\": max(25, total // 4), # ~25% of slides\n \"detailed_summary\": max(40, total // 2), # ~50% of slides\n }\n \n # Count empty slides (notes-only or blank)\n empty_slides = []\n for i, slide in enumerate(slides):\n slide_type = slide.get(\"type\", \"\")\n has_title = bool(slide.get(\"title\", \"\").strip())\n has_items = bool(slide.get(\"items\") or slide.get(\"bullets\") or slide.get(\"content\"))\n has_image = bool(slide.get(\"image\"))\n has_notes = bool(slide.get(\"notes\", \"\").strip())\n \n # Notes-only = empty\n if has_notes and not has_title and not has_items and not has_image:\n empty_slides.append({\"index\": i + 1, \"reason\": \"notes_only\"})\n # Completely blank\n elif not has_title and not has_items and not has_image and not has_notes:\n empty_slides.append({\"index\": i + 1, \"reason\": \"blank\"})\n \n # Check for agenda slide\n has_agenda = any(\n slide.get(\"type\") == \"agenda\" or \n any(kw in (slide.get(\"title\") or \"\").lower() for kw in [\"agenda\", \"目次\", \"outline\", \"contents\", \"アジェンダ\"])\n for slide in slides\n )\n \n analysis = {\n \"source\": {\n \"file\": input_path,\n \"title\": content.get(\"title\", \"\"),\n \"total_slides\": total,\n \"slide_types\": dict(type_counts),\n \"empty_slides\": empty_slides,\n \"has_agenda\": has_agenda,\n },\n \"source\": {\n \"file\": input_path,\n \"title\": content.get(\"title\", \"\"),\n \"total_slides\": total,\n \"slide_types\": dict(type_counts)\n },\n \"sections\": [\n {\n \"name\": s[\"name\"],\n \"slide_range\": f\"{s['start'] + 1}-{s['end'] + 1}\",\n \"slide_count\": s[\"count\"],\n \"key_slides\": [\n sl for sl in s[\"slides\"] \n if sl[\"type\"] in (\"title\", \"section\", \"content\") or sl[\"has_notes\"]\n ][:5] # Top 5 key slides per section\n }\n for s in sections\n ],\n \"recommendations\": recommendations,\n \"instructions\": {\n \"mandatory\": [\n \"★ MUST include Agenda slide (type='agenda') after title slide\",\n \"★ MUST include Summary/Conclusion slide before closing\",\n \"★ EXCLUDE empty slides (notes-only or blank) - they are useless\",\n \"★ Every content slide MUST have visible content (title + items)\",\n \"★ Screenshot slides: short title + large image + detailed notes\",\n ],\n \"workflow\": [\n \"1. Read the full content.json to understand the complete narrative\",\n \"2. EXCLUDE empty slides (notes-only slides are NOT useful)\",\n \"3. Identify the main storyline and key messages\",\n \"4. Group related slides into logical sections\",\n \"5. For each section, extract essential points (not just copy slides)\",\n \"6. Create Agenda slide (type='agenda') listing main sections\",\n \"7. Create new slides that SUMMARIZE the section content\",\n \"8. Ensure smooth narrative flow between sections\",\n \"9. Include Summary slide before closing\",\n \"10. Speaker notes should summarize, not copy original\"\n ],\n \"guidelines\": [\n \"Do NOT just filter/remove slides - restructure and summarize\",\n \"Do NOT include slides with only speaker notes (no visible content)\",\n \"Combine related content from multiple slides into single slides\",\n \"Preserve key statistics, quotes, and data points\",\n \"Maintain product names in English (Microsoft Purview, Azure, etc.)\",\n \"Keep section transitions clear\",\n \"Summarize speaker notes, don't just copy or drop them\"\n ],\n \"image_handling\": [\n \"Keep original image size proportions\",\n \"Do NOT enlarge small images (causes blurry output)\",\n \"DYNAMIC SIZING based on text content:\",\n \" - If slide has bullet items: width_percent 30-45 (image on right)\",\n \" - If slide has only title (no bullets): width_percent 60-80 (larger image)\",\n \" - For screenshots with UI details: prefer larger size for readability\",\n \"For small icons/logos, use 'width_percent': 20-30\",\n \"Only full-screen for high-resolution photos\",\n \"★ Screenshot slides: put detailed explanation in speaker NOTES, not title\"\n ]\n }\n }\n \n return analysis\n\n\ndef validate_summary(summary_path: str) -> dict:\n \"\"\"\n Validate a summary content.json file.\n \n Checks:\n - JSON validity\n - Required fields\n - Slide structure\n - Content presence\n - Agenda slide exists\n - No empty slides (notes-only)\n \"\"\"\n errors = []\n warnings = []\n \n try:\n with open(summary_path, \"r\", encoding=\"utf-8\") as f:\n content = json.load(f)\n except json.JSONDecodeError as e:\n return {\"valid\": False, \"errors\": [f\"JSON parse error: {e}\"], \"warnings\": []}\n \n # Check required fields\n if \"slides\" not in content:\n errors.append(\"Missing 'slides' array\")\n return {\"valid\": False, \"errors\": errors, \"warnings\": warnings}\n \n slides = content.get(\"slides\", [])\n \n if not slides:\n errors.append(\"Empty slides array\")\n \n # Validate each slide\n valid_types = {\"title\", \"section\", \"content\", \"two_column\", \"photo\", \"agenda\", \"closing\", \"blank\", \"summary\"}\n has_agenda = False\n has_summary = False\n empty_slide_indices = []\n \n for i, slide in enumerate(slides):\n slide_num = i + 1\n \n # Check type\n slide_type = slide.get(\"type\")\n if not slide_type:\n errors.append(f\"Slide {slide_num}: Missing 'type' field\")\n elif slide_type not in valid_types:\n warnings.append(f\"Slide {slide_num}: Unusual type '{slide_type}'\")\n \n # Track agenda and summary\n if slide_type == \"agenda\":\n has_agenda = True\n if slide_type == \"summary\" or \"まとめ\" in (slide.get(\"title\") or \"\").lower() or \"summary\" in (slide.get(\"title\") or \"\").lower():\n has_summary = True\n \n # Check content slides have items\n if slide_type == \"content\":\n if not slide.get(\"items\") and not slide.get(\"bullets\"):\n errors.append(f\"Slide {slide_num}: Content slide without items/bullets\")\n \n # Check title presence for most types\n if slide_type in (\"content\", \"section\", \"two_column\") and not slide.get(\"title\"):\n warnings.append(f\"Slide {slide_num}: {slide_type} slide without title\")\n \n # Detect empty slides (notes-only)\n has_title = bool(slide.get(\"title\", \"\").strip())\n has_items = bool(slide.get(\"items\") or slide.get(\"bullets\") or slide.get(\"content\"))\n has_image = bool(slide.get(\"image\"))\n has_notes = bool(slide.get(\"notes\", \"\").strip())\n \n if has_notes and not has_title and not has_items and not has_image:\n errors.append(f\"Slide {slide_num}: EMPTY (only speaker notes - no visible content)\")\n empty_slide_indices.append(slide_num)\n elif not has_title and not has_items and not has_image and not has_notes:\n warnings.append(f\"Slide {slide_num}: Blank slide (no content at all)\")\n empty_slide_indices.append(slide_num)\n \n # Check for narrative flow\n if slides:\n first_type = slides[0].get(\"type\")\n if first_type not in (\"title\", \"section\"):\n warnings.append(\"First slide should be 'title' or 'section' type\")\n \n last_type = slides[-1].get(\"type\")\n if last_type not in (\"closing\", \"section\", \"content\", \"summary\"):\n warnings.append(\"Last slide should be 'closing', 'section', 'content', or 'summary'\")\n \n # Mandatory checks\n if not has_agenda:\n errors.append(\"Missing Agenda slide (type='agenda' or title containing 'agenda/目次')\")\n \n if not has_summary and len(slides) > 5:\n warnings.append(\"Consider adding a Summary/まとめ slide before closing\")\n \n if empty_slide_indices:\n errors.append(f\"Found {len(empty_slide_indices)} empty slides that should be removed: {empty_slide_indices}\")\n \n return {\n \"valid\": len(errors) == 0,\n \"slide_count\": len(slides),\n \"has_agenda\": has_agenda,\n \"has_summary\": has_summary,\n \"errors\": errors,\n \"warnings\": warnings\n }\n\n\ndef print_analysis(analysis: dict) -> None:\n \"\"\"Pretty print analysis for AI agent.\"\"\"\n print(\"\\n\" + \"=\" * 60)\n print(\"CONTENT ANALYSIS FOR AI SUMMARIZATION\")\n print(\"=\" * 60)\n \n src = analysis[\"source\"]\n print(f\"\\n📄 Source: {src['file']}\")\n print(f\" Title: {src['title']}\")\n print(f\" Total Slides: {src['total_slides']}\")\n print(f\" Types: {src['slide_types']}\")\n \n # Show empty slides warning\n if src.get('empty_slides'):\n print(f\"\\n⚠️ Empty Slides Found ({len(src['empty_slides'])}): MUST EXCLUDE THESE\")\n for empty in src['empty_slides']:\n reason = \"notes-only\" if empty['reason'] == 'notes_only' else \"blank\"\n print(f\" - Slide {empty['index']}: {reason}\")\n \n # Agenda check\n if not src.get('has_agenda'):\n print(f\"\\n❌ No Agenda slide found - YOU MUST ADD ONE!\")\n else:\n print(f\"\\n✅ Agenda slide exists\")\n \n print(f\"\\n📑 Sections ({len(analysis['sections'])}):\") \n for sec in analysis[\"sections\"]:\n print(f\"\\n [{sec['slide_range']}] {sec['name']} ({sec['slide_count']} slides)\")\n for sl in sec[\"key_slides\"][:3]:\n icon = \"📷\" if sl[\"has_image\"] else \"📝\" if sl[\"has_notes\"] else \" \"\n print(f\" {icon} {sl['index']:3d}. [{sl['type']:12s}] {sl['title']}\")\n \n print(f\"\\n📊 Recommended Summary Sizes:\")\n for name, count in analysis[\"recommendations\"].items():\n label = name.replace(\"_\", \" \").title()\n print(f\" {label}: {count} slides\")\n \n # Print mandatory rules first\n print(f\"\\n🚨 MANDATORY RULES:\")\n for rule in analysis[\"instructions\"][\"mandatory\"]:\n print(f\" {rule}\")\n \n print(f\"\\n📋 Summarization Instructions:\")\n for step in analysis[\"instructions\"][\"workflow\"]:\n print(f\" {step}\")\n \n print(f\"\\n⚠️ Guidelines:\")\n for guideline in analysis[\"instructions\"][\"guidelines\"]:\n print(f\" • {guideline}\")\n \n print(f\"\\n🖼️ Image Handling:\")\n for rule in analysis[\"instructions\"][\"image_handling\"]:\n print(f\" • {rule}\")\n \n print(\"\\n\" + \"=\" * 60)\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Summarize content.json with AI agent assistance\"\n )\n subparsers = parser.add_subparsers(dest=\"command\", required=True)\n \n # Analyze command\n analyze_parser = subparsers.add_parser(\n \"analyze\", \n help=\"Analyze content.json structure for AI summarization\"\n )\n analyze_parser.add_argument(\"input\", help=\"Input content.json path\")\n analyze_parser.add_argument(\n \"--json\", \n action=\"store_true\", \n help=\"Output as JSON instead of formatted text\"\n )\n \n # Validate command\n validate_parser = subparsers.add_parser(\n \"validate\",\n help=\"Validate a summary content.json file\"\n )\n validate_parser.add_argument(\"input\", help=\"Summary content.json to validate\")\n \n args = parser.parse_args()\n \n if args.command == \"analyze\":\n analysis = analyze_content(args.input)\n if args.json:\n print(json.dumps(analysis, ensure_ascii=False, indent=2))\n else:\n print_analysis(analysis)\n \n elif args.command == \"validate\":\n result = validate_summary(args.input)\n if result[\"valid\"]:\n print(f\"✅ Valid summary with {result['slide_count']} slides\")\n if result.get(\"has_agenda\"):\n print(f\" ✅ Has Agenda slide\")\n if result.get(\"has_summary\"):\n print(f\" ✅ Has Summary slide\")\n else:\n print(\"❌ Invalid summary:\")\n for err in result[\"errors\"]:\n print(f\" ERROR: {err}\")\n \n for warn in result.get(\"warnings\", []):\n print(f\" ⚠️ {warn}\")\n \n sys.exit(0 if result[\"valid\"] else 1)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":16597,"content_sha256":"6a98296e1663d97d7bfb740f9bba1bf4897df3249801c970c29a1f9c00555ca5"},{"filename":"scripts/validate_content.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nValidate content.json against schema and custom rules.\n\nThis script provides deterministic validation as a helper for Reviewer agent.\nIt checks:\n1. JSON Schema compliance\n2. Empty content detection\n3. Image path existence\n4. Text length warnings\n\nUsage:\n python scripts/validate_content.py \u003ccontent.json> [--images-dir \u003cdir>]\n\nExit codes:\n 0: PASS (no errors)\n 1: FAIL (fatal errors found)\n 2: WARN (warnings only, can continue)\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nimport io\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Tuple\n\n# Fix Windows console encoding issues\nsys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')\nsys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')\n\ntry:\n import jsonschema\n from jsonschema import Draft7Validator, ValidationError\n JSONSCHEMA_AVAILABLE = True\nexcept ImportError:\n JSONSCHEMA_AVAILABLE = False\n print(\"Warning: jsonschema not installed. Run: pip install jsonschema\")\n\n\n# Schema path\nSCHEMA_PATH = Path(__file__).parent.parent / \"workspace\" / \"content.schema.json\"\n\n# Validation thresholds\nMAX_TITLE_LENGTH = 80\nMAX_ITEM_LENGTH = 150\nMAX_ITEMS_PER_SLIDE = 8\nBULLET_SYMBOLS = ['•', '・', '●', '○', '-', '*', '+', '◆', '◇', '▪', '▫']\n\n\nclass ValidationResult:\n \"\"\"Holds validation results.\"\"\"\n \n def __init__(self):\n self.errors: List[Dict[str, Any]] = []\n self.warnings: List[Dict[str, Any]] = []\n \n def add_error(self, error_type: str, location: str, message: str, suggestion: str = None):\n \"\"\"Add a fatal error.\"\"\"\n self.errors.append({\n \"type\": error_type,\n \"location\": location,\n \"message\": message,\n \"suggestion\": suggestion\n })\n \n def add_warning(self, warn_type: str, location: str, message: str, suggestion: str = None):\n \"\"\"Add a warning.\"\"\"\n self.warnings.append({\n \"type\": warn_type,\n \"location\": location,\n \"message\": message,\n \"suggestion\": suggestion\n })\n \n @property\n def status(self) -> str:\n \"\"\"Get overall status.\"\"\"\n if self.errors:\n return \"FAIL\"\n elif self.warnings:\n return \"WARN\"\n return \"PASS\"\n \n def to_dict(self) -> Dict[str, Any]:\n \"\"\"Convert to dictionary.\"\"\"\n return {\n \"status\": self.status,\n \"fatal_errors\": self.errors,\n \"warnings\": self.warnings,\n \"error_count\": len(self.errors),\n \"warning_count\": len(self.warnings)\n }\n\n\ndef load_schema() -> Dict[str, Any]:\n \"\"\"Load the JSON schema.\"\"\"\n if not SCHEMA_PATH.exists():\n raise FileNotFoundError(f\"Schema not found: {SCHEMA_PATH}\")\n \n with open(SCHEMA_PATH, \"r\", encoding=\"utf-8\") as f:\n return json.load(f)\n\n\ndef parse_semver(version: str) -> Tuple[int, int, int]:\n \"\"\"\n Parse semantic version string to tuple.\n \n Args:\n version: Version string like \"1.0.0\"\n \n Returns:\n Tuple of (major, minor, patch)\n \"\"\"\n parts = version.split(\".\")\n if len(parts) != 3:\n return (0, 0, 0)\n try:\n return (int(parts[0]), int(parts[1]), int(parts[2]))\n except ValueError:\n return (0, 0, 0)\n\n\ndef check_schema_version_compatibility(\n content_version: str, \n schema_version: str, \n result: ValidationResult\n) -> bool:\n \"\"\"\n Check if content schema_version is compatible with current schema.\n \n Compatibility rules:\n - Major version must match (breaking changes)\n - Content minor version \u003c= Schema minor version (new features)\n - Patch version is ignored (bug fixes)\n \n Args:\n content_version: Version from content.json (e.g., \"1.0.0\")\n schema_version: Version from content.schema.json (e.g., \"1.0.0\")\n result: ValidationResult to add warnings/errors\n \n Returns:\n True if compatible, False otherwise\n \"\"\"\n content_ver = parse_semver(content_version)\n schema_ver = parse_semver(schema_version)\n \n # Major version mismatch = incompatible\n if content_ver[0] != schema_ver[0]:\n result.add_error(\n \"schema_version\",\n \"schema_version\",\n f\"Major version mismatch: content={content_version}, schema={schema_version}\",\n f\"Content was created with schema v{content_version}, but current schema is v{schema_version}. \"\n \"Major version changes indicate breaking changes. Please migrate the content.\"\n )\n return False\n \n # Content minor version > Schema minor version = potentially incompatible\n if content_ver[1] > schema_ver[1]:\n result.add_warning(\n \"schema_version\",\n \"schema_version\",\n f\"Content uses newer schema features: content={content_version}, schema={schema_version}\",\n \"Some features in this content may not be supported. Consider updating the schema.\"\n )\n \n return True\n\n\ndef validate_schema_version(content: Dict[str, Any], schema: Dict[str, Any], result: ValidationResult) -> None:\n \"\"\"\n Validate schema version compatibility.\n \n Args:\n content: The content.json data\n schema: The loaded schema\n result: ValidationResult to add warnings/errors\n \"\"\"\n content_version = content.get(\"schema_version\", \"1.0.0\")\n schema_version = schema.get(\"version\", \"1.0.0\")\n \n check_schema_version_compatibility(content_version, schema_version, result)\n\n\ndef validate_schema(content: Dict[str, Any], result: ValidationResult) -> None:\n \"\"\"Validate against JSON Schema including version compatibility check.\"\"\"\n if not JSONSCHEMA_AVAILABLE:\n result.add_warning(\"schema\", \"global\", \"jsonschema not installed, skipping schema validation\")\n return\n \n try:\n schema = load_schema()\n \n # E2 fix: Check schema version compatibility first\n validate_schema_version(content, schema, result)\n \n validator = Draft7Validator(schema)\n \n for error in validator.iter_errors(content):\n path = \".\".join(str(p) for p in error.absolute_path) or \"root\"\n result.add_error(\n \"schema\",\n path,\n error.message,\n f\"Check the schema at {SCHEMA_PATH}\"\n )\n except FileNotFoundError as e:\n result.add_warning(\"schema\", \"global\", str(e))\n except Exception as e:\n result.add_error(\"schema\", \"global\", f\"Schema validation failed: {e}\")\n\n\ndef validate_empty_content(content: Dict[str, Any], result: ValidationResult) -> None:\n \"\"\"Check for empty slides (content type without items).\"\"\"\n slides = content.get(\"slides\", [])\n \n for i, slide in enumerate(slides):\n slide_type = slide.get(\"type\", \"unknown\")\n location = f\"slides[{i}]\"\n \n # Skip if marked as _skip\n if slide.get(\"_skip\"):\n continue\n \n # Check content slides must have items or image\n if slide_type == \"content\":\n has_items = bool(slide.get(\"items\"))\n has_image = bool(slide.get(\"image\"))\n if not has_items and not has_image:\n result.add_error(\n \"empty_content\",\n location,\n f\"Content slide at index {i} has no items or image\",\n \"Add 'items' array or 'image' object, or change type to 'section'\"\n )\n \n # Check agenda/summary must have items\n elif slide_type in [\"agenda\", \"summary\"]:\n if not slide.get(\"items\"):\n result.add_error(\n \"empty_content\",\n location,\n f\"{slide_type.capitalize()} slide at index {i} has no items\",\n \"Add 'items' array with agenda/summary points\"\n )\n \n # Check photo must have image\n elif slide_type == \"photo\":\n if not slide.get(\"image\"):\n result.add_error(\n \"empty_content\",\n location,\n f\"Photo slide at index {i} has no image\",\n \"Add 'image' object with 'path' or 'url'\"\n )\n \n # Check title/section must have title\n elif slide_type in [\"title\", \"section\"]:\n if not slide.get(\"title\"):\n result.add_error(\n \"empty_content\",\n location,\n f\"{slide_type.capitalize()} slide at index {i} has no title\",\n \"Add 'title' field\"\n )\n \n # Check two_column must have items or left_items/right_items\n elif slide_type == \"two_column\":\n has_items = bool(slide.get(\"items\"))\n has_columns = bool(slide.get(\"left_items\")) or bool(slide.get(\"right_items\"))\n if not has_items and not has_columns:\n result.add_error(\n \"empty_content\",\n location,\n f\"Two-column slide at index {i} has no content\",\n \"Add 'left_items'/'right_items' (recommended) or 'items'\"\n )\n elif has_items and not has_columns:\n # Warn: items is used but left_items/right_items is preferred\n result.add_warning(\n \"two_column_format\",\n location,\n f\"Two-column slide uses 'items' but 'left_items'/'right_items' is recommended\",\n \"Replace 'items' with 'left_title', 'left_items', 'right_title', 'right_items' for proper 2-column layout\"\n )\n\n\ndef validate_items_format(content: Dict[str, Any], result: ValidationResult) -> None:\n \"\"\"Check that items are string arrays, not object arrays.\n \n content.json schema requires items to be string[]:\n ✅ \"items\": [\"item1\", \"item2\"]\n ❌ \"items\": [{\"text\": \"item1\", \"bullet\": true}]\n \n Object format is only valid for replacements.json (preserve method).\n \"\"\"\n slides = content.get(\"slides\", [])\n \n for i, slide in enumerate(slides):\n location = f\"slides[{i}]\"\n \n # Check items array\n items = slide.get(\"items\", [])\n for j, item in enumerate(items):\n if isinstance(item, dict):\n result.add_error(\n \"items_format\",\n f\"{location}.items[{j}]\",\n f\"Item is an object but should be a string\",\n \"Use string array format: \\\"items\\\": [\\\"item1\\\", \\\"item2\\\"] instead of object format\"\n )\n break # Report only first occurrence per slide\n \n # Check left_items\n left_items = slide.get(\"left_items\", [])\n for j, item in enumerate(left_items):\n if isinstance(item, dict):\n result.add_error(\n \"items_format\",\n f\"{location}.left_items[{j}]\",\n f\"Item is an object but should be a string\",\n \"Use string array format for left_items\"\n )\n break\n \n # Check right_items\n right_items = slide.get(\"right_items\", [])\n for j, item in enumerate(right_items):\n if isinstance(item, dict):\n result.add_error(\n \"items_format\",\n f\"{location}.right_items[{j}]\",\n f\"Item is an object but should be a string\",\n \"Use string array format for right_items\"\n )\n break\n\n\ndef validate_bullet_symbols(content: Dict[str, Any], result: ValidationResult) -> None:\n \"\"\"Check for manual bullet symbols in text.\"\"\"\n slides = content.get(\"slides\", [])\n \n for i, slide in enumerate(slides):\n # Check items\n for j, item in enumerate(slide.get(\"items\", [])):\n if isinstance(item, str):\n for sym in BULLET_SYMBOLS:\n if item.startswith(sym):\n result.add_error(\n \"bullet_symbol\",\n f\"slides[{i}].items[{j}]\",\n f\"Manual bullet symbol '{sym}' found at start of item\",\n \"Remove the bullet symbol - it will be added automatically\"\n )\n break\n\n\ndef validate_image_paths(content: Dict[str, Any], result: ValidationResult, images_dir: Path = None) -> None:\n \"\"\"Check that image paths exist.\"\"\"\n slides = content.get(\"slides\", [])\n base_dir = images_dir or Path(\".\")\n \n for i, slide in enumerate(slides):\n image = slide.get(\"image\")\n if not image:\n continue\n \n # Check local path\n path = image.get(\"path\")\n if path:\n image_path = base_dir / path\n if not image_path.exists():\n # Try alternate extensions\n stem = image_path.stem\n parent = image_path.parent\n found = False\n for ext in [\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\"]:\n alt_path = parent / f\"{stem}{ext}\"\n if alt_path.exists():\n found = True\n result.add_warning(\n \"image_path\",\n f\"slides[{i}].image.path\",\n f\"Image not found at {path}, but found at {alt_path.name}\",\n f\"Update path to '{alt_path}'\"\n )\n break\n \n if not found:\n result.add_error(\n \"image_path\",\n f\"slides[{i}].image.path\",\n f\"Image not found: {path}\",\n \"Check the path or run extract_images.py first\"\n )\n\n\ndef validate_text_length(content: Dict[str, Any], result: ValidationResult) -> None:\n \"\"\"Check for text that might overflow.\"\"\"\n slides = content.get(\"slides\", [])\n \n for i, slide in enumerate(slides):\n # Check title length\n title = slide.get(\"title\", \"\")\n if len(title) > MAX_TITLE_LENGTH:\n result.add_warning(\n \"overflow\",\n f\"slides[{i}].title\",\n f\"Title length ({len(title)}) exceeds {MAX_TITLE_LENGTH} characters\",\n \"Consider shortening the title\"\n )\n \n # Check items\n items = slide.get(\"items\", [])\n if len(items) > MAX_ITEMS_PER_SLIDE:\n result.add_warning(\n \"overflow\",\n f\"slides[{i}].items\",\n f\"Too many items ({len(items)}) - recommend max {MAX_ITEMS_PER_SLIDE}\",\n \"Consider splitting into multiple slides\"\n )\n \n for j, item in enumerate(items):\n if isinstance(item, str) and len(item) > MAX_ITEM_LENGTH:\n result.add_warning(\n \"overflow\",\n f\"slides[{i}].items[{j}]\",\n f\"Item length ({len(item)}) exceeds {MAX_ITEM_LENGTH} characters\",\n \"Consider shortening or splitting the item\"\n )\n\n\ndef validate_structure(content: Dict[str, Any], result: ValidationResult) -> None:\n \"\"\"Check for structural issues (agenda, summary presence).\"\"\"\n slides = content.get(\"slides\", [])\n if not slides:\n result.add_error(\"structure\", \"slides\", \"No slides found\", \"Add at least one slide\")\n return\n \n types = [s.get(\"type\") for s in slides]\n \n # Check title slide is first\n if types[0] != \"title\":\n result.add_warning(\n \"structure\",\n \"slides[0]\",\n \"First slide is not a title slide\",\n \"Consider adding a title slide at the beginning\"\n )\n \n # Check agenda exists (for presentations with 5+ slides)\n if len(slides) >= 5 and \"agenda\" not in types:\n result.add_warning(\n \"structure\",\n \"slides\",\n \"No agenda slide found in presentation with 5+ slides\",\n \"Consider adding an agenda slide after the title\"\n )\n \n # Check summary/closing exists (for presentations with 5+ slides)\n if len(slides) >= 5:\n has_summary = \"summary\" in types\n has_closing = \"closing\" in types\n \n # Also check for content slides with summary-like titles\n summary_keywords = [\"まとめ\", \"summary\", \"結論\", \"conclusion\", \"おわりに\", \"closing\"]\n for slide in slides:\n title = slide.get(\"title\", \"\").lower()\n if any(keyword in title for keyword in summary_keywords):\n has_summary = True\n break\n \n if not has_summary and not has_closing:\n result.add_warning(\n \"structure\",\n \"slides\",\n \"No summary or closing slide found\",\n \"Consider adding a summary or closing slide at the end\"\n )\n \n # Check for excessive photo type usage (potential layout issues)\n photo_count = types.count(\"photo\")\n photo_ratio = photo_count / len(slides) if slides else 0\n if photo_count >= 5 and photo_ratio > 0.2:\n result.add_warning(\n \"structure\",\n \"slides\",\n f\"Many photo slides detected ({photo_count}/{len(slides)} = {photo_ratio:.0%})\",\n \"Consider converting some to 'type: content' with image field to add explanatory items\"\n )\n \n # Check for photo slides with center position and high width_percent\n for i, slide in enumerate(slides):\n if slide.get(\"type\") == \"photo\":\n image = slide.get(\"image\", {})\n position = image.get(\"position\", \"\")\n width_pct = image.get(\"width_percent\", 45)\n if position == \"center\" and width_pct > 60:\n result.add_warning(\n \"layout\",\n f\"slides[{i}]\",\n f\"Photo slide with center position and high width_percent ({width_pct}%) may overflow\",\n \"Consider reducing width_percent to 50-60% or using position: right\"\n )\n\n\ndef validate_content(content_path: str, images_dir: str = None) -> ValidationResult:\n \"\"\"Run all validations on a content.json file.\"\"\"\n result = ValidationResult()\n \n # Load content\n try:\n with open(content_path, \"r\", encoding=\"utf-8\") as f:\n content = json.load(f)\n except json.JSONDecodeError as e:\n result.add_error(\"json\", \"file\", f\"Invalid JSON: {e}\")\n return result\n except FileNotFoundError:\n result.add_error(\"file\", \"file\", f\"File not found: {content_path}\")\n return result\n \n # Determine images directory\n content_dir = Path(content_path).parent.parent if images_dir is None else None\n img_dir = Path(images_dir) if images_dir else content_dir\n \n # Run validations\n validate_schema(content, result)\n validate_empty_content(content, result)\n validate_items_format(content, result) # Check items are string[], not object[]\n validate_bullet_symbols(content, result)\n validate_image_paths(content, result, img_dir)\n validate_text_length(content, result)\n validate_structure(content, result)\n \n return result\n\n\ndef print_result(result: ValidationResult) -> None:\n \"\"\"Print validation result in a human-readable format.\"\"\"\n status_icon = {\"PASS\": \"✅\", \"WARN\": \"⚠️\", \"FAIL\": \"❌\"}[result.status]\n \n print(f\"\\n{status_icon} Validation {result.status}\")\n print(\"=\" * 50)\n \n if result.errors:\n print(f\"\\n❌ Errors ({len(result.errors)}):\")\n for err in result.errors:\n print(f\" [{err['type']}] {err['location']}\")\n print(f\" {err['message']}\")\n if err.get('suggestion'):\n print(f\" → {err['suggestion']}\")\n \n if result.warnings:\n print(f\"\\n⚠️ Warnings ({len(result.warnings)}):\")\n for warn in result.warnings:\n print(f\" [{warn['type']}] {warn['location']}\")\n print(f\" {warn['message']}\")\n if warn.get('suggestion'):\n print(f\" → {warn['suggestion']}\")\n \n if not result.errors and not result.warnings:\n print(\"\\n All checks passed!\")\n \n print()\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Validate content.json for PPTX generation\",\n formatter_class=argparse.RawDescriptionHelpFormatter\n )\n parser.add_argument(\"content\", help=\"Path to content.json file\")\n parser.add_argument(\"--images-dir\", \"-i\", help=\"Base directory for image paths\")\n parser.add_argument(\"--json\", action=\"store_true\", help=\"Output as JSON\")\n parser.add_argument(\"--strict\", action=\"store_true\", help=\"Treat warnings as errors\")\n \n args = parser.parse_args()\n \n result = validate_content(args.content, args.images_dir)\n \n if args.json:\n print(json.dumps(result.to_dict(), indent=2, ensure_ascii=False))\n else:\n print_result(result)\n \n # Exit code\n if result.errors:\n sys.exit(1)\n elif result.warnings and args.strict:\n sys.exit(1)\n elif result.warnings:\n sys.exit(2)\n else:\n sys.exit(0)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":21906,"content_sha256":"f8c60c54d1d1ce5a7d2ae5633ee5e119dfe64d8b331a3a516ff64caba81ba82f"},{"filename":"scripts/validate_pptx.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nValidate generated PPTX against content.json.\n\nThis script provides deterministic validation for PPTX files.\nIt checks:\n1. Slide count matches content.json\n2. Speaker notes existence and quality (detects \"source-only\" notes)\n3. Image placement verification\n4. Text overflow detection (excessive text, too many paragraphs)\n5. Basic structure validation\n\nSpeaker notes quality checks:\n- Detects notes with only source citations (e.g., \"[出典: ...]\")\n- Warns when notes are too short (\u003c30 chars)\n- Suggests adding talking points and context\n\nText overflow checks:\n- Detects shapes with >800 characters (potential overflow)\n- Detects shapes with >15 paragraphs\n- Detects lines >120 characters (horizontal overflow risk)\n\nUsage:\n python scripts/validate_pptx.py \u003coutput.pptx> \u003ccontent.json>\n python scripts/validate_pptx.py \u003coutput.pptx> --slides-only\n\nExit codes:\n 0: PASS (no errors)\n 1: FAIL (fatal errors found)\n 2: WARN (warnings only, can continue)\n\"\"\"\n\nimport argparse\nimport json\nimport re\nimport sys\nimport io\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional\n\n# Fix Windows console encoding issues\nsys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')\nsys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')\n\ntry:\n from pptx import Presentation\n from pptx.enum.shapes import MSO_SHAPE_TYPE\n PPTX_AVAILABLE = True\nexcept ImportError:\n PPTX_AVAILABLE = False\n print(\"Warning: python-pptx not installed. Run: pip install python-pptx\")\n\n\nclass ValidationResult:\n \"\"\"Holds validation results.\"\"\"\n \n def __init__(self):\n self.errors: List[Dict[str, Any]] = []\n self.warnings: List[Dict[str, Any]] = []\n self.info: List[Dict[str, Any]] = []\n \n def add_error(self, error_type: str, location: str, message: str, suggestion: str = None):\n \"\"\"Add a fatal error.\"\"\"\n self.errors.append({\n \"type\": error_type,\n \"location\": location,\n \"message\": message,\n \"suggestion\": suggestion\n })\n \n def add_warning(self, warn_type: str, location: str, message: str, suggestion: str = None):\n \"\"\"Add a warning.\"\"\"\n self.warnings.append({\n \"type\": warn_type,\n \"location\": location,\n \"message\": message,\n \"suggestion\": suggestion\n })\n \n def add_info(self, info_type: str, location: str, message: str):\n \"\"\"Add an info message.\"\"\"\n self.info.append({\n \"type\": info_type,\n \"location\": location,\n \"message\": message\n })\n \n @property\n def status(self) -> str:\n \"\"\"Get overall status.\"\"\"\n if self.errors:\n return \"FAIL\"\n elif self.warnings:\n return \"WARN\"\n return \"PASS\"\n \n def to_dict(self) -> Dict[str, Any]:\n \"\"\"Convert to dictionary.\"\"\"\n return {\n \"status\": self.status,\n \"fatal_errors\": self.errors,\n \"warnings\": self.warnings,\n \"info\": self.info,\n \"error_count\": len(self.errors),\n \"warning_count\": len(self.warnings)\n }\n\n\ndef load_content_json(content_path: str) -> Optional[Dict[str, Any]]:\n \"\"\"Load content.json file.\"\"\"\n try:\n with open(content_path, 'r', encoding='utf-8') as f:\n return json.load(f)\n except Exception as e:\n print(f\"Error loading content.json: {e}\")\n return None\n\n\ndef count_pptx_slides(pptx_path: str) -> int:\n \"\"\"Count slides in PPTX file.\"\"\"\n prs = Presentation(pptx_path)\n return len(prs.slides)\n\n\ndef get_slide_info(pptx_path: str) -> List[Dict[str, Any]]:\n \"\"\"Get detailed info about each slide in PPTX.\"\"\"\n prs = Presentation(pptx_path)\n slides_info = []\n \n for idx, slide in enumerate(prs.slides, 1):\n info = {\n \"slide_number\": idx,\n \"has_notes\": False,\n \"notes_length\": 0,\n \"notes_text\": \"\", # Store actual notes text for quality check\n \"has_images\": False,\n \"image_count\": 0,\n \"has_title\": False,\n \"title_text\": \"\",\n \"shape_count\": len(slide.shapes)\n }\n \n # Check for notes\n if slide.has_notes_slide:\n notes_text = slide.notes_slide.notes_text_frame.text.strip()\n if notes_text:\n info[\"has_notes\"] = True\n info[\"notes_length\"] = len(notes_text)\n info[\"notes_text\"] = notes_text # Store for quality analysis\n \n # Check shapes\n for shape in slide.shapes:\n # Check for images\n if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:\n info[\"has_images\"] = True\n info[\"image_count\"] += 1\n \n # Check for title\n if shape.has_text_frame:\n if hasattr(shape, 'is_placeholder') and shape.is_placeholder:\n ph_type = shape.placeholder_format.type\n if str(ph_type) in ['TITLE (1)', 'CENTER_TITLE (3)']:\n info[\"has_title\"] = True\n info[\"title_text\"] = shape.text_frame.text[:50]\n \n slides_info.append(info)\n \n return slides_info\n\n\ndef validate_slide_count(result: ValidationResult, pptx_path: str, content: Dict[str, Any]):\n \"\"\"Validate that PPTX slide count matches content.json.\"\"\"\n pptx_count = count_pptx_slides(pptx_path)\n \n # Count non-skipped slides in content.json\n slides = content.get(\"slides\", [])\n content_count = sum(1 for s in slides if not s.get(\"_skip\", False))\n \n if pptx_count != content_count:\n result.add_error(\n \"slide_count_mismatch\",\n \"global\",\n f\"PPTX has {pptx_count} slides but content.json has {content_count} slides\",\n \"Check if all slides were generated correctly\"\n )\n else:\n result.add_info(\n \"slide_count_match\",\n \"global\",\n f\"Slide count matches: {pptx_count} slides\"\n )\n\n\ndef validate_notes(result: ValidationResult, slides_info: List[Dict[str, Any]], content: Dict[str, Any]):\n \"\"\"Validate speaker notes existence and quality.\"\"\"\n slides = content.get(\"slides\", [])\n non_skipped = [s for s in slides if not s.get(\"_skip\", False)]\n \n missing_notes = []\n insufficient_notes = []\n source_only_notes = []\n \n # Patterns that indicate \"source only\" notes (insufficient content)\n SOURCE_ONLY_PATTERNS = [\n r\"^\\s*\\[出典:.*?\\]\\s*$\", # Only source citation (Japanese)\n r\"^\\s*---\\s*\\n?\\s*\\[出典:.*?\\]\\s*$\", # Separator + source citation only\n r\"^\\s*\\[?Source:.*?\\]?\\s*$\", # English source only\n r\"^\\s*\\[新規作成\\]\\s*$\", # Only \"newly created\" marker\n r\"^\\s*---\\s*\\n?\\s*\\[新規作成\\]\\s*$\", # Separator + newly created only\n ]\n \n # Minimum notes length (excluding source citations)\n MIN_NOTES_LENGTH = 30\n \n for idx, (slide_info, content_slide) in enumerate(zip(slides_info, non_skipped), 1):\n # Check if content.json expects notes\n expected_notes = content_slide.get(\"notes\", \"\")\n slide_type = content_slide.get(\"type\", \"content\")\n \n if expected_notes and not slide_info[\"has_notes\"]:\n missing_notes.append(idx)\n \n # Check notes quality (only for content/section slides with notes)\n if slide_info[\"has_notes\"] and slide_type in [\"content\", \"section\", \"two_column\", \"photo\"]:\n notes_text = slide_info.get(\"notes_text\", \"\")\n notes_length = slide_info.get(\"notes_length\", 0)\n \n # Check if notes match \"source only\" patterns\n is_source_only = False\n for pattern in SOURCE_ONLY_PATTERNS:\n if re.match(pattern, notes_text, re.MULTILINE | re.DOTALL):\n is_source_only = True\n break\n \n if is_source_only:\n source_only_notes.append({\n \"slide\": idx,\n \"type\": slide_type,\n \"preview\": notes_text[:50] + \"...\" if len(notes_text) > 50 else notes_text\n })\n elif notes_length \u003c MIN_NOTES_LENGTH:\n # Check if notes are too short (but not source-only)\n insufficient_notes.append({\n \"slide\": idx,\n \"type\": slide_type,\n \"length\": notes_length,\n \"reason\": \"too_short\"\n })\n \n if missing_notes:\n result.add_warning(\n \"missing_notes\",\n f\"slides {missing_notes}\",\n f\"{len(missing_notes)} slides are missing expected speaker notes\",\n \"Speaker notes may not have been applied correctly\"\n )\n \n if source_only_notes:\n source_slides = [n[\"slide\"] for n in source_only_notes]\n result.add_warning(\n \"source_only_notes\",\n f\"slides {source_slides}\",\n f\"{len(source_only_notes)} slides have only source citations in notes (no actual content)\",\n \"Add talking points, background info, or context for the presenter\"\n )\n \n if insufficient_notes:\n short_slides = [n[\"slide\"] for n in insufficient_notes]\n result.add_warning(\n \"insufficient_notes\",\n f\"slides {short_slides}\",\n f\"{len(insufficient_notes)} slides have very short speaker notes (\u003c{MIN_NOTES_LENGTH} chars)\",\n \"Consider adding more detail: background info, talking points, or context\"\n )\n \n # Count slides without notes\n no_notes_count = sum(1 for s in slides_info if not s[\"has_notes\"])\n if no_notes_count > 0:\n result.add_info(\n \"notes_stats\",\n \"global\",\n f\"{len(slides_info) - no_notes_count}/{len(slides_info)} slides have speaker notes\"\n )\n\n\ndef validate_images(result: ValidationResult, slides_info: List[Dict[str, Any]], content: Dict[str, Any]):\n \"\"\"Validate image placement.\"\"\"\n slides = content.get(\"slides\", [])\n non_skipped = [s for s in slides if not s.get(\"_skip\", False)]\n \n missing_images = []\n \n for idx, (slide_info, content_slide) in enumerate(zip(slides_info, non_skipped), 1):\n # Check if content.json expects images\n has_image_spec = \"image\" in content_slide\n slide_type = content_slide.get(\"type\", \"\")\n \n if has_image_spec and not slide_info[\"has_images\"]:\n missing_images.append(idx)\n \n # Photo type should have images\n if slide_type == \"photo\" and not slide_info[\"has_images\"]:\n missing_images.append(idx)\n \n # Remove duplicates\n missing_images = list(set(missing_images))\n \n if missing_images:\n result.add_warning(\n \"missing_images\",\n f\"slides {sorted(missing_images)}\",\n f\"{len(missing_images)} slides are missing expected images\",\n \"Check image paths and embedding\"\n )\n \n # Count total images\n total_images = sum(s[\"image_count\"] for s in slides_info)\n result.add_info(\n \"image_stats\",\n \"global\",\n f\"Total images in PPTX: {total_images}\"\n )\n\n\ndef validate_text_overflow(result: ValidationResult, pptx_path: str):\n \"\"\"\n Check for text overflow issues in PPTX.\n \n Detects:\n - Excessively long text in a single shape (>500 chars)\n - Too many paragraphs in a single shape (>15)\n - Very long lines that may cause horizontal overflow\n \"\"\"\n try:\n prs = Presentation(pptx_path)\n except Exception:\n return # Already handled in main validation\n \n overflow_slides = []\n long_text_slides = []\n \n for slide_idx, slide in enumerate(prs.slides, 1):\n for shape in slide.shapes:\n if not hasattr(shape, \"text_frame\") or not shape.has_text_frame:\n continue\n \n text_frame = shape.text_frame\n text_content = text_frame.text\n \n # Check for very long text (potential overflow)\n if len(text_content) > 800:\n long_text_slides.append(slide_idx)\n \n # Check paragraph count\n para_count = len(text_frame.paragraphs)\n if para_count > 15:\n overflow_slides.append(slide_idx)\n \n # Check for very long single lines (may cause horizontal overflow)\n for para in text_frame.paragraphs:\n line_text = para.text\n if len(line_text) > 120:\n if slide_idx not in overflow_slides:\n overflow_slides.append(slide_idx)\n \n if overflow_slides:\n result.add_warning(\n \"potential_overflow\",\n f\"slides {sorted(set(overflow_slides))}\",\n f\"{len(set(overflow_slides))} slides may have text overflow\",\n \"Review these slides for text overflow or excessive content\"\n )\n \n if long_text_slides:\n result.add_warning(\n \"long_text\",\n f\"slides {sorted(set(long_text_slides))}\",\n f\"{len(set(long_text_slides))} slides have very long text (>800 chars)\",\n \"Consider splitting content across multiple slides\"\n )\n \n if not overflow_slides and not long_text_slides:\n result.add_info(\n \"text_overflow_check\",\n \"global\",\n \"No text overflow issues detected\"\n )\n\n\ndef validate_signature(result: ValidationResult, slides_info: List[Dict[str, Any]]):\n \"\"\"Check for repository signature in notes.\"\"\"\n if not slides_info:\n return\n \n # Check first and last slide for signature\n first_slide = slides_info[0]\n last_slide = slides_info[-1]\n \n has_signature = first_slide[\"has_notes\"] or last_slide[\"has_notes\"]\n \n if has_signature:\n result.add_info(\n \"signature\",\n \"global\",\n \"Repository signature found in speaker notes\"\n )\n\n\ndef validate_pptx(pptx_path: str, content_path: str = None) -> ValidationResult:\n \"\"\"\n Main validation function.\n \n Args:\n pptx_path: Path to PPTX file\n content_path: Optional path to content.json for comparison\n \n Returns:\n ValidationResult object\n \"\"\"\n result = ValidationResult()\n \n if not PPTX_AVAILABLE:\n result.add_error(\n \"dependency\",\n \"system\",\n \"python-pptx is not installed\",\n \"Run: pip install python-pptx\"\n )\n return result\n \n # Check PPTX file exists\n if not Path(pptx_path).exists():\n result.add_error(\n \"file_not_found\",\n pptx_path,\n \"PPTX file not found\",\n \"Check the file path\"\n )\n return result\n \n # Get slide info\n try:\n slides_info = get_slide_info(pptx_path)\n except Exception as e:\n result.add_error(\n \"pptx_parse_error\",\n pptx_path,\n f\"Failed to parse PPTX: {e}\",\n \"Check if the PPTX file is corrupted\"\n )\n return result\n \n result.add_info(\n \"pptx_loaded\",\n pptx_path,\n f\"Successfully loaded PPTX with {len(slides_info)} slides\"\n )\n \n # If content.json provided, do comparison validation\n if content_path:\n content = load_content_json(content_path)\n if content:\n validate_slide_count(result, pptx_path, content)\n validate_notes(result, slides_info, content)\n validate_images(result, slides_info, content)\n \n # Always check text overflow\n validate_text_overflow(result, pptx_path)\n \n # Always check signature\n validate_signature(result, slides_info)\n \n return result\n\n\ndef print_result(result: ValidationResult, verbose: bool = False):\n \"\"\"Print validation result to console.\"\"\"\n status = result.status\n \n # Status indicator\n if status == \"PASS\":\n print(f\"\\n✅ PASS - No issues found\")\n elif status == \"WARN\":\n print(f\"\\n⚠️ WARN - {result.to_dict()['warning_count']} warning(s)\")\n else:\n print(f\"\\n❌ FAIL - {result.to_dict()['error_count']} error(s)\")\n \n # Print errors\n if result.errors:\n print(\"\\n--- Errors ---\")\n for err in result.errors:\n print(f\" [{err['type']}] {err['location']}: {err['message']}\")\n if err.get('suggestion'):\n print(f\" 💡 {err['suggestion']}\")\n \n # Print warnings\n if result.warnings:\n print(\"\\n--- Warnings ---\")\n for warn in result.warnings:\n print(f\" [{warn['type']}] {warn['location']}: {warn['message']}\")\n if warn.get('suggestion'):\n print(f\" 💡 {warn['suggestion']}\")\n \n # Print info if verbose\n if verbose and result.info:\n print(\"\\n--- Info ---\")\n for info in result.info:\n print(f\" [{info['type']}] {info['message']}\")\n\n\ndef main():\n \"\"\"Main entry point.\"\"\"\n parser = argparse.ArgumentParser(\n description=\"Validate generated PPTX against content.json\"\n )\n parser.add_argument(\"pptx_path\", help=\"Path to PPTX file\")\n parser.add_argument(\"content_path\", nargs=\"?\", help=\"Path to content.json (optional)\")\n parser.add_argument(\"--slides-only\", action=\"store_true\", \n help=\"Only check slide count, skip content comparison\")\n parser.add_argument(\"--verbose\", \"-v\", action=\"store_true\",\n help=\"Show detailed info messages\")\n parser.add_argument(\"--json\", action=\"store_true\",\n help=\"Output result as JSON\")\n \n args = parser.parse_args()\n \n # Run validation\n content_path = None if args.slides_only else args.content_path\n result = validate_pptx(args.pptx_path, content_path)\n \n # Output\n if args.json:\n print(json.dumps(result.to_dict(), indent=2, ensure_ascii=False))\n else:\n print_result(result, args.verbose)\n \n # Exit code\n if result.status == \"FAIL\":\n sys.exit(1)\n elif result.status == \"WARN\":\n sys.exit(2)\n sys.exit(0)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":18698,"content_sha256":"d0cd4497cc7cdc65a6609540a27ea401cf301365163e02d108daf99e8d57d0f0"},{"filename":"scripts/workflow_tracer.py","content":"# -*- coding: utf-8 -*-\n# =============================================================================\n# Ag-ppt-create - AI-powered PPTX generation pipeline\n# https://github.com/aktsmm/Ag-ppt-create\n# \n# Copyright (c) aktsmm. Licensed under CC BY-NC-SA 4.0.\n# DO NOT MODIFY THIS HEADER BLOCK.\n# =============================================================================\n\"\"\"\nWorkflow tracer for observability and debugging.\n\nProvides structured logging for each phase of the PPTX generation workflow.\nLogs are appended to {base}_trace.jsonl for post-mortem analysis.\n\nUsage:\n from workflow_tracer import WorkflowTracer\n \n tracer = WorkflowTracer(\"20251214_example_report\")\n tracer.start_phase(\"EXTRACT\")\n # ... do work ...\n tracer.end_phase(\"EXTRACT\", status=\"success\", metrics={\"slides\": 45})\n tracer.save()\n\nTrace file format (JSONL):\n {\"trace_id\": \"...\", \"phase\": \"INIT\", \"status\": \"success\", ...}\n {\"trace_id\": \"...\", \"phase\": \"EXTRACT\", \"status\": \"success\", ...}\n\"\"\"\n\nimport json\nimport sys\nimport io\nimport uuid\nfrom pathlib import Path\nfrom datetime import datetime\nfrom typing import Any, Dict, List, Optional\n\n# Fix Windows console encoding issues\nsys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')\nsys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')\n\n\nclass WorkflowTracer:\n \"\"\"\n Trace workflow execution for observability.\n \"\"\"\n \n PHASES = [\n \"INIT\",\n \"PLAN\", \n \"PREPARE_TEMPLATE\", # Template diagnosis & cleaning\n \"EXTRACT\",\n \"SUMMARIZE\", # Summarizer agent phase\n \"TRANSLATE\",\n \"REVIEW_JSON\",\n \"BUILD\",\n \"REVIEW_PPTX\",\n \"DONE\",\n \"ESCALATE\"\n ]\n \n def __init__(self, base_name: str, output_dir: str = \"output_manifest\"):\n \"\"\"\n Initialize tracer.\n \n Args:\n base_name: Base name for the workflow (e.g., \"20251214_example_report\")\n output_dir: Directory to save trace file\n \"\"\"\n self.base_name = base_name\n self.trace_id = f\"{base_name}_{uuid.uuid4().hex[:8]}\"\n self.output_dir = Path(output_dir)\n self.trace_file = self.output_dir / f\"{base_name}_trace.jsonl\"\n self.entries: List[Dict[str, Any]] = []\n self.current_phase: Optional[str] = None\n self.phase_start_time: Optional[datetime] = None\n self.retry_counts: Dict[str, int] = {}\n \n # Ensure output directory exists\n self.output_dir.mkdir(parents=True, exist_ok=True)\n \n def log(self, phase: str, status: str, message: str = \"\", \n metrics: Optional[Dict[str, Any]] = None,\n input_file: str = \"\", output_file: str = \"\",\n error: str = \"\") -> Dict[str, Any]:\n \"\"\"\n Log a trace entry.\n \n Args:\n phase: Workflow phase (INIT, PLAN, EXTRACT, etc.)\n status: Status (started, success, failed, warning, escalated)\n message: Human-readable message\n metrics: Optional metrics dictionary\n input_file: Input file for this phase\n output_file: Output file for this phase\n error: Error message if failed\n \n Returns:\n The logged entry dictionary\n \"\"\"\n entry = {\n \"trace_id\": self.trace_id,\n \"base_name\": self.base_name,\n \"timestamp\": datetime.now().isoformat(),\n \"phase\": phase,\n \"status\": status,\n \"message\": message,\n }\n \n if input_file:\n entry[\"input\"] = input_file\n if output_file:\n entry[\"output\"] = output_file\n if metrics:\n entry[\"metrics\"] = metrics\n if error:\n entry[\"error\"] = error\n if phase in self.retry_counts:\n entry[\"retry_count\"] = self.retry_counts[phase]\n \n self.entries.append(entry)\n \n # Also print to console\n status_icon = {\n \"started\": \"🚀\",\n \"success\": \"✅\",\n \"failed\": \"❌\",\n \"warning\": \"⚠️\",\n \"escalated\": \"🆘\",\n }.get(status, \"ℹ️\")\n \n print(f\"{status_icon} [{phase}] {status}: {message}\")\n \n return entry\n \n def start_phase(self, phase: str, input_file: str = \"\", message: str = \"\") -> None:\n \"\"\"Start a new phase.\"\"\"\n self.current_phase = phase\n self.phase_start_time = datetime.now()\n self.log(phase, \"started\", message or f\"Starting {phase} phase\", input_file=input_file)\n \n def end_phase(self, phase: str, status: str = \"success\", \n output_file: str = \"\", message: str = \"\",\n metrics: Optional[Dict[str, Any]] = None,\n error: str = \"\") -> None:\n \"\"\"End the current phase.\"\"\"\n duration_ms = 0\n if self.phase_start_time:\n duration_ms = int((datetime.now() - self.phase_start_time).total_seconds() * 1000)\n \n final_metrics = metrics or {}\n final_metrics[\"duration_ms\"] = duration_ms\n \n self.log(\n phase, status, \n message or f\"{phase} phase completed\",\n metrics=final_metrics,\n output_file=output_file,\n error=error\n )\n \n self.current_phase = None\n self.phase_start_time = None\n \n def record_retry(self, phase: str, reason: str, retry_num: int) -> None:\n \"\"\"Record a retry attempt.\"\"\"\n self.retry_counts[phase] = retry_num\n self.log(\n phase, \"warning\",\n f\"Retry #{retry_num}: {reason}\",\n metrics={\"retry_number\": retry_num}\n )\n \n def escalate(self, phase: str, reason: str, retry_count: int = 0) -> Dict[str, Any]:\n \"\"\"\n Record escalation to human.\n \n Creates escalation file for resumption.\n \"\"\"\n entry = self.log(\n phase, \"escalated\",\n f\"Escalated to human: {reason}\",\n metrics={\"total_retries\": retry_count},\n error=reason\n )\n \n # Create escalation file\n escalation_file = self.output_dir / f\"{self.base_name}_escalation.json\"\n escalation_data = {\n \"trace_id\": self.trace_id,\n \"base_name\": self.base_name,\n \"escalated_at\": datetime.now().isoformat(),\n \"phase\": phase,\n \"reason\": reason,\n \"retry_count\": retry_count,\n \"resume_command\": f\"python scripts/resume_workflow.py {self.base_name} --from {phase}\",\n \"status\": \"pending_human_action\"\n }\n \n with open(escalation_file, \"w\", encoding=\"utf-8\") as f:\n json.dump(escalation_data, f, indent=2, ensure_ascii=False)\n \n print(f\"\\n🆘 ESCALATION: {reason}\")\n print(f\" Escalation file: {escalation_file}\")\n print(f\" Resume with: {escalation_data['resume_command']}\")\n \n return entry\n \n def save(self) -> None:\n \"\"\"Save trace entries to JSONL file.\"\"\"\n with open(self.trace_file, \"a\", encoding=\"utf-8\") as f:\n for entry in self.entries:\n f.write(json.dumps(entry, ensure_ascii=False) + \"\\n\")\n \n # Clear saved entries\n saved_count = len(self.entries)\n self.entries = []\n print(f\"📝 Trace saved: {self.trace_file} ({saved_count} entries)\")\n \n def get_summary(self) -> Dict[str, Any]:\n \"\"\"Get a summary of the workflow execution.\"\"\"\n # Read all entries from file\n all_entries = []\n if self.trace_file.exists():\n with open(self.trace_file, \"r\", encoding=\"utf-8\") as f:\n for line in f:\n if line.strip():\n all_entries.append(json.loads(line))\n \n # Add current unsaved entries\n all_entries.extend(self.entries)\n \n # Calculate summary\n phases_completed = set()\n total_duration = 0\n errors = []\n \n for entry in all_entries:\n if entry.get(\"status\") == \"success\":\n phases_completed.add(entry.get(\"phase\"))\n if entry.get(\"metrics\", {}).get(\"duration_ms\"):\n total_duration += entry[\"metrics\"][\"duration_ms\"]\n if entry.get(\"error\"):\n errors.append(entry[\"error\"])\n \n return {\n \"trace_id\": self.trace_id,\n \"base_name\": self.base_name,\n \"phases_completed\": list(phases_completed),\n \"total_duration_ms\": total_duration,\n \"total_entries\": len(all_entries),\n \"errors\": errors,\n \"has_escalation\": any(e.get(\"status\") == \"escalated\" for e in all_entries)\n }\n\n\ndef create_tracer_for_script(base_name: str) -> WorkflowTracer:\n \"\"\"\n Convenience function to create a tracer for a script.\n \n Usage in scripts:\n from workflow_tracer import create_tracer_for_script\n tracer = create_tracer_for_script(\"20251214_example\")\n tracer.start_phase(\"EXTRACT\")\n # ... work ...\n tracer.end_phase(\"EXTRACT\", status=\"success\")\n tracer.save()\n \"\"\"\n return WorkflowTracer(base_name)\n\n\n# Example usage\nif __name__ == \"__main__\":\n # Demo usage\n tracer = WorkflowTracer(\"20251214_demo_report\")\n \n tracer.start_phase(\"INIT\", message=\"Initializing workflow\")\n tracer.end_phase(\"INIT\", status=\"success\", metrics={\"input_type\": \"pptx_en\"})\n \n tracer.start_phase(\"EXTRACT\", input_file=\"input/demo.pptx\")\n tracer.end_phase(\"EXTRACT\", status=\"success\", \n output_file=\"output_manifest/20251214_demo_report_content.json\",\n metrics={\"slides_extracted\": 45, \"images_extracted\": 12})\n \n tracer.start_phase(\"TRANSLATE\")\n tracer.record_retry(\"TRANSLATE\", \"Rate limit exceeded\", 1)\n tracer.end_phase(\"TRANSLATE\", status=\"success\",\n output_file=\"output_manifest/20251214_demo_report_content_ja.json\",\n metrics={\"slides_translated\": 45})\n \n tracer.save()\n \n print(\"\\n📊 Summary:\")\n print(json.dumps(tracer.get_summary(), indent=2, ensure_ascii=False))\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10271,"content_sha256":"60f869b53cdd052288748840c190b4b30287731101a8b648e15fa35142d13d07"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"PowerPoint Automation","type":"text"}]},{"type":"paragraph","content":[{"text":"AI-powered PPTX generation using Orchestrator-Workers pattern.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When to Use","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Web 記事やブログをスライド化したいとき","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"既存 PPTX を翻訳・再構成したいとき","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"開いている PPTX を COM Automation で直接編集したいとき","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"テンプレートベースで PPTX を生成したいとき","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"content.json を SSOT にして、抽出・翻訳・生成・レビューを分離したいとき","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Start","type":"text"}]},{"type":"paragraph","content":[{"text":"From Web Article","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"Create a 15-slide presentation from: https://zenn.dev/example/article","type":"text"}]},{"type":"paragraph","content":[{"text":"From Existing PPTX","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"Translate this presentation to Japanese: input/presentation.pptx","type":"text"}]},{"type":"paragraph","content":[{"text":"Edit Open PPTX with COM","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"Edit the currently open PowerPoint deck with COM Automation and verify RefURL, notes, overflow, and hyperlinks.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Workflow","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"TRIAGE → PLAN → PREPARE_TEMPLATE → EXTRACT → TRANSLATE → BUILD → REVIEW → DONE","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Phase","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Main Actor","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Purpose","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"EXTRACT","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"reconstruct_analyzer.py","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Source -> content.json (+ ","type":"text"},{"text":"extract_images.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" for images)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BUILD","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"create_from_template.py","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"content.json -> PPTX","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"REVIEW","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PPTX Reviewer","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Overflow / consistency / quality","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Core Assets","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Scripts","type":"text"}]},{"type":"paragraph","content":[{"text":"Script selection, arguments, and validation entry points are defined in ","type":"text"},{"text":"references/SCRIPTS.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/SCRIPTS.md","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"content.json","type":"text"}]},{"type":"paragraph","content":[{"text":"content.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" はこの skill の SSOT。抽出、翻訳、生成、レビューの間は常にこれを基準にする。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"slides\": [\n { \"type\": \"title\", \"title\": \"Title\", \"subtitle\": \"Sub\" },\n { \"type\": \"content\", \"title\": \"Topic\", \"items\": [\"Point 1\"] }\n ]\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"スキーマ詳細は ","type":"text"},{"text":"references/schemas/content.schema.json","type":"text","marks":[{"type":"link","attrs":{"href":"references/schemas/content.schema.json","title":null}}]},{"text":" を参照。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Template","type":"text"}]},{"type":"paragraph","content":[{"text":"標準テンプレートは ","type":"text"},{"text":"assets/template.pptx","type":"text","marks":[{"type":"code_inline"}]},{"text":"。レイアウトや用途の詳細は template 側で管理し、main SKILL には最小限だけ残す。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python scripts/create_from_template.py assets/template.pptx content.json output.pptx --config assets/template_layouts.json","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Agents","type":"text"}]},{"type":"paragraph","content":[{"text":"Role definitions and handoffs are in ","type":"text"},{"text":"references/AGENTS.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/AGENTS.md","title":null}}]},{"text":" and ","type":"text"},{"text":"references/agents/","type":"text","marks":[{"type":"link","attrs":{"href":"references/agents/","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Operating Rules","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"SSOT","type":"text","marks":[{"type":"strong"}]},{"text":": Derive content.json from the approved manuscript and retain stable topic/question-to-slide mappings. Reuse approval for unchanged content; changed claims or explicit scope constraints need confirmation. Compress related Q&A for the presentation format, not by dropping qualifying conditions.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"One phase, one purpose","type":"text","marks":[{"type":"strong"}]},{"text":": 抽出・翻訳・生成・レビューを混ぜない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fail fast","type":"text","marks":[{"type":"strong"}]},{"text":": 問題が出たら次フェーズへ無理に進めない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Human in loop","type":"text","marks":[{"type":"strong"}]},{"text":": PLAN でユーザー確認を入れる","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Technical content is verified content","type":"text","marks":[{"type":"strong"}]},{"text":": Azure / Microsoft の内容は MCP で一次情報確認してから入れる","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PowerPoint lock first","type":"text","marks":[{"type":"strong"}]},{"text":": 開いている PPTX に対して python-pptx で上書きしない。読み取りも ","type":"text"},{"text":"PermissionError","type":"text","marks":[{"type":"code_inline"}]},{"text":" になるので、","type":"text"},{"text":"Copy-Item","type":"text","marks":[{"type":"code_inline"}]},{"text":" で ","type":"text"},{"text":"%TEMP%","type":"text","marks":[{"type":"code_inline"}]},{"text":" へ複製してから読む。","type":"text"},{"text":"markitdown","type":"text","marks":[{"type":"code_inline"}]},{"text":" の出力を PowerShell でパイプすると文字化けするため ","type":"text"},{"text":"-o","type":"text","marks":[{"type":"code_inline"}]},{"text":" でファイルへ書かせる","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"COM for open decks","type":"text","marks":[{"type":"strong"}]},{"text":": 開いている PPTX や既存 deck の直接編集は ","type":"text"},{"text":"references/instructions/com-automation.instructions.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/instructions/com-automation.instructions.md","title":null}}]},{"text":" を参照する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Leave it open or close it fully","type":"text","marks":[{"type":"strong"}]},{"text":": COM 作業の最後は「ユーザーに見せるので開いたまま」か「完全に終了」かを必ず決める。中身のない PowerPoint ウィンドウを残さない。内部処理だけなら ","type":"text"},{"text":"Presentation.Close()","type":"text","marks":[{"type":"code_inline"}]},{"text":" の後に ","type":"text"},{"text":"Presentations.Count","type":"text","marks":[{"type":"code_inline"}]},{"text":" が 0 であることを確認して ","type":"text"},{"text":"Application.Quit()","type":"text","marks":[{"type":"code_inline"}]},{"text":" し、それでも残る ","type":"text"},{"text":"POWERPNT","type":"text","marks":[{"type":"code_inline"}]},{"text":" は ","type":"text"},{"text":"MainWindowTitle が空またはファイル名なしのプロセスだけ","type":"text","marks":[{"type":"strong"}]},{"text":" 停止する(ファイル名入りはユーザー作業中)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Know how the deck was opened","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"Invoke-Item","type":"text","marks":[{"type":"code_inline"}]},{"text":" / エクスプローラー経由で開いた OneDrive・SharePoint 上の PPTX は ","type":"text"},{"text":"Presentation.FullName","type":"text","marks":[{"type":"code_inline"}]},{"text":" が ","type":"text"},{"text":"https://","type":"text","marks":[{"type":"code_inline"}]},{"text":" になり、","type":"text"},{"text":"SaveCopyAs","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" が %TEMP% へも書かずに例外なしで失敗する","type":"text","marks":[{"type":"strong"}]},{"text":"。PDF 書き出し前に ","type":"text"},{"text":"FullName","type":"text","marks":[{"type":"code_inline"}]},{"text":" を確認し、URL なら一度 Close してローカル絶対パスで ","type":"text"},{"text":"Presentations.Open()","type":"text","marks":[{"type":"code_inline"}]},{"text":" し直してから ","type":"text"},{"text":"SaveAs($tmp, 32)","type":"text","marks":[{"type":"code_inline"}]},{"text":" する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Notes are distributable content","type":"text","marks":[{"type":"strong"}]},{"text":": Put presenter guidance in notes, but exclude material the recipient may not receive from the entire deliverable, including hidden slides, notes and embedded sources. Use a separate restricted file; NDA labels and hidden flags do not grant sharing permission.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Audience context first","type":"text","marks":[{"type":"strong"}]},{"text":": Introduce each topic's purpose or trigger, current situation and unresolved concern before answers and next actions. Keep this context on main slides, not only in notes; reserve notes for permitted presenter guidance and implementation detail.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Template means template","type":"text","marks":[{"type":"strong"}]},{"text":": ユーザー指定テンプレートがある場合、特に表紙はテンプレートの既存プレースホルダー/レイアウトを使い、上から別図形を重ねて隠さない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reusable template means slide master","type":"text","marks":[{"type":"strong"}]},{"text":": 「テンプレートを作る」と言われたら、表紙・本文・Ending の再利用デザインはスライド直置きではなく ","type":"text"},{"text":"SlideMaster.CustomLayouts","type":"text","marks":[{"type":"code_inline"}]},{"text":" に置き、編集する文字は placeholder / text shape として残す","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PPTX asset metadata gate","type":"text","marks":[{"type":"strong"}]},{"text":": テンプレートや配布用 PPTX を skill asset / public artifact に含める前に ","type":"text"},{"text":"scripts/clean_template.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" で ","type":"text"},{"text":"docProps/custom.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":"(MIP ラベル、tenant、SharePoint フィールド、同僚メール)と ","type":"text"},{"text":"docProps/core.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":"(","type":"text"},{"text":"cp:lastModifiedBy","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"cp:revision","type":"text","marks":[{"type":"code_inline"}]},{"text":")を sanitize する。PowerPoint は OneDrive / SharePoint から開くたびに MIP を再付与するので、check-in 直前に毎回実行する。詳細は ","type":"text"},{"text":"references/instructions/template.instructions.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/instructions/template.instructions.md#template-metadata-hygiene","title":null}}]},{"text":"。スライド本文検索だけで安全判定しない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"JP template font default","type":"text","marks":[{"type":"strong"}]},{"text":": 日本語の再利用テンプレートでは master/layout の既定和文フォントを ","type":"text"},{"text":"BIZ UDPゴシック","type":"text","marks":[{"type":"code_inline"}]},{"text":" に揃え、生成スライド側の後処理で毎回フォントを直す設計にしない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Rendered QA before handoff","type":"text","marks":[{"type":"strong"}]},{"text":": Export every slide after building or COM touch-up; check overlaps, font consistency and table readability. If an image-capable, read-only reviewer is available, delegate previews, approved content.json and template constraints; request reviewed slide IDs, issues and fixes only. Otherwise inspect locally. Re-render and recheck affected slides after fixes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Review like a critic, not a generator","type":"text","marks":[{"type":"strong"}]},{"text":": ユーザーに見せる前に、レンダー画像で「スカスカ・文字が小さい・アイコンが雑・テンプレ踏襲不足・旧文言残り」を自分で探して直す。詳しくは ","type":"text"},{"text":"references/instructions/deck-iteration-review.instructions.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/instructions/deck-iteration-review.instructions.md","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Media restore gate","type":"text","marks":[{"type":"strong"}]},{"text":": slide insertion/deletion/recovery after a user review must re-check expected slide count and embedded video/media positions before continuing","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Template base-slide gate","type":"text","marks":[{"type":"strong"}]},{"text":": テンプレートを複製して生成する場合は、保持する sample slide を先に明示し、不要な contact / next-event 等を構造編集で削除する。完了前に content manifest から導いた期待枚数、残存 placeholder、slide order を検証する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Editable diagrams","type":"text","marks":[{"type":"strong"}]},{"text":": Generated images are optional design references, not editable output. Rebuild text, shapes and connectors from approved content.json with suitable licensed icon assets; preserve diagram relationships. Keep photos/screenshots as images; no ASCII art or full-slide rasterization as a substitute for editable slides.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Grid-first placement","type":"text","marks":[{"type":"strong"}]},{"text":": Initialize positions on a consistent layout grid, refine against actual slide renders, and remove temporary grid overlays from final exports.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Appendix URLs use Title - URL","type":"text","marks":[{"type":"strong"}]},{"text":": 参考 URL の表示形式は統一する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Review content and visuals separately","type":"text","marks":[{"type":"strong"}]},{"text":": 生成後は見た目レビューだけでなく、公式情報との正確性レビュー、URL hyperlink 数、notes 数、placeholder/internal wording を確認する","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Reference Map","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Frequently Needed","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/SCRIPTS.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/SCRIPTS.md","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/USE_CASES.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/USE_CASES.md","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/content-guidelines.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/content-guidelines.md","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/IMPLEMENTATION_PATTERNS.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/IMPLEMENTATION_PATTERNS.md","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/instructions/com-automation.instructions.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/instructions/com-automation.instructions.md","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/instructions/template.instructions.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/instructions/template.instructions.md","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/instructions/customer-facing-deck.instructions.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/instructions/customer-facing-deck.instructions.md","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/instructions/deck-iteration-review.instructions.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/instructions/deck-iteration-review.instructions.md","title":null}}]}]}]}]},{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"Implementation Patterns","type":"text","marks":[{"type":"link","attrs":{"href":"references/IMPLEMENTATION_PATTERNS.md","title":null}}]},{"text":" for shape diagrams, template XML, SlideMaster edits, visual QA, hyperlinks, RefURL, locks, centering, corruption recovery, and media embedding.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Done Criteria","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"source と goal が固定されている","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"content.json を正として各フェーズが分離されている","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"template / layout の前提が確認できている","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"technical content は一次情報確認が済んでいる","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"operational text がスライド面に出ていない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"template 利用時は単調な同一レイアウト反復と余剰 placeholder を確認している","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"テンプレートの保持/削除対象を含む期待スライド数と実際の slide set が一致している","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"visual QA はレンダー画像で行い、修正後に該当スライドを再確認している","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"表は本文 16pt 以上を原則とし、header は中央揃え・中段揃えで視認性を確認している","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"build 後に overflow / consistency / hyperlink をレビューできている","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"画像の QR は全て書き出してデコードし、期待 URL と照合している(回ごとに宛先が変わる QR は旧版のまま残りやすい。リンク文字列だけの確認では画像側の取り違えを検出できない)","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"powerpoint-automation","author":"@skillopedia","source":{"stars":26,"repo_name":"Agent-Skills","origin_url":"https://github.com/aktsmm/Agent-Skills/blob/HEAD/powerpoint-automation/SKILL.md","repo_owner":"aktsmm","body_sha256":"95560a6a026b443b732640f939c0cca352f5124058ab7b24d3f81299bac14fd4","cluster_key":"77abc2fe65577f7b5bdd1070196b6cfdad41db04c7fe85aa1cf42d30ba1a5660","clean_bundle":{"format":"clean-skill-bundle-v1","source":"aktsmm/Agent-Skills/powerpoint-automation/SKILL.md","text_bytes":646898,"source_commit":"4a574d3bcf662afd5fd84ff14eb9d7e44c454797","skills_sh_rank":8349,"attachment_count":57,"text_attachments":57,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"powerpoint-automation/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"documents-office","category_label":"Documents"},"exact_dupes_collapsed_into_this":0},"license":"CC BY-NC-SA 4.0","version":"v1","category":"documents-office","metadata":{"author":"yamapan (https://github.com/aktsmm)"},"import_tag":"skills-sh-top20k-2026-11","description":"Convert web articles or blog posts into slides through a content.json pipeline, translate or rebuild existing PPTX files onto a template, and edit an already-open PowerPoint deck with COM Automation (RefURL, overflow review). Use for article-to-slides conversion, deck translation, template-based rebuilds, or live COM editing of an open deck. Not for simply reading, inspecting, or lightly editing a .pptx file. Triggers on 記事をスライド化, スライド翻訳, 開いているパワポを編集, COM自動化, RefURL, content.json.","argument-hint":"変換したい URL・PPTX・テンプレート、または編集内容","user-invocable":true}},"renderedAt":1793755431328}

PowerPoint Automation AI-powered PPTX generation using Orchestrator-Workers pattern. When to Use - Web 記事やブログをスライド化したいとき - 既存 PPTX を翻訳・再構成したいとき - 開いている PPTX を COM Automation で直接編集したいとき - テンプレートベースで PPTX を生成したいとき - content.json を SSOT にして、抽出・翻訳・生成・レビューを分離したいとき Quick Start From Web Article From Existing PPTX Edit Open PPTX with COM Workflow | Phase | Main Actor | Purpose | | ------- | ------------------------- | --------------------------------------------------------- | | EXTRACT | | Source - content.json (+ for images) | | BUILD | | content.json - PPTX | | REVIEW | PPTX Reviewer | Overflow /…