Universe

Powerful, Simple, Concise

A Typst plugin for turning data into tables.

Outline

Input Format and Creation

The tabut function takes input in “record” format, an array of dictionaries, with each dictionary representing a single “object” or “record”.

In the example below, each record is a listing for an office supply product.

#let supplies = (
  (name: "Notebook", price: 3.49, quantity: 5),
  (name: "Ballpoint Pens", price: 5.99, quantity: 2),
  (name: "Printer Paper", price: 6.99, quantity: 3),
)

Basic Table

Now create a basic table from the data.

#import "@preview/tabut:1.0.2": tabut
#import "example-data/supplies.typ": supplies

#tabut(
  supplies, // the source of the data used to generate the table
  ( // column definitions
    (
      header: [Name], // label, takes content.
      func: r => r.name // generates the cell content.
    ), 
    (header: [Price], func: r => r.price), 
    (header: [Quantity], func: r => r.quantity), 
  )
)

funct takes a function which generates content for a given cell corrosponding to the defined column for each record. r is the record, so r => r.name returns the name property of each record in the input data if it has one.

The philosphy of tabut is that the display of data should be simple and clearly defined, therefore each column and it’s content and formatting should be defined within a single clear column defintion. One consequence is you can comment out, remove or move, any column easily, for example:

#import "@preview/tabut:1.0.2": tabut
#import "example-data/supplies.typ": supplies

#tabut(
  supplies,
  (
    (header: [Price], func: r => r.price), // This column is moved to the front
    (header: [Name], func: r => r.name), 
    (header: [Name 2], func: r => r.name), // copied
    // (header: [Quantity], func: r => r.quantity), // removed via comment
  )
)

Table Styling

Any default Table style options can be tacked on and are passed to the final table function.

#import "@preview/tabut:1.0.2": tabut
#import "example-data/supplies.typ": supplies

#tabut(
  supplies,
  ( 
    (header: [Name], func: r => r.name), 
    (header: [Price], func: r => r.price), 
    (header: [Quantity], func: r => r.quantity),
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

Header Formatting

You can pass any content or expression into the header property.

#import "@preview/tabut:1.0.2": tabut
#import "example-data/supplies.typ": supplies

#let fmt(it) = {
  heading(
    outlined: false,
    upper(it)
  )
}

#tabut(
  supplies,
  ( 
    (header: fmt([Name]), func: r => r.name ), 
    (header: fmt([Price]), func: r => r.price), 
    (header: fmt([Quantity]), func: r => r.quantity), 
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

Remove Headers

You can prevent from being generated with the headers paramater. This is useful with the tabut-cells function as demonstrated in it’s section.

#import "@preview/tabut:1.0.2": tabut
#import "example-data/supplies.typ": supplies

#tabut(
  supplies,
  (
    (header: [*Name*], func: r => r.name), 
    (header: [*Price*], func: r => r.price), 
    (header: [*Quantity*], func: r => r.quantity), 
  ),
  headers: false, // Prevents Headers from being generated
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none,
)

Cell Expressions and Formatting

Just like the headers, cell contents can be modified and formatted like any content in Typst.

#import "@preview/tabut:1.0.2": tabut
#import "usd.typ": usd
#import "example-data/supplies.typ": supplies

#tabut(
  supplies,
  ( 
    (header: [*Name*], func: r => r.name ), 
    (header: [*Price*], func: r => usd(r.price)), 
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

You can have the cell content function do calculations on a record property.

#import "@preview/tabut:1.0.2": tabut
#import "usd.typ": usd
#import "example-data/supplies.typ": supplies

#tabut(
  supplies,
  ( 
    (header: [*Name*], func: r => r.name ), 
    (header: [*Price*], func: r => usd(r.price)), 
    (header: [*Tax*], func: r => usd(r.price * .2)), 
    (header: [*Total*], func: r => usd(r.price * 1.2)), 
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

Or even combine multiple record properties, go wild.

#import "@preview/tabut:1.0.2": tabut

#let employees = (
    (id: 3251, first: "Alice", last: "Smith", middle: "Jane"),
    (id: 4872, first: "Carlos", last: "Garcia", middle: "Luis"),
    (id: 5639, first: "Evelyn", last: "Chen", middle: "Ming")
);

#tabut(
  employees,
  ( 
    (header: [*ID*], func: r => r.id ),
    (header: [*Full Name*], func: r => [#r.first #r.middle.first(), #r.last] ),
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

Index

tabut automatically adds an _index property to each record.

#import "@preview/tabut:1.0.2": tabut
#import "example-data/supplies.typ": supplies

#tabut(
  supplies,
  ( 
    (header: [*\#*], func: r => r._index),
    (header: [*Name*], func: r => r.name ), 
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

You can also prevent the index property being generated by setting it to none, or you can also set an alternate name of the index property as shown below.

#import "@preview/tabut:1.0.2": tabut
#import "example-data/supplies.typ": supplies

#tabut(
  supplies,
  ( 
    (header: [*\#*], func: r => r.index-alt ),
    (header: [*Name*], func: r => r.name ), 
  ),
  index: "index-alt", // set an aternate name for the automatically generated index property.
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

Transpose

This was annoying to implement, and I don’t know when you’d actually use this, but here.

#import "@preview/tabut:1.0.2": tabut
#import "usd.typ": usd
#import "example-data/supplies.typ": supplies

#tabut(
  supplies,
  (
    (header: [*\#*], func: r => r._index),
    (header: [*Name*], func: r => r.name), 
    (header: [*Price*], func: r => usd(r.price)), 
    (header: [*Quantity*], func: r => r.quantity),
  ),
  transpose: true,  // set optional name arg `transpose` to `true`
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

Alignment

#import "@preview/tabut:1.0.2": tabut
#import "usd.typ": usd
#import "example-data/supplies.typ": supplies

#tabut(
  supplies,
  ( // Include `align` as an optional arg to a column def
    (header: [*\#*], func: r => r._index),
    (header: [*Name*], align: right, func: r => r.name), 
    (header: [*Price*], align: right, func: r => usd(r.price)), 
    (header: [*Quantity*], align: right, func: r => r.quantity),
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

You can also define Alignment manually as in the the standard Table Function.

#import "@preview/tabut:1.0.2": tabut
#import "usd.typ": usd
#import "example-data/supplies.typ": supplies

#tabut(
  supplies,
  ( 
    (header: [*\#*], func: r => r._index),
    (header: [*Name*], func: r => r.name), 
    (header: [*Price*], func: r => usd(r.price)), 
    (header: [*Quantity*], func: r => r.quantity),
  ),
  align: (auto, right, right, right), // Alignment defined as in standard table function
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

Column Width

#import "@preview/tabut:1.0.2": tabut
#import "usd.typ": usd
#import "example-data/supplies.typ": supplies

#box(
  width: 300pt,
  tabut(
    supplies,
    ( // Include `width` as an optional arg to a column def
      (header: [*\#*], func: r => r._index),
      (header: [*Name*], width: 1fr, func: r => r.name), 
      (header: [*Price*], width: 20%, func: r => usd(r.price)), 
      (header: [*Quantity*], width: 1.5in, func: r => r.quantity),
    ),
    fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
    stroke: none,
  )
)

You can also define Columns manually as in the the standard Table Function.

#import "@preview/tabut:1.0.2": tabut
#import "usd.typ": usd
#import "example-data/supplies.typ": supplies

#box(
  width: 300pt,
  tabut(
    supplies,
    (
      (header: [*\#*], func: r => r._index),
      (header: [*Name*], func: r => r.name), 
      (header: [*Price*], func: r => usd(r.price)), 
      (header: [*Quantity*], func: r => r.quantity),
    ),
    columns: (auto, 1fr, 20%, 1.5in),  // Columns defined as in standard table
    fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
    stroke: none,
  )
)

Get Cells Only

#import "@preview/tabut:1.0.2": tabut-cells
#import "usd.typ": usd
#import "example-data/supplies.typ": supplies

#tabut-cells(
  supplies,
  ( 
    (header: [Name], func: r => r.name), 
    (header: [Price], func: r => usd(r.price)), 
    (header: [Quantity], func: r => r.quantity),
  )
)

Use with Tablex

#import "@preview/tabut:1.0.2": tabut-cells
#import "usd.typ": usd
#import "example-data/supplies.typ": supplies

#import "@preview/tablex:0.0.8": tablex, rowspanx, colspanx

#tablex(
  auto-vlines: false,
  header-rows: 2,

  /* --- header --- */
  rowspanx(2)[*Name*], colspanx(2)[*Price*], (), rowspanx(2)[*Quantity*],
  (),                 [*Base*], [*W/Tax*], (),
  /* -------------- */

  ..tabut-cells(
    supplies,
    ( 
      (header: [], func: r => r.name), 
      (header: [], func: r => usd(r.price)), 
      (header: [], func: r => usd(r.price * 1.3)), 
      (header: [], func: r => r.quantity),
    ),
    headers: false
  )
)

While technically seperate from table display, the following are examples of how to perform operations on data before it is displayed with tabut.

Since tabut assumes an “array of dictionaries” format, then most data operations can be performed easily with Typst’s native array functions. tabut also provides several functions to provide additional functionality.

CSV Data

By default, imported CSV gives a “rows” or “array of arrays” data format, which can not be directly used by tabut. To convert, tabut includes a function rows-to-records demonstrated below.

#import "@preview/tabut:1.0.2": tabut, rows-to-records
#import "example-data/supplies.typ": supplies

#let titanic = {
  let titanic-raw = csv("example-data/titanic.csv");
  rows-to-records(
    titanic-raw.first(), // The header row
    titanic-raw.slice(1, -1), // The rest of the rows
  )
}

Imported CSV data are all strings, so it’s usefull to convert them to int or float when possible.

#import "@preview/tabut:1.0.2": tabut, rows-to-records
#import "example-data/supplies.typ": supplies

#let auto-type(input) = {
  let is-int = (input.match(regex("^-?\d+$")) != none);
  if is-int { return int(input); }
  let is-float = (input.match(regex("^-?(inf|nan|\d+|\d*(\.\d+))$")) != none);
  if is-float { return float(input) }
  input
}

#let titanic = {
  let titanic-raw = csv("example-data/titanic.csv");
  rows-to-records( titanic-raw.first(), titanic-raw.slice(1, -1) )
  .map( r => {
    let new-record = (:);
    for (k, v) in r.pairs() { new-record.insert(k, auto-type(v)); }
    new-record
  })
}

tabut includes a function, records-from-csv, to automatically perform this process.

#import "@preview/tabut:1.0.2": records-from-csv

#let titanic = records-from-csv(csv("example-data/titanic.csv"));

Slice

#import "@preview/tabut:1.0.2": tabut, records-from-csv
#import "usd.typ": usd
#import "example-data/titanic.typ": titanic

#let classes = (
  "N/A",
  "First", 
  "Second", 
  "Third"
);

#let titanic-head = titanic.slice(0, 5);

#tabut(
  titanic-head,
  ( 
    (header: [*Name*], func: r => r.Name), 
    (header: [*Class*], func: r => classes.at(r.Pclass)),
    (header: [*Fare*], func: r => usd(r.Fare)), 
    (header: [*Survived?*], func: r => ("No", "Yes").at(r.Survived)), 
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

Sorting and Reversing

#import "@preview/tabut:1.0.2": tabut
#import "usd.typ": usd
#import "example-data/titanic.typ": titanic, classes

#tabut(
  titanic
  .sorted(key: r => r.Fare)
  .rev()
  .slice(0, 5),
  ( 
    (header: [*Name*], func: r => r.Name), 
    (header: [*Class*], func: r => classes.at(r.Pclass)),
    (header: [*Fare*], func: r => usd(r.Fare)), 
    (header: [*Survived?*], func: r => ("No", "Yes").at(r.Survived)), 
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

Filter

#import "@preview/tabut:1.0.2": tabut
#import "usd.typ": usd
#import "example-data/titanic.typ": titanic, classes

#tabut(
  titanic
  .filter(r => r.Pclass == 1)
  .slice(0, 5),
  ( 
    (header: [*Name*], func: r => r.Name), 
    (header: [*Class*], func: r => classes.at(r.Pclass)),
    (header: [*Fare*], func: r => usd(r.Fare)), 
    (header: [*Survived?*], func: r => ("No", "Yes").at(r.Survived)), 
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

Aggregation using Map and Sum

#import "usd.typ": usd
#import "example-data/titanic.typ": titanic, classes

#table(
  columns: (auto, auto),
  [*Fare, Total:*], [#usd(titanic.map(r => r.Fare).sum())],
  [*Fare, Avg:*], [#usd(titanic.map(r => r.Fare).sum() / titanic.len())], 
  stroke: none
)

Grouping

#import "@preview/tabut:1.0.2": tabut, group
#import "example-data/titanic.typ": titanic, classes

#tabut(
  group(titanic, r => r.Pclass),
  (
    (header: [*Class*], func: r => classes.at(r.value)), 
    (header: [*Passengers*], func: r => r.group.len()), 
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

#import "@preview/tabut:1.0.2": tabut, group
#import "usd.typ": usd
#import "example-data/titanic.typ": titanic, classes

#tabut(
  group(titanic, r => r.Pclass),
  (
    (header: [*Class*], func: r => classes.at(r.value)), 
    (header: [*Total Fare*], func: r => usd(r.group.map(r => r.Fare).sum())), 
    (
      header: [*Avg Fare*], 
      func: r => usd(r.group.map(r => r.Fare).sum() / r.group.len())
    ), 
  ),
  fill: (_, row) => if calc.odd(row) { luma(240) } else { luma(220) }, 
  stroke: none
)

tabut

Takes data and column definitions and outputs a table.

tabut(
  data-raw, 
  colDefs, 
  columns: auto,
  align: auto,
  index: "_index",
  transpose: false,
  headers: true,
  ..tableArgs
) -> content

Parameters

data-raw
This is the raw data that will be used to generate the table. The data is expected to be in an array of dictionaries, where each dictionary represents a single record or object.

colDefs
These are the column definitions. An array of dictionaries, each representing column definition. Must include the properties header and a func. header expects content, and specifies the label of the column. func expects a function, the function takes a record dictionary as input and returns the value to be displayed in the cell corresponding to that record and column. There are also two optional properties; align sets the alignment of the content within the cells of the column, width sets the width of the column.

columns
(optional, default: auto) Specifies the column widths. If set to auto, the function automatically generates column widths by each column’s column definition. Otherwise functions exactly the columns paramater of the standard Typst table function. Unlike the tabut-cells setting this to none will break.

align
(optional, default: auto) Specifies the column alignment. If set to auto, the function automatically generates column alignment by each column’s column definition. If set to none no align property is added to the output arg. Otherwise functions exactly the align paramater of the standard Typst table function.

index
(optional, default: "_index") Specifies the property name for the index of each record. By default, an _index property is automatically added to each record. If set to none, no index property is added.

transpose
(optional, default: false) If set to true, transposes the table, swapping rows and columns.

headers
(optional, default: true) Determines whether headers should be included in the output. If set to false, headers are not generated.

tableArgs
(optional) Any additional arguments are passed to the table function, can be used for styling or anything else.

tabut-cells

The tabut-cells function functions as tabut, but returns arguments for use in either the standard table function or other tools such as tablex. If you just want the array of cells, use the pos function on the returned value, ex tabut-cells(...).pos.

tabut-cells is particularly useful when you need to generate only the cell contents of a table or when these cells need to be passed to another function for further processing or customization.

Function Signature

tabut-cells(
  data-raw, 
  colDefs, 
  columns: auto,
  align: auto,
  index: "_index",
  transpose: false,
  headers: true,
) -> arguments

Parameters

data-raw
This is the raw data that will be used to generate the table. The data is expected to be in an array of dictionaries, where each dictionary represents a single record or object.

colDefs
These are the column definitions. An array of dictionaries, each representing column definition. Must include the properties header and a func. header expects content, and specifies the label of the column. func expects a function, the function takes a record dictionary as input and returns the value to be displayed in the cell corresponding to that record and column. There are also two optional properties; align sets the alignment of the content within the cells of the column, width sets the width of the column.

columns
(optional, default: auto) Specifies the column widths. If set to auto, the function automatically generates column widths by each column’s column definition. If set to none no column property is added to the output arg. Otherwise functions exactly the columns paramater of the standard typst table function.

align
(optional, default: auto) Specifies the column alignment. If set to auto, the function automatically generates column alignment by each column’s column definition. If set to none no align property is added to the output arg. Otherwise functions exactly the align paramater of the standard typst table function.

index
(optional, default: "_index") Specifies the property name for the index of each record. By default, an _index property is automatically added to each record. If set to none, no index property is added.

transpose
(optional, default: false) If set to true, transposes the table, swapping rows and columns.

headers
(optional, default: true) Determines whether headers should be included in the output. If set to false, headers are not generated.

records-from-csv

Automatically converts a CSV data into an array of records.

records-from-csv(
  data
) -> array

Parameters

data
The CSV data that needs to be converted, this can be obtained using the native csv function, like records-from-csv(csv(file-path)).

This function simplifies the process of converting CSV data into a format compatible with tabut. It reads the CSV data, extracts the headers, and converts each row into a dictionary, using the headers as keys.

It also automatically converts data into floats or integers when possible.

rows-to-records

Converts rows of data into an array of records based on specified headers.

This function is useful for converting data in a “rows” format (commonly found in CSV files) into an array of dictionaries format, which is required for tabut and allows easy data processing using the built in array functions.

rows-to-records(
  headers, 
  rows, 
  default: none
) -> array

Parameters

headers
An array representing the headers of the table. Each item in this array corresponds to a column header.

rows
An array of arrays, each representing a row of data. Each sub-array contains the cell data for a corresponding row.

default
(optional, default: none) A default value to use when a cell is empty or there is an error.

group

Groups data based on a specified function and returns an array of grouped records.

group(
  data, 
  function
) -> array

Parameters

data
An array of dictionaries. Each dictionary represents a single record or object.

function
A function that takes a record as input and returns a value based on which the grouping is to be performed.

This function iterates over each record in the data, applies the function to determine the grouping value, and organizes the records into groups based on this value. Each group record is represented as a dictionary with two properties: value (the result of the grouping function) and group (an array of records belonging to this group).

In the context of tabut, the group function is particularly useful for creating summary tables where records need to be categorized and aggregated based on certain criteria, such as calculating total or average values for each group.