LaTeX & Typst (papers, theses, resumes and slides) Module 4 – Tables
Long, wide and data-driven tables
Break long tables across pages with repeated headers, turn wide ones onto landscape pages, and build tables and totals from CSV and JSON in LaTeX and Typst.
What you will learn
- Split a long table across pages with a header repeated on every page
- Rotate a wide table onto a landscape page in both systems
- Generate a table from CSV or JSON data instead of typing it
- Compute totals in the document so that they never drift from the data
Before you start
What you’ll learn · Before you start
What you will learn
- Split a long table across pages with a header repeated on every page
- Rotate a wide table onto a landscape page in both systems
- Generate a table from CSV or JSON data instead of typing it
- Compute totals in the document so that they never drift from the data
Before you start
On this page
A class list of 60 students does not fit on one A5 page. Set as an ordinary tabular, it ran off the bottom of the
page, and 19 of its rows were on no page at all, with only a warning in the log to say so. Long tables have to break,
wide ones have to turn, and many tables are copied from a spreadsheet that changes after the copy was made.
Below you will break a 60-row table across pages with its header repeated in LaTeX and in Typst, set a table of 14 columns on a landscape page, build a table from CSV and JSON data, and compute its totals in the document, so that a late correction to the data can never leave an old total behind. The pages, sizes and sums quoted here are those of the recorded builds.
A tabular cannot break across pages, but longtable can
long-tabular.tex puts the 60 rows of marks in a tabular inside a table float on A5 pages. long.tex uses the
longtable package instead, which has most of the features of tabular but lets TeX break the table between pages,
as its manual puts it. Four commands divide its rows into parts that are printed in different places:
long/long-tabular.tex
\documentclass{article}
\usepackage[a5paper]{geometry}
\usepackage{booktabs}
\usepackage{longtable}
\begin{document}
\begin{table}[ht]
\centering
\caption{Marks of the 60 students of section B}
\begin{tabular}{l r r r r}
\toprule
Roll no. & Maths & Physics & Chemistry & Total \\
\midrule
CS-001 & 76 & 54 & 85 & 215 \\
CS-002 & 41 & 44 & 47 & 132 \\
CS-003 & 81 & 42 & 62 & 185 \\
CS-004 & 39 & 46 & 90 & 175 \\
CS-005 & 88 & 43 & 65 & 196 \\
CS-006 & 46 & 89 & 42 & 177 \\
CS-007 & 50 & 63 & 42 & 155 \\
CS-008 & 85 & 41 & 63 & 189 \\
CS-009 & 40 & 52 & 72 & 164 \\
CS-010 & 88 & 53 & 50 & 191 \\
CS-011 & 74 & 58 & 48 & 180 \\
CS-012 & 59 & 82 & 47 & 188 \\
CS-013 & 43 & 42 & 61 & 146 \\
CS-014 & 98 & 89 & 75 & 262 \\
CS-015 & 94 & 93 & 81 & 268 \\
CS-016 & 73 & 66 & 58 & 197 \\
CS-017 & 66 & 45 & 73 & 184 \\
CS-018 & 98 & 78 & 92 & 268 \\
CS-019 & 71 & 44 & 50 & 165 \\
CS-020 & 88 & 56 & 78 & 222 \\
CS-021 & 54 & 97 & 88 & 239 \\
CS-022 & 40 & 44 & 75 & 159 \\
CS-023 & 78 & 79 & 98 & 255 \\
CS-024 & 93 & 43 & 46 & 182 \\
CS-025 & 69 & 95 & 43 & 207 \\
CS-026 & 42 & 74 & 92 & 208 \\
CS-027 & 71 & 84 & 79 & 234 \\
CS-028 & 37 & 94 & 80 & 211 \\
CS-029 & 56 & 49 & 98 & 203 \\
CS-030 & 42 & 62 & 71 & 175 \\
CS-031 & 51 & 66 & 85 & 202 \\
CS-032 & 85 & 98 & 45 & 228 \\
CS-033 & 56 & 92 & 86 & 234 \\
CS-034 & 70 & 52 & 90 & 212 \\
CS-035 & 70 & 88 & 80 & 238 \\
CS-036 & 83 & 64 & 54 & 201 \\
CS-037 & 45 & 57 & 54 & 156 \\
CS-038 & 64 & 64 & 36 & 164 \\
CS-039 & 97 & 58 & 68 & 223 \\
CS-040 & 71 & 35 & 53 & 159 \\
CS-041 & 88 & 82 & 75 & 245 \\
CS-042 & 51 & 41 & 93 & 185 \\
CS-043 & 85 & 85 & 86 & 256 \\
CS-044 & 85 & 48 & 96 & 229 \\
CS-045 & 86 & 42 & 59 & 187 \\
CS-046 & 43 & 61 & 91 & 195 \\
CS-047 & 55 & 49 & 78 & 182 \\
CS-048 & 41 & 48 & 35 & 124 \\
CS-049 & 54 & 47 & 81 & 182 \\
CS-050 & 38 & 44 & 61 & 143 \\
CS-051 & 83 & 54 & 67 & 204 \\
CS-052 & 79 & 81 & 95 & 255 \\
CS-053 & 50 & 49 & 97 & 196 \\
CS-054 & 94 & 96 & 96 & 286 \\
CS-055 & 74 & 45 & 53 & 172 \\
CS-056 & 48 & 78 & 68 & 194 \\
CS-057 & 96 & 55 & 37 & 188 \\
CS-058 & 61 & 81 & 53 & 195 \\
CS-059 & 38 & 73 & 46 & 157 \\
CS-060 & 68 & 81 & 56 & 205 \\
\bottomrule
\end{tabular}
\end{table}
\end{document} long/long.tex
\documentclass{article}
\usepackage[a5paper]{geometry}
\usepackage{booktabs}
\usepackage{longtable}
\begin{document}
\begin{longtable}{l r r r r}
\caption{Marks of the 60 students of section B}\label{tab:marks}\\
\toprule
Roll no. & Maths & Physics & Chemistry & Total \\
\midrule
\endfirsthead
\multicolumn{5}{l}{\small Table~\ref{tab:marks}, continued} \\
\toprule
Roll no. & Maths & Physics & Chemistry & Total \\
\midrule
\endhead
\midrule
\multicolumn{5}{r}{\small Continued on the next page} \\
\endfoot
\bottomrule
\endlastfoot
CS-001 & 76 & 54 & 85 & 215 \\
CS-002 & 41 & 44 & 47 & 132 \\
CS-003 & 81 & 42 & 62 & 185 \\
CS-004 & 39 & 46 & 90 & 175 \\
CS-005 & 88 & 43 & 65 & 196 \\
CS-006 & 46 & 89 & 42 & 177 \\
CS-007 & 50 & 63 & 42 & 155 \\
CS-008 & 85 & 41 & 63 & 189 \\
CS-009 & 40 & 52 & 72 & 164 \\
CS-010 & 88 & 53 & 50 & 191 \\
CS-011 & 74 & 58 & 48 & 180 \\
CS-012 & 59 & 82 & 47 & 188 \\
CS-013 & 43 & 42 & 61 & 146 \\
CS-014 & 98 & 89 & 75 & 262 \\
CS-015 & 94 & 93 & 81 & 268 \\
CS-016 & 73 & 66 & 58 & 197 \\
CS-017 & 66 & 45 & 73 & 184 \\
CS-018 & 98 & 78 & 92 & 268 \\
CS-019 & 71 & 44 & 50 & 165 \\
CS-020 & 88 & 56 & 78 & 222 \\
CS-021 & 54 & 97 & 88 & 239 \\
CS-022 & 40 & 44 & 75 & 159 \\
CS-023 & 78 & 79 & 98 & 255 \\
CS-024 & 93 & 43 & 46 & 182 \\
CS-025 & 69 & 95 & 43 & 207 \\
CS-026 & 42 & 74 & 92 & 208 \\
CS-027 & 71 & 84 & 79 & 234 \\
CS-028 & 37 & 94 & 80 & 211 \\
CS-029 & 56 & 49 & 98 & 203 \\
CS-030 & 42 & 62 & 71 & 175 \\
CS-031 & 51 & 66 & 85 & 202 \\
CS-032 & 85 & 98 & 45 & 228 \\
CS-033 & 56 & 92 & 86 & 234 \\
CS-034 & 70 & 52 & 90 & 212 \\
CS-035 & 70 & 88 & 80 & 238 \\
CS-036 & 83 & 64 & 54 & 201 \\
CS-037 & 45 & 57 & 54 & 156 \\
CS-038 & 64 & 64 & 36 & 164 \\
CS-039 & 97 & 58 & 68 & 223 \\
CS-040 & 71 & 35 & 53 & 159 \\
CS-041 & 88 & 82 & 75 & 245 \\
CS-042 & 51 & 41 & 93 & 185 \\
CS-043 & 85 & 85 & 86 & 256 \\
CS-044 & 85 & 48 & 96 & 229 \\
CS-045 & 86 & 42 & 59 & 187 \\
CS-046 & 43 & 61 & 91 & 195 \\
CS-047 & 55 & 49 & 78 & 182 \\
CS-048 & 41 & 48 & 35 & 124 \\
CS-049 & 54 & 47 & 81 & 182 \\
CS-050 & 38 & 44 & 61 & 143 \\
CS-051 & 83 & 54 & 67 & 204 \\
CS-052 & 79 & 81 & 95 & 255 \\
CS-053 & 50 & 49 & 97 & 196 \\
CS-054 & 94 & 96 & 96 & 286 \\
CS-055 & 74 & 45 & 53 & 172 \\
CS-056 & 48 & 78 & 68 & 194 \\
CS-057 & 96 & 55 & 37 & 188 \\
CS-058 & 61 & 81 & 53 & 195 \\
CS-059 & 38 & 73 & 46 & 157 \\
CS-060 & 68 & 81 & 56 & 205 \\
\end{longtable}
\end{document} pages.py
"""Says, page by page, which rows of a long table stand on each page of a PDF, and what else each page holds: a
caption, a repeated header, a "continued" note. The rows to look for are the first column of a CSV file (roll numbers
such as CS-001); MuPDF (mutool draw -F stext) reads only what lies within each page, so a row placed below a page's
bottom edge is on no page. The last line names the rows that are missing from every page.
usage: python3 pages.py <pdf> <csv>
"""
import csv
import os
import subprocess
import sys
import xml.etree.ElementTree as ET
pdf, data = sys.argv[1], sys.argv[2]
with open(data, newline="", encoding="utf-8") as f:
wanted = [row[0] for row in list(csv.reader(f))[1:]]
root = ET.fromstring(subprocess.run(["mutool", "draw", "-q", "-F", "stext", pdf], capture_output=True, check=True).stdout)
prefix = os.path.commonprefix(wanted) # what every row starts with, such as CS-
seen = set()
for number, page in enumerate(root.iter("page"), 1):
lines = {}
for c in page.iter("char"):
quad = [float(v) for v in c.get("quad").split()]
lines.setdefault(round(float(c.get("y"))), []).append((quad[0], quad[2], c.get("c")))
texts = []
for _, chars in sorted(lines.items()):
chars.sort()
text = chars[0][2]
for (_, right, _), (left, _, ch) in zip(chars, chars[1:]):
text += (" " if left - right > 1.5 else "") + ch
texts.append(" ".join(text.split()))
rows = [t.split()[0] for t in texts if t.split() and t.split()[0] in wanted]
# Other lines, but not page numbers or a row cut by the page's edge.
others = [t for t in texts if t and not t.startswith(prefix) and not t.isdigit()]
seen.update(rows)
span = f"{rows[0]} to {rows[-1]}" if rows else "none"
print(f"page {number}: {len(rows)} rows, {span}" + (f"; also {', '.join(repr(o) for o in others)}" if others else ""))
missing = [r for r in wanted if r not in seen]
print("every row is on a page" if not missing else f"{len(missing)} rows are missing from every page: {missing[0]} to {missing[-1]}") pages.py reads each page as MuPDF does, so it reports only what lies within a page, and it names the rows found on
none:
from session import files, run
# The 60 rows of marks60.csv on A5 pages: as a tabular in a table float (long-tabular.tex) and as a longtable with a
# first head, a head, a foot and a last foot (long.tex, built twice so that \ref finds its label). pages.py says
# which rows are on each page and what stands above and below them.
files("long/long-tabular.tex", "long/long.tex", "long/marks60.csv", "pages.py")
run("""
pdflatex -interaction=nonstopmode -halt-on-error long-tabular.tex | grep -E "Warning|Output written"
python3 pages.py long-tabular.pdf marks60.csv
pdflatex -interaction=nonstopmode -halt-on-error long.tex > /dev/null
pdflatex -interaction=nonstopmode -halt-on-error long.tex | grep -E "Warning|Output written"
python3 pages.py long.pdf marks60.csv
""") Output
$ pdflatex -interaction=nonstopmode -halt-on-error long-tabular.tex | grep -E "Warning|Output written" LaTeX Warning: Float too large for page by 342.83234pt on input line 75. Output written on long-tabular.pdf (1 page, 19298 bytes). $ python3 pages.py long-tabular.pdf marks60.csv page 1: 41 rows, CS-001 to CS-041; also 'Table 1: Marks of the 60 students of section B', 'Roll no. Maths Physics Chemistry Total' 19 rows are missing from every page: CS-042 to CS-060 $ pdflatex -interaction=nonstopmode -halt-on-error long.tex > /dev/null $ pdflatex -interaction=nonstopmode -halt-on-error long.tex | grep -E "Warning|Output written" Output written on long.pdf (2 pages, 31996 bytes). $ python3 pages.py long.pdf marks60.csv page 1: 30 rows, CS-001 to CS-030; also 'Table 1: Marks of the 60 students of section B', 'Roll no. Maths Physics Chemistry Total', 'Continued on the next page' page 2: 30 rows, CS-031 to CS-060; also 'Table 1, continued', 'Roll no. Maths Physics Chemistry Total' every row is on a page
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 long_session.py
The tabular is one box, and a box cannot be split. pdfLaTeX warned Float too large for page by 342.83234pt, put
the box on page 1 all the same, and the box ran off the bottom of the paper: the last 19 rows, CS-042 to CS-060, are
on no page. The longtable gave 30 rows to each of two pages: page 1 started with the caption and the header and ended
with “Continued on the next page”, and page 2 started with “Table 1, continued” and the header again, a
repeated header: The header row of a table that breaks across pages, printed again at the top of each new page so that every page shows what its columns are. LaTeX's longtable sets it with \endhead, Typst with table.header.. Step through how it decided:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first head. Everything before \endfirsthead is printed once, at the start of the table. In long.tex that is the caption, Table 1, and the header row between its rules.
- The rows. longtable sets the rows one by one and checks the room left on the page. The A5 page of long.tex held 30 rows under its first head, CS-001 to CS-030.
- The foot. When the next row would not fit, longtable ends the page with the foot, everything between \endhead and \endfoot. In long.tex it is a rule and the note "Continued on the next page".
- The head again. The new page starts with the head, everything between \endfirsthead and \endhead. long.tex says "Table 1, continued" and repeats the header row, so a reader of page 2 still knows which column is which.
- More rows. The rows go on under the repeated head, and the same foot and head would follow at every further page break. Page 2 held CS-031 to CS-060.
- The last foot. After the final row comes the last foot, everything between \endfoot and \endlastfoot, and only there. In long.tex it is the bottom rule, so the last page has no "Continued" note.
| The rows before | are printed | in long.tex |
|---|---|---|
\endfirsthead |
at the top of the first page | the caption and the header |
\endhead |
at the top of every later page | “Table 1, continued” and the header |
\endfoot |
at the bottom of every page but the last | “Continued on the next page” |
\endlastfoot |
at the bottom of the last page | the bottom rule |
The caption goes inside the longtable, ended by \\, because a longtable is not a float: it numbers itself with the
same counter as table. Like a reference, it also needs a second run, since longtable keeps its column widths in the
.aux file and lines the pages up from them; the session built long.tex twice. For X columns that wrap and break
across pages, xltabular combines longtable with tabularx, and the booktabs manual says its rules work inside a
longtable exactly as in a tabular.
Long tables in a tagged PDF
The LaTeX Project’s tagging status list marks longtable and xltabular compatible, with one condition for pdfLaTeX: page breaks in a tagged longtable or xltabular need a LaTeX release from 2025 or later, and TeX Live 2026 has one.
Checkpoint
Check: Long tables in LaTeX
Typst tables break by themselves, except inside a figure
A Typst table breaks across pages without being asked, and the cells of table.header repeat at the top of each new
page; repeat: true is the default, written out in long.typ to show it. The rules inside the header repeat with it.
long-figure.typ puts the same table in a figure to give it a caption, and long-figure-break.typ adds one show
rule:
long/long.typ
#set page(paper: "a5")
// Each row of marks60.csv is an array of strings: the roll number, then three marks.
#let rows = csv("marks60.csv").slice(1)
#table(
columns: 5,
stroke: none,
align: (left, right, right, right, right),
// The rules belong to the header, so they repeat with it on every page.
table.header(
repeat: true,
table.hline(stroke: 0.08em),
[Roll no.], [Maths], [Physics], [Chemistry], [Total],
table.hline(stroke: 0.05em),
),
..rows.map(r => (..r, str(r.slice(1).map(int).sum()))).flatten(),
table.hline(stroke: 0.08em),
) long/long-figure.typ
#set page(paper: "a5")
#show figure.where(kind: table): set figure.caption(position: top)
#let rows = csv("marks60.csv").slice(1)
#figure(
table(
columns: 5,
stroke: none,
align: (left, right, right, right, right),
table.header(
repeat: true,
table.hline(stroke: 0.08em),
[Roll no.], [Maths], [Physics], [Chemistry], [Total],
table.hline(stroke: 0.05em),
),
..rows.map(r => (..r, str(r.slice(1).map(int).sum()))).flatten(),
table.hline(stroke: 0.08em),
),
caption: [Marks of the 60 students of section B],
) long/long-figure-break.typ
#set page(paper: "a5")
#show figure.where(kind: table): set figure.caption(position: top)
// A figure is one block that does not break; this lets figures break across pages.
#show figure: set block(breakable: true)
#let rows = csv("marks60.csv").slice(1)
#figure(
table(
columns: 5,
stroke: none,
align: (left, right, right, right, right),
table.header(
repeat: true,
table.hline(stroke: 0.08em),
[Roll no.], [Maths], [Physics], [Chemistry], [Total],
table.hline(stroke: 0.05em),
),
..rows.map(r => (..r, str(r.slice(1).map(int).sum()))).flatten(),
table.hline(stroke: 0.08em),
),
caption: [Marks of the 60 students of section B],
) long/marks60.csv
Roll no.,Maths,Physics,Chemistry
CS-001,76,54,85
CS-002,41,44,47
CS-003,81,42,62
CS-004,39,46,90
CS-005,88,43,65
CS-006,46,89,42
CS-007,50,63,42
CS-008,85,41,63
CS-009,40,52,72
CS-010,88,53,50
CS-011,74,58,48
CS-012,59,82,47
CS-013,43,42,61
CS-014,98,89,75
CS-015,94,93,81
CS-016,73,66,58
CS-017,66,45,73
CS-018,98,78,92
CS-019,71,44,50
CS-020,88,56,78
CS-021,54,97,88
CS-022,40,44,75
CS-023,78,79,98
CS-024,93,43,46
CS-025,69,95,43
CS-026,42,74,92
CS-027,71,84,79
CS-028,37,94,80
CS-029,56,49,98
CS-030,42,62,71
CS-031,51,66,85
CS-032,85,98,45
CS-033,56,92,86
CS-034,70,52,90
CS-035,70,88,80
CS-036,83,64,54
CS-037,45,57,54
CS-038,64,64,36
CS-039,97,58,68
CS-040,71,35,53
CS-041,88,82,75
CS-042,51,41,93
CS-043,85,85,86
CS-044,85,48,96
CS-045,86,42,59
CS-046,43,61,91
CS-047,55,49,78
CS-048,41,48,35
CS-049,54,47,81
CS-050,38,44,61
CS-051,83,54,67
CS-052,79,81,95
CS-053,50,49,97
CS-054,94,96,96
CS-055,74,45,53
CS-056,48,78,68
CS-057,96,55,37
CS-058,61,81,53
CS-059,38,73,46
CS-060,68,81,56 from session import files, run
# The same 60 rows, read from marks60.csv by Typst: as a table on its own (long.typ), inside a figure (long-figure.typ),
# and inside a figure that may break across pages (long-figure-break.typ). pages.py says which rows are on each page.
files("long/marks60.csv", "long/long.typ", "long/long-figure.typ", "long/long-figure-break.typ", "pages.py")
run("""
typst compile long.typ
python3 pages.py long.pdf marks60.csv
typst compile long-figure.typ
python3 pages.py long-figure.pdf marks60.csv
typst compile long-figure-break.typ
python3 pages.py long-figure-break.pdf marks60.csv
""") Output
$ typst compile long.typ $ python3 pages.py long.pdf marks60.csv page 1: 27 rows, CS-001 to CS-027; also 'Roll no. Maths Physics Chemistry Total' page 2: 27 rows, CS-028 to CS-054; also 'Roll no. Maths Physics Chemistry Total' page 3: 6 rows, CS-055 to CS-060; also 'Roll no. Maths Physics Chemistry Total' every row is on a page $ typst compile long-figure.typ $ python3 pages.py long-figure.pdf marks60.csv page 1: 28 rows, CS-001 to CS-028; also 'Table 1: Marks of the 60 students of section B', 'Roll no. Maths Physics Chemistry Total' 32 rows are missing from every page: CS-029 to CS-060 $ typst compile long-figure-break.typ $ python3 pages.py long-figure-break.pdf marks60.csv page 1: 26 rows, CS-001 to CS-026; also 'Table 1: Marks of the 60 students of section B', 'Roll no. Maths Physics Chemistry Total' page 2: 27 rows, CS-027 to CS-053; also 'Roll no. Maths Physics Chemistry Total' page 3: 7 rows, CS-054 to CS-060; also 'Roll no. Maths Physics Chemistry Total' every row is on a page
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typst_long_session.py
On its own the table took three pages of 27, 27 and 6 rows, each page starting with the header. Inside the figure it
did not break at all: page 1 held 28 rows and the other 32 were on no page, and Typst printed no warning. The tables
guide explains it: a table in a figure cannot break across pages by default, because the figure is one block. With
#show figure: set block(breakable: true) the figure broke over three pages too, of 26, 27 and 7 rows, with its
caption on page 1 only.
A captioned long table that silently loses rows
Wrapping a long Typst table in figure for its caption, without making figures breakable, leaves most of its rows
where no page shows them. Add #show figure: set block(breakable: true) before it, then count the rows on the
pages, because the compiler will not complain.
table.footer works the same way at the bottom: the table reference says it repeats on every page by default, which
suits totals or column labels that each page needs, and no cell may come after it.
Key idea
LaTeX needs longtable to break a table and states each head and foot; Typst breaks every table and repeats table.header, but a figure holds its table on one page until figures are made breakable.
Checkpoint
Check: Long tables in Typst
A wide table goes on a landscape page
Twelve unit tests and a mean for each of six students make 14 columns. On a portrait page they do not fit:
wide-portrait.tex was 43.42 pt wider than the text. A landscape page: A page that is wider than it is tall. pdflscape makes one by marking a portrait page to be shown turned a quarter turn; Typst's page(flipped: true) makes the page itself wide. gives it
room: wide.tex puts the table in pdflscape’s landscape environment, and wide.typ puts it on a page of its own
with page(flipped: true):
wide/wide-portrait.tex
\documentclass[a4paper]{article}
\usepackage{booktabs}
\begin{document}
\begin{table}[ht]
\centering
\caption{Marks out of 25 in twelve unit tests}\label{tab:tests}
\begin{tabular}{l *{12}{r} r}
\toprule
Roll no. & T1 & T2 & T3 & T4 & T5 & T6 & T7 & T8 & T9 & T10 & T11 & T12 & Mean \\
\midrule
CS-001 & 23 & 23 & 23 & 25 & 15 & 14 & 25 & 24 & 14 & 12 & 23 & 18 & 19.9 \\
CS-002 & 13 & 11 & 10 & 21 & 23 & 14 & 9 & 25 & 11 & 10 & 10 & 15 & 14.3 \\
CS-003 & 16 & 9 & 23 & 19 & 23 & 15 & 25 & 16 & 18 & 24 & 9 & 11 & 17.3 \\
CS-004 & 23 & 17 & 22 & 11 & 17 & 19 & 16 & 25 & 18 & 9 & 11 & 12 & 16.7 \\
CS-005 & 21 & 12 & 18 & 21 & 11 & 9 & 9 & 15 & 15 & 10 & 24 & 21 & 15.5 \\
CS-006 & 21 & 22 & 11 & 15 & 17 & 19 & 11 & 18 & 19 & 9 & 22 & 12 & 16.3 \\
\bottomrule
\end{tabular}
\end{table}
\end{document} wide/wide.tex
\documentclass[a4paper]{article}
\usepackage{booktabs}
\usepackage{pdflscape}
\begin{document}
The marks of the twelve unit tests are in Table~\ref{tab:tests}, on the next page.
% Everything inside landscape is set on pages turned a quarter turn; pdflscape tells PDF viewers to show them upright.
\begin{landscape}
\begin{table}[ht]
\centering
\caption{Marks out of 25 in twelve unit tests}\label{tab:tests}
\begin{tabular}{l *{12}{r} r}
\toprule
Roll no. & T1 & T2 & T3 & T4 & T5 & T6 & T7 & T8 & T9 & T10 & T11 & T12 & Mean \\
\midrule
CS-001 & 23 & 23 & 23 & 25 & 15 & 14 & 25 & 24 & 14 & 12 & 23 & 18 & 19.9 \\
CS-002 & 13 & 11 & 10 & 21 & 23 & 14 & 9 & 25 & 11 & 10 & 10 & 15 & 14.3 \\
CS-003 & 16 & 9 & 23 & 19 & 23 & 15 & 25 & 16 & 18 & 24 & 9 & 11 & 17.3 \\
CS-004 & 23 & 17 & 22 & 11 & 17 & 19 & 16 & 25 & 18 & 9 & 11 & 12 & 16.7 \\
CS-005 & 21 & 12 & 18 & 21 & 11 & 9 & 9 & 15 & 15 & 10 & 24 & 21 & 15.5 \\
CS-006 & 21 & 22 & 11 & 15 & 17 & 19 & 11 & 18 & 19 & 9 & 22 & 12 & 16.3 \\
\bottomrule
\end{tabular}
\end{table}
\end{landscape}
\end{document} wide/wide.typ
#show figure.where(kind: table): set figure.caption(position: top)
The marks of the twelve unit tests are in @tests, on the next page.
// A page of its own whose width and height are swapped; the text after it goes on portrait pages again.
#page(flipped: true)[
#let rows = csv("tests.csv")
#figure(
table(
columns: 14,
stroke: none,
align: (left,) + (right,) * 13,
table.hline(stroke: 0.08em),
table.header(..rows.at(0), [Mean]),
table.hline(stroke: 0.05em),
..rows.slice(1).map(r => {
let marks = r.slice(1).map(int)
(..r, str(calc.round(marks.sum() / marks.len(), digits: 1)))
}).flatten(),
table.hline(stroke: 0.08em),
),
caption: [Marks out of 25 in twelve unit tests],
) <tests>
] wide/tests.csv
Roll no.,T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12
CS-001,23,23,23,25,15,14,25,24,14,12,23,18
CS-002,13,11,10,21,23,14,9,25,11,10,10,15
CS-003,16,9,23,19,23,15,25,16,18,24,9,11
CS-004,23,17,22,11,17,19,16,25,18,9,11,12
CS-005,21,12,18,21,11,9,9,15,15,10,24,21
CS-006,21,22,11,15,17,19,11,18,19,9,22,12 from session import files, run
# Twelve unit tests of six students: on an ordinary portrait page (wide-portrait.tex), on a landscape page with
# pdflscape (wide.tex, built twice so that \ref finds its label) and on a flipped page in Typst (wide.typ). pdfinfo
# prints the size and the rotation of each page.
files("wide/wide-portrait.tex", "wide/wide.tex", "wide/wide.typ", "wide/tests.csv")
run("""
pdflatex -interaction=nonstopmode -halt-on-error wide-portrait.tex | grep -E "Overfull|Output written"
pdflatex -interaction=nonstopmode -halt-on-error wide.tex > /dev/null
pdflatex -interaction=nonstopmode -halt-on-error wide.tex | grep -E "Overfull|Warning|Output written"
pdfinfo -f 1 -l 2 wide.pdf | grep -E "^Page +[0-9]+ (size|rot)"
pdftotext -layout -f 2 -l 2 wide.pdf - | grep -E "Table 1|CS-001"
typst compile wide.typ wide-typst.pdf
pdfinfo -f 1 -l 2 wide-typst.pdf | grep -E "^Page +[0-9]+ (size|rot)"
pdftotext -layout -f 2 -l 2 wide-typst.pdf - | grep -E "Table 1|CS-001"
""") Output
$ pdflatex -interaction=nonstopmode -halt-on-error wide-portrait.tex | grep -E "Overfull|Output written"
Overfull \hbox (43.41731pt too wide) in paragraph at lines 7--19
Output written on wide-portrait.pdf (1 page, 17777 bytes).
$ pdflatex -interaction=nonstopmode -halt-on-error wide.tex > /dev/null
$ pdflatex -interaction=nonstopmode -halt-on-error wide.tex | grep -E "Overfull|Warning|Output written"
Output written on wide.pdf (2 pages, 19673 bytes).
$ pdfinfo -f 1 -l 2 wide.pdf | grep -E "^Page +[0-9]+ (size|rot)"
Page 1 size: 595.276 x 841.89 pts (A4)
Page 1 rot: 0
Page 2 size: 595.276 x 841.89 pts (A4)
Page 2 rot: 90
$ pdftotext -layout -f 2 -l 2 wide.pdf - | grep -E "Table 1|CS-001"
Table 1: Marks out of 25 in twelve unit tests
CS-001 23 23 23 25 15 14 25 24 14 12 23 18 19.9
$ typst compile wide.typ wide-typst.pdf
$ pdfinfo -f 1 -l 2 wide-typst.pdf | grep -E "^Page +[0-9]+ (size|rot)"
Page 1 size: 595.276 x 841.89 pts (A4)
Page 1 rot: 0
Page 2 size: 841.89 x 595.276 pts (A4)
Page 2 rot: 0
$ pdftotext -layout -f 2 -l 2 wide-typst.pdf - | grep -E "Table 1|CS-001"
Table 1: Marks out of 25 in twelve unit tests
CS-001 23 23 23 25 15 14 25 24 14 12 23 18 19.9
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wide_session.py
Both builds put the table on page 2 and fitted it across, but they made that page in different ways. pdfLaTeX kept
page 2 a portrait A4 page, 595.276 by 841.89 pt, and gave it a rotation of 90: CTAN’s description of pdflscape says
it sets the PDF /Rotate attribute, so that viewers display the page in landscape. Typst made page 2 itself
landscape, 841.89 by 595.276 pt, with no rotation. The table reads the same either way, but the two files store page 2
differently, as this drawing to scale shows:
Two ways to a landscape page, as pdfinfo measured them
Text description of the diagram
The figure draws page 2 of the two PDFs to scale.
- pdfLaTeX with pdflscape stored page 2 as an ordinary portrait A4 page, 595 pt wide and 842 pt tall, with the table's rows running up the page, and marked it to be turned: pdfinfo reported rot 90. A PDF viewer turns it a quarter turn, so the reader sees a landscape page with the rows running across.
- Typst with page(flipped: true) stored page 2 as a landscape page, 842 pt wide and 595 pt tall, with rot 0: the page is wide in the file itself, and the rows run across it.
- In both PDFs page 1 stayed an ordinary portrait page.
The Typst page reference says that calling page with a body sets that body on pages of its own and goes back to
the earlier page settings after it, which is why page 1 stayed portrait. A landscape page is not the only answer:
the tables guide shows rotate(reflow: true) for turning only the table, and dropping a column or splitting the
table in two often reads better than any rotation.
Checkpoint
Check: Wide tables on landscape pages
Typst reads CSV and JSON into a table, and pgfplotstable does it in LaTeX
A table typed by hand is a copy of its data, and copies drift. marks.csv holds the five courses of the semester as
CSV: Comma-separated values: a plain-text table with one line per row, its cells separated by commas, often with a first line of column headings. Spreadsheets export it, and Typst's csv() reads it., and sgpa-from-csv.typ builds the table from it: csv() with row-type: dictionary
returns one dictionary: In Typst, a value that maps string keys to values, written (name: "Tanvi", year: 2025). yaml() and json() return one for each mapping, and d.at("key", default: x) reads a key that may be missing. More in “A resume in Typst, generated from data” per course, every value a string, and the file computes the
credit points of each course and the totals. sgpa-from-json.typ reads the same courses from marks.json, and
sgpa-pgf.tex uses pgfplotstable, a LaTeX package that loads and formats tables of numbers:
data/marks.csv
Course,Credits,Grade,Points
Engineering Mathematics II,4,A+,9
Programming in C,3,O,10
Engineering Physics,3,A,8
Basic Electrical Engg.,2,A,8
Communication Skills,1,B,6 data/sgpa-from-csv.typ
#show figure.where(kind: table): set figure.caption(position: top)
// One dictionary per row, keyed by the header line of marks.csv. Every value is a string.
#let rows = csv("marks.csv", row-type: dictionary)
#let credits = rows.map(r => int(r.Credits))
#let weighted = rows.map(r => int(r.Credits) * int(r.Points))
#let sgpa = weighted.sum() / credits.sum()
#figure(
table(
columns: 5,
stroke: none,
align: (left, right, center, right, right),
table.hline(stroke: 0.08em),
table.header[Course][Credits][Grade][Points][Credits × points],
table.hline(stroke: 0.05em),
..rows.zip(weighted).map(((r, w)) => (r.Course, r.Credits, r.Grade, r.Points, str(w))).flatten(),
table.hline(stroke: 0.05em),
table.footer[Total][#credits.sum()][][][#weighted.sum()],
table.hline(stroke: 0.08em),
),
caption: [Second-semester results: SGPA #calc.round(sgpa, digits: 2)],
) data/marks.json
[
{ "course": "Engineering Mathematics II", "credits": 4, "grade": "A+", "points": 9 },
{ "course": "Programming in C", "credits": 3, "grade": "O", "points": 10 },
{ "course": "Engineering Physics", "credits": 3, "grade": "A", "points": 8 },
{ "course": "Basic Electrical Engg.", "credits": 2, "grade": "A", "points": 8 },
{ "course": "Communication Skills", "credits": 1, "grade": "B", "points": 6 }
] data/sgpa-from-json.typ
// json() turns the file's array of objects into an array of dictionaries; its numbers stay numbers.
#let courses = json("marks.json")
#let credits = courses.map(c => c.credits).sum()
#let weighted = courses.map(c => c.credits * c.points).sum()
The courses carry #credits credits and #weighted credit points, so the SGPA is
#calc.round(weighted / credits, digits: 2). data/sgpa-pgf.tex
\documentclass[a4paper]{article}
\usepackage{booktabs}
\usepackage{pgfplotstable}
\pgfplotsset{compat=1.18}
\begin{document}
\pgfplotstableread[col sep=comma]{marks.csv}\marks
% A column computed for every row: credits times grade points.
\pgfplotstablecreatecol[create col/expr={\thisrow{Credits}*\thisrow{Points}}]{Weighted}\marks
% The totals, added up over the rows.
\def\credits{0}\def\weighted{0}
\pgfplotstableforeachcolumnelement{Credits}\of\marks\as\x{\pgfmathsetmacro\credits{\credits+\x}\xdef\credits{\credits}}
\pgfplotstableforeachcolumnelement{Weighted}\of\marks\as\x{\pgfmathsetmacro\weighted{\weighted+\x}\xdef\weighted{\weighted}}
\pgfmathsetmacro\sgpa{\weighted/\credits}
\begin{table}[ht]
\centering
\caption{Second-semester results: SGPA \pgfmathprintnumber[fixed, precision=2]{\sgpa}}
\pgfplotstabletypeset[
columns={Course,Credits,Grade,Points,Weighted},
columns/Course/.style={string type, column type=l},
columns/Credits/.style={int detect, column type=r},
columns/Grade/.style={string type, column type=c},
columns/Points/.style={int detect, column type=r},
columns/Weighted/.style={int detect, column type=r, column name={Credits $\times$ points}},
every head row/.style={before row=\toprule, after row=\midrule},
every last row/.style={after row={\midrule Total & \pgfmathprintnumber[int detect]{\credits} & & & \pgfmathprintnumber[int detect]{\weighted} \\ \bottomrule}},
]\marks
\end{table}
\end{document} from session import files, run
# The five courses of marks.csv as a Typst table with a computed column and a totals row (sgpa-from-csv.typ), the same
# data read from JSON (sgpa-from-json.typ), and the CSV typeset by pgfplotstable in LaTeX (sgpa-pgf.tex).
files("data/marks.csv", "data/marks.json", "data/sgpa-from-csv.typ", "data/sgpa-from-json.typ", "data/sgpa-pgf.tex")
run("""
typst compile sgpa-from-csv.typ
pdftotext -layout sgpa-from-csv.pdf - | head -8
typst compile sgpa-from-json.typ
pdftotext sgpa-from-json.pdf - | head -2
pdflatex -interaction=nonstopmode -halt-on-error sgpa-pgf.tex | grep -E "Warning|Output written"
pdftotext -layout sgpa-pgf.pdf - | head -8
""") Output
$ typst compile sgpa-from-csv.typ
$ pdftotext -layout sgpa-from-csv.pdf - | head -8
Table 1: Second-semester results: SGPA 8.62
Course Credits Grade Points Credits × points
Engineering Mathematics II 4 A+ 9 36
Programming in C 3 O 10 30
Engineering Physics 3 A 8 24
Basic Electrical Engg. 2 A 8 16
Communication Skills 1 B 6 6
Total 13 112
$ typst compile sgpa-from-json.typ
$ pdftotext sgpa-from-json.pdf - | head -2
The courses carry 13 credits and 112 credit points, so the SGPA is 8.62.
$ pdflatex -interaction=nonstopmode -halt-on-error sgpa-pgf.tex | grep -E "Warning|Output written"
Output written on sgpa-pgf.pdf (1 page, 35694 bytes).
$ pdftotext -layout sgpa-pgf.pdf - | head -8
Table 1: Second-semester results: SGPA 8.62
Course Credits Grade Points Credits × points
Engineering Mathematics II 4 A+ 9 36
Programming in C 3 O 10 30
Engineering Physics 3 A 8 24
Basic Electrical Engg. 2 A 8 16
Communication Skills 1 B 6 6
Total 13 112
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 data_session.py
All three builds printed 13 credits, 112 credit points and an SGPA of 8.62, and no number in the table was typed into it. Step through how the Typst file turns five lines of data into a table that adds itself up:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The data. marks.csv holds a header line, Course, Credits, Grade and Points, and one line for each of the five courses. Nothing in it is a total.
- Reading. csv() with row-type set to dictionary returns one dictionary per course, keyed by the header line, so r.Credits is the credits of row r. The reference says every value it returns is a string.
- Numbers. Arithmetic needs numbers, so int() turns "4" into 4. Without it, Typst would refuse to multiply two strings.
- Credit points. Each course's credit points are its credits times its grade points, 36 for Engineering Mathematics II, and they fill the fifth column.
- JSON keeps numbers. json() returns numbers as numbers, so sgpa-from-json.typ multiplies c.credits by c.points with no int() at all, and reached the same 13 credits and 112 credit points.
- Totals and the SGPA. The sums go into table.footer and the caption. 112 credit points over 13 credits gave an SGPA of 8.62, and a changed grade in marks.csv changes all three on the next build.
SGPA from credit points
The University Grants Commission’s guidelines on the choice-based credit system define the SGPA as the sum of each course’s credits times its grade points, divided by the sum of the credits. With the grade points of the commission’s 10-point scale, such as 10 for O and 9 for A+, the five courses give 112 ÷ 13 = 8.62. Your university’s own grade table decides the points.
In LaTeX, \pgfplotstableread loads the CSV, \pgfplotstablecreatecol adds the computed column, and a loop over the
column adds up the totals before \pgfplotstabletypeset sets the table. The csvsimple package offers a simpler
interface for comma-separated files, and a script in Python or R can also write the .tex table from the data; any
of them keeps the numbers in one place.
Checkpoint
Check: Tables built from CSV and JSON
Totals computed from the data cannot drift
After the results came out, the grade of Engineering Physics was corrected from A, 8 points, to B+, 7 points. In
marks-v2.csv that is one changed line. sgpa-typed.typ is the same table typed by hand, in which someone corrected
the row and forgot the totals. totals.py adds up the rows that each PDF prints and compares the sums with the
printed totals and SGPA:
data/marks-v2.csv
Course,Credits,Grade,Points
Engineering Mathematics II,4,A+,9
Programming in C,3,O,10
Engineering Physics,3,B+,7
Basic Electrical Engg.,2,A,8
Communication Skills,1,B,6 data/sgpa-typed.typ
#show figure.where(kind: table): set figure.caption(position: top)
// Typed by hand. The grade of Engineering Physics was corrected from A (8) to B+ (7) after the results came out;
// its row was edited, the totals and the SGPA were not.
#figure(
table(
columns: 5,
stroke: none,
align: (left, right, center, right, right),
table.hline(stroke: 0.08em),
table.header[Course][Credits][Grade][Points][Credits × points],
table.hline(stroke: 0.05em),
[Engineering Mathematics II], [4], [A+], [9], [36],
[Programming in C], [3], [O], [10], [30],
[Engineering Physics], [3], [B+], [7], [21],
[Basic Electrical Engg.], [2], [A], [8], [16],
[Communication Skills], [1], [B], [6], [6],
table.hline(stroke: 0.05em),
table.footer[Total][13][][][112],
table.hline(stroke: 0.08em),
),
caption: [Second-semester results: SGPA 8.62],
) totals.py
"""Adds up the rows of a results table in a PDF and compares the sums with the totals the PDF prints. It reads the text
with pdftotext -layout: a row ends with its credits, grade, grade points and credit points, the line that starts with
Total holds the printed sums, and the caption holds the printed SGPA."""
import re
import subprocess
import sys
text = subprocess.run(["pdftotext", "-layout", sys.argv[1], "-"], capture_output=True, check=True, text=True).stdout
credits = weighted = 0
printed = sgpa = None
for line in text.splitlines():
words = line.split()
if words and words[0] == "Total":
printed = (int(words[1]), int(words[-1]))
elif len(words) >= 5 and words[-4].isdigit() and words[-2].isdigit() and words[-1].isdigit():
credits += int(words[-4])
weighted += int(words[-1])
found = re.search(r"SGPA (\d+\.\d+)", line)
if found:
sgpa = found.group(1)
right = f"{weighted / credits:.2f}"
print(f"the rows add up to {credits} credits and {weighted} credit points: SGPA {right}")
print(f"the PDF prints {printed[0]} credits, {printed[1]} credit points and SGPA {sgpa}")
same = printed == (credits, weighted) and sgpa == right
print("they agree" if same else "they do not agree: the printed totals are out of date") from session import files, run
# A grade is corrected after the results came out: Engineering Physics goes from A (8 points) to B+ (7) in
# marks-v2.csv. The tables computed from the data are built again; sgpa-typed.typ is a copy typed by hand whose row was
# corrected and whose totals were not. totals.py adds up the rows of each PDF and compares them with what it prints.
files("data/marks.csv", "data/marks-v2.csv", "data/sgpa-from-csv.typ", "data/sgpa-pgf.tex", "data/sgpa-typed.typ",
"totals.py")
run("""
typst compile sgpa-from-csv.typ before.pdf
python3 totals.py before.pdf
cp marks-v2.csv marks.csv
typst compile sgpa-from-csv.typ after.pdf
python3 totals.py after.pdf
pdflatex -interaction=nonstopmode -halt-on-error sgpa-pgf.tex > /dev/null
python3 totals.py sgpa-pgf.pdf
typst compile sgpa-typed.typ
python3 totals.py sgpa-typed.pdf
""") Output
$ typst compile sgpa-from-csv.typ before.pdf $ python3 totals.py before.pdf the rows add up to 13 credits and 112 credit points: SGPA 8.62 the PDF prints 13 credits, 112 credit points and SGPA 8.62 they agree $ cp marks-v2.csv marks.csv $ typst compile sgpa-from-csv.typ after.pdf $ python3 totals.py after.pdf the rows add up to 13 credits and 109 credit points: SGPA 8.38 the PDF prints 13 credits, 109 credit points and SGPA 8.38 they agree $ pdflatex -interaction=nonstopmode -halt-on-error sgpa-pgf.tex > /dev/null $ python3 totals.py sgpa-pgf.pdf the rows add up to 13 credits and 109 credit points: SGPA 8.38 the PDF prints 13 credits, 109 credit points and SGPA 8.38 they agree $ typst compile sgpa-typed.typ $ python3 totals.py sgpa-typed.pdf the rows add up to 13 credits and 109 credit points: SGPA 8.38 the PDF prints 13 credits, 112 credit points and SGPA 8.62 they do not agree: the printed totals are out of date
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 drift_session.py
Built again from the corrected data, the Typst and LaTeX tables both printed 109 credit points and an SGPA of 8.38,
and totals.py found that they agree with their rows. The typed table shows the corrected row, 21 credit points for
Engineering Physics, under a total of 112 and an SGPA of 8.62 that no longer follow from it. Nothing failed in the
build; only a check that adds up the rows found it.
Correcting a row and not its totals
Wrong: editing a value in a typed table and leaving its totals, its averages or the numbers quoted in the text as they were. Right: keep the data file next to the document and under version control, compute every total in the document, and quote sums in the text from the same computed values, so the next build changes all of them at once.
Run a check like totals.py after every build as well. It reads the PDF rather than the sources, so it also catches
a table that someone types into the document later, the one place no data file reaches.
Key idea
Store the data once, next to the document, and let the document read it and compute every total; a changed value then changes the table, its totals and every number quoted from them on the next build.
Checkpoint
Check: Totals that follow the data
Practise: a two-page rainfall table from data
The lab builds a long table from data, the way this lesson does. The first step reads a CSV of station rainfall and works out its total, refusing a line with a value missing; the second writes the LaTeX longtable, with a header on every page and the total in the last foot. You can run both steps’ sample tests here in the page, and open a first hint for each:
Code lab · 2 steps
A two-page rainfall table from data
Read a CSV of station rainfall, compute the total, then write a longtable whose header repeats on every page and whose last foot holds the total.
- Read station rainfall from CSV and add it up To do
- Write a longtable with a header on every page To do
Exercise · Easy · Python
Read station rainfall from CSV and add it up
Write read_rainfall(text) in rainfall.py. text is a CSV file with a header line, Station,Rainfall (mm), then one line per station, such as Station A,412.5. Return a pair (rows, total):
rowsis a list of(station, millimetres)tuples in the order of the file, the station a string without spaces around it and the millimetres a float;totalis the sum of the millimetres, rounded to one decimal place.
A line whose rainfall is missing or is not a number would make the total wrong: raise ValueError with the line's number in the file (the header is line 1), so that the table is never built from it.
Starter code · rainfall.py
import csv
import io
def read_rainfall(text):
"""Return ([(station, millimetres), ...], total rounded to 0.1) from the CSV `text`, refusing a bad line."""
rows = []
# Replace this comment: read every line after the header, convert the rainfall, refuse a bad line.
return rows, round(sum(mm for _, mm in rows), 1) The sample tests · test_rainfall.py
from rainfall import read_rainfall
CSV = """Station,Rainfall (mm)
Station A,412.5
Station B , 388.0
Station C,1027.25
"""
def test_rows_in_file_order():
rows, _ = read_rainfall(CSV)
assert rows == [("Station A", 412.5), ("Station B", 388.0), ("Station C", 1027.25)]
def test_total_rounded_to_one_decimal():
_, total = read_rainfall(CSV)
assert total == 1827.8
def test_header_only_gives_an_empty_table():
assert read_rainfall("Station,Rainfall (mm)\n") == ([], 0)
def test_missing_value_is_refused_with_its_line():
try:
read_rainfall("Station,Rainfall (mm)\nStation A,412.5\nStation B,\n")
except ValueError as error:
assert "line 3" in str(error)
else:
assert False, "a station without rainfall was accepted"
def test_text_instead_of_a_number_is_refused():
try:
read_rainfall("Station,Rainfall (mm)\nStation A,heavy\n")
except ValueError as error:
assert "line 2" in str(error)
else:
assert False, "a rainfall of 'heavy' was accepted"
def test_line_without_a_comma_is_refused():
try:
read_rainfall("Station,Rainfall (mm)\nStation A,412.5\nStation B\n")
except ValueError as error:
assert "line 3" in str(error)
else:
assert False, "a line with no rainfall column was accepted" A hint
csv.reader(io.StringIO(text)) gives each line as a list of strings; enumerate(..., start=1) numbers them, and you skip line 1. Convert with float(value) inside try, and turn a ValueError or a short line into your own ValueError(f"line {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.
Exercise · Medium · Python
Write a longtable with a header on every page
Write longtable(rows, total) in longtable.py. rows is a list of (station, millimetres) pairs and total their sum, as the first step returns them. Return the LaTeX source of a longtable, one line per row of the table:
\begin{longtable}{l r}, then the first head:\caption{Station rainfall}\\,\toprule,Station & Rainfall (mm) \\,\midruleand\endfirsthead;- the head of every later page:
\toprule, the same header row,\midrule,\endhead; - the foot of every page but the last:
\midrule,\multicolumn{2}{r}{Continued on the next page} \\,\endfoot; - the last foot:
\midrule,Total &and the total with one decimal, then\\,\bottomrule,\endlastfoot; - one line per station,
name & millimetres \\with one decimal, and\end{longtable}.
Write an & in a station's name as \&.
Starter code · longtable.py
def longtable(rows, total):
"""Return the source of a longtable of station rainfall with a head on every page and the total in the last foot."""
lines = [r"\begin{longtable}{l r}"]
# Replace this comment: the first head, the head, the foot, the last foot, then one line per station.
lines.append(r"\end{longtable}")
return "\n".join(lines) The sample tests · test_longtable.py
from longtable import longtable
ROWS = [("Station A", 412.5), ("Station B", 388.0), ("Hill & Valley", 1027.25)]
LINES = [line.strip() for line in longtable(ROWS, 1827.8).splitlines()]
HEADER = r"Station & Rainfall (mm) \\"
def test_the_four_parts_in_order():
"""first head, head, foot and last foot come before the first row"""
marks = [LINES.index(m) for m in (r"\endfirsthead", r"\endhead", r"\endfoot", r"\endlastfoot")]
assert marks == sorted(marks)
assert marks[-1] < LINES.index(r"Station A & 412.5 \\")
def test_header_in_the_first_head_and_in_the_head():
"""the header row is printed on page 1 and again on every later page"""
assert LINES.count(HEADER) == 2
assert LINES.index(HEADER) < LINES.index(r"\endfirsthead") < len(LINES) - 1 - LINES[::-1].index(HEADER) < LINES.index(r"\endhead")
def test_total_only_in_the_last_foot():
"""the total stands once, between \\endfoot and \\endlastfoot"""
total = LINES.index(r"Total & 1827.8 \\")
assert LINES.index(r"\endfoot") < total < LINES.index(r"\endlastfoot")
assert sum("Total" in line for line in LINES) == 1
def test_continued_note_in_the_foot():
note = LINES.index(r"\multicolumn{2}{r}{Continued on the next page} \\")
assert LINES.index(r"\endhead") < note < LINES.index(r"\endfoot")
def test_rows_with_one_decimal_and_escaped_names():
assert r"Station B & 388.0 \\" in LINES
assert r"Hill \& Valley & 1027.2 \\" in LINES or r"Hill \& Valley & 1027.3 \\" in LINES
assert LINES[-1] == r"\end{longtable}" A hint
Build a list of lines in the order the prompt gives and join it with "\n"; the header row is the same string in the first head and the head. f"{mm:.1f}" writes a number with one decimal, and name.replace("&", r"\&") protects it.
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.
The questions below cover the lesson’s five ideas, starting with the one you are least sure of:
Practice that adapts to you
Practise this lesson
14 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 can a tabular not break across pages, and what do you use instead in LaTeX and in Typst?
A tabular is one box, like a large letter, and TeX never splits a box between pages. If it is taller than the page,
pdfLaTeX warns “Float too large for page” or overfills the page, and the rows past the bottom edge are lost: in a
recorded build, 19 of 60 rows were on no page. In LaTeX the longtable package builds a table that TeX’s page breaker
can divide, with \endfirsthead, \endhead, \endfoot and \endlastfoot to say what is printed at the top and
bottom of the first, middle and last pages; xltabular adds X columns. In Typst every table breaks by itself and
repeats table.header, but a table inside a figure needs #show figure: set block(breakable: true), or it stays on
one page.
Two harder questions follow, about a results table whose totals went wrong and about a table too wide for the page. Decide 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 senior
A department's result sheet was corrected after one grade changed, but the printed SGPA and two totals in the table, and one figure quoted in the text, stayed as they were. How do you rebuild the sheet so that this cannot happen again, in LaTeX or in Typst?
-
Mid to senior
A report has one table of 14 columns that is about 43 pt wider than the text on a portrait page. Compare the ways to fix it in LaTeX and Typst, say which you would choose for a printed report and for a tagged PDF read on screen, and why.
Key takeaways
- A
tabularis one box that cannot break, so 19 of 60 rows fell off an A5 page; longtable broke the same rows over two pages, with a first head, a head, a foot and a last foot that say what each page shows. - Typst breaks tables across pages by itself and repeats
table.header, but a table inside afigurestayed on one page and lost 32 rows below it untilset block(breakable: true)made figures breakable. - pdflscape keeps a portrait page and gives it a rotation of 90 for the viewer, while Typst’s
page(flipped: true)makes the page itself landscape; both fit 14 columns that were 43.42 pt too wide in portrait. - Typst’s
csv()andjson()and LaTeX’s pgfplotstable build a table from its data file and compute its totals, so the five courses gave 13 credits, 112 credit points and an SGPA of 8.62 without a typed number. - After a corrected grade the computed tables printed an SGPA of 8.38 on the next build, while the typed copy kept 8.62; keep the data next to the document and check printed totals against the rows.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What happens to a tabular taller than the page?
It is one box and cannot break: pdfLaTeX warns Float too large for page, and the rows past the bottom edge are on no page. 19 of 60 rows were lost on A5.
-
What do \endfirsthead, \endhead, \endfoot and \endlastfoot mark in a longtable?
The rows printed at the top of the first page, at the top of every later page, at the bottom of every page but the last, and at the bottom of the last page.
-
Where does a longtable's caption go, and why does the table need a second run?
Inside the longtable, ended by \\, because a longtable is not a float. It keeps its column widths in the .aux file and lines the pages up from them on the next run.
-
Do Typst tables break across pages?
Yes, by themselves, and table.header repeats at the top of each page by default; table.footer repeats at the bottom.
-
Why did a Typst table inside a figure lose 32 of its rows?
A figure is one block that cannot break by default. Add #show figure: set block(breakable: true), then count the rows, since Typst prints no warning.
-
How do pdflscape and Typst make a landscape page differently?
pdflscape stores an ordinary portrait page marked with a rotation of 90 for the viewer to turn; Typst's page(flipped: true) writes a page that is wider than it is tall, with no rotation.
-
How does Typst build a table from a CSV file?
csv() with row-type: dictionary returns one dictionary per row, every value a string, so numbers are converted before any sum; json() reads JSON the same way.
-
Why compute a table's totals from its data file?
A corrected value then changes the table, its totals and every number quoted from them on the next build; the typed copy kept an SGPA of 8.62 after the correction made it 8.38.
Terms in this lesson
- CSV
- Comma-separated values: a plain-text table with one line per row, its cells separated by commas, often with a first line of column headings. Spreadsheets export it, and Typst's csv() reads it.
- landscape page
- A page that is wider than it is tall. pdflscape makes one by marking a portrait page to be shown turned a quarter turn; Typst's page(flipped: true) makes the page itself wide.
- repeated header
- The header row of a table that breaks across pages, printed again at the top of each new page so that every page shows what its columns are. LaTeX's longtable sets it with \endhead, Typst with table.header.
References
- The longtable package (The LaTeX Project (CTAN))
- xltabular (CTAN)
- pdflscape (CTAN)
- csvsimple (CTAN)
- pgfplotstable (CTAN)
- Tagging status of LaTeX packages and classes (The LaTeX Project (Tagged PDF project))
- Typst reference: table (Typst)
- Typst guide: Tables (Typst)
- Publication quality tables in LaTeX (the booktabs manual) (CTAN)
- Typst reference: page (Typst)
- Typst reference: csv (Typst)
- Typst reference: json (Typst)
- Guidelines on Adoption of Choice Based Credit System (University Grants Commission)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress