A complete guide to generating dimensionally accurate PDF (and PNG) templates: grid paper, ruled paper, dot grids, staff paper, mazes, polar targets, tilings, fillable forms, perspective grids and mandalas — for paper formats and for e-ink devices.
The one promise: what says 5 mm measures 5 mm on the printout. No scaling, no "fit to page", no stretching a grid so it comes out even. If a template does not fit a page, Ctrl+Grid tells you — it never silently shrinks it.
This handbook is the user-facing manual. For why the tool is built the way it
is, see the specification, pflichtenheft-vorlagengenerator.md
(German); for the decisions taken where the spec was silent, see
implementation-decisions.md.
- Installation
- Quick start
- How it works: handle and blades
- The command line
- The definition file
- Units and measures
- The page
- The pattern area and geometry
- The cycle model
- Frame furniture
- The cover sheet and embedding the definition
- The generators
- E-ink and devices
- Imposition: n-up and booklets
- Output formats
- Batch output: name lists
- Fonts
- Presets
- Printing accurately
- Errors and validation
- Appendix A — units
- Appendix B — paper formats
- Appendix C — command-line flags
- Appendix D — generators at a glance
Ctrl+Grid is a Python CLI, on PyPI as ctrlgrid. With
uv you need not install it at all:
uvx ctrlgrid millimeter-a4 --pages 3 -o grid.pdfThat downloads it, runs it and leaves nothing behind. To keep it on your PATH:
uv tool install ctrlgridOr with pip:
pip install ctrlgridTo run the unreleased state of main, point at the repository instead:
uvx --from git+https://github.com/DocAtPrompt/ctrlgrid.git ctrlgrid --helpFor development (tests and linter):
uv sync --extra dev && uv run pytest && uv run ruff check .The bundled music font (for staff clefs) and the presets travel with the package — nothing else to fetch.
Generate 5 mm graph paper on A4 from a preset:
ctrlgrid millimeter-a4 -o grid.pdfList the presets, print one to copy and bend, or list the e-ink devices:
ctrlgrid presets
ctrlgrid show millimeter-a4 > mine.yaml
ctrlgrid devicesRun your own definition file:
ctrlgrid -d mine.yaml -o out.pdfValidate a definition without writing anything:
ctrlgrid check mine.yamlRun with no arguments in a terminal to open the interactive preset browser (pick a preset, page count and output file).
Ctrl+Grid is a pocket knife: one handle, several blades.
- The handle owns everything about the page: format, margins, the pattern area, the frame, header and footer, stamp, hole marks, the page loop and the output file.
- A blade (a generator) only fills the pattern area with marks. It knows nothing about margins or paper size — it is handed a rectangle and asked for marks in local coordinates.
Because of that split, the same generator fills A4, A6, US Letter or a 3:4 e-ink slate without changing a line: only the handle's page settings change. Every definition names exactly one generator.
Everything is measured, then drawn. A run either aborts completely (with an error you can act on) or produces the whole document — never half a file.
| Command | What it does |
|---|---|
ctrlgrid <preset> |
Build from a shipped preset (e.g. ctrlgrid millimeter-a4) |
ctrlgrid -d <file> |
Build from your own definition file |
ctrlgrid generate … |
The explicit form of the two above |
ctrlgrid check <file> |
Validate and write nothing (§ 20) |
ctrlgrid presets |
List the shipped presets |
ctrlgrid show <preset> |
Print a preset's definition, ready to copy |
ctrlgrid devices |
List device profiles, with where their figures come from |
ctrlgrid (no args) |
Interactive preset browser (in a terminal) |
The output format follows the file extension of -o:
-o out.pdf→ a PDF (vector, the reference output).-o out.png→ one PNG per page at the device's exact pixel size (needs a device; see §13).
If a target file already exists, the run refuses rather than overwrite it. Pass
--force to overwrite.
All flags beat the definition file (the command line always wins). Switch-style
flags (--cover, --strict, --embed-def, --skip-unsupported,
--crop-marks, --booklet) only ever
turn something on — the
definition decides when the flag is absent.
See Appendix C for the full list. The most common:
ctrlgrid --version # which version is installed
ctrlgrid millimeter-a4 --pages 30 -o grid.pdf # 30 numbered sheets
ctrlgrid millimeter-a4 --cover -o grid.pdf # + a calibration first sheet
ctrlgrid -d mine.yaml --format a5 -o out.pdf # override the paper format
ctrlgrid -d mine.yaml --device remarkable-paper-pro -o pad.pdf
ctrlgrid millimeter-a4 --names class.txt -o sheets.pdf # one sheet per nameA definition is a YAML file. The smallest possible one names a version and a generator with its settings:
version: 1
generator: lines
families:
- { direction: horizontal, base_spacing: 5mm }The top level has three kinds of key:
version— currently always1. Required.- Handle sections —
page,header,footer,border,stamp,pattern,pages. All optional; each is described below. - The generator —
generator: <name>plus that generator's own keys at the top level (for examplefamilies:forlines,cells:forgrid).
YAML anchors keep a palette in one place. Define them under defs: (a reserved
block that is not otherwise interpreted) and reference them with *name:
defs:
grid: &grid "#7799bb"
accent: &accent "#4466aa"
# … later …
color: [*grid, *grid, *grid, *grid, *accent]Unknown keys are rejected, not ignored — a misspelled bordr: is an error,
never a silently dropped border.
Every measurement is written with its unit. There is no bare-number default.
| Suffix | Meaning |
|---|---|
mm |
millimetres — 5mm |
cm |
centimetres — 2cm |
in |
inches — 0.5in |
pt |
printer's points, 1/72 in — 0.15pt (typical stroke width) |
Positions are held internally as exact integer micrometres, so a grid never
drifts: 72pt is exactly 25400µm, and repeat runs are byte-identical.
%w, %h and %s are a fraction of the pattern area — its width, its
height, and its shorter side. They let one definition fill both A4 and a 3:4
e-ink slate:
outer_radius: 48%s # 48 % of the shorter side, whatever the formatpx resolves against a device's pixel density and is only allowed when a
device profile is active (§13). On paper it is refused — a printer has no fixed
pixel.
Angles are in degrees: 45deg. Zero points right; positive is counter-clockwise.
Staff paper measures in sp, one staff space — the distance between two lines
of a stave. See the staves generator (§12).
Named formats: a3, a4, a5, a6, letter, legal, tabloid. Or give an
exact size:
page:
format: 210x99mm # a DL-ish slip
# or
format: 8.5x11inThe page: section is the handle's description of the sheet.
page:
format: a4 # or a device (below), or an exact size
orientation: portrait # or landscape
duplex: false # two-sided binding (see margins)
background: none # or a colour, painted under everything
hole_marks: false # ISO 838 punch marks at the binding edge
margin: 10mm # see belowmargin can be a single value (all four sides) or the four named edges:
page:
margin:
top: 15mm
bottom: 15mm
inner: 20mm # the binding edge
outer: 12mm # the open edgeMargins are named inner/outer, not left/right, because of duplex. With
duplex: true, inner and outer swap on even pages so the wide binding
margin always sits at the spine. With duplex off, inner is simply the left
margin.
Instead of format, name a device to target an e-ink screen (§13). Format
and device are mutually exclusive — they are two answers to "what medium".
The pattern area is what is left after margins, header, footer and their gaps are removed. It is the rectangle the generator fills. Everything the generator draws lives here; the handle owns the frame around it.
The pattern: section tunes how a repeating pattern meets the edges of that
area.
pattern:
align: bottom-left # which corner the grid is anchored at
snap: none # or cycle / pixel, per axis
remainder: center # where the leftover space goes, per axisWhich corner the pattern is anchored to: bottom-left (default), top-left,
bottom-right, top-right. The complete block starts at that corner, so any
incomplete block falls to the opposite one. Useful when you want whole cells to
start at the top-left of the page.
A pattern rarely divides the area exactly. remainder decides where the
leftover goes, per axis (x, y) or both:
center(default) — split evenly on both sides, so the leftover reads as breathing room, not a mistake.end— pattern at the origin, all leftover at the far edge.whole_cycles— drop the incomplete cycle entirely; the freed space goes to the far end.
pattern:
remainder: { x: center, y: end }By default (none) the pattern keeps its exact spacing and the leftover is
placed by remainder. snap instead adjusts the geometry so blocks come out
even:
cycle— shrink the pattern area to a whole number of cycles (whole blocks only, no partial cells at the edge).pixel— round every step to whole device pixels, for a crisp e-ink render (needs a device; §13).
snap is an error for generators whose pattern has a centre rather than an
axis to snap to (polar, mandala, staves, grid, maze, tiling,
form) — the tool says so rather than guessing.
The cycle model is the heart of the tool. In lines, dots and polar, each
visual property — spacing, stroke weight, size, dash, colour — follows its own
repeating list, and the lists may be different lengths.
"Every fifth line heavier and blue" is one cycle for weight and one for colour, each five long, running against a one-entry spacing cycle:
generator: lines
families:
- direction: horizontal
base_spacing: 1mm # the base value
spacing: [1] # × 1 each step → 1 mm apart
base_weight: 0.15pt
weight: [1, 1, 1, 1, 2.7] # every fifth line 2.7× as thick
color: ["#7799bb", "#7799bb", "#7799bb", "#7799bb", "#4466aa"]Each cycle is a list of dimensionless multipliers of a base_… value. A bare
base_spacing with no spacing: cycle means "the same every step". Because the
lists run independently and repeat, a short colour cycle against a longer
spacing cycle produces rich, regular patterns from a few numbers.
The millimeter-a4 preset (run ctrlgrid show millimeter-a4) is the canonical
worked example.
Everything around the pattern area is the handle's. All optional.
header and footer are bands of fixed height with three fields — left,
center, right:
header:
height: 8mm
gap: 4mm # blank space between the band and the pattern
font: { family: sans, size: 10pt }
left: "{name}"
center: "Week {page} / {page_count}"
right: "2026"
cut: false # what happens if fields collide (below)Placeholders are filled per page: {page}, {page_count}, and {name}
(from a name list, §16).
If long fields collide, cut: false (the default) refuses the run and names the
field and the missing millimetres; cut: true truncates with an ellipsis.
A field may hold an image instead of text — a PNG, never cropped:
header:
height: 12mm
left: { image: logo.png, height: 10mm }A band can carry its own colours — a full-width strip behind it and a text
colour. Both are off by default, so a definition without them draws exactly what
it drew before. The strip is the height of the band, never more: it stops where
gap begins, so the pattern area keeps its white.
header:
height: 8mm
gap: 3mm
background: "#2f3a48" # the strip
text_color: "#ffffff" # the text on it (default black)
center: "{page}"border:
weight: 0.3pt
color: "#333333"
gap: 2mm # inset from the pattern area
page:
background: "#fbfbf7" # painted under everything
hole_marks: true # ISO 838: two marks, 80 mm apart, 12 mm in
stamp:
text: DRAFT
angle: 45deg
opacity: 0.08 # a faint full-page watermark
size: auto # sized to ~80 % of the sheet width--stamp DRAFT on the command line is the quick route to a stamp.
A printed scale along the edges of the sheet — the one piece of furniture that demonstrates the promise on every page: lay a real ruler against it, and either the numbers agree or your printer scaled.
ruler:
edges: [bottom, left] # bottom | left | top | right, at least one
unit: cm # mm | cm | in — what the numbers mean
step: 1mm # the smallest tick
mid_every: 5mm # the medium tick; `none` leaves it out
label_every: 10mm # the long tick, and the number beside it
weight: 0.2pt
color: "#000000"
font: { size: 6pt }Zero sits at the pattern area, not at the paper's corner — the numbers agree with the grid, so the first line is 0 and reading a position off the sheet needs no subtraction. The ticks grow outward into the margin and reserve no space: switching a ruler on leaves the grid exactly where it was.
origin says which corner that zero is, and which way the numbers grow:
origin |
For | What it does |
|---|---|---|
bottom-left |
technical drawing | the default: x from the left, y from the bottom |
top-left |
screen layouts | y counts downwards from the top; x unchanged |
bottom-right, top-right |
mirrored work | the same formula, anchored at the other corner |
center |
plotting a function | zero in the middle of each edge, negative numbers before it |
Leave it out and the ruler follows pattern.align: a grid anchored
top-left gets a scale that starts there too. That is the point of the whole
feature — a ruler that disagreed with the grid beside it would be worse than
none. Write origin explicitly when you want the other thing: to measure the
sheet rather than the pattern.
With a centred origin the labelled ticks are symmetric about the middle (the ladder hangs on the zero, not on the edge), and the numbers before it carry a minus sign — which the collision check counts, because it measures the labels the sheet will really carry.
A centred zero is the middle of the pattern area, and the grid does not know that. If you want the scale's nought to fall on a grid line — a coordinate cross wants exactly that — two numbers have to agree with it, and neither is obvious:
- The pattern area must be an even multiple of the emphasised period. With
a millimetre grid emphasised every fifth line, the area has to be a multiple
of 10 mm in that direction, or its middle lands between two heavy lines. It
is the area that counts, not the format:
297mm − 2 × 8.5mm = 280mmworks,297mm − 2 × 14mm = 269mmdoes not. - The weight cycle must put the heavy line first. A cycle counts from mark
0, so
weight: [2.4, 1, 1, 1, 1]emphasises the area's own edge and every 5 mm from there. Ordinary millimetre paper is written the other way round ([1, 1, 1, 1, 2.7]), which puts the heavy lines at 4, 9, 14 mm — where the middle will never be.
plot-a4 is the worked example, and a test measures both agreements out of its
finished PDF.
Only edges is required. The rest follows unit:
unit |
step |
mid_every |
label_every |
the numbers read |
|---|---|---|---|---|
mm |
1mm | 5mm | 10mm | 10 20 30 … |
cm |
1mm | 5mm | 10mm | 1 2 3 … |
in |
0.125in | 0.5in | 1in | 1 2 3 … |
The intervals are ordinary lengths, so step: 2mm or label_every: 0.25in are
equally sayable — but each must be a whole multiple of the one below it, or the
run is refused: a numbered tick that sits on no tick of the ladder looks like a
scale and measures wrong. A number states its position exactly and is never
rounded (label_every: 25mm under unit: cm prints 2.5). On the vertical
edges the numbers turn 90°, reading bottom to top.
Edges are physical and do not swap under duplex the way margins do — a scale
belongs to the paper, not to the binding.
What gets refused, always before the first page and always in millimetres: a margin too narrow for tick, gap and number (naming the header or footer band when that is what ends the space), and numbers so close together that they would run into one another. Nothing is shrunk to make a ruler fit. On PNG output the run is refused too — the numbers are text, and the PNG writer has no font file.
Example: 13-ruler-edge.yaml.
--cover (or pages.cover: true) adds one extra first page carrying three
things:
- A calibration square of exactly 50 mm and a 100 mm rule, each labelled. Lay a ruler on them: if the measure is off, your printer scaled.
- A stroke-weight ladder — short lines from 0.1 to 1.0 pt, each labelled, so you see how thin each weight really prints (and, on a device, its pixel width).
- A settings summary — generator, format, margins, base values, cycles, effective period, snap mode, tool version and the definition's checksum. A good sheet stays reproducible years later.
The cover is not counted in page numbering (--pages 30 --cover gives 30
numbered sheets plus the cover), carries no header/footer/border/stamp, and is
never scaled by imposition.
--embed-def (or pages.embed_def: true) embeds the definition's exact
source bytes in the PDF as a file attachment, so the document literally carries
its own source and can be regenerated later without hunting for the file. PDF
only — on PNG output the run is refused with the reason, rather than dropping
the attachment silently.
Every generator fills the pattern area. Colours are #rrggbb. Stroke widths are
lengths (the defaults below are the shipped values). Each has a worked example in
../examples/ and, for most, a preset (run ctrlgrid show <name>).
One or more families of parallel lines. Cross two families for squared paper;
angle them for isometric; use law: log10 for graph paper.
generator: lines
families:
- direction: horizontal # horizontal | vertical | an angle like 55deg
base_spacing: 5mm # required
base_weight: 0.15pt
weight: [1, 1, 1, 1, 2.7] # every fifth line heavier (the cycle model, §9)
color: ["#000000"] # one colour, or a cycle
- direction: vertical # cross two families for squared paper
base_spacing: 5mm
base_weight: 0.15pt
weight: [1, 1, 1, 1, 2.7]Each family also takes, all optional:
spacing: [ … ]— a cycle of spacing multipliers (uneven rules).offset: <length>— shift the whole family across.style: dashedordottedwithbase_dash: <length>(anddash:to set an explicit pattern).base_dashis refused on asolidline — the tool never accepts a setting that would do nothing.extent: { start:, end: }bounds which lines are drawn (never how long a line is — there is no "draw a stroke here" primitive), orcount: <n>caps the number of lines.law: log10withdecades: <n>for logarithmic graph paper;decadeshas no meaning on a linear family and is refused there.governing: true— when two families share an axis and disagree on their period, one must be marked to set it.
# logarithmic graph paper:
families:
- { direction: horizontal, base_spacing: 20mm, law: log10, decades: 3 }Slanted families. A direction may be an angle — direction: 55deg — and
then three things hold, all of them consequences of one idea:
base_spacing,offsetandextentare measured perpendicular to the lines, not along an axis. 8 mm at 55° means the lines are 8 mm apart as a ruler laid across them measures, which is what a writing guide needs.- Every line is clipped to the pattern area, so the family fills the sheet and never runs into the margin.
- Line 0 goes through the pattern area's bottom-left corner — the same place
the first line of a horizontal family sits — and an unlimited family grows to
both sides of it. Perpendicular coordinates below line 0 are negative, so
extent: { start: -40mm, end: 0mm }keeps only the lines on that side.
Angles are taken modulo 180° (a line has no direction, so 55deg and 235deg
are the same family), and 0deg and 90deg draw exactly what horizontal and
vertical draw. Snapping is not supported on a slanted family — it has no
cartesian period to snap to, and governing: true or law: log10 on one is
refused rather than quietly ignored.
# an italic calligraphy guide: a ruled hand, crossed by the pen slant
families:
- { direction: horizontal, base_spacing: 5mm, spacing: [2, 1, 1, 3],
weight: [1, 2, 1, 1] }
- { direction: 55deg, base_spacing: 18mm, base_weight: 0.12pt }Examples: 01-lines-squared.yaml and
14-calligraphy-italic.yaml. Presets:
millimeter-a4, calligraphy-a4.
Two crossed cycles of dots. The interesting question is what happens where they
meet — combine decides.
generator: dots
grid:
x: { base_spacing: 5mm, spacing: [1], offset: 0mm }
y: { base_spacing: 5mm, spacing: [1] }
base_size: 0.3mm
size_x: [1, 1, 1, 1, 1.8] # every fifth column larger
size_y: [1, 1, 1, 1, 1.8]
combine: max # max | product | intersection_only
color: # a single colour, or coloured by an axis:
axis: cross
cycle: ["#aab4c0", "#aab4c0", "#aab4c0", "#aab4c0", "#5577aa"]combine: max gives the full cross grid; intersection_only keeps only dots
where both cycles emphasise. A colour cycle must name its axis.
Example: 02-dots-grid.yaml. Preset: dots-5mm.
A count-driven block of square cells with optional labels and fills — battleship boards, score sheets, seating charts, bingo.
generator: grid
cells: { x: 12, y: 12 } # required
labels:
columns: "A" # A, B, C… (a § 7.10 counting pattern, or a list)
rows: "n" # 1, 2, 3…
weight: 0.3pt
color: "#33475b"
fill: checker # none | checker | rows | columns
fill_color: "#eef2f6"
header_row: false
font: { size: 9pt }Label patterns: n → 1, 2, 3…; a/A → a, b, c / A, B, C; or an explicit list.
Example: 03-grid-battleship.yaml.
Preset: grid-a4.
Concentric rings and radial spokes about a centre, with optional labels. Built
on the same cycle model as lines.
generator: polar
center: auto # or { x: 105mm, y: 148mm }
outer_radius: 48%s # auto, a length, or relative
rings:
base_radius: 10mm # required
radius: [1] # cycle of ring-spacing multipliers
base_weight: 0.15pt
weight: [1, 1, 1, 1, 2]
color: ["#000000"]
spokes:
base_angle: 30deg # required
angle: [1]
weight: [1]
color: ["#000000"]
labels:
spokes: "n" # label each spoke / ring with a pattern or list
spoke_radius: 0.85
rings: "n"
font: { size: 8pt }An optional radial_extent: { start:, end: } on spokes keeps the spokes clear
of the centre (like a bullseye with an open middle). Ring and spoke families use
the same cycle model as lines, so radius/angle and weight follow
repeating lists — every fifth ring heavier, and so on.
Example: 06-polar-target.yaml. Preset:
polar-a4.
An edge net of a regular tiling, drawn once (shared edges are not doubled).
generator: tiling
shape: hex # hex | tri | square | rhombus | octagon_square
size: 10mm # edge length (required; relative allowed)
orientation: pointy # pointy | flat
weight: 0.4pt
color: "#3a4a5a"
fill: none # none | cycle
fill_colors: ["#eef3f7", "#dbe6ef", "#c7d8e8"] # with fill: cycle
labels: none # none | coordinates
font: { size: 7pt }Example: 07-tiling-hex.yaml. Preset:
tiling-hex-a4.
A perfect maze with an optional drawn solution. Procedural, so it takes a
seed.
generator: maze
cells: { x: 20, y: 28 } # required
algorithm: backtracker # backtracker | prim | kruskal
start: bottom-left # a corner
goal: top-right # a corner
min_path_factor: 0.0 # 0–1: bias toward a longer solution path
wall_weight: 0.5pt
color: "#000000"
solution_color: "#cc3333"
seed: 0
solution: none # none | overlay | separate_page | back_mirroredsolution: separate_page prints the solution on the next sheet;
back_mirrored puts it on the back for duplex printing. A maze item can span
several sheets — the handle does the doubling and numbering.
Example: 05-maze-booklet.yaml (a multi-page
booklet). Preset: maze-medium.
Rows of labelled fields — phone logs, checklists, planners, handover sheets. One level of nesting (a row of columns, a column of fields).
generator: form
title: { position: above, font: { size: 8pt } } # above | inline | none
gap: 3mm
weight: 0.4pt
color: "#000000"
rows:
- height: 15% # a percentage of the pattern-area height, or `rest`
columns:
- { title: "Name", width: 70% } # a text field (writing lines)
- { title: "Done", kind: check } # a tick box
- height: rest
columns:
- { title: "Priority", kind: choice, options: ["Lo", "Hi"] }Rows come first, then columns — one level of nesting. A row's height is a
percentage or rest; a column's width is a percentage of the row. A field's
kind is text (the default: ruled writing lines), check (a tick box) or
choice (named options). Labels like "Ja"/"Nein" come from your file, never
the tool — Ctrl+Grid adds no language of its own.
Example: 08-form-weekly.yaml. Preset:
phone-log-a5.
Grouped systems of staff lines, measured in staff spaces (sp). Clefs are real
glyphs from a bundled, subset music font.
generator: staves
count: 12 # number of staves (required)
lines: 5 # lines per stave (5 = music, 6 = guitar tab)
stave_space: 1.75mm # the sp unit (or set stave_height)
system_gap: 6.5sp # space between staves
weight: 0.2pt
color: "#000000"
clef: treble # none | treble | bass | alto | tenor
clef_indent: 3mmExample: 04-staves-treble.yaml. Preset:
staves-treble-a4.
A horizon with one to three vanishing points, each fanning equally divided rays, plus optional true verticals. The grid computes its own geometry (it does not use the cycle model).
generator: perspective
horizon:
at: 0.55 # height up the area, 0–1
weight: 0.3pt
color: "#000000"
vanishing_points:
- at: [-0.6, 0.55] # a point as area fractions, usually off the sheet
count: 16 # rays in the fan (≥ 2)
weight: 0.15pt
color: "#000000"
- at: [1.6, 0.55]
count: 16
weight: 0.15pt
verticals:
count: 18 # evenly spaced true verticals (≥ 2)
weight: 0.12ptA vanishing point's at is a point in area fractions (0 = left/bottom edge,
1 = right/top), so a value below 0 or above 1 puts it off the sheet — where
vanishing points usually sit. An optional base (top/bottom/left/right)
picks which edge is divided into the fan. Rays are clipped to the pattern area.
Example:
09-perspective-2pt.yaml. Preset:
perspective-2pt-a4.
A template to draw on: an N-fold scaffold plus motif families that carry the symmetry. Everything faces up (a vertical axis).
generator: mandala
sectors: 12 # order of symmetry (required, ≥ 2)
center: auto # or { x: …, y: … }
outer_radius: 48%s # auto, a length, or relativeThe motif families (all optional):
| Family | What it draws |
|---|---|
rings |
concentric guide circles, evenly spaced (count, weight, color) |
spokes |
N radial guides (inner clears the centre, weight, color) |
rosette |
N circles on the spokes (at, radius, mirror) |
petals |
a ring of pointed leaves, each two arcs (inner, outer, width, mirror) |
beads |
dots on a ring (at, count, size, rotate) |
scallops |
a wavy ring of arcs (at, count, depth, inward) |
pinwheel |
small polygons round a ring, twisted (at, size, sides, count, twist) |
polygons |
inscribed regular or star polygons (radius, sides, step, rotate, fill_color) |
rosette, petals, beads, scallops and pinwheel each take one entry or
a list — layered bands at different radii. at, radius, inner, outer,
width, depth and size (for pinwheel) are shares of the outer radius (0–1).
petals:
inner: 0.34
outer: 0.94
width: 0.12
mirror: true
beads:
- { at: 0.99, count: 36, size: 0.9mm }
- { at: 0.20, count: 12, size: 1.1mm }
rosette:
- { at: 0.5, radius: 0.16, mirror: true }Examples: 10-mandala.yaml and
10b-mandala-scallops.yaml. Preset:
mandala-a4.
Measurements in, a law computes the net: cut lines solid, creases dashed, glue tabs worked out. The one generator whose output you can check with scissors.
generator: net
style: tuck_top # tuck_top | tray
length: 80mm # inner dimensions — the space inside the box
width: 50mm
height: 30mm
thickness: 0.3mm # the material; 0 for thin paper
glue_tab: 12mm
tuck: 15mm # the tongue that slides into the front wall (tuck_top)Two conventions are worth knowing before you change a number:
- The dimensions are inner ones — the space the contents need, which is the number you actually have.
thicknesshas one rule: a panel that closes over a layer is widened by it, a flap that slides inside one is shortened by it.0gives the ideal net, which is right for paper;0.3mmis ordinary 250 g/m² card.
style: tray is an open box — base, four walls, four glue tabs.
style: tuck_top is the classic carton — a wall strip with a glue tab, and a
lid with a tongue at each end supported by dust flaps.
Cuts and creases are drawn through the ordinary keys, so § 2a's fold notation
needs nothing new: fold: { style: dashed } is a valley fold,
fold: { dash: [3, 1, 1, 1] } a mountain fold.
A net is never scaled. If it does not fit the sheet the run is refused with
the flat size it needs and the size there is — a box 2 mm out does not close, so
"fit to page" would be worse than an error. What else is refused: a dimension of
zero, a thickness at or past half the smallest dimension, tuck or dust on
a tray where they would do nothing, and an unknown style.
Example: 16-net-tray.yaml. Preset:
box-tuck-a4.
Paper folding has a notation — Yoshizawa–Randlett — and Ctrl+Grid needs no new keys for it: it is the dash machinery of §9, named.
| Means | Write |
|---|---|
| valley fold (fold towards you) | style: dashed |
| mountain fold (fold away) | style: dashed with dash: [3, 1, 1, 1] — dash-dot |
| reference crease (a guide, not a fold) | style: dotted, or a lighter color |
| cut line | style: solid — what net draws its outline with |
base_dash sets the scale: the numbers in dash are multiples of it, so
base_dash: 0.7mm with dash: [3, 1, 1, 1] is 2.1 mm on, 0.7 off, 0.7 on, 0.7
off. Keep the dashes short relative to the spacing — at three or four dashes per
cell the sheet reads as a grid; at one it reads as a row of ticks.
Which lines are valleys and which are mountains is a property of the model, not
of the paper, so a pre-creasing sheet does the useful half: it tells them
apart. precrease-16-a4 is one — a 16 × 16 grid of valleys with both diagonals
as mountains, on a square field:
ctrlgrid precrease-16-a4 -o precrease.pdfIts margins make the pattern area itself square (176 × 176 mm), so the diagonals are clipped to the field instead of running out over the rest of the sheet — and because a slanted family is spaced perpendicular to itself (§7.1), a diagonal spacing of 11 mm / √2 puts one diagonal through every corner of every cell.
Unlike every generator above, calendar does not fill one page — it produces a
whole linked document: one PDF of about 400 pages you navigate with the pen.
Tap a date and land on that day; tap a note number and land on that note. Built
for the reMarkable (the links are internal PDF links, which it follows), but it
prints on paper too.
generator: calendar
year: 2026
week_start: monday # or sunday
months: [January, …] # 12 names; English if omitted
weekdays: [Mon, Tue, …] # 7 names; English if omitted
holiday_color: "#fce9e4" # the fill for a marked day with no colour of its own
holidays:
- { date: 2026-12-25, label: Christmas }
- { date: 2026-05-03, label: "Mama's birthday", color: "#ffd9ec" }
holidays_file: holidays.ics # or a YAML list — merged with the inline ones
legend: # what the colours mean, on the contents page
- { color: "#fce9e4", label: "Public holidays" }
- { color: "#ffd9ec", label: "Birthdays" }
title_page: { title: "2026", subtitle: "your name", background: "#2f3a48" } # opt-in; +logo
year_view: { weekend_shade: "#f0f2f5", cell_link: day, day_numbers: both }
month_view: { weekend_shade: "#f0f2f5", surface: lines }
week_view: { surface: lines, tasks: true } # opt-in: one page per week
day:
blocks: # an ordered list — reorder, resize, repeat
- { type: schedule, from: 7, to: 22, height: 55%, surface: lines, half_hours: true }
- { type: todo, rows: 8, height: 20% }
- { type: notes, height: rest, surface: grid }
notes: # one pad, or a list of them
- { count: 20, surface: dots, label: "Sketches" } # blank | lines | dots | gridThe page types, each exactly one page (if a view is small, use the device's zoom — nothing scrolls or scales):
- Title (opt-in via
title_page) — a cover: a full-sheetbackgroundcolour with a centred title and subtitle intext_color, an optionallogo(a PNG above the title) and an optionalbackground_image(a PNG over the whole sheet;background_fit: coverfills and crops,containfits it inside, and transparent areas show the colour through). Both paths are relative to the definition file. The header and footer bands are off on the cover;header: true/footer: trueput them back, each on its own. - Contents — links to the overviews, the months and the note indices, one
centred column with the groups separated by whitespace.
legendadds a colour key at the foot of it — a swatch and your words for it. - Full-year overview — the whole year on one page as twelve mini-months, three across, numbers only: a day number jumps to its day, a month name to its month, and the week number beside each row jumps to its week when week pages exist.
- Half-year 1 & 2 — two month-column × day-row tables (Jan–Jun, Jul–Dec); a
month header jumps to its month, a day cell to its day (
cell_link: day | month | none). Short months' columns simply end — no empty cells. Six columns are wide enough that the eye loses the row on the way across, soday_numbers: bothrepeats the 1…31 column on the right edge as well. - Month — a list of every day; the weekday and the date link to the day page, marked days are labelled and filled, weekends shaded.
- Day — your ordered
blocks(a timedschedule, atodolist,notes), each with a writingsurface(blank/lines/dots/grid) and aheightin per cent orrest. Ascheduletakesfrom/tohours, andhalf_hours: truerules the half hours too — the hour line then steps up a shade so the half stays subordinate. Leaveday:out entirely and a day is one lined page. - Week (opt-in via
week_view) — one page per week (~53), seven day sections inweek_startorder with a writing surface and a tasks column; each date jumps to its day page. Weeks that straddle the year edge show the outside days without a link. - Notes —
notes:takes one pad or a list of them: lined pages to write on, squared ones to reckon on, dotted ones to sketch on. Each pad has its owncount,surfaceandlabel, numbers its pages from 1, and gets its own index — you reach for "Sketches 3", not for "note 23". The indexes link to one another, and a long pad paginates its index over several sheets.
Every page carries a small nav strip (Index · Year · Month · Notes) at the right edge as underlined text — a link is just underlined text plus its tap box, to save space and bytes.
Marked days are not only public holidays: a birthday or an anniversary is
the same thing to the calendar, and color per entry is what tells them apart —
an entry without one takes holiday_color. The mark shows in all five views.
legend is how the meanings get named; the calendar knows the colours and never
what they stand for, so a legend line without a colour is refused rather than
guessed at.
Holidays from a file. holidays_file reads either a YAML list of
{ date, label } or an .ics with concrete dates — a public-holiday feed
exported from a calendar app, for instance. The path is relative to the
definition file. Entries are filtered to year and merged with the inline list,
where an inline entry wins on a shared date: hand-authored beats a feed.
Recurring or timed .ics events are skipped and counted, and the run report
names the source and how many entries survived.
Names come from your definition (English by default), so the calendar adds no
language of its own. Dates are computed from year, so the same definition
always gives the same PDF. The optional header is constant on every page — set
header: { center: "{year}", right: "your name" } for the year and a
personalization (no page numbers), and give the band a background and
text_color (§ 10) if you want it to read as a spine. It is PDF only: on
PNG the run is refused, because links and text cannot live in a PNG.
Nothing is ever scaled to fit, so a view that cannot fit its page is refused before page one, naming the millimetres it needed: a contents column longer than the sheet, a mini-month wider than a third of the width, a month whose 31 rows would fall below a readable size, day blocks whose fixed heights add up past 100 %.
Preset: calendar-a4 (ctrlgrid show calendar-a4), which turns on every view;
worked example: 12-calendar-year.yaml — 405
pages, and the one example that ships without its PDF.
The tool ships no translations and never will (§3.4) — but it writes no word of its own on your sheet either. Everything printed comes from the definition:
months: [Jänner, Februar, März, April, Mai, Juni,
Juli, August, September, Oktober, November, Dezember]
weekdays: [Mo, Di, Mi, Do, Fr, Sa, So]
notes: [{count: 40, label: Notizen}]
words:
index: Inhalt
year: Jahr
month: Monat
week: Woche
notes: Notizen
contents: Inhaltsverzeichnis
full_year_overview: Jahresübersicht
half_year: Halbjahr
full_year: ganzes Jahrwords: names the calendar's own vocabulary — the navigation strip at the top of
every page and the headings. Leave it out and you get the English above, so no
existing definition changes. A notebook's one word is contents_title.
One limit to know about, and it is the same one the README lists first: the
standard PDF fonts reach Latin-1. German, French, Spanish, Italian, Portuguese,
Dutch and the Nordic languages are covered. Polish, Czech, Hungarian, Turkish,
Romanian and Croatian are not — ń, ř, ő, ğ, ă, č need a font of your
own:
font: {file: "~/Library/Fonts/NotoSans-Regular.ttf", size: 9pt}A document takes one font for the whole document — there is nothing on a
calendar page that wants two — and a blade takes one per labelled thing
(grid, polar.labels, form.title, tiling, and the ruler). Header and
footer have their own.
A character the font cannot draw is refused before the first page, naming the character and the word it came from — it is never printed as a box.
Which font, and where it goes. Two stages, and the difference matters (§10.3):
family: sans | serif | mono |
file: /path/to/font.ttf |
|
|---|---|---|
| what it is | Helvetica, Times, Courier — three of the 14 fonts every PDF reader has | any TrueType/OpenType file on your machine |
| embedded? | no, and it does not need to be | yes, and subset to the glyphs actually used |
| coverage | Latin-1 | whatever the file has |
| the PDF | carries no font at all | carries the font, and is the same everywhere |
It is a path and never a font name. Name lookup works differently on every platform and answers differently depending on what happens to be installed — which is precisely the unreliability this tool exists to avoid. A path is unambiguous, checkable, and can be named in an error message.
Before embedding, the fsType field of the font's OS/2 table is read: a font
whose licence forbids embedding stops the run and is named. There is no quiet
substitution — a PDF that violates a font licence is not an acceptable output.
The cover sheet records the file name and the font's version, so a sheet that
came out well can be reproduced years later.
So: making the PDF needs the file on your machine; the PDF it makes does not.
The other document generator, and the one that composes the rest: sections, each filled by an ordinary generator, with a contents page that links to them. One PDF on an e-ink device instead of twelve.
generator: notebook
title_page: { title: "Notebook", subtitle: "your name" } # optional
sections:
- label: "Bullet journal"
pages: 40
divider: true # a sheet with the section's name before it
generator: dots
grid: { x: { base_spacing: 5mm }, y: { base_spacing: 5mm } }
base_size: 0.4mm
- label: "Music"
pages: 10
generator: staves
count: 10
stave_space: 1.8mm
clef: trebleA section is a definition in miniature: generator: and then that
generator's own keys, exactly as at the top of a file. Anything a blade can do,
a section can do — copy the keys out of any preset. A typo inside a section is
reported by that blade, naming the section (sections.2).
The pages come in this order: the optional title, the contents, then each section — its divider if it asked for one, then its pages. The contents names each section, links to it, and prints the page it starts on, because a notebook is a paper object too and there a link is only underlined text.
A section may be a puzzle book. maze with solution: separate_page puts
each solution on the sheet after its puzzle, and inside a section that works
exactly as it does on its own:
- label: "Mazes"
pages: 12 # twelve *puzzles* — twenty-four pages
generator: maze
cells: { x: 16, y: 22 }
solution: separate_pagepages: counts puzzles, not sheets, the same way --pages 12 does for a maze
on its own. With solution: back_mirrored the solution goes on the back of
its puzzle's sheet, so it shows through against the light — and because that
needs the puzzle on the front of a sheet, the notebook may insert one blank leaf
before the section to line the parity up. The run says so when it does.
That mode also needs the pattern area to sit in the same place on both sides:
either duplex: false, or inner and outer margins equal. Otherwise the run
is refused, naming the section.
Bands are worth setting here: {section} names the section a page belongs to
and {page} counts, both filled per page.
header: { height: 8mm, gap: 3mm, left: "{section}" }
footer: { height: 8mm, gap: 3mm, right: "{page} / {page_count}" }What is refused, before the first page: an unknown generator (with the list of
known ones), a document generator inside a section — notebooks do not nest —
pages: 0, an empty sections:, anything a section's own generator refuses for
the page size, a contents page too long for its sheet, and a section whose
generator needs more than one sheet per item (a maze with solution: separate_page, whose solutions could not be paired with their puzzles here).
Not in this version, and named rather than forgotten: no per-section snap,
remainder or align (those are page geometry for the whole document), no
per-section paper size.
Both calendar and notebook own their pages, and that decides which of the
handle's keys apply to them.
They take the whole page model. page.margin including duplex — the
margins swap on even sheets, which is what a bound notebook wants — plus
page.background, page.hole_marks, border, ruler, stamp, and a header
and footer whose placeholders are filled per page ({page}, and {section} in
a notebook).
They refuse three things by name, rather than accepting them and doing nothing:
| Asked for | Why it is refused |
|---|---|
--nup |
imposition works on a page loop a document does not have, and it would destroy the links a calendar is made of |
--cover |
a document writes its own first page; title_page is the place for one |
pattern.align |
it anchors a pattern in its area, and a document page is not one pattern area |
Preset: notebook-a4 (106 pages). Example:
15-notebook.yaml.
Instead of a paper format, target an e-ink screen. The device's physical size comes from its pixel count divided by its density, so the template fits the screen exactly.
page:
device: remarkable-paper-proRun ctrlgrid devices to list profiles and where their figures come from. Two
ship today: the reMarkable Paper Pro (owner-verified, a colour device) and the
reMarkable 2 (manufacturer-specified, monochrome).
With a device active:
pxresolves against the density, so you can size things in pixels.snap: pixelrounds every step to whole pixels for a crisp render, and the exact resulting size is reported.- The media check runs automatically: it warns when a line is too thin to
survive the screen's resolution, or when two colours collapse to the same
grey on a monochrome device.
--strictturns every such warning into an error (good for a CI check of a preset set).
The media check runs whatever the output format — a PDF built for a device is checked just like a PNG.
Print several small pages onto one large sheet, without scaling:
ctrlgrid -d card.yaml --format a6 --pages 4 --nup 2x2 --nup-sheet a4 -o sheet.pdf--nup CxR lays a C×R grid of pages at 100 %. If the small pages do not fit the
big sheet at full size, the run refuses (it never shrinks them — that would break
the promise). --crop-marks adds trim marks. The cover sheet is exempt from
imposition.
A document generator (calendar, notebook) refuses --nup outright: it
writes its own pages rather than running the page loop imposition works on, and
imposing would destroy the links such a document is made of.
ctrlgrid millimeter-a4 --format a5 --pages 8 --booklet --nup-sheet 297x210mm -o booklet.pdfFour sheet sides carrying pages 8-1, 2-7, 6-3, 4-5. Print double-sided flipping on the short edge, fold the stack in half, staple through the fold. The run report names the flip and the one thing to check on the first sheet: page 2 must come out behind page 1.
If your printer turns the paper the other way, say so and build it again:
ctrlgrid millimeter-a4 --format a5 --pages 8 --booklet --booklet-flip long \
--nup-sheet 297x210mm -o booklet.pdfThe backs then come out printed upside down, which is exactly right: a long-edge turn shows them upside down, so they have to be printed that way to be read the right way up.
If your pages carry no numbers — plain grid paper, say — nothing on the sheet shows whether the flip was right, and the run says so. Add a footer for one test run:
footer:
height: 10mm
center: "{page}"Then blank the footer again. With genuinely identical pages the flip makes no visible difference anyway; it matters as soon as the pages differ.
Three things to know before the first run:
- The sheet must be landscape. Two portrait pages sit side by side, and
--nup-sheet a4is portrait — so write the size out, as above. If you forget, the refusal tells you which size to use. - A page count that is not a multiple of four is padded with blank leaves, and the run says how many. A folded sheet carries four pages; nothing is scaled to make the count come out even.
- One signature. Every sheet nests inside the next, one staple through the fold. Past roughly forty pages the fold creep becomes visible and the stack staples badly — split the work into several booklets instead.
--booklet and --nup are refused together, and a document generator refuses
--booklet for the same reason it refuses --nup.
- PDF — the reference output: vector, exact
MediaBox, embedded and subset fonts. Same input produces byte-identical output every run (fixed creation date, content-derived document id), so a definition under version control has a stable PDF. - PNG — one file per page at the device's exact pixel resolution. Chosen by a
.pngextension on-o. The PNG writer cannot draw text (the standard fonts have no glyph file), so a run that needs text on PNG is refused by name, with the way out (name a font file, drop the text, or output PDF).
A run is refused up front when the chosen writer lacks a feature the definition needs — text on PNG is the usual case, and a calendar's links the other. That is the right default: half a file is worse than none.
--skip-unsupported is the explicit way out. The run then leaves out what
cannot be drawn and carries on, and says what it left out:
ctrlgrid -d pad.yaml -o pad.png --skip-unsupported
note: --skip-unsupported: leaving out text — the writer cannot draw it,
so those marks are not on the sheet at all (§ 10.2)The name is deliberate: an --anyway next to --force would be two flags that
both read "do it regardless" and do different things — --force is about the
file, this one about the content.
Give a list of names, one per line, and get one sheet per name with {name}
filled in:
ctrlgrid millimeter-a4 --names class3b.txt -o sheets.pdfheader:
height: 8mm
left: "{name}"With --names and no --pages, the list leads: one sheet per entry. With a
count as well, the count leads and entries repeat or are cut (a notice tells you
when a list was cut, rather than cutting it silently). Each named page also gets
a PDF bookmark.
Text (header/footer, labels, the stamp, form titles) uses one of three logical
families: serif, sans, mono. These map to the standard PDF fonts, whose
metrics are fixed, so geometry is identical on every machine.
For characters outside Latin-1 (ł, ğ, ő), name a font file:
header:
font: { file: fonts/Inter.ttf, size: 10pt }The file is embedded and subset (only the glyphs used travel with the PDF). Its
embedding licence is checked, not assumed: a font whose fsType forbids
embedding aborts the run and is named — never quietly swapped for another.
Presets are ordinary definition files that ship with the tool — they are also the documentation. There is one for every generator:
dots-5mm grid-a4 mandala-a4 maze-medium
perspective-2pt-a4 phone-log-a5 polar-a4 staves-treble-a4
tiling-hex-a4 box-tuck-a4 calendar-a4 notebook-a4
And seven that are papers rather than generators — every one of them is lines
with a different cycle, which is the argument of §9 made in files:
| Preset | What it is |
|---|---|
calligraphy-a4 |
an italic guide: a ruled hand crossed by the 55° pen slant and a 135° angle check |
seyes-a4 |
the French school ruling (grands carreaux): 2 mm rows with every fourth line strong, 8 mm verticals, and the red margin rule |
mizige-a4 |
米字格, Chinese character practice: 20 mm boxes with both midlines and both diagonals |
knitting-chart-a4 |
chart paper proportional to a knitting gauge — a stitch is wider than a row, so the two axes differ |
plot-a4 |
a sheet for plotting by hand: millimetre grid, a coordinate cross through the middle, and a scale whose zero sits there too |
precrease-16-a4 |
a 16 × 16 pre-creasing sheet for folding, with both diagonals — and the fold notation of §12 shown in one file |
millimeter-a4 |
plain 5 mm graph paper, the one to start from |
ctrlgrid presets # list them
ctrlgrid show millimeter-a4 # print one
ctrlgrid show millimeter-a4 > mine.yaml # copy and bend it
ctrlgrid millimeter-a4 --format a5 -o out.pdf # run one with overridesThe whole point is scale, so a printer that rescales defeats it. Two habits:
- Print at 100 %, not "fit to page". Most viewers default to fitting the
page and quietly scale to about 96 %. Choose "Actual size" / "100 %". Run with
--coverand measure the 50 mm square and 100 mm rule with a ruler — if they are wrong, the driver scaled. - Mind thin lines and character coverage. On a home printer a 0.1 pt line is
fine; on an e-ink screen it may vanish — the media check warns. Without a font
file,
ä ö ü ß é à ñ çwork butł ğ ődo not; name a font file for those.
The example gallery in ../examples/ shows one rendered sheet per
generator, each with its definition and PDF.
Ctrl+Grid validates everything before writing page one, and either aborts
completely or builds completely — it never leaves a half-written file. ctrlgrid check <file> runs exactly that validation and writes nothing.
The design principle: a PDF that is almost right is the worst outcome, so the tool fails loudly rather than guessing. An error names the field, the value and, where it can, the fix in your own units. A feature named in the spec but not yet built refuses with a message naming what is missing — never a silently ignored key.
| Unit | Kind | Where |
|---|---|---|
mm cm in pt |
length | everywhere |
%w %h %s |
length, relative to the pattern area (width / height / shorter side) | most length fields |
px |
length, device pixels | only with a device profile |
deg |
angle | angles |
sp |
staff space | staves |
dpi |
density | device profiles only |
a3, a4, a5, a6, letter, legal, tabloid, or an exact size such as
210x99mm or 8.5x11in. Or a device (§13) instead of a format.
| Flag | Meaning |
|---|---|
-V, --version |
print the version and exit |
-d <file> |
build from a definition file |
-o <file> |
output path; the extension picks PDF or PNG |
--pages <n> |
number of numbered sheets |
--names <file> |
one sheet per name; fills {name} |
--format <name> |
paper format |
--device <id> |
e-ink device profile (instead of a format) |
--orientation <portrait|landscape> |
orientation |
--stamp <text> |
full-page stamp |
--cover |
add the calibration cover sheet |
--embed-def |
embed the definition's source in the PDF |
--seed <n> |
seed for procedural generators (maze) |
--strict |
turn media warnings into errors |
--skip-unsupported |
leave out what the writer cannot draw, and say so |
--nup <CxR> |
N-up imposition, never scaled |
--nup-sheet <format> |
the sheet format for imposition |
--booklet |
impose as a folded, saddle-stitched booklet, never scaled |
--booklet-flip <short|long> |
which edge the printer turns on (default short) |
--crop-marks |
trim marks with --nup or --booklet |
--force |
overwrite an existing output file |
--quiet |
report only the output path |
Switch flags (--cover, --strict, --embed-def, --skip-unsupported,
--crop-marks, --booklet) only turn
something on; the definition decides when the flag is absent. All flags beat the
definition.
| Generator | Produces | Key required field |
|---|---|---|
lines |
squared, ruled, isometric, log/semi-log | families |
dots |
dot grids with emphasis | grid |
grid |
labelled cell blocks | cells |
polar |
targets, score discs, polar paper | — (rings/spokes) |
tiling |
hex/tri/square/rhombus/octagon nets | shape, size |
maze |
rectangular mazes, optional solutions | cells |
form |
fillable forms | rows |
staves |
music staves, guitar tab, clefs | count |
perspective |
1–3 point vanishing-point grids | — (horizon/points) |
mandala |
rotationally symmetric templates | sectors |
calendar |
a linked, write-on planner PDF (many pages) | year |