diff --git a/slides_template/mknotes b/slides_template/mknotes new file mode 100755 index 0000000..f6a9118 --- /dev/null +++ b/slides_template/mknotes @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Build a presenter PDF from a slides PDF and the notes captured beside it. + +The ltx-talk class has no speaker notes, so talk-notes.sty records them to a +side file while the slides build ignores them. This program reads that file and +reassembles the two halves. + +The reason a separate program is needed at all is that a frame is not a page: +overlays expand one frame into several slides, and neither the source nor a +reader of the finished PDF can work out which page belongs to which frame. +talk-notes.sty resolves that by writing a record per page at shipout time, so +the mapping here is exact rather than inferred. + +The output is double-width pages, slide on the left and notes on the right, +which is the format dspdfviewer and pdfpc expect. The notes half carries a grey +header band with the section and position in the talk, a thumbnail of the +current slide flush into its outer corner, and the notes below. Previous and +next slides are left to the viewer, which already shows them. + +The output is not tagged and is not meant to be shared: it exists to be looked +at while talking. The slides build is the one that gets uploaded. +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile + +from pypdf import PageObject, PdfReader, PdfWriter, Transformation + + +# ---------------------------------------------------------------- parsing + +class Page: + """One shipped page, and where it sits in the talk.""" + + def __init__(self, number, frame, slide): + self.number = number + self.frame = frame + self.slide = slide + self.section = "" + self.subsection = "" + self.notes = [] + + +def parse_notes_file(path): + """Return (pages, notes_by_frame) from a .notes file.""" + pages = [] + notes = {} + seen = set() + + with open(path, encoding="utf-8") as handle: + lines = handle.read().split("\n") + + i = 0 + while i < len(lines): + line = lines[i].strip() + + match = re.match(r"^PAGE (\d+)\|FRAME (\d+)\|SLIDE (\d+)$", line) + if match: + pages.append(Page(*(int(g) for g in match.groups()))) + i += 1 + continue + + if line.startswith("SECTION ") or line == "SECTION": + if pages: + pages[-1].section = line[8:].strip() + i += 1 + continue + + if line.startswith("SUBSECTION ") or line == "SUBSECTION": + if pages: + pages[-1].subsection = line[11:].strip() + i += 1 + continue + + match = re.match(r"^NOTE FRAME (\d+)\|SPEC (.*)$", line) + if match: + frame, spec = int(match.group(1)), match.group(2).strip() + body = [] + i += 1 + while i < len(lines) and lines[i].strip() != "ENDNOTE": + text = lines[i] + body.append(text[5:] if text.startswith("TEXT ") else text) + i += 1 + i += 1 + text = " ".join(part.strip() for part in body).strip() + # A note is re-run for every slide of its frame, so the same record + # arrives once per slide. Keep the first occurrence only. + key = (frame, spec, text) + if key not in seen: + seen.add(key) + notes.setdefault(frame, []).append((spec, text)) + continue + + i += 1 + + return pages, notes + + +def spec_matches(spec, slide, last): + """Does an overlay spec such as '2-', '-3', '1,4' cover this slide? + + A bare '-' means the note was given without a spec and applies throughout. + 'n' inside a range means the frame's last slide, as in ltx-talk. + """ + if spec in ("", "-"): + return True + + def value(token, default): + token = token.strip() + if token == "": + return default + if token == "n": + return last + try: + return int(token) + except ValueError: + return default + + for part in spec.split(","): + part = part.strip() + if not part: + continue + if "-" in part: + low, _, high = part.partition("-") + if value(low, 1) <= slide <= value(high, last): + return True + elif value(part, -1) == slide: + return True + return False + + +def attach_notes(pages, notes): + """Work out which notes belong on each page.""" + last_slide = {} + for page in pages: + last_slide[page.frame] = max(last_slide.get(page.frame, 0), page.slide) + + for page in pages: + for spec, text in notes.get(page.frame, []): + if spec_matches(spec, page.slide, last_slide[page.frame]): + page.notes.append(text) + + +# ---------------------------------------------------------------- typesetting + +#: Side margin on the generated notes pages, as a fraction of the page. +HMARGIN = 0.06 + +# The page is laid out by hand rather than by geometry: the header band runs +# full bleed to the top and outer edges to meet the thumbnail, so the margins +# have to be applied per element instead of to the page. +NOTES_PREAMBLE = r"""\documentclass{article} +\usepackage[paperwidth=%(width).2fbp,paperheight=%(height).2fbp, + margin=0bp]{geometry} +\usepackage{parskip} +\usepackage{xcolor} +\definecolor{notegrey}{RGB}{230,230,230} +\pagestyle{empty} +\setlength{\parindent}{0pt} +\setlength{\fboxsep}{0pt} +\setlength{\topskip}{0pt} +\begin{document} +""" + + +def escape_for_context(text): + """Guard the few characters that would derail a generated file. + + Note bodies are written out by talk-notes.sty as literal source, so LaTeX + markup inside a note is deliberately preserved. Only a bare percent sign is + dangerous, since it would comment out the rest of the generated line. + """ + return re.sub(r"(?-notes.pdf)") + parser.add_argument("--thumb-scale", type=float, default=0.29, + metavar="F", + help="slide thumbnail size on the notes half, as a " + "fraction of the slide (default: 0.29; 0 disables)") + args = parser.parse_args() + + slides_path = args.jobname + ".pdf" + notes_path = args.jobname + ".notes" + for path in (slides_path, notes_path): + if not os.path.exists(path): + raise SystemExit(f"mknotes: {path} not found; build the slides first") + + pages, notes = parse_notes_file(notes_path) + if not pages: + raise SystemExit(f"mknotes: no page records in {notes_path}") + attach_notes(pages, notes) + + slides = PdfReader(slides_path) + if len(slides.pages) != len(pages): + raise SystemExit( + f"mknotes: {slides_path} has {len(slides.pages)} pages but " + f"{notes_path} describes {len(pages)}; rebuild the slides") + + box = slides.pages[0].mediabox + width, height = float(box.width), float(box.height) + thumb = max(0.0, min(args.thumb_scale, 0.9)) + + workdir = tempfile.mkdtemp(prefix="mknotes-") + try: + notes_pdf = PdfReader(build_notes_pdf( + pages, width, height, width * thumb, height * thumb, workdir)) + writer = assemble_notes(slides, notes_pdf, width, height, thumb) + + output = args.output or f"{args.jobname}-notes.pdf" + with open(output, "wb") as handle: + writer.write(handle) + print(f"mknotes: wrote {output} ({len(writer.pages)} pages)") + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/slides_template/talk-notes.sty b/slides_template/talk-notes.sty new file mode 100644 index 0000000..25e9403 --- /dev/null +++ b/slides_template/talk-notes.sty @@ -0,0 +1,79 @@ +% talk-notes.sty -- capture speaker notes for assembly outside LaTeX. +% +% The ltx-talk class has no \note command (upstream issue #156 is open, and the +% maintainer has said notes are wanted but unimplemented). This package fills +% the gap without touching the slides themselves: \note{...} typesets nothing +% and instead records its text to \jobname.notes, which the mknotes program +% reads to build the presenter PDF. +% +% The awkward part of doing this outside LaTeX is that a frame does not +% correspond to a page: overlays expand one frame into several slides, and the +% source cannot predict how many. That is why the page records are written from +% the shipout hook, which is the first point at which the frame and slide +% identity of a page is actually known. + +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{talk-notes}[2026-08-14 v0.1 Capture speaker notes for ltx-talk] + +\ExplSyntaxOn + +\iow_new:N \g__talknotes_iow +\tl_new:N \g__talknotes_section_tl +\tl_new:N \g__talknotes_subsection_tl + +\AddToHook { begindocument } + { \iow_open:Nn \g__talknotes_iow { \c_sys_jobname_str .notes } } +% The stream is deliberately not closed by hand: LaTeX closes it at the end of +% the run, and closing it from the enddocument hook races the final shipout. + +% Track the section and subsection so the notes page can show where we are. +% ltx-talk exposes the current title as \l__talk_section_tl; guard the lookup +% so that a class change does not turn this into an error. +\AddToHook { section/begin } + { + \cs_if_exist:NT \l__talk_section_tl + { + \tl_gset:NV \g__talknotes_section_tl \l__talk_section_tl + \tl_gclear:N \g__talknotes_subsection_tl + } + } +\AddToHook { subsection/begin } + { + \cs_if_exist:NT \l__talk_subsection_tl + { \tl_gset:NV \g__talknotes_subsection_tl \l__talk_subsection_tl } + } + +% One record per shipped page. +\AddToHook { shipout/before } + { + \iow_now:Nx \g__talknotes_iow + { + PAGE ~ \arabic { page } | + FRAME ~ \int_use:N \g__talk_frame_int | + SLIDE ~ \int_use:N \g__talk_slide_int + } + \iow_now:Nx \g__talknotes_iow + { SECTION ~ \tl_to_str:N \g__talknotes_section_tl } + \iow_now:Nx \g__talknotes_iow + { SUBSECTION ~ \tl_to_str:N \g__talknotes_subsection_tl } + } + +% \note[]{text} +% +% The body is written out stringified, so LaTeX markup inside a note survives +% to be typeset again on the notes page. A note is recorded once per slide of +% its frame (the frame body is re-run for each), so mknotes deduplicates. +% Records are bracketed by ENDNOTE because a long note may be broken across +% output lines. +\NewDocumentCommand \note { d<> +m } + { + \iow_now:Nx \g__talknotes_iow + { + NOTE ~ FRAME ~ \int_use:N \g__talk_frame_int | + SPEC ~ \IfNoValueTF {#1} { - } { \tl_to_str:n {#1} } + } + \iow_now:Nx \g__talknotes_iow { TEXT ~ \tl_to_str:n {#2} } + \iow_now:Nn \g__talknotes_iow { ENDNOTE } + } + +\ExplSyntaxOff