Keisen 罫線
Display tables for Typst, built from data rather than drawn by hand.
Describe the parts and the formatting, and Keisen does the bookkeeping: subtotals, spanners, footnote marks, and numbers aligned on the decimal point. Keisen (罫線) is Japanese for the ruled lines of a table.
Development version
The parts of a display table, named on the table they build.
Six domains, each built twice
Each gallery entry builds one table with Keisen and again in native Typst, from the same data.
Keisen and native Typst
Keisen saves some typing, but that isn’t the point. Across the gallery, the native version of a table runs from a little longer to about twice as long. The bigger difference is what the lines say. Native code says how to build the table: loop over the sections, sum each column, and put each rule in its place. Keisen says what the table is, so when the rows change, the table follows. Add a section or another line to the data, and there’s no code to touch.
// Gallery: income statement, built with Keisen.
// Its native twin is gallery-income-statement-native.typ; the two render alike.
#import "@preview/keisen:0.0.1": *
#set page(width: auto, height: auto, margin: 0.5cm)
#set text(font: "Libertinus Serif", size: 10pt)
#let data = (
section: ("Revenue",) * 3 + ("Operating costs",) * 4,
line: (
"Product sales",
"Services",
"Licensing",
"Cost of sales",
"Staff",
"Premises",
"Marketing",
),
fy2025: (4820000, 1310000, 245000, -2410000, -1870000, -390000, -265000),
fy2026: (5340000, 1480000, 228000, -2650000, -1990000, -405000, -310000),
)
#display-table(
data,
table-header(title: [Income statement], subtitle: [Years ending 31 March]),
table-stub(rowname: "line", group: "section", label: [Line]),
columns-label(fy2025: [2025], fy2026: [2026]),
table-spanner([Pounds sterling], ("fy2025", "fy2026")),
format-currency(("fy2025", "fy2026"), currency: "GBP", decimals: 0),
summary-rows(functions: (Subtotal: aggregate-sum), columns: (
"fy2025",
"fy2026",
)),
grand-summary-rows(functions: ("Operating profit": aggregate-sum), columns: (
"fy2025",
"fy2026",
)),
table-style(style(fill: luma(240)), locations: cells-grand-summary()),
table-source-note([Source: synthetic accounts.]),
theme: theme-booktabs(),
)// Gallery: income statement, built in native Typst from the same data.
// Its Keisen twin is gallery-income-statement-keisen.typ; the two render alike.
#set page(width: auto, height: auto, margin: 0.5cm)
#set text(font: "Libertinus Serif", size: 10pt)
#let data = (
section: ("Revenue",) * 3 + ("Operating costs",) * 4,
line: (
"Product sales",
"Services",
"Licensing",
"Cost of sales",
"Staff",
"Premises",
"Marketing",
),
fy2025: (4820000, 1310000, 245000, -2410000, -1870000, -390000, -265000),
fy2026: (5340000, 1480000, 228000, -2650000, -1990000, -405000, -310000),
)
#let grouped(number) = {
let digits = str(calc.abs(number))
let groups = ()
while digits.len() > 3 {
groups.insert(0, digits.slice(-3))
digits = digits.slice(0, -3)
}
groups.insert(0, digits)
(if number < 0 { "−" } else { "" }) + groups.join(sym.space.thin)
}
#let pounds(number) = {
let amount = grouped(calc.abs(number))
(if number < 0 { "−" } else { "" }) + "£" + amount
}
#let years = ("fy2025", "fy2026")
#let body = ()
#for section in ("Revenue", "Operating costs") {
let rows = range(data.line.len()).filter(row => (
data.section.at(row) == section
))
body.push(table.cell(colspan: 3, strong(section)))
for row in rows {
body.push(data.line.at(row))
body += years.map(year => pounds(data.at(year).at(row)))
}
body.push(table.hline(stroke: 0.5pt))
body.push([*Subtotal*])
body += years.map(year => pounds(
rows.map(row => data.at(year).at(row)).sum(),
))
}
#table(
columns: 3,
align: (start, end, end),
stroke: none,
inset: 0.45em,
table.hline(stroke: 1pt),
table.cell(colspan: 3, align: start, text(
size: 1.1em,
weight: "bold",
)[Income statement]),
table.cell(colspan: 3, align: start)[Years ending 31 March],
table.header(
[],
table.cell(colspan: 2, align: center)[*Pounds sterling*],
table.hline(start: 1, stroke: 0.5pt),
[*Line*], [*2025*], [*2026*],
table.hline(stroke: 0.5pt),
),
..body,
table.hline(stroke: 0.5pt),
table.cell(fill: luma(240))[*Operating profit*],
..years.map(year => table.cell(fill: luma(240), pounds(data.at(year).sum()))),
table.cell(colspan: 3, align: start, text(
size: 0.8em,
)[Source: synthetic accounts.]),
table.hline(stroke: 1pt),
)Both come from the income statement. The Keisen tab shows the whole Keisen file. The Native Typst tab shows the whole native file, with the loop that builds the body of the table highlighted. Around that loop, the file places the header, the rules and the total, and brings its own helpers to group the digits and print the pounds.
Keisen also looks after the details that are easy to get wrong. Sums stay exact because they run in decimals, footnote marks are numbered in reading order, a group’s label comes back after a page break, and negative numbers get a true minus sign. A theme is a value too, so you can define your house style once and pass it to every table in a report.
The grammar
Keisen implements the display-table grammar that R’s gt and Python’s great_tables made familiar, designed for Typst rather than ported to it.
| Layer | What it gives you |
|---|---|
| Parts | A header, a stub with row names and groups, column labels with spanners, summary rows, source notes, and footnotes numbered in reading order. |
| Formatters | Numbers, percentages, currency, scientific notation, byte sizes, dates, and markup, computed in Typst’s decimal type rather than in floats. |
| Locations | Styling addressed by what the data says, so a cell is highlighted because its margin fell below a threshold, not because it is in row nine. |
| Nanoplots | Sparklines and bars inside cells, sharing one domain down a column so they can be compared. |
| Themes | Four presets that are option dictionaries, so a theme and an option call compose by merging. |
Quick look
The table at the top of this page comes from this file, with each part labelled.
// Rows grouped by region, with a subtotal for each group and a grand total.
#import "@preview/keisen:0.0.1": *
#set page(width: auto, height: auto, margin: 0.5cm)
#set text(font: "Libertinus Serif", size: 10pt)
#display-table(
(
product: ("Bolt", "Nut", "Beam", "Plate"),
region: ("North", "North", "South", "South"),
units: (1250, 860, 430, 2100),
revenue: (18750.5, 12900.25, 21500, 31500.75),
),
table-header(title: [Regional sales], subtitle: [Financial year 2025 to 2026]),
table-stub(rowname: "product", group: "region", label: [Product]),
columns-label(units: [Units], revenue: [Revenue]),
table-spanner([Performance], ("units", "revenue")),
format-integer("units"),
format-number("revenue", decimals: 2),
summary-rows(functions: (Subtotal: aggregate-sum), columns: ("units", "revenue")),
grand-summary-rows(functions: (Total: aggregate-sum), columns: ("units", "revenue")),
table-source-note([Source: internal ledger.]),
table-options(row-striping: true),
)No dependencies
Keisen imports no third-party package, so formatting stays pluggable and installing it pulls in nothing else.
Native row groups
Row groups are native repeating subheaders, so a group that spans a page break prints its label again.
Status
Import Keisen from Typst Universe, as each example shows. The API can still change between 0.x releases. Read the changelog for what each release changed.