#!/usr/bin/env python3
"""List where the text in a .pptx lives before you send it to a translator.

Usage: python3 inspect_pptx_text.py deck.pptx [--all]

Standard library only. Reads the file; never modifies it. Groups text by the
part of the PowerPoint package that stores it, because many translation
tools only rewrite slide and speaker-note paragraphs. Also flags paragraphs
whose formatting or links are split across several runs, which is where a
paragraph-level rewrite tends to flatten formatting or drop a link.
"""
import re
import sys
import zipfile
import xml.etree.ElementTree as ET

NS = {
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
    "c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
A = "{%s}" % NS["a"]

GROUPS = [
    ("slides", r"ppt/slides/slide\d+\.xml$"),
    ("notes", r"ppt/notesSlides/notesSlide\d+\.xml$"),
    ("masters+layouts", r"ppt/slide(Masters|Layouts)/slide(Master|Layout)\d+\.xml$"),
    ("charts", r"ppt/charts/chart\d+\.xml$"),
    ("smartart", r"ppt/diagrams/data\d+\.xml$"),
    ("comments", r"ppt/(comments/.*|commentAuthors)\.xml$"),
]


def num(name):
    m = re.search(r"(\d+)\.xml$", name)
    return int(m.group(1)) if m else 0


def para_text(p):
    parts = []
    for child in p:
        if child.tag == A + "br":
            parts.append(" / ")
        else:
            parts.extend(t.text or "" for t in child.iter(A + "t"))
    return "".join(parts).strip()


def is_placeholder(sp):
    return sp.find("p:nvSpPr/p:nvPr/p:ph", NS) is not None


def run_flags(p):
    runs = [r for r in p.findall(A + "r") if (r.findtext(A + "t") or "")]
    flags = []
    if len(runs) > 1:
        styles = set()
        for r in runs:
            rpr = r.find(A + "rPr")
            attrs = dict(rpr.attrib) if rpr is not None else {}
            attrs.pop("lang", None)
            attrs.pop("dirty", None)
            styles.add(tuple(sorted(attrs.items())))
        if len(styles) > 1:
            flags.append("mixed-formatting")
        if any(r.find(A + "rPr/" + A + "hlinkClick") is not None for r in runs[1:]):
            flags.append("link-not-on-first-run")
    if p.find(A + "br") is not None:
        flags.append("line-break")
    if p.find(A + "fld") is not None:
        flags.append("field")
    return flags


def inspect(path, show_all):
    z = zipfile.ZipFile(path)
    names = sorted(z.namelist(), key=lambda n: (re.sub(r"\d+\.xml$", "", n), num(n)))
    totals = {}
    lines = []
    for group, pattern in GROUPS:
        for name in names:
            if not re.search(pattern, name):
                continue
            root = ET.fromstring(z.read(name))
            items = []
            if group == "charts":
                items += [("title/label", para_text(p)) for p in root.iter(A + "p") if para_text(p)]
                items += [("cached value", v.text) for v in root.iterfind(".//c:strCache/c:pt/c:v", NS) if v.text]
            elif group == "comments":
                items += [("comment", (t.text or "").strip()) for t in root.iter() if t.tag.endswith("}text") or t.tag == A + "t"]
                items = [i for i in items if i[1]]
            elif group == "masters+layouts":
                for sp in root.iter("{%s}sp" % NS["p"]):
                    kind = "prompt (not shown in slideshow)" if is_placeholder(sp) else "fixed text (shown on slides)"
                    items += [(kind, para_text(p)) for p in sp.iter(A + "p") if para_text(p)]
            else:
                for p in root.iter(A + "p"):
                    text = para_text(p)
                    if text:
                        flags = run_flags(p)
                        items.append((",".join(flags) or "paragraph", text))
                if group == "slides":
                    for el in root.iter("{%s}cNvPr" % NS["p"]):
                        if el.get("descr"):
                            items.append(("alt text", el.get("descr")))
            if not items:
                continue
            for kind, text in items:
                key = (group, kind)
                totals[key] = totals.get(key, 0) + 1
                if show_all or group != "masters+layouts" or kind.startswith("fixed"):
                    lines.append("  %-16s %-22s %-34s %s" % (group, name.split("/")[-1], kind, text[:70]))
    print("Text inventory for", path)
    print("\nCounts by location:")
    for (group, kind), n in sorted(totals.items()):
        print("  %-16s %-34s %d" % (group, kind, n))
    print("\nItems (master/layout prompts hidden; use --all):")
    print("\n".join(lines))
    flagged = sum(n for (g, k), n in totals.items() if g in ("slides", "notes") and k not in ("paragraph", "alt text"))
    outside = sum(n for (g, k), n in totals.items() if g not in ("slides", "notes") and not k.startswith("prompt")) + totals.get(("slides", "alt text"), 0)
    print("\nParagraphs with split formatting, links, breaks or fields:", flagged)
    print("Text items outside slide/notes paragraphs (charts, SmartArt, masters, comments, alt text):", outside)


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit(__doc__)
    inspect(sys.argv[1], "--all" in sys.argv[2:])
