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>
875 lines
33 KiB
Python
875 lines
33 KiB
Python
"""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)
|