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

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.

  • 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, mupdf 1.28.3 and qpdf 12.4.1
  • 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

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

One table with a rule round every cell, and with three rules

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:

Every rule of the three PDFs Python · rules_session.py
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 tensile table twice: first boxed by thin lines round every cell, then with only three horizontal rules.A rule round every cell: grid.texGradeSpecimenYield (MPa)Tensile (MPa)Elongation (%)Fe 415A1432.5528.2518.4Fe 415A2441.0531.717.95Fe 500B1521.75612.415.2Fe 500B2508.3604.1516.05booktabs rules: booktabs.texGradeSpecimenYield (MPa)Tensile (MPa)Elongation (%)Fe 415A1432.5528.2518.4Fe 415A2441.0531.717.95Fe 500B1521.75612.415.2Fe 500B2508.3604.1516.05

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

  1. Question 1 of 2 In booktabs.tex, at 10 pt type, how thick were the top rule and the rule under the header?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0.80 pt and 0.50 pt

    booktabs draws the top and bottom rules heavier than the middle one, and adds space around each, so rules separate the header and the body without boxing any cell.

  2. Question 2 of 2 The two halves of a table seem to need a line between them. What does the booktabs manual suggest?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Set them as two tables

    Space and alignment show the columns; a table that seems to need a vertical line is often two tables.

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:

The numbers aligned by siunitx and by zero

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),
)
How far apart the decimal points stand Python · decimal_session.py
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:

Interactive · stage by stage How S[table-format=2.2] lines up 18.4 and 17.95 Step through one S column of siunitx.tex, from the format to the decimal points the session measured.
How S[table-format=2.2] lines up 18.4 and 17.95, step 6 of 6. Showing A model from 2.2, Cells read as numbers, Split at the point, Fill the model, One line of points. In focus: One line of points.braced text: no numberA model from 2.22 digits, a point, 2 digitsCells read as numbers18.4, 17.95, 15.2, 16.05Split at the point18 | 4 and 17 | 95Fill the modelwhole part right, rest leftOne line of pointsmeasured 0.0 pt apart

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

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

  1. One question Set flush right in booktabs.tex, the decimal points of each column stood 5.0 pt apart. Why?

    Choose one answer.

    Show the answer: One question

    Answer: Some numbers have one figure after the point and others two, and flush right lines up the last digits

    Right alignment suits whole numbers and numbers with the same number of decimals; otherwise align on the decimal marker.

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:

A column of notes in five versions

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),
)
What each version did with the notes Python · wrap_session.py
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

  1. One question Which LaTeX version wrapped the notes and came out exactly as wide as the text?

    Choose one answer.

    Show the answer: One question

    Answer: \begin{tabularx}{\linewidth}{l X} inside a table float with \centering

    An X column becomes a p column whose width makes the whole table the width you ask for.

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.

The finished table with merged cells

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>
The finished table in both systems Python · merge_session.py
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 tensile table in both systems: a heading over two columns, grades over two rows, numbers lined up on their points.pdfLaTeX, from results.texTable 1: Tensile tests of two bar gradesStrength (MPa)GradeSpecimenElongation (%)YieldTensileA1432.5528.2518.4Fe 415A2441.0531.717.95B1521.75612.415.2Fe 500B2508.3604.1516.05Invented values: two 12 mm bars of each grade.Typst, from results.typTable 1: Tensile tests of two bar gradesStrength (MPa)GradeSpecimenElongation (%)YieldTensileA1432.5528.2518.4Fe 415A2441.0531.717.95B1521.75612.415.2Fe 500B2508.3604.1516.05Invented values: two 12 mm bars of each grade.

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

  1. One question In Typst, which line draws a rule under the third and fourth columns only?

    Choose one answer.

    Show the answer: One question

    Answer: table.hline(start: 2, end: 4)

    results.typ draws its short rule with start: 2 and end: 4; it measured 82.9 pt, untrimmed.

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:

The finished table as a tagged LaTeX PDF

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 ""))
Spanning cells in the tags of both PDFs Python · tags_session.py
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:

