关于 pptx-translator
pptx-translator 是用于翻译 PowerPoint 演示文稿的技能,可在保留格式、布局、字体和结构的同时转换语言。它面向处理 `.pptx` 文件的用户,也支持翻译演讲者备注。
主要功能
- 01支持多种语言互译
- 02保留幻灯片布局与格式
- 03同时翻译演讲者备注
- 04递归处理组合图形
- 05逐页验证并自动重试一次
适用场景
- 01将演示文稿翻译成其他语言
- 02翻译幻灯片及演讲者备注
- 03本地化幻灯片并保留格式
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ ericgandrade/ claude-superskills --skill pptx-translator安装后在 Claude Code 中输入/pptx-translator确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.mdevals/evals.jsontrigger-eval.json详细介绍
pptx-translator
用途
此技能可在任意两种语言之间翻译 PowerPoint 演示文稿(.pptx),同时保留原始格式、布局、字体和结构。它采用并行子 Agent 翻译(每张幻灯片由一个 Agent 负责)、逐张幻灯片验证并自动重试、递归遍历组合形状,以及一次快速写回。
何时使用此技能
- 用户提供了一个 .pptx 文件,并希望将其翻译成另一种语言
- 用户需要将 PowerPoint 从葡萄牙语翻译成英语,或反向翻译
- 用户需要在任意语言对之间翻译幻灯片(西班牙语、法语、德语、日语等)
- 用户希望连同幻灯片内容一起翻译演讲者/演示者备注
- 用户要求“翻译”、“traduzir”或“转换语言”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:一次性综合确认
根据用户的请求推断所有参数。只询问确实不明确的内容。然后显示一个确认框,并在用户确认后立即继续。不要再次询问。
需要推断或确认的参数:
- 源文件路径——验证文件存在且扩展名为
.pptx - 源语言——根据用户的请求推断(例如“从葡萄牙语翻译”);如果未说明,AI 分类器子 Agent 将在步骤 2 中自动判断
- 目标语言——根据请求推断;如果未指定,则询问
- 备份模式——默认采用 Safe(将输出另存为新文件);只有用户明确提出时才使用 YOLO
- 演讲者备注——默认是(与幻灯片内容一并翻译)
- 输出文件名——在步骤 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." - 继续之前显示建议:
建议:{translated_stem}_{lang_code}.pptx — 使用此名称?[Y/n] - 如果确认 →
output_filename = {translated_stem}_{lang_code}.pptx - 如果拒绝 → 静默回退到选项 1
选项 3 — 自定义名称:
- 提示:
输入文件名(可带或不带 .pptx): - 规范化:移除开头的路径分隔符,确保扩展名为
.pptx,将空格替换为_ - 确认:
输出将保存为:{custom_name} — 确认?[Y/n] - 如果确认 →
output_filename = {custom_name} - 如果拒绝 → 重新显示 3 个选项的菜单
不同语言对的示例:
| 原始文件 | 方向 | 选项 1 | 选项 2(AI) |
|---|---|---|---|
proposta_comercial.pptx | PT→EN | proposta_comercial_en.pptx | commercial_proposal_en.pptx |
proposta_comercial.pptx | PT→FR | proposta_comercial_fr.pptx | proposition_commerciale_fr.pptx |
proposta_comercial.pptx | PT→DE | proposta_comercial_de.pptx | geschaeftsvorschlag_de.pptx |
quarterly_review.pptx | EN→PT | quarterly_review_pt.pptx | revisao_trimestral_pt.pptx |
marketing_plan.pptx | EN→ES | marketing_plan_es.pptx | plan_de_marketing_es.pptx |
将解析后的名称存储为 output_filename。在第 4 步和第 5 步中将其用作输出路径。
第 2 步:提取并分析幻灯片内容
[████░░░░░░░░░░░░░░░░] 20% — Step 2/5: Extracting slide content
使用 python-pptx 提取所有可翻译内容。重要:使用递归 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 参数,以尽量减少发送给子 Agent 的载荷大小。
提取后,报告包含 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 会错误分类较短文本或混合语言文本。
模型本身已经能够原生理解语言——使用它。提取后,启动一个分类子 Agent,让它接收所有幻灯片文本,并为每张幻灯片返回一个 JSON 判定。不使用库。不使用硬编码词语。不会漏判。
分类子 Agent 提示词
大型演示文稿注意事项: 如果演示文稿超过 50 张幻灯片,请将输入拆分为每批 50 张,并为每批启动一个分类 Agent — 大型负载可能超出模型上下文限制。合并所有结果后再继续。
# 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)
分类 Agent 会将结果保存到 /tmp/pptx_classify_output.json。分类提示词必须明确指示 Agent 将输出保存到该路径 — 不要依赖 Agent 自行推断。完成后,应用分类结果,并在解析失败时使用回退方案:
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
分批并行策略(每批 3 张幻灯片,通用)
将需要翻译的幻灯片分组,每批 3 张。为每批启动 3 个并行的 Agent 工具调用。等待该批完成后,再启动下一批。这是所有平台的默认策略。
为何每批 3 张: 每张幻灯片分配一个 Agent 可能会超出 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
每批中的每个 Agent:
- 接收其负责幻灯片的 JSON 文本块
- 翻译内容
- 使用语言理解能力自行验证(不使用库)
- 如果验证失败,自动重试一次
- 将结果保存到
/tmp/trans_slide_{N}.json - 报告
✅ Slide N/TOTAL translated — validation: {status}或⚠️ Slide N: warning — {reason}
启动 SlideTranslator Agent
每个子 Agent 都必须以 SlideTranslator 的身份启动:
- Claude Code(Agent 工具): 设置
description="SlideTranslator — slides X-Y/TOTAL"和model="sonnet"。翻译质量很重要 — Sonnet 能可靠处理复杂、法律和技术内容。不要使用 Haiku;处理密集或法律文本时,它会产出不完整的翻译。 - 所有其他平台: 子 Agent 提示词以
# SlideTranslator开头,以便在平台日志中显示身份。为保证输出质量,请使用会话中可用的最佳模型。
子 Agent 提示词
# 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})"
进度显示
在每批开始前显示实时进度条,并在 Agent 完成时打印结果:
[████████░░░░░░░░░░░░] 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
重要: 如果任何 Agent 在重试后输出中包含 "status": "warning",请在最终摘要中标记这些幻灯片需要人工审查 — 不要默默忽略。
步骤 4:一次性写回
[████████████░░░░░░░░] 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 文件,然后一次性写入。关键:使用相同的递归 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
写回后处理:恢复边缘空白
保存后,执行第二轮处理,恢复 Agent 可能删除的前导或尾随空格。将每个翻译后的文本运行与原文进行比较:
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— 字体大小、粗体、斜体、颜色和超链接均不变 - 表格单元格内容:清除现有文本运行,然后添加包含翻译文本的单个新文本运行
- 绝不修改形状的位置、大小、背景和图像
- 完全跳过图像形状(在摘要中报告)
步骤 5:完整性检查、清理与摘要(单个脚本)
[████████████████░░░░] 80% — Step 5/5: Finalizing
运行单个 Python 脚本,执行完整性检查和清理,并打印最终摘要。这必须是一次工具调用,不能拆分为多个步骤。
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 | 仅询问用户一次首选的安装方式 |
| 找不到文件 | 路径错误 | 请用户确认路径 |
| .pptx 无效 | 文件已损坏 | 告知用户;建议在 PowerPoint 中修复 |
| 幻灯片为空 | 仅包含图片/图表 | 静默跳过;在摘要中报告为“无文本内容” |
| 分类器 Agent 失败或返回无效 JSON | 模型/网络错误 | 回退到“翻译所有文本”策略;警告用户 |
| 子 Agent 翻译失败 | 模型/网络错误 | 自动重试一次;若仍失败,则跳过该幻灯片并报告 |
| 创建备份失败 | 磁盘已满/只读 | 在进行任何修改前中止;告知用户 |
| GROUP 形状 ID 冲突 | 两个组具有相同的子形状 shape_id | 使用 (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 个并行 Agent,等待完成后再启动下一批——绝不一次启动所有幻灯片(会耗尽平台轮次预算),也绝不一次处理一张幻灯片(太慢)
- 在 Claude Code 上启动 SlideTranslator Agent 时,始终使用
model="sonnet"——质量至关重要;Haiku 在密集、法律或技术内容上会产生不完整的翻译,绝不能使用 - 始终使用不带缩进的方式序列化 JSON 载荷(
json.dumps(..., ensure_ascii=False))——压缩后的 JSON 可将大型演示文稿的输入 token 减少约 30% - 对于已是目标语言的幻灯片,绝不创建原样转译;直接跳过
- 绝不使用正则表达式模式或
langdetect来决定翻译哪些幻灯片——使用 AI 分类子 Agent;正则表达式列表总是不完整,而langdetect会错误判断较短或混合语言的文本 - 始终使用 AI 分类子 Agent(步骤 2)确定每张幻灯片的
needs_translation——模型能原生理解语言,无需硬编码模式,并能处理任意语言对 - 分类器不确定时,始终将幻灯片视为需要翻译——翻译一张已是英文的幻灯片(没有坏处)比漏掉一张源语言幻灯片更安全
- 绝不翻译专有名词:人名、公司名、品牌名、产品名、技术名称、组织缩写(例如“Accenture”、“Microsoft”、“João Silva”、“Azure”、“BLT”、“YTD”)
- 始终在每个翻译 Agent 中进行逐张幻灯片自我验证(翻译 → 读回 → 修正 → 保存)——Agent 使用自身的语言理解能力,不使用库
- 始终将步骤 5 作为单个脚本运行:在一次工具调用中完成完整性检查、清理和最终摘要——绝不拆分为多个步骤
- 始终保留文本格式——只修改
run.text,绝不修改字体/大小/颜色属性 - 除初始配置确认外,绝不向用户请求超过一次确认
- 绝不添加此工作流未指定的临时“预览”、“调试”或“检查”Shell 命令
使用示例
示例 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)