Universe

A powerful, flexible, and production-ready Mail Merge package for Typst. Easily generate personalized letters, certificates, invoices, address labels, conference badges, envelopes, and reports directly from CSV files or inline data structures.


๐Ÿ“š Documentation & Wiki

Explore our complete GitHub Wiki for detailed guides, API reference, preset specs, and examples:


โœจ Features

  • ๐Ÿ“‘ Document Mail Merge (mail-merge): Seamless single or multi-page personalized document generation.
  • ๐Ÿท๏ธ Label & Badge Sheet Layouts (mail-merge-labels): Grid layout engine with built-in presets for Avery address/shipping labels, name badges, and card sheets.
  • ๐ŸŽฏ Smart Field Access & Normalization (field): Normalizes field names (e.g. matching "First Name", "first_name", "First-Name", or fallback candidate arrays).
  • ๐Ÿงน Address Joining (join-fields): Cleanly joins address lines, automatically omitting blank optional fields (e.g., Address 2).
  • ๐ŸŽจ Field Formatters (fmt-field): Quick presets for "upper", "lower", "title", "currency", or custom transform closures.
  • ๐Ÿ”€ Filtering & Sorting: Filter records with functions or dictionary conditions, sort by string fields or custom functions, reverse order, and paginate (start, limit).
  • ๐Ÿ”ข Page Counter Management: Resets page numbers per recipient document with reset-page-counter: true.
  • ๐Ÿ“Š Dataset Statistics & Fast Preview: Inspect fields and record counts with mail-merge-stats and preview drafts with mail-merge-preview.

๐Ÿš€ Quick Start

1. Simple Letter Merge

#import "@preview/modern-mailmerge:0.1.0": mail-merge, field, fmt-field, join-fields, if-field

#mail-merge(
  csv("data/clients.csv", row-type: dictionary),
  filter: record => field(record, "Status") == "Active",
  sort-by: "Last Name",
  reset-page-counter: true,
  record => [
    #align(right)[#datetime.today().display("[Month repr:long] [Day], [Year]")]

    *Dear #field(record, "First Name"),*

    Thank you for being a valued customer since #field(record, "Join Date").

    #block(
      fill: rgb("#f7fafc"),
      inset: 10pt,
      radius: 4pt,
      [
        *Recipient Address:* \
        #fmt-field(record, "First Name") #fmt-field(record, "Last Name") \
        #if-field(record, "Company", c => [#c \ ])
        #field(record, "Address 1") \
        #join-fields(record, ("City", "State"), separator: ", ") #field(record, "Zip")
      ]
    )

    Your outstanding balance is *#fmt-field(record, "Balance", fmt: "currency")*.

    Best regards, \
    The Acme Team
  ]
)

2. Address Labels & Badge Sheet

#import "@preview/modern-mailmerge:0.1.0": mail-merge-labels, presets, field, fmt-field, join-fields

// Render 3x10 Avery 5160 Address Sticker Sheet with cut guidelines
#mail-merge-labels(
  csv("data/clients.csv", row-type: dictionary),
  preset: presets.avery-5160,
  show-cut-lines: true,
  record => [
    #text(size: 9pt, weight: "bold")[#fmt-field(record, "First Name") #fmt-field(record, "Last Name")] \
    #text(size: 8pt)[
      #field(record, "Address 1") \
      #join-fields(record, ("City", "State"), separator: ", ") #field(record, "Zip")
    ]
  ]
)

๐Ÿ“– API Reference

mail-merge

Main function for generating one document per record.

#mail-merge(
  data,
  template,
  filter: none,
  sort-by: none,
  reverse: false,
  start: 1,
  limit: none,
  pagebreak: true,
  reset-page-counter: false,
  trim: true,
  default-value: "",
  on-empty: [No matching records found.]
)
Parameter Type Default Description
data str | array Required Path to CSV, pre-loaded csv(...), array of dicts, or array of row arrays.
template function Required Content closure record => content.
filter none | function | dict none Filter closure r => bool or dict (Status: "Active").
sort-by none | str | function none Key name or closure r => key to sort records.
reverse bool false Reverse sort order.
start int 1 1-based start record index for pagination.
limit none | int none Maximum number of records to process.
pagebreak bool true Inserts #pagebreak() between rendered records.
reset-page-counter bool false Resets #counter(page) to 1 for each record.
trim bool true Trims whitespace from CSV string fields.
default-value str "" Fallback string for empty or missing CSV fields.
on-empty content [...] Content displayed if zero records match.

