371 lines
14 KiB
Python
371 lines
14 KiB
Python
|
|
#!/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()
|