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; eachcell.text_framedoes. -
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 "")
+ note
+ f"\n {text[:70]}")
print(f"{left} container(s) still contain Japanese; {flags} may need resizing or shorter English")
if __name__ == "__main__":
cmd, *args = sys.argv[1:]
{"extract": extract, "apply": apply, "check": check}[cmd](*args)
The output on our translated sample deck:
$ python3 pptx_jp2en.py check sample_en.pptx segments_en.csv
CHECK SIZE 2/3>4: ~4.17 in wide on one line, box is 3.30 in, text 1.8x wider than the Japanese [fixed box: text will spill]
1. Photograph the receipt
CHECK SIZE 2/3>5: ~3.83 in wide on one line, box is 3.30 in, text 1.3x wider than the Japanese [fixed box: text will spill]
2. Submit it in the app
CHECK SIZE 3/3: needs ~1.43 in, box is 1.20 in, text 1.5x wider than the Japanese [fixed box: text will spill]
Submit your claim within 30 days of the expense. After that deadline,
LEFTOVER JA 3/notes: 質問は総務部のチャットで受け付けます。
1 container(s) still contain Japanese; 3 may need resizing or shorter English
The same check on the original Japanese deck reported 0 boxes to resize, so all three flags were caused by the English.
We rendered both decks to PNG with LibreOffice to see whether the flags were real:
-
Slide 2, both group boxes: real. Those boxes don't wrap (python-pptx 1.0.2's
add_textbox()wrotewrap="none"for them, and we never turned wrapping on), so "1. Photograph the receipt" ran past the edge of its box and almost into the next one. - Slide 3, long sentence: real. The Japanese fit in three lines; the English needed four, and the last line landed at the bottom edge of the box.
- Table cells: not flagged, and in the render none of them wrapped.
- Leftover Japanese: the one note we left out of the glossary, exactly as expected.
Two things we got wrong on the way, because they're useful to know:
- Our first version counted Latin characters at 0.55 em and ignored
wrap="none". It flagged two table cells that rendered fine, and it described the group boxes as "too tall" when the real problem was width. Switching to 0.5 em and checking unwrapped width separately fixed both. - We also started with a simple rule: flag any box where the English is more than 1.6× wider than the Japanese. On its own, that rule flagged big title boxes with plenty of room to spare. The ratio is still printed, but as context, not as the trigger.
Treat the output as a list of places to look, not a verdict. Fonts inherited from the slide layout aren't resolved here (the script assumes 18 pt and says so), and real fonts vary in width.
What to do with the flags
For each CHECK SIZE line, you have three options, roughly in order of preference:
-
Shorten the English. "Things to Watch" beats "Points to Be Careful About." Do this in the CSV and re-run
apply. - Resize the box in PowerPoint, if the layout has room.
- Turn on shrink-to-fit for that box. It works, but text that shrinks a lot gets hard to read.
For LEFTOVER JA lines, fill the empty en cell in the CSV and run apply again. Because the write-back works from the original deck each time, re-running is safe.
Limits worth knowing
- Text in charts, SmartArt and images isn't reached by this script.
- Mixed formatting inside one paragraph collapses to the first run's style, as above.
- Slide layouts and masters can contain text too (footers, placeholder prompts). This script only walks the slides themselves.
- The overflow estimate is approximate by design. Open the result in PowerPoint before you send it.
- For a merged table cell, the size check uses only the first column's width, so it may flag a merged cell that actually fits. Our sample deck had no merged cells.
Related (Dev.to only)
- Translate a Japanese Excel file to English without breaking formulas
- Translate a Japanese i18next JSON file to English with AI without losing placeholders
- How to translate SRT subtitles with ChatGPT (Japanese to English) without breaking the timing
If your deck breaks this in a new way (SmartArt, vertical text, merged cells across a whole row), leave a comment with the edge case. Please don't post a sample that contains confidential data.


Top comments (0)