add_notes_pptx.py ダウンロード/コピー

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()