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

371 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()