Skip to contents

onepagr’s visual identity splits into two genuinely separate mechanisms, and it’s worth knowing which is which before you customize either one:

  • Colors and spacing are controlled by a theme: a single Typst dictionary, selectable by name (theme = "uk") or supplied as your own file (theme_path = "my-theme.typ").
  • Logos and the header texture are data, not theme settings: every template requires seven logo/image tokens in the data list you pass to render_onepager(), the same way it requires doc_title or org_full. Swapping logos means changing those values and staging your own image files, not editing a template’s Typst source.
  • Fonts are a theme setting (body-font), but making a specific font file actually available to Typst is a separate, per-render concern, covered in Part 3.

This vignette covers all three.

Part 1: Colors

Built-in themes

onepagr::list_themes()
#> [1] "default" "kdph"    "uk"
render_onepager(data, template = "trend_snapshot", theme = "default", output = "report.pdf")
render_onepager(data, template = "trend_snapshot", theme = "uk", output = "report.pdf")
render_onepager(data, template = "trend_snapshot", theme = "kdph", output = "report.pdf")

Same template, same data, only theme changed between the three calls above (default, uk, kdph, left to right):

trend_snapshot rendered with the default, brand-neutral theme.The same trend_snapshot report rendered with the uk theme instead, showing only colors and typography changed.The same trend_snapshot report rendered with the kdph theme: navy header, indigo bars, and Calibri type.

The kdph theme is an unofficial implementation of the Kentucky Department for Public Health’s 2026 Data Visualization Style Guidelines. Read the header comment in kdph.typ before using it for anything external: the guide’s palette is for data visualization and does not replace KDPH’s branding guidelines, logos are supplied through the logo_* tokens (the guide puts the KDPH logo first), and its contrast numbers were computed and spot-checked with PAC on the sample templates, so run PAC on your own content before relying on it.

Writing your own theme

A theme is a plain Typst file defining a theme dictionary (colors, fonts, spacing/stroke scale) and a theme-grad dictionary (a handful of gradients derived from theme’s colors). The easiest starting point is a copy of the built-in default theme:

default_theme_path <- system.file(
  "typst", "themes", "default.typ",
  package = "onepagr"
)
cat(readLines(default_theme_path, n = 6), sep = "\n")
#> // Generic default theme -- a brand-neutral palette (not tied to any real
#> // organization), included so onepagr works out of the box before a
#> // consuming project supplies its own theme. Same dictionary keys as
#> // themes/uk.typ; see that file's header comment for the full rationale
#> // on why this is a dictionary, not loose #let bindings.
#> //

Copy that file, then change the values you want. The keys that matter most for an overall look:

Key Controls
brand-blue, brand-midnight The header band’s gradient, and the accent stripe on every stat-card/callout box
brand-accent Large decorative text (big stat-card numbers); needs 3:1 contrast, not 4.5:1
brand-accent-text The same accent color’s small-text-safe variant (e.g. a text-box section heading); needs 4.5:1
card-bg, callout-bg, lessons-bg Box background tints
text-secondary, text-muted Body and caption text colors
severity-warning, severity-critical (+ their -bg/-text variants) Alert-template severity coloring (overdose_spike_alert, syndromic_alert)
map-border-color Map-box border stroke (county_choropleth); kept separate from brand-midnight since a real brand spec for this can diverge from it

The full key list (spacing, stroke widths, radius, margin) is the same across every theme file; see default.typ’s own comments for what each one does; they’re less likely to need changing than the colors above.

Verifying contrast before you ship a color change

This is the part that’s easy to skip and genuinely shouldn’t be. Every color in onepagr’s built-in themes was verified against WCAG’s real contrast formula, not chosen by eye, because a color that looks readable to you is not the same thing as a color that measures 4.5:1. onepagr doesn’t yet ship an automated checker for this (check_theme_contrast() is a planned, not-yet-built function), so for now this is a manual step, using the same formula this package’s own development used:

wcag_contrast <- function(hex1, hex2) {
  linearize <- function(channel) {
    channel <- channel / 255
    ifelse(channel <= 0.03928, channel / 12.92, ((channel + 0.055) / 1.055)^2.4)
  }
  relative_luminance <- function(hex) {
    rgb <- grDevices::col2rgb(hex)
    lin <- linearize(rgb)
    0.2126 * lin[1] + 0.7152 * lin[2] + 0.0722 * lin[3]
  }
  l1 <- relative_luminance(hex1)
  l2 <- relative_luminance(hex2)
  (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
}

# Example: is a purple accent readable as small text on a light card
# background? WCAG needs >= 4.5 for normal text, >= 3 for large text
# (18pt+ regular or 14pt+ bold).
wcag_contrast("#6A4C93", "#EDE7F6")
#> [1] 5.659206

Check every foreground/background pairing your new color actually appears in, at the text size it’s actually used at (a text-box section label is small text and needs 4.5:1; a 28pt stat-card number is large text and only needs 3:1). This project’s own default.typ and uk.typ theme files have real, first-hand examples of a color that passed at one size and failed at another; both files’ comments explain exactly which pairing failed and why.

Rendering with a custom theme

render_onepager(data, template = "trend_snapshot", theme_path = "my-theme.typ", output = "report.pdf")

Part 2: Logos and images

Logos are data, not template edits

Every built-in template reads these tokens from the data list you pass to render_onepager(). Only the primary logo is required:

Token Meaning
logo_primary_path, logo_primary_alt Required. Your own organization’s logo, always shown
logo_partner_a_path, logo_partner_a_alt, show_partner_a Optional, off by default. A co-branding partner, shown left of the primary logo
logo_partner_b_path, logo_partner_b_alt, show_partner_b Optional, off by default. A second co-branding partner, shown right of the primary logo
header_texture_path Optional, defaults to onepagr’s bundled texture. The decorative background behind the header band

Changing a logo means changing these values plus staging the actual image file, via render_onepager()’s extra_assets argument; it does not require exporting or hand-editing a template’s Typst source. Three separate logo slots exist (rather than one flattened composite image) so a screen reader announces real per-organization identification instead of one opaque “logo”, but not every jurisdiction has a three-organization design. show_partner_a/show_partner_b are each independent "true"/"false" toggles (the literal lowercase string, not an R logical; see ?render_onepager) that default to "false", so a single health department reporting under its own name passes only the primary logo and gets one logo with no dangling divider. A two-agency partnership switches on one partner, and a three-organization lockup, like the one the Kentucky Injury Prevention and Research Center (KIPRC) uses, switches on both. The primary logo is never optional. If you switch a partner on, you must also supply its path and alt text: onepagr stops with an error rather than render a blank or unlabeled logo.

# A single health department: one logo, nothing to switch off.
data$logo_primary_path <- "county-health-dept-logo.png"
data$logo_primary_alt <- "Example County Health Department logo"

render_onepager(
  data, template = "syndromic_alert", theme = "default", output = "report.pdf",
  extra_assets = "county-health-dept-logo.png"
)

# A three-organization lockup: switch on both partners and supply each
# one's path and alt text.
data$logo_partner_a_path <- "partner-a-logo.png"
data$logo_partner_a_alt <- "Partner A logo"
data$show_partner_a <- "true"
data$logo_partner_b_path <- "partner-b-logo.png"
data$logo_partner_b_alt <- "Partner B logo"
data$show_partner_b <- "true"

render_onepager(
  data, template = "syndromic_alert", theme = "default", output = "report.pdf",
  extra_assets = c(
    "county-health-dept-logo.png", "partner-a-logo.png", "partner-b-logo.png"
  )
)

extra_assets stages your files into the same directory Typst compiles from (Typst’s compiler sandboxes file access and rejects absolute paths outright), so the token value must be just the basename you pass to extra_assets, not the file’s original full path. If you’re fine with the package’s own three built-in placeholder logos, you don’t need extra_assets at all: the fixture defaults in vignette("getting-started") already point at those bundled assets by name.

A practical tip: crop before you resize

A raster logo file often has transparent padding baked in around the real artwork (this project found one source logo with roughly a third of its canvas being empty space). That padding silently shrinks how big the logo actually looks once Typst applies a height:/width: setting, and can misalign a multi-logo row that assumes each image’s visible content fills its frame. Check the image’s real bounding box before trusting a nominal size setting:

# Using the PIL/Pillow Python library's alpha.getbbox(), or an
# equivalent in your tool of choice, before sizing a new logo.

Accessible alt text

Every logo image() call takes an alt: argument. Write real, specific text (“Sample Health Department logo”), not something generic like “logo” or “image”: this is what a screen reader announces in place of the image, and it’s the same accessibility bar the rest of onepagr’s output is held to.

Part 3: Fonts

A theme’s body-font key names the font family Typst should use for all body/heading text (e.g. default.typ’s body-font: "Liberation Sans"). It can also be a list of fallbacks that Typst tries in order, which is how kdph.typ asks for Calibri, then Carlito (an open font with identical metrics), then Liberation Sans: body-font: ("Calibri", "Carlito", "Liberation Sans"). A family missing from the list produces a warning, not an error, as long as a later one is found. Two different questions hide inside “change the font”:

  1. What family name should Typst look for? That’s the theme’s job: change body-font in your own theme file, same as any other token in Part 1.
  2. Is that family actually available to Typst at compile time? That’s a separate, per-render concern, and it’s the one that actually needs new machinery: Typst only sees fonts installed system-wide by default, and a locked-down enterprise deployment (e.g. Posit Workbench) may not permit installing new fonts at the OS level at all.

render_onepager()’s font_dir argument solves the second problem without needing OS-level font installation: point it at a directory containing your own .ttf/.otf files, and Typst searches that directory in addition to system fonts for this compile only (confirmed directly: quarto typst fonts --font-path <dir> lists a font from <dir> alongside every system font, not instead of them).

# my-theme.typ sets body-font: "Open Sans", and Open Sans isn't
# installed system-wide: ship the font file alongside your project
# and point font_dir at its folder.
render_onepager(
  data, template = "cohort_summary", theme_path = "my-theme.typ",
  output = "report.pdf", font_dir = "fonts/"
)

If body-font names a family Typst can’t find (system-wide or in font_dir), Typst silently falls back to a default font rather than erroring, so after changing a font, actually open the compiled PDF (or check its embedded font list) to confirm your font took effect, the same “verify, don’t assume” discipline Part 1 applies to color contrast.

Onboarding a new jurisdiction: what you actually need

Pulling Parts 1-3 together, a health department or university adopting onepagr for the first time needs exactly three things before it can render its own reports, on top of the same real analysis output every other onepagr user needs:

  1. A color palette. At minimum, a primary/accent color pair; see Part 1. Reuse the built-in default (Bootstrap-derived, brand-neutral) theme as-is if you don’t have (or don’t yet want to commit to) your own institutional colors. A full custom theme is colors, not code: copy default.typ, change the hex values, verify contrast, done.
  2. Logo file(s). One, if you’re reporting under your own name alone; two or three if you co-brand with partner agencies. See Part 2: there’s no minimum of three, and no template editing required either way.
  3. A font, if the default doesn’t suit you. Optional: every built-in theme already names a real, commonly-available font. Only relevant if your institution has a specific brand font it wants to use instead; see Part 3.

None of the three requires touching a template’s Typst source. A new jurisdiction’s entire setup is a theme file (or none, if default/uk suffice), a handful of logo files, and the data list documented in vignette("getting-started") and vignette("end-to-end-workflow").

Part 4: Type size and spacing

Three theme keys control type and spacing. Each has a no-op default, so a built-in theme renders exactly as documented until you change one.

Key Meaning
min-font-size No text renders smaller than this. 0pt means no floor.
font-scale Multiplies every text size (and the boxes and label columns that hold text). 1.0 is unchanged.
space-scale Multiplies padding, gaps, and vertical spacing. 1.0 is unchanged.

You can also set any of them for one report without touching a theme, through optional data tokens (min_font_size in points, font_scale, space_scale), passed as strings such as “12”:

# No text smaller than 12pt, for a large-print version.
render_onepager(
  c(data, list(min_font_size = "12")),
  template = "trend_snapshot", theme = "default", output = "large-print.pdf"
)

A per-render value wins over the theme’s. An unset one falls back to it.

The floor only raises text that is below it and leaves larger text alone. It does not scale everything, so with a 12pt floor the smaller sizes (7 to 9pt in the built-in templates) all become 12pt and weight and color carry the hierarchy. font-scale is the proportional alternative: it keeps every size relationship and grows the layout with it. The floor governs text the templates typeset. It cannot change text baked into a map image or a logo you supply.

The built-in fixed-page templates are designed to fill exactly 2 pages at the defaults, and are tested that way under every built-in theme. Raising type or spacing can push them past 2 pages (a 10pt floor takes cohort_summary to 4 pages and county_choropleth to 3). onepagr allows that and prints a message when it happens, for example “cohort_summary is designed for 2 pages; this render produced 4.” Extra pages of a fixed template have no footer, because the footer is placed once on each designed page. A render that has been scaled or floored is outside the package’s accessibility verification, so run PAC on the result.

Boxes that hold text grow with the type: label columns, the map explainer box in county_choropleth, and the reserved bottom margin that holds the footer in the alert templates. Map images, logos, and the header texture are fixed-size and do not. After a floored or scaled render, look it over for text that runs past its box, and run PAC on it.

Part 5: Rewording headings, labels and text

Every piece of reader-facing text in a built-in template is an optional data token: section headings, box labels, captions, paragraphs, bullets, bar-chart row labels, the metadata strip and footer labels, and the alt text for charts and maps. Each one defaults to the text the template ships with, so you supply only the ones you want to change, and a template you leave alone renders exactly as before. You never need to edit Typst.

template_tokens() lists everything a template takes. Required tokens (your numbers and names) come first, then the optional ones with their current text:

tokens <- onepagr::template_tokens("overdose_spike_alert")
tokens[!tokens$required, c("token", "default")]
#>                    token
#> 22         min_font_size
#> 23            font_scale
#> 24           space_scale
#> 25          banner_label
#> 26 heading_alert_details
#> 27 label_whats_happening
#> 28   label_geo_breakdown
#> 29         label_actions
#> 30       label_resources
#> 31           stat_events
#> 32           stat_spikes
#> 33   text_threshold_note
#> 34         banner_issued
#> 35  footer_label_sources
#> 36  footer_label_contact
#> 37 footer_contact_joiner
#> 38        show_partner_a
#> 39   logo_partner_a_path
#> 40    logo_partner_a_alt
#> 41        show_partner_b
#> 42   logo_partner_b_path
#> 43    logo_partner_b_alt
#> 44   header_texture_path
#>                                                                                                                           default
#> 22                                                                                                                               
#> 23                                                                                                                               
#> 24                                                                                                                               
#> 25                                                                                                                    SPIKE ALERT
#> 26                                                                                                                  ALERT DETAILS
#> 27                                                                                                               WHAT'S HAPPENING
#> 28                                                                                                           GEOGRAPHIC BREAKDOWN
#> 29                                                                                                            RECOMMENDED ACTIONS
#> 30                                                                                                       LOCAL RESPONSE RESOURCES
#> 31                                                                                   overdoses in the last {{{window_days}}} days
#> 32                                                                                spikes in the last {{{spike_window_days}}} days
#> 33 Spike threshold: #sym.gt.eq {{{threshold}}} overdoses. {{{alert_area}}} met or exceeded this threshold, triggering this alert.
#> 34                                                                                                                         issued
#> 35                                                                                                                  Data sources:
#> 36                                                                                                                       Contact:
#> 37                                                                                                                             at
#> 38                                                                                                                          false
#> 39                                                                                                                               
#> 40                                                                                                                               
#> 41                                                                                                                          false
#> 42                                                                                                                               
#> 43                                                                                                                               
#> 44                                                                                                      assets/header-texture.png

Optional tokens are named by what they set, so you can filter on the prefix:

Prefix Sets
heading_* Section headings
label_*, banner_* Box, group and alert-banner labels
stat_* Captions under big numbers
bar_* Row labels in bar charts
text_* Paragraphs, bullets and notes
alt_* Alt text for charts and maps
strip_label_*, footer_label_* Labels in the metadata strip and footer
chips_* A short list of items, separated by |
cohort <- onepagr::template_tokens("cohort_summary")
head(cohort[grepl("^heading_", cohort$token), c("token", "default")], 4)
#>                 token
#> 88     heading_glance
#> 89 heading_background
#> 90  heading_data_adds
#> 91    heading_capture
#>                                                            default
#> 88 SAMPLE COHORT AT A GLANCE #sym.dash.en {{{strip_period}}} CASES
#> 89                                          BACKGROUND & RATIONALE
#> 90                                             WHAT THIS DATA ADDS
#> 91                                     WHO IS CAPTURED BY LINKAGE?

Use them like any other value:

render_onepager(
  c(data, list(
    heading_alert_details = "WHAT WE KNOW",
    label_actions = "WHAT TO DO NEXT",
    text_threshold_note = "Alert level: {{{threshold}}} overdoses in 24 hours."
  )),
  template = "overdose_spike_alert", theme = "default",
  output = "reworded.pdf"
)

A few things to know:

  • A name that no template uses is ignored with a warning that suggests the closest token, such as "heading_glnce" was ignored ... Did you mean "heading_glance"?. A name that belongs to a different built-in template is left alone, so you can share one data list across templates.
  • A value is set as Typst markup. *bold* and _italic_ work. To show @, $, *, _, # or a backtick literally, put a backslash before it. Alt text (alt_*) is plain text, and should not contain a double quote.
  • The number in a default is filled from your data. A default such as Results (N = {{{n_total}}}) uses your n_total. Your own replacement is used exactly as written, so type any numbers into it yourself. The token in the default is still required, because the template needs it whether or not you keep the default text.
  • Headings stay real headings in the PDF’s structure whatever text you give them, so screen readers and the document outline keep working. Update the alt_* text whenever you change what a chart or map shows.
  • A longer heading or paragraph can wrap onto more lines and push a fixed-length template past its page count. Render and look after rewording.
  • To change a template’s structure rather than its words, such as adding a section, use export_template() and edit the Typst.

See also