mcpField
P
pptx-translator

pptx-translator

github.com/ericgandradeドキュメント8552 回インストール24 週間前に更新
リポジトリ

pptx-translator について

pptx-translator は、PowerPoint プレゼンテーションを翻訳するスキルです。書式、レイアウト、フォント、構造を保ち、`.pptx` のスピーカーノートも翻訳できます。

主な機能

  1. 01複数言語間の翻訳
  2. 02スライドのレイアウトと書式を維持
  3. 03スピーカーノートも翻訳
  4. 04グループ図形を再帰的に処理
  5. 05スライドごとに検証し一度再試行

活用シーン

  1. 01プレゼンテーションを別の言語に翻訳
  2. 02スライドとノートをまとめて翻訳
  3. 03書式を保ってスライドをローカライズ

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ericgandrade/claude-superskills --skill pptx-translator

インストール後、Claude Code で/pptx-translatorを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

README.md
evals/
evals.json
trigger-eval.json

詳細

pptx-translator

目的

このスキルは、元の書式、レイアウト、フォント、構造を維持しながら、PowerPoint プレゼンテーション(.pptx)を任意の言語間で翻訳します。スライドごとに 1 つのエージェントを割り当てる並列サブエージェント翻訳、スライドごとの自動再試行付き検証、グループ図形の再帰的な走査、単一の高速な書き戻し処理を使用します。

このスキルを使用するタイミング

  • ユーザーが .pptx ファイルを提供し、別の言語への翻訳を希望している場合
  • ユーザーが PowerPoint をポルトガル語から英語、または英語からポルトガル語に翻訳する必要がある場合
  • ユーザーが任意の言語ペア(スペイン語、フランス語、ドイツ語、日本語など)間でスライドを翻訳する必要がある場合
  • ユーザーがスライドの内容とあわせてスピーカーノート/発表者ノートも翻訳したい場合
  • ユーザーが PowerPoint または .pptx ファイルの「翻訳」や「言語の変換」を依頼している場合

ステップ 0:依存関係のセットアップ(通知なし)

依存関係を通知せずに確認します。確認を求めず、--user を指定して自動的にインストールし、インストールしたものだけをユーザーに伝えます。

python3 -c "import pptx" 2>/dev/null || pip install --user python-pptx -q && echo "Installed python-pptx"

インストールに失敗した場合(pip がない、環境に制限があるなど)は、ユーザーに一度だけ次のように尋ねます。「python-pptx が必要です。希望するインストール方法を選んでください:(a)pip install --user、(b)既存の venv のパス、(c)手動」

ステップ 1:確認事項を一度にまとめて提示

ユーザーの依頼からすべてのパラメーターを推定します。本当に不明確な項目のみ質問します。その後、確認ボックスを1つだけ表示し、ユーザーの確認後すぐに処理を続行します。再度質問してはいけません。

推定または確認するパラメーター:

  1. ソースファイルのパス — ファイルが存在し、拡張子が .pptx であることを確認します
  2. ソース言語 — ユーザーの依頼から推定します(例:「ポルトガル語から翻訳」)。指定されていない場合は、AI 分類サブエージェント(ステップ 2)が自動的に判定します
  3. 対象言語 — 依頼から推定します。指定されていない場合は質問します
  4. バックアップモード — デフォルトは Safe(新しいファイルとして出力)。ユーザーが明示した場合に限り YOLO を使用します
  5. スピーカーノート — デフォルトは Yes(スライドとあわせて翻訳)
  6. 出力ファイル名 — ステップ 1.5 で(この確認ボックスの後に)対話形式で決定します。デフォルトは {stem}_{target_lang_code}.pptx です
╔══════════════════════════════════════════════════════════════╗
║  PPTX TRANSLATOR — Configuration                            ║
╠══════════════════════════════════════════════════════════════╣
║  File:            ~/docs/proposta.pptx                      ║
║  Direction:       Portuguese → English                      ║
║  Speaker notes:   Yes (included)                            ║
║  Original:        preserved (output saved as new file)      ║
║  Output file:     ~/docs/proposta_en.pptx                   ║
║  ⚠️  YOLO: original will be overwritten + backup created    ║
╚══════════════════════════════════════════════════════════════╝
Proceed? [Y/n]

この確認後は、以降の割り込みなしにステップ 1.5 に進みます。

ステップ 1.5:出力ファイル名の選択

ステップ 1 の確認直後に、このミニプロンプトを表示します。 すべての変数は、ステップ 1 で判定された対象言語から導出します。英語にハードコードしてはいけません。

変数:

  • {stem} — 拡張子を除いた元のファイル名(例:proposta_comercial)
  • {lang_code} — 対象言語の ISO 639-1 コード(例:pt、fr、de、es、en、ja)
  • {target_language} — 対象言語の人間が読める名称(例:Portuguese、French、German)
How should the output file be named?

  [1] {stem}_{lang_code}.pptx              ← original name + target language suffix (default)
  [2] {translated_stem}_{lang_code}.pptx   ← filename translated to {target_language} (AI suggestion)
  [3] Custom name                           ← I'll type it myself

Choice [1]:

オプション 1(デフォルト): ユーザーが Enter キーを押すか 1 を入力した場合、output_filename = {stem}_{lang_code}.pptx に設定します。

オプション 2 — AI がファイル名の語幹を対象言語に翻訳:

  • 次の正確なプロンプトでインライン AI 呼び出しを実行します。 "Translate only this filename stem to {target_language}: '{stem}'. Return ONLY the translated stem, lowercase, spaces replaced with underscores, no punctuation, no explanation."
  • 続行する前に候補を表示します。 Suggested: {translated_stem}_{lang_code}.pptx — use this? [Y/n]
  • 承認された場合 → output_filename = {translated_stem}_{lang_code}.pptx
  • 拒否された場合 → オプション 1 に暗黙的にフォールバックします

オプション 3 — カスタム名:

  • プロンプト:Enter filename (with or without .pptx):
  • 正規化:先頭のパス区切り文字を削除し、.pptx 拡張子を付け、スペースを _ に置換します
  • 確認:Output will be saved as: {custom_name} — confirm? [Y/n]
  • 承認された場合 → output_filename = {custom_name}
  • 拒否された場合 → 3 つのオプションを再表示します

言語ペア別の例:

元のファイル方向オプション 1オプション 2(AI)
proposta_comercial.pptxPT→ENproposta_comercial_en.pptxcommercial_proposal_en.pptx
proposta_comercial.pptxPT→FRproposta_comercial_fr.pptxproposition_commerciale_fr.pptx
proposta_comercial.pptxPT→DEproposta_comercial_de.pptxgeschaeftsvorschlag_de.pptx
quarterly_review.pptxEN→PTquarterly_review_pt.pptxrevisao_trimestral_pt.pptx
marketing_plan.pptxEN→ESmarketing_plan_es.pptxplan_de_marketing_es.pptx

解決した名前を output_filename として保存します。ステップ 4 と 5 では、これを出力パスとして使用します。

ステップ 2:スライドコンテンツの抽出と分析

[████░░░░░░░░░░░░░░░░] 20% — Step 2/5: Extracting slide content

python-pptx を使用して、翻訳可能なコンテンツをすべて抽出します。重要:GROUP 図形内にも降りていく再帰的な iter_shapes() 関数を使用してください。GROUP 内にネストされたテキストが見落とされるため、slide.shapes を直接使用してはいけません。

抽出スクリプト
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
import json, sys

def iter_shapes(shapes, parent_id=None):
    """Recursively yield all leaf shapes, descending into GROUP shapes."""
    for shape in shapes:
        if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
            yield from iter_shapes(shape.shapes, parent_id=shape.shape_id)
        else:
            yield shape, parent_id

def extract_text(pptx_path):
    prs = Presentation(pptx_path)
    manifest = []

    for slide_num, slide in enumerate(prs.slides, start=1):
        text_blocks = []

        for shape, parent_id in iter_shapes(slide.shapes):
            # Text frames in regular shapes (including children of groups)
            if shape.has_text_frame:
                for para_idx, para in enumerate(shape.text_frame.paragraphs):
                    for run_idx, run in enumerate(para.runs):
                        if run.text.strip():
                            text_blocks.append({
                                "shape_id": shape.shape_id,
                                "parent_id": parent_id,   # None if top-level
                                "shape_name": shape.name,
                                "para_idx": para_idx,
                                "run_idx": run_idx,
                                "original_text": run.text
                            })

            # Tables
            if shape.has_table:
                for row_idx, row in enumerate(shape.table.rows):
                    for col_idx, cell in enumerate(row.cells):
                        if cell.text.strip():
                            text_blocks.append({
                                "shape_id": shape.shape_id,
                                "parent_id": parent_id,
                                "shape_name": f"table_{shape.name}",
                                "row_idx": row_idx,
                                "col_idx": col_idx,
                                "original_text": cell.text
                            })

        # Speaker notes
        notes_text = ""
        if slide.has_notes_slide:
            notes_tf = slide.notes_slide.notes_text_frame
            notes_text = notes_tf.text.strip() if notes_tf else ""

        # SmartArt detection — flag slides with embedded diagrams
        smartart_found = []
        for shape in slide.shapes:
            if shape.shape_type is None:  # graphicFrame (SmartArt/Chart)
                smartart_found.append(shape.name)

        manifest.append({
            "slide_num": slide_num,
            "text_blocks": text_blocks,
            "notes": notes_text,
            "smartart_shapes": smartart_found  # non-empty = has SmartArt
        })

    return manifest

manifest = extract_text(sys.argv[1])
print(json.dumps(manifest, ensure_ascii=False, indent=2))

マニフェストを /tmp/pptx_manifest_{timestamp}.json に保存します。json.dumps(..., ensure_ascii=False) でシリアライズしてください。サブエージェントに送信するペイロードサイズを最小限にするため、indent パラメーターは指定しません。

抽出後、SmartArt を含むスライドを報告し、zip を使用してそのコンテンツを翻訳します。

import zipfile, os, json
from lxml import etree

