add_notes_pptx.py ダウンロード/コピー
add_notes_pptx.py
add_notes_pptx.py
1# -*- coding: utf-8 -*-
2"""
3PowerPointノート処理スクリプト
4
5このスクリプトは、PowerPointファイル(.pptx)に対して以下の2つの主要なモードで動作します。
61. **addモード**: 各スライドのノートを読み込み、指定されたスタイルと位置で半透明の字幕テキストボックスとしてスライドに追加します。
72. **replaceモード**: スライド内の `{{key}}` 形式のプレースホルダーを、指定されたTOML風ファイルと各スライドのノート内容で置換します。
8 このモードでは、既存のテキスト書式やアニメーションを保持しながらテキストを部分置換します。
9
10:doc:`add_notes_pptx_usage`
11"""
12import os
13import re
14import argparse
15import win32com.client
16from win32com.client import constants
17
18
19def terminate():
20 """
21 プログラムを終了する前にユーザー入力を待つ。
22
23 ユーザーがEnterキーを押すまで待機し、その後プログラムを終了する。
24
25 :returns: None: プログラムが終了するため、戻り値はない。
26 """
27 input("\nPress ENTER to terminate>>\n")
28 exit()
29
30def parse_args():
31 """
32 コマンドライン引数を解析する。
33
34 `argparse`モジュールを使用して、入力ファイル、出力ファイル、モード、
35 および各モードに固有の設定(字幕の見た目、置換ファイルパスなど)を定義し、解析する。
36 出力ファイル名が指定されていない場合は、入力ファイル名に基づいて自動生成される。
37
38 :returns: argparse.Namespace: 解析された引数を格納したオブジェクト。
39 """
40 parser = argparse.ArgumentParser(description="PowerPointノート処理(字幕追加 or {{key}}置換)")
41 parser.add_argument("infile", help="入力PowerPointファイル(.pptx)")
42 parser.add_argument("-o", "--outfile", help="出力ファイル名(省略時は -note-added.pptx / -replaced.pptx)")
43 parser.add_argument("--mode", choices=["add", "replace"], default="add",
44 help="add: ノートを字幕テキストボックスとして追加 / replace: 置換ファイル+ノート(supertitle)で{{key}}置換")
45 parser.add_argument("--replace", "-R", type=str,
46 help="[mode=replace] 置換用のTOML風 key=value ファイル(例: title=Hello)")
47
48 # add モード用の見た目設定
49 parser.add_argument("--box_margin", type=int, default=50, help="字幕ボックスの左右余白(pt)")
50 parser.add_argument("--box_height", type=int, default=60, help="字幕ボックスの高さ(pt)")
51 parser.add_argument("--font_name", default="メイリオ", help="フォント名")
52 parser.add_argument("--font_size", type=int, default=12, help="フォントサイズ(pt)")
53 parser.add_argument("--font_color", default="884444", help="文字色(16進RGB)")
54 parser.add_argument("--bgcolor", default="00dddd", help="背景色(16進RGB)")
55 parser.add_argument("--bgalpha", type=float, default=0.6, help="背景の透明度(0.0〜1.0)")
56
57 args = parser.parse_args()
58
59 if not args.outfile:
60 base, ext = os.path.splitext(args.infile)
61 if args.mode == "add":
62 args.outfile = f"{base}-note-added.pptx"
63 else:
64 args.outfile = f"{base}-replaced.pptx"
65
66 return args
67
68# -----------------------------
69# 共通: ノート本文取得
70# -----------------------------
71def get_notes_text(slide):
72 """
73 スライドのノート本文(Placeholder Body)を返す。無ければ空文字。
74
75 指定されたPowerPointスライドのノートページにあるプレースホルダー
76 (通常は2番目のBodyプレースホルダー)からテキストを抽出する。
77 ノートが存在しない場合は空文字列を返す。
78
79 :param slide: win32com.client.Dispatch: ノートを取得する対象のスライドオブジェクト。
80 :returns: str: ノート本文のテキスト。ノートが存在しない場合は空文字列。
81 """
82 try:
83 # ppPlaceholderBody=2
84 # text = slide.NotesPage.Shapes.Placeholders(2).TextFrame.TextRange.Text.strip() # 変更前
85 text = slide.NotesPage.Shapes.Placeholders(2).TextFrame.TextRange.Text
86 return text # 変更後:clean_text()の呼び出しを削除
87 except Exception:
88 return ""
89
90# -----------------------------
91# add: ノートを字幕ボックスとして追加
92# -----------------------------
93def add_subtitles_with_transparency(args):
94 """
95 PowerPointスライドのノートを字幕テキストボックスとして追加する。
96
97 各スライドのノート内容を読み込み、指定されたスタイルと位置で半透明の
98 テキストボックスをスライド下部に追加する。字幕ボックスの見た目(余白、高さ、
99 フォント、色、背景色、透明度)はコマンドライン引数で設定可能。
100
101 :param args: argparse.Namespace: コマンドライン引数オブジェクト。
102 :returns: int: 処理が成功した場合は0。ファイル保存エラーが発生した場合も0を返す。
103 """
104 print()
105 print(f"Add subtitles (mode={args.mode}):")
106 print(f"{args.infile=}")
107 print(f"{args.outfile=}")
108 print(f"{args.box_margin=}")
109 print(f"{args.box_height=}")
110 print(f"{args.font_name=}")
111 print(f"{args.font_size=}")
112 print(f"{args.font_color=}")
113 print(f"{args.bgcolor=}")
114 print(f"{args.bgalpha=}")
115
116 ppt = win32com.client.Dispatch("PowerPoint.Application")
117 ppt.Visible = True
118
119 args.infile = os.path.abspath(args.infile)
120 args.outfile = os.path.abspath(args.outfile)
121 print(f"\n{args.infile=}")
122 print(f"{args.outfile=}")
123
124 presentation = ppt.Presentations.Open(args.infile, WithWindow=False)
125
126 slide_width = presentation.PageSetup.SlideWidth
127 slide_height = presentation.PageSetup.SlideHeight
128
129 textbox_width = slide_width - 2 * args.box_margin
130 textbox_left = args.box_margin
131 textbox_top = slide_height - args.box_height - 20 # 下部に配置(余白20pt)
132 textbox_height = args.box_height
133
134 font_rgb = int(args.font_color, 16)
135 bg_rgb = int(args.bgcolor, 16)
136
137 for slide in presentation.Slides:
138 note_text = get_notes_text(slide)
139 if not note_text:
140 continue
141
142#空行を削除
143 lines = [line for line in note_text.splitlines() if line.strip()] # 変更後
144 note_text = '\n'.join(lines)
145
146 shape = slide.Shapes.AddTextbox(
147 Orientation=1,
148 Left=textbox_left,
149 Top=textbox_top,
150 Width=textbox_width,
151 Height=textbox_height
152 )
153 shape.TextFrame.WordWrap = True
154 shape.TextFrame.TextRange.Text = note_text
155
156 text_range = shape.TextFrame.TextRange
157 text_range.Font.Name = args.font_name
158 text_range.Font.Size = args.font_size
159 text_range.Font.Color.RGB = font_rgb
160 text_range.ParagraphFormat.Alignment = 1 # ppAlignLeft
161
162 shape.Fill.ForeColor.RGB = bg_rgb
163 shape.Fill.Transparency = args.bgalpha
164 shape.Fill.Visible = True
165 shape.Fill.Solid()
166
167 try:
168 presentation.SaveAs(args.outfile)
169 except:
170 print(f"\nError: ファイル[{args.outfile}]への保存に失敗しました\n")
171 presentation.Close()
172 return 0
173
174 presentation.Close()
175 ppt.Quit()
176 print(f"保存しました: {args.outfile}")
177 return 0
178
179# -----------------------------
180# replace: 置換ファイル読み込み(TOML風 key=value)
181# -----------------------------
182def parse_toml_style(filepath):
183 """
184 とても寛容な key=value 形式を読み込む。
185
186 指定されたファイルパスからTOML風のkey=value形式の設定を読み込み、辞書として返す。
187 - 行頭/行末の空白を許容する。
188 - `#`で始まる行をコメントとして無視する。
189 - 値の両端の `'` または `"` を剥がして扱う。
190 ファイルが存在しない場合は警告を表示し、空辞書を返す。
191
192 :param filepath: str: 読み込むTOML風設定ファイルのパス。
193 :returns: dict: 解析されたキーと値のペアを含む辞書。ファイルが見つからない、またはパスが指定されない場合は空辞書。
194 """
195 if not filepath:
196 return {}
197 if not os.path.isfile(filepath):
198 print(f"Warning: replace ファイルが見つかりません: {filepath}")
199 return {}
200
201 pattern = re.compile(r'^\s*([a-zA-Z0-9_]+)\s*=\s*(.*?)\s*(?:#.*)?$')
202 result = {}
203 with open(filepath, "r", encoding="utf-8") as f:
204 for line in f:
205 line = line.strip()
206 if not line or line.startswith("#") or line.startswith("["):
207 continue
208 m = pattern.match(line)
209 if not m:
210 print(f"警告: 無視された行: {line[:80]}")
211 continue
212 key, val = m.group(1), m.group(2)
213 if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
214 val = val[1:-1]
215 result[key] = val
216 return result
217
218# -----------------------------
219# replace: スライド内の {{key}} を部分置換(書式/アニメ保持)
220# -----------------------------
221def clean_text(text):
222 """
223 テキストから余分な空白行を削除し、整形する。
224
225 PowerPointのノートテキストに含まれる可能性のある余分な改行や、
226 空白文字のみの行を除去し、整形されたテキストを返す。
227
228 :param text: str: 整形する対象のテキスト。
229 :returns: str: 空白行が除去され整形されたテキスト。
230 """
231 # strip()で両端の空白を削除し、空行を除去
232 # lines = [line.strip() for line in text.splitlines() if line.strip()] # 変更前
233 # note_text = "\n".join(lines) # 変更前
234
235 # PowerPointのノートテキストは "\r\n" や末尾の改行を含むことがあるため、
236 # splitlines() で行に分割し、空白文字のみの行や空行を除去します。
237 lines = [line for line in text.splitlines() if line.strip()]
238 note_text = "\n".join(lines)
239
240 return note_text
241
242def replace_placeholders_on_slide(slide, base_map):
243 """
244 指定されたスライド内の`{{key}}`プレースホルダーを置換マップに基づいて置き換える。
245
246 スライドのノート内容を`supertitle`キーとして`base_map`に追加し、
247 スライド内のすべてのテキスト(通常のシェイプ、グループ、表セル)を走査して
248 `{{key}}`形式のプレースホルダーを対応する値で置換する。
249 ノートの`supertitle`はファイル側の指定よりも優先される。
250
251 :param slide: win32com.client.Dispatch: 置換処理を行う対象のスライドオブジェクト。
252 :param base_map: dict: 基本となる置換キーと値のマップ。
253 :returns: int: スライド内で置換されたプレースホルダーの総数。
254 """
255 # スライド固有の 'supertitle' をセット(ファイル側の 'supertitle' 指定より優先)
256 current_map = dict(base_map)
257 raw_notes = get_notes_text(slide)
258 current_map['supertitle'] = clean_text(raw_notes)
259
260 replaced = 0
261 replaced += _replace_in_shapes(slide.Shapes, current_map)
262
263 return replaced
264
265def _replace_in_shapes(shapes, kv):
266 """
267 Shapesコレクション内のすべてのシェイプを走査し、プレースホルダーを置換する。
268
269 グループ化されたシェイプや表内のセルを含む、指定された`Shapes`コレクションの
270 各シェイプに対して再帰的にプレースホルダー置換処理を適用する。
271
272 :param shapes: win32com.client.Dispatch: 走査するShapesコレクション。
273 :param kv: dict: 置換キーと値のマップ。
274 :returns: int: コレクション内で置換されたプレースホルダーの総数。
275 """
276 total = 0
277 for shp in shapes:
278 total += _replace_in_shape_deep(shp, kv)
279 return total
280
281def _replace_in_shape_deep(shp, kv):
282 """
283 単一のPowerPointシェイプ内の`{{key}}`プレースホルダーを部分置換する。
284
285 シェイプがグループ、表、または通常のテキストフレームであるかどうかに応じて、
286 適切な方法でテキストコンテンツを走査し、`{{key}}`形式のプレースホルダーを
287 置換マップ`kv`の値で置き換える。これにより、書式やアニメーションは保持される。
288
289 :param shp: win32com.client.Dispatch: 置換処理を行う対象のシェイプオブジェクト。
290 :param kv: dict: 置換キーと値のマップ。
291 :returns: int: シェイプ内で置換されたプレースホルダーの総数。
292 """
293 cnt = 0
294 try:
295 # グループ
296 if shp.Type == 6: # msoGroup
297 cnt += _replace_in_shapes(shp.GroupItems, kv)
298 except Exception:
299 pass
300
301 try:
302 # 表セル
303 if shp.HasTable:
304 tbl = shp.Table
305 for r in range(1, tbl.Rows.Count + 1):
306 for c in range(1, tbl.Columns.Count + 1):
307 cell_shape = tbl.Cell(r, c).Shape
308 cnt += _replace_in_textframe(cell_shape, kv)
309 except Exception:
310 pass
311
312 # 通常テキスト
313 cnt += _replace_in_textframe(shp, kv)
314
315 return cnt
316
317def _replace_in_textframe(shp, kv):
318 """
319 指定されたシェイプのテキストフレーム内で`{{key}}`プレースホルダーを部分置換する。
320
321 シェイプがテキストフレームを持っている場合、そのテキスト範囲を検索し、
322 `{{key}}`形式のプレースホルダーを`kv`マップの対応する値で置き換える。
323 `Find`メソッドと`TextRange.Text`の直接書き換えを利用することで、
324 既存の書式設定やアニメーションを壊さずにテキストを更新する。
325
326 :param shp: win32com.client.Dispatch: テキストフレームを持つシェイプオブジェクト。
327 :param kv: dict: 置換キーと値のマップ。
328 :returns: int: テキストフレーム内で置換されたプレースホルダーの総数。
329 """
330
331 try:
332 if not shp.HasTextFrame:
333 return 0
334 if not shp.TextFrame.HasText:
335 return 0
336 except Exception:
337 return 0
338
339 tr_all = shp.TextFrame.TextRange
340 count = 0
341
342 # すべてのキーについて順に置換(単純な {{key}} マッチ)
343 for key, value in kv.items():
344 placeholder = "{{" + key + "}}"
345 after_pos = 0
346 while True:
347 try:
348 tr_hit = tr_all.Find(FindWhat=placeholder, After=after_pos,
349 MatchCase=False, WholeWords=False)
350 except Exception:
351 tr_hit = None
352 if tr_hit is None:
353 break
354 # 部分置換:一致範囲の Text を直接書き換える
355 tr_hit.Text = value
356 count += 1
357 # 次検索(現在ヒットの末尾から)
358 after_pos = tr_hit.Start + tr_hit.Length - 1
359
360 return count
361
362# -----------------------------
363# replace モードの本体
364# -----------------------------
365def replace_placeholders_with_file(args):
366 """
367 PowerPointファイル内の`{{key}}`プレースホルダーを指定されたファイルとノート内容で置換する。
368
369 `--replace`オプションで指定されたTOML風ファイルから置換マップを読み込み、
370 各スライドのノート内容を`supertitle`キーとしてマップに追加する。
371 その後、各スライドを走査し、スライド内のすべてのテキストボックス、グループ、
372 表セル内で`{{key}}`形式のプレースホルダーを置換する。
373
374 :param args: argparse.Namespace: コマンドライン引数オブジェクト。
375 :returns: int: 処理が成功した場合は0。ファイル保存エラーが発生した場合も0を返す。
376 """
377 print()
378 print(f"Replace placeholderss (mode={args.mode}):")
379 print(f"{args.infile=}")
380 print(f"{args.outfile=}")
381
382 # 置換key=value を読み込み
383 base_map = parse_toml_style(args.replace) if args.replace else {}
384 print(f"\n置換マップ読み込み: {len(base_map)} 件(+ 各スライドの supertitle を付与)")
385
386 ppt = win32com.client.Dispatch("PowerPoint.Application")
387 ppt.Visible = True
388
389 args.infile = os.path.abspath(args.infile)
390 args.outfile = os.path.abspath(args.outfile)
391 print(f"{args.infile=}")
392 print(f"{args.outfile=}")
393
394 pres = ppt.Presentations.Open(args.infile, WithWindow=False)
395
396 total = 0
397 for slide in pres.Slides:
398 total += replace_placeholders_on_slide(slide, base_map)
399
400 try:
401 pres.SaveAs(args.outfile)
402 except:
403 print(f"\nError: ファイル[{args.outfile}]への保存に失敗しました\n")
404 # Note: presentation.Close() は pres.Close() の間違いと思われるが、既存コードの変更はしない
405 presentation.Close()
406 return 0
407
408 pres.Close()
409 ppt.Quit()
410 print(f"置換完了: {total} 箇所 / 保存しました: {args.outfile}")
411 print( " 注:ノートを置換する場合は {{supertitle}} を使ってください")
412 return 0
413
414
415# -----------------------------
416# main
417# -----------------------------
418if __name__ == "__main__":
419 args = parse_args()
420 if args.mode == "add":
421 add_subtitles_with_transparency(args)
422 else:
423 replace_placeholders_with_file(args)
424
425 terminate()