Open Sans and Roboto Mono ship in texlive-fonts-extra, which the other templates already require. LuaLaTeX resolves them by family name through luaotfload, which indexes the TeX tree, so the vendored copies and their relative Path= keys are unnecessary. Same faces, pixel-identical output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
385 lines
14 KiB
Python
Executable File
385 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 = []
|
|
self.items = []
|
|
|
|
|
|
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 (.*)\|KIND (.*)$", line)
|
|
if match:
|
|
frame = int(match.group(1))
|
|
spec, kind = match.group(2).strip(), match.group(3).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, kind, text)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
notes.setdefault(frame, []).append((spec, kind, 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, kind, text in notes.get(page.frame, []):
|
|
if spec_matches(spec, page.slide, last_slide[page.frame]):
|
|
if kind == "item":
|
|
page.items.append(text)
|
|
else:
|
|
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 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)
|
|
# Paragraph spacing is set here rather than in the preamble so that it
|
|
# separates the notes without also loosening the heading band above.
|
|
body.append(r"\noindent\hspace{%.2fbp}\begin{minipage}[t]{%.2fbp}"
|
|
r"\setlength{\parskip}{0.7em}"
|
|
% (hmargin, textwidth))
|
|
# As in beamer, plain \note commands run together as text and
|
|
# \note[item] ones become a numbered list printed after it. beamer
|
|
# concatenates the plain ones with no separator at all; a paragraph
|
|
# break between them is easier to read and is the one deviation.
|
|
if page.notes:
|
|
body.append("\n\n".join(
|
|
escape_for_context(n) for n in page.notes))
|
|
if page.items:
|
|
body.append(r"\par\begin{enumerate}\setlength{\itemsep}{0pt}"
|
|
r"\setlength{\parskip}{0pt}"
|
|
r"\setlength{\leftmargini}{1.4em}")
|
|
for item in page.items:
|
|
body.append(r"\item %s" % escape_for_context(item))
|
|
body.append(r"\end{enumerate}")
|
|
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()
|