LaTeX & Typst (papers, theses, resumes and slides) Module 4 – Tables
Publication-quality tables: rules and alignment
Give LaTeX and Typst tables booktabs rules, line numbers up on the decimal point, wrap long cells, merge cells and choose packages that keep a PDF tagged.
What you will learn
- Apply booktabs rules and drop vertical and double rules in LaTeX and Typst
- Format a column of numbers so that its decimal points line up, with siunitx and zero
- Wrap long cells with tabularx X columns and Typst fraction columns
- Merge cells across columns and rows in both systems
- Check a table package's tagging status before using it in a tagged PDF
Before you start
What you’ll learn · Before you start
What you will learn
- Apply booktabs rules and drop vertical and double rules in LaTeX and Typst
- Format a column of numbers so that its decimal points line up, with siunitx and zero
- Wrap long cells with tabularx X columns and Typst fraction columns
- Merge cells across columns and rows in both systems
- Check a table package's tagging status before using it in a tagged PDF
Before you start
On this page
Four tensile tests fit in a table of five columns, yet grid.tex drew 37 lines round them, and in each of its
columns of numbers the decimal points stood up to 2.5 pt apart. Tables in journals and theses look different: three
horizontal rules, no vertical ones, and numbers lined up on the decimal point, so the eye runs down a column instead
of across a cage of boxes.
Below you will rule the same table with booktabs in LaTeX and with the same weights in Typst, line its numbers up with siunitx and the zero package, wrap a column of notes, merge a heading over two columns and labels over two rows, and check which of these packages a tagged PDF can use. The thicknesses, gaps and positions below are those of the PDFs in the recorded builds.
booktabs draws three rules of two weights and no vertical lines
grid.tex puts a rule round every cell, the way many first tables do, with a double rule under the header and the
numbers centred. booktabs.tex keeps the same cells and uses the booktabs package instead: \toprule at the top,
\midrule under the header, \bottomrule at the end, and no vertical bar in the column specification. booktabs.typ
does the same in Typst with stroke: none and three table.hline calls:
rules/grid.tex
\documentclass[a4paper]{article}
\begin{document}
\begin{center}
\begin{tabular}{|c|c|c|c|c|}
\hline
Grade & Specimen & Yield (MPa) & Tensile (MPa) & Elongation (\%) \\
\hline\hline
Fe 415 & A1 & 432.5 & 528.25 & 18.4 \\ \hline
Fe 415 & A2 & 441.0 & 531.7 & 17.95 \\ \hline
Fe 500 & B1 & 521.75 & 612.4 & 15.2 \\ \hline
Fe 500 & B2 & 508.3 & 604.15 & 16.05 \\ \hline
\end{tabular}
\end{center}
\end{document} rules/booktabs.tex
\documentclass[a4paper]{article}
\usepackage{booktabs}
\begin{document}
\begin{center}
\begin{tabular}{l l r r r}
\toprule
Grade & Specimen & Yield (MPa) & Tensile (MPa) & Elongation (\%) \\
\midrule
Fe 415 & A1 & 432.5 & 528.25 & 18.4 \\
Fe 415 & A2 & 441.0 & 531.7 & 17.95 \\
Fe 500 & B1 & 521.75 & 612.4 & 15.2 \\
Fe 500 & B2 & 508.3 & 604.15 & 16.05 \\
\bottomrule
\end{tabular}
\end{center}
\end{document} rules/booktabs.typ
// booktabs' weights: 0.08em for the top and bottom rules, 0.05em for the one under the header.
#table(
columns: 5,
stroke: none,
align: (left, left, right, right, right),
table.hline(stroke: 0.08em),
table.header[Grade][Specimen][Yield (MPa)][Tensile (MPa)][Elongation (%)],
table.hline(stroke: 0.05em),
[Fe 415], [A1], [432.5], [528.25], [18.4],
[Fe 415], [A2], [441.0], [531.7], [17.95],
[Fe 500], [B1], [521.75], [612.4], [15.2],
[Fe 500], [B2], [508.3], [604.15], [16.05],
table.hline(stroke: 0.08em),
) measure.py
"""Measures the table on page 1 of a PDF from what MuPDF's mutool reads in it: the characters with their positions
(mutool draw -F stext) and the lines drawn on the page (mutool draw -F trace). Positions are in points (pt), from the
top left corner of the page.
python3 measure.py rules <pdf> every horizontal line from top to bottom, with its thickness and
the white space between it and the text above and below it, then
the number of vertical lines
python3 measure.py decimals <pdf> <first> <last> the rows from the line that starts with <first> to the line that
starts with <last>: for each column of numbers, how far apart the
decimal points of its cells stand, from left to right
python3 measure.py lines <pdf> <word>... for each word, the number of text lines its cell takes: the lines
from the one that holds the word down to the next rule or empty row
"""
import subprocess
import sys
import xml.etree.ElementTree as ET
def mutool(fmt, pdf):
done = subprocess.run(["mutool", "draw", "-q", "-F", fmt, pdf, "1"], capture_output=True, check=True)
return ET.fromstring(done.stdout)
def text_lines(pdf):
"""Each line of page 1, top to bottom, as (top, bottom, [(left, right, character), ...]). Characters whose
baselines are less than 0.5 pt apart belong to one line."""
found = []
for line in mutool("stext", pdf).iter("line"):
top, bottom = (float(v) for v in line.get("bbox").split()[1::2])
for c in line.iter("char"):
quad = [float(v) for v in c.get("quad").split()]
found.append((float(c.get("y")), top, bottom, (quad[0], quad[2], c.get("c"))))
lines = []
for y, top, bottom, ch in sorted(found):
if lines and y - lines[-1][0] < 0.5:
last = lines[-1]
last[1], last[2] = min(last[1], top), max(last[2], bottom)
last[3].append(ch)
else:
lines.append([y, top, bottom, [ch]])
return [(top, bottom, sorted(chars)) for _, top, bottom, chars in lines]
def drawn_lines(pdf):
"""Every straight line drawn, as ("horizontal" or "vertical", thickness, centre across, start, end)."""
out = []
for path in mutool("trace", pdf).iter():
if path.tag not in ("stroke_path", "fill_path"):
continue
a, b, c, d, e, f = (float(v) for v in path.get("transform").split())
points = [(float(p.get("x")), float(p.get("y"))) for p in path if p.tag in ("moveto", "lineto")]
points = [(a * x + c * y + e, b * x + d * y + f) for x, y in points]
if not points:
continue
xs, ys = [p[0] for p in points], [p[1] for p in points]
width, height = max(xs) - min(xs), max(ys) - min(ys)
if path.tag == "stroke_path":
thickness = float(path.get("linewidth"))
if height < 0.01:
out.append(("horizontal", thickness, min(ys), min(xs), max(xs)))
elif width < 0.01:
out.append(("vertical", thickness, min(xs), min(ys), max(ys)))
elif min(width, height) < 2: # a thin filled rectangle is a rule too
if width > height:
out.append(("horizontal", height, (min(ys) + max(ys)) / 2, min(xs), max(xs)))
else:
out.append(("vertical", width, (min(xs) + max(xs)) / 2, min(ys), max(ys)))
return out
def cells(chars):
out, current = [], [chars[0]]
for prev, ch in zip(chars, chars[1:]):
if ch[0] - prev[1] > 6:
out.append(current)
current = []
current.append(ch)
out.append(current)
return [c for c in out if "".join(ch for _, _, ch in c).strip()]
def text(chars):
return "".join(ch for _, _, ch in chars).strip()
def rules(pdf):
lines = text_lines(pdf)
found = sorted((l for l in drawn_lines(pdf) if l[0] == "horizontal"), key=lambda l: l[2])
for _, thickness, y, left, right in found:
above = [bottom for top, bottom, _ in lines if bottom <= y]
below = [top for top, bottom, _ in lines if top >= y]
gaps = []
if above:
gaps.append(f"{y - thickness / 2 - max(above):.1f} pt below the text above")
if below:
gaps.append(f"{min(below) - y - thickness / 2:.1f} pt above the text below")
print(f"rule {thickness:.2f} pt thick, {right - left:.1f} pt long: " + ", ".join(gaps))
vertical = [l for l in drawn_lines(pdf) if l[0] == "vertical"]
print(f"{len(vertical)} vertical lines")
def decimals(pdf, first, last):
rows, inside = [], False
for _, _, chars in text_lines(pdf):
row = cells(chars)
line = " ".join(text(c) for c in row)
inside = inside or line.startswith(first)
if inside:
rows.append(row)
if line.startswith(last):
break
columns = {}
for row in rows[1:]: # the first row is the header
for i, cell in enumerate(row):
points = [left for left, _, ch in cell if ch == "."]
if points:
columns.setdefault(i, []).append((text(cell), points[0]))
for i, found in sorted(columns.items()):
xs = [x for _, x in found]
values = ", ".join(v for v, _ in found)
print(f"column {i + 1} ({values}): decimal points {max(xs) - min(xs):.1f} pt apart")
def wrapped(pdf, *words):
lines = text_lines(pdf)
rule_ys = sorted(l[2] for l in drawn_lines(pdf) if l[0] == "horizontal")
for word in words:
start = next(i for i, l in enumerate(lines) if word in text(l[2]))
count, i = 1, start + 1
top_of = lines[start][0]
while i < len(lines):
top, bottom, chars = lines[i]
if any(lines[i - 1][1] <= y <= top for y in rule_ys) or top - lines[i - 1][1] > 6:
break
if min(ch[0] for ch in chars) < min(ch[0] for ch in lines[start][2]) - 1:
break
count, i = count + 1, i + 1
print(f"the cell with {word}: {count} lines")
if __name__ == "__main__":
{"rules": rules, "decimals": decimals, "lines": wrapped}[sys.argv[1]](*sys.argv[2:]) measure.py lists every horizontal line of each PDF from top to bottom, with its thickness and the white space between
it and the text above and below it, then counts the vertical lines:
from session import files, run
# The same table with a rule round every cell (grid.tex), with booktabs' three rules (booktabs.tex), and in Typst
# with the same three weights (booktabs.typ). measure.py lists every horizontal line, its thickness and the white
# space between it and the text next to it, then counts the vertical lines; for grid.tex it also says how far apart the
# decimal points of each column of numbers stand.
files("rules/grid.tex", "rules/booktabs.tex", "rules/booktabs.typ", "measure.py")
run("""
pdflatex -interaction=nonstopmode -halt-on-error grid.tex | grep -E "Warning|Output written"
python3 measure.py rules grid.pdf
python3 measure.py decimals grid.pdf Grade "Fe 500 B2"
pdflatex -interaction=nonstopmode -halt-on-error booktabs.tex | grep -E "Warning|Output written"
python3 measure.py rules booktabs.pdf
typst compile booktabs.typ booktabs-typst.pdf
python3 measure.py rules booktabs-typst.pdf
""") Output
$ pdflatex -interaction=nonstopmode -halt-on-error grid.tex | grep -E "Warning|Output written" Output written on grid.pdf (1 page, 19219 bytes). $ python3 measure.py rules grid.pdf rule 0.40 pt thick, 312.5 pt long: 0.9 pt above the text below rule 0.40 pt thick, 312.5 pt long: 1.1 pt below the text above, 3.2 pt above the text below rule 0.40 pt thick, 312.5 pt long: 3.1 pt below the text above, 1.2 pt above the text below rule 0.40 pt thick, 312.5 pt long: 3.4 pt below the text above, 1.2 pt above the text below rule 0.40 pt thick, 312.5 pt long: 3.4 pt below the text above, 1.6 pt above the text below rule 0.40 pt thick, 312.5 pt long: 3.4 pt below the text above, 1.6 pt above the text below rule 0.40 pt thick, 312.5 pt long: 3.4 pt below the text above, 554.9 pt above the text below 30 vertical lines $ python3 measure.py decimals grid.pdf Grade "Fe 500 B2" column 3 (432.5, 441.0, 521.75, 508.3): decimal points 2.5 pt apart column 4 (528.25, 531.7, 612.4, 604.15): decimal points 2.5 pt apart column 5 (18.4, 17.95, 15.2, 16.05): decimal points 2.5 pt apart $ pdflatex -interaction=nonstopmode -halt-on-error booktabs.tex | grep -E "Warning|Output written" Output written on booktabs.pdf (1 page, 18963 bytes). $ python3 measure.py rules booktabs.pdf rule 0.80 pt thick, 312.5 pt long: 3.7 pt above the text below rule 0.50 pt thick, 312.5 pt long: 2.8 pt below the text above, 4.0 pt above the text below rule 0.80 pt thick, 312.5 pt long: 5.1 pt below the text above, 548.1 pt above the text below 0 vertical lines $ typst compile booktabs.typ booktabs-typst.pdf $ python3 measure.py rules booktabs-typst.pdf rule 0.88 pt thick, 301.5 pt long: 4.0 pt above the text below rule 0.55 pt thick, 301.2 pt long: 2.1 pt below the text above, 4.7 pt above the text below rule 0.88 pt thick, 301.5 pt long: 4.4 pt below the text above 0 vertical lines
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rules_session.py
The boxed table drew seven horizontal lines of 0.40 pt and 30 vertical ones, its header text stood 0.9 pt under the
top line, and its centred numbers left their decimal points 2.5 pt apart. booktabs drew three: 0.80 pt at the top and
bottom and 0.50 pt under the header, with 3.7 pt of space under the top rule and 4.0 pt under the middle one, so the
text stands clear of the lines. Those are the package’s defaults: its manual sets \heavyrulewidth to 0.08 em and \lightrulewidth to 0.05 em, which at this 10 pt type is
0.8 pt and 0.5 pt. Typst measured em on its 11 pt type, hence 0.88 and 0.55 pt. Here are the two LaTeX tables as their
PDFs drew them; look at the lines round the header and between the rows:
The same four tests, ruled as grid.tex and as booktabs.tex
Text description of the diagram
The figure shows the same table of four tensile tests twice, as the two PDFs drew it.
- In grid.tex every cell has a box round it. A thin line, 0.40 pt, runs above the header, two run close together under it, and one more runs under each of the four rows: seven horizontal lines. Vertical lines separate the five columns and close both sides, 30 short segments in all. The numbers are centred in their columns, so their decimal points do not line up.
- In booktabs.tex there are only three rules and no vertical line: a heavier one at the top, 0.80 pt, a lighter one under the header, 0.50 pt, and a heavier one at the bottom, 0.80 pt. There is more space between each rule and the text next to it, and the columns of numbers are set flush right.
The booktabs manual gives two rules before any command: “Never, ever use vertical rules” and “Never use double rules”.
If the two halves of a table seem to need a line between them, it suggests two tables instead. It adds three smaller
guidelines that help any table: put units in the column heading, as Yield (MPa) does; write a digit before a
decimal point, 0.1 and not .1; and repeat a value rather than use ditto marks.
Mixing vertical bars or \hline with booktabs rules
Wrong: keeping {|l|l|r|r|r|} and adding \toprule. The manual says its rules are not guaranteed to connect with
the vertical lines of | and not guaranteed to work with \hline, so the corners break. Right: {l l r r r} and
only \toprule, \midrule, \cmidrule and \bottomrule, with \addlinespace where a group needs air.
Checkpoint
Check: booktabs rules instead of boxes
Numbers line up on the decimal point, not on the column’s edge
booktabs.tex set its numbers flush right, which lines up their last digits, not their
decimal alignment: Setting a column of numbers so that their decimal points stand one under the other, whatever the number of figures before or after them. siunitx's S column does it in LaTeX and the zero package in Typst.: 528.25 and 531.7 end at the same place, so their decimal points do
not. siunitx.tex uses siunitx’s S column instead, and zero.typ the zero package’s format-table, in which
none leaves a column alone and auto aligns its numbers. nobrace.tex leaves the heading of one S column out of
its braces:
decimal/siunitx.tex
\documentclass[a4paper]{article}
\usepackage{booktabs}
\usepackage{siunitx}
\begin{document}
\begin{center}
% S columns align on the decimal marker; table-format=3.2 reserves 3 digits before it and 2 after.
\begin{tabular}{l l S[table-format=3.2] S[table-format=3.2] S[table-format=2.2]}
\toprule
Grade & Specimen & {Yield (MPa)} & {Tensile (MPa)} & {Elongation (\%)} \\
\midrule
Fe 415 & A1 & 432.5 & 528.25 & 18.4 \\
Fe 415 & A2 & 441.0 & 531.7 & 17.95 \\
Fe 500 & B1 & 521.75 & 612.4 & 15.2 \\
Fe 500 & B2 & 508.3 & 604.15 & 16.05 \\
\bottomrule
\end{tabular}
\end{center}
\end{document} decimal/nobrace.tex
\documentclass[a4paper]{article}
\usepackage{booktabs}
\usepackage{siunitx}
\begin{document}
\begin{center}
% The heading Yield (MPa) is not in braces, so the S column reads it as a number.
\begin{tabular}{l l S[table-format=3.2] S[table-format=3.2] S[table-format=2.2]}
\toprule
Grade & Specimen & Yield (MPa) & {Tensile (MPa)} & {Elongation (\%)} \\
\midrule
Fe 415 & A1 & 432.5 & 528.25 & 18.4 \\
Fe 415 & A2 & 441.0 & 531.7 & 17.95 \\
Fe 500 & B1 & 521.75 & 612.4 & 15.2 \\
Fe 500 & B2 & 508.3 & 604.15 & 16.05 \\
\bottomrule
\end{tabular}
\end{center}
\end{document} decimal/zero.typ
#import "@preview/zero:0.7.1": format-table
// none leaves a column alone; auto aligns its numbers on the decimal point.
#show table: format-table(none, none, auto, auto, auto)
#table(
columns: 5,
stroke: none,
align: (left, left, right, right, right),
table.hline(stroke: 0.08em),
table.header[Grade][Specimen][Yield (MPa)][Tensile (MPa)][Elongation (%)],
table.hline(stroke: 0.05em),
[Fe 415], [A1], [432.5], [528.25], [18.4],
[Fe 415], [A2], [441.0], [531.7], [17.95],
[Fe 500], [B1], [521.75], [612.4], [15.2],
[Fe 500], [B2], [508.3], [604.15], [16.05],
table.hline(stroke: 0.08em),
) from session import files, packages, run
# Where the decimal points of the three columns of numbers stand: right-aligned in booktabs.tex and booktabs.typ,
# aligned on the decimal marker by siunitx's S columns (siunitx.tex) and by zero's format-table (zero.typ).
# nobrace.tex is siunitx.tex with one heading of an S column left out of its braces.
files("rules/booktabs.tex", "rules/booktabs.typ", "decimal/siunitx.tex", "decimal/nobrace.tex", "decimal/zero.typ",
"measure.py")
packages("zero:0.7.1")
run("""
pdflatex -interaction=nonstopmode -halt-on-error booktabs.tex > /dev/null
python3 measure.py decimals booktabs.pdf Grade "Fe 500 B2"
pdflatex -interaction=nonstopmode -halt-on-error siunitx.tex | grep -E "Warning|Output written"
python3 measure.py decimals siunitx.pdf Grade "Fe 500 B2"
!pdflatex -interaction=nonstopmode -halt-on-error -file-line-error nobrace.tex > /dev/null
grep -E "^\\./nobrace.tex" nobrace.log
typst compile booktabs.typ booktabs-typst.pdf
python3 measure.py decimals booktabs-typst.pdf Grade "Fe 500 B2"
typst compile zero.typ
python3 measure.py decimals zero.pdf Grade "Fe 500 B2"
""") Output
$ pdflatex -interaction=nonstopmode -halt-on-error booktabs.tex > /dev/null $ python3 measure.py decimals booktabs.pdf Grade "Fe 500 B2" column 3 (432.5, 441.0, 521.75, 508.3): decimal points 5.0 pt apart column 4 (528.25, 531.7, 612.4, 604.15): decimal points 5.0 pt apart column 5 (18.4, 17.95, 15.2, 16.05): decimal points 5.0 pt apart $ pdflatex -interaction=nonstopmode -halt-on-error siunitx.tex | grep -E "Warning|Output written" Output written on siunitx.pdf (1 page, 26789 bytes). $ python3 measure.py decimals siunitx.pdf Grade "Fe 500 B2" column 3 (432.5, 441.0, 521.75, 508.3): decimal points 0.0 pt apart column 4 (528.25, 531.7, 612.4, 604.15): decimal points 0.0 pt apart column 5 (18.4, 17.95, 15.2, 16.05): decimal points 0.0 pt apart $ pdflatex -interaction=nonstopmode -halt-on-error -file-line-error nobrace.tex > /dev/null [exit status 1] $ grep -E "^\./nobrace.tex" nobrace.log ./nobrace.tex:9: Package siunitx Error: Invalid number 'e'. ./nobrace.tex:9: ==> Fatal error occurred, no output PDF file produced! $ typst compile booktabs.typ booktabs-typst.pdf $ python3 measure.py decimals booktabs-typst.pdf Grade "Fe 500 B2" column 3 (432.5, 441.0, 521.75, 508.3): decimal points 5.1 pt apart column 4 (528.25, 531.7, 612.4, 604.15): decimal points 5.1 pt apart column 5 (18.4, 17.95, 15.2, 16.05): decimal points 5.1 pt apart $ typst compile zero.typ $ python3 measure.py decimals zero.pdf Grade "Fe 500 B2" column 3 (432.5, 441.0, 521.75, 508.3): decimal points 0.0 pt apart column 4 (528.25, 531.7, 612.4, 604.15): decimal points 0.0 pt apart column 5 (18.4, 17.95, 15.2, 16.05): decimal points 0.0 pt apart
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 decimal_session.py
Set flush right, the decimal points of each column stood 5.0 pt apart in LaTeX and 5.1 pt in Typst, one digit’s
width, because some numbers have one figure after the point and others two. In the S columns and with format-table
they stood 0.0 pt apart. Step through what an S column does with the elongations:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The model. table-format=2.2 tells siunitx how much space to keep for a number of the column, two figures before the decimal marker and two after. The manual calls it a model, and setting table-format also switches the column to aligning by it.
- Reading the cells. Every cell of an S column is read as a number, not as text, so siunitx knows where its digits and its decimal marker are. In siunitx.tex the column holds 18.4, 17.95, 15.2 and 16.05.
- Splitting at the marker. Each number is cut at its decimal marker into the part before it and the part after it. 18.4 has one figure after the point and 17.95 two, which is why right alignment set their points 5.0 pt apart.
- Filling the model. The part before the point is set flush right in the room for two figures, and the part after it flush left in the room for two more, so a missing second decimal becomes empty space instead of a shift.
- Headings in braces. A heading such as {Elongation (\%)} is wrapped in braces, so the column sets it as text and does not try to read it. Without the braces, nobrace.tex stopped with "Invalid number 'e'": the e of Yield was taken for part of a number.
- One line of points. Every number now has its decimal point at the same place. The session measured the points of all three S columns 0.0 pt apart, and zero's format-table did the same in Typst.
A heading of an S column without braces
nobrace.tex left one heading as Yield (MPa), and pdfLaTeX stopped at line 9 with Invalid number 'e': the
column tried to read the heading as a number. The siunitx manual says to protect such material with braces, so
write {Yield (MPa)}, as siunitx.tex does for all three headings.
The siunitx manual describes table-format as a model of the space to keep for a number: 3.2 keeps three figures
before the decimal marker and two after it, and the column aligns on it. Zero aligns on the uncertainty and the
exponent as well, its page says, and skips cells that do not start like a number, such as most headings.
Key idea
Flush-right numbers line up their last digits; an S column or zero’s format-table lines up their decimal points, which is what a reader compares.
Checkpoint
Check: Aligning numbers on the decimal point
Long text wraps in an X column or a 1fr column
A column of notes is where tables run off the page. The notes of two specimens are about 90 characters each. Set in
an l column, which never wraps, they are one line each; in tabularx’s X column they wrap. notes-indent.tex puts
the same tabularx in running text instead of a float. notes-fr.typ gives Typst’s second column 1fr, a
fraction (fr): A Typst size such as 1fr or 2fr: a share of the room that is left after the fixed and auto columns are set. Columns of 1fr and 2fr split that room one to two. of the width that is left, and notes-auto.typ leaves both columns auto:
wrap/notes-l.tex
\documentclass[a4paper]{article}
\usepackage{booktabs}
\begin{document}
\begin{table}[ht]
\centering
\begin{tabular}{l l}
\toprule
Specimen & What the test showed \\
\midrule
A1 & Necked evenly and broke near the middle of the gauge length, as a ductile bar should. \\
B2 & Broke close to the upper grip, so its elongation may be low and the test should be repeated. \\
\bottomrule
\end{tabular}
\end{table}
\end{document} wrap/notes-x.tex
\documentclass[a4paper]{article}
\usepackage{booktabs}
\usepackage{tabularx}
\begin{document}
\begin{table}[ht]
\centering
\begin{tabularx}{\linewidth}{l X}
\toprule
Specimen & What the test showed \\
\midrule
A1 & Necked evenly and broke near the middle of the gauge length, as a ductile bar should. \\
B2 & Broke close to the upper grip, so its elongation may be low and the test should be repeated. \\
\bottomrule
\end{tabularx}
\end{table}
\end{document} wrap/notes-indent.tex
\documentclass[a4paper]{article}
\usepackage{booktabs}
\usepackage{tabularx}
\begin{document}
The two specimens behaved differently.
\begin{tabularx}{\linewidth}{l X}
\toprule
Specimen & What the test showed \\
\midrule
A1 & Necked evenly and broke near the middle of the gauge length, as a ductile bar should. \\
B2 & Broke close to the upper grip, so its elongation may be low and the test should be repeated. \\
\bottomrule
\end{tabularx}
\end{document} wrap/notes-fr.typ
// The first column is as wide as its widest cell; 1fr gives the second column all the width that is left.
#table(
columns: (auto, 1fr),
stroke: none,
table.hline(stroke: 0.08em),
table.header[Specimen][What the test showed],
table.hline(stroke: 0.05em),
[A1], [Necked evenly and broke near the middle of the gauge length, as a ductile bar should.],
[B2], [Broke close to the upper grip, so its elongation may be low and the test should be repeated.],
table.hline(stroke: 0.08em),
) wrap/notes-auto.typ
// Both columns are auto: each is as wide as its widest cell, unless the table would be wider than the text.
#table(
columns: 2,
stroke: none,
table.hline(stroke: 0.08em),
table.header[Specimen][What the test showed],
table.hline(stroke: 0.05em),
[A1], [Necked evenly and broke near the middle of the gauge length, as a ductile bar should.],
[B2], [Broke close to the upper grip, so its elongation may be low and the test should be repeated.],
table.hline(stroke: 0.08em),
) from session import files, run
# A column of long notes as an l column (notes-l.tex), as a tabularx X column in a table float (notes-x.tex) and in
# running text (notes-indent.tex), and in Typst as a 1fr column (notes-fr.typ) and in two auto columns
# (notes-auto.typ). measure.py gives the length of the rules, which is the width of the table, in PDF points: TeX's
# 345 pt of text width measure 343.7 of them.
files("wrap/notes-l.tex", "wrap/notes-x.tex", "wrap/notes-indent.tex", "wrap/notes-fr.typ", "wrap/notes-auto.typ",
"measure.py")
run("""
pdflatex -interaction=nonstopmode -halt-on-error notes-l.tex | grep -E "Overfull|Output written"
pdftotext -layout notes-l.pdf - | head -4
python3 measure.py rules notes-l.pdf | head -1
pdflatex -interaction=nonstopmode -halt-on-error notes-x.tex | grep -E "Overfull|Output written"
pdftotext -layout notes-x.pdf - | head -6
python3 measure.py rules notes-x.pdf | head -1
pdflatex -interaction=nonstopmode -halt-on-error notes-indent.tex | grep -E "Overfull|Output written"
typst compile notes-fr.typ
pdftotext -layout notes-fr.pdf - | head -5
python3 measure.py rules notes-fr.pdf | head -1
typst compile notes-auto.typ
pdftotext -layout notes-auto.pdf - | head -5
python3 measure.py rules notes-auto.pdf | head -1
""") Output
$ pdflatex -interaction=nonstopmode -halt-on-error notes-l.tex | grep -E "Overfull|Output written"
Overfull \hbox (124.25081pt too wide) in paragraph at lines 6--14
Output written on notes-l.pdf (1 page, 17819 bytes).
$ pdftotext -layout notes-l.pdf - | head -4
Specimen What the test showed
A1 Necked evenly and broke near the middle of the gauge length, as a ductile bar should.
B2 Broke close to the upper grip, so its elongation may be low and the test should be repeated.
$ python3 measure.py rules notes-l.pdf | head -1
rule 0.80 pt thick, 467.5 pt long: 4.1 pt above the text below
$ pdflatex -interaction=nonstopmode -halt-on-error notes-x.tex | grep -E "Overfull|Output written"
Output written on notes-x.pdf (1 page, 17855 bytes).
$ pdftotext -layout notes-x.pdf - | head -6
Specimen What the test showed
A1 Necked evenly and broke near the middle of the gauge length, as
a ductile bar should.
B2 Broke close to the upper grip, so its elongation may be low and
the test should be repeated.
$ python3 measure.py rules notes-x.pdf | head -1
rule 0.80 pt thick, 343.7 pt long: 4.1 pt above the text below
$ pdflatex -interaction=nonstopmode -halt-on-error notes-indent.tex | grep -E "Overfull|Output written"
Overfull \hbox (15.0pt too wide) in paragraph at lines 14--15
Output written on notes-indent.pdf (1 page, 18446 bytes).
$ typst compile notes-fr.typ
$ pdftotext -layout notes-fr.pdf - | head -5
Specimen What the test showed
A1 Necked evenly and broke near the middle of the gauge length, as a ductile bar should.
B2 Broke close to the upper grip, so its elongation may be low and the test should be
repeated.
$ python3 measure.py rules notes-fr.pdf | head -1
rule 0.88 pt thick, 454.4 pt long: 4.1 pt above the text below
$ typst compile notes-auto.typ
$ pdftotext -layout notes-auto.pdf - | head -5
Specimen What the test showed
A1 Necked evenly and broke near the middle of the gauge length, as a ductile bar should.
B2 Broke close to the upper grip, so its elongation may be low and the test should be
repeated.
$ python3 measure.py rules notes-auto.pdf | head -1
rule 0.88 pt thick, 454.4 pt long: 4.1 pt above the text below
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wrap_session.py
The l column made the table 467.5 pt wide, and pdfLaTeX warned of an overfull box: A line or box that TeX could not fit into its width, so it sticks out into the margin. The log reports it as Overfull \hbox, with the amount in points and the input lines. More in “Reading errors and making a minimal example”
124.25 pt too wide: the table ran into the right margin. tabularx{\linewidth}{l X} gave the first column its natural
width and the X column the rest, so each note wrapped onto two lines and the rules measured 343.7 pt. measure.py
reports PDF points, of which TeX’s 345 pt text width makes 343.7, so the table was exactly as wide as the text. The
tabularx manual explains it: an X column becomes p{width} once tabularx has worked out the width that fills the
table. In Typst, (auto, 1fr) did the same: its rules measured 454.4 pt, the 453.5 pt of Typst’s text block plus
half a rule’s thickness at each end.
A full-width tabularx in a paragraph
notes-indent.tex put \begin{tabularx}{\linewidth} straight after a paragraph of text, and pdfLaTeX warned that
the line was 15.0 pt too wide. The table starts a new paragraph, whose indent of 15 pt comes before it. Put the table
in a table float with \centering, as notes-x.tex does, or write \noindent before it.
In notes-auto.typ both columns are auto, and the notes wrapped all the same: Typst narrowed the second column so
the table fitted the text width. 1fr says which column takes the room instead of leaving it to Typst, and
(1fr, 2fr) would share it one to two.
Checkpoint
Check: Wrapping long cells
Merged cells: a heading over two columns, a label over two rows
The finished table groups Yield and Tensile under one heading, Strength (MPa), and sets each grade once over its two
specimens. In LaTeX, \multicolumn{2}{c}{Strength (MPa)} covers two columns, \cmidrule(lr){3-4} draws a rule under
columns 3 and 4 only, trimmed at both ends, and the multirow package’s \multirow{2}{*}{Fe 415} covers two rows.
Each is a spanning cell: A table cell that covers more than one column or row, such as a heading over two columns. LaTeX writes it with \multicolumn or \multirow, Typst with table.cell(colspan: 2) or rowspan.. In Typst, table.cell(colspan: 2) and
table.cell(rowspan: 2) do the merging, and table.hline(start: 2, end: 4) draws the short rule; start counts
columns from 0 and end is not included.
merge/results.tex
\documentclass[a4paper]{article}
\usepackage{booktabs}
\usepackage{multirow}
\usepackage{siunitx}
\begin{document}
\begin{table}[ht]
\centering
\caption{Tensile tests of two bar grades}
\label{tab:tensile}
\begin{tabular}{l l S[table-format=3.2] S[table-format=3.2] S[table-format=2.2]}
\toprule
\multirow{2}{*}{Grade} & \multirow{2}{*}{Specimen} & \multicolumn{2}{c}{Strength (MPa)} & {\multirow{2}{*}{Elongation (\%)}} \\
\cmidrule(lr){3-4}
& & {Yield} & {Tensile} & \\
\midrule
\multirow{2}{*}{Fe 415} & A1 & 432.5 & 528.25 & 18.4 \\
& A2 & 441.0 & 531.7 & 17.95 \\
\addlinespace
\multirow{2}{*}{Fe 500} & B1 & 521.75 & 612.4 & 15.2 \\
& B2 & 508.3 & 604.15 & 16.05 \\
\bottomrule
\multicolumn{5}{l}{\footnotesize Invented values: two 12 mm bars of each grade.} \\
\end{tabular}
\end{table}
\end{document} merge/results.typ
#import "@preview/zero:0.7.1": format-table
#show figure.where(kind: table): set figure.caption(position: top)
#show table: format-table(none, none, auto, auto, auto)
#figure(
table(
columns: 5,
stroke: none,
align: (left, left, right, right, right),
table.hline(stroke: 0.08em),
table.header(
table.cell(rowspan: 2, align: horizon)[Grade],
table.cell(rowspan: 2, align: horizon)[Specimen],
table.cell(colspan: 2, align: center)[Strength (MPa)],
table.cell(rowspan: 2, align: horizon)[Elongation (%)],
// A rule under columns 3 and 4 only, as \cmidrule{3-4} draws: start is counted from 0, end is excluded.
table.hline(start: 2, end: 4, stroke: 0.03em),
[Yield], [Tensile],
),
table.hline(stroke: 0.05em),
table.cell(rowspan: 2, align: horizon)[Fe 415], [A1], [432.5], [528.25], [18.4],
[A2], [441.0], [531.7], [17.95],
table.cell(rowspan: 2, align: horizon)[Fe 500], [B1], [521.75], [612.4], [15.2],
[B2], [508.3], [604.15], [16.05],
table.hline(stroke: 0.08em),
table.cell(colspan: 5)[#text(size: 0.8em)[Invented values: two 12 mm bars of each grade.]],
),
caption: [Tensile tests of two bar grades],
) <tensile> from session import files, packages, run
# The tensile table with a heading over two columns, labels over two rows, S columns and a line of notes, in LaTeX
# (results.tex, built twice for its label) and in Typst (results.typ). pdftotext -layout prints the text where each PDF
# put it; measure.py lists the rules, among them the short one under the heading over two columns.
files("merge/results.tex", "merge/results.typ", "measure.py")
packages("zero:0.7.1")
run("""
pdflatex -interaction=nonstopmode -halt-on-error results.tex > /dev/null
pdflatex -interaction=nonstopmode -halt-on-error results.tex | grep -E "Warning|Output written"
pdftotext -layout results.pdf - | head -11
python3 measure.py rules results.pdf
typst compile results.typ results-typst.pdf
pdftotext -layout results-typst.pdf - | head -11
python3 measure.py rules results-typst.pdf
""") Output
$ pdflatex -interaction=nonstopmode -halt-on-error results.tex > /dev/null
$ pdflatex -interaction=nonstopmode -halt-on-error results.tex | grep -E "Warning|Output written"
Output written on results.pdf (1 page, 40107 bytes).
$ pdftotext -layout results.pdf - | head -11
Table 1: Tensile tests of two bar grades
Strength (MPa)
Grade Specimen Elongation (%)
Yield Tensile
A1 432.5 528.25 18.4
Fe 415
A2 441.0 531.7 17.95
B1 521.75 612.4 15.2
Fe 500
B2 508.3 604.15 16.05
Invented values: two 12 mm bars of each grade.
$ python3 measure.py rules results.pdf
rule 0.80 pt thick, 253.8 pt long: 0.9 pt below the text above, 3.7 pt above the text below
rule 0.30 pt thick, 71.8 pt long: 2.8 pt below the text above, 4.2 pt above the text below
rule 0.50 pt thick, 253.8 pt long: 5.2 pt below the text above, 4.0 pt above the text below
rule 0.80 pt thick, 253.8 pt long: 5.1 pt below the text above, 2.8 pt above the text below
0 vertical lines
$ typst compile results.typ results-typst.pdf
$ pdftotext -layout results-typst.pdf - | head -11
Table 1: Tensile tests of two bar grades
Strength (MPa)
Grade Specimen Elongation (%)
Yield Tensile
A1 432.5 528.25 18.4
Fe 415
A2 441.0 531.7 17.95
B1 521.75 612.4 15.2
Fe 500
B2 508.3 604.15 16.05
Invented values: two 12 mm bars of each grade.
$ python3 measure.py rules results-typst.pdf
rule 0.88 pt thick, 250.5 pt long: 4.1 pt below the text above, 4.0 pt above the text below
rule 0.33 pt thick, 82.9 pt long: 2.2 pt below the text above, 4.4 pt above the text below
rule 0.55 pt thick, 250.2 pt long: 4.6 pt below the text above, 4.7 pt above the text below
rule 0.88 pt thick, 250.5 pt long: 4.3 pt below the text above, 4.2 pt above the text below
0 vertical lines
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 merge_session.py
Both PDFs printed Strength (MPa) once over the two columns, each grade once between its two rows, and the decimal
points in line. The short rule under the grouped heading measured 0.30 pt and 71.8 pt in LaTeX, trimmed by
(lr), and 0.33 pt and 82.9 pt in Typst, which does not trim it.
Typing in a row that \multirow covers
\multirow{2} leaves the cell of the next row to you, and you must leave it empty: the multirow manual warns that
anything typed there is overwritten by the merged text. That is why the A2 row of results.tex starts with &. In
Typst the merged cell takes its place, so the A2 row simply has one cell fewer.
Here are both tables, drawn from the PDFs:
The finished table, as results.tex and results.typ built it
Text description of the diagram
The figure shows the finished table twice, first as pdfLaTeX built it from results.tex, then as Typst built it from results.typ. Both have the caption "Table 1: Tensile tests of two bar grades" above the table.
- The header has two rows. "Strength (MPa)" stands once over two columns, Yield and Tensile, with a thin rule under it that covers only those two columns. Grade, Specimen and Elongation (%) each stand once, centred on the two header rows.
- In the body, Fe 415 stands once, centred on the rows of specimens A1 and A2, and Fe 500 once on B1 and B2.
- In each column of numbers the decimal points stand one under the other: 432.5, 441.0, 521.75 and 508.3 under Yield; 528.25, 531.7, 612.4 and 604.15 under Tensile; 18.4, 17.95, 15.2 and 16.05 under Elongation.
- A line of notes under the bottom rule reads "Invented values: two 12 mm bars of each grade."
- The two builds differ in small ways: LaTeX's rule under Strength stops short of the columns' outer edges and leaves extra space between the two grades, while Typst's rule runs the full width of the two columns and its rows are evenly spaced.
Key idea
Merge only what a reader reads as one heading or one label: \multicolumn and colspan for a heading over
columns, \multirow and rowspan for a label over rows, with a short \cmidrule or hline under a grouped
heading.
Checkpoint
Check: Merging cells over columns and rows
A tagged PDF needs packages that the tagging code supports
A table that looks right can still be wrong in a tagged PDF. The LaTeX Project keeps a tagging status list of packages, and the packages of this lesson, with three others often used for tables, stand on it as follows:
| Package | What it does here | Tagging status |
|---|---|---|
| booktabs | the three rules | compatible (with the firstaid fix) |
| siunitx | S columns |
compatible |
| tabularx | X columns |
compatible |
| multirow | labels over rows | currently incompatible |
| makecell | breaks inside cells | partially compatible |
| nicematrix, tabularray | complete table systems | currently incompatible |
results-tagged.tex is results.tex with tagging switched on and its two header rows named. spans.py counts the
cells whose tags say they cover more than one column or row:
tags/results-tagged.tex
\DocumentMetadata{lang=en, tagging=on}
\documentclass[a4paper]{article}
\usepackage{booktabs}
\usepackage{multirow}
\usepackage{siunitx}
% The first two rows are the header.
\tagpdfsetup{table/header-rows={1,2}}
\begin{document}
\begin{table}[ht]
\centering
\caption{Tensile tests of two bar grades}
\label{tab:tensile}
\begin{tabular}{l l S[table-format=3.2] S[table-format=3.2] S[table-format=2.2]}
\toprule
\multirow{2}{*}{Grade} & \multirow{2}{*}{Specimen} & \multicolumn{2}{c}{Strength (MPa)} & {\multirow{2}{*}{Elongation (\%)}} \\
\cmidrule(lr){3-4}
& & {Yield} & {Tensile} & \\
\midrule
\multirow{2}{*}{Fe 415} & A1 & 432.5 & 528.25 & 18.4 \\
& A2 & 441.0 & 531.7 & 17.95 \\
\addlinespace
\multirow{2}{*}{Fe 500} & B1 & 521.75 & 612.4 & 15.2 \\
& B2 & 508.3 & 604.15 & 16.05 \\
\bottomrule
\multicolumn{5}{l}{\footnotesize Invented values: two 12 mm bars of each grade.} \\
\end{tabular}
\end{table}
\end{document} spans.py
"""Counts the table cells that a tagged PDF marks as spanning columns or rows: the /ColSpan and /RowSpan attributes of
its structure elements, read from the PDF after qpdf has written every object out uncompressed (qpdf --qdf)."""
import re
import subprocess
import sys
pdf = sys.argv[1]
raw = subprocess.run(["qpdf", "--qdf", "--object-streams=disable", pdf, "-"], capture_output=True, check=True).stdout
for name in ("ColSpan", "RowSpan"):
values = [int(v) for v in re.findall(rb"/" + name.encode() + rb"\s+(\d+)", raw)]
print(f"{name}: {len(values)} cells" + (f", spanning {', '.join(str(v) for v in values)}" if values else "")) from session import files, packages, require, run
# The tensile table as a tagged LaTeX PDF (results-tagged.tex, with booktabs, multirow and siunitx and its first two
# rows marked as the header) and as Typst's PDF (results.typ). spans.py counts the cells that each PDF's tags mark as
# spanning several columns or rows.
require("qpdf")
files("tags/results-tagged.tex", "merge/results.typ", "spans.py")
packages("zero:0.7.1")
run("""
pdflatex -interaction=nonstopmode -halt-on-error results-tagged.tex > /dev/null
pdfinfo results-tagged.pdf | grep -E "^(Tagged|Pages)"
python3 spans.py results-tagged.pdf
typst compile results.typ
pdfinfo results.pdf | grep -E "^(Tagged|Pages)"
python3 spans.py results.pdf
""") Output
$ pdflatex -interaction=nonstopmode -halt-on-error results-tagged.tex > /dev/null $ pdfinfo results-tagged.pdf | grep -E "^(Tagged|Pages)" Tagged: yes Pages: 1 $ python3 spans.py results-tagged.pdf ColSpan: 2 cells, spanning 2, 5 RowSpan: 0 cells $ typst compile results.typ $ pdfinfo results.pdf | grep -E "^(Tagged|Pages)" Tagged: yes Pages: 1 $ python3 spans.py results.pdf ColSpan: 2 cells, spanning 2, 5 RowSpan: 5 cells, spanning 2, 2, 2, 2, 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tags_session.py
Both PDFs were tagged, and both marked their two cells over columns, the heading over two and the line of notes over five. But of the five cells that cover two rows, three headings and two grades, LaTeX’s tags recorded none, while Typst wrote a row span of 2 for every one. In the LaTeX PDF the tags record each of those five cells as a cell of a single row. Answer the questions below for your own table to see which way of setting a label over two rows fits it:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way | Tagged PDF | One label per group | System |
|---|---|---|---|
| Repeat the label Written as: Fe 415 typed in each of its rows | Good Each row holds its own label, and booktabs and siunitx are marked compatible. | Mixed The label is printed twice; booktabs advises repeating a value over ditto marks. | Good Works in LaTeX and in Typst. |
| LaTeX multirow Written as: \multirow{2}{*}{Fe 415} | Poor Marked currently-incompatible: the tagged build wrote no RowSpan for its 5 merged cells. | Good Set once, centred on its two rows. | Mixed LaTeX only. |
| Typst rowspan Written as: table.cell(rowspan: 2)[Fe 415] | Good Typst wrote RowSpan 2 for all 5 merged cells. | Good Set once; align: horizon centres it on its rows. | Mixed Typst only. |
How the answers decide
- “Must the PDF be tagged, for example for PDF/UA or an accessible thesis?” Yes, it must be tagged: rules out LaTeX multirow. The status list marks multirow currently-incompatible, and the tagged build had no RowSpan: the tags describe each merged cell as a cell of one row.
- “Does your style ask for each label to be set once over its group?” Yes, once per group: adds a caveat to Repeat the label. Repeating keeps the tags right but prints the label in every row; check that your style accepts it.
- “Which system do you write in?” LaTeX: rules out Typst rowspan. table.cell(rowspan: 2) is Typst; in LaTeX the choice is between repeating the label and multirow.
- “Which system do you write in?” Typst: rules out LaTeX multirow. multirow is a LaTeX package; Typst merges cells with rowspan.
The list changes as the LaTeX Project releases fixes, so look a package up there before a thesis or a report that must
be tagged depends on it, and check the built PDF, as spans.py does here.
Checkpoint
Check: Table packages in a tagged PDF
Practise: restyle a boxed table and check its decimal points
The lab turns grid.tex into a booktabs table by script, then checks decimal alignment the way measure.py does.
The first step rewrites the LaTeX source, the second decides from measured positions which columns are out of line.
Both come with sample tests you run in the browser, and a first hint if you are stuck:
Code lab · 2 steps
Restyle a table and check its alignment
Rewrite a boxed tabular with booktabs rules and no vertical bars, then report the columns whose decimal points stand more than 0.3 pt apart.
- Restyle a boxed tabular with booktabs rules To do
- Find the columns whose decimal points are out of line To do
Exercise · Medium · Python
Restyle a boxed tabular with booktabs rules
Write restyle(source) in restyle.py. source is a LaTeX tabular like the one in grid.tex, one row per line: a column specification with vertical bars, such as \begin{tabular}{|c|c|c|}, and \hline before the header, under it (sometimes doubled, \hline\hline) and after the rows. Return the same table with booktabs rules:
- the column specification without any
|, so{|c|c|c|}becomes{ccc}; - every place where rules stand, above the first row, between two rows or after the last row, counts once, however many
\hlineit has: the first such place gets\toprule, the second\midruleand the last\bottomrule, each on a line of its own; - every other
\hlineis removed, and the rows themselves stay as they are.
Starter code · restyle.py
def restyle(source):
"""Return the tabular `source` with booktabs rules and no vertical bars."""
lines = source.strip().splitlines()
# Replace this comment: clean the column specification, find the rule positions, rebuild the lines.
return "\n".join(lines) The sample tests · test_restyle.py
from restyle import restyle
GRID = r"""\begin{tabular}{|c|c|c|}
\hline
Grade & Specimen & Yield (MPa) \\
\hline\hline
Fe 415 & A1 & 432.5 \\ \hline
Fe 415 & A2 & 441.0 \\ \hline
Fe 500 & B1 & 521.75 \\ \hline
\end{tabular}"""
LINES = [line.strip() for line in restyle(GRID).splitlines()]
def test_no_vertical_bars():
assert LINES[0] == r"\begin{tabular}{ccc}"
def test_three_rules_in_their_places():
"""\\toprule first, \\midrule under the header, \\bottomrule after the last row"""
assert LINES[1] == r"\toprule"
assert LINES[2] == r"Grade & Specimen & Yield (MPa) \\"
assert LINES[3] == r"\midrule"
assert LINES[-2] == r"\bottomrule"
def test_double_rule_counts_once():
"""\\hline\\hline under the header becomes a single \\midrule"""
assert LINES.count(r"\midrule") == 1
def test_no_hline_left():
assert not any(r"\hline" in line for line in LINES)
def test_rows_kept_without_their_rules():
assert r"Fe 415 & A2 & 441.0 \\" in LINES
assert LINES[-3] == r"Fe 500 & B1 & 521.75 \\" A hint
Split the source into lines. A line is a rule position when, once \hline is removed from it, nothing is left; a row that ends with \\ \hline is a row followed by a rule position. Collect the rows and the positions in order, then write \toprule for the first position, \midrule for the second and \bottomrule for the last.
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 · Easy · Python
Find the columns whose decimal points are out of line
measure.py finds, for each column of numbers, the left edge of every decimal point in points from the page's left edge. Write misaligned(points, tolerance=0.3) in points.py. points maps a column's heading to the list of those positions, such as {"Yield (MPa)": [301.2, 296.2, 296.2, 301.2]}. Return the headings, in alphabetical order, of the columns whose decimal points stand more than tolerance apart: the largest position minus the smallest. A column with fewer than two numbers cannot be out of line.
Starter code · points.py
def misaligned(points, tolerance=0.3):
"""Return, sorted, the headings of the columns whose decimal points stand more than `tolerance` pt apart."""
found = []
# Replace this comment: compare each column's spread of positions with the tolerance.
return sorted(found) The sample tests · test_points.py
from points import misaligned
def test_flush_right_columns_are_out_of_line():
"""booktabs.tex: the points stood 5.0 pt apart in every column"""
points = {"Yield (MPa)": [301.2, 296.2, 296.2, 301.2], "Elongation (%)": [418.9, 413.9, 418.9, 413.9]}
assert misaligned(points) == ["Elongation (%)", "Yield (MPa)"]
def test_s_columns_pass():
"""siunitx.tex: 0.0 pt apart"""
assert misaligned({"Yield (MPa)": [298.7, 298.7, 298.7, 298.7]}) == []
def test_the_tolerance_itself_passes():
"""exactly 0.3 pt apart is not more than 0.3 pt"""
assert misaligned({"Time (s)": [100.0, 100.3]}) == []
def test_a_wider_tolerance():
assert misaligned({"Time (s)": [100.0, 102.5]}, tolerance=3.0) == []
assert misaligned({"Time (s)": [100.0, 102.5]}, tolerance=2.0) == ["Time (s)"]
def test_one_number_cannot_be_out_of_line():
assert misaligned({"Total": [250.0], "Empty": []}) == [] A hint
For each heading and its list, the spread is max(xs) - min(xs). Keep the heading when the list has at least two values and the spread is greater than the tolerance, then return sorted(...) of what you kept.
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 all five ideas and come back to the one you got wrong:
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 do tables set with booktabs have no vertical rules?
Because the rows and columns are already shown by alignment and space, and lines between them add clutter without
adding information. The booktabs manual says it directly, “Never, ever use vertical rules”, and adds “Never use double
rules”; if two halves of a table seem to need a line between them, it suggests two tables. Instead booktabs draws three
horizontal rules of two weights, 0.08 em at the top and bottom and 0.05 em under the header, which measured 0.80 and
0.50 pt at 10 pt type, and it adds space around each rule so that text never touches a line. In a recorded build the
header stood 3.7 pt below the top rule against 0.9 pt with \hline. Its rules are not even guaranteed to meet vertical
lines, so mixing the two breaks the corners.
Two harder questions follow, about aligning a report’s money columns and about a thesis table that must be tagged. 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.
-
Mid to senior
A quarterly report has LaTeX and Typst editions whose tables list amounts such as 12,450.50, 980.25 and -1,200.4, typed with commas and set flush right. Readers find the columns hard to compare. How do you align them on the decimal point in both editions, and what do you check afterwards?
-
Mid to senior
A university now asks for tagged, accessible thesis PDFs. Your LaTeX thesis has 25 tables built with booktabs, siunitx, multirow and threeparttable. What do you change before you submit, and how do you show that the tables are tagged correctly?
Key takeaways
- booktabs replaces a box round every cell with three horizontal rules, 0.80 pt, 0.50 pt and 0.80 pt at 10 pt type,
and more space around them; Typst draws the same with
stroke: noneand threetable.hlinecalls. - Flush-right numbers left their decimal points 5.0 pt apart; siunitx’s
Scolumns withtable-formatand zero’sformat-tablelined them up exactly, and anScolumn’s headings belong in braces. - An
lcolumn never wraps and pushed the table 124.25 pt into the margin; tabularx’sXand Typst’s1frcolumns wrap long text to fill the line width, and a tabularx in running text gains the paragraph indent. \multicolumnandcolspanmerge columns,\multirowandrowspanmerge rows, and\cmidrule(lr)ortable.hline(start:, end:)rules a grouped heading.- multirow, nicematrix and tabularray are marked currently incompatible with LaTeX’s tagging: the tagged LaTeX build recorded no row span for its five merged cells, while Typst recorded all five.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which rules does booktabs draw instead of boxes?
\toprule, \midrule and \bottomrule and no vertical lines: 0.08 em at the top and bottom and 0.05 em under the header, with space around each rule so text never touches it.
-
What are the booktabs manual's two firm rules?
Never, ever use vertical rules, and never use double rules. If a table seems to need a dividing line, make two tables.
-
How do you draw a booktabs-style table in Typst?
stroke: none on the table, then three table.hline calls, thicker at the top and the bottom than under the header.
-
Why don't flush-right numbers line up on their decimal points?
They line up their last digits, so 528.25 and 531.7 end together with their points a digit apart. siunitx's S column or zero's format-table aligns the points.
-
Why did an S column stop with Invalid number 'e'?
Its heading was read as a number. Protect any text in an S column with braces, as {Yield (MPa)}.
-
How do you make a column of notes wrap to the text width?
tabularx's X column, which becomes p{width} once the table's width is worked out, or a 1fr column in Typst. An l column never wraps.
-
How do you merge cells in each system?
LaTeX: \multicolumn{2}{c}{...} across columns and multirow's \multirow{2}{*}{...} down rows, leaving the covered cell empty. Typst: table.cell(colspan: 2) and table.cell(rowspan: 2).
-
Which table packages are marked incompatible with LaTeX's tagging?
multirow, nicematrix and tabularray. The tagged LaTeX build recorded no row span for its five merged cells, while Typst recorded all five.
Terms in this lesson
- decimal alignment
- Setting a column of numbers so that their decimal points stand one under the other, whatever the number of figures before or after them. siunitx's S column does it in LaTeX and the zero package in Typst.
- fraction (fr)
- A Typst size such as 1fr or 2fr: a share of the room that is left after the fixed and auto columns are set. Columns of 1fr and 2fr split that room one to two.
- spanning cell
- A table cell that covers more than one column or row, such as a heading over two columns. LaTeX writes it with \multicolumn or \multirow, Typst with table.cell(colspan: 2) or rowspan.
References
- booktabs (CTAN)
- Publication quality tables in LaTeX (the booktabs manual) (CTAN)
- siunitx: A comprehensive (SI) units package (CTAN)
- The tabularx package (The LaTeX Project (CTAN))
- The multirow, bigstrut and bigdelim packages (CTAN)
- makecell (CTAN)
- Tagging status of LaTeX packages and classes (The LaTeX Project (Tagged PDF project))
- zero on Typst Universe (Typst Universe)
- Typst reference: table (Typst)
- Typst guide: Tables (Typst)
- Typst reference: page (Typst)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress