Skip to main content

A Picture for Karl's New Students

This tutorial is intended for new users of CeTZ. It does not give an exhaustive account of all the features of CeTZ, just of those you are likely to use right away. This tutorial also acts as a parallel to TikZ's tutorial A Picture for Karl's Students.

Karl is a math and chemistry high-school teacher. He used to create the graphics in his worksheets and exams using the TikZ package with LaTeX\LaTeX. While the results were acceptable, Karl, for his own reasons, has started using Typst instead. He looks through the provided packages in Typst: Universe and finds CeTZ, which is supposed to stand for "CeTZ, ein Typst Zeichenpaket" and appears appropriate.

Problem Statement

Karl wants to put a graphic on the next worksheet for his students. He is currently teaching his students about sine and cosine. He already has the graphic drawn with TikZ and would like to keep it as close to it as possible:

Either the example gets rendered using a block or we pre-render it I'm not sure yet.

Setting up the Environment

In CeTZ, to draw a picture, two imports and a function call is all you need. Karl sets up his file as follows:

#set page(width: auto, height: auto)
#import "@preview/cetz:0.3.0"

We are working on
#cetz.canvas({
import cetz.draw: *
line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))
})

When compiled via the Typst web app or the Typst command line interface, the resulting output will contain something that looks like this:

We are working on
#cetz.canvas({
import cetz.draw: *
line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))
})

Admittedly, not quite the whole picture, yet, but we do have the axes established. Well, not quite, but we have the lines that make up the axes drawn. Karl suddenly has a sinking feeling that the picture is still some way off.

Let's have a more detailed look at the code. First, the package cetz is imported. The canvas function in the cetz module is then called and a pair of curly braces are placed as the function's first (and only) positional argument. The braces create a scope or body, in which more functions can be called, but first must be imported from the draw module.

Inside the body there are two line functions. They are draw functions that draw straight lines between given positions. The first line function is given the parameters (-1.5, 0), (1.5, 0), which refer to a point at position (1.5,0)(-1.5, 0) and (1.5,0)(1.5, 0). Here, the positions are specified within a special coordinate system in which, initially, one unit is 1cm.

Karl is quite pleased to note that the environment automatically reserves enough space to encompass the picture.

Line Construction

The basic building block of all pictures in CeTZ are draw functions. A draw function is a function that can be called inside the canvas body in order to create the graphic, such as line, bezier, rect and many more (not all draw functions actually draw something, like set-style, but still effect the outcome of the picture).

In order to draw a path of straight lines, the line draw function can be used. You specify the coordinates of the start position by passing an array with two numbers (a coordinate type) to the first parameter of line. The second coordinate must be given as the second parameter of the function otherwise it will panic. Subsequent coordinates can be passed to the function to draw additional lines between the previous and next coordinates.

line((-1.5, 0), (1.5, 0), (0, -1.5), (0, 1.5))

Note that the canvas function and import statements have been omitted from the code as they are boiler plate and don't always need to be shown. They are very much still required in order to produce the picture, so just remember they are there okay.

Curve Construction

The next think Karl wants to do is to draw the circle. For this, straight lines obviously will not do. Instead, we need some way to draw curves. For this, CeTZ provides several other draw functions, the most useful here would be the bezier function. As the name suggests, it can draw a bezier curve when a start and end coordinate is given, as well as one or two control points.

Here is an example (the control points have been added for clarity):

// start and end
circle((0, 0), radius: 2pt, fill: gray)
circle((2, 0), radius: 2pt, fill: gray)
// control points
circle((1, 1), radius: 2pt, fill: gray)
circle((2, 1), radius: 2pt, fill: gray)

bezier((0, 0), (2, 0), (1, 1), (2, 1))

So, Karl can now add the first half circle to the picture:

line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))

bezier((-1, 0), (0, 1), (-1, 0.555), (-0.555, 1))
bezier((0, 1), (1, 0), (0.555, 1), (1, 0.555))

Karl is happy with the result, but finds specifying circles in this way to be extremely awkward. Fortunately, there is a much simpler way.

Circle Construction

In order to draw a circle, the circle draw function can be used:

circle((0, 0), radius: 10pt)

You can also draw an ellipse with this draw function by passing an array of two numbers to the radius argument:

circle((0, 0), radius: (20pt, 10pt))

To draw an ellipse whose axes are not horizontal and vertical, but point in an arbitrary direction you can use transformations, which are explained later.

So, returning to Karl's problem, he can write circle((0, 0)) to draw the circle as, by default, the radius argument is 1:

line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))

circle((0, 0))

At this point, Karl is a bit alarmed that the circle is so small when he wants the final picture to be much bigger. He is pleased to learn that CeTZ has transformation draw functions and scaling everything by a factor of three is very easy. But let us leave the size as it is for the moment to save some space.

Rectangle Construction

The next things we would like to have is the grid in the background. There are several ways to produce it. For example, one might draw lots of rectangles. To do so, the rect draw function can be used. Two coordinates should be passed as arguments, they specify the corners of the rectangle:

line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))
circle((0, 0))

rect((0, 0), (0.5, 0.5))
rect((-0.5, -0.5), (-1, -1))

While this may be nice in other situations, this is not really leading anywhere with Karl's problem: First, we would need an awful lot of these rectangles and then there is the border that is not "closed".

So, Karl is about to resort to simply drawing four vertical and four horizontal lines using the nice line draw function, when he learns that there is a grid draw function.

Grid Construction

The grid draw function adds a grid to the picture. It will add lines making up a grid that fills the rectangle specified by two coordinates passed to it.

For Karl, the following code could be used:

line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))
circle((0, 0))

grid((-1.5, -1.5), (1.5, 1.5), step: 0.5)

Having another look at his desired picture, karl notices that it would be nice for the grid to be more subdued. To subdue the grid, Karl adds more named arguments to the grid draw function. First, he uses the color gray for the grid lines. Second, he reduces the line width to 0.2pt (TikZ's very thin). Finally, he swaps the ordering of the commands so that the grid is drawn first and everything else on top.

grid((-1.5, -1.5), (1.5, 1.5), step: 0.5, stroke: gray + 0.2pt)
line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))
circle((0, 0))

Adding a Touch of Style

Karl notices that the thickness of the circle and axes paths are much greater than the grid's thickness. He learns that CeTZ's default stroke thickness is actually 1pt and not TikZ's 0.4pt. Karl decides that he would like to use the thinner lines to keep this new picture as close to the original as possible.

We can use the set-style draw function to apply styling to all subsequent draw functions, similar to how Typst's set and show rules work. To set the stroke's thickness he uses the named argument stroke: 0.4pt:

set-style(stroke: 0.4pt)
grid((-1.5, -1.5), (1.5, 1.5), step: 0.5, stroke: gray + 0.2pt)
line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))
circle((0, 0))

Karl can also move the grid's styling into the same set-style function by passing it as a dictionary to the grid named argument:

set-style(
stroke: 0.4pt,
grid: (
stroke: gray + 0.2pt,
step: 0.5
)
)
grid((-1.5, -1.5), (1.5, 1.5))
line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))
circle((0, 0))

Arc Construction

Our next obstacle is to draw the arc for the angle. For this, the arc draw function can be used, which draws part of a circle or ellipse. This function requires arguments to specify the arc. An example would be arc((0, 0), start: 10deg, stop: 80deg, radius: 10pt), which creates an arc starting at (0,0)(0, 0) at an angle of 10°10° to 80°80° with a radius of 10pt. Karl obviously needs an arc from 0° to 30°30°. The radius should be something relatively small, perhaps around one third of the circle's radius.

set-style(
stroke: 0.4pt,
grid: (
stroke: gray + 0.2pt,
step: 0.5
)
)
grid((-1.5, -1.5), (1.5, 1.5))
line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))
circle((0, 0))
arc((3mm, 0), start: 0deg, stop: 30deg, radius: 3mm)

Karl thinks this is really a bit small and he cannot continue unless he learns how to do scaling. For this, he can use the scale draw function at the start of the canvas body

set-style(
stroke: 0.4pt,
grid: (
stroke: gray + 0.2pt,
step: 0.5
)
)
scale(3)
grid((-1.5, -1.5), (1.5, 1.5))
line((-1.5, 0), (1.5, 0))
line((0, -1.5), (0, 1.5))
circle((0, 0))
arc((3mm, 0), start: 0deg, stop: 30deg, radius: 3mm)

As for circles, you can specify the radius as an array of two numbers to get an elliptical arc.

arc((0, 0), start: 0deg, stop: 315deg, radius: (1.75, 1))

Not Really Path Clipping

In order to save space in this manual, it would be nice to clip Karl's graphics a bit so we can focus on the "interesting" parts. Unfortunately clipping is not currently possible in CeTZ. So instead we can replace the circle with an arc and modify the grid.

