feat: initial release v1.0.0 (CW-031)
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the MPM internal kickoff deck from the Kickoff Deck.pptx template look.
|
||||
Usage: python3 build_deck.py "Kickoff Deck.pptx" deck_input.json out.pptx
|
||||
Template slide 1 = cover, slide 2 = content master (dark left title panel + right body). All other template
|
||||
slides are dropped. Each JSON slide becomes one or more content slides (long tables/text auto-continue)."""
|
||||
import copy, json, sys
|
||||
from pptx import Presentation
|
||||
from pptx.util import Emu, Pt
|
||||
from pptx.dml.color import RGBColor
|
||||
|
||||
DARK, GOLD, GREY = RGBColor(0x1C, 0x1D, 0x22), RGBColor(0xD4, 0xAF, 0x37), RGBColor(0xF2, 0xF2, 0xF2)
|
||||
BODY_L, BODY_T, BODY_W, BODY_H = 9596190, 1660731, 13594280, 11700000
|
||||
CHARS_PER_LINE = 60
|
||||
R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
|
||||
|
||||
|
||||
def dup_slide(prs, src):
|
||||
new = prs.slides.add_slide(src.slide_layout)
|
||||
for shp in list(new.shapes):
|
||||
shp._element.getparent().remove(shp._element)
|
||||
for el in src.shapes._spTree.iterchildren():
|
||||
if el.tag.endswith(("}nvGrpSpPr", "}grpSpPr")):
|
||||
continue
|
||||
new.shapes._spTree.append(copy.deepcopy(el))
|
||||
for node in new.shapes._spTree.iter(): # re-point image relationships
|
||||
for attr in ("embed", "link", "id"):
|
||||
rid = node.get(R_NS + attr)
|
||||
if rid and rid in src.part.rels:
|
||||
rel = src.part.rels[rid]
|
||||
if not rel.is_external:
|
||||
node.set(R_NS + attr, new.part.relate_to(rel.target_part, rel.reltype))
|
||||
return new
|
||||
|
||||
|
||||
def drop_slide(prs, slide):
|
||||
lst = prs.slides._sldIdLst
|
||||
for sid in list(lst):
|
||||
if prs.part.related_part(sid.rId) is slide.part:
|
||||
prs.part.drop_rel(sid.rId); lst.remove(sid); return
|
||||
|
||||
|
||||
def shape(slide, name, nth=0):
|
||||
"""Find a template shape by name; fall back to the nth text box if the template was edited."""
|
||||
hit = [s for s in slide.shapes if s.name == name]
|
||||
return hit[0] if hit else [s for s in slide.shapes if s.has_text_frame and s.text_frame.text.strip()][nth]
|
||||
|
||||
|
||||
def set_lines(tf, lines):
|
||||
"""Replace text keeping the first run's formatting."""
|
||||
p0 = tf.paragraphs[0]; r0 = copy.deepcopy(p0.runs[0]._r) if p0.runs else None
|
||||
for p in tf.paragraphs[1:]:
|
||||
p._p.getparent().remove(p._p)
|
||||
for r in list(p0.runs):
|
||||
r._r.getparent().remove(r._r)
|
||||
for i, line in enumerate(lines):
|
||||
p = p0 if i == 0 else tf.add_paragraph()
|
||||
if i:
|
||||
p._p.insert(0, copy.deepcopy(p0._p.pPr)) if p0._p.pPr is not None else None
|
||||
if r0 is not None:
|
||||
rr = copy.deepcopy(r0); p._p.append(rr); p.runs[-1].text = line
|
||||
else:
|
||||
p.text = line
|
||||
|
||||
|
||||
def est_lines(block):
|
||||
n = 0
|
||||
if block.get("heading"): n += 1.3
|
||||
for t in ([block["text"]] if block.get("text") else []) + block.get("bullets", []):
|
||||
n += max(1, -(-len(t) // CHARS_PER_LINE))
|
||||
return n + 0.6
|
||||
|
||||
|
||||
def write_body(tb, blocks):
|
||||
tf = tb.text_frame; tf.word_wrap = True
|
||||
head_r = copy.deepcopy(tf.paragraphs[0].runs[0]._r) # Playfair heading run
|
||||
body_r = copy.deepcopy(next(r for p in tf.paragraphs[1:] for r in p.runs)._r) # Karla body run
|
||||
for p in tf.paragraphs[1:]:
|
||||
p._p.getparent().remove(p._p)
|
||||
p0 = tf.paragraphs[0]
|
||||
for r in list(p0.runs):
|
||||
r._r.getparent().remove(r._r)
|
||||
first = [True]
|
||||
|
||||
def para(run_xml, text, size):
|
||||
p = p0 if first[0] else tf.add_paragraph(); first[0] = False
|
||||
r = copy.deepcopy(run_xml); p._p.append(r); p.runs[-1].text = text; p.runs[-1].font.size = Pt(size)
|
||||
for b in blocks:
|
||||
if b.get("heading"): para(head_r, b["heading"].upper(), 36)
|
||||
if b.get("text"):
|
||||
for line in b["text"].split("\n"): para(body_r, line, 30)
|
||||
for t in b.get("bullets", []): para(body_r, "• " + t, 30)
|
||||
para(body_r, "", 18)
|
||||
|
||||
|
||||
def table_rows_emu(cols, rows):
|
||||
"""Estimated height of each row, allowing for text wrapping in narrow columns."""
|
||||
per_col = max(6, int(92 / max(1, len(cols)))) # usable chars per column at 20pt, after word-wrap loss
|
||||
return [max(1, max(-(-len(str(v or "")) // per_col) for v in r)) * 310000 + 160000 for r in [cols] + rows]
|
||||
|
||||
|
||||
def table_emu(b, with_heading=True):
|
||||
return (950000 if with_heading and b.get("heading") else 0) + sum(table_rows_emu(b["table"]["columns"], b["table"]["rows"])) + 450000
|
||||
|
||||
|
||||
def add_table(slide, heading, cols, rows, top):
|
||||
"""Draw a table at `top`; return the y position just below it."""
|
||||
if heading:
|
||||
tb = slide.shapes.add_textbox(BODY_L, top, BODY_W, Emu(700000))
|
||||
r = tb.text_frame.paragraphs[0].add_run(); r.text = heading.upper()
|
||||
r.font.size, r.font.bold, r.font.underline, r.font.name, r.font.color.rgb = Pt(36), True, True, "Playfair Display", DARK
|
||||
top += 950000
|
||||
heights = table_rows_emu(cols, rows)
|
||||
shp = slide.shapes.add_table(len(rows) + 1, len(cols), BODY_L, top, BODY_W, Emu(sum(heights)))
|
||||
t = shp.table
|
||||
for i, hgt in enumerate(heights):
|
||||
t.rows[i].height = Emu(hgt)
|
||||
for j, c in enumerate(cols):
|
||||
cell = t.cell(0, j); cell.text = str(c); cell.fill.solid(); cell.fill.fore_color.rgb = DARK
|
||||
for i, row in enumerate(rows, start=1):
|
||||
for j, v in enumerate(row):
|
||||
cell = t.cell(i, j); cell.text = "" if v is None else str(v)
|
||||
cell.fill.solid(); cell.fill.fore_color.rgb = GREY if i % 2 else RGBColor(0xFF, 0xFF, 0xFF)
|
||||
for i, row in enumerate(t.rows):
|
||||
for cell in row.cells:
|
||||
for p in cell.text_frame.paragraphs:
|
||||
for r in p.runs:
|
||||
r.font.size = Pt(20); r.font.name = "Karla"
|
||||
r.font.bold = i == 0
|
||||
r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) if i == 0 else DARK
|
||||
if i and str(r.text).upper() in ("HIGH", "CRITICAL"): r.font.color.rgb = RGBColor(0xC0, 0x00, 0x00)
|
||||
return top + sum(heights) + 450000
|
||||
|
||||
|
||||
def paginate(spec):
|
||||
"""Split one JSON slide into pages that fit. Text and tables share a page while they fit;
|
||||
text never follows a table on the same page (the body text box sits at the top)."""
|
||||
pages, cur, used, has_table = [], [], 0, False
|
||||
for b in spec.get("blocks", []):
|
||||
if "table" in b:
|
||||
rows = b["table"]["rows"] or [["—"] * len(b["table"]["columns"])]
|
||||
chunk, k = [], 0
|
||||
while k < len(rows): # split long tables by height, not a fixed row count
|
||||
chunk = [rows[k]]; k += 1
|
||||
while k < len(rows) and table_emu({**b, "table": {**b["table"], "rows": chunk + [rows[k]]}}) <= BODY_H:
|
||||
chunk.append(rows[k]); k += 1
|
||||
part = {**b, "table": {**b["table"], "rows": chunk}}
|
||||
need = table_emu(part)
|
||||
if cur and used + need > BODY_H:
|
||||
pages.append(cur); cur, used = [], 0
|
||||
cur.append(part); used += need; has_table = True
|
||||
continue
|
||||
need = est_lines(b) * 560000
|
||||
if cur and (has_table or used + need > BODY_H):
|
||||
pages.append(cur); cur, used, has_table = [], 0, False
|
||||
cur.append(b); used += need
|
||||
if cur: pages.append(cur)
|
||||
return pages or [[]]
|
||||
|
||||
|
||||
def main(template, src, out):
|
||||
d = json.load(open(src)); prs = Presentation(template)
|
||||
cover, master = prs.slides[0], prs.slides[1]
|
||||
originals = list(prs.slides)[1:]
|
||||
set_lines(shape(cover, "Neoride…").text_frame, [d["agency"]])
|
||||
tf = shape(cover, "Neoride…").text_frame
|
||||
sub = tf.add_paragraph(); r = sub.add_run(); r.text = d.get("subtitle", "Internal Project Kickoff")
|
||||
r.font.size = Pt(40); r.font.color.rgb = GOLD
|
||||
for spec in d["slides"]:
|
||||
pages = paginate(spec)
|
||||
for k, blocks in enumerate(pages):
|
||||
s = dup_slide(prs, master)
|
||||
title = spec["title"].split("\n") + (["(cont.)"] if k else [])
|
||||
set_lines(shape(s, "Transitpoint Software and Services").text_frame, title)
|
||||
body = shape(s, "TextBox 6", 1)
|
||||
text_blocks = [b for b in blocks if "table" not in b]
|
||||
if text_blocks:
|
||||
write_body(body, text_blocks)
|
||||
else:
|
||||
body._element.getparent().remove(body._element)
|
||||
top = BODY_T + (Emu(int(sum(est_lines(b) for b in text_blocks) * 560000)) if text_blocks else 0)
|
||||
for b in blocks:
|
||||
if "table" in b:
|
||||
top = add_table(s, b.get("heading", ""), b["table"]["columns"], b["table"]["rows"], top)
|
||||
for s in originals:
|
||||
drop_slide(prs, s)
|
||||
prs.save(out); print("saved", out, "slides:", len(prs.slides))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(*sys.argv[1:4])
|
||||
Reference in New Issue
Block a user