Development - No stable release of Keisen has been published yet. This documentation is built from the main branch and will change without notice.

Keisen Keisen Keisen
  • Home
  • Reference
  • Examples
  • Gallery
  • Changelog
  • dev
    • Versions
  • GitHub
Skip to main content

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

See the gallery Browse examples

A sales table titled Regional sales with each part named in red in the right margin: header, spanner, column labels, row group, stub and body, summary row, grand summary, and source note.

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.

A booktabs table titled Baseline characteristics. A Treatment arm spanner with footnote mark one covers the Drug and Placebo columns, each labelled with its size. Rows are grouped under Demographics and Disease history, and each cell shows a value with its spread in brackets.

Baseline characteristics

A booktabs table titled Income statement, with 2025 and 2026 columns under a Pounds sterling spanner. Revenue lines and Operating costs lines each end with a Subtotal row. A shaded Operating profit row closes the table.

Income statement

A booktabs table titled Drinking water assay, with eight samples. The Lead column shows each value plus or minus its uncertainty, and the two results over 10 are bold and red. Cadmium shows n.d. for two samples, and Gross alpha uses scientific notation.

Laboratory assay

A table titled League table, with eight teams. The goal difference cells are coloured from red for negative to blue for positive. A small line chart shows the last five results, and the top two rows are green and the bottom row red.

League standings

A table titled Population by region, with nine regions. Each row shows a population with grouped digits, a share as a percentage, a small bar chart of five age bands, and a median age. A Total row closes the table.

Population by region

A booktabs table titled Change in HbA1c at 26 weeks. Unadjusted and Adjusted spanners each cover an Estimate, a 95% CI, and a p column for five terms. Small p-values show as less than 0.001, and the adjusted results with p below 0.05 are in bold.

Regression results
No matching items

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.

  • Keisen
  • Native Typst
// 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.

Back to top

Powered by Quarto.

© Mickaël Canouil. Sponsor.

  • Edit this page
  • Report an issue

Licence: MIT.