smartart_slides = [s for s in manifest if s.get("smartart_shapes")]
if smartart_slides:
    print(f"\n⚠️  {len(smartart_slides)} slide(s) contain SmartArt — text stored in separate XML files:")
    for s in smartart_slides:
        print(f"   Slide {s['slide_num']}: {s['smartart_shapes']}")
    print("   SmartArt text will be translated via direct XML editing after write-back.")

    DRAWING_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'

    with zipfile.ZipFile(output_path, 'r') as zin:
        diagram_files = [f for f in zin.namelist()
                         if f.startswith('ppt/diagrams/data') and f.endswith('.xml')]
        file_roots = {}
        smartart_items = []
        for df in diagram_files:
            root = etree.fromstring(zin.read(df))
            file_roots[df] = root
            for i, t in enumerate(root.findall(f'.//{{{DRAWING_NS}}}t')):
                if t.text and t.text.strip():
                    smartart_items.append({"file": df, "node_idx": i, "text": t.text})

    if smartart_items:
        smartart_input = json.dumps(
            [{"i": idx, "t": item["text"]} for idx, item in enumerate(smartart_items)],
            ensure_ascii=False
        )
        with open("/tmp/smartart_input.json", "w") as f:
            f.write(smartart_input)
        print(f"   {len(smartart_items)} SmartArt text nodes — launching SmartArtTranslator agent…")

        # Launch SmartArtTranslator sub-agent (same prompt pattern as SlideTranslator):
        # Input: /tmp/smartart_input.json — list of {"i": idx, "t": "source text"}
        # Output: /tmp/smartart_output.json — list of {"i": idx, "t": "translated text"}
        # Apply same STRICT RULES (preserve proper nouns, whitespace, completeness)

        with open("/tmp/smartart_output.json") as f:
            smartart_translations = {item["i"]: item["t"] for item in json.load(f)}

        for df, root in file_roots.items():
            nodes = root.findall(f'.//{{{DRAWING_NS}}}t')
            for idx, item in enumerate(smartart_items):
                if item["file"] == df and idx in smartart_translations:
                    nodes[item["node_idx"]].text = smartart_translations[idx]

        tmp_smartart = output_path + ".sa.tmp"
        with zipfile.ZipFile(output_path, 'r') as zin:
            with zipfile.ZipFile(tmp_smartart, 'w', zipfile.ZIP_DEFLATED) as zout:
                for zi in zin.infolist():
                    if zi.filename in file_roots:
                        data = etree.tostring(file_roots[zi.filename],
                                              xml_declaration=True, encoding='UTF-8', standalone=True)
                    else:
                        data = zin.read(zi.filename)
                    zout.writestr(zi, data)
        os.replace(tmp_smartart, output_path)
        print(f"   ✅ SmartArt translations applied to {len(diagram_files)} diagram file(s)")
抽出後:AI による言語分類

翻訳対象のスライドを決める際に、正規表現や langdetect を使用してはいけません。 どちらの方法もハードコードされたルールが必要で、アクセント記号のない有効な単語(例:「nossa jornada」、「cenário」、「nosso time」)を誤って処理します。正規表現の単語リストは完全にはなりません。langdetect は短いテキストや複数の言語が混在するテキストを誤分類します。

モデル自体が言語をネイティブに理解しています。それを活用してください。抽出後、すべてのスライドのテキストを受け取り、各スライドについて JSON 形式の判定を返す単一の分類サブエージェントを起動します。ライブラリは使いません。単語をハードコードしません。偽陰性も発生しません。

分類サブエージェントのプロンプト

大規模なプレゼンテーションに関する注意: スライドが50枚を超える場合は、入力を50枚ずつのバッチに分割し、バッチごとに分類エージェントを1つ起動します。大きなペイロードはモデルのコンテキスト上限を超えることがあります。続行する前に、すべての結果を統合してください。

# SlideClassifier — Language Detection Agent

You are a language classifier. For each slide below, determine whether its text is written (fully or partially) in {SOURCE_LANGUAGE}.

Rules:
- A slide needs translation if ANY text block contains {SOURCE_LANGUAGE} content — even a single sentence or title
- A slide does NOT need translation only if ALL its text is already in {TARGET_LANGUAGE} or is language-neutral (numbers, dates, proper nouns, acronyms, code)
- Proper nouns, brand names, product names, and technical terms are language-neutral — do not use them as evidence of either language
- When in doubt, mark needs_translation: true — it is safer to translate an already-English slide than to skip a source-language one

Slides to classify:
{SLIDE_TEXTS_JSON}

Save your result to /tmp/pptx_classify_output.json as a JSON array (no explanation, no markdown fences):
[
  {"slide_num": 1, "needs_translation": true, "language": "pt", "reason": "Title contains Portuguese: 'A nova era das empresas'"},
  {"slide_num": 2, "needs_translation": false, "language": "en", "reason": "All text is in English"},
  ...
]

After saving, print: "✅ Classification complete: {N} slides need translation, {M} already in target language"

{SLIDE_TEXTS_JSON} はマニフェストから次のように構築します。

import json

slide_texts = []
for slide in manifest:
    combined = " | ".join(
        b["original_text"] for b in slide["text_blocks"] if b["original_text"].strip()
    )
    if combined.strip():
        slide_texts.append({"slide_num": slide["slide_num"], "text": combined})
    else:
        # No text content — images/charts only, skip
        slide["needs_translation"] = False

# Save for the classifier agent
with open("/tmp/pptx_classify_input.json", "w") as f:
    json.dump(slide_texts, f, ensure_ascii=False, indent=2)

分類エージェントは、結果を /tmp/pptx_classify_output.json に保存します。分類プロンプトでは、出力を必ずここに保存するようエージェントに明示的に指示してください。エージェントが保存先を推測することに頼ってはいけません。処理の完了後、解析に失敗した場合のフォールバックを適用して判定結果を反映します。

import json, os

try:
    with open("/tmp/pptx_classify_output.json") as f:
        decisions = {d["slide_num"]: d for d in json.load(f)}
except (FileNotFoundError, json.JSONDecodeError, KeyError):
    # Fallback: if classifier failed or returned invalid JSON, translate all slides with text
    print("⚠️  Classifier output invalid — falling back to translate-all-with-text strategy")
    decisions = {}
    for slide in manifest:
        if slide["text_blocks"]:
            slide["needs_translation"] = True
            slide["detected_language"] = "unknown"
        else:
            slide["needs_translation"] = False

for slide in manifest:
    decision = decisions.get(slide["slide_num"])
    if decision:
        slide["needs_translation"] = decision["needs_translation"]
        slide["detected_language"] = decision.get("language", "unknown")
        slide["detection_reason"] = decision.get("reason", "")
    # slides with no text content were already set to False above

この方法では、ハードコードされたパターンは一切不要で、あらゆる言語ペアに対応し、混在するスライド、固有名詞、短いタイトル、アクセント記号のないポルトガル語といったエッジケースも正しく処理できます。

✅ Extraction complete
   Slides found:       35
   Text blocks:        1911
   Slides to skip:     12 (AI classifier determined already in target language)
   Slides to translate: 23
   Speaker notes:      2 slides with notes
   Group shapes found: 4 slides with grouped content (will be recursed)

ステップ3:スライドごとの検証を伴う並列翻訳

[████████░░░░░░░░░░░░] 40% — Step 3/5: Translating slides in parallel
バッチ並列方式(1バッチ3枚、すべての環境で共通)

翻訳が必要なスライドを3枚ずつのバッチにまとめます。各バッチについて、エージェントのツール呼び出しを3つ同時に起動します。バッチの処理が完了してから、次のバッチを起動します。これは、すべてのプラットフォームでのデフォルト方式です。

3枚ずつのバッチにする理由: スライドごとにエージェントを1つ起動する方式では、Gemini CLI などのプラットフォームで内部ターン数の上限を超え、セッションが中断されることがあります。3枚ずつのバッチなら、並列処理を維持しながら、プラットフォームにかかわらず総ターン数を抑えられます。

Slides to translate: 18
Batches: 6  (slides 1-3, 4-6, 7-9, 10-12, 13-15, 16-18)
Agents per batch: 3 (parallel)
Total rounds: 6

各バッチのエージェントは次の処理を行います。

  1. 担当スライドのJSONテキストブロックを受け取ります
  2. 内容を翻訳します
  3. 言語理解を用いて自己検証します(ライブラリは使用しません)
  4. 検証に失敗した場合、自動的に1回再試行します
  5. 結果を /tmp/trans_slide_{N}.json に保存します
  6. ✅ Slide N/TOTAL translated — validation: {status} または ⚠️ Slide N: warning — {reason} を報告します
SlideTranslator エージェントの起動

各サブエージェントは、SlideTranslator という名前で起動する必要があります。

  • Claude Code(Agent ツール): description="SlideTranslator — slides X-Y/TOTAL" および model="sonnet" を設定します。翻訳品質が重要です。Sonnet は複雑な内容、法律関連の内容、技術的な内容を確実に処理します。Haiku は使用しないでください。密度の高いテキストや法律関連のテキストでは、翻訳が不完全になるためです。
  • その他すべてのプラットフォーム: プラットフォームのログでエージェントの名前が確認できるよう、エージェントのプロンプトを # SlideTranslator で始めます。品質の高い出力を得るため、セッションで利用できる最適なモデルを使用してください。
サブエージェントのプロンプト
# SlideTranslator — Professional Slide Translation Agent

You are a professional translator. Translate the following PowerPoint slide content from {SOURCE_LANGUAGE} to {TARGET_LANGUAGE}.

STRICT RULES:
- Preserve the meaning and tone of the original
- NEVER translate: proper nouns, personal names (people's names), company names, brand names, product names, technology names, organizational acronyms, or code snippets
  Examples of what NOT to translate: "Accenture", "Microsoft", "Bradesco", "João Silva", "Azure", "BLT", "YTD", "KPI", "CCI"
- ALWAYS translate generic role/concept words even if they look like titles: "Agente/Agentes" → "Agent/Agents", "Usuário" → "User", "Reunião" → "Meeting", etc.
- PRESERVE any leading or trailing whitespace in each run exactly. If the original run is " text ", your translation must also start and end with a space. Spaces are part of the formatting.
- COMPLETENESS CHECK: count the number of items in the input JSON. Your translated_blocks array MUST contain exactly the same number of items. If counts differ, you have missed blocks — find and translate them before saving.
- Return JSON with the same structure as input (shape_id, parent_id, para_idx, run_idx preserved — these are required for write-back)
- Translate speaker notes naturally, maintaining the presenter's voice

Slide {N}/{TOTAL}:
{JSON_TEXT_BLOCKS}

Speaker notes:
{NOTES_TEXT}

AFTER translating, self-validate using your own language understanding (no libraries needed):
- Read back all your translated_text values
- Confirm they are in {TARGET_LANGUAGE} and not in {SOURCE_LANGUAGE}
- If you find any block still in {SOURCE_LANGUAGE}, fix it before saving
- Set validation.status to "ok" if all blocks are correctly translated, or "warning" with a description if any issue remains

Save your result to /tmp/trans_slide_{N}.json in this format:
{
  "slide_num": N,
  "translated_blocks": [
    {"shape_id": X, "parent_id": Y_or_null, "para_idx": Z, "run_idx": W, "translated_text": "..."},
    {"shape_id": X, "parent_id": Y_or_null, "row_idx": R, "col_idx": C, "translated_text": "..."}
  ],
  "translated_notes": "...",
  "validation": {"status": "ok|warning", "detected_lang": "{TARGET_LANG_CODE}", "message": ""}
}

After saving, print: "✅ Slide {N}/{TOTAL} translated — validation: {status} ({detected_lang})"
進行状況の表示

各バッチの開始前に進捗ゲージを表示し、エージェントの処理が完了するたびに結果を出力します。

[████████░░░░░░░░░░░░] 40% — Batch 1/6: translating slides 1-3…
✅ Slide 1/18 translated — validation: ok (en)
✅ Slide 2/18 translated — validation: ok (en)
✅ Slide 3/18 translated — validation: ok (en)

[█████████████░░░░░░░] 55% — Batch 2/6: translating slides 4-6…
✅ Slide 4/18 translated — validation: ok (en)
⚠️ Slide 5/18 — warning (pt detected) — retried OK
✅ Slide 6/18 translated — validation: ok (en)

…

[████████████████████] Step 3/5 complete — all 18 slides translated
   Batches: 6  |  Warnings: 1 (auto-retried OK)  |  Failures: 0

重要: 再試行後にエージェントの出力に "status": "warning" が含まれている場合は、そのスライドを最終的な概要で手動確認が必要なものとして示してください。黙って無視してはいけません。

ステップ4:1回の処理で書き戻す

[████████████░░░░░░░░] 60% — Step 4/5: Writing translations to PPTX
セーフモード(デフォルト):元のファイルは自動的に保持されます

セーフモードでは、出力は必ずステップ1.5で決定された output_filename を使用して新しいファイルに保存されます(デフォルト:{stem}_{lang_code}.pptx)。元のファイルには一切変更を加えません。元のファイルがすでにバックアップになっているためです。余分な _backup_{timestamp}.pptx は作成されません。

YOLO モードのみ: ユーザーが明示的に YOLO を選択した場合、出力は元のファイルを上書きします。この場合は、先にバックアップを作成してください。

# YOLO mode only — Safe mode does NOT run this block
import shutil
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = pptx_path.replace(".pptx", f"_backup_{timestamp}.pptx")
shutil.copy2(pptx_path, backup_path)
print(f"✅ Backup created: {backup_path}")
書き戻しスクリプト

すべての /tmp/trans_slide_{N}.json ファイルを収集し、1回の処理で書き戻します。重要:抽出時と同じ再帰的な iter_shapes() 関数を使用してください。 書き戻し処理では、抽出時と同様に GROUP シェイプ内まで再帰的にたどる必要があります。

from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
import json, glob, os

def iter_shapes(shapes, parent_id=None):
    for shape in shapes:
        if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
            yield from iter_shapes(shape.shapes, parent_id=shape.shape_id)
        else:
            yield shape, parent_id

def write_translations(pptx_path, output_path, trans_dir):
    # Load all translation files
    translations_by_slide = {}
    for f in glob.glob(f"{trans_dir}/trans_slide_*.json"):
        with open(f) as fp:
            data = json.load(fp)
            translations_by_slide[data["slide_num"]] = data

    prs = Presentation(pptx_path)
    runs_updated = 0
    cells_updated = 0

    for slide_num, slide in enumerate(prs.slides, start=1):
        slide_data = translations_by_slide.get(slide_num)
        if not slide_data:
            continue  # Slide was skipped (already in target language)

        # Build lookup keyed by (parent_id, shape_id, para_idx, run_idx) for runs
        # and (parent_id, shape_id, row_idx, col_idx) for table cells
        run_map = {}
        cell_map = {}
        for b in slide_data["translated_blocks"]:
            parent_id = b.get("parent_id")
            shape_id = b["shape_id"]
            if "run_idx" in b:
                run_map[(parent_id, shape_id, b["para_idx"], b["run_idx"])] = b["translated_text"]
            elif "col_idx" in b:
                cell_map[(parent_id, shape_id, b["row_idx"], b["col_idx"])] = b["translated_text"]

        for shape, parent_id in iter_shapes(slide.shapes):
            if shape.has_text_frame:
                for para_idx, para in enumerate(shape.text_frame.paragraphs):
                    for run_idx, run in enumerate(para.runs):
                        key = (parent_id, shape.shape_id, para_idx, run_idx)
                        if key in run_map:
                            run.text = run_map[key]  # Only change text — preserve all font properties
                            runs_updated += 1

            if shape.has_table:
                for row_idx, row in enumerate(shape.table.rows):
                    for col_idx, cell in enumerate(row.cells):
                        key = (parent_id, shape.shape_id, row_idx, col_idx)
                        if key in cell_map:
                            translated_cell_text = cell_map[key]
                            tf = cell.text_frame
                            if tf.paragraphs:
                                # Split by newline to preserve multi-paragraph cells
                                cell_lines = translated_cell_text.split("\n")
                                for para_idx, para in enumerate(tf.paragraphs):
                                    for run in para.runs:
                                        run.text = ""
                                    if para_idx < len(cell_lines):
                                        if para.runs:
                                            para.runs[0].text = cell_lines[para_idx]
                                        else:
                                            para.add_run().text = cell_lines[para_idx]
                            cells_updated += 1

        # Write speaker notes — preserve paragraph structure for multiline notes
        if slide_data.get("translated_notes") and slide.has_notes_slide:
            notes_tf = slide.notes_slide.notes_text_frame
            if notes_tf:
                # Split translated notes by newline to restore paragraph structure
                translated_lines = slide_data["translated_notes"].split("\n")
                existing_paras = notes_tf.paragraphs

                for para_idx, para in enumerate(existing_paras):
                    # Clear all runs in this paragraph
                    for run in para.runs:
                        run.text = ""
                    # Write corresponding translated line if available
                    if para_idx < len(translated_lines):
                        if para.runs:
                            para.runs[0].text = translated_lines[para_idx]
                        else:
                            para.add_run().text = translated_lines[para_idx]

                # If translated text has MORE lines than existing paragraphs, append extras
                from copy import deepcopy
                if len(translated_lines) > len(existing_paras):
                    # Use the last paragraph as a template for new ones
                    last_para_xml = existing_paras[-1]._p
                    parent_elem = last_para_xml.getparent()
                    for extra_line in translated_lines[len(existing_paras):]:
                        new_para = deepcopy(last_para_xml)
                        # Clear runs in cloned paragraph and set text
                        for r in new_para.findall(".//{http://schemas.openxmlformats.org/drawingml/2006/main}r"):
                            t = r.find("{http://schemas.openxmlformats.org/drawingml/2006/main}t")
                            if t is not None:
                                t.text = extra_line
                            break
                        parent_elem.append(new_para)

    prs.save(output_path)
    return runs_updated, cells_updated
書き戻し後の処理:行頭・行末の空白を復元する

保存後、エージェントが削除した可能性のある行頭・行末の空白を復元するため、2回目の処理を実行します。翻訳後の各 run を元のテキストと比較します。

orig_prs = Presentation(pptx_path)
trans_prs = Presentation(output_path)
restored = 0

orig_runs_map = {}
for slide_idx, slide in enumerate(orig_prs.slides):
    for shape, parent_id in iter_shapes(slide.shapes):
        if shape.has_text_frame:
            for para_idx, para in enumerate(shape.text_frame.paragraphs):
                for run_idx, run in enumerate(para.runs):
                    orig_runs_map[(slide_idx, shape.shape_id, parent_id, para_idx, run_idx)] = run.text

for slide_idx, slide in enumerate(trans_prs.slides):
    for shape, parent_id in iter_shapes(slide.shapes):
        if shape.has_text_frame:
            for para_idx, para in enumerate(shape.text_frame.paragraphs):
                for run_idx, run in enumerate(para.runs):
                    key = (slide_idx, shape.shape_id, parent_id, para_idx, run_idx)
                    orig_text = orig_runs_map.get(key, '')
                    trans_text = run.text
                    if not orig_text or not trans_text:
                        continue
                    modified = trans_text
                    if orig_text[0] == ' ' and trans_text[0] != ' ':
                        modified = ' ' + modified
                    if orig_text[-1] == ' ' and trans_text[-1] != ' ':
                        modified = modified + ' '
                    if modified != trans_text:
                        run.text = modified
                        restored += 1

trans_prs.save(output_path)
print(f"✅ Edge whitespace restored: {restored} runs fixed")

書式の保持に関するルール:

  • 変更するのは run.text のみです。フォントサイズ、太字、斜体、色、ハイパーリンクには変更を加えません。
  • 表のセルの内容:既存の run を消去し、翻訳後のテキストを含む新しい run を1つ追加します。
  • シェイプの位置、サイズ、背景、画像には一切変更を加えません。
  • 画像シェイプは完全にスキップします(概要で報告します)。

ステップ 5:整合性チェック、クリーンアップ、概要(単一スクリプト)

[████████████████░░░░] 80% — Step 5/5: Finalizing

整合性チェック、クリーンアップ、最終的な概要の表示を実行する単一の Python スクリプトを実行します。これは 1 回のツール呼び出しで行う必要があります。別々のステップに分割しないでください。

from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
import glob, os, re

def iter_shapes_check(shapes, parent_id=None):
    for shape in shapes:
        if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
            yield from iter_shapes_check(shape.shapes, parent_id=shape.shape_id)
        else:
            yield shape, parent_id

# Integrity check
try:
    prs_check = Presentation(output_path)
    integrity_ok = True
except Exception as e:
    integrity_ok = False
    print(f"⚠️  Integrity check failed: {e}")

# Quality check — detect slides with residual source-language content
# Uses unambiguous source-language words unlikely to appear in target language
SOURCE_LANG_MARKERS = re.compile(
    r'\b(não|são|também|já|após|antes|pela|pelo|nosso|nossa|nossos|nossas'
    r'|temos|têm|será|serão|tinha|tinham|foram|deve|devem|pode|podem'
    r'|cliente|empresa|serviços|solução|equipe|reunião|processo|gestão'
    r'|fábrica|ágil|implementação|desenvolvimento|entrega|todos|todas'
    r'|quando|porque|assim|muito|entre|cada)\b',
    re.IGNORECASE
)
residual_slides = []
if integrity_ok:
    for slide_num, slide in enumerate(prs_check.slides, start=1):
        texts = []
        for shape, _ in iter_shapes_check(slide.shapes):
            if shape.has_text_frame:
                for para in shape.text_frame.paragraphs:
                    for run in para.runs:
                        if run.text.strip():
                            texts.append(run.text)
            if shape.has_table:
                for row in shape.table.rows:
                    for cell in row.cells:
                        if cell.text.strip():
                            texts.append(cell.text)
        combined = ' '.join(texts)
        matches = SOURCE_LANG_MARKERS.findall(combined)
        total_words = len(combined.split())
        if total_words > 0 and len(matches) >= 2 and len(matches) / total_words > 0.04:
            residual_slides.append(slide_num)

quality_ok = len(residual_slides) == 0

# Cleanup all temp files
for f in (glob.glob("/tmp/pptx_manifest_*.json") +
          glob.glob("/tmp/trans_slide_*.json") +
          glob.glob("/tmp/pptx_classify_*.json") +
          glob.glob("/tmp/smartart_*.json")):
    os.remove(f)

# Final summary
residual_note = f"None ✅" if quality_ok else f"{len(residual_slides)} slides ⚠️  → {residual_slides[:10]}"
print(f"""
[████████████████████] 100% — Done!

╔══════════════════════════════════════════════════════════════╗
║  TRANSLATION COMPLETE {'✅' if integrity_ok and quality_ok else '⚠️ REVIEW NEEDED'}
╠══════════════════════════════════════════════════════════════╣
║  Original file:      {pptx_path}
║  Translated file:    {output_path}
║  Slides translated:  {slides_translated}/{slides_total} ({slides_skipped} already in target language)
║  Text runs updated:  {runs_updated}
║  Table cells:        {cells_updated}
║  Language pair:      {source_lang} → {target_lang}
║  File integrity:     {'OK' if integrity_ok else 'FAILED — check file manually'}
║  Residual source:    {residual_note}
╚══════════════════════════════════════════════════════════════╝
""")

if residual_slides:
    print(f"⚠️  {len(residual_slides)} slide(s) may still contain source-language text.")
    print(f"   Slides: {residual_slides}")
    print(f"   Recommendation: open the file and verify these slides manually.")

エラー処理

エラー原因対応
python-pptx のインストール失敗制限された環境/pip がないユーザーに希望するインストール方法を 1 回だけ尋ねる
ファイルが見つからないパスが間違っているパスを確認するようユーザーに依頼する
無効な .pptxファイルの破損ユーザーに知らせ、PowerPoint で修復するよう提案する
空のスライド画像/グラフのみ何もせずスキップし、概要では「テキストコンテンツなし」と報告する
分類エージェントの失敗、または無効な JSON の返却モデル/ネットワークエラーすべてのテキストを翻訳する方式にフォールバックし、ユーザーに警告する
サブエージェントによる翻訳の失敗モデル/ネットワークエラー自動で 1 回再試行する。それでも失敗した場合はスライドをスキップして報告する
バックアップの作成失敗ディスク容量不足/読み取り専用変更を加える前に中止し、ユーザーに知らせる
GROUP 図形 ID の衝突同じ子 shape_id を持つグループが 2 つある常に (parent_id, shape_id) の複合キーを使用し、shape_id 単独では決して使用しない

重要なルール

  • ユーザーが YOLO モードを明示的に選択した場合を除き、元のファイルは絶対に変更しないでください
  • Safe モードでは元のファイルが自動的に保持されます(出力は新しいファイルです)。Safe モードで冗長な _backup_{timestamp}.pptx を作成しないでください。上書き前にバックアップを作成するのは YOLO モードのみです
  • 抽出と書き戻しの両方に、必ず再帰的な iter_shapes() を使用してください。slide.shapes を直接使用しないでください
  • 書き戻しの検索キーには必ず (parent_id, shape_id, ...) を使用してください。shape_id 単独では決して使用しないでください
  • 必ずバッチ並列処理を使用してください。スライドを 3 枚ずつのバッチに分け、各バッチを 3 つのエージェントで同時に処理し、完了を待ってから次のバッチを開始してください。すべてのスライドを一度に起動しないでください(プラットフォームのターン予算を使い切ります)。また、1 枚ずつ処理しないでください(遅すぎます)
  • Claude Code では、必ず model="sonnet" を指定して SlideTranslator エージェントを起動してください。品質が重要です。Haiku は密度の高いコンテンツ、法的コンテンツ、技術的コンテンツで不完全な翻訳を生成するため、使用してはいけません
  • JSON ペイロードは必ずインデントなしでシリアライズしてください(json.dumps(..., ensure_ascii=False))。JSON を最小化すると、大規模なプレゼンテーションでは入力トークンを約 30% 削減できます
  • すでに翻訳先の言語になっているスライドに、翻訳を通しただけの内容を作成しないでください。完全にスキップしてください
  • 翻訳するスライドの判定に正規表現パターンや langdetect を使用しないでください。AI 分類サブエージェントを使用してください。正規表現のリストは常に不完全であり、langdetect は短いテキストや複数言語が混在するテキストを誤分類します
  • スライドごとの needs_translation の判定には、必ず AI 分類サブエージェント(ステップ 2)を使用してください。モデルは言語を本来の能力で理解するため、ハードコードされたパターンは不要で、あらゆる言語ペアに対応できます
  • 分類エージェントが確信を持てない場合は、必ず翻訳が必要なスライドとして扱ってください。すでに英語のスライドを翻訳する方が(問題はありません)、原文の言語のスライドを見落とすより安全です
  • 固有名詞は絶対に翻訳しないでください。人名、会社名、ブランド名、製品名、技術名、組織の略称などが該当します(例:「Accenture」、「Microsoft」、「João Silva」、「Azure」、「BLT」、「YTD」)
  • 翻訳エージェント内で、スライドごとの自己検証(翻訳 → 読み返し → 修正 → 保存)を必ず実行してください。エージェント自身の言語理解を使用し、ライブラリは使用しません
  • ステップ 5 は必ず単一のスクリプトとして実行してください。整合性チェック、クリーンアップ、最終的な概要を 1 回のツール呼び出しで行い、別々のステップに分割しないでください
  • テキストの書式は必ず保持してください。変更するのは run.text のみとし、フォント、サイズ、色のプロパティは変更しないでください
  • ユーザーに確認を求めるのは、最初の設定確認を除き、1 回を超えてはいけません
  • このワークフローで指定されていない「プレビュー」、「デバッグ」、「チェック」用のシェルコマンドを即興で追加しないでください

使用例

例 1:標準的な PT→EN 翻訳
User: Translate this presentation from Portuguese to English: ~/docs/proposta_q4.pptx

Skill: [Installs deps silently if needed]
       [One config confirmation: PT→EN, Safe mode, speaker notes]

       How should the output file be named?
         [1] proposta_q4_en.pptx              ← original name + target suffix (default)
         [2] q4_proposal_en.pptx              ← filename translated to English (AI)
         [3] Custom name                       ← I'll type it myself
       Choice [1]: 2
       Suggested: q4_proposal_en.pptx — use this? [Y/n]: Y

       [Extracts 35 slides, 1911 blocks — 12 already in English, skipping]
       [Launches 23 parallel agents simultaneously]
       ✅ Slide  3/35 traduzido e validado
       ✅ Slide  7/35 traduzido e validado
       ✅ Slide 13/35 traduzido e validado (group shapes traversed)
       ...
       [Single write-back pass: 736 runs + 1175 table cells]
       ✅ Saved: ~/docs/q4_proposal_en.pptx
例 2:YOLO モードでの EN→PT 翻訳
User: Translate presentation.pptx to Portuguese, YOLO mode

Skill: [Config confirmation shows ⚠️ YOLO — original will be overwritten]
       [User confirms once]

       How should the output file be named?
         [1] presentation_pt.pptx             ← original name + target suffix (default)
         [2] apresentacao_pt.pptx             ← filename translated to Portuguese (AI)
         [3] Custom name                       ← I'll type it myself
       Choice [1]: 3
       Enter filename (with or without .pptx): Q1_Apresentacao_Final
       Output will be saved as: Q1_Apresentacao_Final.pptx — confirm? [Y/n]: Y

       [Translates in place, saves as Q1_Apresentacao_Final.pptx]
例 3:カスタム言語ペア
User: Translate ~/projects/pitch_es.pptx from Spanish to French

Skill: [AI classifier determines which slides are in Spanish]
       [Launches parallel agents for all non-French slides]
       ✅ Saved: ~/projects/pitch_es_fr.pptx
例 4:グループ図形を含むプレゼンテーション
User: Translate keynote.pptx to English

Skill: [Extraction finds 3 slides with GROUP shapes]
       [iter_shapes() recursively extracts all nested text boxes]
       [All group content included in translation agents]
       ✅ Slide 13/20 traduzido e validado (group shapes incluídos)
       ✅ All grouped text boxes translated correctly
例 5:複数の言語が混在する大規模なデッキ
User: Can you generate this PPT in English? deck.pptx

Skill: [Detects 15/40 slides already in English — skipped]
       [Launches 25 parallel agents for Portuguese slides]
       [Skip slides: 1, 5, 8, 12, 21-35 (already EN)]
       ✅ 25/40 slides translated, 15 skipped (already English)

コメント