#!/usr/bin/env python3 """ build_odp.py — Render a deck spec (JSON) into a native LibreOffice Impress (.odp) file. This produces a TRUE OpenDocument Presentation (not a .pptx). No PowerPoint, no conversion step. Images are embedded directly into the .odp container. Usage: python3 build_odp.py deck.json out.odp [--assets ASSETS_DIR] The deck spec schema is documented in SKILL.md. Image fields are filenames that are resolved relative to --assets (default: directory of deck.json). Slide types: title | section | content | bigimage | comparison | quote """ import argparse import json import os import sys from odf.opendocument import OpenDocumentPresentation from odf.style import ( Style, MasterPage, PageLayout, PageLayoutProperties, GraphicProperties, ParagraphProperties, TextProperties, DrawingPageProperties, ) from odf.text import P, Span from odf.draw import Page, Frame, TextBox, Image, Rect from odf.presentation import Notes # ---- 16:9 geometry (centimetres) ------------------------------------------- PW, PH = 33.867, 19.05 # page width / height (PowerPoint-widescreen equiv.) MARGIN = 1.7 # ---- palette --------------------------------------------------------------- THEMES = { "midnight": dict(bg="#0f172a", band="#1e293b", accent="#38bdf8", accent2="#a78bfa", title="#f8fafc", body="#cbd5e1", muted="#64748b", chip="#0ea5e9"), "paper": dict(bg="#fbfaf7", band="#efe9dd", accent="#b4541f", accent2="#1f6f6b", title="#1c1917", body="#3f3a35", muted="#8a8178", chip="#b4541f"), "forest": dict(bg="#0c1f1a", band="#13302a", accent="#34d399", accent2="#fbbf24", title="#ecfdf5", body="#bbf7d0", muted="#5e8b7e", chip="#10b981"), } def cm(v): return f"{v:.3f}cm" class Deck: def __init__(self, doc, theme): self.doc = doc self.t = theme self._n = 0 self._styles = {} # -- style helpers ------------------------------------------------------- def _key(self, prefix): self._n += 1 return f"{prefix}{self._n}" def gstyle(self, fill=None, stroke="none"): st = Style(name=self._key("gr"), family="graphic") props = {} if fill: props.update(fill="solid", fillcolor=fill) else: props.update(fill="none") props["stroke"] = stroke st.addElement(GraphicProperties(**props)) self.doc.automaticstyles.addElement(st) return st def pstyle(self, color, size, bold=False, align="left", spacing=None, font="Inter"): st = Style(name=self._key("pp"), family="paragraph") para = {"textalign": align} if spacing: para["marginbottom"] = cm(spacing) st.addElement(ParagraphProperties(**para)) st.addElement(TextProperties( color=color, fontsize=f"{size}pt", fontweight="bold" if bold else "normal", fontfamily=font, )) self.doc.automaticstyles.addElement(st) return st def dpstyle(self, bg): st = Style(name=self._key("dp"), family="drawing-page") st.addElement(DrawingPageProperties(fill="solid", fillcolor=bg, displayfooter="true", displaypagenumber="false")) self.doc.automaticstyles.addElement(st) return st # -- primitives ---------------------------------------------------------- def page(self, bg=None): dp = self.dpstyle(bg or self.t["bg"]) pg = Page(stylename=dp, masterpagename=self.master) self.doc.presentation.addElement(pg) return pg def rect(self, pg, x, y, w, h, fill): f = Rect(stylename=self.gstyle(fill=fill), x=cm(x), y=cm(y), width=cm(w), height=cm(h)) pg.addElement(f) def text(self, pg, x, y, w, h, lines, color=None, size=None, bold=False, align="left", line_spacing=0.0, font="Inter"): """lines: list of strings OR list of (text, size, color, bold) tuples.""" fr = Frame(stylename=self.gstyle(), x=cm(x), y=cm(y), width=cm(w), height=cm(h)) tb = TextBox() fr.addElement(tb) for ln in lines: if isinstance(ln, tuple): txt, s, c, b = ln ps = self.pstyle(c, s, b, align, line_spacing, font) else: txt, ps = ln, self.pstyle(color or "#ffffff", size or 14, bold, align, line_spacing, font) tb.addElement(P(stylename=ps, text=txt)) pg.addElement(fr) return fr def bullets(self, pg, x, y, w, h, items, color, size, accent, line_spacing=0.55): fr = Frame(stylename=self.gstyle(), x=cm(x), y=cm(y), width=cm(w), height=cm(h)) tb = TextBox() fr.addElement(tb) for it in items: sub = isinstance(it, dict) txt = it["text"] if sub else it indent = it.get("level", 0) if sub else 0 ps = Style(name=self._key("pp"), family="paragraph") ps.addElement(ParagraphProperties( marginbottom=cm(line_spacing), marginleft=cm(0.7 + indent * 0.9), textindent=cm(-0.7), )) ps.addElement(TextProperties(color=color, fontsize=f"{size}pt", fontfamily="Inter")) self.doc.automaticstyles.addElement(ps) bullet = "▸ " if indent == 0 else "– " p = P(stylename=ps) p.addElement(Span(text=bullet, stylename=self.tspan(accent, size, bold=True))) p.addElement(Span(text=txt)) tb.addElement(p) pg.addElement(fr) return fr def tspan(self, color, size, bold=False): st = Style(name=self._key("ts"), family="text") st.addElement(TextProperties(color=color, fontsize=f"{size}pt", fontweight="bold" if bold else "normal", fontfamily="Inter")) self.doc.automaticstyles.addElement(st) return st def picture(self, pg, path, x, y, w, h, fit=True): if not os.path.exists(path): # draw a placeholder so the deck still builds self.rect(pg, x, y, w, h, self.t["band"]) self.text(pg, x, y + h / 2 - 0.6, w, 1.2, [f"[missing image: {os.path.basename(path)}]"], self.t["muted"], 12, align="center") return iw, ih = _image_dims(path) if fit and iw and ih: scale = min(w / iw, h / ih) dw, dh = iw * scale, ih * scale dx, dy = x + (w - dw) / 2, y + (h - dh) / 2 else: dw, dh, dx, dy = w, h, x, y href = self.doc.addPicture(path) fr = Frame(x=cm(dx), y=cm(dy), width=cm(dw), height=cm(dh)) fr.addElement(Image(href=href, type="simple", show="embed", actuate="onLoad")) pg.addElement(fr) def notes(self, pg, text): if not text: return n = Notes() fr = Frame(x=cm(2), y=cm(12), width=cm(18), height=cm(6)) tb = TextBox() fr.addElement(tb) for para in text.split("\n"): tb.addElement(P(text=para)) n.addElement(fr) pg.addElement(n) # -- slide renderers ----------------------------------------------------- def slide_title(self, s): pg = self.page() self.rect(pg, 0, PH * 0.42, PW, 0.12, self.t["accent"]) self.rect(pg, 0, PH * 0.42 + 0.12, PW * 0.33, 0.12, self.t["accent2"]) self.text(pg, MARGIN, PH * 0.18, PW - 2 * MARGIN, 3, [(s.get("eyebrow", "RESEARCH WALKTHROUGH"), 14, self.t["accent"], True)]) self.text(pg, MARGIN, PH * 0.24, PW - 2 * MARGIN, 6, [(s["title"], 40, self.t["title"], True)]) if s.get("subtitle"): self.text(pg, MARGIN, PH * 0.55, PW - 2 * MARGIN, 4, [(s["subtitle"], 19, self.t["body"], False)]) meta = s.get("meta") if meta: self.text(pg, MARGIN, PH - 2.4, PW - 2 * MARGIN, 1.5, [(meta, 13, self.t["muted"], False)]) self.notes(pg, s.get("notes")) def slide_section(self, s): pg = self.page(bg=self.t["band"]) num = s.get("number", "") self.text(pg, MARGIN, PH * 0.28, 6, 4, [(str(num), 80, self.t["accent"], True)]) self.rect(pg, MARGIN, PH * 0.52, 4.5, 0.1, self.t["accent2"]) self.text(pg, MARGIN, PH * 0.55, PW - 2 * MARGIN, 4, [(s["title"], 34, self.t["title"], True)]) if s.get("subtitle"): self.text(pg, MARGIN, PH * 0.72, PW - 2 * MARGIN, 3, [(s["subtitle"], 17, self.t["body"], False)]) self.notes(pg, s.get("notes")) def _header(self, pg, title, kicker=None): self.rect(pg, 0, 0, 0.45, PH, self.t["accent"]) if kicker: self.text(pg, MARGIN, 1.0, PW - 2 * MARGIN, 1, [(kicker.upper(), 12, self.t["accent"], True)]) self.text(pg, MARGIN, 1.5, PW - 2 * MARGIN, 2.2, [(title, 26, self.t["title"], True)]) self.rect(pg, MARGIN, 3.5, 3.2, 0.08, self.t["accent2"]) def slide_content(self, s): pg = self.page() self._header(pg, s["title"], s.get("kicker")) has_img = bool(s.get("image")) bx, bw = MARGIN, (PW * 0.52 if has_img else PW - 2 * MARGIN) self.bullets(pg, bx, 4.4, bw, PH - 5.5, s.get("bullets", []), self.t["body"], 16, self.t["accent"]) if has_img: ix = PW * 0.56 iw = PW - ix - MARGIN self.picture(pg, s["_imgpath"], ix, 4.4, iw, PH - 5.8) if s.get("caption"): self.text(pg, ix, PH - 1.4, iw, 1, [(s["caption"], 11, self.t["muted"], False)], self.t["muted"], 11, align="center") self.notes(pg, s.get("notes")) def slide_bigimage(self, s): pg = self.page() self._header(pg, s["title"], s.get("kicker")) self.picture(pg, s["_imgpath"], MARGIN, 4.3, PW - 2 * MARGIN, PH - 6.2) if s.get("caption"): self.text(pg, MARGIN, PH - 1.6, PW - 2 * MARGIN, 1, [(s["caption"], 12, self.t["muted"], False)], self.t["muted"], 12, align="center") self.notes(pg, s.get("notes")) def slide_comparison(self, s): pg = self.page() self._header(pg, s["title"], s.get("kicker")) colw = (PW - 2 * MARGIN - 1.2) / 2 left = s.get("left", {}) right = s.get("right", {}) for i, col in enumerate((left, right)): x = MARGIN + i * (colw + 1.2) self.rect(pg, x, 4.3, colw, 1.0, self.t["accent"] if i == 0 else self.t["accent2"]) self.text(pg, x + 0.3, 4.5, colw - 0.6, 0.9, [(col.get("heading", ""), 15, self.t["bg"], True)]) self.bullets(pg, x, 5.7, colw, PH - 6.8, col.get("bullets", []), self.t["body"], 15, self.t["accent"] if i == 0 else self.t["accent2"]) self.notes(pg, s.get("notes")) def slide_quote(self, s): pg = self.page(bg=self.t["band"]) self.text(pg, MARGIN, PH * 0.2, 4, 4, [("\u201C", 90, self.t["accent"], True)]) self.text(pg, MARGIN + 0.3, PH * 0.34, PW - 2 * MARGIN - 1, 6, [(s["text"], 24, self.t["title"], False)]) if s.get("attribution"): self.text(pg, MARGIN + 0.3, PH * 0.74, PW - 2 * MARGIN, 2, [("— " + s["attribution"], 15, self.t["accent"], True)]) self.notes(pg, s.get("notes")) RENDER = { "title": slide_title, "section": slide_section, "content": slide_content, "bigimage": slide_bigimage, "comparison": slide_comparison, "quote": slide_quote, } def build(self, spec): # master page + layout pl = PageLayout(name="DeckPL") pl.addElement(PageLayoutProperties( pagewidth=cm(PW), pageheight=cm(PH), printorientation="landscape")) self.doc.automaticstyles.addElement(pl) self.master = MasterPage(name="DeckMaster", pagelayoutname=pl) self.doc.masterstyles.addElement(self.master) for s in spec["slides"]: renderer = self.RENDER.get(s["type"]) if renderer is None: print(f" ! unknown slide type: {s['type']}", file=sys.stderr) continue renderer(self, s) def _image_dims(path): try: from struct import unpack with open(path, "rb") as fh: head = fh.read(26) if head[:8] == b"\x89PNG\r\n\x1a\n": w, h = unpack(">II", head[16:24]) return w, h if head[:2] == b"\xff\xd8": # jpeg — walk markers with open(path, "rb") as fh: fh.read(2) while True: b = fh.read(1) while b and b != b"\xff": b = fh.read(1) marker = fh.read(1) if marker in (b"\xc0", b"\xc1", b"\xc2", b"\xc3"): fh.read(3) h, w = unpack(">HH", fh.read(4)) return w, h size = unpack(">H", fh.read(2))[0] fh.read(size - 2) except Exception: pass return None, None def main(): ap = argparse.ArgumentParser() ap.add_argument("spec") ap.add_argument("out") ap.add_argument("--assets", default=None) args = ap.parse_args() with open(args.spec) as fh: spec = json.load(fh) assets = args.assets or os.path.dirname(os.path.abspath(args.spec)) for s in spec["slides"]: if s.get("image"): s["_imgpath"] = os.path.join(assets, s["image"]) theme = THEMES.get(spec.get("theme", "midnight"), THEMES["midnight"]) doc = OpenDocumentPresentation() Deck(doc, theme).build(spec) doc.save(args.out) n = len(spec["slides"]) print(f"✓ wrote {args.out} ({n} slides, theme={spec.get('theme','midnight')})") if __name__ == "__main__": main()