LaTeX & Typst (papers, theses, resumes and slides) Module 5 – Figures, floats and drawing
Drawing basics: TikZ and CeTZ
Draw lines, shapes, nodes and arrows in TikZ and CeTZ, place nodes from each other's anchors, crop figures to their own files and describe them.
What you will learn
- Draw lines, shapes, nodes and arrows from absolute, relative and polar coordinates in TikZ and CeTZ
- Arrange nodes from each other's anchors with TikZ's positioning library and CeTZ's relative coordinates
- Build stand-alone figure files that crop to their drawing, and export them as PDF or SVG
- Add alternative text to a drawing in a tagged PDF, and check the tags instead of trusting a validator
Before you start
What you’ll learn · Before you start
What you will learn
- Draw lines, shapes, nodes and arrows from absolute, relative and polar coordinates in TikZ and CeTZ
- Arrange nodes from each other's anchors with TikZ's positioning library and CeTZ's relative coordinates
- Build stand-alone figure files that crop to their drawing, and export them as PDF or SVG
- Add alternative text to a drawing in a tagged PDF, and check the tags instead of trusting a validator
Before you start
On this page
A flowchart of five shapes, drawn by hand in a drawing program, has to be redrawn every time a label changes; written as TikZ or CeTZ code, the same chart is rebuilt in 1.15 s or 0.22 s on the recording machine, and its labels stay text that can be searched and copied. The catch is that both languages must be told where every part goes.
Below you will draw points with absolute, relative and polar coordinates, build the flowchart from nodes, styles and arrows, place each node from another’s anchors so that a longer label cannot knock the chart out of shape, keep the drawing in a file of its own that crops to its content, and give it alternative text, which no check asked for.
A drawing is points joined by paths
TikZ and CeTZ both draw on a grid of centimetres: in TikZ, (2,1) is 2 cm to the right of the origin and 1 cm up,
and CeTZ’s canvas uses the same unit unless told otherwise. A path joins points: \draw (0,0) -- (2,1); in TikZ,
line((0, 0), (2, 1)) in CeTZ. A point can be written in three ways, and coords.tex uses all three, with a small
macro that writes where each named point landed:
coords/coords.tex
% coords.tex: three points on a 1 cm grid, written as an absolute, a relative and a polar coordinate. \report writes
% where TikZ put a named coordinate to the terminal, in centimetres from the origin.
\documentclass[border=4pt]{standalone}
\usepackage{tikz}
\newcommand\report[1]{\path (#1); \pgfgetlastxy{\px}{\py}%
\typeout{#1 is at (\fpeval{round(\px / 1cm, 2)}, \fpeval{round(\py / 1cm, 2)}) cm}}
\begin{document}
\begin{tikzpicture}
\draw[help lines] (0,0) grid (4,3);
\draw[->] (0,0) -- (4.4,0) node[right] {$x$};
\draw[->] (0,0) -- (0,3.4) node[above] {$y$};
\coordinate (A) at (2,1); % absolute: 2 cm right of the origin, 1 cm up
\draw[thick] (A) -- ++(1,1.5) coordinate (B); % relative: from A, 1 cm right and 1.5 cm up
\coordinate (C) at (60:2); % polar: 2 cm from the origin, at 60 degrees
\draw[dashed] (0,0) -- (C);
\foreach \p/\where in {A/below right, B/above right, C/above left}
\fill (\p) circle (1.5pt) node[\where] {\p};
\report{A}\report{B}\report{C}
\end{tikzpicture}
\end{document} coords/coords.typ
// coords.typ: the same three points in CeTZ: absolute, relative to the previous point, and polar.
#import "@preview/cetz:0.5.2"
#set page(width: auto, height: auto, margin: 4pt)
#cetz.canvas({
import cetz.draw: *
grid((0, 0), (4, 3), stroke: gray + 0.4pt)
line((0, 0), (4.4, 0), mark: (end: "stealth"))
content((4.4, 0), $x$, anchor: "west", padding: 3pt)
line((0, 0), (0, 3.4), mark: (end: "stealth"))
content((0, 3.4), $y$, anchor: "south", padding: 3pt)
// Absolute: 2 cm right of the origin, 1 cm up. Relative: from that point, 1 cm right and 1.5 cm up.
line((2, 1), (rel: (1, 1.5)), stroke: 1.2pt, name: "ab")
// Polar: 2 cm from the origin, at 60 degrees.
line((0, 0), (60deg, 2), stroke: (dash: "dashed"), name: "oc")
for (label, at, side) in (("A", "ab.start", "north-west"), ("B", "ab.end", "south-west"), ("C", "oc.end", "south-east")) {
circle(at, radius: 0.053, fill: black)
content(at, label, anchor: side, padding: 3pt)
}
}) from session import files, packages, run
# The three points of coords.tex, which reports where TikZ put each one, and the same drawing in CeTZ. pdfinfo prints
# the size of each cropped page: the standalone class and Typst's automatic page size crop to the drawing.
files("coords/coords.tex", "coords/coords.typ")
packages("cetz:0.5.2", "oxifmt:1.0.0")
run("""
pdflatex -interaction=nonstopmode -halt-on-error coords.tex | grep -E "is at|Warning|Output written"
pdfinfo coords.pdf | grep "Page size"
typst compile coords.typ coords-cetz.pdf
pdfinfo coords-cetz.pdf | grep "Page size"
""") Output
$ pdflatex -interaction=nonstopmode -halt-on-error coords.tex | grep -E "is at|Warning|Output written" A is at (2, 1) cm B is at (3, 2.5) cm C is at (1, 1.73) cm Output written on coords.pdf (1 page, 19527 bytes). $ pdfinfo coords.pdf | grep "Page size" Page size: 151.17 x 122.88 pts $ typst compile coords.typ coords-cetz.pdf $ pdfinfo coords-cetz.pdf | grep "Page size" Page size: 150.865 x 126.963 pts
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 coords_session.py
A is absolute, (2,1), and landed at (2, 1) cm. B is relative: in TikZ, ++(1,1.5) after A means 1 cm further
right and 1.5 cm further up, which gave (3, 2.5); CeTZ writes (rel: (1, 1.5)), relative to the point before. C is a
polar coordinate: A point given by an angle and a distance from the origin instead of by x and y, such as (60:2) in TikZ or (60deg, 2) in CeTZ: 2 cm out at 60 degrees, which is 1 cm across and 1.73 cm up., an angle and a distance from the origin: (60:2) in TikZ,
(60deg, 2) in CeTZ, which is 2 × cos 60° = 1 cm across and 2 × sin 60° = 1.73 cm up. Here is the drawing:
The three points of coords.tex, as pdfLaTeX drew them
Text description of the diagram
A grid of 1 cm squares, four across and three up, with an x axis along the bottom and a y axis up the left side, both ending in arrows.
- A, a dot 2 cm to the right of the origin and 1 cm up, written as the absolute coordinate (2,1).
- B, a dot 1 cm to the right of A and 1.5 cm above it, at (3, 2.5), written as ++(1,1.5) after A. A thick line joins A to B.
- C, a dot 2 cm from the origin at 60 degrees, at (1, 1.73), written as the polar coordinate (60:2). A dashed line runs to it from the origin.
A relative point that moves the pen
In TikZ, ++(1,1.5) moves the current point, so a second ++(1,0) starts from B; +(1,1.5) gives the same point
without moving on, and every later + still starts from A. Chains of ++ are easy to miscount: name the points
that matter with coordinate, as coords.tex names A, B and C.
Checkpoint
Check: Absolute, relative and polar coordinates
Nodes carry the text, and a style names their look once
A flowchart is made of node: In TikZ, a piece of text set in a shape at a point of a drawing, with a name that lines and other nodes refer to. CeTZ's content element plays the same part.: in TikZ, \node[draw] (write) {Write the source}; sets the
text in a shape, here a box drawn round it, and names it write, so a line can later end on its border. A style gives
a look a name: box/.style={draw, rounded corners, minimum width=28mm} once, then \node[box] for every box. The
arrows.meta library adds arrow tips such as Stealth, and shapes.geometric the diamond of the decision node.
CeTZ’s content plays the part of a node: content((0, 0), [Write the source], name: "write"), with
frame: "rect" for the box, and set-style gives every content element and every line the same look. CeTZ has no
diamond frame, so flow-cetz.typ writes a small function that draws a closed line round the question and groups the
two under one name. Both files are in the next section; here is what they drew:
The build loop drawn twice, by TikZ and by CeTZ
Text description of the diagram
Two drawings of one flowchart, the first made by flow-tikz.tex, the second by flow-cetz.typ. Each has five nodes:
- "Write the source" at the top, with an arrow down to "Compile".
- An arrow from "Compile" down to a diamond that asks "Errors?".
- From the diamond, an arrow labelled "yes" runs right to "Fix the first error", and a line runs from that node up and back left to "Compile".
- From the diamond, an arrow labelled "no" runs down to "Read the PDF".
The TikZ boxes have rounded corners and thin lines; the CeTZ boxes have square corners, thicker lines and a larger diamond. The words are the same.
The two charts say the same thing in the same words. The differences are choices: TikZ’s boxes have rounded corners because the style says so, and CeTZ draws its lines thicker unless its style says otherwise.
One style per kind of thing
Give every kind of node a style, box and test here, and keep sizes such as minimum width in the style. A
change of look is then one line, and every box of the chart stays the same size.
Checkpoint
Check: Nodes, styles and arrows
Place nodes from each other’s anchors, not at fixed coordinates
Every node has anchor: A named point on the border of a node or element, such as north, south, east, west or a corner, from which lines are drawn and other nodes are placed., named points on its border: north, south, east, west
and the corners. TikZ’s positioning library places a node from another’s anchor: below=of write puts the new node’s
north anchor one node distance below write.south. flow-tikz.tex sets the distances to 6 mm down and 12 mm across
and writes two gaps to the terminal; flow-long.tex gives the box on the right a longer label; flow-abs.tex places
every node at a coordinate of its own instead:
flow/flow-tikz.tex
% flow-tikz.tex: the build loop as a flowchart, each node placed relative to another with the positioning library.
\documentclass[border=4pt]{standalone}
\usepackage{tikz}
\usetikzlibrary{positioning, arrows.meta, shapes.geometric}
% \hgap and \vgap write the distance between two anchors to the terminal, in millimetres, across and down.
\newcommand\hgap[2]{\path (#1); \pgfgetlastxy{\ax}{\ay}\path (#2); \pgfgetlastxy{\bx}{\by}%
\typeout{#1 to #2: \fpeval{round((\bx - \ax) / 1mm, 1)} mm across}}
\newcommand\vgap[2]{\path (#1); \pgfgetlastxy{\ax}{\ay}\path (#2); \pgfgetlastxy{\bx}{\by}%
\typeout{#1 to #2: \fpeval{round((\ay - \by) / 1mm, 1)} mm down}}
\begin{document}
\begin{tikzpicture}[
node distance=6mm and 12mm,
>={Stealth},
box/.style={draw, rounded corners, minimum width=28mm, minimum height=7mm},
test/.style={draw, diamond, aspect=2.2, inner sep=1pt}]
\node[box] (write) {Write the source};
\node[box, below=of write] (compile) {Compile};
\node[test, below=of compile] (errors) {Errors?};
\node[box, right=of errors] (fix) {Fix the first error};
\node[box, below=of errors] (pdf) {Read the PDF};
\draw[->] (write) -- (compile);
\draw[->] (compile) -- (errors);
\draw[->] (errors) -- node[above] {yes} (fix);
\draw[->] (fix) |- (compile);
\draw[->] (errors) -- node[right] {no} (pdf);
\vgap{write.south}{compile.north}
\hgap{errors.east}{fix.west}
\end{tikzpicture}
\end{document} flow/flow-long.tex
% flow-long.tex: flow-tikz.tex with a longer label in the node on the right.
\documentclass[border=4pt]{standalone}
\usepackage{tikz}
\usetikzlibrary{positioning, arrows.meta, shapes.geometric}
% \hgap and \vgap write the distance between two anchors to the terminal, in millimetres, across and down.
\newcommand\hgap[2]{\path (#1); \pgfgetlastxy{\ax}{\ay}\path (#2); \pgfgetlastxy{\bx}{\by}%
\typeout{#1 to #2: \fpeval{round((\bx - \ax) / 1mm, 1)} mm across}}
\newcommand\vgap[2]{\path (#1); \pgfgetlastxy{\ax}{\ay}\path (#2); \pgfgetlastxy{\bx}{\by}%
\typeout{#1 to #2: \fpeval{round((\ay - \by) / 1mm, 1)} mm down}}
\begin{document}
\begin{tikzpicture}[
node distance=6mm and 12mm,
>={Stealth},
box/.style={draw, rounded corners, minimum width=28mm, minimum height=7mm},
test/.style={draw, diamond, aspect=2.2, inner sep=1pt}]
\node[box] (write) {Write the source};
\node[box, below=of write] (compile) {Compile};
\node[test, below=of compile] (errors) {Errors?};
\node[box, right=of errors] (fix) {Fix the first error in the log};
\node[box, below=of errors] (pdf) {Read the PDF};
\draw[->] (write) -- (compile);
\draw[->] (compile) -- (errors);
\draw[->] (errors) -- node[above] {yes} (fix);
\draw[->] (fix) |- (compile);
\draw[->] (errors) -- node[right] {no} (pdf);
\vgap{write.south}{compile.north}
\hgap{errors.east}{fix.west}
\end{tikzpicture}
\end{document} flow/flow-abs.tex
% flow-abs.tex: the same flowchart with every node placed at a coordinate of its own, and the longer label on the right.
\documentclass[border=4pt]{standalone}
\usepackage{tikz}
\usetikzlibrary{arrows.meta, shapes.geometric}
% \hgap and \vgap write the distance between two anchors to the terminal, in millimetres, across and down.
\newcommand\hgap[2]{\path (#1); \pgfgetlastxy{\ax}{\ay}\path (#2); \pgfgetlastxy{\bx}{\by}%
\typeout{#1 to #2: \fpeval{round((\bx - \ax) / 1mm, 1)} mm across}}
\newcommand\vgap[2]{\path (#1); \pgfgetlastxy{\ax}{\ay}\path (#2); \pgfgetlastxy{\bx}{\by}%
\typeout{#1 to #2: \fpeval{round((\ay - \by) / 1mm, 1)} mm down}}
\begin{document}
\begin{tikzpicture}[
>={Stealth},
box/.style={draw, rounded corners, minimum width=28mm, minimum height=7mm},
test/.style={draw, diamond, aspect=2.2, inner sep=1pt}]
\node[box] (write) at (0, 0) {Write the source};
\node[box] (compile) at (0, -1.3) {Compile};
\node[test] (errors) at (0, -2.7) {Errors?};
\node[box] (fix) at (4.2, -2.7) {Fix the first error in the log};
\node[box] (pdf) at (0, -4.1) {Read the PDF};
\draw[->] (write) -- (compile);
\draw[->] (compile) -- (errors);
\draw[->] (errors) -- node[above] {yes} (fix);
\draw[->] (fix) |- (compile);
\draw[->] (errors) -- node[right] {no} (pdf);
\vgap{write.south}{compile.north}
\hgap{errors.east}{fix.west}
\end{tikzpicture}
\end{document} flow/flow-cetz.typ
// flow-cetz.typ: the build loop in CeTZ. Each node after the first is placed from an anchor of another node, as TikZ's
// positioning library does: (rel: (0, -0.6), to: "write.south") is 6 mm below the bottom of "write", and
// anchor: "north" puts the top of the new node there.
#import "@preview/cetz:0.5.2"
#set page(width: auto, height: auto, margin: 4pt)
#set text(size: 10pt)
#cetz.canvas({
import cetz.draw: *
set-style(content: (frame: "rect", padding: (x: 8pt, y: 5pt)), mark: (end: "stealth", fill: black, scale: 0.6))
// The question in a diamond: the text, then a closed line through four points around it, grouped under one name
// so that the group's anchors (north, east, south, west) are the diamond's corners.
let diamond(below, name, body) = group(name: name, {
content((rel: (0, -0.95), to: below), body, frame: none, name: "text")
line(
(rel: (-0.45, 0), to: "text.west"), (rel: (0, 0.35), to: "text.north"),
(rel: (0.45, 0), to: "text.east"), (rel: (0, -0.35), to: "text.south"),
close: true, mark: none,
)
})
content((0, 0), [Write the source], name: "write")
content((rel: (0, -0.6), to: "write.south"), [Compile], anchor: "north", name: "compile")
diamond("compile.south", "errors", [Errors?])
content((rel: (1.2, 0), to: "errors.east"), [Fix the first error], anchor: "west", name: "fix")
content((rel: (0, -0.6), to: "errors.south"), [Read the PDF], anchor: "north", name: "pdf")
line("write", "compile")
line("compile", "errors")
line("errors", "fix", name: "yes")
content("yes.mid", [yes], anchor: "south", frame: none, padding: 3pt)
line("fix.north", ("fix.north", "|-", "compile.east"), "compile.east")
line("errors", "pdf", name: "no")
content("no.mid", [no], anchor: "west", frame: none, padding: 3pt)
}) from session import files, packages, run
# The flowchart three ways in TikZ: placed with the positioning library (flow-tikz.tex), the same with a longer label
# on the right (flow-long.tex), and every node at a coordinate of its own with that longer label (flow-abs.tex). Each
# writes two gaps between anchors to the terminal. flow-cetz.typ is the same chart in CeTZ; pdfinfo prints the size of
# each cropped page.
files("flow/flow-tikz.tex", "flow/flow-long.tex", "flow/flow-abs.tex", "flow/flow-cetz.typ")
packages("cetz:0.5.2", "oxifmt:1.0.0")
run("""
pdflatex -interaction=nonstopmode -halt-on-error flow-tikz.tex | grep -E " mm |Warning|Output written"
pdfinfo flow-tikz.pdf | grep "Page size"
pdflatex -interaction=nonstopmode -halt-on-error flow-long.tex | grep -E " mm |Warning|Output written"
pdfinfo flow-long.pdf | grep "Page size"
pdflatex -interaction=nonstopmode -halt-on-error flow-abs.tex | grep -E " mm |Warning|Output written"
pdfinfo flow-abs.pdf | grep "Page size"
typst compile flow-cetz.typ
pdfinfo flow-cetz.pdf | grep "Page size"
pdftotext flow-cetz.pdf - | tr -s '\\n' ' '
""") Output
$ pdflatex -interaction=nonstopmode -halt-on-error flow-tikz.tex | grep -E " mm |Warning|Output written" write.south to compile.north: 6 mm down errors.east to fix.west: 12 mm across Output written on flow-tikz.pdf (1 page, 17698 bytes). $ pdfinfo flow-tikz.pdf | grep "Page size" Page size: 192.643 x 144.524 pts $ pdflatex -interaction=nonstopmode -halt-on-error flow-long.tex | grep -E " mm |Warning|Output written" write.south to compile.north: 6 mm down errors.east to fix.west: 12 mm across Output written on flow-long.pdf (1 page, 18040 bytes). $ pdfinfo flow-long.pdf | grep "Page size" Page size: 237.475 x 144.524 pts $ pdflatex -interaction=nonstopmode -halt-on-error flow-abs.tex | grep -E " mm |Warning|Output written" write.south to compile.north: 5.9 mm down errors.east to fix.west: 9.8 mm across Output written on flow-abs.pdf (1 page, 18028 bytes). $ pdfinfo flow-abs.pdf | grep "Page size" Page size: 231.188 x 144.432 pts $ typst compile flow-cetz.typ $ pdfinfo flow-cetz.pdf | grep "Page size" Page size: 205.592 x 139.546 pts $ pdftotext flow-cetz.pdf - | tr -s '\n' ' ' Write the source Compile Errors? no Read the PDF yes Fix the first error
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 flow_session.py
With positioning, both gaps measured exactly 6 mm and 12 mm, and stayed so when the label grew: the box kept its west anchor 12 mm from the diamond and grew to the right, and the page grew from 192.6 pt to 237.5 pt wide to hold it. Placed by its centre at a fixed point, the same box grew to both sides, and the gap fell to 9.8 mm; every longer label would shrink it further. Step through how the nodes are placed:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first node. The first node has no position of its own, so TikZ puts its centre at the origin. Every other node of the chart is placed from it, directly or through another node.
- An anchor of write. below=of write starts from the south anchor of the node write, the middle of its bottom edge. Anchors are named points on a node's border, such as north, south, east and west.
- The node distance. From that anchor, positioning moves down by the vertical node distance, which flow-tikz.tex sets to 6 mm, and puts the north anchor of the new node there. The session measured exactly 6 mm.
- The same rule again. The diamond is placed below=of compile in the same way, 6 mm under the bottom of Compile, whatever the size of either node.
- Across, from the east anchor. right=of errors starts from the east anchor of the diamond, its right corner, and moves right by the horizontal node distance, 12 mm, to the west anchor of the new box.
- A longer label. With a longer label the box grows to the right of its west anchor, so flow-long.tex still measured 12 mm. flow-abs.tex placed the same box by its centre at a fixed point, and the gap fell to 9.8 mm.
CeTZ has no positioning library, but its relative coordinates do the same work: (rel: (0, -0.6), to: "write.south")
is 6 mm below the bottom of write, and anchor: "north" puts the top of the new element there. flow-cetz.typ
places every node after the first that way, and the arrows end on the borders of the named elements by themselves.
Key idea
Place each node from another node’s anchor and a distance, never at a coordinate of its own: then labels and sizes can change and the gaps stay as written.
Checkpoint
Check: Placing nodes from anchors
A stand-alone file crops to the drawing and builds on its own
A figure that lives inside a 40-page document is rebuilt with the whole document, and is hard to reuse. Kept in a
stand-alone figure file: A figure kept in a file of its own that compiles to a PDF cropped to the drawing, such as a LaTeX file with the standalone class or a Typst file whose page size is automatic. A document then includes the PDF., it compiles alone, in about a second, and the document includes the
result. The standalone class does that for LaTeX: \documentclass[border=4pt]{standalone} makes a page exactly the
size of the drawing plus the border, 192.6 by 144.5 pt for the flowchart. In Typst,
#set page(width: auto, height: auto, margin: 4pt) does the same, as the page reference describes for an automatic
size. report.tex then includes the cropped PDF in a tagged report, with alternative text, and the session exports the
chart as SVG for a web page:
export/report.tex
% report.tex: a tagged report that includes the cropped flowchart PDF made from flow-tikz.tex, with alternative text.
% Build it with LuaLaTeX.
\DocumentMetadata{lang=en, tagging=on}
\documentclass{article}
\usepackage{graphicx}
\begin{document}
\section{How to build}
Figure~\ref{fig:loop} shows the loop of writing, compiling and fixing.
\begin{figure}[ht]
\centering
\includegraphics[alt={Flowchart: write the source, compile, and if there are errors fix the first one and compile
again; with no errors, read the PDF.}]{flow-tikz.pdf}
\caption{The build loop.}\label{fig:loop}
\end{figure}
\end{document} tags.py
"""Prints the structure tree that `pdfinfo -struct-text` reads from a tagged PDF, given on standard input, in short:
one line per tag, indented as in the tree, with the alternative text of a Figure in square brackets and the text of
the tag in quotes. The numbers poppler gives each tag, its attributes, its references to link annotations and its empty
text runs are left out.
pdfinfo -struct-text report.pdf | python3 tags.py
"""
import re
import sys
tags = [] # [depth, tag, alt, text]
# pdfinfo prints some titles as raw UTF-16 bytes; they are not text a reader hears, so they are dropped.
for raw in sys.stdin.buffer.read().decode("utf-8", errors="replace").splitlines():
body = raw.lstrip(" ")
depth = (len(raw) - len(body)) // 2
if not body or body.startswith("/") or body.startswith("Syntax") or body.startswith("Object "):
continue
if body.startswith('"'):
text = body.strip().strip('"').strip()
# A run of text belongs to the nearest tag above it that is less indented.
owner = next((t for t in reversed(tags) if t[0] < depth), None)
if text and owner:
owner[3] = (owner[3] + " " + text).strip()
continue
match = re.match(r"(\w+)(?: <[^>]*>)?(?: \[\"(.*)\"\])?", body)
if match:
tags.append([depth, match.group(1), (match.group(2) or "").strip(), ""])
if not tags:
print("no structure tree: the PDF is not tagged")
for depth, tag, alt, text in tags:
line = " " * depth + tag
if tag == "Figure":
line += f" [{alt}]" if alt else " (no alternative text)"
if text:
line += f' "{text}"'
print(line) from session import files, packages, run
# The cropped flowchart PDF of flow-tikz.tex included in a tagged report with alternative text (report.tex, LuaLaTeX),
# then the flowchart exported as SVG for a web page three ways: Typst's own SVG export, poppler's pdftocairo, and
# dvisvgm with and without fonts. For each SVG: its size in bytes, and how many <style> and <text> elements it has.
# The size of a LuaLaTeX PDF changes by a few bytes from one run to the next, so the page shows "(1 page)" instead.
SIZES = [(r"\(1 page, \d+ bytes\)", "(1 page)")]
files("flow/flow-tikz.tex", "flow/flow-cetz.typ", "export/report.tex", "tags.py")
packages("cetz:0.5.2", "oxifmt:1.0.0")
run("""
pdflatex -interaction=nonstopmode -halt-on-error flow-tikz.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error report.tex | grep -E "Alternative|Error|Output written"
pdfinfo -struct-text report.pdf | python3 tags.py | grep -E "^ *(Caption|Lbl|Figure)( |$)"
typst compile --format svg flow-cetz.typ flow-cetz.svg
pdftocairo -svg flow-tikz.pdf flow-cairo.svg
dvisvgm --pdf --no-fonts --output=flow-paths.svg flow-tikz.pdf 2> /dev/null
dvisvgm --pdf --font-format=woff2 --output=flow-fonts.svg flow-tikz.pdf 2> /dev/null
wc -c flow-cetz.svg flow-cairo.svg flow-paths.svg flow-fonts.svg
grep -c "<style" flow-cetz.svg flow-cairo.svg flow-paths.svg flow-fonts.svg
grep -o "<text" flow-cetz.svg flow-cairo.svg flow-paths.svg flow-fonts.svg | sort | uniq -c
""", mask=SIZES) Output
$ pdflatex -interaction=nonstopmode -halt-on-error flow-tikz.tex > /dev/null
$ lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
$ lualatex -interaction=nonstopmode -halt-on-error report.tex | grep -E "Alternative|Error|Output written"
Output written on report.pdf (1 page).
$ pdfinfo -struct-text report.pdf | python3 tags.py | grep -E "^ *(Caption|Lbl|Figure)( |$)"
Caption
Lbl "Figure 1:"
Figure [Flowchart: write the source, compile, and if there are errors fix the first one and compile again; with no errors, read the PDF.] "WritethesourceCompileErrors?FixthefirsterrorReadthePDFyesno"
$ typst compile --format svg flow-cetz.typ flow-cetz.svg
$ pdftocairo -svg flow-tikz.pdf flow-cairo.svg
$ dvisvgm --pdf --no-fonts --output=flow-paths.svg flow-tikz.pdf 2> /dev/null
$ dvisvgm --pdf --font-format=woff2 --output=flow-fonts.svg flow-tikz.pdf 2> /dev/null
$ wc -c flow-cetz.svg flow-cairo.svg flow-paths.svg flow-fonts.svg
31416 flow-cetz.svg
36447 flow-cairo.svg
26602 flow-paths.svg
8699 flow-fonts.svg
103164 total
$ grep -c "<style" flow-cetz.svg flow-cairo.svg flow-paths.svg flow-fonts.svg
flow-cetz.svg:0
flow-cairo.svg:0
flow-paths.svg:0
flow-fonts.svg:1
$ grep -o "<text" flow-cetz.svg flow-cairo.svg flow-paths.svg flow-fonts.svg | sort | uniq -c
7 flow-fonts.svg:<text
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 export_session.py
The report’s Figure tag holds the description, and the chart’s words are inside it as text. For the web, the four SVG
files differ in a way that matters: Typst’s export, pdftocairo’s and dvisvgm’s with --no-fonts all drew the letters
as outlines, 26,602 to 36,447 bytes, with no <style> element and no text; dvisvgm with embedded WOFF2 fonts made the
smallest file, 8,699 bytes, kept seven <text> elements, and needed a <style> element for its fonts. The dvisvgm
manual describes both: --no-fonts uses paths instead of fonts, and by default dvisvgm creates CSS styles to refer to
the fonts it embeds.
| SVG from | Bytes | Words as text | Style element |
|---|---|---|---|
typst compile --format svg |
31,416 | no, outlines | none |
pdftocairo -svg |
36,447 | no, outlines | none |
dvisvgm --pdf --no-fonts |
26,602 | no, outlines | none |
dvisvgm --pdf --font-format=woff2 |
8,699 | yes, 7 text elements | one |
Speed is the other gain. Built three times each, the TikZ chart took a median of 1.15 s with pdfLaTeX and the CeTZ chart 0.22 s with Typst, with the package already in Typst’s cache:
from session import files, packages, run
# How long one flowchart takes to build, as the median of three runs: pdfLaTeX with TikZ, and Typst with CeTZ once the
# package is in Typst's cache. The times depend on the computer and change from run to run.
files("flow/flow-tikz.tex", "flow/flow-cetz.typ", "timing.py")
packages("cetz:0.5.2", "oxifmt:1.0.0")
run("""
python3 timing.py 3 pdflatex -interaction=nonstopmode -halt-on-error flow-tikz.tex
python3 timing.py 3 typst compile flow-cetz.typ
""") Output
$ python3 timing.py 3 pdflatex -interaction=nonstopmode -halt-on-error flow-tikz.tex pdflatex: median of 3 runs 1.15 s $ python3 timing.py 3 typst compile flow-cetz.typ typst: median of 3 runs 0.22 s
This output changes from run to run: the times depend on the computer and change from run to run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 timing_session.py
The standalone class in a tagged PDF
The LaTeX Project’s tagging status list warns that with the standalone class, content is lost at PDF level because
it is boxed, and says that inputting such files with the standalone package is compatible. Including the cropped
PDF with \includegraphics and an alt key, as report.tex does, avoids the question.
Checkpoint
Check: Stand-alone figure files and SVG export
A drawing needs alternative text, and no check will ask for it
A drawing made of lines and text is not an image file, so the rules for images do not catch it. alt.tex puts the
same small drawing in a tagged PDF twice, once bare and once with the alt key that, the tagging status list says,
the tagging code defines for TikZ pictures. alt.typ does the same in Typst, once as a bare canvas and once inside a
figure with alt, then exports the file as PDF/UA-1, and veraPDF judges the result:
alt/alt.tex
% alt.tex: the same small drawing twice in a tagged PDF, first without alternative text, then with the alt key that
% the tagging code defines for tikzpicture. Build it with LuaLaTeX.
\DocumentMetadata{lang=en, tagging=on}
\documentclass{article}
\usepackage{tikz}
\usetikzlibrary{positioning, arrows.meta}
\begin{document}
\section{Build loop}
Text before.
\begin{tikzpicture}[>={Stealth}, node distance=8mm]
\node[draw] (w) {Write};
\node[draw, right=of w] (c) {Compile};
\draw[->] (w) -- (c);
\end{tikzpicture}
Text between.
\begin{tikzpicture}[>={Stealth}, node distance=8mm, alt={Flowchart: Write leads to Compile.}]
\node[draw] (w) {Write};
\node[draw, right=of w] (c) {Compile};
\draw[->] (w) -- (c);
\end{tikzpicture}
Text after.
\end{document} alt/alt.typ
// alt.typ: the same small drawing twice, first as a bare canvas, then in a figure with alternative text.
#import "@preview/cetz:0.5.2"
#set document(title: [Build loop])
#set text(lang: "en")
#let loop = cetz.canvas({
import cetz.draw: *
content((0, 0), [Write], name: "w", frame: "rect", padding: 4pt)
content((rel: (0.8, 0), to: "w.east"), [Compile], anchor: "west", name: "c", frame: "rect", padding: 4pt)
line("w", "c", mark: (end: "stealth", fill: black))
})
= Build loop
Text before.
#loop
Text between.
#figure(loop, alt: "Flowchart: Write leads to Compile.", caption: [The build loop.])
Text after. from session import files, packages, require, run
# Two copies of one small drawing in a tagged PDF, without and with alternative text: alt.tex (LuaLaTeX) and alt.typ.
# tags.py shortens the structure tree that pdfinfo prints. Last, alt.typ exported as PDF/UA-1, and veraPDF's verdict
# on that file.
require("verapdf")
# The size of a LuaLaTeX PDF changes by a few bytes from one run to the next, so the page shows "(1 page)" instead.
SIZES = [(r"\(1 page, \d+ bytes\)", "(1 page)")]
files("alt/alt.tex", "alt/alt.typ", "tags.py")
packages("cetz:0.5.2", "oxifmt:1.0.0")
run("""
lualatex -interaction=nonstopmode -halt-on-error alt.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error alt.tex | grep -E "Alternative|Error|Output written"
pdfinfo -struct-text alt.pdf | python3 tags.py
typst compile alt.typ alt-typst.pdf
pdfinfo -struct-text alt-typst.pdf | python3 tags.py
typst compile --pdf-standard ua-1 alt.typ alt-ua.pdf
verapdf --flavour ua1 --format text alt-ua.pdf 2> /dev/null
""", mask=SIZES) Output
$ lualatex -interaction=nonstopmode -halt-on-error alt.tex > /dev/null
$ lualatex -interaction=nonstopmode -halt-on-error alt.tex | grep -E "Alternative|Error|Output written"
Output written on alt.pdf (1 page).
$ pdfinfo -struct-text alt.pdf | python3 tags.py
Document
Sect
H1 "Build loop"
Span "1"
Part
P "Text before. Write Compile Text between. Text after."
Figure [Flowchart: Write leads to Compile.] "WriteCompile"
$ typst compile alt.typ alt-typst.pdf
$ pdfinfo -struct-text alt-typst.pdf | python3 tags.py
Document
H1 "Build loop"
P "Text before."
Span "Write Compile"
P "Text between."
Div
Caption
Span "Figure 1: The build loop."
Figure [Flowchart: Write leads to Compile. ] "Write Compile"
P "Text after."
$ typst compile --pdf-standard ua-1 alt.typ alt-ua.pdf
$ verapdf --flavour ua1 --format text alt-ua.pdf 2> /dev/null
PASS ~/demo/alt-ua.pdf ua1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 alt_session.py
Without the key, LaTeX’s tags put the two labels into the paragraph, “Text before. Write Compile Text between.”, so a listener hears two words with no arrow between them; with it, a Figure tag holds “Flowchart: Write leads to Compile.” Typst did the same: the bare canvas became a Span of “Write Compile”, the figure a Figure tag with the description. And the PDF/UA-1 export succeeded, and veraPDF printed PASS, although the first drawing has no description. Typst’s accessibility guide explains why: shapes become artifacts on their own, while their text stays readable, and a drawing whose shapes mean something must go in a figure with alternative text.
Trusting the validator
Wrong: export with --pdf-standard ua-1, see no error and stop. The check refused images without text in the
lesson on images, but it passed this drawing. Right: give every drawing alt (a figure’s alt in Typst, the
picture’s alt key in tagged LaTeX), then read the structure tree, as tags.py does.
Checkpoint
Check: Alternative text for drawings
Practise: generate a chain of blocks in both systems
Block diagrams often come from data: a list of stages, a pipeline, the steps of a method. The lab writes the drawing
code for a row of boxes from a list of labels, first in TikZ with right=of, then in CeTZ with relative coordinates
and anchors, escaping the characters each language treats as markup. Each step has sample tests that run in the
browser:
Code lab · 2 steps
Generate a chain of blocks
From a list of labels, write the TikZ and then the CeTZ code of a row of boxes, each placed from the one before and joined by arrows.
- Write a TikZ chain of blocks placed from each other To do
- Write the same chain in CeTZ, from anchors To do
Exercise · Medium · Python
Write a TikZ chain of blocks placed from each other
A block diagram from a list of names should be written once and drawn by TikZ, not placed by hand. Write tikz_chain(labels, distance) in chain.py. It returns the TikZ code of a row of boxes, as one string of lines joined by \n, exactly in this shape for tikz_chain(["Sensor", "Logger"], 10):
\begin{tikzpicture}[node distance=10mm, box/.style={draw, rounded corners}]
\node[box] (n1) {Sensor};
\node[box, right=of n1] (n2) {Logger};
\draw[->] (n1) -- (n2);
\end{tikzpicture}
- The first node has no position; every later node
n2,n3… isright=ofthe one before it. - After the nodes, one
\draw[->]line joins each pair of neighbours, in order. - In a label, the characters
&,%,$,#and_are special to TeX: write each with a backslash before it, soR&DbecomesR\&D. - An empty list of labels raises
ValueError.
Starter code · chain.py
def tikz_chain(labels, distance):
"""Return the TikZ code of a row of boxes named n1, n2 ..., each right=of the one before, joined by arrows."""
lines = [r"\begin{tikzpicture}[node distance=" + str(distance) + r"mm, box/.style={draw, rounded corners}]"]
# Replace this comment: add the node lines, then the draw lines, then the end of the picture.
return "\n".join(lines) The sample tests · test_chain.py
from chain import tikz_chain
THREE = tikz_chain(["Sensor", "Logger", "Report"], 10).split("\n")
def test_opening_line_with_the_distance():
assert THREE[0] == r"\begin{tikzpicture}[node distance=10mm, box/.style={draw, rounded corners}]"
assert tikz_chain(["A"], 6).split("\n")[0].startswith(r"\begin{tikzpicture}[node distance=6mm,")
def test_nodes_placed_from_each_other():
assert THREE[1] == r" \node[box] (n1) {Sensor};"
assert THREE[2] == r" \node[box, right=of n1] (n2) {Logger};"
assert THREE[3] == r" \node[box, right=of n2] (n3) {Report};"
def test_one_arrow_per_pair_then_the_end():
assert THREE[4:] == [r" \draw[->] (n1) -- (n2);", r" \draw[->] (n2) -- (n3);", r"\end{tikzpicture}"]
def test_one_label_has_no_arrow():
assert tikz_chain(["Alone"], 10).split("\n")[1:] == [r" \node[box] (n1) {Alone};", r"\end{tikzpicture}"]
def test_special_characters_are_escaped():
lines = tikz_chain(["R&D", "50% done", "file_2"], 8).split("\n")
assert lines[1] == r" \node[box] (n1) {R\&D};"
assert lines[2] == r" \node[box, right=of n1] (n2) {50\% done};"
assert lines[3] == r" \node[box, right=of n2] (n3) {file\_2};"
def test_no_labels():
try:
tikz_chain([], 10)
except ValueError:
pass
else:
assert False, "an empty chain was accepted" A hint
Build a list of lines: the opening line, then one node line per label (the first without right=of), then one draw line for each i from 1 to the number of labels minus 1, then \end{tikzpicture}. Escape a label with a loop over its characters, adding a backslash before each character that is in "&%$#_".
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
Write the same chain in CeTZ, from anchors
Now the same row of boxes for Typst. Write cetz_chain(labels, gap) in cetzchain.py. It returns the body of a CeTZ canvas, as one string of lines joined by \n, exactly in this shape for cetz_chain(["Sensor", "Logger"], 1.2):
#cetz.canvas({
import cetz.draw: *
content((0, 0), [Sensor], name: "n1", frame: "rect", padding: 4pt)
content((rel: (1.2, 0), to: "n1.east"), [Logger], anchor: "west", name: "n2", frame: "rect", padding: 4pt)
line("n1", "n2", mark: (end: "stealth"))
})
- The first box is at
(0, 0); every later box startsgapcentimetres to the right of the east anchor of the one before, with its own west anchor there, as the lesson'sflow-cetz.typplaces its nodes. - The gap is written with Python's
f"{gap:g}", so1.0becomes1and1.2stays1.2. - After the boxes, one
linejoins each pair of neighbours, in order. - In a label, the characters
\,#,[,],*,_,$and@mean something in Typst markup: write each with a backslash before it, soLoad #2becomesLoad \#2. - An empty list of labels raises
ValueError.
Starter code · cetzchain.py
def cetz_chain(labels, gap):
"""Return a CeTZ canvas with a row of boxes n1, n2 ..., each gap cm right of the one before, joined by arrows."""
lines = ["#cetz.canvas({", " import cetz.draw: *"]
# Replace this comment: add the content lines, then the line lines, then the closing line.
return "\n".join(lines) The sample tests · test_cetzchain.py
from cetzchain import cetz_chain
TWO = cetz_chain(["Sensor", "Logger"], 1.2).split("\n")
def test_canvas_opening():
assert TWO[:2] == ["#cetz.canvas({", " import cetz.draw: *"]
def test_first_box_at_the_origin():
assert TWO[2] == ' content((0, 0), [Sensor], name: "n1", frame: "rect", padding: 4pt)'
def test_next_box_from_the_east_anchor():
assert TWO[3] == ' content((rel: (1.2, 0), to: "n1.east"), [Logger], anchor: "west", name: "n2", frame: "rect", padding: 4pt)'
def test_arrows_and_the_end():
assert TWO[4:] == [' line("n1", "n2", mark: (end: "stealth"))', "})"]
def test_gap_written_short():
lines = cetz_chain(["A", "B", "C"], 1.0).split("\n")
assert lines[4] == ' content((rel: (1, 0), to: "n2.east"), [C], anchor: "west", name: "n3", frame: "rect", padding: 4pt)'
assert lines[5:7] == [' line("n1", "n2", mark: (end: "stealth"))', ' line("n2", "n3", mark: (end: "stealth"))']
def test_markup_characters_are_escaped():
lines = cetz_chain(["Load #2", "a_b [x]"], 1).split("\n")
assert lines[2] == ' content((0, 0), [Load \\#2], name: "n1", frame: "rect", padding: 4pt)'
assert "[a\\_b \\[x\\]]" in lines[3]
def test_no_labels():
try:
cetz_chain([], 1)
except ValueError:
pass
else:
assert False, "an empty chain was accepted" A hint
Make the first content line by hand, then loop over the other labels with their numbers: label i is placed with to: "n{i - 1}.east". Escape the backslash first when you escape a label, or you will double the backslashes you just added before the other characters.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then the practice questions, which come back to anything you miss:
Practice that adapts to you
Practise this lesson
12 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 keep a paper’s diagrams as TikZ or CeTZ code rather than as pictures from a drawing program?
Because the code is the diagram’s source: it lives under version control beside the text, a change of label or spacing is an edit of one line, and a rebuild redraws the figure in the document’s own fonts, at about a second a figure in the recorded builds. Positioning from anchors keeps the layout correct when labels change, and the words stay text in the PDF, so they can be searched and copied. A drawing program’s PNG has none of that: its text is pixels, its resolution is fixed, and every change means opening the program again. The cost is a language to learn, and the same care with alternative text as for any image.
Two harder questions follow, about a thesis whose TikZ figures make the build slow and about one diagram that must appear in a LaTeX paper, a Typst report and a web page. Decide your first step before you open each one:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A thesis with 60 TikZ figures takes four minutes to build, and co-authors have stopped building it before they commit. How do you make the build fast again while keeping the figures editable, their words searchable and the PDF accessible?
-
Mid to senior
The same block diagram must appear in a LaTeX paper, a Typst report and a web page, and stay identical in all three as it changes. How do you produce and maintain it, and what do you check in each output?
Key takeaways
- TikZ and CeTZ draw on centimetres:
(2,1)is absolute,++(1,1.5)or(rel: (1, 1.5))relative, and(60:2)or(60deg, 2)polar, which landed at (1, 1.73) cm. - Nodes carry the text and named styles their look; arrows.meta and shapes.geometric add tips and the diamond in TikZ, while CeTZ needs a closed line, grouped, for a diamond.
- Placing nodes from anchors,
below=ofin TikZ or(rel: …, to: "name.south")in CeTZ, kept the gaps at 6 and 12 mm when a label grew; a box placed by its centre lost 2.2 mm of its gap. - A stand-alone file crops to the drawing and builds on its own, in 1.15 s with pdfLaTeX or 0.22 s with Typst here; of four SVG exports, only dvisvgm with embedded fonts kept the words as text.
- A drawing needs alternative text, and PDF/UA-1 and veraPDF passed one without it: put it in a figure with
alt, and read the structure tree.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do you write an absolute, a relative and a polar point?
TikZ: (2,1), ++(1,1.5) after a point, and (60:2). CeTZ: (2, 1), (rel: (1, 1.5)) and (60deg, 2). Both count in centimetres.
-
Where does the polar point (60:2) land?
2 × cos 60° = 1 cm across and 2 × sin 60° = 1.73 cm up from the origin.
-
How does ++ differ from + in a TikZ point?
++(1,1.5) moves the current point, so the next relative point starts there; +(1,1.5) gives the same point without moving on.
-
Why give each kind of node a style?
A style such as box/.style={draw, rounded corners, minimum width=28mm} names the look once, so a change of look is one line and every box stays the same size.
-
Why place nodes from each other's anchors instead of at fixed coordinates?
below=of write, or CeTZ's (rel: ..., to: "write.south"), keeps each gap as written when a label grows; a node placed by its centre grows both ways and its gap shrinks.
-
How do you crop a drawing's page to the drawing?
\documentclass[border=4pt]{standalone} in LaTeX, or #set page(width: auto, height: auto, margin: 4pt) in Typst.
-
Which SVG export of the flowchart kept its words as text?
Only dvisvgm with embedded WOFF2 fonts; Typst's own SVG export, pdftocairo and dvisvgm with --no-fonts drew the letters as outlines.
-
Does PDF/UA-1 export refuse a drawing without alternative text?
No: shapes become artifacts and both the export and veraPDF passed. Put the drawing in a figure with alt, or give a TikZ picture its alt key in tagged LaTeX, and read the structure tree.
Terms in this lesson
- anchor
- A named point on the border of a node or element, such as north, south, east, west or a corner, from which lines are drawn and other nodes are placed.
- node
- In TikZ, a piece of text set in a shape at a point of a drawing, with a name that lines and other nodes refer to. CeTZ's content element plays the same part.
- polar coordinate
- A point given by an angle and a distance from the origin instead of by x and y, such as (60:2) in TikZ or (60deg, 2) in CeTZ: 2 cm out at 60 degrees, which is 1 cm across and 1.73 cm up.
- stand-alone figure file
- A figure kept in a file of its own that compiles to a PDF cropped to the drawing, such as a LaTeX file with the standalone class or a Typst file whose page size is automatic. A document then includes the PDF.
References
- The PGF and TikZ package (CTAN)
- The standalone package and class (CTAN)
- Tagging project: usage instructions (The LaTeX Project (Tagged PDF project))
- Tagging status of LaTeX packages and classes (The LaTeX Project (Tagged PDF project))
- CeTZ on Typst Universe (Typst Universe)
- Typst reference: page (Typst)
- Typst reference: figure (Typst)
- Typst guide: Accessibility (Typst)
- dvisvgm manual (dvisvgm)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress