Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with TeX Live 2026, TinyTeX 2026.10, Typst 0.15.1, Python 3.14.8, Pyodide 314.0.7, poppler 26.09.0 and mupdf 1.28.3
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Sixty rows as a tabular and as a longtable

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:

Which rows landed on which page Python · long_session.py
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:

Interactive · stage by stage How longtable spread 60 rows over two pages Step through long.tex, from the head on page 1 to the last foot under the final row.
How longtable spread 60 rows over two pages, step 6 of 6. Showing The first head, Rows fill the page, The foot at the break, The head on the next page, The last foot. In focus: The last foot.more rowsThe first headcaption and header, page 1Rows fill the page30 rows on an A5 pageThe foot at the breakContinued on the next pageThe head on the next pageTable 1, continued + headerThe last footthe bottom rule, once

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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".
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 long-tabular.tex puts 60 rows in a tabular inside a table float on A5 pages. What did pdfLaTeX do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It warned that the float was too large, put it on page 1, and 19 rows were on no page

    The warning is the only sign in the log, so read it, or count the rows on the pages as pages.py does.

  2. Question 2 of 2 In long.tex, which rows print "Table 1, continued" and the header at the top of page 2?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The rows between \endfirsthead and \endhead

    Each of the four commands ends a group of rows that longtable prints in one place: first head, head, foot, last foot.

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:

The 60 rows from marks60.csv in three Typst files

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
How Typst spread the rows Python · typst_long_session.py
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

  1. One question long-figure.typ wraps the 60-row table in a figure. What did Typst do with it?

    Choose one answer.

    Show the answer: One question

    Answer: It kept the figure on one page, with 28 rows on the page and 32 on none, and printed no warning

    #show figure: set block(breakable: true) lets the figure, and so its table, break across pages.

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):

Fourteen columns on a portrait page and on landscape pages

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
The size and rotation of each page Python · wide_session.py
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:

Page 2 of each PDF to scale: LaTeX stores a portrait page turned for the viewer; Typst stores a landscape page.pdfLaTeX with pdflscape, page 2stored 595 × 842 ptrotate 90shown by the viewerTypst with page(flipped: true), page 2stored 842 × 595 pt, rotate 0

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

  1. One question How did pdflscape and Typst make page 2 landscape?

    Choose one answer.

    Show the answer: One question

    Answer: pdflscape kept a portrait page and gave it a rotation of 90; Typst made the page 841.89 by 595.276 pt

    Viewers show both pages in landscape; the difference is how the PDF records it.

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:

One semester's results, read from data

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}
Three tables built from the data Python · data_session.py
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:

Interactive · stage by stage From marks.csv to a table that adds itself up Step through sgpa-from-csv.typ, from the five lines of data to the SGPA in the caption.
From marks.csv to a table that adds itself up, step 6 of 6. Showing marks.csv, csv() reads the rows, int() makes numbers, Credit points per row, Totals added up, Cells, footer, caption. In focus: Totals added up, Cells, footer, caption.json(): numbers alreadymarks.csva header line, five coursescsv() reads the rowsone dictionary per courseint() makes numbers"4" becomes 4Credit points per row4 × 9 = 36 for the firstTotals added up13 credits, 112 pointsCells, footer, captionSGPA 112 ÷ 13 = 8.62

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. Numbers. Arithmetic needs numbers, so int() turns "4" into 4. Without it, Typst would refuse to multiply two strings.
  4. 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.
  5. 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.
  6. 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.

SGPA Calculator (India) Work out an SGPA from credits and grades with the working shown, to check the totals your table prints.

Checkpoint

Check: Tables built from CSV and JSON

  1. One question Why does sgpa-from-csv.typ call int() on r.Credits and r.Points?

    Choose one answer.

    Show the answer: One question

    Answer: csv() returns every value as a string, and arithmetic needs numbers

    json() keeps numbers as numbers, which is why sgpa-from-json.typ needs no int() at all.

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:

A corrected grade, and a table typed by hand

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")
The totals after the correction Python · drift_session.py
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

  1. One question After marks-v2.csv corrected one grade, which PDF printed totals that no longer followed from its rows?

    Choose one answer.

    Show the answer: One question

    Answer: sgpa-typed.pdf, the table typed by hand

    A computed total changes with its data on the next build; a typed one waits for someone to remember it.

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.

  1. Read station rainfall from CSV and add it up To do
  2. 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):

  • rows is 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;
  • total is 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}: ...").

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) \\, \midrule and \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.

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.

  1. Question 1 of 14 Which longtable command ends the rows printed only at the bottom of the last page?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: \endlastfoot

    In long.tex the last foot is the bottom rule, so the last page has no "Continued on the next page".

  2. Question 2 of 14 Why did the session build long.tex twice?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: longtable records its column widths in the .aux file, and \ref needs the label from the first run

    latexmk runs LaTeX until the .aux settles, which handles both reasons at once.

  3. Question 3 of 14 A table of long notes needs a column that wraps, and it must break across pages. Which environment fits?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: xltabular, with an X column for the notes

    Choose by the two needs: X for wrapping and longtable for breaking, and xltabular has both.

  4. Question 4 of 14 What repeats the header row at the top of every page of a long Typst table?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: table.header, whose repeat is true by default

    The table reference counts the lines inside table.header as part of the header, so they repeat with it; long.typ puts its two header rules there.

  5. Question 5 of 14 Which line lets a captioned 60-row Typst table break across pages?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: #show figure: set block(breakable: true)

    The tables guide gives this show rule for tables in figures.

  6. Question 6 of 14 A long Typst table must show its column totals at the bottom of every page. Where do the totals go?
    Show the answer: Question 6 of 14

    Answer: In table.footer, which repeats on every page by default

    table.footer(repeat: false) keeps a footer to the last page only, as a grand total often should be.

  7. Question 7 of 14 How many points too wide did pdfLaTeX report the 14-column table of wide-portrait.tex, on a portrait page?

    Type a number.

    Show the answer: Question 7 of 14

    Answer: 43.42 pt (anything from 43.41 to 43.43 counts)

    The log said Overfull \hbox (43.41731pt too wide). On the landscape page of wide.tex the same table fitted.

  8. Question 8 of 14 In wide.typ, why did page 1 stay portrait while page 2 was landscape?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: page(flipped: true) with a body sets that body on pages of its own, then goes back to the earlier settings

    A #set page(flipped: true) rule would instead change every page that follows it.

  9. Question 9 of 14 One table of a thesis is 43 pt too wide, and the university does not allow landscape pages. What is the most reliable fix?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: Split it into two tables, tests 1 to 6 and tests 7 to 12, each with the roll numbers

    Dropping a column or splitting a table often reads better than any rotation, and works in every system.

  10. Question 10 of 14 Why does sgpa-from-json.typ multiply c.credits by c.points without int()?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: json() returns JSON numbers as Typst numbers

    With CSV, convert each number with int() or float(); with JSON, the numbers arrive ready.

  11. Question 11 of 14 In sgpa-pgf.tex, which command adds the column of credit points to the table read from marks.csv?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: \pgfplotstablecreatecol with create col/expr

    Read, add columns, add up, then typeset: the order sgpa-pgf.tex follows.

  12. Question 12 of 14 Three courses have 4, 3 and 2 credits and grade points of 10, 8 and 7. What SGPA does the table print, to two decimal places?

    Type a number.

    A hint

    Multiply each course's credits by its grade points, add the products, then divide by the total credits.

    Show the answer: Question 12 of 14

    Answer: 8.67 (anything from 8.665 to 8.675 counts)

    4 × 10 + 3 × 8 + 2 × 7 = 40 + 24 + 14 = 78 credit points over 9 credits, 78 ÷ 9 = 8.666…, which is 8.67.

  13. Question 13 of 14 What did totals.py compare to find the stale table?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: The sums of the rows it read from each PDF with the totals and the SGPA the PDF prints

    Checking the built PDF catches stale numbers whichever way the table was made.

  14. Question 14 of 14 A report says "an SGPA of 8.62" in a sentence under its computed table. The data changes. What keeps the sentence right?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Compute the number in the document and print the computed value in the sentence

    Every number quoted from the data is another total that can drift; compute it from the same values.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A tabular is 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 a figure stayed on one page and lost 32 rows below it until set 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() and json() 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.