#!/usr/bin/env python3 """ pptx-to-html: Convert a PowerPoint deck (.pptx / .ppt / .pptm) into a single, self-contained HTML "viewer" that mirrors the deck's formatting and page structure. Each slide is rendered to a high-resolution image (via PowerPoint itself) and embedded as base64 so the output is one portable file. Usage: python convert.py --input "C:\\path\\deck.pptx" [--out "C:\\dir"] [--scale 2.0] python convert.py --input "a.pptx" --input "b.ppt" (multiple files) python convert.py --input "deck.pptx" --titles titles.json (override labels) Output: ".html" written next to each source file, or into --out if provided. Prints a JSON summary to stdout. Requires: Windows + Microsoft PowerPoint + pywin32. No LibreOffice needed. Handles legacy binary .ppt files (OLE2) that zip-based readers cannot open. """ import argparse import base64 import datetime import html import json import os import re import shutil import sys import tempfile MSO_TRUE = -1 MSO_FALSE = 0 BLANK_PNG_BYTES = 1500 # exports smaller than this are treated as blank slides MONTHS = ["", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] # --------------------------------------------------------------------------- # # HTML template (tokens are %%UPPER%% to avoid clashing with CSS braces) # --------------------------------------------------------------------------- # TEMPLATE = r""" %%DOCTITLE%%
%%BRAND%%
%%DATEBLOCK%%
%%N%% slides
%%SLIDES%%
%%FOOTER%%
""" def clean_text(s): if not s: return "" s = re.sub(r"[\r\n\x0b\x07]+", " ", s) return re.sub(r"\s+", " ", s).strip() def parse_name(basename): """Return (brand, date_label_or_None) from a file basename. Recognizes a leading YYYY.MM.DD / YYYY-MM-DD / YYYY_MM_DD prefix. """ m = re.match(r"^(\d{4})[.\-_](\d{1,2})[.\-_](\d{1,2})\s+(.*)$", basename) if m: y, mo, d, rest = int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4).strip() date_label = None if 1 <= mo <= 12 and 1 <= d <= 31: try: datetime.date(y, mo, d) date_label = "%s %d, %d" % (MONTHS[mo], d, y) except ValueError: date_label = None return (rest or basename, date_label) return (basename, None) def derive_title(slide): """Best-effort human label for a slide.""" try: shapes = slide.Shapes if shapes.HasTitle: t = clean_text(shapes.Title.TextFrame.TextRange.Text) if t: return t[:90] except Exception: pass # Fallback: first non-empty text on the slide try: shapes = slide.Shapes for i in range(1, shapes.Count + 1): shp = shapes.Item(i) try: if shp.HasTextFrame and shp.TextFrame.HasText: t = clean_text(shp.TextFrame.TextRange.Text) if t: return t[:90] except Exception: continue except Exception: pass return "" def data_uri(path): with open(path, "rb") as f: return "data:image/png;base64," + base64.b64encode(f.read()).decode("ascii") def build_html(basename, slides): """slides: list of dicts {num, title, uri or None (blank)}""" n = len(slides) brand, date_label = parse_name(basename) toc_rows, slide_rows = [], [] for s in slides: num = s["num"] title = html.escape(s["title"] or ("Slide %d" % num)) toc_rows.append( '
  • %02d%s
  • ' % (num, num, title)) if s.get("uri"): body = 'Slide %d: %s' % (s["uri"], num, title) frame = '
    %s
    ' % body else: frame = '
    Slide %d (blank)
    ' % num slide_rows.append( '
    \n' '
    %02d' '%s' '%d / %d
    \n' ' %s\n' '
    ' % (num, num, title, num, n, frame)) if date_label: dateblock = '
    %s
    ' % html.escape(date_label) else: dateblock = "" footer = "Converted from “%s.pptx” · %d slides · 16:9" % ( html.escape(basename), n) out = TEMPLATE out = out.replace("%%DOCTITLE%%", html.escape(basename)) out = out.replace("%%BRAND%%", html.escape(brand)) out = out.replace("%%DATEBLOCK%%", dateblock) out = out.replace("%%N%%", str(n)) out = out.replace("%%TOC%%", "\n".join(toc_rows)) out = out.replace("%%SLIDES%%", "\n".join(slide_rows)) out = out.replace("%%FOOTER%%", footer) return out def move_to_processed(src, processed_dir): """Move the source deck into processed_dir after a successful conversion, avoiding collisions by adding a numeric suffix.""" os.makedirs(processed_dir, exist_ok=True) base = os.path.basename(src) stem, ext = os.path.splitext(base) target = os.path.join(processed_dir, base) n = 1 while os.path.exists(target): target = os.path.join(processed_dir, "%s (%d)%s" % (stem, n, ext)) n += 1 shutil.move(src, target) return target def convert_one(app, src, out_dir, scale, tmp_root, title_overrides=None, processed_dir=""): """Convert a single deck. Returns (out_path, slide_count, moved_to).""" import pythoncom # noqa title_overrides = title_overrides or {} basename = os.path.splitext(os.path.basename(src))[0] work = tempfile.mkdtemp(prefix="ppt2html_", dir=tmp_root) img_dir = os.path.join(work, "img") os.makedirs(img_dir, exist_ok=True) # Hydrate: copy source locally (handles OneDrive cloud-only files & locks) local_src = os.path.join(work, os.path.basename(src)) shutil.copy2(src, local_src) pres = app.Presentations.Open(local_src, MSO_TRUE, MSO_FALSE, MSO_FALSE) try: sw = float(pres.PageSetup.SlideWidth) sh = float(pres.PageSetup.SlideHeight) px_w = int(round(sw * scale)) px_h = int(round(sh * scale)) count = pres.Slides.Count slides = [] for i in range(1, count + 1): slide = pres.Slides.Item(i) title = title_overrides.get(str(i)) or title_overrides.get(i) or derive_title(slide) out_png = os.path.join(img_dir, "slide-%02d.png" % i) uri = None try: slide.Export(out_png, "PNG", px_w, px_h) if os.path.exists(out_png) and os.path.getsize(out_png) >= BLANK_PNG_BYTES: uri = data_uri(out_png) except Exception: uri = None # blank / unexportable -> white frame slides.append({"num": i, "title": title, "uri": uri}) finally: pres.Close() doc = build_html(basename, slides) target_dir = out_dir if out_dir else os.path.dirname(os.path.abspath(src)) os.makedirs(target_dir, exist_ok=True) out_path = os.path.join(target_dir, basename + ".html") with open(out_path, "w", encoding="utf-8") as f: f.write(doc) shutil.rmtree(work, ignore_errors=True) # Move the source only after a confirmed, non-empty output exists. moved_to = None if processed_dir and os.path.exists(out_path) and os.path.getsize(out_path) > 0: moved_to = move_to_processed(os.path.abspath(src), processed_dir) return out_path, len(slides), moved_to def main(): ap = argparse.ArgumentParser(description="Convert PowerPoint decks to self-contained HTML.") ap.add_argument("--input", action="append", default=[], help="Path to a .pptx/.ppt/.pptm file. Repeat for multiple files.") ap.add_argument("--input-dir", default="", help="Convert every .pptx/.ppt/.pptm in this folder (non-recursive).") ap.add_argument("--out", default="", help="Output directory (default: next to each source).") ap.add_argument("--scale", type=float, default=2.0, help="Render scale (default 2.0 = 1920x1080).") ap.add_argument("--titles", default="", help="Optional JSON file mapping slide number -> title, to override " "auto-derived labels. Applied to every input deck.") ap.add_argument("--skip-existing", action="store_true", help="Skip a deck if its output .html already exists.") ap.add_argument("--processed-dir", default="", help="If set, move each source deck here after a successful conversion.") args = ap.parse_args() import pythoncom import win32com.client # Assemble the input list (explicit files + folder scan), de-duplicated. inputs = list(args.input) if args.input_dir: exts = (".pptx", ".ppt", ".pptm") for name in sorted(os.listdir(args.input_dir)): if name.startswith("~$"): continue # Office lock/temp file if name.lower().endswith(exts): inputs.append(os.path.join(args.input_dir, name)) # De-dupe while preserving order. seen, ordered = set(), [] for p in inputs: ap_ = os.path.abspath(p) if ap_ not in seen: seen.add(ap_) ordered.append(ap_) inputs = ordered if not inputs: print(json.dumps({"processed": [], "errors": [ {"file": "", "error": "no input files (use --input or --input-dir)"}]})) return 1 title_overrides = {} if args.titles: try: with open(args.titles, "r", encoding="utf-8") as f: title_overrides = json.load(f) except Exception as e: print(json.dumps({"processed": [], "errors": [ {"file": args.titles, "error": "could not read titles JSON: %r" % e}]})) return 1 processed, errors = [], [] tmp_root = tempfile.gettempdir() total = len(inputs) def log(msg): # Progress goes to stderr so stdout stays a clean JSON document. sys.stderr.write(msg + "\n") sys.stderr.flush() pythoncom.CoInitialize() app = None try: # Dedicated instance so we never disturb the user's open PowerPoint. app = win32com.client.DispatchEx("PowerPoint.Application") for idx, src in enumerate(inputs, start=1): name = os.path.basename(src) if not os.path.exists(src): errors.append({"file": src, "error": "not found"}) log("[%d/%d] SKIP (not found): %s" % (idx, total, name)) continue if args.skip_existing: base = os.path.splitext(name)[0] tdir = args.out if args.out else os.path.dirname(src) if os.path.exists(os.path.join(tdir, base + ".html")): log("[%d/%d] SKIP (exists): %s" % (idx, total, name)) continue log("[%d/%d] converting: %s ..." % (idx, total, name)) try: out_path, n, moved_to = convert_one( app, src, args.out, args.scale, tmp_root, title_overrides, args.processed_dir) processed.append({"file": src, "output": out_path, "slides": n, "moved_to": moved_to}) extra = " -> moved" if moved_to else "" log("[%d/%d] done: %s (%d slides)%s" % (idx, total, name, n, extra)) except Exception as e: errors.append({"file": src, "error": repr(e)}) log("[%d/%d] ERROR: %s -> %r" % (idx, total, name, e)) finally: try: if app is not None: app.Quit() except Exception: pass pythoncom.CoUninitialize() log("BATCH COMPLETE: %d converted, %d errors" % (len(processed), len(errors))) print(json.dumps({"processed": processed, "errors": errors}, indent=2)) return 1 if errors and not processed else 0 if __name__ == "__main__": sys.exit(main())