set-style(
stroke: 0.4pt,
grid: (
stroke: gray + 0.2pt,
step: 0.5
)
)
scale(3)
grid((-0.5, -0.5), (1, 1))
line((-0.5, 0), (1, 0))
line((0, -0.5), (0, 1))
arc((0, 1), start: 90deg, delta: -120deg)
arc((3mm, 0), start: 0deg, stop: 30deg, radius: 3mm)

Filling and Drawing

Returning to the picture, Karl now wants the angle to be "filled" with a very light green. For this he uses the fill styling parameter. Here is what Karl does:

set-style(
stroke: 0.4pt,
grid: (
stroke: gray + 0.2pt,
step: 0.5
)
)
scale(3)
grid((-0.5, -0.5), (1, 1))
line((-0.5, 0), (1, 0))
line((0, -0.5), (0, 1))
arc((0, 1), start: 90deg, delta: -120deg)
arc(
(3mm, 0),
start: 0deg,
stop: 30deg,
radius: 3mm,
mode: "PIE",
fill: color.mix((green, 20%), white),
)

The color color.mix((green, 20%), white) means 20% green and 80% white mixed together. In fact fill can take any value of type color, you can even do shading!

By default arcs are in "OPEN" mode where only the curve is drawn and only the chord of the arc will be filled. By using mode: "PIE" the arc will be closed around its origin leaving you with a shape akin to a slice of pie, or in this case an angle which can be properly filled.

Specifying Coordinates

Karl now wants to add the sine and cosine lines. He knows already that he can use the stroke: styling parameter to set the lines' colors. So, what is the best way to specify the coordinates?

There are different ways of specifying coordinates. The easiest way is to say something like (10pt, 2cm). This means 10pt in xx-direction and 2cm in yy-directions. Alternatively, you can also leave out the units as in (1, 2), which means "one times the unit length in the xx-direction and twice the unit length in the yy-direction". The unit length defaults to 1cm in both directions.

In order to specify points in polar coordinates, use an array of the form (30deg, 1cm), which means 1cm in direction 30 degree. This is obviously quite useful to "get to the point (cos30°,sin30°)(\cos 30\degree, \sin 30 \degree) on the circle".

You can wrap a coordinate in a dictionary with the key rel as in (rel: (0cm, 1cm)). Such coordinates are interpreted differently: They mean "1cm upwards from the previous specified position, making this the new specified position". You can include the key and value update: false in the coordinate (rel: (2cm, 0cm), update: false) which means "2cm to the right of the previous specified position and do not change the previous position". For example, we an draw the sine line as follows:

set-style(
stroke: 0.4pt,
grid: (
stroke: gray + 0.2pt,
step: 0.5
)
)
scale(3)
grid((-0.5, -0.5), (1, 1))
line((-0.5, 0), (1, 0))
line((0, -0.5), (0, 1))
arc((0, 1), start: 90deg, delta: -120deg)
arc(
(3mm, 0),
start: 0deg,
stop: 30deg,
radius: 3mm,
mode: "PIE",
fill: color.mix((green, 20%), white),
)
line((30deg, 1cm), (rel: (0, -0.5)), stroke: red + 1.2pt)

Karl used the fact sin30°=1/2\sin 30 \degree = 1/2. However, he very much doubts that his students know this, so it would be nice to have a way of specifying "the point straight down from (30deg, 1cm) that lies on the xx-axis". This is, indeed, possible using a special coordinate: Karl can write ((30deg, 1cm), "|-", (0, 0)). In general, the meaning of (p, "|-", q) is "the intersection of a vertical line through pp and a horizontal line through qq".

Next, let us draw the cosine line. One way would be to use line(((30deg, 1cm), "|-", (0, 0)), (0, 0)). Another way is the following: we "continue" from where the sine ends:

set-style(
stroke: 0.4pt,
grid: (
stroke: gray + 0.2pt,
step: 0.5
)
)
scale(3)
grid((-0.5, -0.5), (1, 1))
line((-0.5, 0), (1, 0))
line((0, -0.5), (0, 1))
arc((0, 1), start: 90deg, delta: -120deg)
arc(
(3mm, 0),
start: 0deg,
stop: 30deg,
radius: 3mm,
mode: "PIE",
fill: color.mix((green, 20%), white),
)
line((30deg, 1cm), (rel: (0, -0.5)), stroke: red + 1.2pt)
line((), (0, 0), stroke: blue + 1.2pt)

Note that an empty array () is given as the line's starting coordinate. This value means "the previous specified coordinate", which in this case is the end of the cosine line.

