← Files Public Equity InvestingARCHIVED FILE

skills/public-equity-investing/internal-support/style-guide-adapter/scripts/extract_office_style.py

16.9 KB · Oct 2, 2026 · 00:03 UTC

↓ Download file

#!/usr/bin/env python3
"""Extract lightweight style metadata from Office files.

Supports .pptx, .docx, and .xlsx files without modifying them. The output is a
style reconnaissance aid, not a replacement for rendered visual review.

Usage:
  python scripts/extract_office_style.py path/to/file.pptx --format markdown
  python scripts/extract_office_style.py deck.pptx memo.docx model.xlsx --format json --out style.json
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import zipfile
from collections import Counter
from pathlib import Path
from typing import Any, Iterable
from xml.etree import ElementTree as ET

NS = {
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
    "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
    "x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
}


def _read_xml(zf: zipfile.ZipFile, name: str) -> ET.Element | None:
    try:
        with zf.open(name) as fh:
            return ET.parse(fh).getroot()
    except (KeyError, ET.ParseError, zipfile.BadZipFile):
        return None


def _local(tag: str) -> str:
    return tag.rsplit("}", 1)[-1]


def _attr(el: ET.Element, name: str) -> str | None:
    value = el.attrib.get(name)
    if value is not None:
        return value
    for key, val in el.attrib.items():
        if key.endswith("}" + name):
            return val
    return None


def _count_members(zf: zipfile.ZipFile, prefix: str, pattern: str) -> int:
    rx = re.compile(pattern)
    return sum(1 for n in zf.namelist() if n.startswith(prefix) and rx.search(n))


def _unique_sorted(values: Iterable[str], limit: int = 30) -> list[str]:
    clean = [v for v in values if v]
    return sorted(set(clean))[:limit]


def _top(counter: Counter, limit: int = 12) -> list[dict[str, Any]]:
    return [{"value": k, "count": v} for k, v in counter.most_common(limit) if k]


def _theme_summary(zf: zipfile.ZipFile, candidates: list[str]) -> dict[str, Any]:
    root = None
    source = None
    for name in candidates:
        root = _read_xml(zf, name)
        if root is not None:
            source = name
            break
    if root is None:
        return {}

    theme: dict[str, Any] = {"source": source}
    theme_name = _attr(root, "name")
    if theme_name:
        theme["theme_name"] = theme_name

    colors: dict[str, str] = {}
    clr_scheme = root.find(".//a:clrScheme", NS)
    if clr_scheme is not None:
        for child in list(clr_scheme):
            slot = _local(child.tag)
            rgb = child.find(".//a:srgbClr", NS)
            sysclr = child.find(".//a:sysClr", NS)
            if rgb is not None and _attr(rgb, "val"):
                colors[slot] = "#" + _attr(rgb, "val").upper()
            elif sysclr is not None:
                last = _attr(sysclr, "lastClr")
                val = _attr(sysclr, "val")
                colors[slot] = ("#" + last.upper()) if last else f"system:{val}"
    if colors:
        theme["colors"] = colors

    fonts: dict[str, str] = {}
    for slot, xpath in [
        ("major_latin", ".//a:majorFont/a:latin"),
        ("minor_latin", ".//a:minorFont/a:latin"),
    ]:
        el = root.find(xpath, NS)
        if el is not None and _attr(el, "typeface"):
            fonts[slot] = _attr(el, "typeface")
    if fonts:
        theme["fonts"] = fonts

    return theme


def _drawing_text_style_counts(root: ET.Element) -> dict[str, Any]:
    fonts: Counter = Counter()
    sizes: Counter = Counter()
    colors: Counter = Counter()

    for rpr in root.findall(".//a:rPr", NS):
        sz = _attr(rpr, "sz")
        if sz and sz.isdigit():
            sizes[str(int(sz) / 100)] += 1
        latin = rpr.find("a:latin", NS)
        if latin is not None and _attr(latin, "typeface"):
            fonts[_attr(latin, "typeface")] += 1
        srgb = rpr.find(".//a:solidFill/a:srgbClr", NS)
        if srgb is not None and _attr(srgb, "val"):
            colors["#" + _attr(srgb, "val").upper()] += 1

    return {
        "observed_text_fonts": _top(fonts),
        "observed_text_sizes_pt": _top(sizes),
        "observed_text_colors": _top(colors),
    }


def _pptx_summary(path: Path, zf: zipfile.ZipFile) -> dict[str, Any]:
    summary: dict[str, Any] = {
        "type": "pptx",
        "slide_count": _count_members(zf, "ppt/slides/", r"slide\d+\.xml$"),
        "layout_count": _count_members(zf, "ppt/slideLayouts/", r"slideLayout\d+\.xml$"),
        "master_count": _count_members(zf, "ppt/slideMasters/", r"slideMaster\d+\.xml$"),
    }
    theme = _theme_summary(zf, ["ppt/theme/theme1.xml"])
    if theme:
        summary["theme"] = theme

    presentation = _read_xml(zf, "ppt/presentation.xml")
    if presentation is not None:
        sld_sz = presentation.find("p:sldSz", NS)
        if sld_sz is not None:
            summary["slide_size_emu"] = {
                "cx": _attr(sld_sz, "cx"),
                "cy": _attr(sld_sz, "cy"),
                "type": _attr(sld_sz, "type"),
            }

    layout_names: list[str] = []
    for name in zf.namelist():
        if name.startswith("ppt/slideLayouts/slideLayout") and name.endswith(".xml"):
            root = _read_xml(zf, name)
            if root is None:
                continue
            csld = root.find("p:cSld", NS)
            if csld is not None and _attr(csld, "name"):
                layout_names.append(_attr(csld, "name"))
    if layout_names:
        summary["layout_names"] = _unique_sorted(layout_names)

    font_counts: Counter = Counter()
    size_counts: Counter = Counter()
    color_counts: Counter = Counter()
    fill_counts: Counter = Counter()
    line_counts: Counter = Counter()

    for name in zf.namelist():
        if not (
            name.startswith(
                ("ppt/slides/slide", "ppt/slideMasters/slideMaster", "ppt/slideLayouts/slideLayout")
            )
        ):
            continue
        if not name.endswith(".xml"):
            continue
        root = _read_xml(zf, name)
        if root is None:
            continue
        counts = _drawing_text_style_counts(root)
        for item in counts["observed_text_fonts"]:
            font_counts[item["value"]] += item["count"]
        for item in counts["observed_text_sizes_pt"]:
            size_counts[item["value"]] += item["count"]
        for item in counts["observed_text_colors"]:
            color_counts[item["value"]] += item["count"]
        for srgb in root.findall(".//a:solidFill/a:srgbClr", NS):
            if _attr(srgb, "val"):
                fill_counts["#" + _attr(srgb, "val").upper()] += 1
        for srgb in root.findall(".//a:ln//a:srgbClr", NS):
            if _attr(srgb, "val"):
                line_counts["#" + _attr(srgb, "val").upper()] += 1

    summary["observed"] = {
        "text_fonts": _top(font_counts),
        "text_sizes_pt": _top(size_counts),
        "text_colors": _top(color_counts),
        "fill_colors": _top(fill_counts),
        "line_colors": _top(line_counts),
    }
    return summary


def _docx_summary(path: Path, zf: zipfile.ZipFile) -> dict[str, Any]:
    summary: dict[str, Any] = {"type": "docx"}
    theme = _theme_summary(zf, ["word/theme/theme1.xml"])
    if theme:
        summary["theme"] = theme

    styles = _read_xml(zf, "word/styles.xml")
    style_rows: list[dict[str, Any]] = []
    if styles is not None:
        for st in styles.findall("w:style", NS):
            row: dict[str, Any] = {
                "type": _attr(st, "type"),
                "style_id": _attr(st, "styleId"),
            }
            name_el = st.find("w:name", NS)
            if name_el is not None:
                row["name"] = _attr(name_el, "val")
            based_on = st.find("w:basedOn", NS)
            if based_on is not None:
                row["based_on"] = _attr(based_on, "val")
            rfonts = st.find(".//w:rFonts", NS)
            if rfonts is not None:
                row["font"] = (
                    _attr(rfonts, "ascii") or _attr(rfonts, "hAnsi") or _attr(rfonts, "cs")
                )
            sz = st.find(".//w:sz", NS)
            if sz is not None and _attr(sz, "val") and _attr(sz, "val").isdigit():
                row["size_pt"] = int(_attr(sz, "val")) / 2
            color = st.find(".//w:color", NS)
            if color is not None and _attr(color, "val") and _attr(color, "val") != "auto":
                row["color"] = "#" + _attr(color, "val").upper()
            if row.get("name") or row.get("style_id"):
                style_rows.append(row)
    summary["styles"] = style_rows[:80]

    document = _read_xml(zf, "word/document.xml")
    if document is not None:
        para_styles: Counter = Counter()
        table_count = len(document.findall(".//w:tbl", NS))
        for pstyle in document.findall(".//w:pStyle", NS):
            if _attr(pstyle, "val"):
                para_styles[_attr(pstyle, "val")] += 1
        summary["document_usage"] = {
            "paragraph_style_counts": _top(para_styles),
            "table_count": table_count,
        }
    return summary


def _xlsx_summary(path: Path, zf: zipfile.ZipFile) -> dict[str, Any]:
    summary: dict[str, Any] = {"type": "xlsx"}
    theme = _theme_summary(zf, ["xl/theme/theme1.xml"])
    if theme:
        summary["theme"] = theme

    workbook = _read_xml(zf, "xl/workbook.xml")
    if workbook is not None:
        sheets = []
        for sh in workbook.findall(".//x:sheet", NS):
            row = {"name": _attr(sh, "name"), "sheet_id": _attr(sh, "sheetId")}
            if row["name"]:
                sheets.append(row)
        summary["sheets"] = sheets

    styles = _read_xml(zf, "xl/styles.xml")
    if styles is not None:
        fonts = []
        for font in styles.findall("x:fonts/x:font", NS):
            row: dict[str, Any] = {}
            name_el = font.find("x:name", NS)
            sz_el = font.find("x:sz", NS)
            color_el = font.find("x:color", NS)
            if name_el is not None:
                row["name"] = _attr(name_el, "val")
            if sz_el is not None:
                row["size_pt"] = _attr(sz_el, "val")
            if color_el is not None:
                if _attr(color_el, "rgb"):
                    row["color"] = "#" + _attr(color_el, "rgb")[-6:].upper()
                elif _attr(color_el, "theme"):
                    row["theme_color"] = _attr(color_el, "theme")
            if row:
                fonts.append(row)

        fills = []
        for fill in styles.findall("x:fills/x:fill", NS):
            fg = fill.find(".//x:fgColor", NS)
            if fg is not None:
                if _attr(fg, "rgb"):
                    fills.append("#" + _attr(fg, "rgb")[-6:].upper())
                elif _attr(fg, "theme"):
                    fills.append("theme:" + _attr(fg, "theme"))

        cell_styles = []
        for st in styles.findall("x:cellStyles/x:cellStyle", NS):
            row = {"name": _attr(st, "name"), "builtin_id": _attr(st, "builtinId")}
            if row["name"]:
                cell_styles.append(row)

        num_fmts = []
        for nf in styles.findall("x:numFmts/x:numFmt", NS):
            fmt = _attr(nf, "formatCode")
            if fmt:
                num_fmts.append(fmt)

        summary["styles"] = {
            "fonts": fonts[:60],
            "fills": _unique_sorted(fills, limit=40),
            "cell_styles": cell_styles[:80],
            "custom_number_formats": num_fmts[:80],
        }
    return summary


def inspect_file(path: Path) -> dict[str, Any]:
    result: dict[str, Any] = {
        "file": str(path),
        "name": path.name,
        "extension": path.suffix.lower(),
    }
    if not path.exists():
        result["error"] = "file not found"
        return result
    if path.suffix.lower() not in {".pptx", ".docx", ".xlsx"}:
        result["error"] = "unsupported file type"
        return result
    try:
        with zipfile.ZipFile(path) as zf:
            if path.suffix.lower() == ".pptx":
                result.update(_pptx_summary(path, zf))
            elif path.suffix.lower() == ".docx":
                result.update(_docx_summary(path, zf))
            else:
                result.update(_xlsx_summary(path, zf))
    except zipfile.BadZipFile:
        result["error"] = "not a valid Office zip container"
    return result


def _md_table(rows: list[dict[str, Any]], columns: list[str]) -> str:
    if not rows:
        return ""
    out = ["| " + " | ".join(columns) + " |", "|" + "|".join(["---"] * len(columns)) + "|"]
    for row in rows:
        out.append("| " + " | ".join(str(row.get(c, "")) for c in columns) + " |")
    return "\n".join(out)


def to_markdown(results: list[dict[str, Any]]) -> str:
    lines: list[str] = ["# Office style extraction summary", ""]
    for res in results:
        lines.append(f"## {res.get('name', res.get('file'))}")
        if res.get("error"):
            lines.append(f"- Error: {res['error']}")
            lines.append("")
            continue
        lines.append(f"- Type: {res.get('type')}")
        for key in ["slide_count", "layout_count", "master_count"]:
            if key in res:
                lines.append(f"- {key.replace('_', ' ').title()}: {res[key]}")
        if "slide_size_emu" in res:
            lines.append(f"- Slide size EMU: {res['slide_size_emu']}")
        theme = res.get("theme", {})
        if theme:
            lines.append("\n### Theme")
            if theme.get("theme_name"):
                lines.append(f"- Theme name: {theme['theme_name']}")
            if theme.get("fonts"):
                lines.append(f"- Fonts: {theme['fonts']}")
            if theme.get("colors"):
                lines.append("- Colors:")
                for slot, color in theme["colors"].items():
                    lines.append(f"  - {slot}: {color}")
        if res.get("layout_names"):
            lines.append("\n### Layout names")
            for name in res["layout_names"][:30]:
                lines.append(f"- {name}")
        if res.get("sheets"):
            lines.append("\n### Sheets")
            lines.append(_md_table(res["sheets"], ["name", "sheet_id"]))
        if res.get("observed"):
            lines.append("\n### Observed style usage")
            for label, rows in res["observed"].items():
                if rows:
                    lines.append(f"\n**{label.replace('_', ' ').title()}**")
                    lines.append(_md_table(rows, ["value", "count"]))
        if res.get("document_usage"):
            usage = res["document_usage"]
            lines.append("\n### Document usage")
            lines.append(f"- Table count: {usage.get('table_count', 0)}")
            if usage.get("paragraph_style_counts"):
                lines.append(_md_table(usage["paragraph_style_counts"], ["value", "count"]))
        if res.get("styles"):
            styles = res["styles"]
            lines.append("\n### Styles")
            if isinstance(styles, list):
                lines.append(
                    _md_table(styles[:40], ["type", "style_id", "name", "font", "size_pt", "color"])
                )
            elif isinstance(styles, dict):
                if styles.get("fonts"):
                    lines.append("\n**Fonts**")
                    lines.append(
                        _md_table(styles["fonts"][:30], ["name", "size_pt", "color", "theme_color"])
                    )
                if styles.get("cell_styles"):
                    lines.append("\n**Cell styles**")
                    lines.append(_md_table(styles["cell_styles"][:40], ["name", "builtin_id"]))
                if styles.get("fills"):
                    lines.append("\n**Fills**")
                    for fill in styles["fills"][:30]:
                        lines.append(f"- {fill}")
                if styles.get("custom_number_formats"):
                    lines.append("\n**Custom number formats**")
                    for fmt in styles["custom_number_formats"][:30]:
                        lines.append(f"- `{fmt}`")
        lines.append("")
    lines.append(
        "Note: This extraction is metadata-only. Confirm visual layout, charts, images, and manual overrides by rendering or inspecting the artifact before final delivery."
    )
    return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Extract style metadata from .pptx, .docx, and .xlsx files without modifying them."
    )
    parser.add_argument("files", nargs="+", help="Office files to inspect")
    parser.add_argument(
        "--format", choices=["markdown", "json"], default="json", help="Output format"
    )
    parser.add_argument("--out", help="Optional output file")
    args = parser.parse_args(argv)

    results = [inspect_file(Path(p)) for p in args.files]
    if args.format == "json":
        output = json.dumps(results, indent=2)
    else:
        output = to_markdown(results)

    if args.out:
        Path(args.out).write_text(output, encoding="utf-8")
    else:
        print(output)

    return 1 if any("error" in r for r in results) else 0


if __name__ == "__main__":
    sys.exit(main())

SHA-256: 090f86432ac25d60993ad6d1b200d6dc270dfb09fb5584cc2c5193c74010ec8b