From 042fe30396e3caf946046fe9d09d255f1689dc3d Mon Sep 17 00:00:00 2001 From: christian Date: Thu, 22 Oct 2026 11:40:04 -0400 Subject: [PATCH] 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 --- README.md | 16 ++++- src/app.py | 25 ++++++-- src/deck.py | 60 +++++++++++++++--- src/notes.py | 21 +++++- src/plantext.py | 7 +- ...EMPLATE_GENERIC - DO NOT DELETE 2609-1.pro | Bin 0 -> 24930 bytes tests/run_tests.py | 38 ++++++++++- 7 files changed, 145 insertions(+), 22 deletions(-) create mode 100755 tests/pro/_TEMPLATE_GENERIC - DO NOT DELETE 2609-1.pro diff --git a/README.md b/README.md index 8435984..7449ec7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app.py b/src/app.py index 0384592..c4f4b1e 100644 --- a/src/app.py +++ b/src/app.py @@ -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-") diff --git a/src/deck.py b/src/deck.py index 7b8ed31..65afe1f 100644 --- a/src/deck.py +++ b/src/deck.py @@ -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): diff --git a/src/notes.py b/src/notes.py index acfd6fa..9b33b96 100644 --- a/src/notes.py +++ b/src/notes.py @@ -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) diff --git a/src/plantext.py b/src/plantext.py index 36b78c8..df06c4e 100644 --- a/src/plantext.py +++ b/src/plantext.py @@ -10,7 +10,8 @@ HEADER = """# Slide list for: {name} # [Title] [Point] [Build] [Subpoint] [Quote] [Sentence] [Scripture] # [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))) diff --git a/tests/pro/_TEMPLATE_GENERIC - DO NOT DELETE 2609-1.pro b/tests/pro/_TEMPLATE_GENERIC - DO NOT DELETE 2609-1.pro new file mode 100755 index 0000000000000000000000000000000000000000..8e62ed05fe1286452d86a61e3733494d10813459 GIT binary patch literal 24930 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