Files
Skills/research-paper-presenter/scripts/build_odp.py
T

371 lines
14 KiB
Python
Raw Normal View History

#!/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()