Simple python-pptx translation loops miss grouped shapes, tables and speaker notes. This one exports every Japanese paragraph to a CSV you can review, writes the English back, then checks for leftover Japanese and text that no longer fits.

Konnichiwa! I'm AI Sensei LAB, and I share prompts and AI workflows for bilingual creators.

Disclosure: this post was drafted and edited with AI tools. #ABotWroteThis. The code below was run on October 25, 2026, with python-pptx 1.0.2 and Python 3.13.5 on a small Japanese sample deck we made up for testing. The "translation" step in the run uses a tiny offline glossary, not a real machine-translation service. The before/after check was rendered with LibreOffice, not PowerPoint, so spacing in PowerPoint may differ a little. No product links.

Someone hands you a Japanese deck and asks for "the same thing, in English, by Friday." Copy-pasting slide by slide works for five slides. For fifty, you want a script.

A first script often loops over slide.shapes, grabs shape.text_frame, translates, and writes back. It looks done. Then you open the result and find Japanese still sitting in a grouped diagram, every table untouched, and the speaker notes exactly as they were. And the English that did get translated is spilling out of its boxes.

This post fixes all four, in about 150 lines.

First: what Office's own Translate covers

Before writing code, check whether you need to. Microsoft's support page "Translate text into a different language" (checked October 25, 2026) says "The Translate feature is currently available for Word, Excel, OneNote, Outlook, and PowerPoint." For PowerPoint, the page describes translating text you select: highlight it, then Review > Translate. The whole-document steps on that same page are written for Word.

So if your deck is short, selecting text and translating in PowerPoint may be enough. The script is for when it isn't: long decks, many decks, or when you want a reviewable list of every string before anything gets written back.

How python-pptx sees text

The python-pptx docs ("Working with text") put it plainly: "Auto shapes and table cells can contain text. Other shapes can't." Text then sits in a three-level hierarchy: Shape.text_frame → TextFrame.paragraphs → _Paragraph.runs.

That one sentence explains the usual misses:

  • Group shapes hold no text themselves. Their children do, so you have to recurse into group.shapes.
  • Tables live inside a graphic frame. The frame has no text_frame; each cell.text_frame does.
  • Speaker notes aren't on the slide at all. They're on slide.notes_slide.notes_text_frame.

And one thing that isn't a miss but causes bad translations: runs. A single sentence with one bold word is split into three runs. Translate run by run and the machine sees 「アプリで」, 「申請」 and 「する」 as three separate fragments. Translate at paragraph level instead.

The sample deck

To test this, we built a three-slide Japanese sample deck for a fictional company's expense rules:

  • Slide 1: title and subtitle placeholders (the subtitle has a Shift+Enter line break), plus a speaker note.
  • Slide 2: a group of two fixed-size text boxes (one paragraph has a bold word in the middle, so it's three runs), a 3×2 table, and a note.
  • Slide 3: one fixed-size box with a long sentence, plus a note.

Step 1: Walk every text container

This part of pptx_jp2en.py yields every text frame in the deck with a stable key, plus the box size we'll need later for the overflow check.

"""Japanese .pptx -> English round-trip with python-pptx. python pptx_jp2en.py extract sample.pptx segments.csv python pptx_jp2en.py apply sample.pptx segments_en.csv sample_en.pptx python pptx_jp2en.py check sample_en.pptx [segments_en.csv] """ import csv, math, re, sys from copy import deepcopy from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE from pptx.enum.text import MSO_AUTO_SIZE from pptx.util import Emu, Pt JA = re.compile(r"[\u3040-\u30ff\u3400-\u9fff\uff00-\uffef]") # kana, kanji, full-width forms  def walk_shapes(shapes, prefix="", scale=1.0): """Yield (key, text_frame, width, height) for every text container, groups included.""" for shp in shapes: key = f"{prefix}{shp.shape_id}" if shp.shape_type == MSO_SHAPE_TYPE.GROUP: # child sizes are in the group's own coordinate space; convert back to slide EMU  ch_ext = shp._element.grpSpPr.find( "{http://schemas.openxmlformats.org/drawingml/2006/main}xfrm/" "{http://schemas.openxmlformats.org/drawingml/2006/main}chExt") s = scale * (shp.width / int(ch_ext.get("cx"))) if ch_ext is not None and int(ch_ext.get("cx")) else scale yield from walk_shapes(shp.shapes, key + ">", s) elif shp.has_text_frame: yield key, shp.text_frame, (shp.width or 0) * scale, (shp.height or 0) * scale elif getattr(shp, "has_table", False) and shp.has_table: tbl = shp.table for r, row in enumerate(tbl.rows): for c, cell in enumerate(row.cells): if cell.is_spanned: continue # merged-away cell; its text lives in the origin cell  yield f"{key}/r{r}c{c}", cell.text_frame, tbl.columns[c].width * scale, row.height * scale def walk_deck(prs): for n, slide in enumerate(prs.slides, start=1): for key, tf, w, h in walk_shapes(slide.shapes): yield f"{n}/{key}", tf, w, h if slide.has_notes_slide: yield f"{n}/notes", slide.notes_slide.notes_text_frame, 0, 0 # no box to overflow 

Keys look like 2/3>4: slide 2, group shape 3, child shape 4. Tables get 2/6/r1c1, and notes get 2/notes. Shape IDs don't change when python-pptx opens and saves the file, which is what lets the write-back find the same paragraph later. Don't edit the Japanese deck between extract and apply.

Two details that are easy to skip. Child shapes in a group are positioned in the group's own coordinate space, so the code scales their size by the group's ext / chExt ratio. And merged table cells report is_spanned on the cells that were merged away, so we skip those and keep the origin cell.

Step 2: Export to CSV, write back from CSV

def extract(src, out_csv): rows = [] for key, tf, _, _ in walk_deck(Presentation(src)): for i, p in enumerate(tf.paragraphs): text = p.text.replace("\v", "\n") # whole paragraph, not run by run; keep Shift+Enter breaks  if JA.search(text): rows.append({"key": key, "para": i, "ja": text, "en": ""}) with open(out_csv, "w", newline="", encoding="utf-8-sig") as f: w = csv.DictWriter(f, fieldnames=["key", "para", "ja", "en"]) w.writeheader(); w.writerows(rows) print(f"extracted {len(rows)} paragraphs -> {out_csv}") def set_paragraph_text(p, text): """Replace the paragraph's text, reusing the first run's formatting for all of it.""" rpr = p.runs[0]._r.find("{http://schemas.openxmlformats.org/drawingml/2006/main}rPr") p.text = text # python-pptx turns "\n" into a line break and drops the old runs  if rpr is not None: for r in p.runs: old = r._r.find(rpr.tag) if old is not None: r._r.remove(old) r._r.insert(0, deepcopy(rpr)) def apply(src, csv_path, dst): with open(csv_path, encoding="utf-8-sig") as f: todo = {(r["key"], int(r["para"])): r["en"].strip() for r in csv.DictReader(f)} prs = Presentation(src) done = skipped = 0 for key, tf, _, _ in walk_deck(prs): for i, p in enumerate(tf.paragraphs): en = todo.get((key, i)) if en and p.runs: set_paragraph_text(p, en); done += 1 elif (key, i) in todo: skipped += 1 prs.save(dst) print(f"wrote {done} paragraphs, {skipped} left empty in CSV -> {dst}") 

Why a CSV in the middle? Because someone should look at the translations before they go into a client deck, and a spreadsheet is the easiest place to do that. It also means the translation step can be anything: a reviewer typing, a machine-translation service your company has approved, or an LLM.

The write-back replaces the whole paragraph and copies the first run's formatting (font, size, color, bold) onto the new text. Assigning to paragraph.text turns each \n (or \v) into a line break, per the python-pptx API docs, so a Shift+Enter break in the Japanese survives if the translation keeps the \n. Our sample's subtitle has one, and it came through.

The trade-off is real, and we hit it in our run: 「② アプリで申請する」 came back as "2. Submit it in the app" with no bold, because the bold word was the second run. If emphasis matters, fix those few paragraphs by hand after the run.

Step 3: Translate the CSV

Here's the translation step. In our run, translate() is a dictionary lookup against a small glossary file, so the whole thing runs offline with no API key. Swap in whatever engine you're allowed to use; the rest of the pipeline doesn't care.

"""Fill the 'en' column. Swap translate() for any engine you are allowed to use. This demo uses a small glossary file so it runs offline with no API key.""" import csv, json, sys GLOSSARY = json.load(open("glossary.json", encoding="utf-8")) def translate(ja: str) -> str: """Return English for one paragraph, or "" to leave it for a human.""" return GLOSSARY.get(ja, "") src, dst = sys.argv[1], sys.argv[2] with open(src, encoding="utf-8-sig") as f: rows = list(csv.DictReader(f)) for r in rows: if not r["en"]: r["en"] = translate(r["ja"]) with open(dst, "w", newline="", encoding="utf-8-sig") as f: w = csv.DictWriter(f, fieldnames=rows[0].keys()); w.writeheader(); w.writerows(rows) print(f"{sum(1 for r in rows if r['en'])}/{len(rows)} translated -> {dst}") 

We left one speaker note out of the glossary on purpose, to see whether the checks would catch it.

Here's what the extract found in the sample deck: 16 paragraphs, including both group children, all six table cells and three notes.

key,para,ja,en 1/2,0,新しい経費精算ルール, 1/3,0,"架空の会社 サンプル商事 総務部", 1/notes,0,最初に、変更は11月1日からだと伝えてください。, 2/2,0,申請の流れ, 2/3>4,0,① 領収書を撮影, 2/3>5,0,② アプリで申請する, 2/6/r0c0,0,項目, 2/6/r0c1,0,上限, 2/6/r1c0,0,交通費, 2/6/r1c1,0,"1回5,000円まで", 2/6/r2c0,0,会議費, 2/6/r2c1,0,"1人3,000円まで", 2/notes,0,表の金額は架空の例です。, 3/2,0,注意点, 3/3,0,申請は利用日から30日以内に行い、期限を過ぎた場合は上長の承認が必要です。, 3/notes,0,質問は総務部のチャットで受け付けます。, 

And the first three commands of the run:

$ python3 pptx_jp2en.py extract sample.pptx segments.csv extracted 16 paragraphs -> segments.csv $ python3 translate_csv.py segments.csv segments_en.csv 15/16 translated -> segments_en.csv $ python3 pptx_jp2en.py apply sample.pptx segments_en.csv sample_en.pptx wrote 15 paragraphs, 1 left empty in CSV -> sample_en.pptx 

Step 4: Check for leftover Japanese and overflow

python-pptx doesn't lay out text, so it can't tell you whether a line fits. What it can tell you is the box size, the font size (when it's set on the run) and whether the box wraps. That's enough for a rough estimate: count Japanese characters as about 1 em wide and everything else as about 0.5 em, then work out lines and height.

def est_width_em(text): # rough: full-width (Japanese) chars ~1 em, everything else ~0.5 em  return sum(1.0 if JA.match(ch) else 0.5 for ch in text) def fit(tf, width_emu, default_pt=18): """Rough estimate: (height needed, widest unwrapped line, usable width, size guessed?).""" usable = width_emu - (tf.margin_left + tf.margin_right) wraps = tf.word_wrap is not False # wrap="none" boxes never wrap: text runs sideways  height = widest = 0.0 guessed = False for p in tf.paragraphs: size = next((r.font.size for r in p.runs if r.font.size), None) if size is None: size, guessed = Pt(default_pt), True # inherited from layout/master; not resolved here  for line in p.text.split("\v"): # each Shift+Enter line wraps on its own  line_w = est_width_em(line) * size widest = max(widest, line_w) n = max(1, math.ceil(line_w / usable)) if (line and wraps) else 1 height += n * size * 1.2 return height + tf.margin_top + tf.margin_bottom, (widest if not wraps else 0), usable, guessed def check(path, csv_path=None): prs = Presentation(path) ja_em = {} if csv_path: # original Japanese widths, to print a before/after ratio  with open(csv_path, encoding="utf-8-sig") as f: for r in csv.DictReader(f): ja_em[r["key"]] = ja_em.get(r["key"], 0) + est_width_em(r["ja"]) left = flags = 0 for key, tf, w, h in walk_deck(prs): text = " ".join(p.text.replace("\v", " ") for p in tf.paragraphs) if JA.search(text): left += 1 print(f"LEFTOVER JA {key}: {text}") if not w or not h: continue # notes pages have no box to overflow  need, wide, usable, guessed = fit(tf, w) if need <= h and wide <= usable: continue flags += 1 ratio = est_width_em(text) / ja_em[key] if ja_em.get(key) else None if "/r" in key or tf.auto_size == MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT: note = " [box grows: check what sits below]" # table rows grow too  elif tf.auto_size == MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE: note = " [shrink-on-overflow: check the font is still readable]" elif tf.auto_size == MSO_AUTO_SIZE.NONE: note = " [fixed box: text will spill]" else: note = " [autofit inherited from layout: look at it in PowerPoint]" what = (f"~{Emu(int(wide)).inches:.2f} in wide on one line, box is {Emu(int(w)).inches:.2f} in" if wide > usable else f"needs ~{Emu(int(need)).inches:.2f} in, box is {Emu(int(h)).inches:.2f} in") print(f"CHECK SIZE {key}: {what}" + (f", text {ratio:.1f}x wider than the Japanese" if ratio else "") + (" [font size inherited: assumed 18 pt]" if guessed else Source:https://dev.to/crypteasensei/translate-a-japanese-powerpoint-pptx-to-english-with-python-pptx-grouped-shapes-tables-3cc

Related posts

I Launched My Company Website, Made Task Monitor Free, and Opened Custom Project Requests

I Launched My Company Website, Made Task Monitor Free, and Opened Custom Project Requests

dev.to AI
How I turn Hacker News, GitHub and npm into product ideas every 3 hours

How I turn Hacker News, GitHub and npm into product ideas every 3 hours

dev.to AI
Lenovo's 'Encrypted' Fingerprint USB Drive Is an SD Card. How to Check Yours

Lenovo's 'Encrypted' Fingerprint USB Drive Is an SD Card. How to Check Yours

dev.to Linux
Email: OpenAI plans to merge ChatGPT's Chat and Work tabs on desktop, rolling out to select users before a wider release; Codex remains a separate experience (Ivan Mehta/TechCrunch)

Email: OpenAI plans to merge ChatGPT's Chat and Work tabs on desktop, rolling out to select users before a wider release; Codex remains a separate experience (Ivan Mehta/TechCrunch)

Techmeme
Memo: Alphabet is issuing €6B in euro-denominated debt across seven tranches, attracting €17B+ of demand; the company sold €9B of bonds earlier in 2026 (Reuters)

Memo: Alphabet is issuing €6B in euro-denominated debt across seven tranches, attracting €17B+ of demand; the company sold €9B of bonds earlier in 2026 (Reuters)

Techmeme
S-1: Anthropic reports Q3 revenue up 12x YoY to nearly $17B, a net loss of $23.3B, up from $12.1B in Q3 2025, and operating expenses up 292% to $17.5B (Bailey Lipschultz/Bloomberg)

S-1: Anthropic reports Q3 revenue up 12x YoY to nearly $17B, a net loss of $23.3B, up from $12.1B in Q3 2025, and operating expenses up 292% to $17.5B (Bailey Lipschultz/Bloomberg)

Techmeme