mail-merge-labels

Engine for multi-column label grids, sticker sheets, and badges.

#mail-merge-labels(
  data,
  template,
  preset: none,
  columns: none,
  rows: none,
  width: none,
  height: none,
  column-gutter: none,
  row-gutter: none,
  page-margin: none,
  paper: none,
  cell-padding: none,
  show-cut-lines: false,
  fill: none,
  ..options
)
Parameter Type Default Description
preset dictionary none Preset layout from presets.* (e.g. presets.avery-5160).
columns int 3 Number of grid columns per sheet.
rows int 8 Number of grid rows per sheet.
width length | 1fr 1fr Width of each label cell.
height length | 1fr 1fr Height of each label cell.
column-gutter length 0pt Space between columns.
row-gutter length 0pt Space between rows.
show-cut-lines bool | stroke false Visual guidelines around labels.
fill none | color | function none Background fill or closure record => color.

๐Ÿท๏ธ Label Presets (presets)

Pre-configured grid dimensions for standard label sheets:

  • presets.avery-5160: 3x10 Address Labels (2.625" x 1.0", Letter)
  • presets.avery-5161: 2x10 Address Labels (4.0" x 1.0", Letter)
  • presets.avery-5162: 2x7 Folder Labels (4.0" x 1.33", Letter)
  • presets.avery-5163: 2x5 Shipping Labels (4.0" x 2.0", Letter)
  • presets.avery-5164: 2x3 Large Shipping Labels (4.0" x 3.33", Letter)
  • presets.avery-l7160: 3x7 Metric Address Labels (63.5mm x 38.1mm, A4)
  • presets.avery-l7163: 2x7 Metric Address Labels (99.1mm x 38.1mm, A4)
  • presets.a4-3x8: 3x8 Standard A4 Labels (70mm x 36mm, A4)
  • presets.a4-2x7: 2x7 Standard A4 Labels (105mm x 42.3mm, A4)
  • presets.badge-2x4: 2x4 Name Badges (3.5" x 2.25", Letter)
  • presets.card-2x2: 2x2 Place Cards / Large Badges (3.75" x 4.5", Letter)

๐Ÿ”ง Utility Functions

  • field(record, key, fmt: none, default: ""): Retrieves and optionally formats a field value with smart key normalization (matches "First Name", "first_name", "First-Name" or candidate array ("FirstName", "Name")). Supports fmt: "upper", "lower", "title", "currency", or custom function.
  • bind-field(record): Binds a record to field for ultra-concise #f("Field Name") template syntax (let f = bind-field(record) or let f = field.with(record)).
  • fmt-field(record, key, fmt: none, default: ""): Alias for field(record, key, fmt: fmt, default: default).
  • join-fields(record, keys, separator: ", ", default: ""): Joins multiple non-empty fields, automatically omitting blank optional lines.
  • if-field(record, key, then-content, else-content: []): Conditionally renders then-content when field is present and non-empty.
  • is-empty(record, key) / is-non-empty(record, key): Helper predicates for conditional checks.
  • record-index(record) / record-total(record): Returns 1-based current record index and total records count.
  • is-first-record(record) / is-last-record(record): Boolean flags for first/last record in merged set.
  • mail-merge-stats(data): Returns (total-records: int, fields: array, sample-record: dict).
  • mail-merge-preview(data, template, limit: 3): Convenience wrapper for rapid draft rendering.

๐Ÿ“ Repository Examples

Check out the examples/ directory for full working .typ templates:

  1. examples/letter_merge.typ โ€” Personalized letters and invoices.
  2. examples/certificate_merge.typ โ€” Landscape certificate of completion templates.
  3. examples/label_merge.typ โ€” Address sticker sheets and conference name badges.
  4. examples/envelope_merge.typ โ€” DL envelope printing.
  5. examples/advanced_features.typ โ€” Advanced sorting, filtering, statistics, and inline array data.

๐Ÿ“„ License

Distributed under the MIT License.