Intersecting Paths

Karl is left with the line for tanα\tan \alpha, which seems difficult to specify using transformations and polar coordinates. The first - and easiest - thing he can do is so simply use the coordinate (1, calc.tan(30deg)).

Karl can, however, also use a more elaborate, but also more "geometric" way of computing the length of the orange line: He can specify intersections of paths as coordinates. The line for tanα\tan \alpha starts at (1,0)(1, 0) and goes upward to a point that is at the intersection of a line going "up" and a line going from the origin through (30deg, 1cm). Such computations are made available by the intersections function.

What Karl must do is to create two "invisible" paths that intersect at the position of interest. Creating lines that are not otherwise seen can be done by either setting their stroke to none or by wrapping them in the hide function. Then, Karl can add the name parameter to the lines for later reference. Once the lines have been constructed, Karl can use the intersections function to get the coordinate for later reference.

hide({
line((1, 0), (1, 1), name: "upward line")
line((0, 0), (30deg, 1.5cm), name: "sloped line") // a bit longer, so that there is an intersection
})

intersections("x", "upward line", "sloped line")
line((1, 0), "x.0", stroke: orange + 1.2pt)

Adding Marks

Karl now wants to add the little arrow tips at the end of the axes. He has noticed that in many plots, even in scientific journals, these arrow tips seem to be missing, presumably because the generating programs cannot produce them. Karl thinks arrow tips belong at the end of axes. His son agrees. His students do not care about arrow tips.

It turns out that adding arrow tips is pretty easy: Karl adds the mark styling parameter with the value (end: ">") to the line functions for the axes:

set-style(
stroke: 0.4pt,
grid: (
stroke: gray + 0.2pt,
step: 0.5
)
)
scale(3)
grid((-0.5, -0.5), (1.5, 1.5))
line((-0.5, 0), (1.5, 0), mark: (end: ">"))
line((0, -0.5), (0, 1.5), mark: (end: ">"))
arc((0, 0), start: 120deg, stop: -30deg, anchor: "origin")
arc(
(3mm, 0),
start: 0deg,
stop: 30deg,
radius: 3mm,
mode: "PIE",
fill: color.mix((green, 20%), white),
)
line((30deg, 1cm), (rel: (0, -0.5)), stroke: red + 1.2pt)
line((), (0, 0), stroke: blue + 1.2pt)

hide({
line((1, 0), (1, 1), name: "upward line")
line((0, 0), (30deg, 1.5cm), name: "sloped line")
})
intersections("x", "upward line", "sloped line")
line((1, 0), "x.0", stroke: orange + 1.2pt)

If Karl had used the value (start: ">") instead of (end: ">"), arrow tips would have been put at the beginning of the path. The value (start: ">", end: ">") or (symbol: ">") puts the marks at both ends of the path.

Some elements do not support marks. As a rule of thumb, you can add marks only to elements that do not have a closed path.

Karl notices that the marks are unnaturally large, this is because the shape of marks are transformed by default. So the marks are three times as big. This can be resolved by setting the transform-shape: false styling parameter. He also wants the mark to be filled and to have a different shape. As done earlier, the fill styling parameter can be used to fill marks and a different shape can be obtained by referring to the table of currently supported marks.

Repeating Things: For-Loops

Karl's next aim is to add little ticks on the axes at positions 1-1, 1/2-1/2, 1/21/2, and 11. For this, it would be nice to use some kind of "loop", especially since he wishes to do the same thing at each of these positions. Thankfully Typst has built in loops.

Karl can then use the following code:

set-style(
stroke: 0.4pt,
grid: (
stroke: gray + 0.2pt,
step: 0.5
),
mark: (
transform-shape: false,
fill: black
)
)
scale(3)
grid((-1, -1), (1.5, 1.5))
line((-1, 0), (1.5, 0), mark: (end: "stealth"))
line((0, -1), (0, 1.5), mark: (end: "stealth"))
circle((0, 0))
arc(
(3mm, 0),
start: 0deg,
stop: 30deg,
radius: 3mm,
mode: "PIE",
fill: color.mix((green, 20%), white),
)

for x in (-1, -0.5, 1) {
line((x, -1pt), (x, 1pt))
}
for y in (-1, -0.5, 0.5, 1) {
line((-1pt, y), (1pt, y))
}

Adding Text

Karl is, by now, quite satisfied with the picture. However, the most important parts, namely the labels, are still missing!

CeTZ has the content draw function which allows you to place any content onto the canvas at a specified position.