diff --git a/agent/prompts/role/spec_agent.md b/agent/prompts/role/spec_agent.md
index cef68715..5ceb3d7b 100644
--- a/agent/prompts/role/spec_agent.md
+++ b/agent/prompts/role/spec_agent.md
@@ -2,6 +2,31 @@ You are the SPEC agent for spec-driven-presentation-maker.
You handle Phase 1 through user dialogue.
Write all spec files in the user's language.
+## Flow selection (evaluate this FIRST on every turn)
+
+Before applying any other instruction in this prompt, decide which of the
+two flows you are in:
+
+1. **Guide flow (edit branch)** — triggered when any tool response in the
+ conversation contains a `guideInstruction` field. The instruction asks
+ you to classify the user's intent and may direct you to a specific
+ guide (e.g. `import-pptx` for PPTX edits). Once a guide is active,
+ follow the guide's Steps 1 → 5 in order. Do NOT read any
+ `create-new-*` workflow, do NOT start a Phase 1 Briefing, and do NOT
+ ask the user questions about audience / tone / time budget / etc.
+ Those belong to the new-deck flow below. The guide auto-generates the
+ briefing / outline / art-direction from the source material in its
+ own Step 3, and the deck builds against a PPTX-derived placeholder
+ template (no template selection hearing).
+
+2. **New-deck flow** — triggered when no `guideInstruction` is pending and
+ the user wants to build a presentation from scratch. Run the Phase 1
+ Flow described later in this prompt.
+
+If you are in a guide flow, all subsequent sections of this prompt titled
+`## Phase 1 Flow` and `## Delegation to Composer` (new-deck specifics) do
+NOT apply until the guide completes.
+
## Hearing
Your primary job is user hearing. Do not rush to produce output.
@@ -14,6 +39,10 @@ and the better the composer's output.
## Phase 1 Flow
+**This section applies to the new-deck flow only** (see "Flow selection"
+above). When a guide is active, skip this section entirely and follow
+the guide instead.
+
Phase 1 produces 3 spec files through sequential sub-phases.
Each sub-phase has a workflow file that defines the deliverable format and procedure.
You MUST read the workflow before starting that sub-phase — the deliverables have strict formats
@@ -54,8 +83,45 @@ If it is not in the brief, it does not exist for the composer.
## Delegation to Composer
-When all 3 spec files are approved:
+When all 3 spec files are approved (new-deck flow) OR when the guide's
+Step 5 completes (edit branch):
- Call `compose_slides(deck_id=..., slide_groups=[...])` to delegate slide generation
- You do NOT write slide JSON yourself. You do NOT call build/measure/preview tools directly
- After compose_slides returns, follow the Post-Compose Workflow
- For user modification requests, translate them into instructions and call compose_slides again
+
+## Guide-driven flows
+
+If a tool response (e.g. `upload_file`) contains a `guideInstruction`
+field, you MUST evaluate that instruction before any other action. The
+instruction may ask you to determine user intent and branch accordingly.
+
+**While a guide is active (edit branch), the guide's steps are the only
+workflow you follow.** Do NOT read `create-new-*` workflows, do NOT ask
+Phase 1 briefing/outline/art-direction questions, and do NOT invoke
+Phase 1 Flow instructions. Complete every Step in the guide (Step 1
+through Step 5) before returning to the normal edit loop.
+
+For uploaded PPTX files specifically:
+
+1. `guideInstruction` tells you to determine whether the user wants to
+ edit the PPTX or use it as reference material.
+2. If intent is clear → follow the branch directly.
+3. If intent is ambiguous → use `hearing` once to ask, then branch.
+4. **Edit branch**: call `read_guides(["import-pptx"])` and follow it
+ exactly from Step 1 through Step 5. After each hearing response,
+ immediately continue to the next Step in the guide — do NOT re-enter
+ Phase 1 Flow. The specs (brief / outline / art-direction) are
+ auto-generated from the PPTX content inside the guide, so you do NOT
+ need to run a briefing hearing. The deck-local placeholder template
+ (`template.pptx`) is copied automatically by `import_attachment`, so
+ no template selection hearing is needed either. **Remember the
+ `uploadId`, `suggestedName`, `slideCount`, and `themeHints` from the
+ initial `upload_file` response — you need them in Steps 1, 2, and 4.
+ Never ask the user to re-upload the file; those values remain in the
+ conversation context from the original attachment.** After Step 5
+ completes, return to the normal edit loop (user requests →
+ `compose_slides`).
+5. **Reference branch**: proceed with the normal briefing flow. Use
+ `read_uploaded_file(upload_id)` when you need content, and cite line
+ numbers in `specs/brief.md` Source Material.
diff --git a/api/index.py b/api/index.py
index 24c4bcfd..f9aa108b 100644
--- a/api/index.py
+++ b/api/index.py
@@ -1396,23 +1396,51 @@ def presign_upload() -> Dict[str, Any]:
_TEXT_EXTRACTABLE = {"text/plain", "text/markdown", "application/json"}
-def _extract_pptx_text(s3_key: str) -> str:
- """Extract slide text from PPTX using zipfile + XML (no python-pptx needed)."""
- import io
- import re
- import zipfile
- obj = s3_client.get_object(Bucket=BUCKET_NAME, Key=s3_key)
- data = obj["Body"].read()
- slides_text = []
- with zipfile.ZipFile(io.BytesIO(data)) as zf:
- slide_names = sorted(n for n in zf.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", n))
- for name in slide_names:
- xml = zf.read(name).decode("utf-8")
- texts = re.findall(r"([^<]+)", xml)
- if texts:
- slide_num = re.search(r"slide(\d+)", name).group(1)
- slides_text.append(f"--- Slide {slide_num} ---\n" + "\n".join(texts))
- return "\n\n".join(slides_text)
+# Shared guide instruction for PPTX uploads — same text as Local mode.
+_PPTX_GUIDE_INSTRUCTION = (
+ "This PPTX can either be converted into an editable deck, or used as "
+ "reference material for a new deck. "
+ "If the user's intent is to edit this PPTX, call read_guides(['import-pptx']) "
+ "and follow it exactly. "
+ "If the intent is to use as reference, proceed with the normal briefing flow "
+ "and call read_uploaded_file to access content. "
+ "If the user's intent is ambiguous, use the `hearing` tool once to clarify "
+ "before choosing."
+)
+
+
+def _pptx_guide_fields(item_or_values: Dict[str, Any]) -> Dict[str, Any]:
+ """Build the guide/guideInstruction/suggestedName/slideCount/themeHints fields
+ for a PPTX upload. Accepts either a fresh expr_values dict (from process_upload)
+ or a DDB item (from get_upload_status). Returns {} when the record has no
+ deck-structure metadata (non-PPTX uploads).
+ """
+ theme = item_or_values.get(":th") if ":th" in item_or_values else item_or_values.get("themeHints")
+ slide_count = (
+ item_or_values.get(":sc") if ":sc" in item_or_values else item_or_values.get("slideCount")
+ )
+ suggested = (
+ item_or_values.get(":sn")
+ if ":sn" in item_or_values
+ else item_or_values.get("suggestedName")
+ )
+ if theme is None and slide_count is None and suggested is None:
+ return {}
+ if theme is not None:
+ # DDB returns numeric values as Decimal; JSON serializer on the
+ # response can't handle Decimal, so coerce backgroundLuminance back
+ # to a float.
+ from decimal import Decimal
+ bg_lum = theme.get("backgroundLuminance") if isinstance(theme, dict) else None
+ if isinstance(bg_lum, Decimal):
+ theme = {**theme, "backgroundLuminance": float(bg_lum)}
+ return {
+ "guide": "import-pptx",
+ "guideInstruction": _PPTX_GUIDE_INSTRUCTION,
+ "suggestedName": suggested,
+ "slideCount": int(slide_count) if slide_count is not None else None,
+ "themeHints": theme,
+ }
@app.post("/uploads//process")
@@ -1457,7 +1485,7 @@ def process_upload(upload_id: str) -> Dict[str, Any]:
expr_values[":st"] = "completed"
# --- Binary files (PDF/DOCX/XLSX/PPTX): download → convert → upload ---
- elif ext in (".pdf", ".docx", ".xlsx") and s3_key:
+ elif ext in (".pdf", ".docx", ".xlsx", ".pptx") and s3_key:
converted_prefix = f"uploads/{user_id}/{upload_id}/converted"
try:
with tempfile.TemporaryDirectory() as tmp:
@@ -1496,6 +1524,22 @@ def process_upload(upload_id: str) -> Dict[str, Any]:
if result.warnings:
update_expr_parts.append("conversionWarnings = :cw")
expr_values[":cw"] = result.warnings
+ # PPTX deck-structure metadata — stored for the edit guide
+ # and returned by process_upload / get_upload_status.
+ if result.deck_structure:
+ from decimal import Decimal
+ bg_lum = result.theme_hints.get("backgroundLuminance")
+ update_expr_parts.append("themeHints = :th")
+ expr_values[":th"] = {
+ # DynamoDB rejects native floats; round-trip via str(Decimal).
+ "backgroundLuminance": Decimal(str(bg_lum)) if bg_lum is not None else None,
+ "accentColors": result.theme_hints.get("accentColors", []),
+ "fonts": result.theme_hints.get("fonts", {}),
+ }
+ update_expr_parts.append("slideCount = :sc")
+ expr_values[":sc"] = result.slide_count
+ update_expr_parts.append("suggestedName = :sn")
+ expr_values[":sn"] = result.suggested_name
except Exception as e:
logger.exception("Conversion failed for %s", upload_id)
@@ -1517,12 +1561,14 @@ def process_upload(upload_id: str) -> Dict[str, Any]:
if file_type.startswith("image/") and s3_key:
image_url = presigned_url(s3_client, BUCKET_NAME, s3_key)
- return {
+ response: Dict[str, Any] = {
"uploadId": upload_id,
"status": expr_values[":st"],
"extractedText": extracted_text,
"imageUrl": image_url,
}
+ response.update(_pptx_guide_fields(expr_values))
+ return response
@app.get("/uploads//status")
@@ -1538,7 +1584,7 @@ def get_upload_status(upload_id: str) -> Dict[str, Any]:
if item.get("fileType", "").startswith("image/") and item.get("s3KeyRaw"):
image_url = presigned_url(s3_client, BUCKET_NAME, item["s3KeyRaw"])
- return {
+ response: Dict[str, Any] = {
"uploadId": upload_id,
"fileName": item.get("fileName", ""),
"fileType": item.get("fileType", ""),
@@ -1546,6 +1592,8 @@ def get_upload_status(upload_id: str) -> Dict[str, Any]:
"extractedText": item.get("extractedText"),
"imageUrl": image_url,
}
+ response.update(_pptx_guide_fields(item))
+ return response
# ---------------------------------------------------------------------------
diff --git a/api/requirements.txt b/api/requirements.txt
index 9aeca1ba..d6984593 100644
--- a/api/requirements.txt
+++ b/api/requirements.txt
@@ -4,5 +4,12 @@ pdfplumber>=0.11.0
pypdf>=4.0.0
python-docx>=1.0.0
openpyxl>=3.1.0
+# Required for PPTX upload-time conversion (shared/ingest._convert_pptx).
+# Mirrors skill/pyproject.toml because we copy skill/sdpm into the Lambda
+# bundle without pip-installing the package itself.
python-pptx>=1.0.0
lxml>=5.0.0
+Pillow>=10.0.0
+qrcode>=7.4.0
+pygments>=2.19.2
+defusedxml>=0.7.0
diff --git a/infra/lib/web-ui-stack.ts b/infra/lib/web-ui-stack.ts
index bad94b79..b0e96e91 100644
--- a/infra/lib/web-ui-stack.ts
+++ b/infra/lib/web-ui-stack.ts
@@ -231,9 +231,7 @@ function handler(event) {
"bash", "-c",
"pip install -r /asset-input/api/requirements.txt -t /asset-output/ && " +
"cp -r /asset-input/api/* /asset-input/shared /asset-output/ && " +
- "mkdir -p /asset-output/sdpm && cp -r /asset-input/skill/sdpm/analyzer /asset-output/sdpm/ && " +
- "cp /asset-input/skill/sdpm/__init__.py /asset-output/sdpm/ && " +
- "mkdir -p /asset-output/sdpm/utils && cp /asset-input/skill/sdpm/utils/__init__.py /asset-input/skill/sdpm/utils/io.py /asset-output/sdpm/utils/",
+ "cp -r /asset-input/skill/sdpm /asset-output/sdpm",
],
local: {
tryBundle(outputDir: string): boolean {
@@ -250,10 +248,9 @@ function handler(event) {
}
execSync(`cp -r ${root}/api/* ${outputDir}/`, { stdio: "inherit" });
execSync(`cp -r ${root}/shared ${outputDir}/shared`, { stdio: "inherit" });
- execSync(`mkdir -p ${outputDir}/sdpm/utils`, { stdio: "inherit" });
- execSync(`cp -r ${root}/skill/sdpm/analyzer ${outputDir}/sdpm/`, { stdio: "inherit" });
- execSync(`cp ${root}/skill/sdpm/__init__.py ${outputDir}/sdpm/`, { stdio: "inherit" });
- execSync(`cp ${root}/skill/sdpm/utils/__init__.py ${root}/skill/sdpm/utils/io.py ${outputDir}/sdpm/utils/`, { stdio: "inherit" });
+ // sdpm Engine — required by shared.ingest._convert_pptx for upload-time
+ // PPTX → deck conversion (deck.json + slides/*.json + template.pptx).
+ execSync(`cp -r ${root}/skill/sdpm ${outputDir}/sdpm`, { stdio: "inherit" });
return true;
},
},
diff --git a/mcp-local/acp-agent-prompts/spec-agent.md b/mcp-local/acp-agent-prompts/spec-agent.md
index fe962790..16c2a791 100644
--- a/mcp-local/acp-agent-prompts/spec-agent.md
+++ b/mcp-local/acp-agent-prompts/spec-agent.md
@@ -3,6 +3,31 @@ You handle Phase 1 through user dialogue.
Respond in the same language as the user.
Write all spec files in the user's language.
+## Flow selection (evaluate this FIRST on every turn)
+
+Before applying any other instruction in this prompt, decide which of the
+two flows you are in:
+
+1. **Guide flow (edit branch)** — triggered when any tool response in the
+ conversation contains a `guideInstruction` field. The instruction asks
+ you to classify the user's intent and may direct you to a specific
+ guide (e.g. `import-pptx` for PPTX edits). Once a guide is active,
+ follow the guide's Steps 1 → 5 in order. Do NOT read any
+ `create-new-*` workflow, do NOT start a Phase 1 Briefing, and do NOT
+ ask the user questions about audience / tone / time budget / etc.
+ Those belong to the new-deck flow below. The guide auto-generates the
+ briefing / outline / art-direction from the source material in its
+ own Step 3, and the deck builds against a PPTX-derived placeholder
+ template (no template selection hearing).
+
+2. **New-deck flow** — triggered when no `guideInstruction` is pending and
+ the user wants to build a presentation from scratch. Run the Phase 1
+ Flow described later in this prompt.
+
+If you are in a guide flow, all subsequent sections of this prompt titled
+`## Phase 1 Flow`, `## Workflow: New Presentation`, and `## Slide Group
+Assignment` do NOT apply until the guide completes.
+
## Hearing
Your primary job is user hearing. Do not rush to produce output.
@@ -20,6 +45,10 @@ Limit to 5 questions per call. If you need more, call again after the user respo
## Phase 1 Flow
+**This section applies to the new-deck flow only** (see "Flow selection"
+above). When a guide is active, skip this section entirely and follow
+the guide instead.
+
Phase 1 produces 3 spec files through sequential sub-phases.
Each sub-phase has a workflow file that defines the deliverable format and procedure.
You MUST read the workflow before starting that sub-phase — the deliverables have strict formats
@@ -66,7 +95,8 @@ When user provides a file path or URL:
## Delegation to Composer
-When all 3 spec files are approved:
+When all 3 spec files are approved (new-deck flow) OR when the guide's
+Step 5 completes (edit branch):
- Split slides into groups and invoke sdpm-composer subagents in parallel (max 4)
- Use `use_subagent` with `subagents: [{"query": "deck_id=... slides: slug1, slug2", "agent_name": "sdpm-composer"}, ...]` (max 4 parallel). ASCII-only queries.
- You do NOT write slide JSON yourself. You do NOT call build/measure/preview tools directly
@@ -76,6 +106,42 @@ When all 3 spec files are approved:
Each subagent query MUST include: deck_id (path), assigned slide slugs, and a pointer to specs/.
+## Guide-driven flows
+
+If a tool response (e.g. `upload_file`) contains a `guideInstruction`
+field, you MUST evaluate that instruction before any other action. The
+instruction may ask you to determine user intent and branch accordingly.
+
+**While a guide is active (edit branch), the guide's steps are the only
+workflow you follow.** Do NOT read `create-new-*` workflows, do NOT ask
+Phase 1 briefing/outline/art-direction questions, and do NOT invoke
+Phase 1 Flow instructions. Complete every Step in the guide (Step 1
+through Step 5) before returning to the normal edit loop.
+
+For uploaded PPTX files specifically:
+
+1. `guideInstruction` tells you to determine whether the user wants to
+ edit the PPTX or use it as reference material.
+2. If intent is clear → follow the branch directly.
+3. If intent is ambiguous → use `hearing` once to ask, then branch.
+4. **Edit branch**: call `read_guides(["import-pptx"])` and follow it
+ exactly from Step 1 through Step 5. After each hearing response,
+ immediately continue to the next Step in the guide — do NOT re-enter
+ Phase 1 Flow. The specs (brief / outline / art-direction) are
+ auto-generated from the PPTX content inside the guide, so you do NOT
+ need to run a briefing hearing. The deck-local placeholder template
+ (`template.pptx`) is copied automatically by `import_attachment`, so
+ no template selection hearing is needed either. **Remember the
+ `uploadId`, `suggestedName`, `slideCount`, and `themeHints` from the
+ initial `upload_file` response — you need them in Steps 1, 2, and 4.
+ Never ask the user to re-upload the file; those values remain in the
+ conversation context from the original attachment.** After Step 5
+ completes, return to the normal edit loop (user requests → invoke
+ sdpm-composer subagents via `use_subagent`).
+5. **Reference branch**: proceed with the normal briefing flow. Use
+ `read_uploaded_file(upload_id)` when you need content, and cite line
+ numbers in `specs/brief.md` Source Material.
+
## Workflow: New Presentation
→ Read `read_workflows(["create-new-1-briefing"])` to start. Follow each file's Next Step from there.
diff --git a/mcp-local/server.py b/mcp-local/server.py
index 5196ed51..41339f3c 100644
--- a/mcp-local/server.py
+++ b/mcp-local/server.py
@@ -65,8 +65,11 @@
## Workflow B: Edit Existing PPTX
-When an existing PPTX is provided.
-→ Read `read_workflows(["edit-existing"])` to start.
+When an existing PPTX is provided. The Web UI / API path is the
+preferred entrypoint: `upload_file` returns
+`guideInstruction: "read_guides([\"import-pptx\"])"` automatically,
+so just follow that instruction. For CLI-only flows, call
+`read_guides(["import-pptx"])` directly.
## Workflow C: Hand-Edit Sync
diff --git a/mcp-local/server_acp.py b/mcp-local/server_acp.py
index b8b34305..70881d85 100644
--- a/mcp-local/server_acp.py
+++ b/mcp-local/server_acp.py
@@ -234,8 +234,8 @@ def init_presentation(name: str, template: str = "") -> str:
from datetime import datetime
from sdpm.analyzer import extract_fonts
- root = os.environ.get("SDPM_DECK_ROOT", "")
- base_dir = Path(root) if root else Path.home() / "Documents" / "SDPM-Presentations"
+ base_dir_env = os.environ.get("SDPM_DECK_ROOT", "").strip()
+ base_dir = Path(base_dir_env) if base_dir_env else Path.home() / "Documents" / "SDPM-Presentations"
ts = datetime.now().strftime("%Y%m%d-%H%M")
dir_name = f"{ts}-{name}" if name else ts
out_dir = base_dir / dir_name
diff --git a/mcp-local/tools.py b/mcp-local/tools.py
index 98821a33..e8041ab2 100644
--- a/mcp-local/tools.py
+++ b/mcp-local/tools.py
@@ -157,9 +157,28 @@ def code_block(
def pptx_to_json(pptx_path: str) -> dict[str, Any]:
- """Convert PPTX to JSON representation."""
+ """Convert PPTX to JSON representation.
+
+ Writes a deck-structure directory (``deck.json`` + ``slides/slide-NN.json``
+ + ``images/``) alongside the input PPTX (``{stem}/`` sibling directory).
+ The returned dict still contains ``{slides, fonts, defaultTextColor}`` for
+ in-memory use.
+
+ Note: the on-disk output is deck-structure (since pptx-import-edit);
+ no single ``slides.json`` file is produced. Callers that need an
+ editable deck should prefer the upload → import_attachment flow.
+
+ Returns:
+ Dict with slides list plus deck metadata (fonts, defaultTextColor),
+ plus ``deck_dir`` (string path to the written directory) for
+ downstream tooling.
+ """
from sdpm.converter import pptx_to_json as _convert
path = Path(pptx_path)
if not path.exists():
raise FileNotFoundError(f"PPTX not found: {pptx_path}")
- return _convert(str(path))
+ result = _convert(path)
+ deck_dir = path.with_suffix("")
+ if isinstance(result, dict):
+ result = {**result, "deck_dir": str(deck_dir)}
+ return result
diff --git a/mcp-local/upload_tools.py b/mcp-local/upload_tools.py
index 2d9202a3..a38d93c4 100644
--- a/mcp-local/upload_tools.py
+++ b/mcp-local/upload_tools.py
@@ -49,8 +49,10 @@ def _convert_webp_to_png(data: bytes) -> bytes:
def _deck_root() -> Path:
- root = os.environ.get("SDPM_DECK_ROOT", "")
- return Path(root) if root else Path.home() / "Documents" / "SDPM-Presentations"
+ env_value = os.environ.get("SDPM_DECK_ROOT", "").strip()
+ if env_value:
+ return Path(env_value)
+ return Path.home() / "Documents" / "SDPM-Presentations"
def _session_dir(session_id: str) -> Path:
@@ -131,6 +133,18 @@ def _analyze_colors(file_path: Path) -> dict | None:
return None
+_GUIDE_INSTRUCTION = (
+ "This PPTX can either be converted into an editable deck, or used as "
+ "reference material for a new deck. "
+ "If the user's intent is to edit this PPTX, call read_guides(['import-pptx']) "
+ "and follow it exactly. "
+ "If the intent is to use as reference, proceed with the normal briefing flow "
+ "and call read_uploaded_file to access content. "
+ "If the user's intent is ambiguous, use the `hearing` tool once to clarify "
+ "before choosing."
+)
+
+
def upload_file(session_id: str, file_path: str, filename: str = "") -> str:
"""Convert and store a file in session storage.
@@ -140,7 +154,15 @@ def upload_file(session_id: str, file_path: str, filename: str = "") -> str:
filename: Original filename (defaults to basename of file_path).
Returns:
- JSON with {uploadId, fileName, fileType, status, filePath?, colorAnalysis?}.
+ JSON with {uploadId, fileName, fileType, status, warnings?, filePath?, colorAnalysis?}.
+
+ For PPTX uploads that successfully converted to deck structure, the
+ response additionally contains:
+ guide: "import-pptx"
+ guideInstruction:
+ suggestedName: file stem
+ slideCount: number of slides
+ themeHints: {backgroundLuminance, accentColors, fonts}
"""
src = Path(file_path)
if not src.exists():
@@ -161,6 +183,7 @@ def upload_file(session_id: str, file_path: str, filename: str = "") -> str:
"warnings": [],
}
+ conversion_result = None # shared.ingest.ConversionResult or None
try:
# Passthrough: images/text — just copy the raw file
if ext in IMAGE_EXTS or ext in TEXT_EXTS:
@@ -169,13 +192,18 @@ def upload_file(session_id: str, file_path: str, filename: str = "") -> str:
meta["rawFile"] = original_name
else:
# Convert via shared pipeline
- result = convert_file(src, upload_dir)
- if result.status == "error":
+ conversion_result = convert_file(src, upload_dir)
+ if conversion_result.status == "error":
meta["status"] = "error"
- meta["error"] = result.error or "Conversion failed"
+ meta["error"] = conversion_result.error or "Conversion failed"
else:
meta["status"] = "converted"
- meta["warnings"] = result.warnings
+ meta["warnings"] = conversion_result.warnings
+ if conversion_result.deck_structure:
+ meta["deckStructure"] = True
+ meta["slideCount"] = conversion_result.slide_count
+ meta["themeHints"] = conversion_result.theme_hints
+ meta["suggestedName"] = conversion_result.suggested_name
except Exception as e:
meta["status"] = "error"
meta["error"] = str(e)
@@ -198,16 +226,19 @@ def upload_file(session_id: str, file_path: str, filename: str = "") -> str:
if meta["status"] == "completed":
response["filePath"] = str((upload_dir / original_name).resolve())
elif meta["status"] == "converted":
- stem = original_name.rsplit(".", 1)[0] if "." in original_name else original_name
- md_path = upload_dir / f"{stem}.md"
- json_path = upload_dir / "slides.json"
- if md_path.exists():
- response["filePath"] = str(md_path.resolve())
- elif json_path.exists():
- response["filePath"] = str(json_path.resolve())
- images_dir = upload_dir / "images"
- if images_dir.exists():
- response["imagesDir"] = str(images_dir.resolve())
+ # Deck-structure PPTX: filePath would only point at deck.json and miss
+ # slides/, so the guide/themeHints fields below replace it.
+ if conversion_result is None or not conversion_result.deck_structure:
+ stem = original_name.rsplit(".", 1)[0] if "." in original_name else original_name
+ md_path = upload_dir / f"{stem}.md"
+ json_path = upload_dir / "slides.json"
+ if md_path.exists():
+ response["filePath"] = str(md_path.resolve())
+ elif json_path.exists():
+ response["filePath"] = str(json_path.resolve())
+ images_dir = upload_dir / "images"
+ if images_dir.exists():
+ response["imagesDir"] = str(images_dir.resolve())
# Color analysis for images
if file_type.startswith("image/") and meta["status"] == "completed":
@@ -215,6 +246,13 @@ def upload_file(session_id: str, file_path: str, filename: str = "") -> str:
if colors:
response["colorAnalysis"] = colors
+ # PPTX deck-structure: include guide hints for the agent
+ if conversion_result is not None and conversion_result.deck_structure:
+ response["guide"] = "import-pptx"
+ response["guideInstruction"] = _GUIDE_INSTRUCTION
+ response["suggestedName"] = conversion_result.suggested_name
+ response["slideCount"] = conversion_result.slide_count
+ response["themeHints"] = conversion_result.theme_hints
return json.dumps(response, ensure_ascii=False)
@@ -245,6 +283,82 @@ def _format_cat_n(text: str, file_name: str, offset: int, limit: int) -> str:
return f"{header}\n\n{body}{footer}"
+def _format_deck_text_summary(upload_dir: Path, file_name: str, offset: int, limit: int) -> str:
+ """Format a deck-structure upload (deck.json + slides/) as a markdown-style text summary.
+
+ Output shape::
+
+ --- Slide 1: ---
+
+
+ --- Slide 2: ---
+ ...
+
+ The title is taken from slide["title"] (string or dict-with-text).
+ Body text concatenates element ``text``, ``paragraphs[].text``, ``items``,
+ table ``headers`` / ``rows``, and recursive group elements.
+ """
+ slides_dir = upload_dir / "slides"
+ if not slides_dir.is_dir():
+ return f"No slides/ directory found in {file_name}."
+
+ def _extract_title(data: dict) -> str:
+ t = data.get("title")
+ if isinstance(t, str):
+ return t
+ if isinstance(t, dict):
+ return t.get("text", "") or ""
+ return ""
+
+ def _collect_text(node, out: list[str]) -> None:
+ if isinstance(node, dict):
+ for key in ("text", "subtitle", "label", "date", "notes"):
+ v = node.get(key)
+ if isinstance(v, str) and v.strip():
+ out.append(v)
+ for p in node.get("paragraphs", []) or []:
+ if isinstance(p, dict):
+ t = p.get("text")
+ if isinstance(t, str) and t.strip():
+ out.append(t)
+ for item in node.get("items", []) or []:
+ if isinstance(item, str) and item.strip():
+ out.append(item)
+ headers = node.get("headers")
+ if isinstance(headers, list):
+ out.extend(str(c) for c in headers if c)
+ rows = node.get("rows")
+ if isinstance(rows, list):
+ for row in rows:
+ if isinstance(row, list):
+ out.extend(str(c) for c in row if c)
+ for child in node.get("elements", []) or []:
+ _collect_text(child, out)
+
+ sections: list[str] = []
+ for i, slide_file in enumerate(sorted(slides_dir.glob("slide-*.json")), start=1):
+ try:
+ data = json.loads(slide_file.read_text(encoding="utf-8"))
+ except Exception:
+ continue
+ title = _extract_title(data)
+ header = f"--- Slide {i}: {title} ---" if title else f"--- Slide {i} ---"
+ body_parts: list[str] = []
+ for el in data.get("elements", []) or []:
+ _collect_text(el, body_parts)
+ # Drop duplicates while keeping order
+ seen: set[str] = set()
+ deduped: list[str] = []
+ for p in body_parts:
+ if p not in seen:
+ seen.add(p)
+ deduped.append(p)
+ body = "\n".join(deduped)
+ sections.append(f"{header}\n{body}".rstrip())
+
+ return _format_cat_n("\n\n".join(sections), file_name, offset, limit)
+
+
def read_uploaded_file(upload_id: str, offset: int = 0, limit: int = 2000) -> list:
"""Read an uploaded file's pre-converted content (cat -n format)."""
try:
@@ -275,12 +389,34 @@ def read_uploaded_file(upload_id: str, offset: int = 0, limit: int = 2000) -> li
# Converted (PDF/DOCX/XLSX/PPTX)
if status == "converted":
+ # PPTX: deck structure — full text summary across slides
+ is_deck = (upload_dir / "deck.json").exists() and (upload_dir / "slides").is_dir()
+ if is_deck:
+ result.append(_format_deck_text_summary(upload_dir, file_name, offset, limit))
+ # Image previews (converter extracted slide images)
+ img_dir = upload_dir / "images"
+ if img_dir.exists():
+ imgs = sorted(img_dir.iterdir())
+ for i, img_file in enumerate(imgs):
+ if i >= _MAX_IMAGE_PREVIEWS:
+ result.append(f"({len(imgs) - i} more images not previewed)")
+ break
+ try:
+ jpeg = _to_jpeg(img_file.read_bytes())
+ result.append(f"Extracted image: {img_file.name}")
+ result.append(Image(data=jpeg, format="jpeg"))
+ except Exception:
+ continue
+ if warning_text:
+ result.append(warning_text)
+ return result
# Try Markdown
stem = file_name.rsplit(".", 1)[0] if "." in file_name else file_name
md_path = upload_dir / f"{stem}.md"
if md_path.exists():
result.append(_format_cat_n(md_path.read_text(encoding="utf-8"), file_name, offset, limit))
- # Try PPTX JSON
+ # Try PPTX JSON (legacy slides.json — kept for backward compat; deck structure is
+ # handled by the is_deck branch above).
json_path = upload_dir / "slides.json"
if not result and json_path.exists():
pretty = json.dumps(json.loads(json_path.read_text(encoding="utf-8")), ensure_ascii=False, indent=2)
@@ -349,7 +485,7 @@ def _import_from_upload(upload_id: str, deck_id: str, filename: str) -> str:
file_type = meta.get("fileType", "")
short_id = uuid.uuid4().hex[:8]
- result: dict = {"source": upload_id, "files": [], "image_mapping": {}}
+ result: dict = {"source": upload_id, "files": [], "image_mapping": {}, "shortId": short_id}
images_dir = deck_dir / "images"
attachments_dir = deck_dir / "attachments"
@@ -366,19 +502,33 @@ def _import_from_upload(upload_id: str, deck_id: str, filename: str) -> str:
shutil.copy2(img, images_dir / dest_name)
result["files"].append(f"images/{dest_name}")
result["image_mapping"][img.name] = f"images/{dest_name}"
+ elif src_file.is_dir():
+ # Preserve directory structure (e.g. slides/) under
+ # attachments/{shortId}/{name}/ so agents can walk it.
+ dest_subdir = attachments_dir / short_id / src_file.name
+ dest_subdir.mkdir(parents=True, exist_ok=True)
+ for child in src_file.iterdir():
+ if child.is_file():
+ shutil.copy2(child, dest_subdir / child.name)
+ result["files"].append(
+ f"attachments/{short_id}/{src_file.name}/{child.name}"
+ )
elif src_file.is_file():
+ # PPTX-derived placeholder template lives at deck/template.pptx
+ # so pptx_builder can resolve it via the deck-local relative path.
+ if src_file.name == "template.pptx":
+ shutil.copy2(src_file, deck_dir / "template.pptx")
+ result["files"].append("template.pptx")
+ result["templatePath"] = "template.pptx"
+ continue
attachments_dir.mkdir(exist_ok=True)
- if src_file.name == "slides.json":
- dest_name = f"{short_id}_{file_name.rsplit('.', 1)[0]}.json"
- shutil.copy2(src_file, attachments_dir / dest_name)
- result["files"].append(f"attachments/{dest_name}")
- result["json"] = f"attachments/{dest_name}"
- else:
- dest_name = f"{short_id}_{src_file.name}"
- shutil.copy2(src_file, attachments_dir / dest_name)
- result["files"].append(f"attachments/{dest_name}")
- if src_file.suffix == ".md":
- result["markdown"] = f"attachments/{dest_name}"
+ dest_name = f"{short_id}_{src_file.name}"
+ shutil.copy2(src_file, attachments_dir / dest_name)
+ result["files"].append(f"attachments/{dest_name}")
+ if src_file.suffix == ".md":
+ result["markdown"] = f"attachments/{dest_name}"
+ if src_file.name == "deck.json":
+ result["deckJson"] = f"attachments/{dest_name}"
return json.dumps(result, ensure_ascii=False)
if status == "completed":
diff --git a/mcp-server/server.py b/mcp-server/server.py
index ec2f88b5..7707e95a 100644
--- a/mcp-server/server.py
+++ b/mcp-server/server.py
@@ -54,11 +54,11 @@
→ Read `read_workflows(["create-new-1-briefing"])` to start. Follow each file's Next Step from there.
"""
-# TODO: Add these workflows when web UI supports them
-# ## Workflow B: Edit Existing PPTX
-# When an existing PPTX is provided.
-# → Read `read_workflows(["edit-existing"])` to start.
+# Edit-existing-PPTX flow on Cloud is driven by upload_file returning
+# guideInstruction: "read_guides([\"import-pptx\"])" — the spec agent
+# follows that pointer instead of a hard-coded workflow name here.
#
+# TODO: Add these workflows when web UI supports them
# ## Workflow C: Hand-Edit Sync
# When the user hand-edits the generated PPTX in PowerPoint and then asks for further changes.
# → Read `read_workflows(["create-new-4-hand-edit-sync"])` to start.
@@ -207,18 +207,45 @@ def init_presentation(name: str) -> str:
@mcp.tool()
-def analyze_template(template: str) -> str:
+def analyze_template(template: str, deck_id: str = "") -> str:
"""Get pre-analyzed template information — layouts, theme colors, fonts.
Call this to understand what layouts are available before building slides.
Args:
- template: Template name from list_templates. Required.
+ template: Template name from list_templates, OR "template.pptx" to
+ analyze the deck-local placeholder template (set by import_attachment
+ during the import-pptx flow). When passing "template.pptx", deck_id
+ must also be supplied.
+ deck_id: Required only when template == "template.pptx" — identifies
+ the deck whose deck-local template.pptx should be analyzed.
Returns:
JSON with layouts, theme colors, and font information.
"""
if not template or not template.strip():
return json.dumps({"error": "template is required"})
+
+ # Deck-local placeholder template (PPTX-derived; copied by import_attachment
+ # during the import-pptx guide). Download from the deck workspace bucket
+ # and analyze on the fly so import-pptx Step 5 can extract theme colors
+ # and layouts without first registering the source PPTX as a sdpm template.
+ if template == "template.pptx":
+ if not deck_id:
+ return json.dumps({"error": "deck_id is required when template == 'template.pptx'"})
+ try:
+ import tempfile
+ from pathlib import Path
+ from sdpm.analyzer import analyze_template as _analyze
+ data = _storage.download_file_from_pptx_bucket(f"decks/{deck_id}/template.pptx")
+ tmp = Path(tempfile.mkdtemp())
+ tpl_path = tmp / "template.pptx"
+ tpl_path.write_bytes(data)
+ analysis = _analyze(tpl_path)
+ analysis["templateName"] = "template.pptx"
+ return json.dumps(analysis, ensure_ascii=False)
+ except Exception as e:
+ return json.dumps({"error": f"Failed to analyze deck-local template: {e}"})
+
return json.dumps(
template_mod.analyze_template(template_name=template, storage=_storage, user_id=_get_user_id()),
ensure_ascii=False,
@@ -613,8 +640,8 @@ def run_python(purpose: str, code: str, deck_id: str | None = None, save: bool =
If deck_id is provided, the entire deck workspace is loaded as files:
deck.json — deck metadata (template, fonts, defaultTextColor)
- slides/{slug}.json — per-slide data (read/write via json.load/json.dump)
- specs/brief.md — briefing document
+ slides/{slug}.json — per-slide data
+ specs/brief.md — briefing document
specs/art-direction.html — design direction (HTML)
specs/outline.md — slide outline (1 line = 1 slide = 1 message)
includes/ — code block JSON files (created by code_to_slide)
@@ -622,7 +649,22 @@ def run_python(purpose: str, code: str, deck_id: str | None = None, save: bool =
Legacy decks with presentation.json are also supported (read-only compat).
- All files are accessible via normal file I/O (open, read, write).
+ ## Sandbox helpers (preferred — identical API on Local and Cloud)
+
+ read_json(path) → dict/list Read a JSON file
+ write_json(path, data) → None Write data as JSON
+ read_text(path) → str Read a text file
+ write_text(path, text) → None Write a text file
+ list_files(subdir=".") → list[str] List filenames in a subdirectory
+
+ All paths are relative to the deck root. The helpers are injected
+ automatically — do NOT write `from _sdpm_helpers import ...` yourself;
+ the import is prepended by the sandbox. Using the helpers keeps the
+ same code portable between Local (AST-restricted) and Cloud.
+
+ Raw `open()` / `json.load` still work on Cloud for backward compat,
+ but new code should prefer the helpers.
+
If save=True, all modified/new workspace files are written back to S3.
**Always specify measure_slides when editing slides.** Runs validation after
@@ -637,12 +679,25 @@ def run_python(purpose: str, code: str, deck_id: str | None = None, save: bool =
Example: files=["uploads/tmp/user/abc/data.csv"] → accessible as "data.csv" in code.
Examples:
- Edit slides: run_python(code="...", deck_id="abc", save=True, measure_slides=["title", "feature-a"])
- Edit specs: run_python(code="open('specs/brief.md','w').write('...')", deck_id="abc", save=True)
- Measure only: run_python(code="print('ok')", deck_id="abc", measure_slides=["title"])
- Compute: run_python(code="print(2**100)")
- CSV: run_python(code="import pandas as pd; print(pd.read_csv('data.csv'))",
- files=["uploads/tmp/user/x/data.csv"])
+ Edit slide:
+ data = read_json("slides/title.json")
+ data["elements"][0]["text"] = "New Title"
+ write_json("slides/title.json", data)
+ # run_python(code=, deck_id="abc", save=True, measure_slides=["title"])
+
+ Edit spec:
+ write_text("specs/brief.md", "# Brief\\n\\nContents...")
+ # run_python(code=, deck_id="abc", save=True)
+
+ Read deck metadata:
+ deck = read_json("deck.json")
+ print(deck.get("template"))
+
+ List slide files:
+ print(list_files("slides"))
+
+ General computation (no deck_id):
+ print(2 ** 100)
Args:
code: Python code to execute.
diff --git a/mcp-server/tools/attachment.py b/mcp-server/tools/attachment.py
index 59170e9e..7fcfc759 100644
--- a/mcp-server/tools/attachment.py
+++ b/mcp-server/tools/attachment.py
@@ -61,7 +61,7 @@ def _import_from_upload(
file_type = item.get("fileType", "")
s3_key = item.get("s3KeyRaw", "")
- result = {"source": upload_id, "files": [], "image_mapping": {}}
+ result = {"source": upload_id, "files": [], "image_mapping": {}, "shortId": short_id}
# --- Converted files (PDF/DOCX/XLSX/PPTX) ---
if status == "converted":
@@ -181,6 +181,8 @@ def _import_converted(
) -> str:
"""Copy converted files from S3 upload prefix to deck workspace."""
keys = storage.list_files(converted_prefix, bucket=storage.pptx_bucket)
+ # Ensure shortId is surfaced even when the branch above did not set it.
+ result.setdefault("shortId", short_id)
for key in keys:
rel = key[len(converted_prefix) + 1:] # strip prefix + /
@@ -194,12 +196,33 @@ def _import_converted(
storage.upload_file(key=dest_key, data=src_data, content_type=ct)
result["files"].append(f"images/{dest_name}")
result["image_mapping"][img_name] = f"images/{dest_name}"
- elif rel == "slides.json":
- dest_name = f"{short_id}_{file_name.rsplit('.', 1)[0]}.json"
+ elif rel.startswith("slides/"):
+ # Preserve deck-structure slides under attachments/{shortId}/slides/
+ # so the agent can walk them during guide Step 5.
+ slide_name = rel.split("/", 1)[1]
+ dest_key = f"decks/{deck_id}/attachments/{short_id}/slides/{slide_name}"
+ storage.upload_file(
+ key=dest_key, data=src_data, content_type="application/json",
+ )
+ result["files"].append(f"attachments/{short_id}/slides/{slide_name}")
+ elif rel == "deck.json":
+ dest_name = f"{short_id}_deck.json"
dest_key = f"decks/{deck_id}/attachments/{dest_name}"
- storage.upload_file(key=dest_key, data=src_data, content_type="application/json")
+ storage.upload_file(
+ key=dest_key, data=src_data, content_type="application/json",
+ )
result["files"].append(f"attachments/{dest_name}")
- result["json"] = f"attachments/{dest_name}"
+ result["deckJson"] = f"attachments/{dest_name}"
+ elif rel == "template.pptx":
+ # PPTX-derived placeholder template lives at deck/template.pptx
+ # so pptx_builder can resolve it via the deck-local relative path.
+ dest_key = f"decks/{deck_id}/template.pptx"
+ storage.upload_file(
+ key=dest_key, data=src_data,
+ content_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ )
+ result["files"].append("template.pptx")
+ result["templatePath"] = "template.pptx"
else:
dest_name = f"{short_id}_{rel}"
dest_key = f"decks/{deck_id}/attachments/{dest_name}"
diff --git a/mcp-server/tools/generate.py b/mcp-server/tools/generate.py
index 3cd038ba..b50c1dd4 100644
--- a/mcp-server/tools/generate.py
+++ b/mcp-server/tools/generate.py
@@ -198,17 +198,30 @@ def _prepare_workspace(
# Resolve template
template_key = ""
+ template_path = tmpdir / "template.pptx"
tmpl_name = presentation.get("template", "")
+ # Deck-local placeholder template (PPTX-derived; copied by import_attachment).
+ # When deck.json["template"] == "template.pptx", read decks/{deck_id}/template.pptx
+ # from the deck workspace bucket instead of treating it as a sdpm template name.
+ if tmpl_name == "template.pptx":
+ deck_template_key = f"decks/{deck_id}/template.pptx"
+ try:
+ template_path.write_bytes(
+ storage.download_file_from_pptx_bucket(deck_template_key),
+ )
+ tmpl_name = "" # signal: resolved
+ except Exception:
+ tmpl_name = "" # fall through to sdpm-template lookup below
if tmpl_name:
normalized = tmpl_name.removesuffix(".pptx")
for t in storage.list_templates():
if t.get("name") == normalized:
template_key = t.get("s3Key", "")
break
- if not template_key:
- template_key = deck.get("templateS3Key", "templates/blank-dark.pptx")
- template_path = tmpdir / "template.pptx"
- template_path.write_bytes(storage.download_file(key=template_key))
+ if not template_path.exists():
+ if not template_key:
+ template_key = deck.get("templateS3Key", "templates/blank-dark.pptx")
+ template_path.write_bytes(storage.download_file(key=template_key))
# Fonts
fonts = presentation.get("fonts") or deck.get("fonts")
diff --git a/mcp-server/tools/sandbox.py b/mcp-server/tools/sandbox.py
index 4ae89ef6..d17e9a32 100644
--- a/mcp-server/tools/sandbox.py
+++ b/mcp-server/tools/sandbox.py
@@ -31,6 +31,45 @@
_WORKSPACE_PREFIXES = ("deck.json", "slides/", "specs/", "includes/", "attachments/")
+# Helpers injected into every sandbox session so agent code can be written once
+# and run unchanged on Local (AST-restricted subprocess) and Cloud (AgentCore
+# Code Interpreter). These mirror mcp-local/sandbox.py's _RUNNER_WITH_DECK.
+_HELPERS_PY = '''\
+import json as _json
+from pathlib import Path as _Path
+
+def read_json(path):
+ return _json.loads(_Path(path).read_text(encoding="utf-8"))
+
+def write_json(path, data):
+ p = _Path(path)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(_json.dumps(data, ensure_ascii=False, indent=2) + "\\n", encoding="utf-8")
+
+def read_text(path):
+ return _Path(path).read_text(encoding="utf-8")
+
+def write_text(path, text):
+ p = _Path(path)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(text, encoding="utf-8")
+
+def list_files(subdir="."):
+ d = _Path(subdir)
+ if not d.is_dir():
+ raise FileNotFoundError(f"Not a directory: {subdir}")
+ return sorted(f.name for f in d.iterdir() if f.is_file())
+'''
+
+# Prepended to every user code invocation so read_json / write_json / ...
+# are available without an explicit import. Agents must NOT write their own
+# 'from _sdpm_helpers import ...' line — the injection handles it so Local
+# and Cloud guide code can stay identical (Local's AST forbids import).
+_HELPERS_IMPORT = (
+ "from _sdpm_helpers import read_json, write_json, read_text, write_text, list_files\n"
+)
+
+
def execute_in_sandbox(
code: str,
storage: Storage,
@@ -86,6 +125,10 @@ def execute_in_sandbox(
if deck_id:
_upload_deck_workspace(client, session_id, storage, deck_id)
+ # Inject shared sandbox helpers (read_json / write_json / ...) so user
+ # code can use the same API on Local and Cloud.
+ _inject_helpers(client, session_id)
+
# Upload additional files by basename
if files:
file_contents = []
@@ -95,12 +138,13 @@ def execute_in_sandbox(
file_contents.append({"path": basename, "text": data.decode("utf-8")})
_write_files(client, session_id, file_contents)
- # Execute user code
+ # Execute user code (prefixed with helper import so agents can use
+ # read_json / write_json / ... without an explicit import).
response = client.invoke_code_interpreter(
codeInterpreterIdentifier="aws.codeinterpreter.v1",
sessionId=session_id,
name="executeCode",
- arguments={"language": "python", "code": code},
+ arguments={"language": "python", "code": _HELPERS_IMPORT + code},
)
output = _collect_stream(response)
@@ -267,6 +311,16 @@ def _save_deck_workspace(
return outline_rejected, lint_diagnostics
+def _inject_helpers(client: Any, session_id: str) -> None:
+ """Write the shared helper module into the sandbox cwd.
+
+ Runs after _upload_deck_workspace so `_sdpm_helpers.py` sits alongside
+ the deck files. User code gets these helpers via the `_HELPERS_IMPORT`
+ prefix prepended to every invocation in `execute_in_sandbox`.
+ """
+ _write_files(client, session_id, [{"path": "_sdpm_helpers.py", "text": _HELPERS_PY}])
+
+
def _write_files(client: Any, session_id: str, content: list[dict[str, str]]) -> None:
"""Write files into the sandbox.
diff --git a/mcp-server/tools/upload.py b/mcp-server/tools/upload.py
index deee8f00..b8f8cc48 100644
--- a/mcp-server/tools/upload.py
+++ b/mcp-server/tools/upload.py
@@ -207,6 +207,87 @@ def _format_cat_n(text: str, file_name: str, offset: int, limit: int) -> str:
return f"{header}\n\n{body}{footer}"
+def _format_deck_summary_from_s3(
+ storage: Storage, prefix: str, file_name: str, offset: int, limit: int,
+) -> str | None:
+ """Return a markdown-style slide summary when the S3 prefix contains deck structure.
+
+ Output shape mirrors the Local `_format_deck_text_summary` helper so agents
+ receive identical content from both modes.
+ """
+ import json as _json
+
+ # Cheap check: is there a deck.json + at least one slides/slide-*.json?
+ try:
+ deck_bytes = storage.download_file_from_pptx_bucket(f"{prefix}/deck.json")
+ _ = deck_bytes # only used to confirm existence
+ except Exception:
+ return None
+
+ slide_keys = [
+ k for k in storage.list_files(f"{prefix}/slides/", bucket=storage.pptx_bucket)
+ if k.endswith(".json")
+ ]
+ if not slide_keys:
+ return None
+
+ def _extract_title(data: dict) -> str:
+ t = data.get("title")
+ if isinstance(t, str):
+ return t
+ if isinstance(t, dict):
+ return t.get("text", "") or ""
+ return ""
+
+ def _collect_text(node, out: list[str]) -> None:
+ if isinstance(node, dict):
+ for key in ("text", "subtitle", "label", "date", "notes"):
+ v = node.get(key)
+ if isinstance(v, str) and v.strip():
+ out.append(v)
+ for p in node.get("paragraphs", []) or []:
+ if isinstance(p, dict):
+ t = p.get("text")
+ if isinstance(t, str) and t.strip():
+ out.append(t)
+ for item in node.get("items", []) or []:
+ if isinstance(item, str) and item.strip():
+ out.append(item)
+ headers = node.get("headers")
+ if isinstance(headers, list):
+ out.extend(str(c) for c in headers if c)
+ rows = node.get("rows")
+ if isinstance(rows, list):
+ for row in rows:
+ if isinstance(row, list):
+ out.extend(str(c) for c in row if c)
+ for child in node.get("elements", []) or []:
+ _collect_text(child, out)
+
+ sections: list[str] = []
+ for i, key in enumerate(sorted(slide_keys), start=1):
+ try:
+ data = _json.loads(
+ storage.download_file_from_pptx_bucket(key).decode("utf-8"),
+ )
+ except Exception:
+ continue
+ title = _extract_title(data)
+ header = f"--- Slide {i}: {title} ---" if title else f"--- Slide {i} ---"
+ body_parts: list[str] = []
+ for el in data.get("elements", []) or []:
+ _collect_text(el, body_parts)
+ seen: set[str] = set()
+ deduped: list[str] = []
+ for p in body_parts:
+ if p not in seen:
+ seen.add(p)
+ deduped.append(p)
+ sections.append(f"{header}\n{chr(10).join(deduped)}".rstrip())
+
+ return _format_cat_n("\n\n".join(sections), file_name, offset, limit)
+
+
def _read_converted(
storage: Storage, prefix: str, file_name: str, warning_text: str,
offset: int, limit: int,
@@ -214,25 +295,18 @@ def _read_converted(
"""Read converted files from S3 prefix (cat -n format)."""
result: list = []
- # Try Markdown (.md)
- md_found = False
- stem = file_name.rsplit(".", 1)[0] if "." in file_name else file_name
- candidate = f"{prefix}/{stem}.md"
- try:
- md_data = storage.download_file_from_pptx_bucket(candidate)
- result.append(_format_cat_n(md_data.decode("utf-8"), file_name, offset, limit))
- md_found = True
- except Exception:
- pass
+ # Deck structure (PPTX): full slide text summary
+ deck_summary = _format_deck_summary_from_s3(storage, prefix, file_name, offset, limit)
+ if deck_summary is not None:
+ result.append(deck_summary)
- # Try JSON (slides.json for PPTX)
- if not md_found:
+ # Try Markdown (.md) — PDF/DOCX/XLSX path
+ if not result:
+ stem = file_name.rsplit(".", 1)[0] if "." in file_name else file_name
+ candidate = f"{prefix}/{stem}.md"
try:
- json_data = storage.download_file_from_pptx_bucket(f"{prefix}/slides.json")
- # JSON is usually compact; format with line numbers too
- import json as _json
- pretty = _json.dumps(_json.loads(json_data), ensure_ascii=False, indent=2)
- result.append(_format_cat_n(pretty, file_name, offset, limit))
+ md_data = storage.download_file_from_pptx_bucket(candidate)
+ result.append(_format_cat_n(md_data.decode("utf-8"), file_name, offset, limit))
except Exception:
pass
diff --git a/prompts/composer-agent.md b/prompts/composer-agent.md
deleted file mode 100644
index 47ffe11e..00000000
--- a/prompts/composer-agent.md
+++ /dev/null
@@ -1,34 +0,0 @@
-Current date and time: {now}
-
-You are the composer agent for spec-driven-presentation-maker.
-You handle Phase 2 (compose slides) and Phase 3 (review + polish).
-You work silently — no user interaction. Execute the instruction fully and return.
-
-## Target Deck
-deck_id: {deck_id}
-Use this deck_id for ALL run_python and generate_pptx calls. Do NOT call init_presentation.
-
-## Architecture
-- Edit workspace files via `run_python(deck_id="{deck_id}", save=True)` using normal file I/O
-- Measure: `run_python(code=..., deck_id="{deck_id}", save=True, measure_slides=["slug"])` — always specify measure_slides when editing slides
-- MCP tools: generate_pptx, get_preview for build and preview
-- Do NOT call read_workflows, read_guides, read_examples — all references are pre-loaded below
-- Do NOT call init_presentation — the deck already exists
-
-## Your Role
-- Read the instruction provided, which specifies which slides to compose
-- deck.json is READ-ONLY — do not modify it
-- Write each slide to slides/{{slug}}.json via run_python
-- Follow the compose workflow below — you already have everything you need
-- After composing, generate PPTX, measure, preview, and polish autonomously
-- ALL references below are already loaded — skip any "Before starting, you MUST run/read" instructions in the workflow
-- Your assigned slides are pre-loaded below. Other slides in slides/ are listed by name only — read them via run_python if you need to reference their content
-
-## Constraints
-- Do NOT ask the user anything — you have no user interaction
-- Do NOT modify deck.json, specs/brief.md, specs/outline.md, or specs/art-direction.html
-- Write ONLY the slides assigned to you — NEVER write to other slides/*.json files
- - Multiple composer agents run in parallel, each owning different slides
- - Writing to another agent's slides causes data races and corrupts their work
-
-{common_context}
diff --git a/prompts/spec-agent.md b/prompts/spec-agent.md
deleted file mode 100644
index 5c78d6ea..00000000
--- a/prompts/spec-agent.md
+++ /dev/null
@@ -1,56 +0,0 @@
-Current date and time: {now}
-
-You are the SPEC agent for spec-driven-presentation-maker.
-You handle Phase 1 (briefing → outline → art-direction) through user dialogue.
-Respond in the same language as the user.
-
-{mcp_instructions}
-
-## Your Role
-- Conduct Phase 1: briefing → outline → art direction — all through user dialogue
- You MUST complete each step in order. Do NOT skip any step — the composer relies on all 3 spec files
-- When Phase 1 is complete and the user approves, call `compose_slides(deck_id=..., slide_groups=[...])` to delegate slide generation to the composer agent
-- Before calling compose_slides, confirm you have written all 3 spec files:
- specs/brief.md, specs/outline.md, specs/art-direction.html
- If any are missing, write them before proceeding — the composer cannot work without them
-- The composer agent can only see specs/ files — it has no access to the conversation.
- All information needed to compose slides (content, data, context, references) must be written into the spec files
-- You do NOT write slide JSON yourself. You do NOT call build/measure/preview tools directly
-- Do NOT read Phase 2/3 workflows (create-new-2-compose, create-new-3-review, slide-json-spec) or Phase 2 guides/examples (grid, components, patterns) — the composer agent has its own references pre-loaded
-- After compose_slides returns, review the report and relay results to the user
-- For user modification requests, translate them into instructions and call compose_slides again
-
-## Post-Compose Review
-After compose_slides returns, perform a cross-slide consistency review:
-1. Check `outline_check` in the report — if `missing` is non-empty, decide whether to retry or inform the user
-2. Call `get_preview(deck_id, slide_numbers=[...])` to get preview images of ALL slides
-3. Review the preview images for:
- - Adjacent slides using the same layout (repetitive feel) → instruct composer to vary
- - Message flow disconnects between slides (does the story progress logically?)
- - Foreshadowing set up in early slides but not resolved later
- - Design token deviations (colors, fonts inconsistent with art-direction)
-4. If issues found, call compose_slides again with targeted instructions for specific slugs
-5. Present the final result to the user with preview images
-
-## Slide Group Assignment for compose_slides
-When calling compose_slides, split the outline into groups using this 2-step process:
-
-**Step 1 — Form core groups** (slides that MUST share the same design):
-- Override-inherited slides (same slug prefix, e.g. demo-1, demo-2) → same group (required)
-- Structurally identical roles (e.g. all intro slides, all demo slides) → same group (strongly recommended)
-- Slides the user explicitly asked to unify → same group
-
-**Step 2 — Distribute independent slides** for load balancing:
-- Assign remaining slides (title, closing, etc.) to existing groups so each group has roughly equal work
-- Do NOT create a group with only independent slides
-
-Rules:
-- Do NOT consider adjacent-slide layout diversity (handled by post-review)
-- No constraint on group count or size (concurrency is controlled by semaphore)
-- Do NOT create a core group with only 1 slide (nothing to unify — treat as independent)
-
-## File Uploads
-- When a user message contains [Attached: filename (uploadId: xxx)], use read_uploaded_file(upload_id) to read content. Works before deck creation (no deck_id needed).
-- For uploaded PDFs/DOCX/XLSX, use page_start=N to paginate through long documents.
-- To use an uploaded file in slides, call import_attachment(source=upload_id, deck_id=...) after deck creation.
-- To use a web image in slides, call import_attachment(source=url, deck_id=...).
diff --git a/scripts/translate_apply.py b/scripts/translate_apply.py
new file mode 100755
index 00000000..a042420e
--- /dev/null
+++ b/scripts/translate_apply.py
@@ -0,0 +1,263 @@
+#!/usr/bin/env python3
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: MIT-0
+"""Apply translations from a derived deck's translation_map.json.
+
+Usage::
+
+ uv run python3 scripts/translate_apply.py {deck_dir} [--dry-run]
+
+Reads ``{deck_dir}/translate/translation_map.json`` and rewrites each
+``{deck_dir}/slides/*.json`` in place, replacing translatable strings whose
+key exists in the dictionary (and whose value is non-empty).
+
+Structural attributes (layout, type, shape, src, fontColor, fontFamily, etc.)
+are never touched. ``\x0b`` (vertical tab) is preserved because the map file
+round-trips via ``json``.
+
+Entire-paragraph gradients sync automatically: when a paragraph carries
+``_textGradientRuns`` whose concatenated text equals the paragraph's
+``text``, the runs are replaced so they stay consistent with the translated
+paragraph. Partial paragraph gradients are left unchanged — the operator
+must adjust the runs manually.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+# Keep in sync with translate_extract.py.
+_TRANSLATABLE_KEYS = {
+ "text",
+ "subtitle",
+ "title",
+ "date",
+ "label",
+ "notes",
+}
+_SKIP_KEYS = {
+ "layout",
+ "type",
+ "shape",
+ "preset",
+ "connectorType",
+ "src",
+ "masterIndex",
+ "fontFamily",
+ "fontColor",
+ "fill",
+ "color",
+ "fontSize",
+ "opacity",
+ "align",
+ "verticalAlign",
+ "id",
+ "x",
+ "y",
+ "width",
+ "height",
+ "defaultTextColor",
+ "background",
+ "template",
+}
+
+
+def _translate_string(
+ value: str, dictionary: dict[str, str], counter: dict[str, int],
+) -> str:
+ """Return the translation when the key exists and value is non-empty."""
+ if not isinstance(value, str):
+ return value
+ replacement = dictionary.get(value)
+ if replacement: # non-empty string means "translate"
+ counter["replaced"] = counter.get("replaced", 0) + 1
+ return replacement
+ return value
+
+
+def _apply(
+ node, dictionary: dict[str, str], counter: dict[str, int],
+):
+ """Recursively rewrite translatable strings in-place and return node."""
+ if isinstance(node, dict):
+ for key, value in list(node.items()):
+ if key in _SKIP_KEYS:
+ continue
+ if key == "_textGradientRuns":
+ continue # auto-synced later at paragraph level
+ if isinstance(value, str):
+ if key in _TRANSLATABLE_KEYS:
+ node[key] = _translate_string(value, dictionary, counter)
+ elif isinstance(value, list):
+ if key == "items":
+ node[key] = [
+ _translate_string(item, dictionary, counter)
+ if isinstance(item, str) else _apply(item, dictionary, counter)
+ for item in value
+ ]
+ elif key == "headers":
+ node[key] = [
+ _translate_string(cell, dictionary, counter)
+ if isinstance(cell, str) else cell
+ for cell in value
+ ]
+ elif key == "rows":
+ new_rows = []
+ for row in value:
+ if isinstance(row, list):
+ new_rows.append([
+ _translate_string(cell, dictionary, counter)
+ if isinstance(cell, str) else cell
+ for cell in row
+ ])
+ else:
+ new_rows.append(row)
+ node[key] = new_rows
+ else:
+ for child in value:
+ _apply(child, dictionary, counter)
+ elif isinstance(value, dict):
+ _apply(value, dictionary, counter)
+ _sync_gradient_runs(node)
+ elif isinstance(node, list):
+ for child in node:
+ _apply(child, dictionary, counter)
+ return node
+
+
+def _sync_gradient_runs(node: dict) -> None:
+ """When a paragraph's gradient covers the whole text, rewrite runs.text.
+
+ Only applies when sum of run texts equalled the original ``text`` — that
+ is the entire-paragraph gradient case. Partial gradients keep the runs
+ intact; the operator must adjust them manually.
+ """
+ if not isinstance(node, dict):
+ return
+ runs = node.get("_textGradientRuns")
+ text = node.get("text")
+ if not isinstance(runs, list) or not isinstance(text, str):
+ return
+ if not all(isinstance(r, dict) and isinstance(r.get("text", ""), str) for r in runs):
+ return
+ run_concat = "".join(r.get("text", "") for r in runs)
+ # Only sync when the runs originally spanned the entire paragraph.
+ if run_concat and run_concat == text:
+ # Already in sync (pre-translation state); no-op.
+ return
+ if run_concat and run_concat != text:
+ # If the text is now shorter/longer (translated), collapse to single run.
+ if len(runs) == 1:
+ runs[0]["text"] = text
+
+
+def _diff_summary(before: dict, after: dict) -> list[tuple[str, str]]:
+ """Flatten-diff translatable strings for --dry-run reporting."""
+ changes: list[tuple[str, str]] = []
+
+ def walk(b, a):
+ if isinstance(b, dict) and isinstance(a, dict):
+ for k in b.keys() | a.keys():
+ if k in _SKIP_KEYS or k == "_textGradientRuns":
+ continue
+ if isinstance(b.get(k), str) and isinstance(a.get(k), str):
+ if b[k] != a[k] and k in _TRANSLATABLE_KEYS:
+ changes.append((b[k], a[k]))
+ elif isinstance(b.get(k), list) and isinstance(a.get(k), list):
+ for bi, ai in zip(b[k], a[k]):
+ if isinstance(bi, str) and isinstance(ai, str):
+ if bi != ai:
+ changes.append((bi, ai))
+ else:
+ walk(bi, ai)
+ elif isinstance(b.get(k), dict) and isinstance(a.get(k), dict):
+ walk(b[k], a[k])
+ elif isinstance(b, list) and isinstance(a, list):
+ for bi, ai in zip(b, a):
+ walk(bi, ai)
+
+ walk(before, after)
+ return changes
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Apply translations from a derived deck's translation_map.json.",
+ )
+ parser.add_argument("deck_dir", help="Path to the derived deck directory")
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Print the diff without modifying any files",
+ )
+ args = parser.parse_args()
+
+ deck = Path(args.deck_dir).resolve()
+ if not deck.is_dir():
+ print(f"Error: deck directory not found: {deck}", file=sys.stderr)
+ return 1
+
+ map_path = deck / "translate" / "translation_map.json"
+ if not map_path.exists():
+ print(f"Error: {map_path} not found", file=sys.stderr)
+ return 1
+
+ try:
+ dictionary: dict[str, str] = json.loads(map_path.read_text(encoding="utf-8"))
+ except Exception as e:
+ print(f"Error: failed to parse translation_map.json: {e}", file=sys.stderr)
+ return 1
+ if not isinstance(dictionary, dict):
+ print("Error: translation_map.json must be a JSON object", file=sys.stderr)
+ return 1
+
+ slides_dir = deck / "slides"
+ if not slides_dir.is_dir():
+ print(f"Error: {slides_dir} not found", file=sys.stderr)
+ return 1
+
+ total_changes = 0
+ for slide_file in sorted(slides_dir.glob("*.json")):
+ try:
+ data_text = slide_file.read_text(encoding="utf-8")
+ data = json.loads(data_text)
+ except Exception as e:
+ print(f"Warning: failed to parse {slide_file.name}: {e}", file=sys.stderr)
+ continue
+ counter: dict[str, int] = {}
+ # Work on a deep copy so dry-run diffs are accurate.
+ after = json.loads(data_text)
+ _apply(after, dictionary, counter)
+
+ if args.dry_run:
+ diffs = _diff_summary(data, after)
+ if diffs:
+ print(f"{slide_file.name}: {len(diffs)} change(s)")
+ for before, now in diffs[:3]:
+ # Truncate for readability.
+ b = before if len(before) <= 60 else before[:57] + "..."
+ n = now if len(now) <= 60 else now[:57] + "..."
+ print(f" - {b!r} → {n!r}")
+ if len(diffs) > 3:
+ print(f" - ... ({len(diffs) - 3} more)")
+ total_changes += len(diffs)
+ else:
+ slide_file.write_text(
+ json.dumps(after, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ if counter.get("replaced"):
+ total_changes += counter["replaced"]
+
+ if args.dry_run:
+ print(f"\n[dry-run] {total_changes} replacement(s) — no files modified.")
+ else:
+ print(f"Applied {total_changes} replacement(s) in-place.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/translate_extract.py b/scripts/translate_extract.py
new file mode 100755
index 00000000..e148eddb
--- /dev/null
+++ b/scripts/translate_extract.py
@@ -0,0 +1,220 @@
+#!/usr/bin/env python3
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: MIT-0
+"""Extract translatable text from a deck and scaffold a derived deck.
+
+Workflow::
+
+ uv run python3 scripts/translate_extract.py {deck_dir} \\
+ --target-lang ja \\
+ [--skip-short N] \\
+ [--output-dir ]
+
+Creates a derived deck ``{deck_dir}-{lang}/`` (or the path given via
+``--output-dir``) containing copies of the source deck plus a
+``translate/`` sub-directory with an empty dictionary template
+(``translation_map.json``) and a review-oriented TSV dump
+(``texts.tsv``).
+
+The original deck is untouched.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+import sys
+from pathlib import Path
+
+# Copied verbatim to keep the script self-contained (no sdpm.* imports
+# beyond the standard library). Keep in sync with translate_apply.py.
+_TRANSLATABLE_KEYS = {
+ "text",
+ "subtitle",
+ "title",
+ "date",
+ "label",
+ "notes",
+}
+_SKIP_KEYS = {
+ "layout",
+ "type",
+ "shape",
+ "preset",
+ "connectorType",
+ "src",
+ "masterIndex",
+ "fontFamily",
+ "fontColor",
+ "fill",
+ "color",
+ "fontSize",
+ "opacity",
+ "align",
+ "verticalAlign",
+ "id",
+ "x",
+ "y",
+ "width",
+ "height",
+ "defaultTextColor",
+ "background",
+ "template",
+}
+# Directories/files NOT copied to the derived deck.
+_SKIP_COPY = {"output.pptx", "preview", "compose", "translate"}
+
+
+def _is_translatable_string(value: str, skip_short: int) -> bool:
+ """True when the string should be included in the dictionary template."""
+ if not isinstance(value, str):
+ return False
+ stripped = value.strip()
+ if not stripped:
+ return False
+ if len(stripped) <= skip_short:
+ return False
+ if stripped.startswith(("http://", "https://")):
+ return False
+ return True
+
+
+def _collect(node: object, out: dict[str, None], skip_short: int) -> None:
+ """Walk a slide dict/list and collect translatable strings as keys."""
+ if isinstance(node, dict):
+ for key, value in node.items():
+ if key in _SKIP_KEYS:
+ continue
+ if key == "_textGradientRuns":
+ # Auto-synced by translate_apply when the whole paragraph
+ # carries the gradient; not a primary extraction target.
+ continue
+ if isinstance(value, str):
+ if key in _TRANSLATABLE_KEYS and _is_translatable_string(value, skip_short):
+ out.setdefault(value, None)
+ elif isinstance(value, list):
+ if key == "items":
+ for item in value:
+ if isinstance(item, str) and _is_translatable_string(item, skip_short):
+ out.setdefault(item, None)
+ else:
+ _collect(item, out, skip_short)
+ elif key == "headers":
+ for cell in value:
+ if isinstance(cell, str) and _is_translatable_string(cell, skip_short):
+ out.setdefault(cell, None)
+ elif key == "rows":
+ for row in value:
+ if isinstance(row, list):
+ for cell in row:
+ if isinstance(cell, str) and _is_translatable_string(cell, skip_short):
+ out.setdefault(cell, None)
+ else:
+ for child in value:
+ _collect(child, out, skip_short)
+ elif isinstance(value, dict):
+ _collect(value, out, skip_short)
+ elif isinstance(node, list):
+ for child in node:
+ _collect(child, out, skip_short)
+
+
+def _copy_deck(src: Path, dst: Path) -> None:
+ """Copy the source deck to the derived location, skipping generated files."""
+ dst.mkdir(parents=True)
+ for entry in src.iterdir():
+ if entry.name in _SKIP_COPY:
+ continue
+ target = dst / entry.name
+ if entry.is_dir():
+ shutil.copytree(entry, target)
+ else:
+ shutil.copy2(entry, target)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Scaffold a derived translated deck by extracting translatable strings.",
+ )
+ parser.add_argument("deck_dir", help="Path to the source deck directory")
+ parser.add_argument(
+ "--target-lang",
+ required=True,
+ help="Target language code (appended to derived-deck name)",
+ )
+ parser.add_argument(
+ "--skip-short",
+ type=int,
+ default=0,
+ help="Exclude text of this many characters or fewer (default: 0 = keep all)",
+ )
+ parser.add_argument(
+ "--output-dir",
+ default=None,
+ help="Override derived-deck path (default: {deck_dir}-{target-lang})",
+ )
+ args = parser.parse_args()
+
+ src = Path(args.deck_dir).resolve()
+ if not src.is_dir():
+ print(f"Error: deck directory not found: {src}", file=sys.stderr)
+ return 1
+ if not (src / "deck.json").exists():
+ print(f"Error: {src}/deck.json not found — is this a deck directory?", file=sys.stderr)
+ return 1
+
+ if args.output_dir:
+ dst = Path(args.output_dir).resolve()
+ else:
+ dst = src.parent / f"{src.name}-{args.target_lang}"
+
+ if dst.exists():
+ print(
+ f"Error: derived deck already exists: {dst}\n"
+ f" Remove it or pick a different --output-dir / --target-lang.",
+ file=sys.stderr,
+ )
+ return 1
+
+ _copy_deck(src, dst)
+
+ # Walk slides/*.json in the derived deck and collect translatable text.
+ collected: dict[str, None] = {}
+ slides_dir = dst / "slides"
+ if slides_dir.is_dir():
+ for slide_file in sorted(slides_dir.glob("*.json")):
+ try:
+ data = json.loads(slide_file.read_text(encoding="utf-8"))
+ except Exception as e:
+ print(f"Warning: failed to parse {slide_file.name}: {e}", file=sys.stderr)
+ continue
+ _collect(data, collected, args.skip_short)
+
+ # Write translate/translation_map.json (empty values) + texts.tsv.
+ translate_dir = dst / "translate"
+ translate_dir.mkdir()
+ dictionary = {text: "" for text in collected}
+ (translate_dir / "translation_map.json").write_text(
+ json.dumps(dictionary, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ with (translate_dir / "texts.tsv").open("w", encoding="utf-8") as tsv:
+ tsv.write("text\n")
+ for text in collected:
+ # TSV: single column, preserve tabs/newlines as literal escapes for review.
+ tsv.write(text.replace("\t", "\\t").replace("\n", "\\n") + "\n")
+
+ print(f"Derived deck created: {dst}")
+ print(f" translate/translation_map.json ({len(dictionary)} entries)")
+ print(" translate/texts.tsv (review copy)")
+ print()
+ print("Next steps:")
+ print(f" 1. Fill values in {dst}/translate/translation_map.json")
+ print(f" 2. uv run python3 scripts/translate_apply.py {dst} --dry-run")
+ print(f" 3. uv run python3 scripts/translate_apply.py {dst}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/shared/ingest.py b/shared/ingest.py
index 3e9b1865..bc30b032 100644
--- a/shared/ingest.py
+++ b/shared/ingest.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import logging
+import re
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
@@ -49,6 +50,15 @@ class ConversionResult:
images: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
error: str | None = None
+ # Deck-structure metadata (populated only when output_dir contains
+ # deck.json + slides/, i.e. for PPTX after pptx-import-edit).
+ deck_structure: bool = False
+ slide_count: int = 0
+ theme_hints: dict | None = None
+ suggested_name: str | None = None
+ # Path (relative to output_dir) of the placeholder-only template extracted
+ # from the source PPTX. Populated only when deck_structure is True.
+ template_path: str | None = None
def convert_file(file_path: Path, output_dir: Path) -> ConversionResult:
@@ -390,12 +400,169 @@ def _convert_xlsx(file_path: Path, output_dir: Path, images_dir: Path) -> Conver
# ---------------------------------------------------------------------------
-# PPTX: pptx_to_json Engine
+# PPTX: pptx_to_json Engine + deck-structure metadata extraction
# ---------------------------------------------------------------------------
+def _extract_theme_hints(pptx_path: Path, deck_json_path: Path, slides_dir: Path) -> dict:
+ """Extract theme hints (backgroundLuminance, accentColors, fonts) from a converted deck.
+
+ Keys are stable across Local / Cloud / DynamoDB: ``backgroundLuminance``,
+ ``accentColors``, ``fonts``.
+
+ Background luminance strategy (prioritised):
+ 1. If a slide declares an explicit ``background`` (hex), use that slide's color.
+ 2. Else, fall back to the template's light theme color (lt1 if available,
+ otherwise dk1).
+ 3. Compute per-slide luminance (0.299 R + 0.587 G + 0.114 B, 0-1) and
+ return the median across all slides.
+ """
+ import json as _json
+ import statistics
+
+ from sdpm.converter.color import extract_theme_colors_and_mapping
+
+ # Theme colors. PowerPoint can ship multiple slideMasters with
+ # different clrMaps; corporate decks frequently flip bg1=dk1 (dark
+ # deck) on a secondary master while keeping master 0 light. Walk all
+ # masters and pick the one whose clrMap most slides actually use, so
+ # backgroundLuminance reflects the rendered deck rather than master 0.
+ theme_colors: dict = {}
+ color_mapping: dict = {}
+ try:
+ # Default to master 0.
+ theme_colors, color_mapping, _ = extract_theme_colors_and_mapping(pptx_path, 0)
+ except Exception:
+ pass
+
+ # Discover all slideMasters and pick the one used by the most slides.
+ try:
+ with zipfile.ZipFile(pptx_path) as zf:
+ names = zf.namelist()
+ masters = sorted(
+ n for n in names
+ if n.startswith("ppt/slideMasters/slideMaster") and n.endswith(".xml")
+ )
+ # layout → master index (0-based per master ordering above)
+ layout_to_master: dict[str, int] = {}
+ for idx, master in enumerate(masters):
+ rels_name = master.replace("slideMasters/", "slideMasters/_rels/") + ".rels"
+ if rels_name not in names:
+ continue
+ rels_xml = zf.read(rels_name).decode("utf-8", errors="ignore")
+ for layout_match in re.finditer(r'Target="\.\./slideLayouts/(slideLayout\d+\.xml)"', rels_xml):
+ layout_to_master[layout_match.group(1)] = idx
+
+ # slide → layout → master
+ master_usage: dict[int, int] = {}
+ slide_files = sorted(
+ n for n in names
+ if re.match(r"ppt/slides/slide\d+\.xml$", n)
+ )
+ for slide in slide_files:
+ rels_name = slide.replace("slides/", "slides/_rels/") + ".rels"
+ if rels_name not in names:
+ continue
+ rels_xml = zf.read(rels_name).decode("utf-8", errors="ignore")
+ m = re.search(r'Target="\.\./slideLayouts/(slideLayout\d+\.xml)"', rels_xml)
+ if m:
+ layout = m.group(1)
+ master_idx = layout_to_master.get(layout)
+ if master_idx is not None:
+ master_usage[master_idx] = master_usage.get(master_idx, 0) + 1
+
+ if master_usage:
+ dominant_master = max(master_usage.items(), key=lambda kv: kv[1])[0]
+ if dominant_master != 0:
+ try:
+ theme_colors, color_mapping, _ = extract_theme_colors_and_mapping(
+ pptx_path, dominant_master
+ )
+ except Exception:
+ pass
+ except Exception:
+ # Multi-master discovery is best-effort; fall through to master 0
+ # values that were already loaded above.
+ pass
+
+ # Resolve bg1 through clrMap. PowerPoint's default mapping is
+ # bg1=lt1 / tx1=dk1, but corporate decks frequently flip it
+ # (bg1=dk1 / tx1=lt1) to declare a dark theme without changing the
+ # theme XML. The dominant slideMaster's clrMap is the authoritative
+ # answer.
+ bg1_target = color_mapping.get("bg1", "lt1") if color_mapping else "lt1"
+ resolved_bg = theme_colors.get(bg1_target)
+ lt1 = theme_colors.get("lt1", "#FFFFFF")
+ dk1 = theme_colors.get("dk1", "#000000")
+ # Fallback chain: slide bg > clrMap-resolved bg1 > lt1 > dk1 inverted
+ default_bg = resolved_bg if resolved_bg else (lt1 if lt1 else _invert_hex(dk1))
+
+ def _hex_to_luminance(hex_color: str) -> float:
+ h = hex_color.lstrip("#")
+ try:
+ r = int(h[0:2], 16)
+ g = int(h[2:4], 16)
+ b = int(h[4:6], 16)
+ except (ValueError, IndexError):
+ return 0.5
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
+
+ luminances: list[float] = []
+ if slides_dir.is_dir():
+ for slide_file in sorted(slides_dir.glob("slide-*.json")):
+ try:
+ data = _json.loads(slide_file.read_text(encoding="utf-8"))
+ except Exception:
+ continue
+ bg = data.get("background") or default_bg
+ luminances.append(_hex_to_luminance(bg))
+ if not luminances:
+ luminances = [_hex_to_luminance(default_bg)]
+
+ background_luminance = statistics.median(luminances)
+
+ accent_colors: list[str] = []
+ for name in ("accent1", "accent2", "accent3"):
+ c = theme_colors.get(name)
+ if isinstance(c, str) and c.startswith("#"):
+ accent_colors.append(c.upper())
+ accent_colors = accent_colors[:3]
+
+ fonts: dict = {}
+ if deck_json_path.exists():
+ try:
+ deck = _json.loads(deck_json_path.read_text(encoding="utf-8"))
+ f = deck.get("fonts")
+ if isinstance(f, dict):
+ fonts = f
+ except Exception:
+ pass
+
+ return {
+ "backgroundLuminance": round(background_luminance, 4),
+ "accentColors": accent_colors,
+ "fonts": fonts,
+ }
+
+
+def _invert_hex(hex_color: str) -> str:
+ """Invert a #RRGGBB color (for the dk1-inverted fallback)."""
+ h = hex_color.lstrip("#")
+ try:
+ r = 255 - int(h[0:2], 16)
+ g = 255 - int(h[2:4], 16)
+ b = 255 - int(h[4:6], 16)
+ except (ValueError, IndexError):
+ return "#FFFFFF"
+ return f"#{r:02X}{g:02X}{b:02X}"
+
+
def _convert_pptx(file_path: Path, output_dir: Path) -> ConversionResult:
- """Convert PPTX to JSON via Engine's pptx_to_json."""
+ """Convert PPTX to deck structure via Engine's pptx_to_json.
+
+ Populates deck_structure, slide_count, theme_hints, suggested_name when
+ the output directory contains the new deck layout (deck.json + slides/).
+ """
import json
try:
@@ -405,7 +572,6 @@ def _convert_pptx(file_path: Path, output_dir: Path) -> ConversionResult:
try:
result = pptx_to_json(file_path, output_dir=output_dir)
- # pptx_to_json writes slides.json + images/ to output_dir
json_str = json.dumps(result, ensure_ascii=False)
# Collect extracted images
@@ -414,8 +580,42 @@ def _convert_pptx(file_path: Path, output_dir: Path) -> ConversionResult:
if img_dir.exists():
images = [f.name for f in img_dir.iterdir() if f.is_file()]
+ deck_json_path = output_dir / "deck.json"
+ slides_dir = output_dir / "slides"
+ deck_structure = deck_json_path.exists() and slides_dir.is_dir()
+
+ slide_count = 0
+ theme_hints: dict | None = None
+ suggested_name: str | None = None
+ template_path: str | None = None
+ warnings: list[str] = []
+ if deck_structure:
+ slide_count = len(list(slides_dir.glob("slide-*.json")))
+ try:
+ theme_hints = _extract_theme_hints(file_path, deck_json_path, slides_dir)
+ except Exception as e:
+ logger.warning("theme_hints extraction failed: %s", e)
+ suggested_name = file_path.stem
+ try:
+ from sdpm.converter.template import extract_placeholder_template
+
+ template_out = output_dir / "template.pptx"
+ extract_placeholder_template(file_path, template_out)
+ template_path = "template.pptx"
+ except Exception as e:
+ logger.warning("placeholder template extraction failed: %s", e)
+ warnings.append(f"placeholder template extraction failed: {e}")
+
return ConversionResult(
- status="success", json_data=json_str, images=images,
+ status="success",
+ json_data=json_str,
+ images=images,
+ warnings=warnings,
+ deck_structure=deck_structure,
+ slide_count=slide_count,
+ theme_hints=theme_hints,
+ suggested_name=suggested_name,
+ template_path=template_path,
)
except Exception as e:
return ConversionResult(status="error", error=f"PPTX conversion failed: {e}")
diff --git a/skill/SKILL.md b/skill/SKILL.md
index ce9f27d9..c1293db4 100644
--- a/skill/SKILL.md
+++ b/skill/SKILL.md
@@ -30,8 +30,10 @@ When no existing PPTX is provided.
## Workflow B: Edit Existing PPTX
-When an existing PPTX is provided.
-→ Run `uv run python3 scripts/pptx_builder.py workflows edit-existing` to start.
+When an existing PPTX is provided. Web UI / API path is preferred:
+`upload_file` returns `guideInstruction:
+"read_guides([\"import-pptx\"])"` automatically. For CLI flows, run
+`uv run python3 scripts/pptx_builder.py guides import-pptx` to start.
## Workflow C: Hand-Edit Sync
diff --git a/skill/references/guides/import-pptx.md b/skill/references/guides/import-pptx.md
new file mode 100644
index 00000000..655a89a6
--- /dev/null
+++ b/skill/references/guides/import-pptx.md
@@ -0,0 +1,596 @@
+---
+name: import-pptx
+description: "Convert an uploaded PPTX into an editable deck (invoked when upload_file returns guide='import-pptx' and user intent is edit)"
+category: guide
+---
+
+# Import PPTX (Edit Existing Presentation)
+
+Invoke this guide when **both** are true:
+
+1. `upload_file` response contains `guide: "import-pptx"`, AND
+2. The user's intent is confirmed to be **editing** the PPTX (not using
+ it as reference material for a new deck).
+
+If intent is ambiguous, use the `hearing` tool **once** to confirm before
+entering this guide. If the user wants to use the PPTX as reference, stop
+here and follow the normal briefing flow (use `read_uploaded_file` to
+access content when writing `specs/brief.md`).
+
+## Overview
+
+This guide is the complete workflow for the edit branch. The user already
+provided the PPTX itself — that *is* the brief. The PPTX-derived
+**placeholder template** (extracted automatically during upload and
+copied into the deck as `deck/template.pptx`) means there is no template
+selection step: the deck builds against the source PPTX's own layouts.
+
+Steps 1 → 6 generate brief / outline / build / art-direction from the
+PPTX content automatically; the only user-facing question is the final
+review at Step 6.
+
+The build (Step 4) runs **before** art-direction (Step 5) on purpose.
+art-direction.html is consumed by the **composer** when the user
+later asks to edit slides — the initial reproduction does not need
+it. Building first against the source's own placeholder template
+lets you read the rendered slide previews and use them as ground
+truth when authoring art-direction.html.
+
+User-facing `hearing` calls in this guide:
+
+- **Step 6** — final review and hand-off to the edit loop.
+
+Between Step 1 and Step 6, do not call `hearing`. Generate everything
+from the PPTX content already in your context.
+
+## State you must carry through the guide
+
+The triggering `upload_file` response contains fields you reuse later:
+
+- `uploadId` — Step 2 (`import_attachment(source=uploadId, ...)`)
+- `suggestedName` — Step 1 (`init_presentation(name=suggestedName)`)
+- `slideCount`, `themeHints` — Step 4 validation and style selection
+
+These values stay in your conversation context. If you cannot locate
+them, scroll back through the prior tool responses — do not ask the
+user to re-upload.
+
+---
+
+## Step 1 — Initialize the deck
+
+Call `init_presentation(name=)` — **do NOT pass a template
+argument**.
+
+- Cloud `init_presentation` has no template parameter, and Local's
+ template parameter would pre-populate fonts that Step 4 immediately
+ overwrites with PPTX-derived fonts. Skipping the argument keeps Local
+ and Cloud symmetric.
+- Template (`"template.pptx"` — deck-local), fonts, and
+ `defaultTextColor` are written to `deck.json` in Step 4.
+- Returns the new `deck_id` (directory path in Local, deckId in Cloud).
+
+---
+
+## Step 2 — Import converted files
+
+Call `import_attachment(source=, deck_id=)`.
+
+The helper copies session files into the deck:
+
+- `template.pptx` — PPTX-derived placeholder template (deck root)
+- `attachments/{shortId}_deck.json` — PPTX-derived fonts / defaultTextColor
+- `attachments/{shortId}/slides/slide-NN.json` — per-slide JSON
+- `images/{shortId}_*` — extracted images (flattened into deck/images/)
+
+The returned JSON includes `shortId`, `templatePath`, `deckJson`, and
+`files[]`. Keep `shortId` — Step 3 and Step 4 need it to locate the
+imported per-slide files.
+
+---
+
+## Step 3 — Prepare brief and outline
+
+Populate `specs/brief.md` and `specs/outline.md` **before** Step 4
+builds the deck. `specs/art-direction.html` is intentionally deferred
+to Step 5 — the rendered slide previews from Step 4 are a far better
+input for it than the upload-time image extraction. Each sub-step
+uses `run_python(save=True)` so the intermediate state is persisted —
+Cloud discards the sandbox VM between calls, so `save=False` would
+lose the write.
+
+You generate these specs from the PPTX content you imported in Step 2.
+Do not call `hearing` in Step 3 — if a particular field is thin, leave
+it succinct rather than asking the user.
+
+Sandbox helpers (`read_json / write_json / read_text / write_text /
+list_files`) are available on both Local and Cloud. Do NOT use `open()`
+or `import` inside the sandbox code — Local forbids both and the Cloud
+import is already prepended.
+
+### 3-1. brief.md (Source Material from PPTX)
+
+First, explore the imported slides to extract titles and text (no save):
+
+```python
+short_id = ""
+files = list_files(f"attachments/{short_id}/slides")
+for name in sorted(files):
+ data = read_json(f"attachments/{short_id}/slides/{name}")
+ title = data.get("title") or ""
+ if isinstance(title, dict):
+ title = title.get("text", "")
+ print(name, "::", title)
+```
+
+Run that via `run_python(code=, deck_id=deck_id, save=False)`
+(Cloud: prepend `purpose="Inspect PPTX slides"`).
+
+Then write `specs/brief.md` in a second call with `save=True`:
+
+```python
+short_id = ""
+lines = ["# Brief", "", "## Source Material", ""]
+for name in sorted(list_files(f"attachments/{short_id}/slides")):
+ slug = name.removesuffix(".json")
+ data = read_json(f"attachments/{short_id}/slides/{name}")
+ title = data.get("title") or ""
+ if isinstance(title, dict):
+ title = title.get("text", "")
+ lines.append(f"### {slug}")
+ lines.append(f"Source: attachments/{short_id}/slides/{name}")
+ if title:
+ lines.append(f"Title: {title}")
+ lines.append("")
+write_text("specs/brief.md", "\n".join(lines) + "\n")
+print("brief.md written")
+```
+
+Call as `run_python(code=, deck_id=deck_id, save=True)`
+(Cloud: prepend `purpose="Write brief.md from PPTX content"`).
+
+### 3-2. outline.md (LLM summarization)
+
+Summarise each slide in one line (you, the agent, produce the summary —
+the sandbox does NOT call LLMs). Pass the `(slug, message)` pairs as a
+Python literal:
+
+```python
+# Agent fills this list from slide content seen in Step 3-1.
+pairs = [
+ ("slide-01", "Introduction to the system"),
+ ("slide-02", "Storage classes overview"),
+ # ... one entry per slide, matching attachments/{shortId}/slides/*.json
+]
+lines = [f"- [{slug}] {msg}" for slug, msg in pairs]
+write_text("specs/outline.md", "\n".join(lines) + "\n")
+print("outline.md written:", len(pairs))
+```
+
+Call with `run_python(code=, deck_id=deck_id, save=True)`
+(Cloud: add `purpose="Write outline.md from PPTX content"`).
+
+Requirements (outline lint will otherwise reject the write on Cloud):
+
+- Each slug MUST match the filename of an imported slide
+ (`slide-01`, `slide-02`, ...) — do not rename.
+- Messages MUST be non-empty.
+- One line per slide, no sub-items.
+
+---
+
+## Step 4 — Place slides + build + preview + compose (single `run_python`)
+
+Copy the PPTX-derived slide JSON into `slides/`, merge deck metadata
+into `deck.json` (using the deck-local `template.pptx`), and build the
+deck in a **single** `run_python` call with `save=True`.
+
+**Do not split Step 4 into multiple calls.** Each Cloud `run_python`
+runs in a fresh sandbox VM that is discarded afterward, so intermediate
+`save=False` writes are lost. Keeping Step 4 in one call ensures the
+copy, S3 writeback, build, preview, and compose all share a single VM.
+
+Assemble the slug list from Step 3-2 as a Python literal:
+
+```python
+short_id = ""
+slugs = ["slide-01", "slide-02", "slide-03"] # agent fills from Step 3-2
+# image_mapping is in the import_attachment result. It maps the original
+# converter-emitted filename (e.g. "slide1_image1.png") to its
+# deck-relative path after rename (e.g. "images/_slide1_image1.png").
+image_mapping = {}
+
+# 1. Merge PPTX-derived metadata into deck.json (deck-local placeholder template)
+deck = read_json("deck.json")
+imported = read_json(f"attachments/{short_id}_deck.json")
+deck["template"] = "template.pptx" # deck-local; copied by import_attachment
+deck["fonts"] = imported.get("fonts", {})
+deck["defaultTextColor"] = imported.get("defaultTextColor")
+write_json("deck.json", deck)
+
+# 2. Pre-flight check — every slug must have a corresponding imported slide
+missing = []
+for slug in slugs:
+ try:
+ _ = read_json(f"attachments/{short_id}/slides/{slug}.json")
+ except Exception:
+ missing.append(slug)
+if missing:
+ print("ERROR missing:", missing)
+else:
+ # 3. Copy each slide JSON from attachments/ into slides/, rewriting
+ # image src refs through image_mapping. import_attachment renames
+ # extracted images (e.g. "slide1_image1.png" → deck/images/_slide1_image1.png),
+ # so the converter-emitted src strings ("images/slide1_image1.png")
+ # no longer resolve and the build silently drops the picture.
+ def _rewrite_image_refs(node):
+ if isinstance(node, dict):
+ if node.get("type") == "image" and isinstance(node.get("src"), str):
+ src = node["src"]
+ # src looks like "images/"
+ base = src.split("/", 1)[1] if src.startswith("images/") else src
+ mapped = image_mapping.get(base)
+ if mapped:
+ node["src"] = mapped
+ for v in node.values():
+ _rewrite_image_refs(v)
+ elif isinstance(node, list):
+ for item in node:
+ _rewrite_image_refs(item)
+
+ for slug in slugs:
+ data = read_json(f"attachments/{short_id}/slides/{slug}.json")
+ _rewrite_image_refs(data)
+ write_json(f"slides/{slug}.json", data)
+ print("placed:", slugs)
+```
+
+Call as:
+
+```
+run_python(
+ code=,
+ deck_id=deck_id,
+ save=True,
+ measure_slides=slugs,
+)
+```
+
+Cloud: prepend `purpose="Import PPTX slides into deck and build"`.
+
+Because `specs/outline.md` was populated in Step 3-2, `save=True`
+triggers a full build that includes every slide, followed by preview
+and SVG compose. The PPTX-derived placeholder template means **layout
+mismatch is impossible** — the build should succeed in one shot.
+
+After the `run_python` call returns successfully, call
+`generate_pptx(deck_id=deck_id)` once. This persists `output.pptx`
+to the deck workspace and updates the deck record's `pptxS3Key`, so
+the Web UI can offer a "Download PPTX" button immediately. Without
+this call the UI sees no PPTX yet and hides the download action,
+even though the slides have rendered.
+
+---
+
+## Step 5 — art-direction.html (deck-specific style)
+
+Goal: produce a `specs/art-direction.html` that **describes the source
+PPTX's visual identity as a style specification** — design tokens
+plus 5-6 demonstration slides that show *how the design rules apply*,
+not what the source deck contained.
+
+The output follows the same conventions as every other sdpm style:
+
+- `:root` block with all design tokens as CSS variables (the style's
+ *machine-readable specification* — composer reads `var()`
+ references, not pixel values).
+- 5-6 demonstration slides (cover, palette / type ramp / component
+ swatches / ...). Each slide demonstrates the design while
+ explaining the reasoning. This is **NOT** a re-render of the
+ source deck's content slides.
+- 1920×1080 absolute positioning, pt units, `.t-*` text classes,
+ `.el` for absolute elements. (See `create-style` workflow for the
+ full rule list.)
+
+The composer reads this file when the user later asks to **edit**
+slides — it consumes the tokens, not the demonstration markup.
+
+> **Critical reframe:** art-direction.html is a *style guide*, not a
+> reproduction. If your demonstration slides contain the source
+> deck's headlines, bullet points, charts, or specific data, you've
+> written the wrong artifact. Demonstration slides should contain
+> placeholder text like "Cover Title" / "Section header" / "Body
+> sample with **bold** and accent" that exists purely to show how
+> the design rules render.
+
+### 5-1. Load the style-authoring workflow + scaffold
+
+`create-style.md` is the canonical workflow for authoring sdpm
+styles. **Read it first** so you understand what tokens to define,
+the demonstration slide pattern, and the critical CSS rules. The
+authoring conventions there apply unchanged to art-direction.html;
+this guide only adds the import-pptx-specific signal extraction in
+Step 5-2.
+
+```
+read_workflows(["create-style"])
+```
+
+Key conventions you must follow (full list in the workflow):
+- All design tokens in `:root` as CSS variables.
+- All colors via `var()` references — never hardcoded in elements.
+- Text style classes (`.t-cover-title`, `.t-slide-title`, `.t-body`,
+ ...) reference CSS variables. Use class names consistently.
+- Component classes (`.card`, `.accent-bar`, `.divider`, ...) also
+ via CSS variables.
+- Inline `style="..."` only for `left / top / width / height`.
+- 5-6 demonstration slides (cover + design areas) — NOT the source
+ deck's slides.
+
+Then pull a built-in style as a structural reference:
+
+1. Call `list_styles()`.
+2. Pick any scaffold — choose whichever you can read most easily.
+ The selection has no effect on the final output.
+3. Call `apply_style(deck_id, )` (MCP tool — not via
+ `run_python`).
+4. Read the copied file once with `read_text("specs/art-direction.html")`
+ to confirm the demonstration-slide pattern (cover slide first,
+ palette swatches, type ramp, then a couple of component-only
+ variants). Treat its colors / fonts / decorations as
+ **structural examples**, not values to keep.
+
+### 5-2. Extract the source PPTX's actual design tokens
+
+`themeHints` from `upload_file` is a coarse summary (a single
+background luminance, three accent colors, two font families). The
+source PPTX's master/theme XML and the **rendered slide previews
+generated in Step 4** carry far more precise data — layout positions,
+every theme color slot, true background fills, and the actual color
+frequencies on each slide.
+
+Combine three lenses on the same source — each catches what the
+others miss.
+
+**Lens A — Visual inspection of rendered previews via `get_preview`:**
+
+Pull the actual rendered slides into your context as images so you
+can see them. PIL pixel statistics (Lens C below) give you frequency
+of colors but not *meaning* — they cannot tell you that the orange
+bar is a "section divider" or that the rounded box is a "card with
+shadow". You have to look.
+
+```
+get_preview(deck_id, slugs=["slide-01", "slide-03", "slide-05",
+ "",
+ ""],
+ quality="high")
+```
+
+Pick 4-6 slugs that span the deck's variety: cover, a section
+header, a typical content slide, any slide with charts/tables, the
+closing slide. `quality="high"` (1280px) is worth the extra tokens
+because decoration motifs (shadows, line weights, corner radii) are
+hard to see at low quality.
+
+While inspecting each preview, write down:
+- **Background** — solid? gradient? bitmap? if solid, the rough hex
+ (Lens C will pin it down).
+- **Title vs body color** — is the title color the same as body, or
+ a separate accent? Is one of the accents used only in the title
+ band?
+- **Decoration motifs** — accent bars (length / weight / position),
+ shadows (soft? hard? colored?), corner radii (sharp? rounded?
+ pill?), divider lines (1px? thicker? colored?), card backgrounds
+ (filled? bordered? shadowed?), bullet markers (round? square?
+ arrow?).
+- **Layout grid** — left/right margin, where the title sits, where
+ body content starts, vertical rhythm. Cross-check with
+ `analyze_template().layouts[]`.
+- **Typography hierarchy** — relative size of cover title vs slide
+ title vs body, weights, italics, font pair contrast.
+
+These are the qualitative tokens (`--decoration-*`, `--shadow-*`,
+`--radius-*`, `--size-*`) that Lens B and C cannot give you.
+
+**Lens B — Theme XML / layouts via `analyze_template`:**
+
+Call the MCP tool on the deck-local template (`template.pptx` was
+copied here in Step 2 by `import_attachment`). It returns the full
+theme color map (lt1 / dk1 / accent1-6 / hlink / folHlink), font
+pairs (latin/eastAsian/complex), and per-layout placeholder
+positions.
+
+```
+# Cloud (deck-local placeholder template requires deck_id):
+analyze_template(template="template.pptx", deck_id=)
+
+# Local (file path is fine; deck_id ignored):
+analyze_template(template="template.pptx")
+```
+
+This is an MCP tool — do not wrap in `run_python`.
+
+Capture from the result:
+- `theme_colors` — the canonical 12 theme slots. Use these as the
+ primary source for `--color-*` tokens. accent1-6 names map to
+ whatever the source PPTX intends (corporate primary, secondary,
+ highlight, etc.). Read every accent — `themeHints.accentColors`
+ truncates to 3.
+- `fonts.latin / fonts.eastAsian / fonts.complex` — carry these
+ through verbatim. Don't substitute with system fonts unless the
+ source explicitly uses one.
+- `layouts[]` — placeholder positions per layout. Use these to size
+ cover title, slide title, content area in `--size-*` and the
+ body x/y/width/height in your demonstration slides.
+
+**Lens C — Pixel-frequency sampling via PIL on `previews/`:**
+
+Theme XML tells you what colors are *defined*; the rendered slide
+previews tell you what's actually *used* and in what proportion.
+Step 4's build produced PNG previews under `previews/` — these are
+the same images you saw via Lens A. Quantify the dominant hex values
+across all of them so the visual impression is grounded in numbers:
+
+```python
+from collections import Counter
+from PIL import Image
+import os
+
+# Step 4 wrote rendered slide previews here
+preview_files = sorted(p for p in os.listdir("previews") if p.endswith(".png"))
+sample = preview_files[:6] # cover + a few content slides
+all_pixels = []
+for f in sample:
+ img = Image.open(os.path.join("previews", f)).convert("RGB").resize((150, 150))
+ all_pixels.extend(img.getdata())
+common = Counter(all_pixels).most_common(20)
+# Convert RGB tuples to #RRGGBB hex
+swatches = ["#{:02X}{:02X}{:02X}".format(r, g, b) for (r, g, b), _ in common]
+print("Top 20 hex by pixel frequency:", swatches)
+```
+
+Cross-reference these swatches with `theme_colors` (Lens B) and
+your visual notes (Lens A):
+- Frequencies near `theme_colors.lt1 / dk1` confirm the **actual
+ background** (which may differ from `themeHints.backgroundLuminance`
+ if the deck uses a non-default master).
+- Frequencies near `theme_colors.accent1` confirm which accent is
+ the deck's hero color (the most-used one is rarely accent1 — pick
+ the most-frequent accent that isn't bg/text).
+- Outliers (high frequency but no match) are deck-specific brand
+ colors not declared in the theme — capture them as their own
+ tokens (`--color-brand-orange`, etc.).
+- If Lens A noticed a color that PIL ranks low (e.g. only on one
+ slide), still encode it — Lens A gives the meaning, Lens C only
+ the prevalence.
+
+### 5-3. Author art-direction.html following the create-style workflow
+
+You are now writing a style — follow the **`create-style` workflow**
+you loaded in 5-1. The HTML skeleton, `:root` token conventions,
+text-class naming (`.t-cover-title` / `.t-body` / ...), demonstration
+slide pattern (cover + palette + type ramp + component variants,
+total 5-6 slides), absolute-positioning rules, font-size unit, and
+violation examples are all defined there. Do not re-invent any of
+those conventions in this guide.
+
+This Step contributes only the **import-pptx-specific token
+sourcing**: where each token value comes from. Map each token kind
+to the lens that produced it in 5-2:
+
+| Token kind | Source |
+|--------------------------------------------|--------------------------------------|
+| `--color-bg` | Lens B `theme_colors.lt1` (light deck) or `dk1` (dark deck), confirmed by Lens C frequency. **Do not** use a guessed neutral or the scaffold's bg. |
+| `--color-fg` / text | Lens B `theme_colors.dk1` (light deck) or `lt1` (dark deck) |
+| `--color-accent-N` (1 per accent in use) | Lens B `theme_colors.accent1..6`. Hero is the most-used accent per Lens C, not necessarily accent1. |
+| Brand color outside the theme | Lens C outliers (high frequency, not in theme_colors). Encode as `--color-brand-`. Lens A confirms semantic role. |
+| `--font-heading` / `--font-body` | Lens B `fonts.latin / eastAsian / complex`, verbatim. No system-font substitution. |
+| `--size-cover-title` / `--size-slide-title` / `--size-body` | Lens B `layouts[]` text-frame heights → derive pt sizes; cross-check with Lens A visual hierarchy. |
+| `--radius-*` / `--shadow-*` / `--border-*` / decoration motifs | Lens A only. If Lens A did not see it, do not declare it. |
+| Margin / grid (where title sits, body x/y) | Lens B `layouts[]` placeholder x/y/width/height. |
+
+After populating tokens, write the demonstration slides. **The
+demonstration slides are NOT a re-render of the source deck.** Read
+the create-style workflow's "Plan slide composition" section: each
+slide demonstrates one design rule with placeholder content like
+"Cover Title" / "Section header" / "Body sample paragraph" /
+"Component swatches". Do not paste source-deck headlines, bullet
+lists, charts, or specific data into the demonstration slides — that
+content lives in `slides/` (placed by Step 4), not in the style
+specification.
+
+Write incrementally via `run_python(save=True)` — one call for the
+skeleton + `:root` + first slide, then one or two more for the
+remaining slides (per the create-style workflow's incremental writing
+guidance):
+
+```python
+# Cloud: prepend purpose="Author art-direction.html — skeleton + tokens"
+header = """
+
+
+
+
+
+
+
+"""
+cover_slide = """
+
+
+"""
+write_text("specs/art-direction.html", header + cover_slide)
+```
+
+Subsequent calls append palette swatches, type ramp, and
+component-only demonstration slides — see the create-style workflow
+for the standard demonstration-slide set.
+
+Quality bar before considering this Step done:
+
+- `:root` declares every color, font, and size the slides reference.
+ No hardcoded hex / pt anywhere outside `:root`.
+- Demonstration slides use `.t-*` text classes and `var(--*)`
+ references exclusively (verify with a `grep` for `style="font-size`
+ or `style="color`).
+- Demonstration slide *content* is placeholder copy — not the
+ source deck's content.
+- `--color-bg` matches what Lens A and Lens C agree the source
+ background actually is (dark theme decks have `--color-bg` set
+ to dark, not white).
+- Total demonstration slides: 5-6 (cover counted).
+- **No re-build is needed after writing art-direction.html.** Step 4
+ already produced the as-is reproduction the user can review. The
+ file you write here is consumed by the composer the next time the
+ user asks to edit slides; until then the deck stays at its Step 4
+ state.
+
+---
+
+## Step 6 — Present to the user
+
+Call `get_preview` to surface visuals:
+
+- Local: `get_preview(slides_json_path=deck_id, pages="")`
+- Cloud: `get_preview(deck_id, slugs=[...])`
+
+Then use a single `hearing` (the only user-facing hearing of this
+guide) to wrap up: surface what was auto-generated and let the user
+direct the next edits. Suggested `inference`:
+
+> 「PPTX を取り込んで以下の内容で deck を生成しました:
+> - 概要 (brief):
+> - 構成 (outline): <スライド数> ページ
+> - art-direction: 元 PPTX の theme XML とプレビュー画像から抽出したスタイル
+>
+> このまま編集に進めて良いですか?他に変えたいところはありますか?」
+
+A `free_text` question is appropriate here ("どこを変えたいですか?").
+After the user responds, return control to the normal edit loop
+(Cloud: `compose_slides`; Local: `use_subagent` with `sdpm-composer`).
+
+---
+
+## Notes on lossy conversion
+
+`pptx_to_json` has known limitations:
+
+- Connectors are rendered as straight lines.
+- Arrow-head styles are not preserved.
+- Complex gradients may render differently.
+
+Do NOT proactively warn the user about this — the converter is tracked
+for improvement separately. Address specific visual regressions only
+if the user reports them after previewing the deck.
diff --git a/skill/references/workflows/edit-existing.md b/skill/references/workflows/edit-existing.md
deleted file mode 100644
index 3617f0b3..00000000
--- a/skill/references/workflows/edit-existing.md
+++ /dev/null
@@ -1,70 +0,0 @@
----
-name: edit-existing
-description: "Workflow B: Edit existing PPTX"
-category: workflow
----
-
-# Workflow B: Edit Existing PPTX
-
-Convert an existing PPTX to JSON and edit it.
-
----
-
-### 0. Review available guides
-
-Run `guides` to review available guides. Read any that are relevant to the edit.
-
----
-
-### 1. PPTX → JSON conversion
-
-```bash
-# Use the project directory from `init` for all output
-uv run python3 scripts/pptx_to_json.py {input_pptx} -o {project_dir}
-# → Generates {project_dir}/slides.json + images/
-# → From here on, output_json = {project_dir}/slides.json
-```
-
----
-
-### 2. Edit JSON
-
-Edit `slides.json` directly. Roundtrip JSON is valid input for pptx_builder as-is.
-
-- Text changes: edit `text`, `items` (bullet array), or `paragraphs` (paragraph array)
-- Add/remove/move elements: edit the `elements` array
-- Elements inside groups: edit the `elements` array under `type: "group"` (not `children`)
-- Table edits: `headers` is a cell array, `rows` is `[[cell, cell, ...], ...]` (each row is a list)
-- Add/remove/reorder slides: edit the `slides` array
-- Image references: point `src` to files in `{output_dir}/images/`
-
-**Constraints:**
-- You MUST edit slides one at a time — do NOT batch-edit multiple slides simultaneously because it causes context overflow and coordinate errors
-- You MUST NOT carry over colors or styles from source slides — always apply the new theme's design guidelines because source styles conflict with the target theme
-- For new slides or layout changes, you MUST read:
- - `python scripts/pptx_builder.py workflows slide-json-spec`
- - `python scripts/pptx_builder.py examples components/all`
- - Pattern details (`uv run python3 scripts/pptx_builder.py examples patterns/N`) — only the pages needed
- - Icon search (`icon-search`) — only for the pages needed
-
----
-
-### 3. Generate + review
-
-```bash
-uv run python3 scripts/pptx_builder.py generate {output_json} -o {output_pptx}
-uv run python3 scripts/pptx_builder.py measure {output_json}
-uv run python3 scripts/pptx_builder.py preview {output_json}
-```
-
-Review measure output and preview PNGs, fix and regenerate as needed.
-
-**Constraints:**
-- You MUST NOT run generate until all slides are edited/added to the JSON
-
----
-
-## Translation
-
-For translating an existing PPTX to another language, use the dedicated workflow.
-→ **Read `translate-pptx` before executing**
diff --git a/skill/references/workflows/translate-pptx.md b/skill/references/workflows/translate-pptx.md
index 6ffc8842..732ddb9b 100644
--- a/skill/references/workflows/translate-pptx.md
+++ b/skill/references/workflows/translate-pptx.md
@@ -1,147 +1,134 @@
---
name: translate-pptx
-description: "Translate existing PPTX (e.g. EN→JA, JA→EN)"
+description: "Translate an existing deck into another language as a derived deck"
category: workflow
---
-# PPTX Translation
+# Translate Existing Deck
-Translate an existing PPTX into another language. A variant of Workflow B.
+Translate a deck to another language by creating a **derived deck**. The
+original deck is untouched — translation is written to a sibling directory
+(``{deck_dir}-{lang}/``) so the source language remains available.
----
-
-### 0. Review available guides
+## Prerequisites
-Run `guides` to review available guides. Read any that are relevant to the translation.
+- A deck in the new format: ``{deck_dir}/deck.json`` + ``slides/*.json``
+ + ``specs/`` + ``images/``. If you only have the source PPTX, first
+ import it via the edit flow (see ``guides/import-pptx.md``).
+- ``{deck_dir}/deck.json`` has ``template`` set to the original PPTX so
+ the derived deck's PPTX build finds the same layouts.
---
-### 1. Reverse-convert
+## Step 1 — Create the derived deck + extract translatable text
```bash
-uv run python3 scripts/pptx_builder.py init {name}
-# {project_dir} = the directory created by init (printed in output)
-uv run python3 scripts/pptx_to_json.py {input_pptx} -o {project_dir}
+uv run python3 scripts/translate_extract.py {deck_dir} --target-lang ja
```
-- Do NOT use `--minimal` — translation requires a full roundtrip
-- The reverse-convert writes `"template"` into the JSON, so `generate` uses the original PPTX automatically
-
----
+Creates::
-### 2. Extract text + generate dictionary template
+ {deck_dir}-ja/
+ deck.json (copy)
+ slides/*.json (copy)
+ specs/ (copy — NOT translated by the script)
+ attachments/ (copy)
+ images/ (copy)
+ translate/translation_map.json (empty-value dictionary template)
+ translate/texts.tsv (review copy of translatable strings)
-```bash
-# Extract as TSV for review
-uv run python3 scripts/translate_extract.py {project_dir}/slides.json -o {project_dir}/texts.tsv
+``output.pptx``, ``preview/``, and ``compose/`` are intentionally not
+copied — the derived deck regenerates them from scratch.
-# Generate empty dictionary template (recommended — keys with \x0b and other control characters are generated accurately)
-uv run python3 scripts/translate_extract.py {project_dir}/slides.json --generate-map -o {project_dir}/translation_map.json
+Useful options:
-# Exclude short text (technical abbreviations, numbers, etc.)
-uv run python3 scripts/translate_extract.py {project_dir}/slides.json --generate-map --skip-short 3 -o {project_dir}/translation_map.json
-```
+- ``--skip-short 3`` — exclude text of 3 characters or fewer (VPC, TAG,
+ BGP, numbers, etc.).
+- ``--output-dir `` — pick an explicit path for the derived deck
+ instead of the default ``{deck_dir}-{target-lang}`` naming.
-- `--generate-map` generates an empty dictionary template. Keys match the original text in slides.json exactly, preventing `\x0b` mismatches from manual copy-paste
-- `--skip-short N` excludes text of N characters or fewer (VPC, TAG, BGP, etc.)
-- `\x0b` (vertical tab) is invisible in TSV but exists in slides.json. Always generate dictionary keys with `--generate-map` or copy directly from slides.json
+The script fails if the derived-deck path already exists, so you can
+re-run safely without accidentally overwriting in-progress work.
---
-### 3. Build translation dictionary + batch apply
+## Step 2 — Fill the translation dictionary
-Create a translation mapping dictionary in `translation_map.json` based on the extracted text.
+Edit ``{deck_dir}-ja/translate/translation_map.json``:
-**Dictionary creation procedure:**
-1. Fill in the values of the template generated by `--generate-map` (NEVER write keys manually — `\x0b` and special quote mismatches will occur)
-2. Leave value as empty string `""` for entries that should not be translated (skipped during apply)
-3. For 100+ entries, split into batches of 50-80
-4. Run `--dry-run` after each batch to verify match status, then apply
+- Each value is an empty string by default. Replace it with the
+ translation. Keys must NOT be edited — ``\x0b`` and other control
+ characters are encoded correctly only when the script generates them.
+- An empty string ``""`` means **skip this key** — the apply script
+ leaves the original text in place.
+- Preserve styled-text tags. Tag positions may need to move to match the
+ translated word boundaries:
+ ``"{{bold,#00D6C7:Contextual Planning}}{{16pt:- Builds...}}"`` →
+ ``"{{bold,#00D6C7:コンテキスト対応の計画}}{{16pt:- 設計、コード...}}"``.
+
+For 100+ entries, fill in batches of 50–80 and ``--dry-run`` after each
+batch to catch copy-paste mistakes early.
+
+---
+
+## Step 3 — Apply the translation
```bash
-# Dry run to verify key matching (no file changes)
-uv run python3 scripts/translate_apply.py {project_dir}/slides.json {project_dir}/translation_map.json --dry-run
+# Dry run first — prints the diff without touching files.
+uv run python3 scripts/translate_apply.py {deck_dir}-ja --dry-run
-# Apply
-uv run python3 scripts/translate_apply.py {project_dir}/slides.json {project_dir}/translation_map.json
+# Apply when the dry-run output looks correct.
+uv run python3 scripts/translate_apply.py {deck_dir}-ja
```
-**Translating styled text:**
-- Preserve tag types (bold, italic, color, font, size) as-is
-- Adjust tag positions to match the translated text — word boundaries shift across languages
-- Example: `"{{bold,#00D6C7:Contextual Planning }}{{16pt:- Builds...}}"` → `"{{bold,#00D6C7:コンテキスト対応の計画 }}{{16pt:- 設計、コード...}}"`
-- Dictionary keys must be exact matches of the original. The apply script handles replacement, so the agent adjusts tag positions when building the dictionary
-
-**Translating partial styles (text gradients, bold, etc.):**
-- Bold / color / font changes → expressed as styled text tags (`{{bold:...}}`). Agent adjusts tag positions in the dictionary
-- All runs share the same gradient → automatically promoted to `textGradient` (element level). No action needed
-- Entire heading paragraph has gradient → `translate_apply.py` auto-syncs `_textGradientRuns[].text`. No action needed
-- Only some runs within a paragraph have gradient → agent manually updates `_textGradientRuns[].text` in slides.json to match the translated text
-
-**Translation target keys:**
-- `text`, `paragraphs[].text`, `items[]`, `title`, `subtitle`, `date`, `label`, `notes`
-- `elements` inside `group` elements (recursively nested)
-- Table `headers` and `rows`
-
-**Constraints:**
-- You MUST NOT translate key names, layout names, or structural values (`layout`, `type`, `shape`, `preset`, `connectorType`, `src`, `masterIndex`, etc.)
-- You MUST NOT translate values of `fontFamily`, `fontColor`, `fill`, `color`, `fontSize`, `opacity`, `align`, `verticalAlign`
-- You MUST NOT translate copyright notices, legal text, company names, product names, or person names
-- You MUST NOT translate URLs
-- You MUST preserve `\x0b` (vertical tab) positions — used as line breaks
-- You MUST match the original text exactly in the dictionary key (strip() fallback is handled by the script)
-
-**Skip criteria (do not include in dictionary):**
-- Technical abbreviations only (VPC, TAG, BGP, GRE, TGW, etc.)
-- Numbers only (36, 114, 700+, etc.)
-- Code blocks, JSON, or other structured text
-- Text inside images (cannot be translated)
-- Use `--skip-short 3` to auto-exclude text of 3 characters or fewer
+The apply script rewrites ``{deck_dir}-ja/slides/*.json`` in place.
----
+What the script handles automatically:
-### 4. Check for untranslated text
+- ``\x0b`` (vertical tab) preservation — JSON round-trips encode it.
+- Styled-text tag preservation — the replacement is key-exact, so the
+ tag syntax you put in the dictionary value comes through verbatim.
+- ``_textGradientRuns[].text`` sync when the runs originally spanned the
+ entire paragraph (single-run case). Partial-paragraph gradients are
+ left alone — adjust them manually in slides JSON.
-Run extract again on the translated slides.json to find remaining text.
+What the script does NOT handle (keep in mind):
-```bash
-uv run python3 scripts/translate_extract.py {project_dir}/slides.json
-```
-
-Person names, company names, technical abbreviations, and numbers remaining is expected. If other source-language text remains, add it to the dictionary and re-apply.
+- ``specs/brief.md`` / ``specs/outline.md`` / ``specs/art-direction.html``
+ are copied as-is. If you need translated specs, edit them separately
+ (LLM-assisted or by hand).
+- Text rendered inside images. Swap the image or add speaker notes if
+ the image has critical translated content.
---
-### 5. Generate + measure + preview
+## Step 4 — Build the derived deck
```bash
-uv run python3 scripts/pptx_builder.py generate {project_dir}/slides.json -o {project_dir}/output.pptx
-uv run python3 scripts/pptx_builder.py measure {project_dir}/slides.json
-uv run python3 scripts/pptx_builder.py preview {project_dir}/slides.json
+uv run python3 scripts/pptx_builder.py generate {deck_dir}-ja -o {deck_dir}-ja/output.pptx
+uv run python3 scripts/pptx_builder.py measure {deck_dir}-ja
+uv run python3 scripts/pptx_builder.py preview {deck_dir}-ja
# Check specific slides
-uv run python3 scripts/pptx_builder.py preview {project_dir}/slides.json -p 1,3,5
+uv run python3 scripts/pptx_builder.py preview {deck_dir}-ja -p 1,3,5
```
-**Layout breakage checks:**
-- Japanese text is wider than English, so text may overflow or clip
-- Prioritize slides where measure shows significant size discrepancies
-- Review measure output and preview PNGs, fix slides.json for any broken slides, then regenerate
-- Common fixes:
- - Reduce `fontSize`
- - Shorten text or increase element `width`/`height`
- - Adjust line breaks — Japanese word-wrap breaks at different positions than English. Use `\n` to set explicit break points
+Common post-translation fixes (layout breakage is typical when
+translating EN → JA because Japanese characters are wider):
-**Constraints:**
-- You MUST use the original PPTX as template because the default template causes layout name mismatch errors — ensure `"template"` in the JSON points to the original PPTX
-- Japanese text is typically wider than English — measure discrepancies are expected. Focus on slides where text significantly exceeds the declared height
+- Reduce ``fontSize`` on overflowing elements.
+- Widen the containing element (``width`` / ``height``).
+- Insert explicit line breaks (``\n``) where auto-wrap produces awkward
+ splits.
+- Re-run measure after each fix; iterate until the overflow warnings
+ clear.
---
-### 6. Final checklist
-
-- [ ] All slides with significant measure discrepancies reviewed
-- [ ] Cover and section slide titles checked for overflow
-- [ ] Table cell text checked for overflow
-- [ ] Speaker notes added for text inside images (cannot be translated)
+## Notes
-**Output file naming:** `{original_filename}-JA.pptx` (or target language code)
+- Re-run ``translate_extract.py`` from the same source deck with a
+ different ``--target-lang`` to create additional language variants
+ without touching the existing ones.
+- To translate ``specs/`` as well, do so separately. A reasonable path
+ is to run the spec files through an LLM with the translation_map
+ entries as glossary context so terminology stays consistent.
diff --git a/skill/sdpm/builder/__init__.py b/skill/sdpm/builder/__init__.py
index 2f8b6e61..23509366 100644
--- a/skill/sdpm/builder/__init__.py
+++ b/skill/sdpm/builder/__init__.py
@@ -314,9 +314,12 @@ def add_slide(self, slide_def: dict):
if "notes" in slide_def:
notes_frame = slide.notes_slide.notes_text_frame
- notes_frame.clear()
- p = notes_frame.paragraphs[0]
- self._apply_styled_text(p, slide_def["notes"])
+ if notes_frame is not None:
+ notes_frame.clear()
+ p = notes_frame.paragraphs[0]
+ self._apply_styled_text(p, slide_def["notes"])
+ # Else: notesMaster lacks a body placeholder (degenerate source PPTX);
+ # silently skip notes rather than crash the build.
# Remove empty placeholders
if not self.keep_empty_placeholders:
diff --git a/skill/sdpm/converter/__init__.py b/skill/sdpm/converter/__init__.py
index 264bff22..5b40d700 100644
--- a/skill/sdpm/converter/__init__.py
+++ b/skill/sdpm/converter/__init__.py
@@ -27,3 +27,4 @@
from .table import extract_table_element as extract_table_element
from .chart import extract_chart_element as extract_chart_element
from .constants import _NS as _NS, EMU_PER_PX as EMU_PER_PX
+from .template import extract_placeholder_template as extract_placeholder_template
diff --git a/skill/sdpm/converter/pipeline.py b/skill/sdpm/converter/pipeline.py
index fe10048e..c1b5cfdb 100644
--- a/skill/sdpm/converter/pipeline.py
+++ b/skill/sdpm/converter/pipeline.py
@@ -1,6 +1,20 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
-"""PPTX to JSON conversion pipeline."""
+"""PPTX to JSON conversion pipeline.
+
+Output structure (deck format — stable since pptx-import-edit):
+ {output_dir}/
+ ├── deck.json # {fonts, defaultTextColor} (template set by caller)
+ ├── slides/
+ │ ├── slide-01.json
+ │ ├── slide-02.json
+ │ └── ...
+ └── images/ # extracted slide images
+
+The in-memory ``result`` dict still contains ``{slides: [...], fonts, defaultTextColor}``
+for callers that prefer the all-in-one structure (e.g. sdpm.diff). Only the on-disk
+layout changed — the legacy single ``slides.json`` is no longer written.
+"""
import argparse
import sys
from pathlib import Path
@@ -12,32 +26,49 @@
from sdpm.utils.io import write_json
from sdpm.schema.defaults import sort_element_keys
+
def pptx_to_json(pptx_path: Path, output_dir: Path = None, use_layout_names: bool = True, minimal: bool = False):
- """Convert PPTX to JSON. Output is a project folder with slides.json + images/."""
+ """Convert PPTX to JSON. Output is a deck-structure directory.
+
+ Args:
+ pptx_path: Input PPTX file.
+ output_dir: Output directory (auto-derived from pptx_path stem if None).
+ use_layout_names: Use layout names verbatim (else heuristic detection).
+ minimal: Strip defaults for clean output.
+
+ Returns:
+ Dict with {slides, fonts, defaultTextColor} for in-memory consumers.
+
+ On-disk output:
+ deck.json (fonts + defaultTextColor; template left unset)
+ slides/slide-{NN}.json (one per slide; NN is 2-digit 1-based)
+ images/ (unchanged — populated by extract_slide)
+ """
actual_path = pptx_path
prs = Presentation(str(actual_path))
# Set EMU_PER_PX based on actual slide size
from .constants import set_emu_per_px
set_emu_per_px(int(prs.slide_width))
-
+
# Create output directory
if output_dir is None:
output_dir = pptx_path.with_suffix('')
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
-
- result = {
- "slides": []
- }
-
+
+ slides_dir = output_dir / "slides"
+ slides_dir.mkdir(parents=True, exist_ok=True)
+
+ result: dict = {"slides": []}
+
# Extract fonts from template
try:
from sdpm.analyzer import extract_fonts
result["fonts"] = extract_fonts(actual_path)
except Exception:
pass
-
+
# Compute builder's default text color (from slide_masters[0])
builder_text_color = None
try:
@@ -47,49 +78,68 @@ def pptx_to_json(pptx_path: Path, output_dir: Path = None, use_layout_names: boo
except Exception:
pass
+ if builder_text_color:
+ result["defaultTextColor"] = builder_text_color
+
for slide_idx, slide in enumerate(prs.slides):
# Get slide master index
slide_master = slide.slide_layout.slide_master
master_idx = list(prs.slide_masters).index(slide_master)
-
+
# Extract theme colors and mapping for this master
theme_colors, color_mapping, theme_styles = extract_theme_colors_and_mapping(actual_path, master_idx)
-
- slide_dict = extract_slide(slide, theme_colors, color_mapping, theme_styles, master_idx, output_dir, slide_idx, pptx_path=actual_path, use_layout_names=use_layout_names, builder_text_color=builder_text_color)
+
+ slide_dict = extract_slide(
+ slide, theme_colors, color_mapping, theme_styles, master_idx, output_dir, slide_idx,
+ pptx_path=actual_path, use_layout_names=use_layout_names, builder_text_color=builder_text_color,
+ )
slide_dict["elements"] = [sort_element_keys(e) for e in slide_dict.get("elements", [])]
if minimal:
from sdpm.schema.minimal import minimize
slide_dict["elements"] = minimize(slide_dict["elements"])
result["slides"].append(slide_dict)
-
- # Output
- json_path = output_dir / "slides.json"
- write_json(json_path, result)
-
- # Cleanup
+
+ # Write deck.json (fonts + defaultTextColor; template is caller's responsibility)
+ deck_meta: dict = {}
+ if "fonts" in result:
+ deck_meta["fonts"] = result["fonts"]
+ if "defaultTextColor" in result:
+ deck_meta["defaultTextColor"] = result["defaultTextColor"]
+ write_json(output_dir / "deck.json", deck_meta)
+
+ # Write slides/slide-{NN}.json (1-based, zero-padded, hyphen separator to match parse_outline_slugs)
+ for idx, slide_dict in enumerate(result["slides"], start=1):
+ slug = f"slide-{idx:02d}"
+ slide_path = slides_dir / f"{slug}.json"
+ write_json(slide_path, slide_dict)
+
+ # Status output
print(f"Converted: {output_dir}/")
- print(f" {json_path}")
+ print(" deck.json")
+ print(f" slides/ ({len(result['slides'])} files)")
images_dir = output_dir / "images"
if images_dir.exists():
count = len(list(images_dir.iterdir()))
- print(f" {images_dir}/ ({count} files)")
-
+ print(f" images/ ({count} files)")
+
return result
+
def main():
- parser = argparse.ArgumentParser(description="Convert PPTX to JSON")
+ parser = argparse.ArgumentParser(description="Convert PPTX to JSON (deck structure)")
parser.add_argument("input", help="Input PPTX file")
parser.add_argument("-o", "--output", help="Output directory (default: input filename without extension)")
parser.add_argument("--minimal", action="store_true", help="Strip defaults, internal keys, and font tags for clean output")
args = parser.parse_args()
-
+
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: File not found: {input_path}", file=sys.stderr)
sys.exit(1)
-
+
output_dir = Path(args.output) if args.output else None
pptx_to_json(input_path, output_dir, minimal=args.minimal)
+
if __name__ == "__main__":
main()
diff --git a/skill/sdpm/converter/template.py b/skill/sdpm/converter/template.py
new file mode 100644
index 00000000..54aa3958
--- /dev/null
+++ b/skill/sdpm/converter/template.py
@@ -0,0 +1,89 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: MIT-0
+"""Extract a placeholder-only template PPTX from an arbitrary input PPTX.
+
+The output PPTX preserves every slide_master, slide_layout, and theme
+from the source — nothing is removed from those parts. The original
+slides are dropped and replaced with one placeholder-only sample slide
+per layout that the source PPTX actually exercised. That way the
+template stays browsable in PowerPoint (one preview per layout used),
+remains a drop-in replacement for any layout the source used, *and*
+keeps every other layout the deck author may want to reach for later.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from pptx import Presentation
+
+
+_REL_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
+
+
+def extract_placeholder_template(
+ pptx_path: str | Path,
+ output_path: str | Path,
+) -> dict:
+ """Build a placeholder-only template from a PPTX.
+
+ Args:
+ pptx_path: Source PPTX file.
+ output_path: Destination path for the placeholder template.
+
+ Returns:
+ Metadata dict: {input_size, output_size, layout_count,
+ master_count, used_layout_count}.
+
+ Behavior:
+ - All slide_masters are retained.
+ - All slide_layouts are retained (including layouts the source
+ PPTX never used).
+ - All slides from the source are dropped, then one
+ placeholder-only sample slide is emitted for each layout that
+ at least one source slide used. Layouts the source never
+ touched do not get sample slides.
+ """
+ src = Path(pptx_path)
+ dst = Path(output_path)
+ dst.parent.mkdir(parents=True, exist_ok=True)
+
+ prs = Presentation(str(src))
+
+ # Step 1: figure out which layout parts are referenced by source slides.
+ used_layout_partnames: set[str] = set()
+ for slide in prs.slides:
+ used_layout_partnames.add(str(slide.slide_layout.part.partname))
+
+ # Step 2: drop every original slide (rel + sldIdLst entry).
+ sldIdLst = prs.slides._sldIdLst
+ for entry in list(sldIdLst):
+ rId = entry.attrib.get(f"{_REL_NS}id")
+ if rId:
+ try:
+ prs.part.drop_rel(rId)
+ except KeyError:
+ pass
+ sldIdLst.remove(entry)
+
+ # Step 3: emit one placeholder-only sample slide per *used* layout.
+ # Iterate masters/layouts in their original order so the output
+ # presentation order is stable.
+ used_count = 0
+ for master in prs.slide_masters:
+ for layout in master.slide_layouts:
+ if str(layout.part.partname) in used_layout_partnames:
+ prs.slides.add_slide(layout)
+ used_count += 1
+
+ master_count = len(prs.slide_masters)
+ layout_count = sum(len(m.slide_layouts) for m in prs.slide_masters)
+
+ prs.save(str(dst))
+
+ return {
+ "input_size": src.stat().st_size,
+ "output_size": dst.stat().st_size,
+ "layout_count": layout_count,
+ "master_count": master_count,
+ "used_layout_count": used_count,
+ }
diff --git a/skill/sdpm/diff/__init__.py b/skill/sdpm/diff/__init__.py
index 1e0d91d2..75867fd4 100644
--- a/skill/sdpm/diff/__init__.py
+++ b/skill/sdpm/diff/__init__.py
@@ -134,6 +134,27 @@ def align_slides(base_slides, edit_slides, threshold=0.1):
return result
+def _load_deck_as_roundtrip(deck_dir: Path) -> dict:
+ """Load deck-structure output (deck.json + slides/*.json) as a single roundtrip dict.
+
+ Restores the legacy {slides: [...], fonts, defaultTextColor} shape that the diff
+ algorithms expect. Slides are ordered by filename (slide-01, slide-02, ...).
+ """
+ deck_json = deck_dir / "deck.json"
+ slides_dir = deck_dir / "slides"
+ data: dict = {}
+ if deck_json.exists():
+ with open(deck_json) as f:
+ data = json.load(f)
+ slides: list = []
+ if slides_dir.is_dir():
+ for slide_file in sorted(slides_dir.glob("slide-*.json")):
+ with open(slide_file) as f:
+ slides.append(json.load(f))
+ data["slides"] = slides
+ return data
+
+
def load_slides_json_or_pptx(path):
"""Load roundtrip slides JSON from .json or .pptx."""
if path.endswith('.pptx'):
@@ -142,8 +163,8 @@ def load_slides_json_or_pptx(path):
[sys.executable, str(Path(__file__).resolve().parent.parent.parent / 'scripts' / 'pptx_to_json.py'), path, '-o', tmpdir],
capture_output=True, text=True, check=True
)
- with open(Path(tmpdir) / 'slides.json') as f:
- return json.load(f)
+ # New deck-structure output: deck.json + slides/slide-NN.json
+ return _load_deck_as_roundtrip(Path(tmpdir))
with open(path) as f:
data = json.load(f)
# Check if this is a source JSON (not already a roundtrip JSON) by looking
@@ -164,14 +185,14 @@ def load_slides_json_or_pptx(path):
if is_source:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_pptx = Path(tmpdir) / "tmp.pptx"
- from sdpm.api import _find_template_in_dirs, get_templates_dirs
+ from sdpm.api import _find_template_in_dirs, _get_templates_dirs
from sdpm.builder import PPTXBuilder, resolve_override
tpl_name = data.get("template")
if not tpl_name:
raise ValueError("No \"template\" specified in JSON. Cannot build for diff.")
template = Path(path).parent / tpl_name
if not template.exists():
- named = _find_template_in_dirs(tpl_name, get_templates_dirs())
+ named = _find_template_in_dirs(tpl_name, _get_templates_dirs())
if named is not None:
template = named
else:
@@ -186,6 +207,5 @@ def load_slides_json_or_pptx(path):
[sys.executable, str(Path(__file__).resolve().parent.parent.parent / 'scripts' / 'pptx_to_json.py'), str(tmp_pptx), '-o', tmpdir],
capture_output=True, text=True, check=True
)
- with open(Path(tmpdir) / 'slides.json') as f:
- return json.load(f)
+ return _load_deck_as_roundtrip(Path(tmpdir))
return data
diff --git a/tests/test_pptx_import.py b/tests/test_pptx_import.py
new file mode 100644
index 00000000..9593127e
--- /dev/null
+++ b/tests/test_pptx_import.py
@@ -0,0 +1,608 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: MIT-0
+"""Tests for PPTX import → deck structure conversion + upload integration.
+
+Covers T8 from .kiro/specs/pptx-import-edit/tasks.md:
+- pptx_to_json output: deck.json + slides/slide-NN.json
+- shared/ingest._convert_pptx: ConversionResult with deck_structure / theme_hints
+- mcp-local/upload_tools: guide/guideInstruction in response
+- mcp-local/upload_tools.read_uploaded_file: deck text summary
+- mcp-local/upload_tools._import_from_upload: slides/ recursive copy + shortId
+- pptx_builder.py CLI: accepts deck directory
+- Non-regression: PDF/DOCX/XLSX import unchanged
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_FIXTURE_PPTX = _REPO_ROOT / "skill" / "references" / "examples" / "components.pptx"
+
+
+@pytest.fixture
+def fixture_pptx() -> Path:
+ """Return path to the bundled components.pptx fixture."""
+ if not _FIXTURE_PPTX.exists():
+ pytest.skip(f"Fixture PPTX not found: {_FIXTURE_PPTX}")
+ return _FIXTURE_PPTX
+
+
+# ---------------------------------------------------------------------------
+# T1: pptx_to_json output structure
+# ---------------------------------------------------------------------------
+
+
+class TestPptxToJsonDeckStructure:
+ """pptx_to_json must output deck.json + slides/slide-NN.json."""
+
+ def test_pptx_to_json_outputs_deck_structure(self, fixture_pptx: Path, tmp_path: Path) -> None:
+ """Output directory contains deck.json and slides/slide-NN.json — no single slides.json."""
+ from sdpm.converter import pptx_to_json
+
+ out_dir = tmp_path / "out"
+ pptx_to_json(fixture_pptx, output_dir=out_dir)
+
+ assert (out_dir / "deck.json").exists(), "deck.json should be created"
+ slides_dir = out_dir / "slides"
+ assert slides_dir.is_dir(), "slides/ directory should be created"
+ slide_files = list(slides_dir.glob("slide-*.json"))
+ assert len(slide_files) > 0, "at least one slide-NN.json should exist"
+ # Legacy single slides.json must NOT be written
+ assert not (out_dir / "slides.json").exists(), "legacy slides.json must not be emitted"
+
+ def test_pptx_to_json_slug_format(self, fixture_pptx: Path, tmp_path: Path) -> None:
+ """Slug format is slide-NN (hyphen + 2-digit zero-padded) and matches parse_outline_slugs regex."""
+ from sdpm.api import parse_outline_slugs
+ from sdpm.converter import pptx_to_json
+
+ out_dir = tmp_path / "out"
+ pptx_to_json(fixture_pptx, output_dir=out_dir)
+
+ slide_files = sorted((out_dir / "slides").glob("*.json"))
+ slug_re = re.compile(r"^slide-\d{2}$")
+ for f in slide_files:
+ assert slug_re.match(f.stem), f"slug must match slide-NN format: {f.stem}"
+
+ # Sanity: construct fake outline.md with these slugs and verify parse_outline_slugs accepts them
+ fake_outline = "\n".join(f"- [{f.stem}] msg" for f in slide_files)
+ outline_path = tmp_path / "outline.md"
+ outline_path.write_text(fake_outline, encoding="utf-8")
+ parsed = parse_outline_slugs(outline_path)
+ assert parsed == [f.stem for f in slide_files]
+
+ def test_deck_json_has_fonts_and_default_text_color(self, fixture_pptx: Path, tmp_path: Path) -> None:
+ """deck.json contains fonts dict and defaultTextColor (both PPTX-derived)."""
+ from sdpm.converter import pptx_to_json
+
+ out_dir = tmp_path / "out"
+ pptx_to_json(fixture_pptx, output_dir=out_dir)
+
+ deck = json.loads((out_dir / "deck.json").read_text(encoding="utf-8"))
+ assert "fonts" in deck, "deck.json should contain fonts"
+ # defaultTextColor may be None if extraction failed, but the key should exist
+ assert "defaultTextColor" in deck, "deck.json should contain defaultTextColor key"
+
+
+# ---------------------------------------------------------------------------
+# T2: shared/ingest._convert_pptx + _extract_theme_hints
+# ---------------------------------------------------------------------------
+
+
+class TestConvertPptxThemeHints:
+ """_convert_pptx must populate deck_structure, slide_count, theme_hints, suggested_name."""
+
+ def test_convert_pptx_populates_new_fields(self, fixture_pptx: Path, tmp_path: Path) -> None:
+ from shared.ingest import convert_file
+
+ out_dir = tmp_path / "out"
+ result = convert_file(fixture_pptx, out_dir)
+
+ assert result.status == "success"
+ assert result.deck_structure is True, "deck_structure should be True for PPTX"
+ assert result.slide_count > 0, "slide_count should be positive"
+ assert result.suggested_name == fixture_pptx.stem
+ assert result.theme_hints is not None
+
+ def test_convert_pptx_theme_hints_keys(self, fixture_pptx: Path, tmp_path: Path) -> None:
+ """theme_hints uses unified keys: backgroundLuminance / accentColors / fonts."""
+ from shared.ingest import convert_file
+
+ out_dir = tmp_path / "out"
+ result = convert_file(fixture_pptx, out_dir)
+
+ assert "backgroundLuminance" in result.theme_hints
+ assert "accentColors" in result.theme_hints
+ assert "fonts" in result.theme_hints
+ # Luminance: 0.0-1.0
+ lum = result.theme_hints["backgroundLuminance"]
+ assert isinstance(lum, (int, float))
+ assert 0.0 <= lum <= 1.0
+ # Accent colors: list of #RRGGBB, max 3
+ assert isinstance(result.theme_hints["accentColors"], list)
+ assert len(result.theme_hints["accentColors"]) <= 3
+ for c in result.theme_hints["accentColors"]:
+ assert re.match(r"^#[0-9A-Fa-f]{6}$", c), f"invalid hex: {c}"
+ # Fonts: dict with halfwidth/fullwidth
+ fonts = result.theme_hints["fonts"]
+ assert isinstance(fonts, dict)
+
+ def test_convert_pptx_theme_hints_uses_slide_bg(self, tmp_path: Path) -> None:
+ """When slide has explicit background, luminance reflects it (median across slides).
+
+ Synthetic fixture: modify slide XML to force explicit backgrounds.
+ If the fixture doesn't have explicit bg, we fall back to template-derived luminance
+ — both paths must yield a valid number.
+ """
+ from shared.ingest import convert_file
+
+ out_dir = tmp_path / "out"
+ result = convert_file(_FIXTURE_PPTX, out_dir)
+ # The fixture may or may not have explicit slide bg; we just verify the median is valid.
+ # (Full-fidelity synthetic testing of slide-bg override is deferred; the pipeline
+ # correctness is validated via test_convert_pptx_theme_hints_keys.)
+ assert 0.0 <= result.theme_hints["backgroundLuminance"] <= 1.0
+
+
+# ---------------------------------------------------------------------------
+# T3: Local upload_file returns guide/guideInstruction for PPTX
+# ---------------------------------------------------------------------------
+
+
+class TestUploadFileGuideInstruction:
+ """Local upload_file must return guide, guideInstruction, suggestedName, slideCount, themeHints."""
+
+ def _upload_pptx(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
+ """Helper: upload the fixture PPTX via mcp-local/upload_tools.upload_file."""
+ # Route SDPM_DECK_ROOT so session storage lives under tmp_path
+ monkeypatch.setenv("SDPM_DECK_ROOT", str(tmp_path / "deck_root"))
+ sys.path.insert(0, str(_REPO_ROOT / "mcp-local"))
+ from upload_tools import upload_file
+
+ # Copy fixture to tmp_path so the temp upload path is stable
+ src = tmp_path / "input.pptx"
+ shutil.copy2(_FIXTURE_PPTX, src)
+
+ raw = upload_file("test-session", str(src), "input.pptx")
+ return json.loads(raw)
+
+ def test_upload_file_returns_guide_for_pptx(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ resp = self._upload_pptx(tmp_path, monkeypatch)
+ assert resp.get("status") == "converted"
+ assert resp.get("guide") == "import-pptx"
+ assert "guideInstruction" in resp
+ assert resp.get("suggestedName") == "input"
+ assert isinstance(resp.get("slideCount"), int)
+ assert resp.get("slideCount") > 0
+ theme = resp.get("themeHints")
+ assert theme is not None
+ assert "backgroundLuminance" in theme
+ assert "accentColors" in theme
+ assert "fonts" in theme
+
+ def test_upload_file_does_not_include_head_text_or_slide_titles(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Per spec: headText / slideTitles must NOT be in the response."""
+ resp = self._upload_pptx(tmp_path, monkeypatch)
+ assert "headText" not in resp
+ assert "slideTitles" not in resp
+
+ def test_guide_instruction_contains_intent_branching(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """guideInstruction must mention edit / reference / hearing branching."""
+ resp = self._upload_pptx(tmp_path, monkeypatch)
+ instruction = resp["guideInstruction"].lower()
+ assert "edit" in instruction
+ assert "reference" in instruction
+ assert "hearing" in instruction
+
+
+# ---------------------------------------------------------------------------
+# T3b: Local read_uploaded_file returns deck text summary
+# ---------------------------------------------------------------------------
+
+
+class TestReadUploadedFileTextSummary:
+ def test_read_uploaded_file_returns_text_summary(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("SDPM_DECK_ROOT", str(tmp_path / "deck_root"))
+ sys.path.insert(0, str(_REPO_ROOT / "mcp-local"))
+ from upload_tools import read_uploaded_file, upload_file
+
+ src = tmp_path / "input.pptx"
+ shutil.copy2(_FIXTURE_PPTX, src)
+ raw = upload_file("test-session", str(src), "input.pptx")
+ upload_id = json.loads(raw)["uploadId"]
+
+ result = read_uploaded_file(upload_id)
+ # Must contain a markdown-style summary with "--- Slide N" section markers
+ text_parts = [r for r in result if isinstance(r, str)]
+ joined = "\n".join(text_parts)
+ assert "--- Slide 1" in joined or "Slide 1" in joined, \
+ f"Expected slide section markers, got: {joined[:500]}"
+
+
+# ---------------------------------------------------------------------------
+# T3c: import_attachment recursive slides/ copy + shortId in result
+# ---------------------------------------------------------------------------
+
+
+class TestImportAttachmentSlidesDir:
+ def test_import_attachment_copies_slides_dir_and_returns_short_id(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("SDPM_DECK_ROOT", str(tmp_path / "deck_root"))
+ sys.path.insert(0, str(_REPO_ROOT / "mcp-local"))
+ from upload_tools import import_attachment, upload_file
+
+ src = tmp_path / "input.pptx"
+ shutil.copy2(_FIXTURE_PPTX, src)
+ raw = upload_file("test-session", str(src), "input.pptx")
+ upload_id = json.loads(raw)["uploadId"]
+
+ # Create a deck dir
+ deck_dir = tmp_path / "deck"
+ deck_dir.mkdir()
+
+ result_raw = import_attachment(source=upload_id, deck_id=str(deck_dir))
+ result = json.loads(result_raw)
+
+ assert "shortId" in result, "import_attachment must return shortId"
+ short_id = result["shortId"]
+ assert re.match(r"^[0-9a-f]{8}$", short_id)
+
+ # attachments/{shortId}/slides/slide-NN.json must exist
+ slides_dir = deck_dir / "attachments" / short_id / "slides"
+ assert slides_dir.is_dir(), f"expected {slides_dir} to exist"
+ slide_files = list(slides_dir.glob("slide-*.json"))
+ assert len(slide_files) > 0
+
+ # deckJson path should be recorded in the result
+ assert "deckJson" in result
+
+ def test_import_attachment_copies_template_pptx_to_deck_root(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Placeholder template lives at deck/template.pptx (deck-local path)."""
+ monkeypatch.setenv("SDPM_DECK_ROOT", str(tmp_path / "deck_root"))
+ sys.path.insert(0, str(_REPO_ROOT / "mcp-local"))
+ from upload_tools import import_attachment, upload_file
+
+ src = tmp_path / "input.pptx"
+ shutil.copy2(_FIXTURE_PPTX, src)
+ raw = upload_file("test-session", str(src), "input.pptx")
+ upload_id = json.loads(raw)["uploadId"]
+
+ deck_dir = tmp_path / "deck"
+ deck_dir.mkdir()
+
+ result_raw = import_attachment(source=upload_id, deck_id=str(deck_dir))
+ result = json.loads(result_raw)
+
+ assert result.get("templatePath") == "template.pptx"
+ assert (deck_dir / "template.pptx").is_file(), \
+ "template.pptx must land at deck/template.pptx"
+ # And NOT under attachments/
+ attachments_dir = deck_dir / "attachments"
+ if attachments_dir.exists():
+ stray = list(attachments_dir.glob("*template.pptx"))
+ assert not stray, f"template.pptx must not be placed under attachments/: {stray}"
+
+
+# ---------------------------------------------------------------------------
+# T18 (Cloud): _import_converted copies template.pptx to deck/template.pptx
+# ---------------------------------------------------------------------------
+
+
+class TestCloudImportConvertedCopiesTemplate:
+ """mcp-server/tools/attachment._import_converted handles template.pptx specially."""
+
+ def test_import_converted_copies_template_to_deck_root(self) -> None:
+ import types
+ from unittest.mock import MagicMock
+
+ sys.path.insert(0, str(_REPO_ROOT / "mcp-server"))
+ # mcp-server has its own dependency set; stub the modules that the
+ # attachment module imports at top level so we can import it under
+ # the local-test venv.
+ sys.modules.setdefault("requests", types.ModuleType("requests"))
+ if "storage" not in sys.modules:
+ stg_mod = types.ModuleType("storage")
+ stg_mod.Storage = object # type stub
+ sys.modules["storage"] = stg_mod
+ from tools.attachment import _import_converted
+
+ converted_prefix = "uploads/u1/up1/converted"
+ deck_id = "deck1"
+ short_id = "abcd1234"
+ # Storage stub: list_files returns a few converted files including template.pptx
+ storage = MagicMock()
+ storage.pptx_bucket = "test-bucket"
+ files_in_prefix = [
+ f"{converted_prefix}/deck.json",
+ f"{converted_prefix}/template.pptx",
+ f"{converted_prefix}/slides/slide-01.json",
+ ]
+ storage.list_files.return_value = files_in_prefix
+ storage.download_file_from_pptx_bucket.return_value = b"PPTX_BYTES"
+
+ result = json.loads(_import_converted(
+ converted_prefix, deck_id, "u1", storage, "input.pptx", "", short_id,
+ {"source": "up1", "files": [], "image_mapping": {}, "shortId": short_id},
+ ))
+
+ # templatePath in result, files list contains "template.pptx"
+ assert result.get("templatePath") == "template.pptx"
+ assert "template.pptx" in result.get("files", [])
+
+ # storage.upload_file was called with deck-local key for the template
+ upload_keys = [c.kwargs["key"] for c in storage.upload_file.call_args_list]
+ assert f"decks/{deck_id}/template.pptx" in upload_keys, \
+ f"expected deck-local template key in upload_keys, got: {upload_keys}"
+ # And NOT under attachments/
+ for k in upload_keys:
+ if k.endswith("template.pptx"):
+ assert "attachments" not in k, f"template ended up under attachments: {k}"
+
+
+# ---------------------------------------------------------------------------
+# T1 regression: pptx_builder.py CLI accepts deck directory
+# ---------------------------------------------------------------------------
+
+
+class TestPptxBuilderCliAcceptsDeckDir:
+ def test_pptx_builder_cli_generate_on_deck_dir(self, fixture_pptx: Path, tmp_path: Path) -> None:
+ """Convert PPTX → deck structure → pptx_builder.py generate should work on the directory."""
+ from sdpm.converter import pptx_to_json
+
+ deck_dir = tmp_path / "deck"
+ pptx_to_json(fixture_pptx, output_dir=deck_dir)
+
+ # pptx_to_json writes deck.json (with template=None) — supply template for generate
+ deck_json_path = deck_dir / "deck.json"
+ deck_data = json.loads(deck_json_path.read_text(encoding="utf-8"))
+ deck_data["template"] = str(_REPO_ROOT / "skill" / "templates" / "blank-dark.pptx")
+ deck_json_path.write_text(json.dumps(deck_data, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ # Build an outline covering all slides
+ specs_dir = deck_dir / "specs"
+ specs_dir.mkdir(exist_ok=True)
+ slide_files = sorted((deck_dir / "slides").glob("*.json"))
+ outline_lines = [f"- [{f.stem}] Slide {f.stem}" for f in slide_files]
+ (specs_dir / "outline.md").write_text("\n".join(outline_lines), encoding="utf-8")
+
+ # CLI: pptx_builder.py generate {deck_dir} -o {output}
+ output_pptx = tmp_path / "out.pptx"
+ cli = _REPO_ROOT / "skill" / "scripts" / "pptx_builder.py"
+ proc = subprocess.run(
+ [sys.executable, str(cli), "generate", str(deck_dir), "-o", str(output_pptx)],
+ capture_output=True, text=True, timeout=120,
+ )
+ assert proc.returncode == 0, f"pptx_builder.py generate failed:\nSTDOUT: {proc.stdout}\nSTDERR: {proc.stderr}"
+ assert output_pptx.exists()
+
+
+# ---------------------------------------------------------------------------
+# Non-regression: PDF/DOCX/XLSX conversion still works (old flow)
+# ---------------------------------------------------------------------------
+
+
+class TestDeckRootEnvHandling:
+ """Protect against Path('') being truthy — a silent fallback bug.
+
+ Prior to pptx-import-edit hardening, `Path(os.environ.get("SDPM_DECK_ROOT", ""))`
+ evaluated to `Path('.')` when the env var was unset, silently writing
+ sessions to the process cwd instead of the user's home directory.
+ """
+
+ def test_deck_root_falls_back_to_home_when_env_empty(
+ self, monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.delenv("SDPM_DECK_ROOT", raising=False)
+ sys.path.insert(0, str(_REPO_ROOT / "mcp-local"))
+ from upload_tools import _deck_root
+
+ root = _deck_root()
+ assert root == Path.home() / "Documents" / "SDPM-Presentations", \
+ f"expected home fallback, got {root}"
+
+ def test_deck_root_respects_env(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.setenv("SDPM_DECK_ROOT", str(tmp_path / "custom"))
+ sys.path.insert(0, str(_REPO_ROOT / "mcp-local"))
+ from upload_tools import _deck_root
+
+ assert _deck_root() == tmp_path / "custom"
+
+ def test_deck_root_ignores_whitespace_only_env(
+ self, monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.setenv("SDPM_DECK_ROOT", " ")
+ sys.path.insert(0, str(_REPO_ROOT / "mcp-local"))
+ from upload_tools import _deck_root
+
+ assert _deck_root() == Path.home() / "Documents" / "SDPM-Presentations"
+
+
+# ---------------------------------------------------------------------------
+# T16: extract_placeholder_template (FR-9)
+# ---------------------------------------------------------------------------
+
+
+class TestExtractPlaceholderTemplate:
+ """extract_placeholder_template keeps every master/layout, replaces slides
+ with one placeholder-only sample per *used* layout."""
+
+ def test_drops_source_content_emits_one_slide_per_used_layout(
+ self, fixture_pptx: Path, tmp_path: Path
+ ) -> None:
+ from pptx import Presentation
+ from sdpm.converter.template import extract_placeholder_template
+
+ src_prs = Presentation(str(fixture_pptx))
+ assert len(src_prs.slides) > 0
+ used_layout_names = {s.slide_layout.name for s in src_prs.slides}
+
+ out = tmp_path / "template.pptx"
+ meta = extract_placeholder_template(fixture_pptx, out)
+ assert out.exists()
+
+ out_prs = Presentation(str(out))
+ # Slide count equals the number of distinct layouts the source used.
+ assert len(out_prs.slides) == len(used_layout_names)
+ assert meta["used_layout_count"] == len(used_layout_names)
+ # Every emitted slide's layout is one of the used ones.
+ for s in out_prs.slides:
+ assert s.slide_layout.name in used_layout_names
+
+ def test_preserves_all_layouts(
+ self, fixture_pptx: Path, tmp_path: Path
+ ) -> None:
+ """All slide_layouts are preserved (including layouts the source never used)."""
+ from pptx import Presentation
+ from sdpm.converter.template import extract_placeholder_template
+
+ src_prs = Presentation(str(fixture_pptx))
+ src_layout_names = []
+ for master in src_prs.slide_masters:
+ src_layout_names.append(sorted(layout.name for layout in master.slide_layouts))
+
+ out = tmp_path / "template.pptx"
+ extract_placeholder_template(fixture_pptx, out)
+
+ dst_prs = Presentation(str(out))
+ dst_layout_names = []
+ for master in dst_prs.slide_masters:
+ dst_layout_names.append(sorted(layout.name for layout in master.slide_layouts))
+
+ assert dst_layout_names == src_layout_names
+
+ def test_preserves_all_masters(
+ self, fixture_pptx: Path, tmp_path: Path
+ ) -> None:
+ """slide_master count is preserved."""
+ from pptx import Presentation
+ from sdpm.converter.template import extract_placeholder_template
+
+ src_master_count = len(Presentation(str(fixture_pptx)).slide_masters)
+
+ out = tmp_path / "template.pptx"
+ extract_placeholder_template(fixture_pptx, out)
+
+ dst_master_count = len(Presentation(str(out)).slide_masters)
+ assert dst_master_count == src_master_count
+
+ def test_output_smaller_than_input(
+ self, fixture_pptx: Path, tmp_path: Path
+ ) -> None:
+ """Dropping slide content shrinks the file."""
+ from sdpm.converter.template import extract_placeholder_template
+
+ out = tmp_path / "template.pptx"
+ meta = extract_placeholder_template(fixture_pptx, out)
+ assert meta["output_size"] < meta["input_size"]
+
+
+# ---------------------------------------------------------------------------
+# T17: shared/ingest._convert_pptx integrates placeholder template extraction
+# ---------------------------------------------------------------------------
+
+
+class TestConvertPptxOutputsTemplate:
+ """_convert_pptx writes deck.json + slides/ + template.pptx and reports template_path."""
+
+ def test_convert_pptx_outputs_template(
+ self, fixture_pptx: Path, tmp_path: Path
+ ) -> None:
+ from shared.ingest import convert_file
+
+ out_dir = tmp_path / "out"
+ result = convert_file(fixture_pptx, out_dir)
+
+ assert result.status == "success"
+ assert result.template_path == "template.pptx"
+ assert (out_dir / "template.pptx").is_file()
+
+ def test_pptx_builder_with_derived_template(
+ self, fixture_pptx: Path, tmp_path: Path
+ ) -> None:
+ """E2E: convert PPTX → use derived template.pptx → pptx_builder generate succeeds."""
+ from shared.ingest import convert_file
+
+ deck_dir = tmp_path / "deck"
+ result = convert_file(fixture_pptx, deck_dir)
+ assert result.status == "success"
+ assert result.template_path == "template.pptx"
+
+ # Wire deck.json to point at the derived template + populate outline
+ deck_json_path = deck_dir / "deck.json"
+ deck_data = json.loads(deck_json_path.read_text(encoding="utf-8"))
+ deck_data["template"] = str(deck_dir / "template.pptx")
+ deck_json_path.write_text(
+ json.dumps(deck_data, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+
+ specs_dir = deck_dir / "specs"
+ specs_dir.mkdir(exist_ok=True)
+ slide_files = sorted((deck_dir / "slides").glob("*.json"))
+ outline_lines = [f"- [{f.stem}] Slide {f.stem}" for f in slide_files]
+ (specs_dir / "outline.md").write_text(
+ "\n".join(outline_lines), encoding="utf-8"
+ )
+
+ output_pptx = tmp_path / "out.pptx"
+ cli = _REPO_ROOT / "skill" / "scripts" / "pptx_builder.py"
+ proc = subprocess.run(
+ [sys.executable, str(cli), "generate", str(deck_dir), "-o", str(output_pptx)],
+ capture_output=True, text=True, timeout=180,
+ )
+ assert proc.returncode == 0, (
+ f"pptx_builder.py generate failed:\nSTDOUT: {proc.stdout}\nSTDERR: {proc.stderr}"
+ )
+ assert output_pptx.exists()
+
+
+# ---------------------------------------------------------------------------
+# Non-regression: PDF/DOCX/XLSX conversion still works (old flow)
+# ---------------------------------------------------------------------------
+
+
+class TestNonRegression:
+ """PDF/DOCX/XLSX conversion paths must not be affected by the PPTX deck-structure change."""
+
+ def test_convert_docx_still_produces_markdown(self, tmp_path: Path) -> None:
+ """A simple DOCX is converted to Markdown (deck_structure stays False)."""
+ try:
+ from docx import Document # noqa: F401
+ except ImportError:
+ pytest.skip("python-docx not available")
+
+ from docx import Document
+ from shared.ingest import convert_file
+
+ src = tmp_path / "simple.docx"
+ doc = Document()
+ doc.add_heading("Test", level=1)
+ doc.add_paragraph("Hello world")
+ doc.save(str(src))
+
+ out_dir = tmp_path / "out"
+ result = convert_file(src, out_dir)
+ assert result.status in ("success", "partial")
+ # DOCX must not trigger deck_structure
+ assert result.deck_structure is False
+ md_path = out_dir / "simple.md"
+ assert md_path.exists()
diff --git a/tests/test_translate.py b/tests/test_translate.py
new file mode 100644
index 00000000..c5b20a3a
--- /dev/null
+++ b/tests/test_translate.py
@@ -0,0 +1,315 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: MIT-0
+"""Tests for translate_extract.py / translate_apply.py (deck-structure aware).
+
+Covers T8 translate portion from .kiro/specs/pptx-import-edit/tasks.md.
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_EXTRACT_CLI = _REPO_ROOT / "scripts" / "translate_extract.py"
+_APPLY_CLI = _REPO_ROOT / "scripts" / "translate_apply.py"
+
+
+def _make_minimal_deck(deck_dir: Path) -> None:
+ """Create a minimal but realistic deck directory for translation tests."""
+ deck_dir.mkdir(parents=True, exist_ok=True)
+ (deck_dir / "deck.json").write_text(
+ json.dumps({"template": "blank-dark.pptx", "defaultTextColor": "#FFFFFF"}, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ slides_dir = deck_dir / "slides"
+ slides_dir.mkdir()
+
+ # slide-01: title + paragraphs + items + table
+ slide01 = {
+ "layout": "title",
+ "title": "Hello World",
+ "subtitle": "Welcome",
+ "elements": [
+ {
+ "type": "textbox",
+ "text": "Vertical\x0btab preserved", # \x0b must roundtrip
+ "fontSize": 24,
+ "fontColor": "#FFFFFF", # structural: must NOT be in dict
+ },
+ {
+ "type": "textbox",
+ "paragraphs": [
+ {"text": "First paragraph"},
+ {"text": "Second paragraph"},
+ ],
+ },
+ {
+ "type": "textbox",
+ "items": ["Item A", "Item B", "Ab"], # "Ab" short-text candidate
+ },
+ {
+ "type": "table",
+ "headers": ["Name", "Value"],
+ "rows": [["Alpha", "1"], ["Beta", "2"]],
+ },
+ {
+ "type": "group",
+ "elements": [
+ {"type": "textbox", "text": "Inside group"},
+ ],
+ },
+ {
+ "type": "textbox",
+ "text": "{{bold:Styled}} text here",
+ },
+ {
+ "type": "image",
+ "src": "images/foo.png", # structural: must NOT be translated
+ },
+ {
+ "type": "textbox",
+ "text": "https://example.com", # URL: must NOT be translated
+ },
+ ],
+ "notes": "speaker note text",
+ }
+ (slides_dir / "slide-01.json").write_text(
+ json.dumps(slide01, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+
+ # specs (copied but not translated)
+ specs_dir = deck_dir / "specs"
+ specs_dir.mkdir()
+ (specs_dir / "brief.md").write_text("# Brief\n\nOriginal English content.", encoding="utf-8")
+ (specs_dir / "outline.md").write_text("- [slide-01] Hello World\n", encoding="utf-8")
+
+ # attachments (copied)
+ attachments_dir = deck_dir / "attachments"
+ attachments_dir.mkdir()
+ (attachments_dir / "dummy.txt").write_text("attachment content", encoding="utf-8")
+
+ # images (copied)
+ images_dir = deck_dir / "images"
+ images_dir.mkdir()
+ (images_dir / "foo.png").write_bytes(b"fake-png")
+
+ # files that should NOT be copied
+ (deck_dir / "output.pptx").write_bytes(b"fake-pptx")
+ preview_dir = deck_dir / "preview"
+ preview_dir.mkdir()
+ (preview_dir / "page1.png").write_bytes(b"fake")
+ compose_dir = deck_dir / "compose"
+ compose_dir.mkdir()
+ (compose_dir / "defs_1.json").write_text("{}", encoding="utf-8")
+
+
+def _run_extract(*args: str) -> subprocess.CompletedProcess:
+ if not _EXTRACT_CLI.exists():
+ pytest.skip("translate_extract.py not yet implemented")
+ return subprocess.run(
+ [sys.executable, str(_EXTRACT_CLI), *args],
+ capture_output=True, text=True, timeout=60,
+ )
+
+
+def _run_apply(*args: str) -> subprocess.CompletedProcess:
+ if not _APPLY_CLI.exists():
+ pytest.skip("translate_apply.py not yet implemented")
+ return subprocess.run(
+ [sys.executable, str(_APPLY_CLI), *args],
+ capture_output=True, text=True, timeout=60,
+ )
+
+
+# ---------------------------------------------------------------------------
+# translate_extract.py — derived-deck creation
+# ---------------------------------------------------------------------------
+
+
+class TestTranslateExtract:
+ def test_creates_derived_deck(self, tmp_path: Path) -> None:
+ deck = tmp_path / "mydeck"
+ _make_minimal_deck(deck)
+ proc = _run_extract(str(deck), "--target-lang", "ja")
+ assert proc.returncode == 0, f"failed: {proc.stderr}"
+
+ derived = tmp_path / "mydeck-ja"
+ assert derived.is_dir()
+ assert (derived / "deck.json").exists()
+ assert (derived / "slides" / "slide-01.json").exists()
+ assert (derived / "specs" / "brief.md").exists()
+ assert (derived / "attachments" / "dummy.txt").exists()
+ assert (derived / "images" / "foo.png").exists()
+ # Not copied:
+ assert not (derived / "output.pptx").exists()
+ assert not (derived / "preview").exists()
+ assert not (derived / "compose").exists()
+ # translate/ sub-dir created
+ assert (derived / "translate" / "translation_map.json").exists()
+ assert (derived / "translate" / "texts.tsv").exists()
+
+ def test_generates_empty_map_with_all_translatable_texts(self, tmp_path: Path) -> None:
+ deck = tmp_path / "mydeck"
+ _make_minimal_deck(deck)
+ proc = _run_extract(str(deck), "--target-lang", "ja")
+ assert proc.returncode == 0
+
+ map_path = tmp_path / "mydeck-ja" / "translate" / "translation_map.json"
+ dictionary = json.loads(map_path.read_text(encoding="utf-8"))
+
+ # All values must be empty strings (template)
+ assert all(v == "" for v in dictionary.values())
+
+ # Translatable keys must be present
+ translatable = [
+ "Hello World",
+ "Welcome",
+ "Vertical\x0btab preserved",
+ "First paragraph",
+ "Second paragraph",
+ "Item A",
+ "Item B",
+ "Name",
+ "Value",
+ "Alpha",
+ "Beta",
+ "1",
+ "2",
+ "Inside group",
+ "{{bold:Styled}} text here",
+ "speaker note text",
+ ]
+ for key in translatable:
+ assert key in dictionary, f"Missing translatable key: {key!r}"
+
+ # Non-translatable keys must be absent
+ assert "images/foo.png" not in dictionary # src
+ assert "https://example.com" not in dictionary # URL
+ assert "#FFFFFF" not in dictionary # fontColor / defaultTextColor
+
+ def test_skip_short_option(self, tmp_path: Path) -> None:
+ deck = tmp_path / "mydeck"
+ _make_minimal_deck(deck)
+ proc = _run_extract(str(deck), "--target-lang", "ja", "--skip-short", "2")
+ assert proc.returncode == 0
+
+ map_path = tmp_path / "mydeck-ja" / "translate" / "translation_map.json"
+ dictionary = json.loads(map_path.read_text(encoding="utf-8"))
+
+ # "1" and "2" (length 1) should be excluded
+ assert "1" not in dictionary
+ assert "2" not in dictionary
+ # "Ab" (length 2) should be excluded
+ assert "Ab" not in dictionary
+ # Longer text should remain
+ assert "Hello World" in dictionary
+
+ def test_existing_target_errors(self, tmp_path: Path) -> None:
+ deck = tmp_path / "mydeck"
+ _make_minimal_deck(deck)
+ (tmp_path / "mydeck-ja").mkdir() # already exists
+ proc = _run_extract(str(deck), "--target-lang", "ja")
+ assert proc.returncode != 0, "Should fail when derived deck already exists"
+
+
+# ---------------------------------------------------------------------------
+# translate_apply.py — apply translations in-place on derived deck
+# ---------------------------------------------------------------------------
+
+
+class TestTranslateApply:
+ def _prepare_derived(self, tmp_path: Path, translations: dict[str, str]) -> Path:
+ """Build a derived deck with a filled-in translation_map."""
+ deck = tmp_path / "mydeck"
+ _make_minimal_deck(deck)
+ proc = _run_extract(str(deck), "--target-lang", "ja")
+ assert proc.returncode == 0
+ derived = tmp_path / "mydeck-ja"
+ map_path = derived / "translate" / "translation_map.json"
+ dictionary = json.loads(map_path.read_text(encoding="utf-8"))
+ for k, v in translations.items():
+ if k in dictionary:
+ dictionary[k] = v
+ map_path.write_text(
+ json.dumps(dictionary, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ return derived
+
+ def test_apply_in_place(self, tmp_path: Path) -> None:
+ derived = self._prepare_derived(tmp_path, {
+ "Hello World": "こんにちは世界",
+ "Welcome": "ようこそ",
+ "Item A": "項目A",
+ })
+ proc = _run_apply(str(derived))
+ assert proc.returncode == 0, f"failed: {proc.stderr}"
+
+ slide = json.loads((derived / "slides" / "slide-01.json").read_text(encoding="utf-8"))
+ assert slide["title"] == "こんにちは世界"
+ assert slide["subtitle"] == "ようこそ"
+ # Items list: only "Item A" translated
+ items = next(e for e in slide["elements"] if e.get("type") == "textbox" and "items" in e)
+ assert items["items"][0] == "項目A"
+
+ def test_dry_run_does_not_modify_files(self, tmp_path: Path) -> None:
+ derived = self._prepare_derived(tmp_path, {"Hello World": "こんにちは世界"})
+ before = (derived / "slides" / "slide-01.json").read_text(encoding="utf-8")
+ proc = _run_apply(str(derived), "--dry-run")
+ assert proc.returncode == 0
+ after = (derived / "slides" / "slide-01.json").read_text(encoding="utf-8")
+ assert before == after, "--dry-run must not modify slide files"
+
+ def test_preserves_vertical_tab(self, tmp_path: Path) -> None:
+ derived = self._prepare_derived(tmp_path, {
+ "Vertical\x0btab preserved": "縦\x0bタブ保持",
+ })
+ proc = _run_apply(str(derived))
+ assert proc.returncode == 0
+ slide = json.loads((derived / "slides" / "slide-01.json").read_text(encoding="utf-8"))
+ found = False
+ for e in slide["elements"]:
+ if e.get("text") == "縦\x0bタブ保持":
+ found = True
+ break
+ assert found, "vertical tab must roundtrip"
+
+ def test_preserves_styled_text_tags(self, tmp_path: Path) -> None:
+ derived = self._prepare_derived(tmp_path, {
+ "{{bold:Styled}} text here": "{{bold:装飾された}} テキスト",
+ })
+ proc = _run_apply(str(derived))
+ assert proc.returncode == 0
+ slide = json.loads((derived / "slides" / "slide-01.json").read_text(encoding="utf-8"))
+ found = False
+ for e in slide["elements"]:
+ if isinstance(e.get("text"), str) and e["text"].startswith("{{bold:"):
+ found = True
+ assert e["text"] == "{{bold:装飾された}} テキスト"
+ assert found, "styled text must be replaced and preserve tag syntax"
+
+ def test_skips_structural_keys(self, tmp_path: Path) -> None:
+ """Structural attributes like fontColor/src must never be in the dictionary."""
+ deck = tmp_path / "mydeck"
+ _make_minimal_deck(deck)
+ proc = _run_extract(str(deck), "--target-lang", "ja")
+ assert proc.returncode == 0
+
+ dictionary = json.loads(
+ (tmp_path / "mydeck-ja" / "translate" / "translation_map.json").read_text(encoding="utf-8")
+ )
+ # fontColor value should not be a key
+ assert "#FFFFFF" not in dictionary
+ # src value should not be a key
+ assert "images/foo.png" not in dictionary
+
+ def test_does_not_touch_specs(self, tmp_path: Path) -> None:
+ derived = self._prepare_derived(tmp_path, {"Hello World": "こんにちは世界"})
+ proc = _run_apply(str(derived))
+ assert proc.returncode == 0
+ brief = (derived / "specs" / "brief.md").read_text(encoding="utf-8")
+ assert "Original English content" in brief, "specs/ must remain in source language"
diff --git a/web-ui/src/app/(authenticated)/decks/page.tsx b/web-ui/src/app/(authenticated)/decks/page.tsx
index 3a337bff..0e9c0d15 100644
--- a/web-ui/src/app/(authenticated)/decks/page.tsx
+++ b/web-ui/src/app/(authenticated)/decks/page.tsx
@@ -115,7 +115,7 @@ export default function DecksPage() {
chatTab={ws.chatTab}
onChatTabChange={ws.setChatTab}
chatRef={chatRef}
- deckId={ws.isWorkspace && !ws.isNew ? ws.activeDeckId : null}
+ deckId={ws.isWorkspace ? (ws.isNew ? (ws.createdDeckId ?? null) : ws.activeDeckId) : null}
deckName={ws.deck?.name || null}
chatSessionId={ws.deck?.chatSessionId}
slideSlugs={ws.deck?.slides.map(s => s.slug || "") || []}
@@ -224,7 +224,7 @@ export default function DecksPage() {
chatTab={ws.chatTab}
onChatTabChange={ws.setChatTab}
chatRef={chatRef}
- deckId={ws.isWorkspace && !ws.isNew ? ws.activeDeckId : null}
+ deckId={ws.isWorkspace ? (ws.isNew ? (ws.createdDeckId ?? null) : ws.activeDeckId) : null}
deckName={ws.deck?.name || null}
chatSessionId={ws.deck?.chatSessionId}
slideSlugs={ws.deck?.slides.map(s => s.slug || "") || []}
diff --git a/web-ui/src/components/chat/ChatPanel.tsx b/web-ui/src/components/chat/ChatPanel.tsx
index 1f3e9400..3d52b86e 100644
--- a/web-ui/src/components/chat/ChatPanel.tsx
+++ b/web-ui/src/components/chat/ChatPanel.tsx
@@ -48,7 +48,23 @@ export const ChatPanel = forwardRef(function Ch
if (deckId === "new") return generateSessionId()
return deckId.padEnd(36, "0")
})
- useEffect(() => { if (chatSessionId && chatSessionId !== sessionId) setSessionId(chatSessionId) }, [chatSessionId])
+ // Mid-stream guard: when a deck is created while the agent is still
+ // streaming (init_presentation in the import-pptx guide), the parent
+ // re-renders with a fresh chatSessionId. Swapping sessionId here would
+ // re-trigger history loading and clobber the in-flight messages
+ // (toolUses disappear from the UI). Defer the swap until streaming
+ // finishes, then apply it once isLoading flips back to false.
+ const pendingChatSessionIdRef = useRef(null)
+ const isLoadingRef = useRef(false)
+ useEffect(() => {
+ if (chatSessionId && chatSessionId !== sessionId) {
+ if (isLoadingRef.current) {
+ pendingChatSessionIdRef.current = chatSessionId
+ } else {
+ setSessionId(chatSessionId)
+ }
+ }
+ }, [chatSessionId])
// --- Config ---
const [configLoaded, setConfigLoaded] = useState(false)
@@ -295,6 +311,13 @@ export const ChatPanel = forwardRef(function Ch
// --- Reconnect to running background session (Local mode) ---
useEffect(() => {
if (!IS_LOCAL || !deckId || deckId === "new") return
+ // Skip reconnect when this ChatPanel is already streaming via
+ // /api/agent/invoke. Without this guard, the deckId prop swap from
+ // "new" → real deckId after init_presentation triggers a second SSE
+ // consumer (EventSource), which reads the same stream alongside the
+ // in-flight fetch — every chunk lands in setMessages twice and the
+ // tool cards / text either get clobbered or duplicated mid-stream.
+ if (isLoadingRef.current) return
let es: EventSource | null = null
let completion = ""
const toolPositions = new Map()
@@ -482,6 +505,19 @@ export const ChatPanel = forwardRef(function Ch
}, [stream.isLoading, stream.messages])
const isLoading = stream.isLoading || reconnectLoading
+
+ // Sync streaming flag and flush any deferred sessionId swap once
+ // streaming finishes — at that point loadHistory can safely re-fetch
+ // (the .chat.json server-side state is already complete).
+ useEffect(() => {
+ isLoadingRef.current = stream.isLoading
+ if (!stream.isLoading && pendingChatSessionIdRef.current) {
+ const next = pendingChatSessionIdRef.current
+ pendingChatSessionIdRef.current = null
+ setSessionId(next)
+ }
+ }, [stream.isLoading])
+
const isInitial = stream.messages.length === 0 && !historyLoading
return (
diff --git a/web-ui/src/components/chat/ChatPanelShell.tsx b/web-ui/src/components/chat/ChatPanelShell.tsx
index b51233ba..99764bac 100644
--- a/web-ui/src/components/chat/ChatPanelShell.tsx
+++ b/web-ui/src/components/chat/ChatPanelShell.tsx
@@ -199,8 +199,8 @@ export function ChatPanelShell({
([])
const [isLoading, setIsLoading] = useState(false)
+ // Synchronous re-entry guard: setIsLoading(true) below is React state
+ // and does not flip until the next render, so two near-simultaneous
+ // triggers (Enter key repeat, double click, fast tap) both pass the
+ // isLoading check above and end up firing /api/agent/invoke twice in
+ // parallel, producing duplicate user bubbles and parallel streams.
+ // Updated synchronously at function entry and reset in finally.
+ const isLoadingRef = useRef(false)
const abortControllerRef = useRef(null)
const messagesRef = useRef(messages)
messagesRef.current = messages
@@ -113,10 +120,15 @@ export function useChatStream({ sessionId, mode, deckId, onToolEvent, onSendComp
) => {
if (!userMessage.trim() && (!uploadedFiles || uploadedFiles.length === 0) && (!snippets || snippets.length === 0)) return
if (isLoading) return
+ if (isLoadingRef.current) return
+ isLoadingRef.current = true
const accessToken = auth.user?.access_token
const userId = auth.user?.profile?.sub
- if (!IS_LOCAL && (!accessToken || !userId)) return
+ if (!IS_LOCAL && (!accessToken || !userId)) {
+ isLoadingRef.current = false
+ return
+ }
const display = options?.displayContent ?? userMessage
setMessages((prev) => [
@@ -282,6 +294,7 @@ export function useChatStream({ sessionId, mode, deckId, onToolEvent, onSendComp
}
} finally {
abortControllerRef.current = null
+ isLoadingRef.current = false
setIsLoading(false)
onSendComplete?.()
}
diff --git a/web-ui/src/lib/attachmentMarker.ts b/web-ui/src/lib/attachmentMarker.ts
index 9b361062..d6d5231b 100644
--- a/web-ui/src/lib/attachmentMarker.ts
+++ b/web-ui/src/lib/attachmentMarker.ts
@@ -5,6 +5,23 @@ import type { UploadedFile } from "@/services/uploadService"
/** Build [Attached:...] marker string for a single file. */
export function buildAttachedMarker(file: UploadedFile): string {
+ // PPTX deck-structure: when upload_file produced a guide hint, include
+ // it inline so the agent branches into the import-pptx guide instead of
+ // probing list_workflows or treating the upload as generic reference
+ // material. uploadId is still surfaced so the agent can call
+ // import_attachment / read_uploaded_file later.
+ if (file.guide && file.guideInstruction) {
+ const parts = [
+ `uploadId: ${file.uploadId}`,
+ `guide: ${file.guide}`,
+ `guideInstruction: ${JSON.stringify(file.guideInstruction)}`,
+ ]
+ if (file.suggestedName) parts.push(`suggestedName: "${file.suggestedName}"`)
+ if (typeof file.slideCount === "number") parts.push(`slideCount: ${file.slideCount}`)
+ if (file.themeHints) parts.push(`themeHints: ${JSON.stringify(file.themeHints)}`)
+ return `[Attached: ${file.fileName} (${parts.join(", ")})]`
+ }
+
if (IS_LOCAL && file.filePath) {
const parts = [`path: "${file.filePath}"`]
if (file.imagesDir) parts.push(`images: "${file.imagesDir}"`)
diff --git a/web-ui/src/lib/local/acp-process.ts b/web-ui/src/lib/local/acp-process.ts
index 0c8cb0af..b720cb98 100644
--- a/web-ui/src/lib/local/acp-process.ts
+++ b/web-ui/src/lib/local/acp-process.ts
@@ -50,13 +50,15 @@ function handleLine(ps: ProcessState, line: string) {
let msg: Record
try { msg = JSON.parse(line) } catch { return }
- // JSON-RPC response
+ // JSON-RPC response — resolve the pending promise, then fall through
+ // so the end_turn detection below can flip ps.running back to false.
+ // Returning here would short-circuit reuse: each turn would spawn a fresh
+ // process, dropping the prior turn's context (read_guides, upload result,
+ // hearing answers) and making the agent appear to drift back into Phase 1.
if (msg.id != null && ps.pending.has(msg.id as number)) {
const resolve = ps.pending.get(msg.id as number)!
ps.pending.delete(msg.id as number)
resolve(msg.result)
- for (const fn of ps.listeners) fn(msg)
- return
}
// Auto-approve permission requests
diff --git a/web-ui/src/services/uploadService.ts b/web-ui/src/services/uploadService.ts
index 2372c74b..17e97b19 100644
--- a/web-ui/src/services/uploadService.ts
+++ b/web-ui/src/services/uploadService.ts
@@ -33,6 +33,15 @@ export interface UploadedFile {
filePath?: string
imagesDir?: string
colorAnalysis?: { palette: { hex: string; ratio: number }[]; brightness: string; saturation: string }
+ // PPTX deck-structure conversion fields (set by mcp-local/upload_tools.py
+ // and the Cloud upload Lambda when convert_file produces a deck). The
+ // agent uses `guideInstruction` to branch into the import-pptx guide
+ // instead of treating the file as generic reference material.
+ guide?: string
+ guideInstruction?: string
+ suggestedName?: string
+ slideCount?: number
+ themeHints?: { backgroundLuminance?: number; accentColors?: string[]; fonts?: Record }
}
let apiBaseUrl = ""
@@ -121,6 +130,14 @@ export async function uploadFile(
if (data.filePath) uploaded.filePath = data.filePath
if (data.imagesDir) uploaded.imagesDir = data.imagesDir
if (data.colorAnalysis) uploaded.colorAnalysis = data.colorAnalysis
+ // PPTX deck-structure: forward guide hints so the agent can branch
+ // into the import-pptx guide instead of treating the upload as a
+ // generic reference file.
+ if (data.guide) uploaded.guide = data.guide
+ if (data.guideInstruction) uploaded.guideInstruction = data.guideInstruction
+ if (data.suggestedName) uploaded.suggestedName = data.suggestedName
+ if (typeof data.slideCount === "number") uploaded.slideCount = data.slideCount
+ if (data.themeHints) uploaded.themeHints = data.themeHints
onProgress?.(uploaded)
return uploaded
}
@@ -184,6 +201,13 @@ export async function uploadFile(
uploadedFile.status = processResult.status
uploadedFile.extractedText = processResult.extractedText
uploadedFile.imageUrl = processResult.imageUrl
+ // PPTX deck-structure: forward guide hints from the Cloud upload Lambda
+ // so the agent can branch into the import-pptx guide.
+ if (processResult.guide) uploadedFile.guide = processResult.guide
+ if (processResult.guideInstruction) uploadedFile.guideInstruction = processResult.guideInstruction
+ if (processResult.suggestedName) uploadedFile.suggestedName = processResult.suggestedName
+ if (typeof processResult.slideCount === "number") uploadedFile.slideCount = processResult.slideCount
+ if (processResult.themeHints) uploadedFile.themeHints = processResult.themeHints
onProgress?.(uploadedFile)
return uploadedFile
@@ -224,6 +248,14 @@ export async function pollUploadStatus(
status: result.status,
extractedText: result.extractedText,
imageUrl: result.imageUrl,
+ // Forward PPTX deck-structure guide hints (set by Cloud upload Lambda
+ // when convert_file produces a deck) so polling clients see the same
+ // import-pptx branching signal as synchronous-completion clients.
+ guide: result.guide,
+ guideInstruction: result.guideInstruction,
+ suggestedName: result.suggestedName,
+ slideCount: typeof result.slideCount === "number" ? result.slideCount : undefined,
+ themeHints: result.themeHints,
}
}