The notes pages were typeset in the default Latin Modern while the slides used OpenSans and RobotoMono, so the two halves of a presenter page did not match. Move the font declarations to fonts.tex, which the slides \input and mknotes passes to the notes document. mknotes uses it by default when it exists, so a deck that copies the two notes files in gets matching fonts without having to remember a flag; --preamble names a different fragment, and a file named there but missing is an error rather than ignored. The notes document now compiles from the document directory with -output-directory rather than running inside the temporary one, so the relative font paths in the fragment resolve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
369 lines
14 KiB
Python
Executable File
369 lines
14 KiB
Python
Executable File
#!/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
|
|
|
|
#: Font setup shared with the slides. Used when it exists, so that the notes
|
|
#: match the deck without anyone having to remember a flag.
|
|
DEFAULT_PREAMBLE = "fonts.tex"
|
|
|
|
# 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}
|
|
\usepackage{fontspec}
|
|
\definecolor{notegrey}{RGB}{230,230,230}
|
|
\pagestyle{empty}
|
|
\setlength{\parindent}{0pt}
|
|
\setlength{\fboxsep}{0pt}
|
|
\setlength{\topskip}{0pt}
|
|
%(preamble)s
|
|
\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"(?<!\\)%", r"\\%", text)
|
|
|
|
|
|
def build_notes_pdf(pages, width, height, thumb_w, thumb_h, workdir,
|
|
preamble=None, jobname="notes"):
|
|
"""Typeset one notes page per slide page and return the resulting PDF path.
|
|
|
|
The page is made at the size it will actually occupy in the finished sheet,
|
|
so that it can be dropped in without scaling. Shrinking a slide-sized page
|
|
into a narrow column would leave the text far too small to read while
|
|
presenting.
|
|
|
|
thumb_w and thumb_h are the size of the slide thumbnail that assembly stamps
|
|
flush into the top outer corner. The header band is made exactly as tall as
|
|
the thumbnail and stops exactly where it begins, so the two read as a single
|
|
band across the head of the page.
|
|
"""
|
|
hmargin = width * HMARGIN
|
|
pad = width * 0.03
|
|
gap = height * 0.05
|
|
textwidth = width - hmargin * 2
|
|
bandwidth = width - thumb_w
|
|
|
|
# \input rather than the file's contents, so that anything relative inside
|
|
# it (font paths, say) resolves against the document directory. LaTeX runs
|
|
# from there even though its output goes to the working directory.
|
|
extra = r"\input{%s}" % preamble if preamble else ""
|
|
|
|
body = [NOTES_PREAMBLE % {
|
|
"width": width, "height": height, "preamble": extra,
|
|
}]
|
|
|
|
total = len(pages)
|
|
for index, page in enumerate(pages):
|
|
where = page.section or ""
|
|
if page.subsection:
|
|
where = f"{where} / {page.subsection}" if where else page.subsection
|
|
|
|
# Sans-serif and small, against the roman body below, so the heading
|
|
# reads as apparatus rather than as part of the notes.
|
|
heading = [r"\sffamily\small"]
|
|
if where:
|
|
heading.append(r"{\bfseries %s\par}" % escape_for_context(where))
|
|
heading.append(
|
|
r"{\footnotesize Frame %d, slide %d of %d \quad Page %d of %d\par}"
|
|
% (page.frame, page.slide,
|
|
max(p.slide for p in pages if p.frame == page.frame),
|
|
page.number, total)
|
|
)
|
|
|
|
inner = (r"\hspace{%.2fbp}\begin{minipage}[t]{%.2fbp}\vspace{%.2fbp}%s"
|
|
r"\end{minipage}"
|
|
% (pad, bandwidth - pad * 2, pad, "".join(heading)))
|
|
if thumb_h:
|
|
band = (r"\begin{minipage}[t][%.2fbp][t]{%.2fbp}%s\end{minipage}"
|
|
% (thumb_h, bandwidth, inner))
|
|
else:
|
|
band = (r"\begin{minipage}[t]{%.2fbp}%s\vspace{%.2fbp}\end{minipage}"
|
|
% (bandwidth, inner, pad))
|
|
body.append(r"\noindent\colorbox{notegrey}{%s}%%" % band)
|
|
|
|
body.append(r"\par\vspace{%.2fbp}%%" % gap)
|
|
body.append(r"\noindent\hspace{%.2fbp}\begin{minipage}[t]{%.2fbp}"
|
|
% (hmargin, textwidth))
|
|
if page.notes:
|
|
body.append(r"\begin{itemize}\setlength{\itemsep}{0.6em}"
|
|
r"\setlength{\leftmargini}{1.2em}")
|
|
for note in page.notes:
|
|
body.append(r"\item %s" % escape_for_context(note))
|
|
body.append(r"\end{itemize}")
|
|
body.append(r"\end{minipage}")
|
|
|
|
if index != total - 1:
|
|
body.append(r"\newpage")
|
|
|
|
body.append(r"\end{document}")
|
|
|
|
source = os.path.join(workdir, jobname + ".tex")
|
|
with open(source, "w", encoding="utf-8") as handle:
|
|
handle.write("\n".join(body) + "\n")
|
|
|
|
# Run from the document directory so relative paths in the preamble resolve,
|
|
# but send the output elsewhere so the build leaves nothing behind.
|
|
result = subprocess.run(
|
|
["lualatex", "-interaction=nonstopmode", "-halt-on-error",
|
|
"-output-directory=" + workdir, source],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
)
|
|
output = os.path.join(workdir, jobname + ".pdf")
|
|
if result.returncode != 0 or not os.path.exists(output):
|
|
sys.stderr.write(result.stdout.decode("utf-8", "replace")[-3000:])
|
|
raise SystemExit("mknotes: failed to typeset the notes pages")
|
|
return output
|
|
|
|
|
|
# ---------------------------------------------------------------- assembly
|
|
|
|
def place(target, source, scale, x, y):
|
|
"""Draw source onto target, scaled, with its lower-left corner at (x, y)."""
|
|
target.merge_transformed_page(
|
|
source, Transformation().scale(scale).translate(x, y)
|
|
)
|
|
|
|
|
|
def assemble_notes(slides, notes_pdf, width, height, thumb_scale):
|
|
"""Double-width pages: slide left, thumbnail and notes right."""
|
|
writer = PdfWriter()
|
|
for index, note_page in enumerate(notes_pdf.pages):
|
|
sheet = PageObject.create_blank_page(width=width * 2, height=height)
|
|
place(sheet, slides.pages[index], 1.0, 0, 0)
|
|
place(sheet, note_page, 1.0, width, 0)
|
|
if thumb_scale:
|
|
# Flush into the top outer corner of the notes half.
|
|
tw, th = width * thumb_scale, height * thumb_scale
|
|
place(sheet, slides.pages[index], thumb_scale,
|
|
width * 2 - tw, height - th)
|
|
writer.add_page(sheet)
|
|
return writer
|
|
|
|
|
|
# ---------------------------------------------------------------- entry point
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Assemble a presenter PDF from slides and captured notes.")
|
|
parser.add_argument("jobname",
|
|
help="base name of the build, e.g. 'example'")
|
|
parser.add_argument("--output",
|
|
help="output file (default: <jobname>-notes.pdf)")
|
|
parser.add_argument("--preamble", metavar="FILE", default=DEFAULT_PREAMBLE,
|
|
help="LaTeX fragment to \\input into the notes pages, "
|
|
"so they can share the slides' font setup "
|
|
f"(default: {DEFAULT_PREAMBLE} when it exists)")
|
|
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))
|
|
|
|
# A preamble named on the command line has to be there; the default one is
|
|
# a convenience and is skipped when the document does not use it.
|
|
preamble = args.preamble
|
|
if preamble and not os.path.exists(preamble):
|
|
if preamble != DEFAULT_PREAMBLE:
|
|
raise SystemExit(f"mknotes: {preamble} not found")
|
|
preamble = None
|
|
|
|
workdir = tempfile.mkdtemp(prefix="mknotes-")
|
|
try:
|
|
notes_pdf = PdfReader(build_notes_pdf(
|
|
pages, width, height, width * thumb, height * thumb, workdir,
|
|
preamble=preamble))
|
|
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()
|