Interactive · answer and compare A label over two rows, in a tagged PDF or not Answer the three questions about your table; the ways that do not fit are marked, with the reason.
Must the PDF be tagged, for example for PDF/UA or an accessible thesis?
Does your style ask for each label to be set once over its group?
Which system do you write in?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to set Fe 415 over its two specimens, and what the measured PDFs and the status list show
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

  1. One question What did the tags of results-tagged.tex record about its five \multirow cells?

    Choose one answer.

    Show the answer: One question

    Answer: No row span at all, so each is a cell of a single row

    multirow is marked currently-incompatible with LaTeX's tagging code, and this is what that looks like in a 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.

  1. Restyle a boxed tabular with booktabs rules To do
  2. 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 \hline it has: the first such place gets \toprule, the second \midrule and the last \bottomrule, each on a line of its own;
  • every other \hline is 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.

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.

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.

  1. Question 1 of 14 Which three booktabs commands rule a simple table?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: \toprule, \midrule and \bottomrule

    Each goes where \hline would go, after the \\ of the row above, and \toprule right after the column specification.

  2. Question 2 of 14 How does booktabs.typ draw the same three rules in Typst?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: stroke: none, then table.hline at the top, after the header and at the end, with 0.08em, 0.05em and 0.08em

    Removing the default stroke and adding exactly the lines you want is the Typst form of booktabs.

  3. Question 3 of 14 A column shows 12 kg, 7.5 kg and 0.25 kg, one in each cell. What do the booktabs guidelines change?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: Put the unit in the heading, Mass (kg), and only the numbers in the cells

    With the units in the heading the cells hold bare numbers, which an S column can then align.

  4. Question 4 of 14 What does S[table-format=3.2] reserve for each number of the column?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: Room for three figures before the decimal marker and two after it

    For elongations such as 17.95, siunitx.tex uses 2.2; for strengths such as 528.25, 3.2.

  5. Question 5 of 14 In #show table: format-table(none, none, auto, auto, auto), what do the two nones do?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: They leave the first two columns, Grade and Specimen, as they are

    There is one entry per column, as in siunitx's column specification.

  6. Question 6 of 14 An S column has the heading Mean speed and the build stops with an invalid-number error. What do you change?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: Wrap the heading in braces, {Mean speed}

    nobrace.tex stopped with Invalid number 'e' for Yield (MPa); {Yield (MPa)} built.

  7. Question 7 of 14 What does tabularx turn an X column into?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: A p column whose width makes the table as wide as asked

    Several X columns share the width that is left equally; each becomes a paragraph column of that width.

  8. Question 8 of 14 A tabularx of width \linewidth, placed right after a paragraph of text, is reported 15.0 pt too wide. Why?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: The table starts a new paragraph, and its 15 pt indent comes before the table

    A paragraph indent is easy to miss because the table looks like a block, but TeX sets it in a line like a word.

  9. Question 9 of 14 A Typst table 450 pt wide has columns: (90pt, 1fr, 2fr) and no gutter. How wide is its third column, in points?

    Type a number.

    A hint

    First take away the fixed column, then share what is left in the ratio of the fractions.

    Show the answer: Question 9 of 14

    Answer: 240 pt

    450 − 90 = 360 pt are left, shared 1 to 2: 120 pt and 240 pt. The tables guide says fr columns share the width that the fixed and auto columns leave, a 2fr column twice as much as a 1fr column.

  10. Question 10 of 14 Which LaTeX command sets Strength (MPa) centred over two columns?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: \multicolumn{2}{c}{Strength (MPa)}

    In Typst the same cell is table.cell(colspan: 2)[Strength (MPa)].

  11. Question 11 of 14 In the row under \multirow{2}{*}{Fe 415}, what do you type in the first cell?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: Nothing; the row starts with &

    In Typst the merged cell takes the place itself, so the next row has one cell fewer instead of an empty one.

  12. Question 12 of 14 The rule under Strength (MPa) measured 71.8 pt in LaTeX and 82.9 pt in Typst. Why the difference?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: \cmidrule(lr) trims both ends of the rule, and Typst's hline is not trimmed

    A trimmed rule shows that it belongs to the heading above it, not to the whole table.

  13. Question 13 of 14 Where do you check whether a LaTeX package works with tagged PDF?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: The LaTeX Project's tagging status list

    Then check the PDF itself, as spans.py did, because a status can change with each release.

  14. Question 14 of 14 A thesis must be tagged, and its LaTeX table sets each grade over two rows with \multirow. What do you do?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Repeat the grade in each of its rows, then check the built PDF's tags

    booktabs advises repeating a value rather than using ditto marks, so a repeated label is also good table style.

LaTeX Table Generator Paste a range from a spreadsheet and choose booktabs rules and S columns for it; leave merged rows out if the PDF must be tagged.

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.

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

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

    Model answer, Pro. First step and follow-ups

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: none and three table.hline calls.
  • Flush-right numbers left their decimal points 5.0 pt apart; siunitx’s S columns with table-format and zero’s format-table lined them up exactly, and an S column’s headings belong in braces.
  • An l column never wraps and pushed the table 124.25 pt into the margin; tabularx’s X and Typst’s 1fr columns wrap long text to fill the line width, and a tabularx in running text gains the paragraph indent.
  • \multicolumn and colspan merge columns, \multirow and rowspan merge rows, and \cmidrule(lr) or table.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.

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

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

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

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

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

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

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

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

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.