#!/usr/bin/env python3 # -*- coding: utf-8 -*- r"""Convert speaker notes between PowerPoint (.pptx) and notes files (.docx/.txt/.md). Modes ----- word2pptx Read slide sections from a .docx/.txt/.md file and overwrite notes of the corresponding PowerPoint slides. pptx2word Collect notes from all PowerPoint slides into a .docx/.txt/.md file. Slide headers in .docx/.txt/.md files are recognized by this pattern:: ^\s*#\s*(?:スライド|Slides?)?\s*(\d+)\s*$ Examples:: # スライド 3 # Slide 3 # Slides 3 #3 Dependencies:: pip install python-pptx pip install python-docx # only needed for .docx files """ from __future__ import annotations import argparse import re import shutil import sys from pathlib import Path from pptx import Presentation SLIDE_HEADER_RE = re.compile( r"^\s*#\s*(?:スライド|Slides?)?\s*(\d+)\s*$", re.IGNORECASE, ) def warning(message: str) -> None: print(f"Warning: {message}", file=sys.stderr) def _parse_note_lines(lines: list[str], source_name: str) -> dict[int, str]: """Parse slide-numbered note sections from a sequence of text lines.""" sections: dict[int, str] = {} current_slide: int | None = None current_lines: list[str] = [] preamble_lines: list[str] = [] def store_current() -> None: nonlocal current_slide, current_lines if current_slide is None: return note_text = "\n".join(current_lines).strip("\n") if current_slide in sections: warning( f"slide {current_slide} appears more than once in {source_name}; " "the last section is used." ) sections[current_slide] = note_text for text in lines: match = SLIDE_HEADER_RE.match(text) if match: store_current() current_slide = int(match.group(1)) current_lines = [] continue if current_slide is None: preamble_lines.append(text) else: current_lines.append(text) store_current() if any(line.strip() for line in preamble_lines): warning("text before the first slide header was ignored.") if not sections: warning(f"no slide headers were found in {source_name}.") return sections def read_notes_sections(notes_path: Path) -> dict[int, str]: """Read slide-numbered note sections from .docx, .txt, or .md.""" suffix = notes_path.suffix.lower() if suffix == ".docx": try: from docx import Document except ImportError: raise RuntimeError( "python-docx is required for .docx files. " "Install it with: pip install python-docx" ) from None doc = Document(notes_path) lines = [paragraph.text for paragraph in doc.paragraphs] return _parse_note_lines(lines, "Word file") if suffix in (".txt", ".md"): text = notes_path.read_text(encoding="utf-8-sig") return _parse_note_lines(text.splitlines(), f"{suffix} file") raise ValueError( f"unsupported notes file extension: {notes_path.suffix or '(none)'}; " "use .docx, .txt, or .md" ) def get_slide_notes(slide) -> str: """Return notes text for one slide without creating a notes slide.""" if not slide.has_notes_slide: return "" text_frame = slide.notes_slide.notes_text_frame if text_frame is None: return "" return text_frame.text or "" def set_slide_notes(slide, text: str) -> bool: """Overwrite notes text for one slide. Returns False only when the notes placeholder is unavailable. """ text_frame = slide.notes_slide.notes_text_frame if text_frame is None: return False text_frame.text = text return True def notes_to_pptx(pptx_path: Path, notes_path: Path, output_path: Path | None) -> int: sections = read_notes_sections(notes_path) prs = Presentation(pptx_path) nslides = len(prs.slides) updated = 0 skipped = 0 for slide_no, note_text in sections.items(): if slide_no < 1 or slide_no > nslides: warning( f"slide {slide_no} does not exist in PPTX " f"(valid range: 1-{nslides}); skipped." ) skipped += 1 continue slide = prs.slides[slide_no - 1] if not set_slide_notes(slide, note_text): warning( f"slide {slide_no} has no usable notes placeholder; skipped." ) skipped += 1 continue print(f"Updated: slide {slide_no}") updated += 1 if output_path is None: output_path = pptx_path # python-pptx writes a complete package. Saving directly over the input # file is supported, but a temporary file makes replacement safer. if output_path.resolve() == pptx_path.resolve(): tmp_path = pptx_path.with_name(pptx_path.stem + ".__notes_tmp__.pptx") prs.save(tmp_path) shutil.move(tmp_path, pptx_path) else: output_path.parent.mkdir(parents=True, exist_ok=True) prs.save(output_path) print(f"Saved: {output_path}") print(f"Updated slides: {updated}") if skipped: print(f"Skipped sections: {skipped}") return 0 def pptx_to_notes(pptx_path: Path, notes_path: Path) -> int: """Export notes from a PPTX to .docx, .txt, or .md.""" prs = Presentation(pptx_path) suffix = notes_path.suffix.lower() if suffix == ".docx": try: from docx import Document except ImportError: raise RuntimeError( "python-docx is required for .docx files. " "Install it with: pip install python-docx" ) from None doc = Document() for slide_no, slide in enumerate(prs.slides, start=1): # Keep the literal marker in the paragraph text so the generated # file can be fed back to notes2pptx/word2pptx unchanged. p = doc.add_paragraph(f"# Slide {slide_no}") try: p.style = "Heading 1" except KeyError: pass note_text = get_slide_notes(slide) if note_text: for line in note_text.split("\n"): doc.add_paragraph(line) else: doc.add_paragraph("") doc.add_paragraph("") notes_path.parent.mkdir(parents=True, exist_ok=True) doc.save(notes_path) elif suffix in (".txt", ".md"): blocks: list[str] = [] for slide_no, slide in enumerate(prs.slides, start=1): note_text = get_slide_notes(slide).strip("\n") block = f"# Slide {slide_no}\n" if note_text: block += note_text + "\n" blocks.append(block) notes_path.parent.mkdir(parents=True, exist_ok=True) notes_path.write_text("\n".join(blocks), encoding="utf-8") else: raise ValueError( f"unsupported notes file extension: {notes_path.suffix or '(none)'}; " "use .docx, .txt, or .md" ) print(f"Saved: {notes_path}") print(f"Slides exported: {len(prs.slides)}") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Convert PowerPoint speaker notes to/from .docx/.txt/.md files." ) parser.add_argument( "--mode", required=True, choices=("word2pptx", "pptx2word", "notes2pptx", "pptx2notes"), help="Conversion direction.", ) parser.add_argument( "-p", "--pptx", required=True, help="PowerPoint .pptx file.", ) parser.add_argument( "-w", "--word", "--notes", dest="notes", required=True, help="Notes file (.docx, .txt, or .md).", ) parser.add_argument( "-o", "--output", help=( "Output .pptx for mode=word2pptx/notes2pptx. " "If omitted, the input PPTX is overwritten. " "Ignored in mode=pptx2word/pptx2notes." ), ) return parser def main() -> int: args = build_parser().parse_args() pptx_path = Path(args.pptx).expanduser() notes_path = Path(args.notes).expanduser() if args.mode in ("word2pptx", "notes2pptx"): if not pptx_path.is_file(): print(f"Error: PPTX file not found: {pptx_path}", file=sys.stderr) return 2 if not notes_path.is_file(): print(f"Error: notes file not found: {notes_path}", file=sys.stderr) return 2 if notes_path.suffix.lower() not in (".docx", ".txt", ".md"): print("Error: notes file must have .docx, .txt, or .md extension.", file=sys.stderr) return 2 output_path = Path(args.output).expanduser() if args.output else None if output_path is not None and output_path.suffix.lower() != ".pptx": print("Error: output file must have .pptx extension.", file=sys.stderr) return 2 try: return notes_to_pptx(pptx_path, notes_path, output_path) except (ValueError, RuntimeError, UnicodeError) as exc: print(f"Error: {exc}", file=sys.stderr) return 2 if args.output: warning("--output is ignored in mode=pptx2word/pptx2notes; --word/--notes is the output file.") if not pptx_path.is_file(): print(f"Error: PPTX file not found: {pptx_path}", file=sys.stderr) return 2 if notes_path.suffix.lower() not in (".docx", ".txt", ".md"): print("Error: notes file must have .docx, .txt, or .md extension.", file=sys.stderr) return 2 try: return pptx_to_notes(pptx_path, notes_path) except (ValueError, RuntimeError, UnicodeError) as exc: print(f"Error: {exc}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())