Message Notes -> ProPresenter tool (v1.1)

Turns the pastor's sermon notes into the message presentation in
ProPresenter's Messages library, built from the series or generic template.

- Notes (RTF/Word) are parsed into title, scriptures (with bold emphasis),
  points, builds, quotes, picture slides and the Sermon in a Sentence;
  team instructions are left off and shown in the summary
- Slides are cloned from the template with fresh ids and the template's own
  formatting; a dependency-free protobuf codec reads/writes .pro files
- Text is checked to fit on the slide and in the stream and LED wall theme
  boxes; long scriptures split at verse numbers, font sizes never change
- Links the presentation into the Planning Center playlist's Message section
  (restarting ProPresenter safely, never while anything is live)
- Picks the newest notes from the email inbox folder (Google Apps Script),
  reads the speaker from the email, and writes the YouTube title and
  description with the Claude API for pasting into Restream
- Offline tests rebuild last Sunday's deck from its notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 15:58:03 -04:00
co-authored by Claude Opus 5.5
commit 7122fd144d
63 changed files with 8984 additions and 0 deletions
+874
View File
@@ -0,0 +1,874 @@
"""Template + slide plan -> ProPresenter presentation.
Every slide is a copy of the matching slide in the template (same boxes, fonts, looks,
macros, media), with fresh ids, as if someone had duplicated it in ProPresenter and typed
the text in. Text takes the template's own formatting: the template's sample text decides
which paragraph style a title, lead-in, point or verse gets.
"""
import json
import pkgutil
import re
import struct
import uuid as uuidlib
import notes as notesmod
import pbwire
import rtf
C = pbwire.Codec(json.loads(pkgutil.get_data("deck", "schema.json").decode("utf-8")))
CONTENT = ("point", "subpoint", "scripture", "quote", "prebuild", "build", "picture", "clearpicture")
TEXT_ELEMENT = {"point": "point", "subpoint": "subpoint", "quote": "quote", "build": "build",
"sentence": "sermoninasentence", "title": "title"}
# uuids that identify something inside a slide (regenerated when a slide is copied);
# every other uuid points at something shared (a macro, look, transition favourite...)
IDENTITY = {("rv.data.Cue", "uuid"), ("rv.data.Action", "uuid"), ("rv.data.Slide", "uuid"),
("rv.data.Graphics.Element", "uuid"), ("rv.data.Slide.Element.Build", "uuid"),
("rv.data.Slide.Element.ChildBuild", "uuid"), ("rv.data.AlignmentGuide", "uuid"),
("rv.data.Media", "uuid"), ("rv.data.Effect", "uuid"), ("rv.data.Group", "uuid")}
class TemplateError(Exception):
pass
def new_uuid():
return str(uuidlib.uuid4()).upper()
def uuid_node(s):
return pbwire.Node("rv.data.UUID", [[1, 2, s.encode()]])
def uuid_str(node):
return C.string(node, "string") if node is not None else ""
def dbl(node, name):
return C.double(node, name) if node is not None else 0.0
# ----------------------------------------------------------------------------- template
def slide_action(cue):
for a in C.all(cue, "actions"):
if C.get(a, "slide") is not None and C.path(a, "slide", "presentation") is not None:
return a
return None
def action_label(a):
lab = C.get(a, "label") if a is not None else None
return C.string(lab, "text") if lab is not None else ""
def base_slide(a):
return C.path(a, "slide", "presentation", "base_slide")
def slide_elements(a):
bs = base_slide(a)
return C.all(bs, "elements") if bs is not None else []
def element_name(el):
return C.string(C.get(el, "element"), "name")
def element_text(el):
return C.path(el, "element", "text")
class ElementStyle:
"""Formatting of one template text box, paragraph by paragraph."""
def __init__(self, el, theme=False):
self.name = element_name(el)
text = element_text(el)
data = C.get(text, "rtf_data", b"")
doc = rtf.read(data)
self.header = doc.header
self.paras = []
for p in doc.paras:
runs = [r for r in p.runs if r.text.strip()] or p.runs
first = dict(runs[0].state) if runs else dict(p.end_state or {})
self.paras.append((p.par_codes, first, dict(p.end_state or first), p.text))
if theme or not self.paras or "\\fonttbl\\f" not in self.header:
# a theme box formats all ordinary text one way: its box-level attributes
self._from_attributes(C.get(text, "attributes"))
b = C.path(el, "element", "bounds", "size")
self.w, self.h = dbl(b, "width"), dbl(b, "height")
o = C.path(el, "element", "bounds", "origin")
self.y = dbl(o, "y")
self.valign = C.get(text, "vertical_alignment", 0) # 0 top, 1 middle, 2 bottom
self.slide_h = 1080.0
# fallback font records from ProPresenter's own run list
self.fonts = []
attrs = C.get(text, "attributes")
for ca in (C.all(attrs, "custom_attributes") if attrs is not None else []):
f = C.get(ca, "original_font")
if f is not None:
self.fonts.append(f)
self.base_font = C.get(attrs, "font") if attrs is not None else None
def _from_attributes(self, attrs):
font = C.get(attrs, "font") if attrs is not None else None
name = C.string(font, "name") or "Helvetica"
size = C.double(font, "size") if font is not None else 12.0
ps = C.get(attrs, "paragraph_style") if attrs is not None else None
align = C.get(ps, "alignment", 0) if ps is not None else 0
kern = C.double(attrs, "kerning") if attrs is not None else 0.0
lig = C.get(attrs, "ligature_style", 0) if attrs is not None else 0
self.header = ("{\\rtf1\\ansi\\ansicpg1252\\cocoartf2867\n\\cocoatextscaling0\\cocoaplatform0"
"{\\fonttbl\\f0\\fnil\\fcharset0 %s;}\n{\\colortbl;\\red255\\green255\\blue255;"
"\\red255\\green255\\blue255;}\n{\\*\\expandedcolortbl;;\\cssrgb\\c100000\\c100000"
"\\c100000;}\n\\deftab1680\n" % name)
codes = [("pard", None), ("pardeftab", 1680), ("pardirnatural", None),
({0: "ql", 1: "qr", 2: "qc", 3: "qj"}.get(align, "ql"), None), ("partightenfactor", 0)]
st = {"f": 0, "fs": int(round(size * 2)), "cf": 2}
if kern:
st.update({"kerning": 1, "expnd": int(round(kern * 4)), "expndtw": int(round(kern * 20))})
if lig == 1:
st["CocoaLigature"] = 0
self.paras = [(codes, st, dict(st), "")]
def para(self, k):
if k < 0:
k = len(self.paras) + k
return self.paras[max(0, min(k, len(self.paras) - 1))]
class Template:
def __init__(self, data, path=""):
self.path = path
self.pres = C.decode(data, "rv.data.Presentation")
self.name = C.string(self.pres, "name")
cues = {uuid_str(C.get(c, "uuid")): c for c in C.all(self.pres, "cues")}
self.order = []
for g in C.all(self.pres, "cue_groups"):
for cid in C.all(g, "cue_identifiers"):
c = cues.get(uuid_str(cid))
if c is not None:
self.order.append(c)
self.kinds = [classify(c) for c in self.order]
self.sample = {}
for c, k in zip(self.order, self.kinds):
self.sample.setdefault(k, c)
if "title" not in self.sample:
raise TemplateError("The template has no Title slide (a text box named 'Title').")
if "scripture" not in self.sample or "point" not in self.sample:
raise TemplateError("The template needs a SCRIPTURE slide and a Point slide.")
ti = self.kinds.index("title")
self.head = list(range(0, ti + 1))
if "sentence" in self.kinds:
si = self.kinds.index("sentence")
self.tail = list(range(si + 1, len(self.order)))
else:
last = max(i for i, k in enumerate(self.kinds) if k in CONTENT)
self.tail = list(range(last + 1, len(self.order)))
self.styles = {}
for k, c in self.sample.items():
a = slide_action(c)
size = C.get(base_slide(a), "size") if base_slide(a) is not None else None
for el in slide_elements(a):
if element_text(el) is not None and C.get(element_text(el), "rtf_data"):
st = ElementStyle(el)
if size is not None and dbl(size, "height"):
st.slide_h = dbl(size, "height")
self.styles[(k, element_name(el).lower())] = st
self.notes_styles = {}
default = None
for c, k in zip(self.order, self.kinds):
n = C.path(slide_action(c), "slide", "presentation", "notes")
if n is not None and C.get(n, "rtf_data"):
st = notes_style(C.get(n, "rtf_data"))
self.notes_styles.setdefault(k, st)
if default is None and k in ("build", "other", "speaker"):
default = st
self.default_notes = default or next(iter(self.notes_styles.values()), None)
# the series name shown in the template's sample title ("Where Is Your Faith")
self.sample_title = ""
st = self.styles.get(("title", "title"))
if st and st.paras:
self.sample_title = st.paras[0][3].strip()
def style(self, kind, element):
return self.styles.get((kind, element.lower()))
def speaker(self):
"""(role, name) as typed in the template's SPEAKER NAME slide."""
return speaker_of(self.sample.get("speaker"))
def speaker_of(cue):
out = {}
if cue is not None:
for el in slide_elements(slide_action(cue)):
t = element_text(el)
data = C.get(t, "rtf_data") if t is not None else None
if data:
out[element_name(el).lower()] = " ".join(p.text for p in rtf.read(data).paras).strip()
return out.get("role", ""), out.get("preacher", "")
def classify(cue):
a = slide_action(cue)
if a is None:
return "other"
label = action_label(a).lower()
names = {element_name(el).lower() for el in slide_elements(a)}
if "sermon in a sentence" in label or "sermoninasentence" in names:
return "sentence"
if "role" in names and "preacher" in names:
return "speaker"
if "reference" in names and "scripture" in names:
return "scripture"
if "build" in names:
return "build"
if "title" in names:
return "title"
if "subpoint" in names:
return "subpoint"
if "point" in names:
return "point"
if "quote" in names:
return "quote"
if "stream deck" in label:
return "prebuild"
if label == "clear picture":
return "clearpicture"
if label == "picture":
return "picture"
if label == "quiet":
return "quiet"
return "other"
def notes_style(data):
doc = rtf.read(data)
if doc.paras:
p = doc.paras[0]
st = dict((p.runs[0].state if p.runs else p.end_state) or {})
return doc.header, p.par_codes, st
return doc.header, [("pard", None)], {"f": 0, "fs": 24}
# ----------------------------------------------------------------------------- text
def scaled(state, scale):
if scale == 1.0 or "fs" not in state:
return state
s = dict(state)
s["fs"] = max(2, int(round(state["fs"] * scale)))
return s
def with_emphasis(state, bold, italic):
s = dict(state)
if bold:
s["b"] = None
if italic:
s["i"] = None
return s
def make_rtf(style, paras, scale=1.0):
"""paras: [(template paragraph index, [(text, bold, italic), ...]), ...]"""
out = []
for k, (pi, segs) in enumerate(paras):
codes, first, end, _ = style.para(pi)
runs = [(t, scaled(with_emphasis(first, b, i), scale)) for t, b, i in segs if t]
if not runs:
runs = [("", scaled(first, scale))]
out.append((codes, runs))
return rtf.write(style.header, out)
def notes_rtf(nstyle, text):
header, codes, st = nstyle
paras = [(codes, [(line, st)]) for line in text.split("\n")]
return rtf.write(header, paras)
def slide_texts(tpl, s):
"""What goes into each text box of one plan slide: {element: [(para idx, segs)]}."""
L = s.lines
if s.kind in ("picture", "clearpicture"):
return {}
if s.kind == "title":
roles = notesmod.title_roles(L)
idx = {"title": 0, "subtitle": 1, "reference": -1}
return {"title": [(idx[r], ln.segs_plain()) for r, ln in zip(roles, L)]}
if s.kind == "point":
two = len(tpl.style("point", "point").paras) > 1
return {"point": [((0 if (ln.lead and two) else -1), ln.segs_plain()) for ln in L]}
if s.kind == "subpoint":
return {"subpoint": [(0, ln.segs_plain()) for ln in L]}
if s.kind == "quote":
out = []
for ln in L:
attrib = re.match(r"^\s*[-\u2013\u2014~]", ln.text) is not None
out.append((-1 if attrib else 0, ln.segs_plain()))
return {"quote": out}
if s.kind == "build":
return {"build": [(0, ln.segs_plain()) for ln in L]}
if s.kind == "sentence":
st = tpl.style("sentence", "sermoninasentence")
label = st.paras[0][3].strip() if st and len(st.paras) > 1 else ""
out = [(0, [(label, False, False)])] if label else []
return {"sermoninasentence": out + [(-1, ln.segs_plain()) for ln in L]}
if s.kind == "scripture":
# the template's sample reference ends with a space ("Exodus 8:1 ESV "); keep that habit
ref_st = tpl.style("scripture", "reference")
tail = " " if ref_st and ref_st.paras[0][3].endswith(" ") and not s.ref.endswith(" ") else ""
return {"reference": [(0, [(s.ref + tail, False, False)])],
"scripture": [(0, ln.segs) for ln in L] or [(0, [("", False, False)])]}
return {}
def slide_notes(tpl, s):
if s.kind in ("picture", "clearpicture"):
return s.text
if s.kind == "title":
return " - ".join(l.text for l in s.lines)
if s.kind == "scripture":
return (s.ref + " " + " ".join(l.text for l in s.lines)).strip()
if s.kind == "sentence":
st = tpl.style("sentence", "sermoninasentence")
label = st.paras[0][3].strip() if st and len(st.paras) > 1 else "Sermon in a Sentence:"
return (label + " " + " ".join(l.text for l in s.lines)).strip()
return "\n".join(l.text for l in s.lines)
def kind_for(tpl, s):
k = s.kind
if k in ("picture", "clearpicture"):
return k
if k not in tpl.sample:
k = "point"
return k
# ----------------------------------------------------------------------------- fitting
def element_rtfs(tpl, s):
"""[(element name, ElementStyle, rtf bytes)] for one plan slide at its current scale."""
out = []
kind = kind_for(tpl, s)
texts = slide_texts(tpl, s) if kind == s.kind else _as_point(tpl, s)
for name, paras in texts.items():
st = tpl.style(kind, name)
if st is None:
continue
out.append((name, st, make_rtf(st, paras, s.scale.get(name, 1.0))))
return out
def _as_point(tpl, s):
return {"point": [(-1, ln.segs_plain()) for ln in s.lines]}
def effective(st, paras):
"""Paragraph runs as they really look on the slide: [[(text, bold, italic)]]."""
out = []
for pi, segs in paras:
_, first, _, _ = st.para(pi)
base_b = "b" in first
base_i = "i" in first
out.append([(t, base_b or b, base_i or i) for t, b, i in segs if t])
return out
def output_rtf(tpl, kind, name, st_out, paras):
"""The same text as a themed screen draws it: the theme's font and size, with only the
'special' words (styled differently from the rest of the box) keeping bold/italic."""
st_slide = tpl.style(kind, name)
eff = effective(st_slide, paras) if st_slide is not None else [[(t, b, i) for t, b, i in segs] for _, segs in paras]
count = {}
for para in eff:
for t, b, i in para:
count[(b, i)] = count.get((b, i), 0) + len(t)
base = max(count, key=count.get) if count else (False, False)
out = []
for para in eff:
segs = [(t, b, i) if (b, i) != base else (t, False, False) for t, b, i in para]
out.append((0, segs))
return make_rtf(st_out, out)
SAFE_BOTTOM = 12.0 / 1080.0 # text must end at least this far above the slide's bottom edge
def fits(st, h):
if h > st.h + 0.5:
return False
if st.valign == 1:
bottom = st.y + st.h / 2.0 + h / 2.0
elif st.valign == 2:
bottom = st.y + st.h
else:
bottom = st.y + h
return bottom <= st.slide_h * (1 - SAFE_BOTTOM) + 0.5
def checks(tpl, s, outputs):
"""Every place one plan slide's text will be drawn:
[(where, element, ElementStyle, rtf)] - where is None for the slide itself."""
out = [(None, name, st, data) for name, st, data in element_rtfs(tpl, s)]
kind = kind_for(tpl, s)
texts = slide_texts(tpl, s) if kind == s.kind else _as_point(tpl, s)
for o in outputs or []:
for name, paras in texts.items():
st = o.styles.get(name)
if st is not None:
out.append((o.name, name, st, output_rtf(tpl, kind, name, st, paras)))
return out
def measure_all(tpl, plan, outputs, measure):
items, where = [], []
for si, s in enumerate(plan.slides):
for w, name, st, data in checks(tpl, s, outputs):
items.append({"rtf": data, "w": st.w})
where.append((si, w, name, st))
return measure(items), where
def line_height(st, measure_cache, measure):
key = id(st)
if key not in measure_cache:
r = measure([{"rtf": make_rtf(st, [(0, [("Xg", False, False)])]), "w": st.w}])[0]
measure_cache[key] = max(1.0, r["h"])
return measure_cache[key]
def fit(tpl, plan, measure, outputs=None, min_scale=0.7):
"""Check every text everywhere it is shown. Font sizes are never changed.
Scriptures that are too long anywhere are split at verse numbers. Anything else that
doesn't fit is reported on the slide (s.flags): for the slide's own box with the size it
would need, for a theme box (stream, LED wall) with the lines it needs and has room for.
Returns (results, where) for the slide-level boxes, used for ProPresenter's run list.
"""
for s in plan.slides:
s.flags = [f for f in s.flags if not f.startswith(("Font reduced", "Text is too long", "Too long for"))]
s.scale = {}
lh_cache = {}
for _round in range(6):
res, where = measure_all(tpl, plan, outputs, measure)
over = [(w, r) for w, r in zip(where, res) if not fits(w[3], r["h"])]
split = sorted({w[0] for w, r in over if w[2] == "scripture"
and plan.slides[w[0]].kind == "scripture" and not getattr(plan.slides[w[0]], "_nosplit", False)})
if split:
new = []
for si, s in enumerate(plan.slides):
new.extend(split_scripture(tpl, s, measure, outputs) if si in split else [s])
plan.slides = new
continue
break
# slide-level boxes: find the size that would fit, but only report it
own = [(w, r) for w, r in over if w[1] is None]
cands, cwhere = [], []
for (si, _, name, st), r in own:
s = plan.slides[si]
base = max([p[1].get("fs", 24) for p in st.paras] or [24])
for dd in range(1, int(base * (1 - min_scale) / 2) + 2):
sc = (base - 2 * dd) / float(base)
if sc < min_scale - 1e-9:
break
s2 = notesmod.Slide(s.kind, s.lines, s.ref)
s2.scale = {name: sc}
for nm, st2, data in element_rtfs(tpl, s2):
if nm == name:
cands.append({"rtf": data, "w": st2.w})
cwhere.append((si, name, sc, st2))
cres = measure(cands) if cands else []
best = {}
for (si, name, sc, st2), r in zip(cwhere, cres):
if fits(st2, r["h"]) and sc > best.get((si, name), 0):
best[(si, name)] = sc
for (si, w, name, st), r in over:
s = plan.slides[si]
if w is None:
base = max([p[1].get("fs", 24) for p in st.paras] or [24]) / 2.0
if (si, name) in best:
msg = "Too long for the slide's own box at %gpt (fits at %gpt) - font NOT changed" % (
base, round(base * best[(si, name)]))
else:
msg = "Too long for the slide's own box at %gpt, even at %gpt - font NOT changed" % (
base, round(base * min_scale))
else:
lh = line_height(st, lh_cache, measure)
need = int(round(r["h"] / lh))
room = max(1, int((min(st.h, st.slide_h * (1 - SAFE_BOTTOM) - st.y) + 0.5) // lh))
msg = "Too long for %s: %d lines, room for %d" % (w, need, room)
if msg not in s.flags:
s.flags.append(msg)
return res, where
_VERSE_START = re.compile(r"(?<=[\s\u201d\u2019\"'.;:,!?)\]])(?=\d{1,3}[a-c]?\s+[\u201c\u2018\"'(A-Za-z])")
_SENT_END = re.compile(r"[.!?;:][\u201d\u2019\"']?\s+(?=[\u201c\u2018\"'A-Z0-9])")
_CLAUSE = re.compile(r"(?<=[,\u2014])\s*")
def split_scripture(tpl, s, measure, outputs=None):
"""One long scripture -> as few slides as possible that fit everywhere, cut at verse
numbers if possible (then sentences, then clauses), pieces as even as possible."""
flat = [] # (text, bold, italic) with "\n" between paragraphs
for k, ln in enumerate(s.lines):
if k:
flat.append(("\n", False, False))
flat.extend(ln.segs)
text = "".join(t for t, _, _ in flat)
for level in (_VERSE_START, _SENT_END, _CLAUSE):
bounds = {0, len(text)} | {i + 1 for i, ch in enumerate(text) if ch == "\n"}
bounds |= {m.start() if level is _VERSE_START else m.end() for m in level.finditer(text)}
bounds = sorted(b for b in bounds if 0 <= b <= len(text))
if len(bounds) <= 2:
continue
pairs = [(i, j) for i in range(len(bounds)) for j in range(i + 1, len(bounds))]
items, owner = [], []
for pi, (i, j) in enumerate(pairs):
piece = slice_segs(flat, bounds[i], bounds[j])
ps = notesmod.Slide("scripture", [notesmod.Line(l) for l in split_lines(piece)], ref=s.ref)
for w, name, st, data in checks(tpl, ps, outputs):
if name == "scripture":
items.append({"rtf": data, "w": st.w})
owner.append((pi, st))
res = measure(items)
ok = {p: True for p in range(len(pairs))}
for (pi, st), r in zip(owner, res):
if not fits(st, r["h"]):
ok[pi] = False
okp = {pairs[p]: v for p, v in ok.items()}
n = len(bounds)
best = {0: (0, 0, [])}
for j in range(1, n):
for i in range(j):
if i in best and okp[(i, j)]:
cnt, worst, path = best[i]
cand = (cnt + 1, max(worst, bounds[j] - bounds[i]), path + [j])
if j not in best or cand[:2] < best[j][:2]:
best[j] = cand
if n - 1 in best:
cuts = [0] + best[n - 1][2]
total = len(cuts) - 1
out = []
for k in range(total):
piece = slice_segs(flat, bounds[cuts[k]], bounds[cuts[k + 1]])
ns = notesmod.Slide("scripture", [notesmod.Line(l) for l in split_lines(piece)],
ref=s.ref, src=s.src)
ns.part = (k + 1, total)
ns.flags = [f for f in s.flags if not f.startswith("Long scripture")] + \
["Long scripture - split over %d slides (%d/%d)" % (total, k + 1, total)]
out.append(ns)
return out
s._nosplit = True
s.flags.append("Scripture is too long for one slide and could not be split - check it")
return [s]
def slice_segs(flat, a, b):
out, pos = [], 0
for t, bo, it in flat:
x, y = pos, pos + len(t)
pos = y
lo, hi = max(a, x), min(b, y)
if lo < hi:
out.append((t[lo - x:hi - x], bo, it))
while out and not out[0][0].strip():
out.pop(0)
while out and not out[-1][0].strip():
out.pop()
if out:
out[0] = (out[0][0].lstrip(),) + out[0][1:]
out[-1] = (out[-1][0].rstrip(),) + out[-1][1:]
return out
def split_lines(segs):
lines, cur = [], []
for t, b, i in segs:
parts = t.split("\n")
for k, part in enumerate(parts):
if k:
lines.append(cur)
cur = []
if part:
cur.append((part, b, i))
lines.append(cur)
return [l for l in lines if l]
# ----------------------------------------------------------------------------- building
def clone_cue(cue):
c = C.copy(cue)
mapping = {}
def collect(node):
spec = C.schema.get(node.type, {})
for num, wt, val in node.fields:
if isinstance(val, pbwire.Node):
f = spec.get(str(num))
if val.type == "rv.data.UUID" and f and (node.type, f[0]) in IDENTITY:
s = uuid_str(val)
if s and s not in mapping:
mapping[s] = new_uuid()
collect(val)
def remap(node):
for fld in node.fields:
val = fld[2]
if isinstance(val, pbwire.Node):
if val.type == "rv.data.UUID":
s = uuid_str(val)
if s in mapping:
C.set(val, "string", mapping[s].encode())
else:
remap(val)
collect(c)
remap(c)
return c
def set_repeated(node, name, values):
n = C.num(node, name)
pos = None
keep = []
for k, f in enumerate(node.fields):
if f[0] == n:
if pos is None:
pos = len(keep)
else:
keep.append(f)
if pos is None:
pos = len(keep)
for k, f in enumerate(keep):
if f[0] > n:
pos = k
break
node.fields = keep[:pos] + [[n, 2, v] for v in values] + keep[pos:]
def font_node(name, size, bold, italic, family):
f = pbwire.Node("rv.data.Font", [])
C.set(f, "name", name.encode())
C.set_double(f, "size", float(size))
if italic:
C.set(f, "italic", 1, wt=0)
if bold:
C.set(f, "bold", 1, wt=0)
C.set(f, "family", family.encode())
return f
def set_text(el, data, runs=None, scale=1.0, style=None):
"""Put new RTF into a slide element and keep ProPresenter's run list in step."""
text = element_text(el)
C.set(text, "rtf_data", data)
attrs = C.get(text, "attributes")
if attrs is None:
return
cas = []
if runs:
for r in runs:
ca = pbwire.Node("rv.data.Graphics.Text.Attributes.CustomAttribute", [])
rng = pbwire.Node("rv.data.IntRange", [])
if r["loc"]:
C.set(rng, "start", r["loc"], wt=0)
C.set(rng, "end", r["loc"] + r["len"], wt=0)
C.set(ca, "range", rng)
C.set(ca, "original_font", font_node(r["name"], r["size"], r["bold"], r["italic"], r["family"]))
cas.append(ca)
first = runs[0]
C.set(attrs, "font", font_node(first["name"], first["size"], first["bold"], first["italic"],
first["family"]))
elif style is not None and style.fonts:
# no text layout available: reuse the template's own font records
n16 = len(rtf_plain(data).encode("utf-16-le")) // 2
f = C.copy(style.fonts[-1] if len(style.fonts) > 1 else style.fonts[0])
sz = C.double(f, "size") * scale
C.set_double(f, "size", sz)
ca = pbwire.Node("rv.data.Graphics.Text.Attributes.CustomAttribute", [])
rng = pbwire.Node("rv.data.IntRange", [])
C.set(rng, "end", n16, wt=0)
C.set(ca, "range", rng)
C.set(ca, "original_font", f)
cas.append(ca)
set_repeated(attrs, "custom_attributes", cas)
def rtf_plain(data):
return "\n".join(p.text for p in rtf.read(data).paras)
def set_notes(a, nstyle, text):
ps = C.path(a, "slide", "presentation")
if ps is None or nstyle is None:
return
n = C.get(ps, "notes")
if n is None:
n = pbwire.Node("rv.data.PresentationSlide.Notes", [])
C.set(n, "attributes", pbwire.Node("rv.data.Graphics.Text.Attributes", []))
C.set(ps, "notes", n)
C.set(n, "rtf_data", notes_rtf(nstyle, text))
def rebuild_builds(a, el, nlines):
"""One build step per line, the way ProPresenter numbers them."""
bi = C.get(el, "build_in")
if bi is None:
return
proto = (C.all(el, "childBuilds") or [None])[0]
kids = []
for k in range(max(0, nlines - 1)):
ch = C.copy(proto) if proto is not None else pbwire.Node("rv.data.Slide.Element.ChildBuild", [])
C.set(ch, "uuid", uuid_node(new_uuid()))
if k:
C.set(ch, "index", k, wt=0)
else:
C.remove(ch, "index")
kids.append(ch)
set_repeated(el, "childBuilds", kids)
bs = base_slide(a)
order = []
for e in C.all(bs, "elements"):
b = C.get(e, "build_in")
if b is not None:
order.append(uuid_node(uuid_str(C.get(b, "uuid"))))
for ch in C.all(e, "childBuilds"):
order.append(uuid_node(uuid_str(C.get(ch, "uuid"))))
set_repeated(bs, "element_build_order", order)
def build(tpl, plan, name, speaker, layout=None, speaker_runs=None):
"""-> .pro bytes. layout: (results, where) from fit(), for exact font runs."""
runs = {}
if layout:
res, where = layout
for (si, w, el_name, st), r in zip(where, res):
if w is None:
runs[(si, el_name)] = r["runs"]
pres = C.copy(tpl.pres)
C.set(pres, "uuid", uuid_node(new_uuid()))
C.set(pres, "name", name.encode("utf-8"))
out = []
def fill(cue, s, si):
kind = kind_for(tpl, s)
a = slide_action(cue)
texts = slide_texts(tpl, s) if kind == s.kind else _as_point(tpl, s)
rtfs = {nm: data for nm, st, data in element_rtfs(tpl, s)}
for el in slide_elements(a):
nm = element_name(el).lower()
if nm in rtfs:
set_text(el, rtfs[nm], runs.get((si, nm)), s.scale.get(nm, 1.0), tpl.style(kind, nm))
if nm == "build":
rebuild_builds(a, el, len(texts.get("build", [])))
set_notes(a, tpl.notes_styles.get(kind) or tpl.default_notes, slide_notes(tpl, s))
role, preacher = speaker
title_slide = None
for si, s in enumerate(plan.slides):
if s.kind == "title":
title_slide = (si, s)
break
for i in tpl.head:
cue = clone_cue(tpl.order[i])
k = tpl.kinds[i]
if k == "speaker":
fill_speaker(tpl, cue, role, preacher, speaker_runs)
elif k == "title" and title_slide is not None:
fill(cue, title_slide[1], title_slide[0])
out.append(cue)
for si, s in enumerate(plan.slides):
if s.kind == "title":
continue
kind = kind_for(tpl, s)
if kind not in tpl.sample:
continue # e.g. a picture slide with a template that has none
if kind == "build" and "prebuild" in tpl.sample:
out.append(clone_cue(tpl.sample["prebuild"]))
cue = clone_cue(tpl.sample[kind])
if kind in ("picture", "clearpicture"):
if s.text.strip():
set_notes(slide_action(cue), tpl.notes_styles.get(kind) or tpl.default_notes, s.text)
else:
fill(cue, s, si)
out.append(cue)
for i in tpl.tail:
out.append(clone_cue(tpl.order[i]))
groups = C.all(pres, "cue_groups")
g = C.copy(groups[0]) if groups else pbwire.Node("rv.data.Presentation.CueGroup", [])
grp = C.get(g, "group")
if grp is not None:
C.set(grp, "uuid", uuid_node(new_uuid()))
set_repeated(g, "cue_identifiers", [uuid_node(uuid_str(C.get(c, "uuid"))) for c in out])
set_repeated(pres, "cue_groups", [g])
set_repeated(pres, "cues", out)
for f in ("arrangements", "selected_arrangement"):
C.remove(pres, f)
return C.encode(pres)
def slide_numbers(tpl, plan):
"""The ProPresenter slide number (1-based) each plan slide will have in the built deck."""
nums = []
n = len(tpl.head)
title_at = tpl.head.index(tpl.kinds.index("title")) + 1
for s in plan.slides:
if s.kind == "title":
nums.append(title_at)
continue
kind = kind_for(tpl, s)
if kind not in tpl.sample:
nums.append(None)
continue
if kind == "build" and "prebuild" in tpl.sample:
n += 1
n += 1
nums.append(n)
return nums
def slide_count(tpl, plan):
nums = [x for x in slide_numbers(tpl, plan) if x]
return max(nums + [len(tpl.head)]) + len(tpl.tail)
def ensure_pictures(tpl, plan):
"""Keep the template's PICTURE / CLEAR PICTURE pair even when the notes have no picture,
just before the Sermon in a Sentence (where the template has it)."""
if any(s.kind == "picture" for s in plan.slides) or "picture" not in tpl.sample:
return
at = next((k for k, s in enumerate(plan.slides) if s.kind == "sentence"), len(plan.slides))
pair = [notesmod.Slide("picture", [])]
if "clearpicture" in tpl.sample:
pair.append(notesmod.Slide("clearpicture", []))
plan.slides[at:at] = pair
plan.skipped = [x[:3] + ((x[3] + len(pair)) if x[3] > at else x[3],) if len(x) > 3 else x
for x in plan.skipped]
def speaker_rtfs(tpl, role, preacher):
out = []
for nm, text in (("role", role), ("preacher", preacher)):
st = tpl.style("speaker", nm)
if st is not None and text:
out.append((nm, st, make_rtf(st, [(0, [(text, False, False)])])))
return out
def fill_speaker(tpl, cue, role, preacher, runs_by_name=None):
a = slide_action(cue)
data = {nm: (st, d) for nm, st, d in speaker_rtfs(tpl, role, preacher)}
for el in slide_elements(a):
nm = element_name(el).lower()
if nm in data:
st, d = data[nm]
set_text(el, d, (runs_by_name or {}).get(nm), 1.0, st)