LaTeX & Typst (papers, theses, resumes and slides) Module 8 – Theses, reports and long documents
Build a thesis template from your university's rules
Turn a thesis manual into rules a script can measure, implement them once as a LaTeX class and a Typst template, and check every PDF against them.
What you will learn
- Convert a written thesis manual into a list of rules, each with a number that can be measured
- Implement the rules once, as a LaTeX class built on report and as a Typst template function
- Check a compiled PDF against the rules with a script instead of by eye, and read what it reports
- Use, adapt and share thesis templates responsibly, marked unofficial, licensed and checked against the current manual
Before you start
What you’ll learn · Before you start
What you will learn
- Convert a written thesis manual into a list of rules, each with a number that can be measured
- Implement the rules once, as a LaTeX class built on report and as a Typst template function
- Check a compiled PDF against the rules with a script instead of by eye, and read what it reports
- Use, adapt and share thesis templates responsibly, marked unofficial, licensed and checked against the current manual
Before you start
On this page
A thesis manual is written in sentences, for people, and a thesis office may check a PDF against it with a ruler. In this lesson’s run, a thesis on the report class’s own settings broke 5 of the 13 rules of an invented manual: wrong paper, 10 pt type, single spacing, page numbers too high and arabic numbers from the first page.
Here you turn that manual into rules a program can measure, write the checker before the template, and then implement the rules once as a LaTeX class and once as a Typst template, until both pass all 13. The last section is about using and sharing such templates honestly: who made them, under which licence, and against which edition of the manual.
A rule you can measure is a rule you can check
The manual of Example University, invented for this lesson, says “space the lines one and a half times the type size apart”. That sentence becomes checkable once it is a number: with 12 pt type, baseline: The line that letters stand on. Line spacing is measured from the baseline of one line to the baseline of the next, so 12 pt type set one and a half lines apart has baselines 18 pt apart. 18 pt apart. Every rule of the manual can be written that way, as a quantity, a limit and a way to measure it in the PDF:
| Rule | The manual says | Measured in the PDF as |
|---|---|---|
| paper | A4, 210 by 297 mm | the size of every page |
| margins | at least 35 mm left, 25 mm elsewhere | where the ink of the text starts and ends |
| body size, line spacing | 12 pt type, 18 pt from baseline to baseline | the commonest type size, and the gap between baselines |
| chapter titles | bold, at least 16 pt | the font of each title line |
| page numbers | centred at the foot, 12 to 20 mm up | the last line of each page |
| numbering, order | roman to chapter 1, then arabic; the manual’s order | page labels, and the first line of each page |
| citations | by number, as [3] | numbers in brackets, and no author and year |
rules.toml holds the 13 rules, each with the sentence it comes from, so that a reader can trace any number back to
the manual. TOML is a plain format that Python reads with its standard tomllib module:
# rules.toml: the thesis manual of Example University, invented for this lesson, as rules that a program can check.
# Each rule keeps the sentence of the manual it comes from (manual), the kind of measurement (check) and its numbers.
# spec_check.py reads this file and a PDF and says, rule by rule, what it measured and whether the rule holds.
[[rule]]
id = "paper"
manual = "Print on A4 paper, 210 by 297 mm."
check = "page-size"
width_mm = 210
height_mm = 297
[[rule]]
id = "margin-left"
manual = "Leave at least 35 mm on the left of every page, for the binding."
check = "margin"
side = "left"
min_mm = 35
[[rule]]
id = "margin-right"
manual = "Leave at least 25 mm on the right."
check = "margin"
side = "right"
min_mm = 25
[[rule]]
id = "margin-top"
manual = "Leave at least 25 mm above the text."
check = "margin"
side = "top"
min_mm = 25
[[rule]]
id = "margin-bottom"
manual = "Leave at least 25 mm below the text; the page number goes in this margin."
check = "margin"
side = "bottom"
min_mm = 25
[[rule]]
id = "body-size"
manual = "Set the text in 12 pt type."
check = "body-size"
pt = 12
tolerance_pt = 0.1
[[rule]]
id = "line-spacing"
manual = "Space the lines of the text one and a half times the type size apart: 18 pt from baseline to baseline."
check = "line-pitch"
pt = 18
tolerance_pt = 0.2
[[rule]]
id = "chapter-titles"
manual = "Set chapter titles in bold type of at least 16 pt."
check = "chapter-titles"
min_pt = 16
[[rule]]
id = "page-numbers"
manual = "Number every page but the title page at its foot, centred under the text, 12 to 20 mm above the bottom edge."
check = "page-numbers"
min_mm = 12
max_mm = 20
tolerance_mm = 1
[[rule]]
id = "numbering"
manual = "Number the pages before chapter 1 in lowercase roman, counting the title page, and from chapter 1 on in arabic from 1."
check = "labels"
[[rule]]
id = "front-matter"
manual = "Give the certificate, declaration, acknowledgements, abstract and contents in that order."
check = "order"
titles = ["Certificate", "Declaration", "Acknowledgements", "Abstract", "Contents"]
[[rule]]
id = "chapter-pages"
manual = "Start every chapter on a new page."
check = "chapter-pages"
[[rule]]
id = "citations"
manual = "Cite sources by number in square brackets, as [3], never by author and year."
check = "citations" Some rules resist numbers: “use a clear, professional font” is a judgement. Ask the thesis office what it means, or write it down as a list of fonts that are accepted, and keep a short list of the rules you still check by eye.
Checkpoint
Check: Rules written as numbers
The checker comes before the template
Write the check before the template, as a programmer writes a test before the code: then the template is done when
the checker says so, not when the pages look right. spec_check.py reads a PDF with two programs, MuPDF’s
mutool draw -F stext, which gives every line of text with its box, its baseline and the size and name of its font,
and qpdf --json, which gives the page labels, and it prints one line per rule. Step through one rule from the
manual to the verdict:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Start from the sentence. The manual asks for lines one and a half times the type size apart. For 12 pt type that is a number you can measure, 18 pt from one baseline to the next.
- Write it as a rule. rules.toml keeps the sentence beside the number: check = "line-pitch", pt = 18, and a tolerance of 0.2 pt for rounding. The rule says nothing about LaTeX or Typst.
- Build the PDF. The class or the template turns the thesis into a PDF. The checker reads only that file, so it judges what a reader and the thesis office will see.
- Read every line. mutool draw -F stext gives each line of text with the size and the name of its font, its box and its baseline, in points. qpdf gives the page labels.
- Measure. Body text is the type size used most, 12 pt here. The checker takes the gaps between baselines of body lines in one paragraph and keeps the most common one.
- Decide. The class measured 17.9 pt and Typst 18 pt, both within 0.2 pt of 18: PASS. The plain report measured 12 pt: FAIL. The same steps give every other rule its number.
"""Checks a thesis PDF against the rules of a manual, written as rules.toml, and prints one line per rule: what it
measured, and PASS or FAIL. Nothing is judged by eye: every number comes from the PDF.
python3 spec_check.py rules.toml thesis.pdf
The PDF is read with two command-line tools: MuPDF's mutool draw -F stext gives each page's size and every line of
text with its box, its baseline and the size and name of its font, in points (72 to the inch), and qpdf --json gives
the page labels. The title page, the first page, is left out of the margin and page-number checks, since its layout is
the manual's own. A page number is the last line of a page when that line is the page's label. Margins are measured
from the ink of the text, from the first to the last visible character of each line (a PDF may keep a space after the
last word of a line, which is not ink), with 1 mm of tolerance, because glyphs such as a full stop may hang a little
into the margin. A line counts as body text when its type is the size used most in the document, and the spacing of
lines is the most common distance between the baselines of two body lines that follow each other in one paragraph.
"""
import json
import re
import subprocess
import sys
import tomllib
import xml.etree.ElementTree as ET
from collections import Counter
MM = 25.4 / 72 # millimetres per point
MARGIN_TOLERANCE_MM = 1.0
BOLD = re.compile(r"Bold|BX|Black|Heavy|Semibold", re.I)
def roman(number):
"""4 -> 'iv'."""
out = ""
for value, letters in [(1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"), (50, "l"),
(40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i")]:
while number >= value:
out, number = out + letters, number - value
return out
def read_pages(pdf):
"""Every page as {width, height, lines}, each line {text, size, bold, x0, y0, x1, y1, baseline, block}."""
xml = subprocess.run(["mutool", "draw", "-F", "stext", "-o", "-", pdf], capture_output=True, text=True,
check=True).stdout
pages = []
for page in ET.fromstring(xml).iter("page"):
lines = []
for number, block in enumerate(page.iter("block")):
for line in block.iter("line"):
fonts = line.findall("font")
chars = [char for font in fonts for char in font.findall("char")]
if not chars or not line.get("text", "").strip():
continue
main = max(fonts, key=lambda font: len(font.findall("char")))
_, y0, _, y1 = (float(v) for v in line.get("bbox").split())
# The ink of the line runs from its first to its last visible character; a space that a PDF keeps
# after the last word is not part of it.
ink = [char for char in chars if char.get("c", "").strip()]
corners = [float(v) for v in ink[0].get("quad").split()[0::2] + ink[-1].get("quad").split()[0::2]]
lines.append({"text": line.get("text").strip(), "size": float(main.get("size")),
"bold": bool(BOLD.search(main.get("name", ""))), "x0": min(corners[:4]),
"y0": y0, "x1": max(corners[4:]), "y1": y1, "baseline": float(chars[0].get("y")),
"block": number})
pages.append({"width": float(page.get("width")), "height": float(page.get("height")), "lines": lines})
return pages
def read_labels(pdf, count):
"""The label of every page from the PDF's /PageLabels, or None when it has none."""
found = json.loads(subprocess.run(["qpdf", "--json", "--json-key=pagelabels", pdf], capture_output=True,
text=True, check=True).stdout)["pagelabels"]
if not found:
return None
styles = {"/D": str, "/r": roman, "/R": lambda n: roman(n).upper()}
out = []
for index in range(count):
start = max((r for r in found if r["index"] <= index), key=lambda r: r["index"])
label = start["label"]
number = label.get("/St", 1) + index - start["index"]
out.append(styles[label["/S"]](number) if "/S" in label else "")
return out
class Thesis:
def __init__(self, pdf):
self.pages = read_pages(pdf)
self.labels = read_labels(pdf, len(self.pages))
sizes = Counter()
for page in self.pages:
for line in page["lines"]:
sizes[round(line["size"], 1)] += len(line["text"])
self.body_size = sizes.most_common(1)[0][0]
first_lines = [page["lines"][0]["text"] if page["lines"] else "" for page in self.pages]
self.first_lines = first_lines
self.chapter_one = next((i for i, line in enumerate(first_lines) if re.fullmatch(r"Chapter 1", line)), None)
def number_line(self, index):
"""The page number line of page `index`, or None."""
lines = self.pages[index]["lines"]
if not lines or self.labels is None:
return None
last = max(lines, key=lambda line: line["baseline"])
return last if last["text"] == self.labels[index] else None
def text_lines(self, index):
number = self.number_line(index)
return [line for line in self.pages[index]["lines"] if line is not number]
def check_page_size(thesis, rule):
sizes = {(round(p["width"] * MM, 1), round(p["height"] * MM, 1)) for p in thesis.pages}
ok = all(abs(w - rule["width_mm"]) <= 1 and abs(h - rule["height_mm"]) <= 1 for w, h in sizes)
return ", ".join(f"{w} × {h} mm" for w, h in sorted(sizes)), ok
def check_margin(thesis, rule):
side = rule["side"]
values = []
for index in range(1, len(thesis.pages)):
page, lines = thesis.pages[index], thesis.text_lines(index)
if not lines:
continue
values.append({"left": min(l["x0"] for l in lines), "right": page["width"] - max(l["x1"] for l in lines),
"top": min(l["y0"] for l in lines), "bottom": page["height"] - max(l["y1"] for l in lines)}[side])
least = min(values) * MM
return f"{least:.1f} mm at the least", least >= rule["min_mm"] - MARGIN_TOLERANCE_MM
def check_body_size(thesis, rule):
return f"{thesis.body_size:g} pt", abs(thesis.body_size - rule["pt"]) <= rule["tolerance_pt"]
def check_line_pitch(thesis, rule):
gaps = Counter()
for page in thesis.pages:
body = [l for l in page["lines"] if abs(l["size"] - thesis.body_size) < 0.05]
for before, after in zip(body, body[1:]):
if before["block"] == after["block"]:
gaps[round(after["baseline"] - before["baseline"], 1)] += 1
if not gaps:
return "no two body lines in a row", False
pitch = gaps.most_common(1)[0][0]
return f"{pitch:g} pt between baselines", abs(pitch - rule["pt"]) <= rule["tolerance_pt"]
def check_chapter_titles(thesis, rule):
titles = []
for index, page in enumerate(thesis.pages[1:], 1):
lines = thesis.text_lines(index)
if not lines or lines[0]["size"] <= thesis.body_size:
continue
opening = [lines[0]] + ([lines[1]] if re.fullmatch(r"Chapter \d+", lines[0]["text"]) and len(lines) > 1 else [])
titles.extend(opening)
if not titles:
return "no chapter titles found", False
smallest = min(t["size"] for t in titles)
bold = sum(t["bold"] for t in titles)
return f"{len(titles)} title lines, {bold} bold, {smallest:.1f} pt at the least", bold == len(titles) and \
smallest >= rule["min_pt"]
def check_page_numbers(thesis, rule):
found, good = 0, 0
lefts = [min(l["x0"] for l in thesis.text_lines(i)) for i in range(1, len(thesis.pages)) if thesis.text_lines(i)]
rights = [max(l["x1"] for l in thesis.text_lines(i)) for i in range(1, len(thesis.pages)) if thesis.text_lines(i)]
middle = (min(lefts) + max(rights)) / 2
heights = []
for index in range(1, len(thesis.pages)):
line = thesis.number_line(index)
if line is None:
continue
found += 1
above = (thesis.pages[index]["height"] - line["baseline"]) * MM
centred = abs((line["x0"] + line["x1"]) / 2 - middle) * MM <= rule["tolerance_mm"]
heights.append(above)
good += centred and rule["min_mm"] <= above <= rule["max_mm"]
pages = len(thesis.pages) - 1
if not heights:
return f"0 of {pages} pages numbered", False
return f"{found} of {pages} numbered, {good} in place, {min(heights):.1f} to {max(heights):.1f} mm up", \
found == good == pages
def check_labels(thesis, rule):
if thesis.labels is None:
return "no page labels", False
if thesis.chapter_one is None:
return "no page starts with Chapter 1", False
expected = [roman(n) for n in range(1, thesis.chapter_one + 1)]
expected += [str(n) for n in range(1, len(thesis.pages) - thesis.chapter_one + 1)]
for index, (label, want) in enumerate(zip(thesis.labels, expected), 1):
if label != want:
return f"page {index} is {label or '(empty)'}, not {want}", False
return f"{thesis.labels[0]} to {thesis.labels[thesis.chapter_one - 1]}, then 1 to {thesis.labels[-1]}", True
def check_order(thesis, rule):
places = []
for title in rule["titles"]:
if title not in thesis.first_lines:
return f"{title} missing", False
places.append(thesis.first_lines.index(title))
return f"{len(places)} parts on pages {', '.join(str(p + 1) for p in places)}", places == sorted(places)
def check_chapter_pages(thesis, rule):
openings = [(i, line) for i, page in enumerate(thesis.pages) for line in page["lines"]
if re.fullmatch(r"Chapter \d+", line["text"])]
first = sum(line is thesis.pages[i]["lines"][0] for i, line in openings)
return f"{first} of {len(openings)} chapters open a page", bool(openings) and first == len(openings)
def check_citations(thesis, rule):
start = thesis.chapter_one or 0
text = " ".join(line["text"] for page in thesis.pages[start:] for line in page["lines"])
numbered = len(re.findall(r"\[\d+(?:[,–-] ?\d+)*\]", text))
named = len(re.findall(r"\([A-Z][a-z]+(?: and [A-Z][a-z]+| et al\.)?,? \d{4}[a-z]?\)", text))
return f"{numbered} numbered, {named} by author and year", numbered > 0 and named == 0
CHECKS = {"page-size": check_page_size, "margin": check_margin, "body-size": check_body_size,
"line-pitch": check_line_pitch, "chapter-titles": check_chapter_titles, "page-numbers": check_page_numbers,
"labels": check_labels, "order": check_order, "chapter-pages": check_chapter_pages,
"citations": check_citations}
def main(rules_file, pdf):
with open(rules_file, "rb") as handle:
rules = tomllib.load(handle)["rule"]
thesis = Thesis(pdf)
passed = 0
print(f"{'rule':<15}{'measured':<52}result")
for rule in rules:
measured, ok = CHECKS[rule["check"]](thesis, rule)
passed += ok
print(f"{rule['id']:<15}{measured:<52}{'PASS' if ok else 'FAIL'}")
print(f"{len(rules)} rules: {passed} pass, {len(rules) - passed} fail")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2]) Its first run is on plain.tex, the thesis on the report class’s own settings, with nothing of the manual applied:
from session import files, run
# The test before the template: plain.tex, the thesis on the report class's own settings, built and checked against
# rules.toml with spec_check.py.
files("rules.toml", "spec_check.py", "latex/plain.tex=plain.tex", "latex/chapters.tex=chapters.tex")
run(r"""
latexmk -pdf -interaction=nonstopmode -silent plain.tex > /dev/null 2>&1
python3 spec_check.py rules.toml plain.pdf
""") Output
$ latexmk -pdf -interaction=nonstopmode -silent plain.tex > /dev/null 2>&1 $ python3 spec_check.py rules.toml plain.pdf rule measured result paper 215.9 × 279.4 mm FAIL margin-left 47.2 mm at the least PASS margin-right 47.4 mm at the least PASS margin-top 45.1 mm at the least PASS margin-bottom 42.4 mm at the least PASS body-size 10 pt FAIL line-spacing 12 pt between baselines FAIL chapter-titles 10 title lines, 10 bold, 20.7 pt at the least PASS page-numbers 9 of 9 numbered, 0 in place, 31.5 to 31.5 mm up FAIL numbering page 1 is 1, not i FAIL front-matter 5 parts on pages 2, 3, 4, 5, 6 PASS chapter-pages 2 of 2 chapters open a page PASS citations 4 numbered, 0 by author and year PASS 13 rules: 8 pass, 5 fail
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rules_session.py
Five rules failed, each with the number that broke it: the report class’s default paper is US letter, 215.9 by 279.4 mm; its default type is 10 pt, with baselines 12 pt apart; the page numbers stood 31.5 mm above the bottom edge; and the first page was labelled 1. The margins passed, but only because letter paper and the default text block leave wide ones. Eight passes are not eight rules that a template implements, which is why the template’s own run must pass all 13.
A checker you have not checked
Wrong: trusting a checker you have never run on a PDF whose numbers you know. A checker that measures the right
margin from each line’s box finds Typst’s justified lines a millimetre in the margin, because in Typst’s PDFs that
box includes a space after the last word. Right: measure the ink, from the first to the last visible character, as
spec_check.py does, and look into any number that surprises you.
Checkpoint
Check: A checker written before the template
A class implements every rule once
A thesis layout is a class: the LaTeX guide for class authors says that commands which could be used with any class
belong in a package, and the rest in a class. exu-thesis.cls builds on report with \LoadClass, takes two
key-value option: A package or class option written as key=value, such as course=Hydrology lab, which a LaTeX file creates with \DeclareKeys and reads with \ProcessKeyOptions. An option without a value can set a switch. More in “Writing LaTeX classes and Typst templates”, degree and draft, and names in comments the rules each block implements; a later lesson of this
track, on writing classes and Typst templates, goes through every line of such a file:
latex/exu-thesis.cls
% exu-thesis.cls: a thesis class for Example University, whose manual is invented for this lesson. Unofficial: no
% institution publishes it. Each block names the rules of rules.toml that it implements.
\NeedsTeXFormat{LaTeX2e}[2022-06-01]
\ProvidesClass{exu-thesis}[2026/10/01 v0.1 Thesis layout for the invented Example University manual (unofficial)]
% Options: the degree printed on the title page, and draft, which builds without images (graphicx's draft mode).
\DeclareKeys[exu]{
degree.store = \exu@degree,
degree.usage = load,
draft.if = exu@draft,
draft.usage = load,
}
\SetKeys[exu]{degree = Doctor of Philosophy}
\DeclareUnknownKeyHandler[exu]{\PassOptionsToClass{\CurrentOption}{report}}
\ProcessKeyOptions[exu]
% paper, body-size: A4 and 12 pt type; oneside, so the binding margin is on the left of every page.
\LoadClass[a4paper,12pt,oneside]{report}
% margin-left, margin-right, margin-top, margin-bottom: the text block, with the page number below it.
\RequirePackage[left=35mm,right=25mm,top=25mm,bottom=25mm]{geometry}
% line-spacing: setspace's one-and-a-half spacing.
\RequirePackage{setspace}
\onehalfspacing
\RequirePackage{graphicx}
\ifexu@draft \setkeys{Gin}{draft} \fi
% numbering, front-matter: the lists in the contents, page labels in the PDF.
\RequirePackage[nottoc]{tocbibind}
\RequirePackage{hyperref}
% The title page counts as page i and prints no number (rule numbering).
\newcommand{\department}[1]{\gdef\exu@department{#1}}
\newcommand{\exu@department}{}
\renewcommand{\maketitle}{%
\pagenumbering{roman}%
\thispagestyle{empty}%
\begin{center}
{\LARGE\bfseries\@title\par}
\vspace{2cm}
A thesis submitted for the degree of \exu@degree\par
\vspace{1cm}
{\large\@author\par}
\vfill
Department of \exu@department\\ Example University
\end{center}
\clearpage
}
% front-matter: one command for each unnumbered part, listed in the contents at the page where it opens.
\newcommand{\frontpart}[1]{\chapter*{#1}\addcontentsline{toc}{chapter}{#1}}
% numbering: chapter 1 starts the arabic count at 1, on a new page.
\newcommand{\mainpart}{\clearpage\pagenumbering{arabic}} latex/thesis.tex
% thesis.tex: the invented thesis, built with exu-thesis.cls.
\documentclass[degree={Doctor of Philosophy}]{exu-thesis}
\title{Rooftop Rainwater Harvesting in Example District}
\author{Nisha Varghese}
\department{Civil Engineering}
\usepackage{lipsum} % filler paragraphs, so that chapter 1 fills whole pages
\begin{document}
\maketitle
\frontpart{Certificate}
This is to certify that this thesis was carried out by Nisha Varghese under my supervision. (Invented wording.)
\frontpart{Declaration}
I declare that this thesis is my own work and that every source I used is cited. (Invented wording.)
\frontpart{Acknowledgements}
I thank the staff of the district library and of the school, who looked after the two gauges.
\frontpart{Abstract}
Two rain gauges on flat roofs in Example District logged every 0.2 mm of rain from June to November. This thesis
reports what they recorded and how much of it a roof could store.
\tableofcontents
\mainpart
\input{chapters}
\end{document} from session import files, run
# The class at work: thesis.tex, built with exu-thesis.cls and checked against the same rules.
files("rules.toml", "spec_check.py", "latex/exu-thesis.cls=exu-thesis.cls", "latex/thesis.tex=thesis.tex",
"latex/chapters.tex=chapters.tex")
run(r"""
latexmk -pdf -interaction=nonstopmode -silent thesis.tex > /dev/null 2>&1
python3 spec_check.py rules.toml thesis.pdf
""") Output
$ latexmk -pdf -interaction=nonstopmode -silent thesis.tex > /dev/null 2>&1 $ python3 spec_check.py rules.toml thesis.pdf rule measured result paper 210.0 × 297.0 mm PASS margin-left 35.0 mm at the least PASS margin-right 25.0 mm at the least PASS margin-top 26.3 mm at the least PASS margin-bottom 25.0 mm at the least PASS body-size 12 pt PASS line-spacing 17.9 pt between baselines PASS chapter-titles 10 title lines, 10 bold, 24.8 pt at the least PASS page-numbers 9 of 9 numbered, 9 in place, 14.5 to 14.5 mm up PASS numbering i to vi, then 1 to 4 PASS front-matter 5 parts on pages 2, 3, 4, 5, 6 PASS chapter-pages 2 of 2 chapters open a page PASS citations 4 numbered, 0 by author and year PASS 13 rules: 13 pass, 0 fail
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 class_session.py
All 13 rules passed. The page is A4, the text block: The part of a page that holds the running text, inside the margins. Page numbers and running headers sit outside it, in the margins. leaves 35 mm on the left and 25 mm
on the right, the type is 12 pt,
and setspace’s \onehalfspacing put the baselines 17.9 pt apart, inside the 0.2 pt tolerance of 18. The page numbers
stood 14.5 mm above the bottom edge on all 9 numbered pages, and the labels ran i to vi, then 1 to 4. The front-matter
commands of the earlier lesson are now part of the class: \frontpart{Declaration} writes the heading and its contents
entry together, and \mainpart starts the arabic count, so a student cannot place either one wrongly.
Defaults in the class, choices in the options
Put the manual’s numbers into the class, not into each thesis: a student who writes \usepackage{geometry} with
other margins in their own preamble is overriding the manual. What really differs between students, such as the
degree or a draft build without images, becomes an option.
Checkpoint
Check: A LaTeX class that implements the rules
A Typst template is one function with the same rules
Typst’s tutorial builds a template as a function that takes the document’s content as its last parameter and is
applied with #show: conf.with(…), its other parameters filled in by name. exu-thesis.typ does that, with title,
author, degree and department, and checks the two it cannot do without. Line spacing needs care here. The
paragraph reference says leading: In Typst, the space between the bottom edge of one line and the top edge of the next, set with par(leading: …). Together with the text's top and bottom edges it decides how far apart the baselines are. is the space between the bottom edge of one line and the
top edge of the next, and
the text reference says the top edge is, by default, the height of the capital letters. So the template makes each
line 1 em high, from top-edge: 1em to bottom-edge: "baseline", and adds 6 pt of leading: 12 pt plus 6 pt is
18 pt from baseline to baseline.
first version · typst/first/exu-thesis.typ
// exu-thesis.typ, first version: a thesis template for Example University, whose manual is invented for this lesson. Unofficial: no
// institution publishes it. Each part names the rules of rules.toml that it implements.
#let exu-thesis(
title: none,
author: none,
degree: "Doctor of Philosophy",
department: none,
body,
) = {
assert(title != none, message: "exu-thesis: give the thesis a title, as in title: [Rainfall]")
assert(type(author) == str, message: "exu-thesis: give the author as a string, as in author: \"N. Varghese\"")
set document(title: title, author: author)
// paper, margin-left, margin-right, margin-top, margin-bottom; page-numbers: roman until chapter 1, centred at the
// foot of the page, in the bottom margin.
set page(paper: "a4", margin: (left: 35mm, right: 25mm, top: 25mm, bottom: 25mm), numbering: "i")
// body-size and line-spacing: 12 pt type whose lines are 1em high from baseline to baseline before leading, so that
// 6 pt of leading puts the baselines 18 pt apart.
set text(size: 12pt, top-edge: 1em, bottom-edge: "baseline")
set par(leading: 6pt, spacing: 18pt, justify: true)
set heading(numbering: none)
// chapter-titles, chapter-pages: every part and chapter on a new page, its title bold at 20 pt.
show heading.where(level: 1): it => {
pagebreak(weak: true)
set text(size: 20pt, weight: "bold")
if it.numbering != none {
block(context [Chapter #counter(heading).display()])
}
block(below: 24pt, it.body)
}
// numbering: the title page counts as page i and shows no number.
page(numbering: none, align(center)[
#text(size: 20pt, weight: "bold", title)
#v(2cm)
A thesis submitted for the degree of #degree
#v(1cm)
#text(size: 14pt, author)
#v(1fr)
Department of #department \ Example University
])
body
}
// numbering: chapter 1 starts the arabic count at 1, at the top of a new page, and the chapters are numbered.
#let main-part(body) = {
pagebreak()
set page(numbering: "1")
counter(page).update(1)
set heading(numbering: "1.1")
body
} second version · typst/exu-thesis.typ
// exu-thesis.typ: a thesis template for Example University, whose manual is invented for this lesson. Unofficial: no
// institution publishes it. Each part names the rules of rules.toml that it implements.
#let exu-thesis(
title: none,
author: none,
degree: "Doctor of Philosophy",
department: none,
body,
) = {
assert(title != none, message: "exu-thesis: give the thesis a title, as in title: [Rainfall]")
assert(type(author) == str, message: "exu-thesis: give the author as a string, as in author: \"N. Varghese\"")
set document(title: title, author: author)
// paper, margin-left, margin-right, margin-top, margin-bottom; page-numbers: roman until chapter 1, centred at the
// foot of the page, in the bottom margin.
set page(paper: "a4", margin: (left: 35mm, right: 25mm, top: 25mm, bottom: 25mm), numbering: "i")
// body-size and line-spacing: 12 pt type whose lines are 1em high from baseline to baseline before leading, so that
// 6 pt of leading puts the baselines 18 pt apart. margin-right: no glyph hangs into the margin, which the manual
// measures from the text.
set text(size: 12pt, top-edge: 1em, bottom-edge: "baseline", overhang: false)
set par(leading: 6pt, spacing: 18pt, justify: true)
set heading(numbering: none)
// chapter-titles, chapter-pages: every part and chapter on a new page, its title bold at 20 pt.
show heading.where(level: 1): it => {
pagebreak(weak: true)
set text(size: 20pt, weight: "bold")
if it.numbering != none {
block(context [Chapter #counter(heading).display()])
}
block(below: 24pt, it.body)
}
// numbering: the title page counts as page i, and its label in the PDF is i, but its footer shows no number.
page(footer: none, align(center)[
#text(size: 20pt, weight: "bold", title)
#v(2cm)
A thesis submitted for the degree of #degree
#v(1cm)
#text(size: 14pt, author)
#v(1fr)
Department of #department \ Example University
])
body
}
// numbering: chapter 1 starts the arabic count at 1, at the top of a new page, and the chapters are numbered.
#let main-part(body) = {
pagebreak()
set page(numbering: "1")
counter(page).update(1)
set heading(numbering: "1.1")
body
} typst/thesis.typ
// thesis.typ: the invented thesis, built with the exu-thesis template.
#import "exu-thesis.typ": exu-thesis, main-part
#show: exu-thesis.with(
title: [Rooftop Rainwater Harvesting in Example District],
author: "Nisha Varghese",
department: [Civil Engineering],
)
= Certificate
This is to certify that this thesis was carried out by Nisha Varghese under my supervision. (Invented wording.)
= Declaration
I declare that this thesis is my own work and that every source I used is cited. (Invented wording.)
= Acknowledgements
I thank the staff of the district library and of the school, who looked after the two gauges.
= Abstract
Two rain gauges on flat roofs in Example District logged every 0.2 mm of rain from June to November. This thesis
reports what they recorded and how much of it a roof could store.
#outline()
#show: main-part
= Introduction <chap:intro>
Rooftop rainwater harvesting stores the rain that falls on a roof for later use, in a tank or in the ground below the
building. In Example District most houses have flat concrete roofs, which shed water quickly and evenly, so a roof of
ninety square metres could gather a large part of a household's water in the wettest months of the year. Earlier work
measured storage tanks in small towns @rao2021tanks, but no record of rainfall on the roofs themselves has been kept in
the district, so this thesis sets two gauges on two roofs and reads them through a whole season of rain.
#for _ in range(6) [#lorem(110)\ ]
= Results <chap:results>
The library gauge recorded its wettest month in August, with 701 mm, and the school gauge recorded 528 mm in the same
month. The two gauges agreed on the order of the months, from the wettest to the driest, and on the dry spell that
followed the first week of September. Their totals differed most in July, when a storm passed north of the school and
over the library, which is why the yield tables of the district office give a range rather than one number for each
month @menon2023yield.
#bibliography("refs.bib", style: "ieee") from session import files, run
# The template at work: typst/thesis.typ compiled with the first version of the exu-thesis template, then with the
# second, and each PDF checked against the same rules.
files("rules.toml", "spec_check.py", "typst/first/exu-thesis.typ=first/exu-thesis.typ",
"typst/exu-thesis.typ=exu-thesis.typ", "typst/thesis.typ=thesis.typ", "typst/refs.bib=refs.bib")
run(r"""
sed 's#"exu-thesis.typ"#"first/exu-thesis.typ"#' thesis.typ > first.typ
typst compile first.typ
python3 spec_check.py rules.toml first.pdf
typst compile thesis.typ
python3 spec_check.py rules.toml thesis.pdf
""") Output
$ sed 's#"exu-thesis.typ"#"first/exu-thesis.typ"#' thesis.typ > first.typ $ typst compile first.typ $ python3 spec_check.py rules.toml first.pdf rule measured result paper 210.0 × 297.0 mm PASS margin-left 35.0 mm at the least PASS margin-right 24.2 mm at the least PASS margin-top 25.4 mm at the least PASS margin-bottom 27.7 mm at the least PASS body-size 12 pt PASS line-spacing 18 pt between baselines PASS chapter-titles 10 title lines, 10 bold, 20.0 pt at the least PASS page-numbers 9 of 9 numbered, 9 in place, 13.3 to 13.3 mm up PASS numbering page 1 is (empty), not i FAIL front-matter 5 parts on pages 2, 3, 4, 5, 6 PASS chapter-pages 2 of 2 chapters open a page PASS citations 4 numbered, 0 by author and year PASS 13 rules: 12 pass, 1 fail $ typst compile thesis.typ $ python3 spec_check.py rules.toml thesis.pdf rule measured result paper 210.0 × 297.0 mm PASS margin-left 35.0 mm at the least PASS margin-right 25.0 mm at the least PASS margin-top 25.4 mm at the least PASS margin-bottom 27.7 mm at the least PASS body-size 12 pt PASS line-spacing 18 pt between baselines PASS chapter-titles 10 title lines, 10 bold, 20.0 pt at the least PASS page-numbers 9 of 9 numbered, 9 in place, 13.3 to 13.3 mm up PASS numbering i to vi, then 1 to 4 PASS front-matter 5 parts on pages 2, 3, 4, 5, 6 PASS chapter-pages 2 of 2 chapters open a page PASS citations 4 numbered, 0 by author and year PASS 13 rules: 13 pass, 0 fail
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typst_session.py
The first version passed 12 rules and failed one: its title page had an empty label, because numbering: none on
that page leaves the label out, while the manual counts the title page as i. The second version keeps the roman
numbering on the title page and removes only its footer, page(footer: none, …), so the label is i and no number is
printed. It also turns off overhang, which the text reference says lets certain glyphs hang into the margin in
justified text: with it, the right margin measured 24.2 mm, inside the checker’s 1 mm tolerance; without it, 25.0 mm.
A stricter office would measure without tolerance, and the template should not depend on one.
Key idea
One rules file and one checker judge both implementations by their PDFs: the checker found the Typst template’s empty title label, which no page showed.
Checkpoint
Check: A Typst template that implements the rules
A template is unofficial until the institution publishes it
Templates are easy to find. In Typst’s package index, read for this lesson, 226 of the 1,684 packages on Typst
Universe mention a thesis or a dissertation in their name or description, and 223 of those are templates. A name
proves nothing about whose rules a template follows. The package unofficial-iitkgp-thesis says so in its name and
in its disclaimer, that it is not affiliated with, endorsed by or maintained by the institute; many others say
nothing.
Not affiliated
Based on public information about the Indian Institute of Technology Kharagpur. MySmartCoPilot is not affiliated with the Indian Institute of Technology Kharagpur, and the Indian Institute of Technology Kharagpur’s real thesis rules may differ from what this lesson describes.
Before you use or publish a template, check four things:
| Check | Why |
|---|---|
| Which edition of the manual it follows | Manuals change between years; a margin or the order of the front matter may have moved |
| What the checker says about its PDF | Every rule of your manual, measured on a thesis built with it |
| Who made it, and whether the institution publishes it | Only the institution can call a template official; call yours unofficial |
| Its licence | Without one, others have no stated permission to copy or adapt it; the Typst team’s own templates carry the MIT-0 licence |
When you share your own, publish the rules file and the checker with it, so a student next year can rerun the checks against the new manual, and mark every invented or example text as such, as this lesson’s certificate is marked.
A template handed down
When a template passes from one batch of students to the next, rerun the checker on it every year against the current manual, and keep the rules file in the template’s folder, so that anyone who receives it can check what it promises.
Checkpoint
Check: Using and sharing templates honestly
Practise: measure line spacing and check page labels
Two of the checker’s measurements make good exercises: the line spacing, from the baselines mutool reports, and the numbering rule, from the page labels. Each runs in the page against sample tests taken from this lesson’s PDFs, and its first hint is open:
Code lab · 2 steps
Two measurements of a thesis checker
Measure the line spacing of a page from its baselines, then report the first page whose label breaks the manual's numbering.
Exercise · Medium · Python
Measure the line spacing of a PDF
spec_check.py decided the line-spacing rule from the lines that mutool read out of each PDF. Write line_pitch(lines, body_size) in pitch.py, the measurement behind that rule.
lines lists the lines of a page in reading order, each as (block, size, baseline): the number of the paragraph block the line belongs to, its type size in points, and the height of its baseline in points, counted down from the top of the page. Return the most common gap between the baselines of two lines that follow each other in the list, when both belong to the same block and both have the body size (within 0.05 pt). Round each gap to 0.1 pt before you count it. When two gaps are equally common, return the smaller one; when no pair of lines qualifies, return None.
Three body lines of one paragraph whose baselines are at 100.0, 118.0 and 136.0 give 18.0.
Starter code · pitch.py
from collections import Counter
def line_pitch(lines, body_size):
"""Return the most common baseline-to-baseline gap of body lines that follow each other in one block."""
# Replace this line: count the rounded gaps between body lines of the same block.
return None The sample tests · test_pitch.py
from pitch import line_pitch
def test_one_paragraph():
"""measures three lines 18 pt apart"""
assert line_pitch([(0, 12.0, 100.0), (0, 12.0, 118.0), (0, 12.0, 136.0)], 12.0) == 18.0
def test_rounding():
"""rounds each gap to 0.1 pt, as LaTeX's one-and-a-half spacing measured 17.9 pt"""
lines = [(0, 12.0, 100.0), (0, 12.0, 117.93), (0, 12.0, 135.86), (0, 12.0, 153.79)]
assert line_pitch(lines, 12.0) == 17.9
def test_other_blocks_do_not_count():
"""leaves out the gap between two paragraphs"""
lines = [(0, 12.0, 100.0), (0, 12.0, 118.0), (1, 12.0, 160.0), (1, 12.0, 178.0)]
assert line_pitch(lines, 12.0) == 18.0
def test_other_sizes_do_not_count():
"""leaves out headings and footnotes"""
lines = [(0, 20.0, 60.0), (0, 20.0, 85.0), (0, 20.0, 110.0), (1, 12.0, 140.0), (1, 12.0, 152.0)]
assert line_pitch(lines, 12.0) == 12.0
def test_ties_take_the_smaller_gap():
"""returns the smaller of two equally common gaps"""
lines = [(0, 12.0, 100.0), (0, 12.0, 118.0), (0, 12.0, 130.0)]
assert line_pitch(lines, 12.0) == 12.0
def test_no_pair():
"""returns None when no two body lines follow each other in one block"""
assert line_pitch([(0, 12.0, 100.0), (1, 12.0, 118.0)], 12.0) is None
assert line_pitch([], 12.0) is None A hint
Keep only the body lines first, then pair each line with the next one: zip(body, body[1:]). Count the rounded gaps of pairs in the same block with collections.Counter, and choose with min(counts, key=lambda gap: (-counts[gap], gap)).
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
Check the page labels against the manual
The numbering rule caught the first version of the Typst template: its title page had an empty label. Write numbering_problem(labels, first_lines) in numbering.py, the check behind that rule.
labels holds the label of every page, in the order of the PDF ("" for an empty label), and first_lines the first line of every page. Chapter 1 opens on the first page whose first line is exactly "Chapter 1". The manual wants the pages before it labelled i, ii, iii and so on, counting from the first page, and the pages from it on labelled 1, 2, 3 and so on.
Return None when every label is right. Otherwise return one string: "no page starts with Chapter 1", or, for the first page whose label is wrong, "page <n> is <label>, not <expected>", where <n> counts pages from 1 and an empty label is written (empty). The first Typst template gives "page 1 is (empty), not i".
Starter code · numbering.py
def numbering_problem(labels, first_lines):
"""Return None when the labels follow the manual, else the first problem as a string."""
# Replace this line: find chapter 1, build the labels the manual expects and compare.
return None The sample tests · test_numbering.py
from numbering import numbering_problem
FIRST = ["Rooftop Rainwater Harvesting", "Certificate", "Declaration", "Acknowledgements", "Abstract", "Contents",
"Chapter 1", "Rain fell all week", "Chapter 2", "Bibliography"]
GOOD = ["i", "ii", "iii", "iv", "v", "vi", "1", "2", "3", "4"]
def test_the_class():
"""accepts the labels of the LaTeX class's thesis"""
assert numbering_problem(GOOD, FIRST) is None
def test_empty_title_label():
"""reports the first Typst template's title page"""
assert numbering_problem([""] + GOOD[1:], FIRST) == "page 1 is (empty), not i"
def test_no_roman_front_matter():
"""reports the plain report, numbered 1, 2, 3 from the start"""
assert numbering_problem([str(n) for n in range(1, 11)], FIRST) == "page 1 is 1, not i"
def test_no_restart_at_chapter_one():
"""reports a chapter 1 that does not start the arabic count"""
labels = GOOD[:6] + ["7", "8", "9", "10"]
assert numbering_problem(labels, FIRST) == "page 7 is 7, not 1"
def test_a_late_mistake():
"""reports the first wrong page only"""
labels = GOOD[:8] + ["4", "5"]
assert numbering_problem(labels, FIRST) == "page 9 is 4, not 3"
def test_larger_roman_numbers():
"""writes roman numbers past viii correctly"""
first = ["Title"] + ["Part"] * 10 + ["Chapter 1"]
labels = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "1"]
assert numbering_problem(labels, first) is None
assert numbering_problem(labels[:8] + ["viiii"] + labels[9:], first) == "page 9 is viiii, not ix"
def test_no_chapter_one():
"""says when no page starts with Chapter 1"""
assert numbering_problem(GOOD, ["Chapter 2" if line == "Chapter 1" else line for line in FIRST]) == \
"no page starts with Chapter 1" A hint
Build the list of labels the manual expects, roman numbers up to chapter 1 and arabic numbers after it, then compare it with labels page by page with enumerate(zip(labels, expected), 1). A loop over the pairs (1000, "m"), (900, "cm") … (1, "i") writes a roman number.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then work through the questions. Each answer is explained once you check it, and the practice comes back to the ideas you have missed:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): why write a thesis manual’s rules as numbers before you write any template code?
Because a number can be checked and a sentence cannot. “One and a half spacing” becomes 18 pt from baseline to baseline for 12 pt type, and “a wide left margin” becomes at least 35 mm, so a script can measure every page of the PDF and say which rule fails and by how much. Written first, the rules are the test the template must pass: in this lesson the report class’s own settings failed 5 of 13 rules, and the class and the second Typst template passed all 13. The numbers also expose what the words hide: LaTeX’s one-and-a-half spacing measured 17.9 pt, and Typst needed explicit text edges and 6 pt of leading to reach 18 pt. Rules that stay judgements, such as a clear font, go on a short list that someone checks by eye.
Two harder questions follow: building templates for a whole department, and a thesis rejected for its margins although it was built from a template named after the university. Plan your first step before you open each one:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A student built their thesis from a popular online template named after your university, and the thesis office rejected it: the left margin is 30 mm, and the manual asks for 35. What went wrong, and what process stops it from happening to the next student?
Key takeaways
- Rewrite each rule of a thesis manual as a quantity with a limit and a way to measure it in the PDF, and keep the manual’s sentence beside it.
- Write the checker before the template, check the checker on a PDF you know, and treat a template as done only when every rule passes.
- A LaTeX class built on report puts the manual’s numbers in one file, with options only for what differs between students.
- A Typst template is one function applied with a show rule; its line spacing needs explicit text edges and leading, and its title page needs roman numbering without a footer.
- A template is unofficial until the institution publishes it: check its edition of the manual, its PDF and its licence, and share yours with the rules and the checker.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do you make "one and a half spacing" checkable?
Turn it into a number you can measure in the PDF: with 12 pt type, baselines 18 pt apart.
-
What do you do with a rule that resists numbers, such as "a clear, professional font"?
Ask the thesis office what it means or write down the accepted fonts, and keep a short list of the rules you check by eye.
-
Why write the checker before the template?
The template is then done when every rule passes, not when the pages look right; the report class's own settings failed 5 of the 13 rules.
-
Which defaults of the report class broke the invented manual?
US letter paper, 10 pt type with baselines 12 pt apart, page numbers 31.5 mm above the bottom edge, and arabic numbers from the first page.
-
What belongs in a thesis class, and what in its options?
The manual's numbers belong in the class; only what really differs between students, such as the degree or a draft build, becomes an option.
-
How does a Typst template get 18 pt between baselines with 12 pt type?
It makes each line 1 em high, with top-edge: 1em and bottom-edge: "baseline", and adds 6 pt of leading.
-
Why did the first Typst template fail the numbering rule?
numbering: none on the title page left its label empty. Keeping roman numbering and removing only the footer labels it i without printing a number.
-
What do you check before using a thesis template found online?
Which edition of the manual it follows, what a checker says about its PDF, whether the institution publishes it, and its licence: a template is unofficial until the institution publishes it.
Terms in this lesson
- baseline
- The line that letters stand on. Line spacing is measured from the baseline of one line to the baseline of the next, so 12 pt type set one and a half lines apart has baselines 18 pt apart.
- leading
- In Typst, the space between the bottom edge of one line and the top edge of the next, set with par(leading: …). Together with the text's top and bottom edges it decides how far apart the baselines are.
- text block
- The part of a page that holds the running text, inside the margins. Page numbers and running headers sit outside it, in the margins.
References
- LaTeX for package and class authors (current version) (The LaTeX Project)
- Typst tutorial: Making a template (Typst)
- Typst reference: paragraph (leading and spacing) (Typst)
- Typst reference: text (top-edge, bottom-edge and overhang) (Typst)
- setspace: set space between lines (CTAN)
- geometry: flexible and complete interface to document dimensions (CTAN)
- typst/templates: templates maintained by the Typst team (Typst)
- Typst package index (every package and version on Typst Universe) (Typst)
- unofficial-iitkgp-thesis on Typst Universe (Typst Universe)
- MuPDF: mutool draw (structured text output) (Artifex)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress