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)
|