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
datalist you pass torender_onepager(), the same way it requiresdoc_titleororg_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):



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.659206Check 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”:
-
What family name should Typst look for? That’s the
theme’s job: change
body-fontin your own theme file, same as any other token in Part 1. - 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:
-
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: copydefault.typ, change the hex values, verify contrast, done. - 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.
- 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.pngOptional 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 yourn_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
-
vignette("getting-started", package = "onepagr")for the basic render workflow. -
vignette("end-to-end-workflow", package = "onepagr")for a full worked example starting from real data. - Any file under
system.file("typst/themes", package = "onepagr")for a complete, real theme file to read or copy.
