# -*- coding: utf-8 -*-
"""
PowerPointノート処理スクリプト
このスクリプトは、PowerPointファイル(.pptx)に対して以下の2つの主要なモードで動作します。
1. **addモード**: 各スライドのノートを読み込み、指定されたスタイルと位置で半透明の字幕テキストボックスとしてスライドに追加します。
2. **replaceモード**: スライド内の `{{key}}` 形式のプレースホルダーを、指定されたTOML風ファイルと各スライドのノート内容で置換します。
このモードでは、既存のテキスト書式やアニメーションを保持しながらテキストを部分置換します。
:doc:`add_notes_pptx_usage`
"""
import os
import re
import argparse
import win32com.client
from win32com.client import constants
[ドキュメント]
def terminate():
"""
プログラムを終了する前にユーザー入力を待つ。
ユーザーがEnterキーを押すまで待機し、その後プログラムを終了する。
:returns: None: プログラムが終了するため、戻り値はない。
"""
input("\nPress ENTER to terminate>>\n")
exit()
[ドキュメント]
def parse_args():
"""
コマンドライン引数を解析する。
`argparse`モジュールを使用して、入力ファイル、出力ファイル、モード、
および各モードに固有の設定(字幕の見た目、置換ファイルパスなど)を定義し、解析する。
出力ファイル名が指定されていない場合は、入力ファイル名に基づいて自動生成される。
:returns: argparse.Namespace: 解析された引数を格納したオブジェクト。
"""
parser = argparse.ArgumentParser(description="PowerPointノート処理(字幕追加 or {{key}}置換)")
parser.add_argument("infile", help="入力PowerPointファイル(.pptx)")
parser.add_argument("-o", "--outfile", help="出力ファイル名(省略時は -note-added.pptx / -replaced.pptx)")
parser.add_argument("--mode", choices=["add", "replace"], default="add",
help="add: ノートを字幕テキストボックスとして追加 / replace: 置換ファイル+ノート(supertitle)で{{key}}置換")
parser.add_argument("--replace", "-R", type=str,
help="[mode=replace] 置換用のTOML風 key=value ファイル(例: title=Hello)")
# add モード用の見た目設定
parser.add_argument("--box_margin", type=int, default=50, help="字幕ボックスの左右余白(pt)")
parser.add_argument("--box_height", type=int, default=60, help="字幕ボックスの高さ(pt)")
parser.add_argument("--font_name", default="メイリオ", help="フォント名")
parser.add_argument("--font_size", type=int, default=12, help="フォントサイズ(pt)")
parser.add_argument("--font_color", default="884444", help="文字色(16進RGB)")
parser.add_argument("--bgcolor", default="00dddd", help="背景色(16進RGB)")
parser.add_argument("--bgalpha", type=float, default=0.6, help="背景の透明度(0.0〜1.0)")
args = parser.parse_args()
if not args.outfile:
base, ext = os.path.splitext(args.infile)
if args.mode == "add":
args.outfile = f"{base}-note-added.pptx"
else:
args.outfile = f"{base}-replaced.pptx"
return args
# -----------------------------
# 共通: ノート本文取得
# -----------------------------
[ドキュメント]
def get_notes_text(slide):
"""
スライドのノート本文(Placeholder Body)を返す。無ければ空文字。
指定されたPowerPointスライドのノートページにあるプレースホルダー
(通常は2番目のBodyプレースホルダー)からテキストを抽出する。
ノートが存在しない場合は空文字列を返す。
:param slide: win32com.client.Dispatch: ノートを取得する対象のスライドオブジェクト。
:returns: str: ノート本文のテキスト。ノートが存在しない場合は空文字列。
"""
try:
# ppPlaceholderBody=2
# text = slide.NotesPage.Shapes.Placeholders(2).TextFrame.TextRange.Text.strip() # 変更前
text = slide.NotesPage.Shapes.Placeholders(2).TextFrame.TextRange.Text
return text # 変更後:clean_text()の呼び出しを削除
except Exception:
return ""
# -----------------------------
# add: ノートを字幕ボックスとして追加
# -----------------------------
[ドキュメント]
def add_subtitles_with_transparency(args):
"""
PowerPointスライドのノートを字幕テキストボックスとして追加する。
各スライドのノート内容を読み込み、指定されたスタイルと位置で半透明の
テキストボックスをスライド下部に追加する。字幕ボックスの見た目(余白、高さ、
フォント、色、背景色、透明度)はコマンドライン引数で設定可能。
:param args: argparse.Namespace: コマンドライン引数オブジェクト。
:returns: int: 処理が成功した場合は0。ファイル保存エラーが発生した場合も0を返す。
"""
print()
print(f"Add subtitles (mode={args.mode}):")
print(f"{args.infile=}")
print(f"{args.outfile=}")
print(f"{args.box_margin=}")
print(f"{args.box_height=}")
print(f"{args.font_name=}")
print(f"{args.font_size=}")
print(f"{args.font_color=}")
print(f"{args.bgcolor=}")
print(f"{args.bgalpha=}")
ppt = win32com.client.Dispatch("PowerPoint.Application")
ppt.Visible = True
args.infile = os.path.abspath(args.infile)
args.outfile = os.path.abspath(args.outfile)
print(f"\n{args.infile=}")
print(f"{args.outfile=}")
presentation = ppt.Presentations.Open(args.infile, WithWindow=False)
slide_width = presentation.PageSetup.SlideWidth
slide_height = presentation.PageSetup.SlideHeight
textbox_width = slide_width - 2 * args.box_margin
textbox_left = args.box_margin
textbox_top = slide_height - args.box_height - 20 # 下部に配置(余白20pt)
textbox_height = args.box_height
font_rgb = int(args.font_color, 16)
bg_rgb = int(args.bgcolor, 16)
for slide in presentation.Slides:
note_text = get_notes_text(slide)
if not note_text:
continue
#空行を削除
lines = [line for line in note_text.splitlines() if line.strip()] # 変更後
note_text = '\n'.join(lines)
shape = slide.Shapes.AddTextbox(
Orientation=1,
Left=textbox_left,
Top=textbox_top,
Width=textbox_width,
Height=textbox_height
)
shape.TextFrame.WordWrap = True
shape.TextFrame.TextRange.Text = note_text
text_range = shape.TextFrame.TextRange
text_range.Font.Name = args.font_name
text_range.Font.Size = args.font_size
text_range.Font.Color.RGB = font_rgb
text_range.ParagraphFormat.Alignment = 1 # ppAlignLeft
shape.Fill.ForeColor.RGB = bg_rgb
shape.Fill.Transparency = args.bgalpha
shape.Fill.Visible = True
shape.Fill.Solid()
try:
presentation.SaveAs(args.outfile)
except:
print(f"\nError: ファイル[{args.outfile}]への保存に失敗しました\n")
presentation.Close()
return 0
presentation.Close()
ppt.Quit()
print(f"保存しました: {args.outfile}")
return 0
# -----------------------------
# replace: 置換ファイル読み込み(TOML風 key=value)
# -----------------------------
[ドキュメント]
def parse_toml_style(filepath):
"""
とても寛容な key=value 形式を読み込む。
指定されたファイルパスからTOML風のkey=value形式の設定を読み込み、辞書として返す。
- 行頭/行末の空白を許容する。
- `#`で始まる行をコメントとして無視する。
- 値の両端の `'` または `"` を剥がして扱う。
ファイルが存在しない場合は警告を表示し、空辞書を返す。
:param filepath: str: 読み込むTOML風設定ファイルのパス。
:returns: dict: 解析されたキーと値のペアを含む辞書。ファイルが見つからない、またはパスが指定されない場合は空辞書。
"""
if not filepath:
return {}
if not os.path.isfile(filepath):
print(f"Warning: replace ファイルが見つかりません: {filepath}")
return {}
pattern = re.compile(r'^\s*([a-zA-Z0-9_]+)\s*=\s*(.*?)\s*(?:#.*)?$')
result = {}
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or line.startswith("["):
continue
m = pattern.match(line)
if not m:
print(f"警告: 無視された行: {line[:80]}")
continue
key, val = m.group(1), m.group(2)
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
val = val[1:-1]
result[key] = val
return result
# -----------------------------
# replace: スライド内の {{key}} を部分置換(書式/アニメ保持)
# -----------------------------
[ドキュメント]
def clean_text(text):
"""
テキストから余分な空白行を削除し、整形する。
PowerPointのノートテキストに含まれる可能性のある余分な改行や、
空白文字のみの行を除去し、整形されたテキストを返す。
:param text: str: 整形する対象のテキスト。
:returns: str: 空白行が除去され整形されたテキスト。
"""
# strip()で両端の空白を削除し、空行を除去
# lines = [line.strip() for line in text.splitlines() if line.strip()] # 変更前
# note_text = "\n".join(lines) # 変更前
# PowerPointのノートテキストは "\r\n" や末尾の改行を含むことがあるため、
# splitlines() で行に分割し、空白文字のみの行や空行を除去します。
lines = [line for line in text.splitlines() if line.strip()]
note_text = "\n".join(lines)
return note_text
[ドキュメント]
def replace_placeholders_on_slide(slide, base_map):
"""
指定されたスライド内の`{{key}}`プレースホルダーを置換マップに基づいて置き換える。
スライドのノート内容を`supertitle`キーとして`base_map`に追加し、
スライド内のすべてのテキスト(通常のシェイプ、グループ、表セル)を走査して
`{{key}}`形式のプレースホルダーを対応する値で置換する。
ノートの`supertitle`はファイル側の指定よりも優先される。
:param slide: win32com.client.Dispatch: 置換処理を行う対象のスライドオブジェクト。
:param base_map: dict: 基本となる置換キーと値のマップ。
:returns: int: スライド内で置換されたプレースホルダーの総数。
"""
# スライド固有の 'supertitle' をセット(ファイル側の 'supertitle' 指定より優先)
current_map = dict(base_map)
raw_notes = get_notes_text(slide)
current_map['supertitle'] = clean_text(raw_notes)
replaced = 0
replaced += _replace_in_shapes(slide.Shapes, current_map)
return replaced
def _replace_in_shapes(shapes, kv):
"""
Shapesコレクション内のすべてのシェイプを走査し、プレースホルダーを置換する。
グループ化されたシェイプや表内のセルを含む、指定された`Shapes`コレクションの
各シェイプに対して再帰的にプレースホルダー置換処理を適用する。
:param shapes: win32com.client.Dispatch: 走査するShapesコレクション。
:param kv: dict: 置換キーと値のマップ。
:returns: int: コレクション内で置換されたプレースホルダーの総数。
"""
total = 0
for shp in shapes:
total += _replace_in_shape_deep(shp, kv)
return total
def _replace_in_shape_deep(shp, kv):
"""
単一のPowerPointシェイプ内の`{{key}}`プレースホルダーを部分置換する。
シェイプがグループ、表、または通常のテキストフレームであるかどうかに応じて、
適切な方法でテキストコンテンツを走査し、`{{key}}`形式のプレースホルダーを
置換マップ`kv`の値で置き換える。これにより、書式やアニメーションは保持される。
:param shp: win32com.client.Dispatch: 置換処理を行う対象のシェイプオブジェクト。
:param kv: dict: 置換キーと値のマップ。
:returns: int: シェイプ内で置換されたプレースホルダーの総数。
"""
cnt = 0
try:
# グループ
if shp.Type == 6: # msoGroup
cnt += _replace_in_shapes(shp.GroupItems, kv)
except Exception:
pass
try:
# 表セル
if shp.HasTable:
tbl = shp.Table
for r in range(1, tbl.Rows.Count + 1):
for c in range(1, tbl.Columns.Count + 1):
cell_shape = tbl.Cell(r, c).Shape
cnt += _replace_in_textframe(cell_shape, kv)
except Exception:
pass
# 通常テキスト
cnt += _replace_in_textframe(shp, kv)
return cnt
def _replace_in_textframe(shp, kv):
"""
指定されたシェイプのテキストフレーム内で`{{key}}`プレースホルダーを部分置換する。
シェイプがテキストフレームを持っている場合、そのテキスト範囲を検索し、
`{{key}}`形式のプレースホルダーを`kv`マップの対応する値で置き換える。
`Find`メソッドと`TextRange.Text`の直接書き換えを利用することで、
既存の書式設定やアニメーションを壊さずにテキストを更新する。
:param shp: win32com.client.Dispatch: テキストフレームを持つシェイプオブジェクト。
:param kv: dict: 置換キーと値のマップ。
:returns: int: テキストフレーム内で置換されたプレースホルダーの総数。
"""
try:
if not shp.HasTextFrame:
return 0
if not shp.TextFrame.HasText:
return 0
except Exception:
return 0
tr_all = shp.TextFrame.TextRange
count = 0
# すべてのキーについて順に置換(単純な {{key}} マッチ)
for key, value in kv.items():
placeholder = "{{" + key + "}}"
after_pos = 0
while True:
try:
tr_hit = tr_all.Find(FindWhat=placeholder, After=after_pos,
MatchCase=False, WholeWords=False)
except Exception:
tr_hit = None
if tr_hit is None:
break
# 部分置換:一致範囲の Text を直接書き換える
tr_hit.Text = value
count += 1
# 次検索(現在ヒットの末尾から)
after_pos = tr_hit.Start + tr_hit.Length - 1
return count
# -----------------------------
# replace モードの本体
# -----------------------------
[ドキュメント]
def replace_placeholders_with_file(args):
"""
PowerPointファイル内の`{{key}}`プレースホルダーを指定されたファイルとノート内容で置換する。
`--replace`オプションで指定されたTOML風ファイルから置換マップを読み込み、
各スライドのノート内容を`supertitle`キーとしてマップに追加する。
その後、各スライドを走査し、スライド内のすべてのテキストボックス、グループ、
表セル内で`{{key}}`形式のプレースホルダーを置換する。
:param args: argparse.Namespace: コマンドライン引数オブジェクト。
:returns: int: 処理が成功した場合は0。ファイル保存エラーが発生した場合も0を返す。
"""
print()
print(f"Replace placeholderss (mode={args.mode}):")
print(f"{args.infile=}")
print(f"{args.outfile=}")
# 置換key=value を読み込み
base_map = parse_toml_style(args.replace) if args.replace else {}
print(f"\n置換マップ読み込み: {len(base_map)} 件(+ 各スライドの supertitle を付与)")
ppt = win32com.client.Dispatch("PowerPoint.Application")
ppt.Visible = True
args.infile = os.path.abspath(args.infile)
args.outfile = os.path.abspath(args.outfile)
print(f"{args.infile=}")
print(f"{args.outfile=}")
pres = ppt.Presentations.Open(args.infile, WithWindow=False)
total = 0
for slide in pres.Slides:
total += replace_placeholders_on_slide(slide, base_map)
try:
pres.SaveAs(args.outfile)
except:
print(f"\nError: ファイル[{args.outfile}]への保存に失敗しました\n")
# Note: presentation.Close() は pres.Close() の間違いと思われるが、既存コードの変更はしない
presentation.Close()
return 0
pres.Close()
ppt.Quit()
print(f"置換完了: {total} 箇所 / 保存しました: {args.outfile}")
print( " 注:ノートを置換する場合は {{supertitle}} を使ってください")
return 0
# -----------------------------
# main
# -----------------------------
if __name__ == "__main__":
args = parse_args()
if args.mode == "add":
add_subtitles_with_transparency(args)
else:
replace_placeholders_with_file(args)
terminate()