Builds as one slide per step when the template has Build 1, 2, ... (v1.3.2)

- Template.step_builds: build slides without ProPresenter's build feature;
  each build in the notes becomes one slide per step from the template's
  k-th build slide (last one reused): heading + first item, then one more
  line per slide; font runs cut to each step's text
- Build headings (":"/"?"/italic, or above a numbered / same-word list) kept
  as Line.lead; TextEdit format marks them with ">"
- Prefer a one-slide-per-step template over a newer old-style one
- Clear message when a notes file in Google Drive can't be read
- README: builds, underline, fonts are never changed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 11:40:04 -04:00
co-authored by Claude Opus 5.5
parent f2306f781e
commit 042fe30396
7 changed files with 145 additions and 22 deletions
+14 -2
View File
@@ -75,12 +75,23 @@ Tools first if Python is missing). The first run asks which campus it is and sav
|---|---|
| first line / second line / bare reference | **Title** slide (title, subtitle, main passage) |
| paragraph starting with a reference (`John 1:12 ESV …`) | **SCRIPTURE**. Bold words stay bold. |
| underlined words (any slide) | underlined on the slide |
| a line on its own | **Point** |
| lead-in (`…:` / `…?` / italic) + one line | one **Point**, lead-in in the italic style |
| 3+ lines together, a list, or after "(one at a time)" | **\*STREAM DECK\*** slide + **Build** (one step per line) |
| 3+ lines together, a list, or after "(one at a time)" | a **Build**: one step per line (see below) |
| `Sermon in a Sentence:` + sentence | **SERMON IN A SENTENCE** |
| `(…)`, `[…]`, `// …`, coloured non-scripture notes | left off, shown in the review |
**Builds** follow the template:
- slides **Build 1, Build 2, …** (no build feature on them): one slide per step. Step 1 is the
heading with the first item, each next slide adds one line. Step *k* is a copy of the
template's *k*-th build slide (the last one again for longer builds). The heading is a first
line ending in `:`/`?`, italic, or above a numbered / same-word list.
- one **Build** slide using ProPresenter's build feature: a **\*STREAM DECK\*** slide, then the
build with one build step per line.
With two templates of the same kind, the one-slide-per-step one is used, else the newest.
Formatting always comes from the template's own sample slides. To change how something looks,
change the template in ProPresenter and the tool follows. Templates are found by name: any
`_TEMPLATE … DO NOT DELETE …` presentation in Messages, with `GENERIC` marking the generic one.
@@ -95,7 +106,8 @@ On themed screens ProPresenter uses the theme's font size, and only words styled
from the rest of their box keep bold/italic.
- Scriptures too long anywhere are split at verse numbers into as few, even slides as possible,
all with the same reference.
- Other text too big for the slide is reduced in 1pt steps.
- Other text too big for the slide is reported with the size it would fit at; font sizes are
never changed (several outputs share them).
- Text too long for a theme box is flagged, since the theme fixes the size there.
## Files
+20 -5
View File
@@ -33,7 +33,7 @@ import spelling
import themes
import youtube
VERSION = "1.3.1"
VERSION = "1.3.2"
API = None # ProPresenter's network API on this Mac - set in run() from its preferences
SUPPORT = os.path.expanduser("~/Library/Application Support/MessageNotes")
LOG = os.path.expanduser("~/Library/Logs/message-notes.log")
@@ -113,10 +113,20 @@ def find_library(explicit=None):
return settings.find_library(explicit=explicit)
def template_rank(path):
"""Which of two templates of the same kind to use: one that shows builds as one slide per
step (Build 1, Build 2, ...) first, then the most recently saved."""
try:
step = deck.Template(open(path, "rb").read(), path).step_builds
except Exception:
step = False
return (step, os.path.getmtime(path))
def find_templates(library):
out = [os.path.join(library, f) for f in sorted(os.listdir(library)) if settings.is_template(f)]
series = sorted([p for p in out if "generic" not in os.path.basename(p).lower()], key=os.path.getmtime, reverse=True)
generic = sorted([p for p in out if "generic" in os.path.basename(p).lower()], key=os.path.getmtime, reverse=True)
series = sorted([p for p in out if "generic" not in os.path.basename(p).lower()], key=template_rank, reverse=True)
generic = sorted([p for p in out if "generic" in os.path.basename(p).lower()], key=template_rank, reverse=True)
return series, generic
@@ -155,8 +165,13 @@ def read_notes(path):
"""Notes file -> RTF bytes (converting Word with macOS textutil)."""
low = path.lower()
if low.endswith(".rtf"):
with open(path, "rb") as f:
return f.read()
try:
with open(path, "rb") as f:
return f.read()
except OSError as e:
why = "Google Drive isn't running or hasn't downloaded it - open Google Drive, or save the " \
"notes into Downloads" if "/CloudStorage/" in path else e.strerror or str(e)
raise SystemExit("Could not read %s\n (%s)" % (os.path.basename(path), why))
src, tmpdir = path, None
if low.endswith(".docx"):
tmpdir = tempfile.mkdtemp(prefix="msgnotes-")
+50 -10
View File
@@ -155,6 +155,11 @@ class Template:
self.sample = {}
for c, k in zip(self.order, self.kinds):
self.sample.setdefault(k, c)
# a build is either one slide using ProPresenter's build feature ("Build *DO NOT CLEAR
# MID BUILD*"), or one slide per step ("Build 1", "Build 2", ...) - the template decides
self.builds = [c for c, k in zip(self.order, self.kinds) if k == "build"]
self.step_builds = bool(self.builds) and not any(
C.get(el, "build_in") is not None for el in slide_elements(slide_action(self.builds[0])))
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:
@@ -747,6 +752,22 @@ def rebuild_builds(a, el, nlines):
set_repeated(bs, "element_build_order", order)
def build_steps(s):
"""Lines shown on each slide when a build is one slide per step: the heading stays up
with the first item, then one more line per slide."""
n = len(s.lines)
first = 2 if (n > 1 and s.lines[0].lead) else 1
return list(range(min(first, n), n + 1))
def clip_runs(runs, data):
"""The measured font runs of a full build, cut to the text one of its steps shows."""
if not runs:
return runs
n16 = len(rtf_plain(data).encode("utf-16-le")) // 2
return [dict(r, len=min(r["len"], n16 - r["loc"])) for r in runs if r["loc"] < n16] or None
def build(tpl, plan, name, speaker, layout=None, speaker_runs=None):
"""-> .pro bytes. layout: (results, where) from fit(), for exact font runs."""
runs = {}
@@ -768,7 +789,8 @@ def build(tpl, plan, name, speaker, layout=None, speaker_runs=None):
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))
set_text(el, rtfs[nm], clip_runs(runs.get((si, nm)), rtfs[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))
@@ -795,6 +817,15 @@ def build(tpl, plan, name, speaker, layout=None, speaker_runs=None):
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"]))
if kind == "build" and tpl.step_builds:
# step k from the template's k-th build slide (the last one again for longer builds)
for k, shown in enumerate(build_steps(s)):
step = notesmod.Slide("build", s.lines[:shown], src=s.src)
step.scale = s.scale
cue = clone_cue(tpl.builds[min(k, len(tpl.builds) - 1)])
fill(cue, step, si)
out.append(cue)
continue
cue = clone_cue(tpl.sample[kind])
if kind in ("picture", "clearpicture"):
if s.text.strip():
@@ -818,8 +849,19 @@ def build(tpl, plan, name, speaker, layout=None, speaker_runs=None):
return C.encode(pres)
def cues_for(tpl, s):
"""How many slides one plan slide becomes in the built deck."""
kind = kind_for(tpl, s)
if s.kind == "title" or kind not in tpl.sample:
return 0
if kind != "build":
return 1
return (len(build_steps(s)) if tpl.step_builds else 1) + (1 if "prebuild" in tpl.sample else 0)
def slide_numbers(tpl, plan):
"""The ProPresenter slide number (1-based) each plan slide will have in the built deck."""
"""The ProPresenter slide number (1-based) each plan slide will have in the built deck
(a build: its first step)."""
nums = []
n = len(tpl.head)
title_at = tpl.head.index(tpl.kinds.index("title")) + 1
@@ -827,20 +869,18 @@ def slide_numbers(tpl, plan):
if s.kind == "title":
nums.append(title_at)
continue
kind = kind_for(tpl, s)
if kind not in tpl.sample:
c = cues_for(tpl, s)
if not c:
nums.append(None)
continue
if kind == "build" and "prebuild" in tpl.sample:
n += 1
n += 1
nums.append(n)
pre = 1 if (kind_for(tpl, s) == "build" and "prebuild" in tpl.sample) else 0
nums.append(n + pre + 1)
n += c
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)
return len(tpl.head) + sum(cues_for(tpl, s) for s in plan.slides) + len(tpl.tail)
def ensure_pictures(tpl, plan):
+20 -1
View File
@@ -561,9 +561,11 @@ def statements_to_slides(group, force_build=False):
kind = "build" if parallel else "point"
else:
kind = "build"
heading = kind == "build" and build_heading(lines, lead)
out_lines = []
for k, (text, p, mark, part, start) in enumerate(lines):
out_lines.append(Line(underlined(text, p, part, start), lead=(k == 0 and lead and kind == "point")))
out_lines.append(Line(underlined(text, p, part, start),
lead=(k == 0 and ((lead and kind == "point") or heading))))
s = Slide(kind, out_lines, src=src)
plain = [p for p in group if not p.bold and not p.italic]
if plain and all(p.size <= 15 for p in plain):
@@ -573,6 +575,23 @@ def statements_to_slides(group, force_build=False):
return [s]
def build_heading(lines, lead):
"""Is a build's first line its heading ("What is your FLAW?" over "1. ... 2. ... 3.")?
A heading stays up with the first item when a template shows builds as one slide per step."""
if len(lines) < 2:
return False
if lead:
return True
first, rest = lines[0], lines[1:]
if len(rest) < 2:
return False
numbered = lambda l: bool(l[2] or NUMBERED_RE.match(l[0]))
if all(numbered(l) for l in rest) and not numbered(first):
return True
word = lambda l: l[0].split(" ")[0].lower()
return len({word(l) for l in rest}) == 1 and word(first) != word(rest[0])
def is_lead(text, p):
t = text.strip()
return t.endswith(":") or t.endswith("?") or (p.italic and not p.bold) or (p.italic and p.bold)
+4 -3
View File
@@ -10,7 +10,8 @@ HEADER = """# Slide list for: {name}
# [Title] [Point] [Build] [Subpoint] [Quote] [Sentence] [Scripture] <reference>
# [Picture] <what picture> [Clear Picture]
# The lines under it are the slide's lines (one line = one paragraph; in a Build, one step).
# In a Point, start a line with > to make it the italic lead-in line.
# In a Point, start a line with > to make it the italic lead-in line. In a Build, > marks the
# heading (it stays up with the first step).
# In a Scripture, put **double asterisks** around bold words and *single ones* around italic words.
# On any slide, __double underscores__ around words underline them.
# Delete a slide by deleting its whole block. Blank lines and lines starting with "# " are ignored.
@@ -50,7 +51,7 @@ def dump(plan, name=""):
out.append("[%s]" % NAMES.get(s.kind, "Point"))
for ln in s.lines:
text = "".join(mark(t, False, False, u) for t, _, _, u in ln.segs)
out.append(("> " if (ln.lead and s.kind == "point") else "") + text)
out.append(("> " if (ln.lead and s.kind in ("point", "build")) else "") + text)
for item in left.get(len(plan.slides), []):
out.append("#! %s" % item[1].replace("\n", " "))
return "\n".join(out) + "\n"
@@ -125,7 +126,7 @@ def load(text):
t = line.strip()
if cur.kind == "scripture":
cur.lines.append(notesmod.Line(parse_bold(t)))
elif cur.kind == "point" and t.startswith(">"):
elif cur.kind in ("point", "build") and t.startswith(">"):
cur.lines.append(notesmod.Line(parse_bold(t[1:].strip(), styles=False), lead=True))
else:
cur.lines.append(notesmod.Line(parse_bold(t, styles=False)))
Binary file not shown.
+37 -1
View File
@@ -144,7 +144,43 @@ def main():
check("fill-in blanks are not underline marks", plantext.parse_bold("Faith is ______.") ==
[("Faith is ______.", False, False, False)])
# 8. other campuses: their template names, and who preaches there according to the email
# 8. a template with one slide per build step (Build 1, Build 2, ...) instead of the build feature
STEP = os.path.join(PRO, "_TEMPLATE_GENERIC - DO NOT DELETE 2609-1.pro")
stpl = deck.Template(open(STEP, "rb").read())
check("step template detected", stpl.step_builds and len(stpl.builds) == 4 and not tpl.step_builds)
p8 = notes.parse(open(os.path.join(NOTES, "Slides - Where is Your Faith - Part 8.rtf"), "rb").read())
out = slides_of(deck.build(stpl, p8, "TEST", stpl.speaker()))
check("step deck slide count", len(out) == deck.slide_count(stpl, p8), "%d vs %d" % (len(out), deck.slide_count(stpl, p8)))
steps = [(lab, texts[0][1]) for lab, texts in out if lab.startswith("Build")]
flaw = ["What is your FLAW?", "1. FEARFUL INSECURITY - Gideon and Barak", "2. FLESHLY IMPULSES - David and Samson",
"3. FAMILY DYSFUNCTION - David and Jephthah"]
want = [("Build %d" % k, " / ".join(flaw[:k + 1])) for k in (1, 2, 3)]
check("build steps: heading + one more line per slide", steps[:3] == want, "%s" % steps[:3])
check("second build: 3 steps from Build 1-3", [s[0] for s in steps[3:]] == ["Build 1", "Build 2", "Build 3"]
and steps[5][1].count(" / ") == 3, "%s" % steps[3:])
check("no STREAM DECK slide with step builds", not any("STREAM DECK" in lab for lab, _ in out))
nums = deck.slide_numbers(stpl, p8)
firsts = [nums[i] for i, s in enumerate(p8.slides) if s.kind == "build"]
check("slide numbers point at each build's first step", [out[n - 1][0] for n in firsts] == ["Build 1", "Build 1"],
"%s" % firsts)
five = notes.Plan()
five.slides = [p8.slides[0], notes.Slide("build", [notes.Line([("Heading:", False, False)], lead=True)] +
[notes.Line([("Item %d" % k, False, False)]) for k in range(1, 6)])]
out5 = [(lab, texts[0][1]) for lab, texts in slides_of(deck.build(stpl, five, "TEST", stpl.speaker()))
if lab.startswith("Build")]
check("a 5-item build: 5 slides, the last template slide reused",
[l for l, _ in out5] == ["Build 1", "Build 2", "Build 3", "Build 4", "Build 4"]
and out5[-1][1] == "Heading: / Item 1 / Item 2 / Item 3 / Item 4 / Item 5", "%s" % out5)
runs = [{"loc": 0, "len": 10}, {"loc": 10, "len": 30}, {"loc": 40, "len": 5}]
data = deck.make_rtf(stpl.style("build", "build"), [(0, [("What is your FLAW?", False, False, False)]),
(0, [("1. FEARFUL", False, False, False)])])
check("font runs cut to a step's text", deck.clip_runs(runs, data) == [{"loc": 0, "len": 10}, {"loc": 10, "len": 19}])
rt = plantext.load(plantext.dump(p8, "x"))
check("build heading survives the TextEdit round trip",
[l.lead for s in p8.slides if s.kind == "build" for l in s.lines] ==
[l.lead for s in rt.slides if s.kind == "build" for l in s.lines])
# 9. other campuses: their template names, and who preaches there according to the email
import app
import settings
for n, want in (("_TEMPLATE - DO NOT DELETE 2608 Lower Thirds.pro", True),