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 0000000..8e62ed0 Binary files /dev/null and b/tests/pro/_TEMPLATE_GENERIC - DO NOT DELETE 2609-1.pro differ diff --git a/tests/run_tests.py b/tests/run_tests.py index 84f40db..75c1531 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -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),