diff --git a/NAMESPACE b/NAMESPACE
index 6993ef53e..650a5d4d3 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -16,6 +16,7 @@ S3method(plot,gt_tbl)
S3method(print,gt_group)
S3method(print,gt_tbl)
S3method(print,rtf_text)
+S3method(print,typst_text)
S3method(resolve_location,cells_body)
S3method(resolve_location,cells_column_labels)
S3method(resolve_location,cells_column_spanners)
@@ -64,6 +65,7 @@ export(as_gtable)
export(as_latex)
export(as_raw_html)
export(as_rtf)
+export(as_typst)
export(as_word)
export(cell_borders)
export(cell_fill)
@@ -140,6 +142,7 @@ export(fmt_scientific)
export(fmt_spelled_num)
export(fmt_tf)
export(fmt_time)
+export(fmt_typst)
export(fmt_units)
export(fmt_url)
export(from_column)
@@ -236,6 +239,7 @@ export(text_case_match)
export(text_case_when)
export(text_replace)
export(text_transform)
+export(typst)
export(unit_conversion)
export(vars)
export(vec_fmt_bytes)
diff --git a/NEWS.md b/NEWS.md
index 94cf1fa8d..af557c26f 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,7 @@
# gt (development version)
+* Added native Typst output support. Tables can now be rendered directly to Typst markup using `as_typst()`, saved to `.typ` files with `gtsave()`, and automatically rendered in Quarto documents with `format: typst`. This produces idiomatic Typst tables with proper `table.header()` for page-break repetition, `#super[]`/`#sub[]` for footnotes and units, cell styling via `table.cell()` properties, and support for all major `tab_options()` including borders, fonts, colors, padding, row striping, and column label customization. New exported functions: `as_typst()`, `typst()` (text helper), and `fmt_typst()` (raw Typst cell formatter). The `fmt_markdown()` function also gains a Typst handler for proper bold, italic, strikethrough, and link conversion. (@malcolmbarrett, #2134)
+
* Expand functionality of `gt_group()` to allow `gt_group` objects to be combined with `gt_tbls` (#2128)
# gt 1.3.0
@@ -109,7 +111,7 @@
* Fixed an issue in `fmt_number()` where `drop_trailing_dec_mark` would be ignored if `use_seps = FALSE` (#1961). (@olivroy, #1962).
* Fixed an issue where `fmt_markdown()` could create strange output in Quarto (html and Typst formats) (#1957). (@olivroy, #1958, [quarto-dev/quarto-cli#11932](https://github.com/quarto-dev/quarto-cli/issues/11932), [quarto-dev/quarto-cli#11610](https://github.com/quarto-dev/quarto-cli/issues/11610)).
-
+
* The default table position in LaTeX is now "t" instead of "!t" (@AaronGullickson, #1935).
* Fixed an issue where cross-references would fail in bookdown::html_document2 (@olivroy, #1948)
@@ -146,9 +148,9 @@
* Interactive tables now respect more styling options, namely: `column_labels.background.color`, `row_group.background.color`, `row_group.font.weight`, `table_body.hlines.style`, `table.font.weight`, `table.font.size`, and `stub.font.weight`. (#1693)
-* `opt_interactive()` now works when columns are merged with `cols_merge()`. (@olivroy, #1785)
+* `opt_interactive()` now works when columns are merged with `cols_merge()`. (@olivroy, #1785)
-* `opt_interactive()` now works when columns are substituted with `sub_*()`. (@olivroy, #1759)
+* `opt_interactive()` now works when columns are substituted with `sub_*()`. (@olivroy, #1759)
* More support for `cells_stubhead()` styling and footnotes in interactive tables.
@@ -340,7 +342,7 @@
* Most functions now produce better error messages if not provided with a `gt_tbl` object. (#1504, #1624)
-* The URL formatting through `fmt_url()` has been improved by preventing link text breaking across lines (#1509). (#1537)
+* The URL formatting through `fmt_url()` has been improved by preventing link text breaking across lines (#1509). (#1537)
* We now remove some unnecessary newlines in the HTML text produced by `as_raw_html()`, which caused an issue when integrating **gt** tables into **blastula** email messages (#1506). (#1520)
@@ -364,7 +366,7 @@
* There are two basic types of nanoplots available: `"line"` and `"bar"`. A line plot shows individual data points and has smooth connecting lines between them to allow for easier scanning of values. You can opt for straight-line connections between data points, or, no connections at all (it's up to you). The data you feed into a line plot can consist of a single vector of values (resulting in equally-spaced *y* values), or, you can supply two vectors representative of *x* and *y*.
-* A bar plot is built a little bit differently. The focus is on evenly-spaced bars (requiring a single vector of values) that project from a zero line, clearly showing the difference between positive and negative values.
+* A bar plot is built a little bit differently. The focus is on evenly-spaced bars (requiring a single vector of values) that project from a zero line, clearly showing the difference between positive and negative values.
* By default, any type of nanoplot will have basic interactivity. One can hover over the data points and vertical guides will display values ascribed to each. A guide on the left-hand side of the plot area will display the minimal and maximal *y* values on hover.
@@ -428,7 +430,7 @@
* The `gtsave()` function now works with `gt_group` objects (usually generated through `gt_split()` or `gt_group()`) (#1354). (#1365)
-* All `gt_group` objects can now be printed using R Markdown or Quarto (#1286). (#1332)
+* All `gt_group` objects can now be printed using R Markdown or Quarto (#1286). (#1332)
* When using `fmt_currency()` with a locale value set, **gt** will now use that to automatically select the locale's default currency. While some countries can have multiple currencies, we opt for the most-widely used currency (users could alternatively specify the currency code and `info_currencies()` contains all supported currencies used in the package) (#1346). (#1347)
@@ -693,7 +695,7 @@
* The `fmt_fraction()` formatter was added, allowing for flexible formatting of numerical values to mixed fractions of configurable accuracy (#402). (#753)
-* Added the `opt_horizontal_padding()` and `opt_vertical_padding()` functions to easily expand or contract an HTML table in the horizontal and vertical directions (#868). (#882)
+* Added the `opt_horizontal_padding()` and `opt_vertical_padding()` functions to easily expand or contract an HTML table in the horizontal and vertical directions (#868). (#882)
* There is now a `locale` argument in the `gt()` function. If set, formatter functions like `fmt_number()` will automatically use this global locale while formatting. There also remains the option to override the global locale with any non-`NULL` value set for `locale` within a `fmt_*()` call (#682). (#866)
@@ -701,7 +703,7 @@
* There is now more flexibility, improved documentation, and more testing/reliability for the date/time formatting functions (`fmt_date()`, `fmt_time()`, and `fmt_datetime()`). Now, `Date` and `POSIXct` columns are allowed to be formatted with these functions. With `fmt_datetime()`, we can even supply a format code for generation of custom dates/times (#612, #775, #800). (#801)
-* Footnote marks for HTML tables now have an improved appearance. They are slightly larger, set better against the text they follow, and, asterisks are specially handled such that their sizing is consistent with other marks (#511). (#876)
+* Footnote marks for HTML tables now have an improved appearance. They are slightly larger, set better against the text they follow, and, asterisks are specially handled such that their sizing is consistent with other marks (#511). (#876)
* Further improving support for color value inputs, **gt** now allows shorthand hexadecimal color values (like `#333`) and the use of the `transparent` CSS color keyword (#839, #856). (#870)
@@ -755,7 +757,7 @@ This release focuses on improvements to two main areas:
* References to columns (by way of the `columns` argument in many **gt** functions) now better adhere to **tidyselect** semantics.
* Instead of using `columns = vars(a, b)`, we now use `columns = c(a, b)` (`columns = c("a", "b")` also works, and this type of expression always has been an option in **gt**).
* Other **tidyselect** idioms should also work; things like using `where()` to target columns (e.g., `gt(exibble) %>% cols_hide(columns = where(is.numeric))` will hide all numeric columns) and negation (e.g., `columns = -c(a, b)`) function as expected.
-
+
## Breaking changes and deprecations
* Column labels subordinate to column spanner labels had their alignment forced to be `"center"` but now there is no specialized alignment of column labels under spanners. Should you need the old behavior, `tab_style()` can be used along with `cell_text(align = "center")` for all columns that live under spanners. (#662)
diff --git a/R/export.R b/R/export.R
index bcdd3aaa6..41ba10207 100644
--- a/R/export.R
+++ b/R/export.R
@@ -284,6 +284,167 @@ as_latex <- function(data) {
}
}
+# as_typst() -------------------------------------------------------------------
+#' Output a **gt** object as Typst
+#'
+#' @description
+#'
+#' Get the Typst content from a `gt_tbl` object as a single-element character
+#' vector. This object can be used with `writeLines()` to generate a valid .typ
+#' file, or it will be automatically rendered in Quarto documents targeting
+#' Typst output.
+#'
+#' @inheritParams gtsave
+#'
+#' @return An object of class `typst_text`.
+#'
+#' @family table export functions
+#' @section Function ID:
+#' 13-10
+#'
+#' @section Function Introduced:
+#' `v0.13.0` (2026-03-26)
+#'
+#' @export
+as_typst <- function(data) {
+
+ # Perform input object validation
+ stop_if_not_gt_tbl(data = data)
+
+ # Build all table data objects through a common pipeline
+ data <- build_data(data = data, context = "typst")
+
+ # Composition of Typst -------------------------------------------------------
+
+ # Global set rules — font first, then fill, then size (matches Typst convention)
+ set_rules <- ""
+
+ # Font names — filter out CSS-only names that Typst can't resolve.
+ # If font_names is NULL or empty, don't emit #set text(font:) so the
+ # table inherits the document's font settings (important for Quarto/Typst
+ # documents that set their own fonts).
+ css_only_fonts <- c("-apple-system", "BlinkMacSystemFont", "system-ui",
+ "Apple Color Emoji", "Segoe UI Emoji",
+ "Segoe UI Symbol", "Noto Color Emoji")
+ font_names <- dt_options_get_value(data = data, option = "table_font_names")
+ if (!is.null(font_names) && length(font_names) > 0L && !identical(font_names, "")) {
+ typst_fonts <- font_names[!font_names %in% css_only_fonts]
+ if (length(typst_fonts) > 0L) {
+ font_str <- paste0("\"", typst_fonts, "\"", collapse = ", ")
+ set_rules <- paste0(set_rules, "#set text(font: (", font_str, "))\n")
+ }
+ }
+
+ # Font color — use light color if background is dark
+ font_color <- dt_options_get_value(data = data, option = "table_font_color")
+ font_color_light <- dt_options_get_value(data = data, option = "table_font_color_light")
+ bg_color_val <- dt_options_get_value(data = data, option = "table_background_color")
+
+ if (!is.null(bg_color_val) && nzchar(bg_color_val) && bg_color_val != "#FFFFFF") {
+ tryCatch({
+ rgb_vals <- grDevices::col2rgb(bg_color_val)
+ luminance <- (0.299 * rgb_vals[1] + 0.587 * rgb_vals[2] + 0.114 * rgb_vals[3]) / 255
+ if (luminance < 0.5 && !is.null(font_color_light) && nzchar(font_color_light)) {
+ font_color <- font_color_light
+ }
+ }, error = function(e) NULL)
+ }
+
+ if (!is.null(font_color) && nzchar(font_color)) {
+ typst_color <- css_color_to_typst(font_color)
+ if (!is.null(typst_color)) {
+ set_rules <- paste0(set_rules, "#set text(fill: ", typst_color, ")\n")
+ }
+ }
+
+ font_size <- dt_options_get_value(data = data, option = "table_font_size")
+ if (!is.null(font_size) && nzchar(font_size) && font_size != "16px") {
+ typst_size <- css_length_to_typst_text_size(font_size)
+ if (!is.null(typst_size)) {
+ set_rules <- paste0(set_rules, "#set text(size: ", typst_size, ")\n")
+ }
+ }
+
+ font_weight <- dt_options_get_value(data = data, option = "table_font_weight")
+ if (!is.null(font_weight) && nzchar(font_weight) && font_weight != "normal") {
+ typst_weight <- css_weight_to_typst(font_weight)
+ set_rules <- paste0(set_rules, "#set text(weight: \"", typst_weight, "\")\n")
+ }
+
+ font_style <- dt_options_get_value(data = data, option = "table_font_style")
+ if (!is.null(font_style) && nzchar(font_style) && font_style != "normal") {
+ set_rules <- paste0(set_rules, "#set text(style: \"", font_style, "\")\n")
+ }
+
+ # Create the heading component (title/subtitle above the table)
+ heading_component <- create_heading_component_t(data = data)
+
+ # Create the table start (#table( with columns, align, stroke, inset)
+ table_start <- create_table_start_t(data = data)
+
+ # Create the columns component (table.header with heading + spanners + labels)
+ # Heading goes inside table.header() for page-break repetition
+ columns_component <- create_columns_component_t(data = data, heading_content = heading_component)
+
+ # Create the body component (data rows, group rows, summary rows)
+ body_component <- create_body_component_t(data = data)
+
+ # Create the table end (bottom rule + closing paren)
+ table_end <- create_table_end_t(data = data)
+
+ # Create the footer component (footnotes + source notes below the table)
+ footer_component <- create_footer_component_t(data = data)
+
+ # Check for table_caption to wrap in #figure()
+ table_caption <- dt_options_get_value(data = data, option = "table_caption")
+
+ # Compose the table body (start through end)
+ # Heading and footer go INSIDE the table as table.cell rows
+ table_body <- paste0(
+ table_start,
+ columns_component,
+ body_component,
+ footer_component,
+ table_end
+ )
+
+ # Wrap in #figure() if caption is set
+ if (!is.null(table_caption) && !is.na(table_caption) && nzchar(table_caption)) {
+ caption_text <- process_text(table_caption, context = "typst")
+ table_id <- dt_options_get_value(data = data, option = "table_id")
+ label_str <- if (!is.null(table_id) && !is.na(table_id) && nzchar(table_id)) {
+ paste0(" <", table_id, ">")
+ } else {
+ ""
+ }
+ table_body <- paste0(
+ "#figure(\n",
+ table_body,
+ ", caption: [", caption_text, "],\n",
+ ")", label_str, "\n"
+ )
+ }
+
+ # Table width — wrap in #block(width:) if not auto
+ table_width <- dt_options_get_value(data = data, option = "table_width")
+ if (!is.null(table_width) && table_width != "auto") {
+ typst_width <- css_length_to_typst(table_width)
+ if (!is.null(typst_width)) {
+ table_body <- paste0("#block(width: ", typst_width, ")[\n", table_body, "]\n")
+ }
+ }
+
+ # Compose the Typst output (heading + footer are inside table_body)
+ typst_str <- paste0(
+ set_rules,
+ table_body
+ )
+
+ class(typst_str) <- "typst_text"
+
+ typst_str
+}
+
# as_rtf() ---------------------------------------------------------------------
#' Output a **gt** object as RTF
#'
diff --git a/R/fmt.R b/R/fmt.R
index 613c7bec9..8eefd31c1 100644
--- a/R/fmt.R
+++ b/R/fmt.R
@@ -875,7 +875,8 @@ context_missing_text <- function(missing_text, context) {
html = ,
grid = ,
latex = ,
- word =
+ word = ,
+ typst =
{
if (!is_asis && missing_text == "---") {
"\U02014"
@@ -923,7 +924,8 @@ context_plusminus_mark <- function(plusminus_mark, context) {
html = ,
latex = ,
grid = ,
- word =
+ word = ,
+ typst =
{
if (!is_asis && plusminus_mark == " +/- ") {
" \U000B1 "
@@ -986,6 +988,7 @@ context_lte_mark <- function(context) {
context,
grid = ,
word = ,
+ typst = ,
html = "\U02264",
latex = "$\\leq$",
"<="
@@ -1002,6 +1005,7 @@ context_gte_mark <- function(context) {
context,
grid = ,
word = ,
+ typst = ,
html = "\U02265",
latex = "$\\geq$",
">="
@@ -1016,6 +1020,7 @@ context_minus_mark <- function(context) {
switch(
context,
+ typst = ,
html = "\U02212",
"-"
)
@@ -1086,6 +1091,7 @@ context_exp_marks <- function(context) {
html = c(" \U000D7 10", ""),
latex = c(" $\\times$ 10\\textsuperscript{", "}"),
rtf = c(" \\'d7 10{\\super ", "}"),
+ typst = c(" \U000D7 10#super[", "]"),
word = c(" \U000D7 10^", ""),
c(" \U000D7 10^", "")
)
@@ -1112,6 +1118,7 @@ context_exp_str <- function(context, exp_style) {
html = "10",
latex = "{}_10",
rtf = "{\\sub 10}",
+ typst = "#sub[10]",
word = "10^",
"E"
)
diff --git a/R/format_data.R b/R/format_data.R
index 7d31ab219..dfcae07f2 100644
--- a/R/format_data.R
+++ b/R/format_data.R
@@ -3000,6 +3000,14 @@ fmt_fraction <- function(
gsub(" ", "", non_fraction_part),
paste0("{\\super ", num_vec, "}/{\\sub ", denom_vec, "}")
)
+
+ } else if (context == "typst") {
+
+ x_str[has_a_fraction] <-
+ paste0(
+ gsub(" ", "", non_fraction_part),
+ paste0("#super[", num_vec, "]/#sub[", denom_vec, "]")
+ )
}
}
@@ -6194,6 +6202,17 @@ fmt_tf <- function(
context = "word"
)
},
+ typst = function(x) {
+ format_tf_by_context(
+ x,
+ true_val = true_val,
+ false_val = false_val,
+ na_val = na_val,
+ colors = colors,
+ pattern = pattern,
+ context = "typst"
+ )
+ },
default = function(x) {
format_tf_by_context(
x,
@@ -6240,7 +6259,13 @@ format_tf_by_context <- function(
true_val_color <- false_val_color <- na_val_color <- NULL
}
- if (context == "html" && !is.null(colors)) {
+ if (context == "typst" && !is.null(colors)) {
+
+ true_val <- make_typst_text_with_color(text = true_val, color = true_val_color)
+ false_val <- make_typst_text_with_color(text = false_val, color = false_val_color)
+ na_val <- make_typst_text_with_color(text = na_val, color = na_val_color)
+
+ } else if (context == "html" && !is.null(colors)) {
# Ensure that any empty strings are replaced with a '
'
# when in an HTML context; this avoids the potential collapse of
@@ -10461,6 +10486,12 @@ fmt_markdown <- function(
}
process_text(md(x), context = "html")
},
+ typst = function(x) {
+ if (!is.character(x)) {
+ x <- as.character(x)
+ }
+ markdown_to_typst(x)
+ },
latex = function(x) {
markdown_to_latex(x, md_engine = md_engine)
},
@@ -10497,6 +10528,62 @@ fmt_markdown <- function(
)
}
+# fmt_typst() -------------------------------------------------------------------
+#' Format cells with raw Typst markup
+#'
+#' @description
+#'
+#' `fmt_typst()` lets you format cell values as raw Typst markup. The content
+#' will pass through to Typst output without escaping, allowing you to use
+#' native Typst syntax in table cells. In non-Typst output contexts, the
+#' content is passed through as plain text.
+#'
+#' @inheritParams fmt_number
+#'
+#' @return An object of class `gt_tbl`.
+#'
+#' @family data formatting functions
+#' @section Function ID:
+#' 3-50
+#'
+#' @section Function Introduced:
+#' `v0.13.0` (2026-03-26)
+#'
+#' @export
+fmt_typst <- function(
+ data,
+ columns = everything(),
+ rows = everything()
+) {
+
+ # Perform input object validation
+ stop_if_not_gt_tbl(data = data)
+
+ # Pass `data`, `columns`, `rows`, and the formatting
+ # functions as a function list to `fmt()`
+ fmt(
+ data = data,
+ columns = {{ columns }},
+ rows = {{ rows }},
+ fns = list(
+ typst = function(x) {
+ # Pass through raw Typst content without escaping
+ if (!is.character(x)) {
+ x <- as.character(x)
+ }
+ x
+ },
+ default = function(x) {
+ # For non-Typst contexts, pass through as plain text
+ if (!is.character(x)) {
+ x <- as.character(x)
+ }
+ x
+ }
+ )
+ )
+}
+
# fmt_passthrough() ------------------------------------------------------------
#' Format by simply passing data through
#'
diff --git a/R/gtsave.R b/R/gtsave.R
index 5cba3c709..2304f5277 100644
--- a/R/gtsave.R
+++ b/R/gtsave.R
@@ -29,7 +29,7 @@
#'
#' `gtsave()` makes it easy to save a **gt** table to a file. The function
#' guesses the file type by the extension provided in the output filename,
-#' producing either an HTML, PDF, PNG, LaTeX, RTF, or Word (.docx) file.
+#' producing either an HTML, PDF, PNG, LaTeX, RTF, Typst, or Word (.docx) file.
#'
#' @details
#'
@@ -65,6 +65,10 @@
#' document. The LaTeX and RTF saving functions don't have any options to pass
#' to `...`.
#'
+#' If the output filename extension is `.typ`, a Typst source file is produced.
+#' This file can be compiled to PDF using Typst directly or used in Quarto
+#' documents with `format: typst`.
+#'
#' If the output filename extension is `.docx`, a Word document file is
#' produced. This process is facilitated by the **rmarkdown** package, so this
#' package needs to be installed before attempting to save any table as a
@@ -77,7 +81,8 @@
#' `scalar` // **required**
#'
#' The file name to create on disk. Ensure that an extension compatible with
-#' the output types is provided (`.html`, `.tex`, `.ltx`, `.rtf`, `.docx`). If
+#' the output types is provided (`.html`, `.tex`, `.ltx`, `.rtf`, `.typ`,
+#' `.docx`). If
#' a custom save function is provided then the file extension is disregarded.
#'
#' @param path *Output path*
@@ -183,6 +188,7 @@ gtsave <- function(
"*" = "`.pdf` (PDF file)",
"*" = "`.tex`, `.rnw` (LaTeX file)",
"*" = "`.rtf` (RTF file)",
+ "*" = "`.typ` (Typst file)",
"*" = "`.docx` (Word file)"
))
}
@@ -197,6 +203,7 @@ gtsave <- function(
"rnw" = ,
"tex" = gt_save_latex(data = data, filename, path, ...),
"rtf" = gt_save_rtf(data = data, filename, path, ...),
+ "typ" = gt_save_typst(data = data, filename, path, ...),
"png" = ,
"pdf" = gt_save_webshot(data = data, filename, path, ...),
"docx" = gt_save_docx(data = data, filename, path, ...),
@@ -209,6 +216,7 @@ gtsave <- function(
"*" = "`.pdf` (PDF file)",
"*" = "`.tex`, `.rnw` (LaTeX file)",
"*" = "`.rtf` (RTF file)",
+ "*" = "`.typ` (Typst file)",
"*" = "`.docx` (Word file)"
))
}
@@ -419,6 +427,39 @@ gt_save_rtf <- function(
writeLines(rtf_lines, con = filename)
}
+#' Saving function for a Typst (.typ) file
+#'
+#' @noRd
+gt_save_typst <- function(
+ data,
+ filename,
+ path = NULL,
+ ...
+) {
+
+ filename <- gtsave_filename(path = path, filename = filename)
+
+ if (is_gt_tbl(data = data)) {
+
+ typst_str <- as_typst(data = data)
+
+ } else if (is_gt_group(data = data)) {
+
+ typst_tbls <- NULL
+
+ seq_tbls <- seq_len(nrow(data$gt_tbls))
+
+ for (i in seq_tbls) {
+ typst_tbl_i <- as_typst(grp_pull(data, which = i))
+ typst_tbls <- c(typst_tbls, typst_tbl_i)
+ }
+
+ typst_str <- paste(typst_tbls, collapse = "\n#pagebreak()\n")
+ }
+
+ writeLines(as.character(typst_str), con = filename)
+}
+
#' Saving function for a Word (docx) file
#'
#' @noRd
diff --git a/R/helpers.R b/R/helpers.R
index 4681040ae..ac8a92b95 100644
--- a/R/helpers.R
+++ b/R/helpers.R
@@ -189,6 +189,40 @@ latex <- function(text) {
text
}
+# typst() ----------------------------------------------------------------------
+#' Interpret input text as Typst-formatted text
+#'
+#' @description
+#'
+#' For certain pieces of text (like in column labels or table headings) we may
+#' want to express them as raw Typst markup. The `typst()` function will guard
+#' the input from being escaped in the Typst output context.
+#'
+#' @param text *Typst text*
+#'
+#' `scalar` // **required**
+#'
+#' The text that is understood to be Typst markup, which is to be preserved in
+#' the Typst output context.
+#'
+#' @return A character object of class `from_typst`. It's tagged as a Typst
+#' fragment that is not to be sanitized.
+#'
+#' @family helper functions
+#' @section Function ID:
+#' 8-32
+#'
+#' @section Function Introduced:
+#' `v0.13.0` (2026-03-26)
+#'
+#' @export
+typst <- function(text) {
+
+ # Apply the `from_typst` class
+ class(text) <- "from_typst"
+ text
+}
+
# px() -------------------------------------------------------------------------
#' Helper for providing a numeric value as pixels value
#'
@@ -3220,6 +3254,78 @@ escape_latex <- function(text, unicode_conversion = getOption("gt.latex.unicode_
text
}
+# escape_typst() ---------------------------------------------------------------
+#' Escape Typst special characters
+#'
+#' Typst markup mode treats several characters as special: backslash, hash,
+#' dollar, at-sign, angle brackets, asterisk, underscore, backtick, tilde,
+#' and square brackets. This function prefixes each with a backslash.
+#'
+#' @param text A character vector.
+#' @return A character vector with Typst special characters escaped.
+#'
+#' @noRd
+escape_typst <- function(text) {
+
+ if (length(text) < 1L) return(text)
+ if (all(is.na(text))) return(text)
+
+ na_text <- is.na(text)
+
+ # Typst special chars in markup mode: \ # $ @ < > * _ ` ~ [ ]
+ # Must escape backslash first to avoid double-escaping
+ text[!na_text] <- gsub("\\\\", "\\\\\\\\", text[!na_text])
+ text[!na_text] <- gsub("#", "\\\\#", text[!na_text])
+ text[!na_text] <- gsub("\\$", "\\\\$", text[!na_text])
+ text[!na_text] <- gsub("@", "\\\\@", text[!na_text])
+ text[!na_text] <- gsub("<", "\\\\<", text[!na_text])
+ text[!na_text] <- gsub(">", "\\\\>", text[!na_text])
+ text[!na_text] <- gsub("\\*", "\\\\*", text[!na_text])
+ text[!na_text] <- gsub("_", "\\\\_", text[!na_text])
+ text[!na_text] <- gsub("`", "\\\\`", text[!na_text])
+ text[!na_text] <- gsub("~", "\\\\~", text[!na_text])
+ text[!na_text] <- gsub("\\[", "\\\\[", text[!na_text])
+ text[!na_text] <- gsub("\\]", "\\\\]", text[!na_text])
+
+ text
+}
+
+# escape_typst_safe() ----------------------------------------------------------
+#' Escape Typst special characters (idempotent)
+#'
+#' Like `escape_typst()` but skips characters already preceded by a backslash.
+#' This is safe to call on text that may have already been partially escaped.
+#'
+#' @param text A character vector.
+#' @return A character vector with unescaped Typst special characters escaped.
+#'
+#' @noRd
+escape_typst_safe <- function(text) {
+
+ if (length(text) < 1L) return(text)
+ if (all(is.na(text))) return(text)
+
+ na_text <- is.na(text)
+
+ # Escape each special char only if NOT already preceded by \
+ # Use negative lookbehind (?*_`~\\[\\]])", "\\\\\\\\", text[!na_text], perl = TRUE)
+ text[!na_text] <- gsub("(?", "\\\\>", text[!na_text], perl = TRUE)
+ text[!na_text] <- gsub("(?>(+<<3>>, -<<2>>)"
+
+ } else if (context == "typst") {
+
+ pattern_unequal <- "<<1>>(+<<3>>, -<<2>>)"
}
# Determine rows where NA values exist
diff --git a/R/utils_render_typst.R b/R/utils_render_typst.R
new file mode 100644
index 000000000..b8422cfa1
--- /dev/null
+++ b/R/utils_render_typst.R
@@ -0,0 +1,1588 @@
+#------------------------------------------------------------------------------#
+#
+# /$$
+# | $$
+# /$$$$$$ /$$$$$$
+# /$$__ $$|_ $$_/
+# | $$ \ $$ | $$
+# | $$ | $$ | $$ /$$
+# | $$$$$$$ | $$$$/
+# \____ $$ \___/
+# /$$ \ $$
+# | $$$$$$/
+# \______/
+#
+# This file is part of the 'rstudio/gt' project.
+#
+# Copyright (c) 2018-2026 gt authors
+#
+# For full copyright and license information, please look at
+# https://gt.rstudio.com/LICENSE.html
+#
+#------------------------------------------------------------------------------#
+
+
+# -- Color & length conversion helpers ----------------------------------------
+
+css_color_to_typst <- function(color) {
+
+ if (is.null(color) || is.na(color) || nchar(color) == 0L) {
+ return(NULL)
+ }
+
+ # If already a hex color, wrap in rgb()
+
+ if (grepl("^#[0-9A-Fa-f]{3,8}$", color)) {
+
+ # Expand 3-digit hex to 6-digit
+ if (nchar(color) == 4L) {
+ chars <- strsplit(sub("^#", "", color), "")[[1]]
+ color <- paste0("#", chars[1], chars[1], chars[2], chars[2], chars[3], chars[3])
+ }
+
+ return(paste0("rgb(\"", color, "\")"))
+ }
+
+ # Handle rgba(r, g, b, a) or rgb(r, g, b)
+ if (grepl("^rgba?\\(", color)) {
+ nums <- as.numeric(regmatches(color, gregexpr("[0-9.]+", color))[[1]])
+ if (length(nums) >= 3L) {
+ hex <- sprintf("#%02X%02X%02X", as.integer(nums[1]), as.integer(nums[2]), as.integer(nums[3]))
+ if (length(nums) >= 4L) {
+ alpha_hex <- sprintf("%02X", as.integer(nums[4] * 255))
+ hex <- paste0(hex, alpha_hex)
+ }
+ return(paste0("rgb(\"", hex, "\")"))
+ }
+ }
+
+ # For named colors, convert to hex via grDevices to avoid Typst/CSS name mismatches
+ tryCatch({
+ rgb_vals <- grDevices::col2rgb(color)
+ hex <- sprintf("#%02X%02X%02X", rgb_vals[1], rgb_vals[2], rgb_vals[3])
+ paste0("rgb(\"", hex, "\")")
+ }, error = function(e) {
+ # Fallback: pass through (Typst may handle it)
+ color
+ })
+}
+
+css_length_to_typst <- function(length) {
+
+ if (is.null(length) || is.na(length) || nchar(length) == 0L) {
+ return(NULL)
+ }
+
+ # px -> pt conversion (1px = 0.75pt)
+ if (grepl("px$", length)) {
+ val <- as.numeric(sub("px$", "", length))
+ return(paste0(format(val * 0.75, nsmall = 1), "pt"))
+ }
+
+ # Passthrough for pt, cm, mm, in, em, %
+ if (grepl("(pt|cm|mm|in|em|%)$", length)) {
+ return(length)
+ }
+
+ # Numeric value: assume pt
+ if (grepl("^[0-9.]+$", length)) {
+ return(paste0(length, "pt"))
+ }
+
+ length
+}
+
+#' Convert CSS length to Typst length for text sizes
+#'
+#' Like css_length_to_typst() but converts % to em since Typst text()
+#' doesn't accept percentage sizes.
+#' @noRd
+css_length_to_typst_text_size <- function(length) {
+ if (is.null(length) || is.na(length) || nchar(length) == 0L) return(NULL)
+
+ # Convert % to em for text size
+ if (grepl("%$", length)) {
+ pct_val <- as.numeric(sub("%$", "", length))
+ return(paste0(format(pct_val / 100, nsmall = 2), "em"))
+ }
+
+ css_length_to_typst(length)
+}
+
+# -- Markdown to Typst conversion ---------------------------------------------
+
+markdown_to_typst <- function(text) {
+
+ if (length(text) == 0L) return(text)
+ if (all(is.na(text))) return(text)
+
+ na_idx <- is.na(text)
+
+ result <- text[!na_idx]
+
+ for (i in seq_along(result)) {
+
+ x <- result[i]
+
+ # Preserve inline code spans by replacing with placeholders
+ code_spans <- character(0)
+ code_pattern <- "`([^`]+)`"
+ while (grepl(code_pattern, x)) {
+ code_match <- regmatches(x, regexpr(code_pattern, x))
+ code_spans <- c(code_spans, code_match)
+ x <- sub(code_pattern, paste0("<>"), x, perl = TRUE)
+ }
+
+ # Convert links first (before any other processing that might affect [] or ())
+ x <- gsub("\\[([^\\]]+)\\]\\(([^)]+)\\)", "#link(\"\\2\")[\\1]", x, perl = TRUE)
+
+ # Convert strikethrough
+ x <- gsub("~~(.+?)~~", "#strike[\\1]", x, perl = TRUE)
+
+ # Markdown→Typst mapping:
+ # MD bold **x** or __x__ → Typst bold *x*
+ # MD italic *x* or _x_ → Typst italic _x_
+ # MD bold-italic ***x*** → Typst *_x_*
+ #
+ # Strategy: convert bold-italic first, then bold, then italic.
+ # Since MD italic *x* maps to Typst _x_, and MD bold **x** maps to
+ # Typst *x*, we must handle * carefully to avoid re-matching.
+ # Use placeholders to prevent re-matching.
+
+ # Bold-italic (*** or ___) → placeholder
+ x <- gsub("\\*\\*\\*(.+?)\\*\\*\\*", "\x01_\\1_\x01", x, perl = TRUE)
+ x <- gsub("___(.+?)___", "\x01_\\1_\x01", x, perl = TRUE)
+
+ # Bold (** or __) → placeholder
+ x <- gsub("\\*\\*(.+?)\\*\\*", "\x01\\1\x01", x, perl = TRUE)
+ x <- gsub("__(.+?)__", "\x01\\1\x01", x, perl = TRUE)
+
+ # Italic (* or _) → Typst _x_
+ # Single * not adjacent to another *
+ x <- gsub("(?>"), code_spans[j], x, fixed = TRUE)
+ }
+
+ result[i] <- x
+ }
+
+ text[!na_idx] <- result
+ text
+}
+
+# -- CSS to Typst weight mapping -----------------------------------------------
+
+#' Map CSS font-weight values to Typst weight values
+#' @noRd
+css_weight_to_typst <- function(weight) {
+
+ if (is.null(weight) || is.na(weight) || !nzchar(weight)) return(NULL)
+
+ # Typst accepts: "thin", "extralight", "light", "regular", "medium",
+ # "semibold", "bold", "extrabold", "black", or integer 100-900
+ weight_map <- c(
+ "normal" = "regular",
+ "initial" = "regular",
+ "inherit" = "regular",
+ "lighter" = "light",
+ "bolder" = "bold"
+ )
+
+ if (weight %in% names(weight_map)) {
+ return(weight_map[[weight]])
+ }
+
+ # Already a valid Typst weight or numeric
+ weight
+}
+
+# -- Option border helper ------------------------------------------------------
+
+#' Convert a gt border option triple (style, width, color) to a Typst stroke
+#' @param data A built gt_tbl object
+#' @param option_prefix The option prefix, e.g. "table_border_top"
+#' @return A Typst stroke string like "1.5pt + rgb(\"#000000\")" or NULL
+#' @noRd
+option_border_to_typst <- function(data, option_prefix) {
+
+ style <- dt_options_get_value(data = data, option = paste0(option_prefix, "_style"))
+ width <- dt_options_get_value(data = data, option = paste0(option_prefix, "_width"))
+ color <- dt_options_get_value(data = data, option = paste0(option_prefix, "_color"))
+
+ if (is.null(style) || style == "none" || style == "hidden") return(NULL)
+
+ typst_width <- css_length_to_typst(width) %||% "1pt"
+ typst_color <- css_color_to_typst(color) %||% "black"
+
+ # Typst doesn't support "double" — approximate as solid at ~1/3 the width
+ if (style == "double") {
+ tryCatch({
+ val <- as.numeric(sub("pt$", "", typst_width))
+ typst_width <- paste0(max(val / 3, 0.75), "pt")
+ }, error = function(e) NULL, warning = function(w) NULL)
+ }
+
+ paste0(typst_width, " + ", typst_color)
+}
+
+# -- Typst text color helper ---------------------------------------------------
+
+make_typst_text_with_color <- function(text, color = NULL) {
+
+ if (is.null(color) || is.null(text) || is.na(color)) {
+ return(text)
+ }
+
+ typst_color <- css_color_to_typst(color)
+ paste0("#text(fill: ", typst_color, ")[", text, "]")
+}
+
+# -- Footnote helpers ----------------------------------------------------------
+
+footnote_mark_to_typst <- function(data, mark, location = c("ref", "ftr")) {
+
+ location <- match.arg(location)
+
+ if (length(mark) == 1L && is.na(mark)) return("")
+ if (length(mark) == 0L) return("")
+
+ mark <- as.character(mark)
+
+ paste0("#super[", mark, "]")
+}
+
+
+# -- Style application helpers ------------------------------------------------
+
+#' Convert a consolidated style list to Typst cell and text properties
+#'
+#' Returns a list with two elements:
+#' $cell_props - named character vector of table.cell() properties (fill, stroke, align)
+#' $text_wraps - character vector of text wrappers to nest around content
+#' @noRd
+styles_to_typst <- function(styles) {
+
+ if (is.null(styles) || length(styles) == 0L) {
+ return(list(cell_props = character(0), text_wraps = character(0)))
+ }
+
+ cell_props <- character(0)
+ text_props <- character(0)
+ outer_wraps <- character(0) # wraps applied outside #text()
+
+ for (style_obj in styles) {
+
+ if (is.null(names(style_obj))) next
+
+ cls <- class(style_obj)
+
+ if ("cell_text" %in% cls) {
+
+ s <- unclass(style_obj)
+
+ if (!is.null(s$color) && !is.na(s$color)) {
+ typst_color <- css_color_to_typst(s$color)
+ if (!is.null(typst_color)) {
+ text_props <- c(text_props, paste0("fill: ", typst_color))
+ }
+ }
+
+ if (!is.null(s$weight) && !is.na(s$weight)) {
+ weight_val <- if (is.numeric(s$weight)) {
+ as.character(as.integer(s$weight))
+ } else {
+ paste0("\"", css_weight_to_typst(s$weight), "\"")
+ }
+ text_props <- c(text_props, paste0("weight: ", weight_val))
+ }
+
+ if (!is.null(s$style) && !is.na(s$style)) {
+ text_props <- c(text_props, paste0("style: \"", s$style, "\""))
+ }
+
+ if (!is.null(s$size) && !is.na(s$size)) {
+ typst_size <- css_length_to_typst_text_size(s$size)
+ if (!is.null(typst_size)) {
+ text_props <- c(text_props, paste0("size: ", typst_size))
+ }
+ }
+
+ if (!is.null(s$font) && !is.na(s$font)) {
+ text_props <- c(text_props, paste0("font: \"", s$font, "\""))
+ }
+
+ if (!is.null(s$decorate) && !is.na(s$decorate)) {
+ if (s$decorate == "underline") {
+ outer_wraps <- c(outer_wraps, "underline")
+ } else if (s$decorate %in% c("line-through", "strikeout")) {
+ outer_wraps <- c(outer_wraps, "strike")
+ } else if (s$decorate == "overline") {
+ outer_wraps <- c(outer_wraps, "overline")
+ }
+ }
+
+ if (!is.null(s$transform) && !is.na(s$transform)) {
+ if (s$transform == "uppercase") {
+ outer_wraps <- c(outer_wraps, "upper")
+ } else if (s$transform == "lowercase") {
+ outer_wraps <- c(outer_wraps, "lower")
+ } else if (s$transform == "capitalize") {
+ outer_wraps <- c(outer_wraps, "smallcaps")
+ }
+ }
+
+ if (!is.null(s$align) && !is.na(s$align)) {
+ cell_props <- c(cell_props, paste0("align: ", s$align))
+ }
+
+ } else if ("cell_fill" %in% cls) {
+
+ s <- unclass(style_obj)
+
+ if (!is.null(s$color) && !is.na(s$color)) {
+ typst_color <- css_color_to_typst(s$color)
+ if (!is.null(typst_color)) {
+ cell_props <- c(cell_props, paste0("fill: ", typst_color))
+ }
+ }
+
+ } else if ("cell_border" %in% cls) {
+
+ s <- unclass(style_obj)
+ # cell_border objects have: style, width, color, sides
+ side <- sub("^cell_border_", "", cls[grepl("^cell_border_", cls)])
+ if (length(side) == 0L) side <- "bottom"
+
+ width <- if (!is.null(s$width)) css_length_to_typst(s$width) else "1pt"
+ color <- if (!is.null(s$color)) css_color_to_typst(s$color) else "black"
+
+ stroke_val <- paste0(width, " + ", color)
+ cell_props <- c(cell_props, paste0("stroke: (", side, ": ", stroke_val, ")"))
+ }
+ }
+
+ list(
+ cell_props = cell_props,
+ text_props = text_props,
+ outer_wraps = outer_wraps
+ )
+}
+
+#' Apply Typst styles to cell content
+#'
+#' @param content The cell content string (already escaped)
+#' @param styles_list Output of styles_to_typst()
+#' @param colspan Integer colspan (default 1)
+#' @return A string like `table.cell(fill: ...)[#text(weight: "bold")[content]]`
+#' @noRd
+apply_cell_styles_t <- function(content, styles_list = NULL, colspan = 1L) {
+
+ if (is.null(styles_list)) {
+ styles_list <- list(cell_props = character(0), text_props = character(0), outer_wraps = character(0))
+ }
+
+ inner <- content
+
+ # Apply outer wraps (innermost first)
+ for (wrap in rev(styles_list$outer_wraps)) {
+ inner <- paste0("#", wrap, "[", inner, "]")
+ }
+
+ # Apply text properties
+ if (length(styles_list$text_props) > 0L) {
+ inner <- paste0("#text(", paste(styles_list$text_props, collapse = ", "), ")[", inner, "]")
+ }
+
+ # Build cell properties
+ cell_props <- styles_list$cell_props
+ if (colspan > 1L) {
+ cell_props <- c(paste0("colspan: ", colspan), cell_props)
+ }
+
+ # If any cell-level properties, wrap in table.cell()
+ if (length(cell_props) > 0L) {
+ paste0("table.cell(", paste(cell_props, collapse = ", "), ")[", inner, "]")
+ } else {
+ paste0("[", inner, "]")
+ }
+}
+
+# -- Component constructors ---------------------------------------------------
+
+#' Get the total number of visible columns (including stub)
+#' @noRd
+get_n_cols_total <- function(data) {
+
+ n_data_cols <- length(dt_boxhead_get_vars_default(data = data))
+ n_stub_cols <- get_stub_column_count(data = data)
+
+ n_data_cols + n_stub_cols
+}
+
+#' Create the heading component as table.cell rows inside the table
+#'
+#' Returns table.cell(colspan: N) entries for title/subtitle, placed inside
+#' the #table() before the header. This way the heading inherits the table's
+#' width and the background/borders work as part of the table structure.
+#' @noRd
+create_heading_component_t <- function(data) {
+
+ heading <- dt_heading_get(data = data)
+ has_title <- dt_heading_has_title(data = data)
+
+ if (!has_title) return("")
+
+ title <- heading$title
+ subtitle <- heading$subtitle
+ n_cols <- get_n_cols_total(data = data)
+
+ # Heading options
+ heading_align <- dt_options_get_value(data = data, option = "heading_align")
+ title_font_size <- dt_options_get_value(data = data, option = "heading_title_font_size")
+ title_font_weight <- dt_options_get_value(data = data, option = "heading_title_font_weight")
+ subtitle_font_size <- dt_options_get_value(data = data, option = "heading_subtitle_font_size")
+ subtitle_font_weight <- dt_options_get_value(data = data, option = "heading_subtitle_font_weight")
+ heading_bg <- dt_options_get_value(data = data, option = "heading_background_color")
+ heading_padding <- dt_options_get_value(data = data, option = "heading_padding")
+ heading_padding_h <- dt_options_get_value(data = data, option = "heading_padding_horizontal")
+ font_color_light <- dt_options_get_value(data = data, option = "table_font_color_light")
+
+ typst_align <- heading_align %||% "center"
+ typst_padding <- css_length_to_typst(heading_padding) %||% "4pt"
+ typst_padding_h <- css_length_to_typst(heading_padding_h) %||% "5pt"
+
+ typst_title_size <- css_length_to_typst_text_size(title_font_size) %||% "1.25em"
+ typst_subtitle_size <- css_length_to_typst_text_size(subtitle_font_size) %||% "0.85em"
+
+ # Title text properties
+ title_text_props <- paste0("size: ", typst_title_size)
+ if (!is.null(title_font_weight) && title_font_weight != "initial") {
+ title_text_props <- paste0(title_text_props, ", weight: \"", css_weight_to_typst(title_font_weight), "\"")
+ } else {
+ title_text_props <- paste0(title_text_props, ", weight: \"bold\"")
+ }
+
+ # Use light text color when heading has a dark background
+ heading_text_fill <- ""
+ if (!is.null(heading_bg) && !is.na(heading_bg) && nzchar(heading_bg)) {
+ tryCatch({
+ rgb_vals <- grDevices::col2rgb(heading_bg)
+ luminance <- (0.299 * rgb_vals[1] + 0.587 * rgb_vals[2] + 0.114 * rgb_vals[3]) / 255
+ if (luminance < 0.5) {
+ light_color <- font_color_light %||% "#FFFFFF"
+ typst_light <- css_color_to_typst(light_color)
+ heading_text_fill <- paste0(", fill: ", typst_light)
+ title_text_props <- paste0(title_text_props, heading_text_fill)
+ }
+ }, error = function(e) NULL)
+ }
+
+ # Build cell properties
+ cell_props <- paste0("colspan: ", n_cols, ", align: ", typst_align)
+ if (!is.null(heading_bg) && !is.na(heading_bg) && nzchar(heading_bg)) {
+ typst_bg <- css_color_to_typst(heading_bg)
+ if (!is.null(typst_bg)) {
+ cell_props <- paste0(cell_props, ", fill: ", typst_bg)
+ }
+ }
+ cell_props <- paste0(cell_props, ", inset: (x: ", typst_padding_h, ", y: ", typst_padding, ")")
+
+ # Build title content (with optional subtitle on next line using \)
+ title_content <- paste0("#text(", title_text_props, ")[", title, "]")
+
+ if (!is.null(subtitle) && !is.na(subtitle) && nzchar(subtitle)) {
+ subtitle_text_props <- paste0("size: ", typst_subtitle_size)
+ if (!is.null(subtitle_font_weight) && subtitle_font_weight != "initial") {
+ subtitle_text_props <- paste0(subtitle_text_props, ", weight: \"", css_weight_to_typst(subtitle_font_weight), "\"")
+ }
+ subtitle_text_props <- paste0(subtitle_text_props, heading_text_fill)
+ title_content <- paste0(title_content, " \\ #text(", subtitle_text_props, ")[", subtitle, "]")
+ }
+
+ out <- paste0(" table.cell(", cell_props, ")[", title_content, "],\n")
+
+ # Heading border bottom
+ heading_border <- option_border_to_typst(data, "heading_border_bottom")
+ if (!is.null(heading_border)) {
+ out <- paste0(out, " table.hline(stroke: ", heading_border, "),\n")
+ }
+
+ out
+}
+
+#' Create the table start: #table(columns:, align:, stroke:, inset:,
+#' @noRd
+create_table_start_t <- function(data) {
+
+ n_cols <- get_n_cols_total(data = data)
+ stub_layout <- get_stub_layout(data = data)
+ default_vars <- dt_boxhead_get_vars_default(data = data)
+ boxhead <- dt_boxhead_get(data = data)
+
+ # Build alignment vector
+ alignments <- character(0)
+
+ # Stub columns
+ if (!is.null(stub_layout)) {
+ n_stub <- get_stub_column_count(data = data)
+ alignments <- c(alignments, rep("left", n_stub))
+ }
+
+ # Data columns
+ for (var in default_vars) {
+ align_val <- boxhead$column_align[boxhead$var == var]
+ if (length(align_val) == 0L) align_val <- "center"
+ alignments <- c(alignments, align_val)
+ }
+
+ align_str <- paste(alignments, collapse = ", ")
+
+ # Build column widths
+ col_widths <- character(0)
+ has_widths <- FALSE
+
+ if (!is.null(stub_layout)) {
+ n_stub <- get_stub_column_count(data = data)
+ col_widths <- c(col_widths, rep("auto", n_stub))
+ }
+
+ for (var in default_vars) {
+ width_raw <- boxhead$column_width[boxhead$var == var]
+ # column_width is a deeply nested list column; unwrap to get the string value
+ while (is.list(width_raw) && length(width_raw) > 0L) {
+ width_raw <- width_raw[[1]]
+ }
+ if (length(width_raw) == 0L || is.null(width_raw) || is.na(width_raw)) {
+ col_widths <- c(col_widths, "auto")
+ } else {
+ typst_width <- css_length_to_typst(as.character(width_raw))
+ if (!is.null(typst_width)) {
+ col_widths <- c(col_widths, typst_width)
+ has_widths <- TRUE
+ } else {
+ col_widths <- c(col_widths, "auto")
+ }
+ }
+ }
+
+ # Use tuple format if any widths specified, otherwise integer
+
+ if (has_widths) {
+ columns_str <- paste0("(", paste(col_widths, collapse = ", "), ")")
+ } else {
+ columns_str <- as.character(n_cols)
+ }
+
+ # Table width — stored for use by as_typst() to wrap in #block(width: ...)
+ # Typst's #table() doesn't have a width parameter; we use a block wrapper
+
+ # Table background color
+ bg_color_str <- ""
+ bg_color <- dt_options_get_value(data = data, option = "table_background_color")
+ if (!is.null(bg_color) && nzchar(bg_color) && bg_color != "#FFFFFF") {
+ typst_bg <- css_color_to_typst(bg_color)
+ if (!is.null(typst_bg)) {
+ bg_color_str <- paste0(" fill: ", typst_bg, ",\n")
+ }
+ }
+
+ # Cell padding from options
+ row_pad <- dt_options_get_value(data = data, option = "data_row_padding")
+ row_pad_h <- dt_options_get_value(data = data, option = "data_row_padding_horizontal")
+
+ inset_y <- css_length_to_typst(row_pad) %||% "5pt"
+ inset_x <- css_length_to_typst(row_pad_h) %||% "8pt"
+ inset_str <- paste0("inset: (x: ", inset_x, ", y: ", inset_y, ")")
+
+ # Compute header row count for striping offset.
+ # Since heading, spanners, and column labels are all rendered as table rows,
+ # the fill function's y index includes them. We offset so striping starts
+ # at the first data row.
+ has_heading <- dt_heading_has_title(data = data)
+ spanner_levels <- dt_spanners_matrix_height(data = data, omit_columns_row = TRUE)
+ col_labels_visible <- !isTRUE(dt_options_get_value(data = data, option = "column_labels_hidden"))
+ header_row_count <- (if (has_heading) 1L else 0L) + spanner_levels + (if (col_labels_visible) 1L else 0L)
+
+ # Row striping (takes precedence over static background for fill)
+ fill_str <- ""
+ striping_opt <- dt_options_get_value(data = data, option = "row_striping_include_table_body")
+ if (isTRUE(striping_opt)) {
+ stripe_color <- dt_options_get_value(data = data, option = "row_striping_background_color")
+ if (!is.null(stripe_color) && nzchar(stripe_color)) {
+ typst_color <- css_color_to_typst(stripe_color)
+ } else {
+ typst_color <- "luma(244)"
+ }
+ striping_include_stub <- isTRUE(dt_options_get_value(data = data, option = "row_striping_include_stub"))
+ has_stub <- !is.null(stub_layout)
+
+ if (has_stub && !striping_include_stub) {
+ fill_str <- paste0(
+ " fill: (x, y) => if y >= ", header_row_count,
+ " and calc.odd(y - ", header_row_count, ") and x > 0 { ", typst_color, " },\n"
+ )
+ } else {
+ fill_str <- paste0(
+ " fill: (_, y) => if y >= ", header_row_count,
+ " and calc.odd(y - ", header_row_count, ") { ", typst_color, " },\n"
+ )
+ }
+ } else if (nzchar(bg_color_str)) {
+ # Static background color (only if not using row striping)
+ fill_str <- bg_color_str
+ }
+
+ # Top table border
+ top_hline <- ""
+ top_border_include <- dt_options_get_value(data = data, option = "table_border_top_include")
+ if (isTRUE(top_border_include)) {
+ top_border <- option_border_to_typst(data, "table_border_top")
+ if (!is.null(top_border)) {
+ top_hline <- paste0(" table.hline(stroke: ", top_border, "),\n")
+ }
+ }
+
+ # Table body hlines (horizontal lines between rows)
+ hlines_style <- dt_options_get_value(data = data, option = "table_body_hlines_style")
+ stroke_str <- " stroke: none,\n"
+ if (!is.null(hlines_style) && hlines_style != "none") {
+ hlines_border <- option_border_to_typst(data, "table_body_hlines")
+ if (!is.null(hlines_border)) {
+ stroke_str <- paste0(" stroke: (x: none, y: ", hlines_border, "),\n")
+ }
+ }
+
+ # Table body vlines (vertical lines between columns)
+ vlines_style <- dt_options_get_value(data = data, option = "table_body_vlines_style")
+ if (!is.null(vlines_style) && vlines_style != "none") {
+ vlines_border <- option_border_to_typst(data, "table_body_vlines")
+ if (!is.null(vlines_border)) {
+ # If both hlines and vlines, combine
+ if (hlines_style != "none" && !is.null(hlines_border)) {
+ hlines_border_val <- option_border_to_typst(data, "table_body_hlines")
+ stroke_str <- paste0(" stroke: (x: ", vlines_border, ", y: ", hlines_border_val, "),\n")
+ } else {
+ stroke_str <- paste0(" stroke: (x: ", vlines_border, ", y: none),\n")
+ }
+ }
+ }
+
+ # Table border left/right (vertical lines at edges)
+ left_vline <- ""
+ left_border <- option_border_to_typst(data, "table_border_left")
+ if (!is.null(left_border)) {
+ left_vline <- paste0(" table.vline(x: 0, stroke: ", left_border, "),\n")
+ }
+
+ right_vline <- ""
+ right_border <- option_border_to_typst(data, "table_border_right")
+ if (!is.null(right_border)) {
+ right_vline <- paste0(" table.vline(x: ", n_cols, ", stroke: ", right_border, "),\n")
+ }
+
+ paste0(
+ "#table(\n",
+ " columns: ", columns_str, ",\n",
+ " align: (", align_str, "),\n",
+ stroke_str,
+ " ", inset_str, ",\n",
+ fill_str,
+ left_vline,
+ right_vline,
+ # Stub vertical border (stub_separate + stub_border)
+ if (!is.null(stub_layout) && isTRUE(dt_options_get_value(data = data, option = "stub_separate"))) {
+ stub_border_val <- option_border_to_typst(data, "stub_border")
+ n_stub_cols <- get_stub_column_count(data = data)
+ if (!is.null(stub_border_val)) {
+ paste0(" table.vline(x: ", n_stub_cols, ", stroke: ", stub_border_val, "),\n")
+ } else {
+ ""
+ }
+ } else {
+ ""
+ },
+ top_hline
+ )
+}
+
+#' Create the columns component (table.header with heading + spanners + labels)
+#' @param heading_content Character string of heading table.cell rows to include
+#' at the top of table.header() for page-break repetition
+#' @noRd
+create_columns_component_t <- function(data, heading_content = "") {
+
+ # Check if column labels are hidden
+ col_labels_hidden <- dt_options_get_value(data = data, option = "column_labels_hidden")
+ if (isTRUE(col_labels_hidden)) {
+ # Still emit table.header() with heading for page-break repetition
+ if (nzchar(heading_content)) {
+ return(paste0(" table.header(\n", heading_content, " ),\n"))
+ }
+ return("")
+ }
+
+ stub_layout <- get_stub_layout(data = data)
+ default_vars <- dt_boxhead_get_vars_default(data = data)
+ headings_labels <- dt_boxhead_get_vars_labels_default(data = data)
+ n_cols <- get_n_cols_total(data = data)
+
+ # Column label options
+ col_bg <- dt_options_get_value(data = data, option = "column_labels_background_color")
+ col_font_size <- dt_options_get_value(data = data, option = "column_labels_font_size")
+ col_font_weight <- dt_options_get_value(data = data, option = "column_labels_font_weight")
+ col_text_transform <- dt_options_get_value(data = data, option = "column_labels_text_transform")
+ col_border_lr <- option_border_to_typst(data, "column_labels_border_lr")
+
+ # Build label vector (stub labels first)
+ labels <- character(0)
+
+ if (!is.null(stub_layout)) {
+ n_stub <- get_stub_column_count(data = data)
+ stubh <- dt_stubhead_get(data = data)
+
+ if (n_stub == 1L) {
+ stub_label <- if (!is.null(stubh$label) && !is.na(stubh$label)) stubh$label else ""
+ labels <- c(labels, stub_label)
+ } else {
+ # Multi-column stub
+ for (j in seq_len(n_stub)) {
+ if (j == 1L && "group_label" %in% stub_layout) {
+ labels <- c(labels, "")
+ } else {
+ stub_vars <- dt_boxhead_get_var_stub(data = data)
+ idx <- if ("group_label" %in% stub_layout) j - 1L else j
+ labels <- c(labels, if (idx <= length(stub_vars)) stub_vars[idx] else "")
+ }
+ }
+ }
+ }
+
+ labels <- c(labels, headings_labels)
+
+ # Format header cells with column label options
+ # Build text properties for header cells
+ header_text_props <- character(0)
+ if (!is.null(col_font_size) && nzchar(col_font_size) && col_font_size != "100%") {
+ typst_size <- css_length_to_typst_text_size(col_font_size)
+ if (!is.null(typst_size)) {
+ header_text_props <- c(header_text_props, paste0("size: ", typst_size))
+ }
+ }
+ # For non-standard weights (not "normal" or "bold"), use #text(weight:)
+ # For "bold", we use *...* markup instead (simpler, more idiomatic Typst)
+ if (!is.null(col_font_weight) && !col_font_weight %in% c("normal", "bold")) {
+ typst_cw <- css_weight_to_typst(col_font_weight)
+ header_text_props <- c(header_text_props, paste0("weight: \"", typst_cw, "\""))
+ }
+
+ header_cell_props <- character(0)
+
+ if (!is.null(col_bg) && !is.na(col_bg) && nzchar(col_bg)) {
+ typst_bg <- css_color_to_typst(col_bg)
+ if (!is.null(typst_bg)) {
+ header_cell_props <- c(header_cell_props, paste0("fill: ", typst_bg))
+ }
+ }
+
+ if (!is.null(col_border_lr)) {
+ header_cell_props <- c(header_cell_props, paste0("stroke: (left: ", col_border_lr, ", right: ", col_border_lr, ")"))
+ }
+
+ header_cells <- vapply(labels, function(lbl) {
+ if (nchar(lbl) == 0L) return("[]")
+
+ # Apply text transform
+ if (!is.null(col_text_transform) && col_text_transform != "inherit") {
+ if (col_text_transform == "uppercase") lbl <- toupper(lbl)
+ else if (col_text_transform == "lowercase") lbl <- tolower(lbl)
+ }
+
+ inner <- lbl
+
+ # Bold formatting only when column_labels_font_weight is "bold"
+ if (!is.null(col_font_weight) && col_font_weight == "bold") {
+ inner <- paste0("*", inner, "*")
+ }
+
+ # Wrap with text properties if any
+ if (length(header_text_props) > 0L) {
+ inner <- paste0("#text(", paste(header_text_props, collapse = ", "), ")[", inner, "]")
+ }
+
+ # Wrap with cell properties if any
+ if (length(header_cell_props) > 0L) {
+ paste0("table.cell(", paste(header_cell_props, collapse = ", "), ")[", inner, "]")
+ } else {
+ paste0("[", inner, "]")
+ }
+ }, character(1), USE.NAMES = FALSE)
+
+ # Build spanner rows (if any)
+ spanner_output <- ""
+
+ spanner_height <- dt_spanners_matrix_height(data = data, omit_columns_row = TRUE)
+
+ if (spanner_height > 0L) {
+
+ spanners_matrix <- dt_spanners_print_matrix(
+ data = data, include_hidden = FALSE, omit_columns_row = TRUE
+ )
+ spanner_ids_matrix <- dt_spanners_print_matrix(
+ data = data, ids = TRUE, omit_columns_row = TRUE
+ )
+
+ n_stub <- get_stub_column_count(data = data)
+
+ for (row_i in seq_len(nrow(spanners_matrix))) {
+
+ spanner_row <- spanners_matrix[row_i, ]
+ spanner_ids_row <- spanner_ids_matrix[row_i, ]
+
+ # Build cells for this spanner row
+ spanner_cells <- character(0)
+
+ # Stub columns get empty cells
+ if (n_stub > 0L) {
+ spanner_cells <- c(spanner_cells, rep("[]", n_stub))
+ }
+
+ # Process data column spanners using run-length encoding
+ rle_result <- rle(as.character(spanner_ids_row))
+
+ hline_segments <- character(0)
+ col_pos <- n_stub # track position for hline start/end
+
+ for (j in seq_along(rle_result$lengths)) {
+
+ span_len <- rle_result$lengths[j]
+ span_id <- rle_result$values[j]
+ span_label <- spanners_matrix[row_i, sum(rle_result$lengths[seq_len(j - 1)]) + 1L]
+
+ start_col <- col_pos
+ end_col <- col_pos + span_len
+
+ if (!is.na(span_id) && nzchar(span_id)) {
+ # Spanner cell — inherit column_labels font weight
+ spanner_inner <- if (!is.null(col_font_weight) && col_font_weight == "bold") {
+ paste0("*", span_label, "*")
+ } else {
+ span_label
+ }
+ if (span_len > 1L) {
+ spanner_cells <- c(
+ spanner_cells,
+ paste0("table.cell(colspan: ", span_len, ", align: center)[", spanner_inner, "]")
+ )
+ } else {
+ spanner_cells <- c(spanner_cells, paste0("[", spanner_inner, "]"))
+ }
+
+ # Spanner underline — lighter than column_labels_border_bottom
+ spanner_stroke <- "0.75pt + rgb(\"#D3D3D3\")"
+ hline_segments <- c(
+ hline_segments,
+ paste0("table.hline(start: ", start_col, ", end: ", end_col, ", stroke: ", spanner_stroke, ")")
+ )
+ } else {
+ # Empty cells for non-spanned columns
+ spanner_cells <- c(spanner_cells, rep("[]", span_len))
+ }
+
+ col_pos <- end_col
+ }
+
+ spanner_output <- paste0(
+ spanner_output,
+ " ", paste(spanner_cells, collapse = ", "), ",\n",
+ if (length(hline_segments) > 0L) {
+ paste0(" ", paste(hline_segments, collapse = ", "), ",\n")
+ } else {
+ ""
+ }
+ )
+ }
+ }
+
+ # Column labels vlines (vertical lines between header cells)
+ col_vlines_str <- ""
+ col_vlines_style <- dt_options_get_value(data = data, option = "column_labels_vlines_style")
+ if (!is.null(col_vlines_style) && col_vlines_style != "none") {
+ col_vlines_border <- option_border_to_typst(data, "column_labels_vlines")
+ if (!is.null(col_vlines_border)) {
+ # Add vlines between each column in the header
+ for (vi in seq_len(n_cols - 1L)) {
+ col_vlines_str <- paste0(
+ col_vlines_str,
+ " table.vline(x: ", vi, ", stroke: ", col_vlines_border, "),\n"
+ )
+ }
+ }
+ }
+
+ # Column label top border
+ col_top_border <- option_border_to_typst(data, "column_labels_border_top")
+ col_top_hline <- if (!is.null(col_top_border)) {
+ paste0(" table.hline(stroke: ", col_top_border, "),\n")
+ } else {
+ ""
+ }
+
+ # Column label bottom border
+ col_border <- option_border_to_typst(data, "column_labels_border_bottom")
+ col_hline <- if (!is.null(col_border)) {
+ paste0(" table.hline(stroke: ", col_border, "),\n")
+ } else {
+ " table.hline(stroke: 0.75pt),\n"
+ }
+
+ # Assemble header — heading goes inside table.header() for page-break repetition
+ paste0(
+ " table.header(\n",
+ heading_content,
+ col_top_hline,
+ col_vlines_str,
+ spanner_output,
+ " ", paste(header_cells, collapse = ", "), ",\n",
+ col_hline,
+ " ),\n"
+ )
+}
+
+#' Render a single body row with styles applied
+#' @param stub_style_list Optional pre-computed stub style list from styles_to_typst()
+#' @noRd
+render_body_row_t <- function(row_cells, row_i, col_vars, styles_tbl, stub_col_vars = NULL, stub_style_list = NULL) {
+
+ styled_cells <- character(length(row_cells))
+
+ for (j in seq_along(row_cells)) {
+ cell_content <- row_cells[j]
+ var_name <- col_vars[j]
+
+ # Look up styles for this cell
+ cell_styles <- styles_tbl[
+ styles_tbl$locname == "data" &
+ styles_tbl$colname == var_name &
+ styles_tbl$rownum == row_i,
+ ,
+ drop = FALSE
+ ]
+
+ if (nrow(cell_styles) > 0L) {
+ # Consolidate and apply styles
+ all_style_objs <- unlist(cell_styles$styles, recursive = FALSE)
+ typst_styles <- styles_to_typst(all_style_objs)
+ styled_cells[j] <- apply_cell_styles_t(cell_content, typst_styles)
+ } else if (!is.null(stub_style_list) && var_name %in% stub_col_vars) {
+ # Apply default stub styling from options
+ styled_cells[j] <- apply_cell_styles_t(cell_content, stub_style_list)
+ } else {
+ styled_cells[j] <- paste0("[", cell_content, "]")
+ }
+ }
+
+ paste0(" ", paste(styled_cells, collapse = ", "), ",\n")
+}
+
+#' Create the body component (data rows, group rows, summary rows)
+#' @noRd
+create_body_component_t <- function(data) {
+
+ cell_matrix <- get_body_component_cell_matrix(data = data)
+
+ if (nrow(cell_matrix) == 0L) return("")
+
+ # Escape Typst special characters in formatted cell content.
+ # Format functions (fmt_currency, fmt_number, etc.) produce plain strings
+ # that may contain Typst-special chars like $ (currency) or % (percent).
+ # These need escaping since they'll be placed inside [...] content blocks.
+ # Cells processed by fmt_typst() are already raw Typst and should not be
+ # escaped — but those cells are handled by the format dispatch (they have
+ # a "typst" context function that skips escaping). The cell_matrix here
+ # contains the output of format functions, so we escape remaining specials.
+ formats <- dt_formats_get(data = data)
+ typst_fmt_cols <- character(0)
+ for (fmt in formats) {
+ if ("typst" %in% names(fmt$func)) {
+ typst_fmt_cols <- c(typst_fmt_cols, fmt$cols)
+ }
+ }
+
+ # Escape Typst special chars in formatted cells.
+ # Unformatted cells are already escaped by migrate_unformatted_to_output().
+ # Formatted cells (from fmt_currency, fmt_number, etc.) may contain
+ # unescaped $ or % characters. We use escape_typst_safe() which only
+ # escapes characters NOT already preceded by a backslash.
+ #
+ # Columns formatted with fmt_typst() are skipped entirely.
+ default_vars_vec <- dt_boxhead_get_vars_default(data = data)
+ typst_fmt_col_indices <- integer(0)
+ for (fmt in formats) {
+ # Detect formats that produce raw Typst markup (should not be escaped):
+ # - fmt_typst(): has "typst" + "default" keys only (no "html")
+ # - fmt_markdown(): has "typst" + "html" + other keys, but its typst
+ # handler produces Typst markup via markdown_to_typst()
+ # We identify these by checking if the typst handler differs from the
+ # default handler — standard fmt_* functions share the same logic across
+ # contexts (produced by the format infrastructure), while fmt_typst and
+ # fmt_markdown have distinct hand-written typst handlers.
+ fmt_keys <- names(fmt$func)
+ is_raw_typst <- FALSE
+ if ("typst" %in% fmt_keys) {
+ if (!("html" %in% fmt_keys)) {
+ # fmt_typst: only has typst + default
+ is_raw_typst <- TRUE
+ } else if (!identical(body(fmt$func$typst), body(fmt$func$default))) {
+ # fmt_markdown and similar: typst handler differs from default
+ # (standard fmts share the same body via the format infrastructure)
+ is_raw_typst <- TRUE
+ }
+ }
+ if (is_raw_typst) {
+ for (fc in fmt$cols) {
+ idx <- match(fc, default_vars_vec)
+ if (!is.na(idx)) {
+ typst_fmt_col_indices <- c(typst_fmt_col_indices, idx + get_stub_column_count(data = data))
+ }
+ }
+ }
+ }
+
+ for (col_i in seq_len(ncol(cell_matrix))) {
+ if (col_i %in% typst_fmt_col_indices) next
+ cell_matrix[, col_i] <- escape_typst_safe(cell_matrix[, col_i])
+ # Convert scientific notation exponents (10^N) to Typst superscripts
+ # ^ is not a Typst special char so it survives escape_typst_safe unchanged
+ cell_matrix[, col_i] <- gsub(
+ "10\\^([0-9\u2212+.-]+)", "10#super[\\1]",
+ cell_matrix[, col_i], perl = TRUE
+ )
+ }
+
+ # Note: table_body_border_top overlaps with column_labels_border_bottom
+ # and table_body_border_bottom overlaps with grand_summary/table_border_bottom.
+ # These are handled by their respective component functions to avoid double borders.
+
+ stub_layout <- get_stub_layout(data = data)
+ groups_rows_df <- dt_groups_rows_get(data = data)
+ n_cols <- get_n_cols_total(data = data)
+ list_of_summaries <- dt_summary_df_get(data = data)
+ has_groups <- nrow(groups_rows_df) > 0L
+ has_group_column <- "group_label" %in% stub_layout
+
+ styles_tbl <- dt_styles_get(data = data)
+
+ # Apply stub indentation
+ has_stub_column <- !is.null(stub_layout) && "rowname" %in% stub_layout
+ if (has_stub_column) {
+ stub_df <- dt_stub_df_get(data = data)
+ if (!all(is.na(stub_df$indent))) {
+ indent_length <- dt_options_get_value(data = data, option = "stub_indent_length")
+ indent_px <- as.integer(gsub("px", "", indent_length))
+ indent_pt <- indent_px * 0.75
+
+ # Find the stub column index in the matrix
+ stub_col_idx <- if (has_group_column) 2L else 1L
+
+ for (r in seq_len(nrow(cell_matrix))) {
+ indent_val <- stub_df$indent[r]
+ if (!is.na(indent_val) && indent_val > 0L) {
+ cell_matrix[r, stub_col_idx] <- paste0(
+ "#h(", indent_pt * indent_val, "pt)", cell_matrix[r, stub_col_idx]
+ )
+ }
+ }
+ }
+ }
+
+ # Build column variable names vector matching the cell_matrix columns
+ default_vars <- dt_boxhead_get_vars_default(data = data)
+ stub_vars <- character(0)
+ if (!is.null(stub_layout)) {
+ if ("group_label" %in% stub_layout) {
+ stub_vars <- c(stub_vars, "::group::")
+ }
+ if ("rowname" %in% stub_layout) {
+ sv <- dt_boxhead_get_var_stub(data = data)
+ if (!anyNA(sv)) stub_vars <- c(stub_vars, sv)
+ else stub_vars <- c(stub_vars, "::stub::")
+ }
+ }
+ all_col_vars <- c(stub_vars, default_vars)
+
+ # Compute stub styling from options
+ stub_style_list <- NULL
+ stub_col_var_names <- NULL
+ if (has_stub_column && length(stub_vars) > 0L) {
+ stub_bg <- dt_options_get_value(data = data, option = "stub_background_color")
+ stub_font_sz <- dt_options_get_value(data = data, option = "stub_font_size")
+ stub_font_wt <- dt_options_get_value(data = data, option = "stub_font_weight")
+ stub_txt_xform <- dt_options_get_value(data = data, option = "stub_text_transform")
+
+ stub_cell_p <- character(0)
+ stub_text_p <- character(0)
+ stub_outer_w <- character(0)
+
+ if (!is.null(stub_bg) && !is.na(stub_bg) && nzchar(stub_bg)) {
+ typst_bg <- css_color_to_typst(stub_bg)
+ if (!is.null(typst_bg)) stub_cell_p <- c(stub_cell_p, paste0("fill: ", typst_bg))
+ }
+
+ # When row_striping is enabled but row_striping_include_stub is FALSE,
+ # override stub cells with white/table background to cancel the table-level fill
+ striping_active <- isTRUE(dt_options_get_value(data = data, option = "row_striping_include_table_body"))
+ striping_include_stub <- isTRUE(dt_options_get_value(data = data, option = "row_striping_include_stub"))
+ if (striping_active && !striping_include_stub && !any(grepl("^fill:", stub_cell_p))) {
+ tbl_bg <- dt_options_get_value(data = data, option = "table_background_color")
+ override_bg <- css_color_to_typst(tbl_bg %||% "#FFFFFF")
+ stub_cell_p <- c(stub_cell_p, paste0("fill: ", override_bg))
+ }
+ if (!is.null(stub_font_sz) && nzchar(stub_font_sz) && stub_font_sz != "100%") {
+ tsz <- css_length_to_typst_text_size(stub_font_sz)
+ if (!is.null(tsz)) stub_text_p <- c(stub_text_p, paste0("size: ", tsz))
+ }
+ if (!is.null(stub_font_wt) && nzchar(stub_font_wt) && stub_font_wt != "initial") {
+ typst_swt <- css_weight_to_typst(stub_font_wt)
+ stub_text_p <- c(stub_text_p, paste0("weight: \"", typst_swt, "\""))
+ }
+ if (!is.null(stub_txt_xform) && stub_txt_xform != "inherit") {
+ if (stub_txt_xform == "uppercase") stub_outer_w <- c(stub_outer_w, "upper")
+ else if (stub_txt_xform == "lowercase") stub_outer_w <- c(stub_outer_w, "lower")
+ }
+
+ if (length(stub_cell_p) > 0L || length(stub_text_p) > 0L || length(stub_outer_w) > 0L) {
+ stub_style_list <- list(
+ cell_props = stub_cell_p,
+ text_props = stub_text_p,
+ outer_wraps = stub_outer_w
+ )
+ stub_col_var_names <- stub_vars
+ }
+ }
+
+ out <- ""
+
+ # Track which rows belong to which group for interleaving
+ if (has_groups && nrow(groups_rows_df) > 0L) {
+
+ for (grp_i in seq_len(nrow(groups_rows_df))) {
+
+ group_label <- groups_rows_df$group_label[grp_i]
+ group_id <- groups_rows_df$group_id[grp_i]
+ row_start <- groups_rows_df$row_start[grp_i]
+ row_end <- groups_rows_df$row_end[grp_i]
+
+ # Add separator before group (except the first)
+ if (grp_i > 1L) {
+ rg_border_top <- option_border_to_typst(data, "row_group_border_top")
+ if (!is.null(rg_border_top)) {
+ out <- paste0(out, " table.hline(stroke: ", rg_border_top, "),\n")
+ } else {
+ out <- paste0(out, " table.hline(stroke: 0.5pt),\n")
+ }
+ }
+
+ # Group label row spanning all columns (with row_group options)
+ rg_bg <- dt_options_get_value(data = data, option = "row_group_background_color")
+ rg_font_weight <- dt_options_get_value(data = data, option = "row_group_font_weight")
+ rg_font_size <- dt_options_get_value(data = data, option = "row_group_font_size")
+ rg_text_transform <- dt_options_get_value(data = data, option = "row_group_text_transform")
+
+ grp_label_text <- group_label
+ if (!is.null(rg_text_transform) && rg_text_transform != "inherit") {
+ if (rg_text_transform == "uppercase") grp_label_text <- toupper(grp_label_text)
+ else if (rg_text_transform == "lowercase") grp_label_text <- tolower(grp_label_text)
+ }
+
+ grp_text_props <- character(0)
+ rg_weight <- if (!is.null(rg_font_weight) && rg_font_weight != "initial") {
+ css_weight_to_typst(rg_font_weight)
+ } else {
+ "bold"
+ }
+ grp_text_props <- c(grp_text_props, paste0("weight: \"", rg_weight, "\""))
+ if (!is.null(rg_font_size) && nzchar(rg_font_size) && rg_font_size != "100%") {
+ typst_rg_size <- css_length_to_typst_text_size(rg_font_size)
+ if (!is.null(typst_rg_size)) {
+ grp_text_props <- c(grp_text_props, paste0("size: ", typst_rg_size))
+ }
+ }
+
+ grp_cell_props <- paste0("colspan: ", n_cols)
+ if (!is.null(rg_bg) && !is.na(rg_bg) && nzchar(rg_bg)) {
+ typst_rg_bg <- css_color_to_typst(rg_bg)
+ if (!is.null(typst_rg_bg)) {
+ grp_cell_props <- paste0(grp_cell_props, ", fill: ", typst_rg_bg)
+ }
+ }
+
+ # Row group border left/right
+ rg_border_lr <- option_border_to_typst(data, "row_group_border_right")
+ rg_border_left <- option_border_to_typst(data, "row_group_border_left")
+ if (!is.null(rg_border_lr) || !is.null(rg_border_left)) {
+ rg_strokes <- character(0)
+ if (!is.null(rg_border_left)) rg_strokes <- c(rg_strokes, paste0("left: ", rg_border_left))
+ if (!is.null(rg_border_lr)) rg_strokes <- c(rg_strokes, paste0("right: ", rg_border_lr))
+ grp_cell_props <- paste0(grp_cell_props, ", stroke: (", paste(rg_strokes, collapse = ", "), ")")
+ }
+
+ # Row group padding
+ rg_pad <- dt_options_get_value(data = data, option = "row_group_padding")
+ rg_pad_h <- dt_options_get_value(data = data, option = "row_group_padding_horizontal")
+ rg_pad_y <- css_length_to_typst(rg_pad)
+ rg_pad_x <- css_length_to_typst(rg_pad_h)
+ if (!is.null(rg_pad_y) || !is.null(rg_pad_x)) {
+ pad_x <- rg_pad_x %||% "5pt"
+ pad_y <- rg_pad_y %||% "8pt"
+ grp_cell_props <- paste0(grp_cell_props, ", inset: (x: ", pad_x, ", y: ", pad_y, ")")
+ }
+
+ out <- paste0(
+ out,
+ " table.cell(", grp_cell_props, ")[#text(",
+ paste(grp_text_props, collapse = ", "),
+ ")[", grp_label_text, "]],\n"
+ )
+
+ # Row group border bottom
+ rg_border_bottom <- option_border_to_typst(data, "row_group_border_bottom")
+ if (!is.null(rg_border_bottom)) {
+ out <- paste0(out, " table.hline(stroke: ", rg_border_bottom, "),\n")
+ }
+
+ # Summary rows (top side)
+ summary_top <- render_summary_rows_t(
+ data = data,
+ list_of_summaries = list_of_summaries,
+ group_id = group_id,
+ side = "top",
+ n_cols = n_cols
+ )
+ out <- paste0(out, summary_top)
+
+ # Data rows for this group
+ for (row_i in row_start:row_end) {
+
+ row_cells <- cell_matrix[row_i, ]
+ col_vars <- all_col_vars
+
+ # Skip group_label column (column 1) if it exists in the matrix
+ if (has_group_column) {
+ row_cells <- row_cells[-1]
+ col_vars <- col_vars[-1]
+ }
+
+ out <- paste0(out, render_body_row_t(row_cells, row_i, col_vars, styles_tbl, stub_col_var_names, stub_style_list))
+ }
+
+ # Summary rows (bottom side)
+ summary_bottom <- render_summary_rows_t(
+ data = data,
+ list_of_summaries = list_of_summaries,
+ group_id = group_id,
+ side = "bottom",
+ n_cols = n_cols
+ )
+ out <- paste0(out, summary_bottom)
+ }
+
+ } else {
+
+ # No groups — just render rows
+ for (row_i in seq_len(nrow(cell_matrix))) {
+
+ row_cells <- cell_matrix[row_i, ]
+
+ out <- paste0(out, render_body_row_t(row_cells, row_i, all_col_vars, styles_tbl, stub_col_var_names, stub_style_list))
+ }
+ }
+
+ # Grand summary rows
+ if (!is.null(list_of_summaries$summary_df_display_list[["::GRAND_SUMMARY"]]) &&
+ nrow(list_of_summaries$summary_df_display_list[["::GRAND_SUMMARY"]]) > 0L) {
+
+ gs_border <- option_border_to_typst(data, "grand_summary_row_border")
+ if (!is.null(gs_border)) {
+ out <- paste0(out, " table.hline(stroke: ", gs_border, "),\n")
+ } else {
+ out <- paste0(out, " table.hline(stroke: 0.75pt),\n")
+ }
+
+ grand_summary <- render_summary_rows_t(
+ data = data,
+ list_of_summaries = list_of_summaries,
+ group_id = "::GRAND_SUMMARY",
+ side = NULL,
+ n_cols = n_cols
+ )
+ out <- paste0(out, grand_summary)
+ }
+
+ out
+}
+
+#' Render summary rows for a given group
+#' @noRd
+render_summary_rows_t <- function(
+ data,
+ list_of_summaries,
+ group_id,
+ side = NULL,
+ n_cols
+) {
+
+ summary_df <- list_of_summaries$summary_df_display_list[[group_id]]
+
+ if (is.null(summary_df) || nrow(summary_df) == 0L) return("")
+
+ # Filter by side if specified
+ if (!is.null(side) && "::side::" %in% names(summary_df)) {
+ summary_df <- summary_df[summary_df[["::side::"]] == side, , drop = FALSE]
+ }
+
+ if (nrow(summary_df) == 0L) return("")
+
+ default_vars <- dt_boxhead_get_vars_default(data = data)
+ stub_layout <- get_stub_layout(data = data)
+ has_groups <- "group_label" %in% stub_layout
+
+ # Determine if this is a grand summary or regular summary
+ is_grand <- identical(group_id, "::GRAND_SUMMARY")
+ opt_prefix <- if (is_grand) "grand_summary_row" else "summary_row"
+
+ # Read summary row options
+ sr_padding <- dt_options_get_value(data = data, option = paste0(opt_prefix, "_padding"))
+ sr_padding_h <- dt_options_get_value(data = data, option = paste0(opt_prefix, "_padding_horizontal"))
+ sr_bg <- dt_options_get_value(data = data, option = paste0(opt_prefix, "_background_color"))
+ sr_text_transform <- dt_options_get_value(data = data, option = paste0(opt_prefix, "_text_transform"))
+ sr_border <- option_border_to_typst(data, paste0(opt_prefix, "_border"))
+
+ # Build cell properties for summary cells
+ # Only wrap summary cells in table.cell() when explicit styling is set
+ sr_cell_props <- character(0)
+ if (!is.null(sr_bg) && !is.na(sr_bg) && nzchar(sr_bg)) {
+ typst_bg <- css_color_to_typst(sr_bg)
+ if (!is.null(typst_bg)) sr_cell_props <- c(sr_cell_props, paste0("fill: ", typst_bg))
+ }
+
+ out <- ""
+
+ # Add border before summary rows
+ if (!is.null(sr_border)) {
+ out <- paste0(out, " table.hline(stroke: ", sr_border, "),\n")
+ }
+
+ for (row_i in seq_len(nrow(summary_df))) {
+
+ row_label <- escape_typst(summary_df[["::rowname::"]][row_i])
+
+ # Apply text transform to label
+ if (!is.null(sr_text_transform) && sr_text_transform != "inherit") {
+ if (sr_text_transform == "uppercase") row_label <- toupper(row_label)
+ else if (sr_text_transform == "lowercase") row_label <- tolower(row_label)
+ }
+
+ cells <- character(0)
+
+ # Label cell (bold)
+ label_cell <- paste0("*", row_label, "*")
+ if (length(sr_cell_props) > 0L) {
+ label_cell <- paste0("table.cell(", paste(sr_cell_props, collapse = ", "), ")[", label_cell, "]")
+ } else {
+ label_cell <- paste0("[", label_cell, "]")
+ }
+ cells <- c(cells, label_cell)
+
+ # Data columns
+ for (var in default_vars) {
+ cell_val <- if (var %in% names(summary_df)) {
+ val <- summary_df[[var]][row_i]
+ if (is.na(val)) "" else escape_typst(as.character(val))
+ } else {
+ ""
+ }
+
+ if (length(sr_cell_props) > 0L) {
+ cells <- c(cells, paste0("table.cell(", paste(sr_cell_props, collapse = ", "), ")[", cell_val, "]"))
+ } else {
+ cells <- c(cells, paste0("[", cell_val, "]"))
+ }
+ }
+
+ # Adjust cell count to match n_cols
+ n_stub <- get_stub_column_count(data = data)
+ if (has_groups) {
+ needed_stub <- n_stub - 1L
+ } else {
+ needed_stub <- n_stub
+ }
+
+ if (needed_stub > 1L) {
+ cells <- c(cells[1], rep("[]", needed_stub - 1L), cells[-1])
+ }
+
+ out <- paste0(out, " ", paste(cells, collapse = ", "), ",\n")
+ }
+
+ out
+}
+
+#' Create the table end
+#' @noRd
+create_table_end_t <- function(data) {
+
+ bottom_hline <- ""
+ bottom_border_include <- dt_options_get_value(data = data, option = "table_border_bottom_include")
+ if (isTRUE(bottom_border_include)) {
+ bottom_border <- option_border_to_typst(data, "table_border_bottom")
+ if (!is.null(bottom_border)) {
+ bottom_hline <- paste0(" table.hline(stroke: ", bottom_border, "),\n")
+ }
+ }
+
+ paste0(
+ bottom_hline,
+ ")\n"
+ )
+}
+
+#' Create the footer component as table.cell rows inside the table
+#'
+#' Returns table.cell(colspan: N) entries for footnotes and source notes,
+#' placed inside the #table() after the body rows and before the bottom border.
+#' This way the footer inherits the table's width and styling.
+#' @noRd
+create_footer_component_t <- function(data) {
+
+ n_cols <- get_n_cols_total(data = data)
+
+ footnotes_tbl <- dt_footnotes_get(data = data)
+ source_notes_vec <- dt_source_notes_get(data = data)
+
+ # Footnote/source note options
+ fn_font_size <- dt_options_get_value(data = data, option = "footnotes_font_size")
+ fn_size_typst <- css_length_to_typst_text_size(fn_font_size) %||% "8pt"
+ fn_multiline <- dt_options_get_value(data = data, option = "footnotes_multiline")
+ fn_sep <- dt_options_get_value(data = data, option = "footnotes_sep")
+
+ sn_font_size <- dt_options_get_value(data = data, option = "source_notes_font_size")
+ sn_size_typst <- css_length_to_typst_text_size(sn_font_size) %||% "8pt"
+ sn_multiline <- dt_options_get_value(data = data, option = "source_notes_multiline")
+ sn_sep <- dt_options_get_value(data = data, option = "source_notes_sep")
+
+ # Footnotes options
+ fn_padding <- dt_options_get_value(data = data, option = "footnotes_padding")
+ fn_padding_h <- dt_options_get_value(data = data, option = "footnotes_padding_horizontal")
+ fn_bg <- dt_options_get_value(data = data, option = "footnotes_background_color")
+ fn_margin <- dt_options_get_value(data = data, option = "footnotes_margin")
+ fn_border_bottom <- option_border_to_typst(data, "footnotes_border_bottom")
+ fn_border_lr <- option_border_to_typst(data, "footnotes_border_lr")
+
+ # Source notes options
+ sn_padding <- dt_options_get_value(data = data, option = "source_notes_padding")
+ sn_padding_h <- dt_options_get_value(data = data, option = "source_notes_padding_horizontal")
+ sn_bg <- dt_options_get_value(data = data, option = "source_notes_background_color")
+ sn_border_bottom <- option_border_to_typst(data, "source_notes_border_bottom")
+ sn_border_lr <- option_border_to_typst(data, "source_notes_border_lr")
+
+ out <- ""
+
+ has_footer <- (!is.null(footnotes_tbl) && nrow(footnotes_tbl) > 0L) || length(source_notes_vec) > 0L
+ if (!has_footer) return("")
+
+ # Separator line before footer (uses table_body_border_bottom)
+ body_bottom_border <- option_border_to_typst(data, "table_body_border_bottom")
+ if (!is.null(body_bottom_border)) {
+ out <- paste0(out, " table.hline(stroke: ", body_bottom_border, "),\n")
+ }
+
+ # Helper to build a table.cell for a footer section
+ build_footer_cell <- function(content, bg, padding, padding_h, border_bottom, border_lr) {
+ cell_props <- paste0("colspan: ", n_cols, ", align: left")
+ # Always set fill to prevent table-level striping from leaking into footer
+ if (!is.null(bg) && !is.na(bg) && nzchar(bg)) {
+ typst_bg <- css_color_to_typst(bg)
+ } else {
+ tbl_bg <- dt_options_get_value(data = data, option = "table_background_color")
+ typst_bg <- css_color_to_typst(tbl_bg %||% "#FFFFFF")
+ }
+ if (!is.null(typst_bg)) cell_props <- paste0(cell_props, ", fill: ", typst_bg)
+ pad_y <- css_length_to_typst(padding) %||% "4pt"
+ pad_x <- css_length_to_typst(padding_h) %||% "5pt"
+ cell_props <- paste0(cell_props, ", inset: (x: ", pad_x, ", y: ", pad_y, ")")
+ # Borders via stroke on the cell
+ strokes <- character(0)
+ if (!is.null(border_bottom)) strokes <- c(strokes, paste0("bottom: ", border_bottom))
+ if (!is.null(border_lr)) {
+ strokes <- c(strokes, paste0("left: ", border_lr), paste0("right: ", border_lr))
+ }
+ if (length(strokes) > 0L) {
+ cell_props <- paste0(cell_props, ", stroke: (", paste(strokes, collapse = ", "), ")")
+ }
+ paste0(" table.cell(", cell_props, ")[", content, "],\n")
+ }
+
+ # Footnotes
+ if (!is.null(footnotes_tbl) && nrow(footnotes_tbl) > 0L) {
+
+ footnotes_resolved <- unique(footnotes_tbl[, c("fs_id", "footnotes"), drop = FALSE])
+
+ if (isTRUE(fn_multiline)) {
+ fn_parts <- vapply(seq_len(nrow(footnotes_resolved)), function(i) {
+ mark <- footnotes_resolved$fs_id[i]
+ note_text <- footnotes_resolved$footnotes[i]
+ paste0("#text(size: ", fn_size_typst, ")[#super[", mark, "] ", note_text, "]")
+ }, character(1))
+ fn_content <- paste(fn_parts, collapse = " \\ ")
+ } else {
+ parts <- vapply(seq_len(nrow(footnotes_resolved)), function(i) {
+ mark <- footnotes_resolved$fs_id[i]
+ note_text <- footnotes_resolved$footnotes[i]
+ paste0("#super[", mark, "] ", note_text)
+ }, character(1))
+ fn_content <- paste0("#text(size: ", fn_size_typst, ")[", paste(parts, collapse = fn_sep), "]")
+ }
+
+ out <- paste0(out, build_footer_cell(
+ fn_content, fn_bg, fn_padding, fn_padding_h, fn_border_bottom, fn_border_lr
+ ))
+ }
+
+ # Source notes
+ if (length(source_notes_vec) > 0L) {
+
+ if (isTRUE(sn_multiline)) {
+ sn_parts <- vapply(source_notes_vec, function(note) {
+ paste0("#text(size: ", sn_size_typst, ")[", note, "]")
+ }, character(1))
+ sn_content <- paste(sn_parts, collapse = " \\ ")
+ } else {
+ sn_content <- paste0("#text(size: ", sn_size_typst, ")[", paste(source_notes_vec, collapse = sn_sep), "]")
+ }
+
+ out <- paste0(out, build_footer_cell(
+ sn_content, sn_bg, sn_padding, sn_padding_h, sn_border_bottom, sn_border_lr
+ ))
+ }
+
+ out
+}
+
+#' Create the caption component (wraps table in #figure if caption needed)
+#' @noRd
+create_caption_component_t <- function(data) {
+
+ # For now, captions are handled outside the table
+
+ # The heading component handles title/subtitle
+ # A future enhancement could wrap in #figure(..., caption: [...])
+ ""
+}
diff --git a/R/utils_units.R b/R/utils_units.R
index fed5da672..39c26b6eb 100644
--- a/R/utils_units.R
+++ b/R/utils_units.R
@@ -391,6 +391,10 @@ render_units <- function(units_object, context = "html") {
unit <- escape_latex(unit)
unit_subscript <- escape_latex(unit_subscript)
exponent <- escape_latex(exponent)
+ } else if (context == "typst") {
+ unit <- escape_typst(unit)
+ unit_subscript <- escape_typst(unit_subscript)
+ exponent <- escape_typst(exponent)
}
if (
@@ -399,6 +403,12 @@ render_units <- function(units_object, context = "html") {
!chemical_formula
) {
unit <- gsub("x", "×", unit, fixed = TRUE)
+ } else if (
+ context == "typst" &&
+ grepl("x10", unit) &&
+ !chemical_formula
+ ) {
+ unit <- gsub("x", "\U000D7", unit, fixed = TRUE)
}
unit <- units_symbol_replacements(text = unit, context = context)
@@ -414,6 +424,8 @@ render_units <- function(units_object, context = "html") {
unit <- gsub("\n$", "", unit)
} else if (context == "rtf") {
unit <- markdown_to_rtf(text = unit)
+ } else if (context == "typst") {
+ unit <- markdown_to_typst(text = unit)
}
}
@@ -429,6 +441,8 @@ render_units <- function(units_object, context = "html") {
unit_subscript <- gsub("\n$", "", unit_subscript)
} else if (context == "rtf") {
unit_subscript <- markdown_to_rtf(text = unit_subscript)
+ } else if (context == "typst") {
+ unit_subscript <- markdown_to_typst(text = unit_subscript)
}
}
@@ -444,6 +458,8 @@ render_units <- function(units_object, context = "html") {
exponent <- gsub("\n$", "", exponent)
} else if (context == "rtf") {
exponent <- markdown_to_rtf(text = exponent)
+ } else if (context == "typst") {
+ exponent <- markdown_to_typst(text = exponent)
}
}
@@ -495,6 +511,8 @@ render_units <- function(units_object, context = "html") {
exponent <- gsub("-", "−", exponent)
} else if (context == "latex") {
exponent <- gsub("-", "--", exponent)
+ } else if (context == "typst") {
+ exponent <- gsub("-", "\U02212", exponent)
}
exponent <- units_to_superscript(content = exponent, context = context)
@@ -575,6 +593,10 @@ units_to_superscript <- function(content, context = "html") {
)
}
+ if (context == "typst") {
+ out <- paste0("#super[", content, "]")
+ }
+
out
}
@@ -613,6 +635,10 @@ units_to_subscript <- function(content, context = "html") {
)
}
+ if (context == "typst") {
+ out <- paste0("#sub[", content, "]")
+ }
+
out
}
@@ -726,7 +752,7 @@ units_symbol_replacements <- function(
text <- replace_units_symbol(text, ":omega:", ":omega:", "ω")
}
- if (context == "word") {
+ if (context %in% c("word", "typst")) {
text <- replace_units_symbol(text, "^um$", "um", paste0("\U003BC", "m"))
text <- replace_units_symbol(text, "^uL$", "uL", paste0("\U003BC", "L"))
diff --git a/R/z_utils_render_footnotes.R b/R/z_utils_render_footnotes.R
index 752f3ab04..50da938e2 100644
--- a/R/z_utils_render_footnotes.R
+++ b/R/z_utils_render_footnotes.R
@@ -788,6 +788,9 @@ place_footnote_on_left <- function(text, mark, context) {
} else if (context == "rtf") {
text <- paste(mark, text)
+
+ } else if (context == "typst") {
+ text <- paste0(mark, "\U000A0", text)
}
text
@@ -967,7 +970,8 @@ footnotes_dispatch <-
rtf = footnote_mark_to_rtf,
grid = footnote_mark_to_grid,
latex = footnote_mark_to_latex,
- word = footnote_mark_to_xml
+ word = footnote_mark_to_xml,
+ typst = footnote_mark_to_typst
)
apply_footnotes_method <-
@@ -976,5 +980,6 @@ apply_footnotes_method <-
rtf = paste0,
grid = paste0,
latex = paste_footnote_latex,
- word = paste_footnote_xml
+ word = paste_footnote_xml,
+ typst = paste0
)
diff --git a/man/adjust_luminance.Rd b/man/adjust_luminance.Rd
index e274e9b93..87cfd0234 100644
--- a/man/adjust_luminance.Rd
+++ b/man/adjust_luminance.Rd
@@ -120,6 +120,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/as_gtable.Rd b/man/as_gtable.Rd
index 1fe00f5c6..a5e6abe09 100644
--- a/man/as_gtable.Rd
+++ b/man/as_gtable.Rd
@@ -51,6 +51,7 @@ Other table export functions:
\code{\link{as_latex}()},
\code{\link{as_raw_html}()},
\code{\link{as_rtf}()},
+\code{\link{as_typst}()},
\code{\link{as_word}()},
\code{\link{extract_body}()},
\code{\link{extract_cells}()},
diff --git a/man/as_latex.Rd b/man/as_latex.Rd
index 3539c851e..dad3db4b9 100644
--- a/man/as_latex.Rd
+++ b/man/as_latex.Rd
@@ -82,6 +82,7 @@ Other table export functions:
\code{\link{as_gtable}()},
\code{\link{as_raw_html}()},
\code{\link{as_rtf}()},
+\code{\link{as_typst}()},
\code{\link{as_word}()},
\code{\link{extract_body}()},
\code{\link{extract_cells}()},
diff --git a/man/as_raw_html.Rd b/man/as_raw_html.Rd
index 51ad2796a..7ee835ce0 100644
--- a/man/as_raw_html.Rd
+++ b/man/as_raw_html.Rd
@@ -70,6 +70,7 @@ Other table export functions:
\code{\link{as_gtable}()},
\code{\link{as_latex}()},
\code{\link{as_rtf}()},
+\code{\link{as_typst}()},
\code{\link{as_word}()},
\code{\link{extract_body}()},
\code{\link{extract_cells}()},
diff --git a/man/as_rtf.Rd b/man/as_rtf.Rd
index 6fa082689..d4c08aeb0 100644
--- a/man/as_rtf.Rd
+++ b/man/as_rtf.Rd
@@ -88,6 +88,7 @@ Other table export functions:
\code{\link{as_gtable}()},
\code{\link{as_latex}()},
\code{\link{as_raw_html}()},
+\code{\link{as_typst}()},
\code{\link{as_word}()},
\code{\link{extract_body}()},
\code{\link{extract_cells}()},
diff --git a/man/as_typst.Rd b/man/as_typst.Rd
new file mode 100644
index 000000000..eea3d104a
--- /dev/null
+++ b/man/as_typst.Rd
@@ -0,0 +1,48 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/export.R
+\name{as_typst}
+\alias{as_typst}
+\title{Output a \strong{gt} object as Typst}
+\usage{
+as_typst(data)
+}
+\arguments{
+\item{data}{\emph{The gt table data object}
+
+\verb{obj:} // \strong{required}
+
+This is the \strong{gt} table object that is commonly created through use of the
+\code{\link[=gt]{gt()}} function.}
+}
+\value{
+An object of class \code{typst_text}.
+}
+\description{
+Get the Typst content from a \code{gt_tbl} object as a single-element character
+vector. This object can be used with \code{writeLines()} to generate a valid .typ
+file, or it will be automatically rendered in Quarto documents targeting
+Typst output.
+}
+\section{Function ID}{
+
+13-10
+}
+
+\section{Function Introduced}{
+
+\code{v0.13.0} (2026-03-26)
+}
+
+\seealso{
+Other table export functions:
+\code{\link{as_gtable}()},
+\code{\link{as_latex}()},
+\code{\link{as_raw_html}()},
+\code{\link{as_rtf}()},
+\code{\link{as_word}()},
+\code{\link{extract_body}()},
+\code{\link{extract_cells}()},
+\code{\link{extract_summary}()},
+\code{\link{gtsave}()}
+}
+\concept{table export functions}
diff --git a/man/as_word.Rd b/man/as_word.Rd
index 604d157dd..351efbaba 100644
--- a/man/as_word.Rd
+++ b/man/as_word.Rd
@@ -104,6 +104,7 @@ Other table export functions:
\code{\link{as_latex}()},
\code{\link{as_raw_html}()},
\code{\link{as_rtf}()},
+\code{\link{as_typst}()},
\code{\link{extract_body}()},
\code{\link{extract_cells}()},
\code{\link{extract_summary}()},
diff --git a/man/cell_borders.Rd b/man/cell_borders.Rd
index 841827656..8d9c638c0 100644
--- a/man/cell_borders.Rd
+++ b/man/cell_borders.Rd
@@ -144,6 +144,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/cell_fill.Rd b/man/cell_fill.Rd
index 8f58ec110..1a2988243 100644
--- a/man/cell_fill.Rd
+++ b/man/cell_fill.Rd
@@ -97,6 +97,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/cell_text.Rd b/man/cell_text.Rd
index ba5898cec..a75af8fe9 100644
--- a/man/cell_text.Rd
+++ b/man/cell_text.Rd
@@ -191,6 +191,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/currency.Rd b/man/currency.Rd
index 3edba06d6..65153bd55 100644
--- a/man/currency.Rd
+++ b/man/currency.Rd
@@ -102,6 +102,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/data_color.Rd b/man/data_color.Rd
index 97118d470..3a686c682 100644
--- a/man/data_color.Rd
+++ b/man/data_color.Rd
@@ -721,6 +721,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/default_fonts.Rd b/man/default_fonts.Rd
index 1a191b3d6..7301577c3 100644
--- a/man/default_fonts.Rd
+++ b/man/default_fonts.Rd
@@ -79,6 +79,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/escape_latex.Rd b/man/escape_latex.Rd
index 1309129d1..fb48fe671 100644
--- a/man/escape_latex.Rd
+++ b/man/escape_latex.Rd
@@ -62,6 +62,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/extract_body.Rd b/man/extract_body.Rd
index b03144245..a283b794d 100644
--- a/man/extract_body.Rd
+++ b/man/extract_body.Rd
@@ -213,6 +213,7 @@ Other table export functions:
\code{\link{as_latex}()},
\code{\link{as_raw_html}()},
\code{\link{as_rtf}()},
+\code{\link{as_typst}()},
\code{\link{as_word}()},
\code{\link{extract_cells}()},
\code{\link{extract_summary}()},
diff --git a/man/extract_cells.Rd b/man/extract_cells.Rd
index b1a2d7fff..4c805e4e8 100644
--- a/man/extract_cells.Rd
+++ b/man/extract_cells.Rd
@@ -110,6 +110,7 @@ Other table export functions:
\code{\link{as_latex}()},
\code{\link{as_raw_html}()},
\code{\link{as_rtf}()},
+\code{\link{as_typst}()},
\code{\link{as_word}()},
\code{\link{extract_body}()},
\code{\link{extract_summary}()},
diff --git a/man/extract_summary.Rd b/man/extract_summary.Rd
index 93ea30025..3e4d8d62b 100644
--- a/man/extract_summary.Rd
+++ b/man/extract_summary.Rd
@@ -118,6 +118,7 @@ Other table export functions:
\code{\link{as_latex}()},
\code{\link{as_raw_html}()},
\code{\link{as_rtf}()},
+\code{\link{as_typst}()},
\code{\link{as_word}()},
\code{\link{extract_body}()},
\code{\link{extract_cells}()},
diff --git a/man/fmt.Rd b/man/fmt.Rd
index a24438b2a..2f42ae306 100644
--- a/man/fmt.Rd
+++ b/man/fmt.Rd
@@ -136,6 +136,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_auto.Rd b/man/fmt_auto.Rd
index 5d2de0fb1..d6d69bcd3 100644
--- a/man/fmt_auto.Rd
+++ b/man/fmt_auto.Rd
@@ -159,6 +159,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_bins.Rd b/man/fmt_bins.Rd
index a721828f9..4193a67f3 100644
--- a/man/fmt_bins.Rd
+++ b/man/fmt_bins.Rd
@@ -186,6 +186,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_bytes.Rd b/man/fmt_bytes.Rd
index 74d75da05..d435ec331 100644
--- a/man/fmt_bytes.Rd
+++ b/man/fmt_bytes.Rd
@@ -325,6 +325,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_chem.Rd b/man/fmt_chem.Rd
index f01da071b..5b694f609 100644
--- a/man/fmt_chem.Rd
+++ b/man/fmt_chem.Rd
@@ -262,6 +262,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_country.Rd b/man/fmt_country.Rd
index 706a8dbc5..12bce53e6 100644
--- a/man/fmt_country.Rd
+++ b/man/fmt_country.Rd
@@ -365,6 +365,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_currency.Rd b/man/fmt_currency.Rd
index 0676775e6..72e387145 100644
--- a/man/fmt_currency.Rd
+++ b/man/fmt_currency.Rd
@@ -534,6 +534,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_date.Rd b/man/fmt_date.Rd
index 823f1e56f..f3e72373b 100644
--- a/man/fmt_date.Rd
+++ b/man/fmt_date.Rd
@@ -293,6 +293,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_datetime.Rd b/man/fmt_datetime.Rd
index f3551dec8..41ba58b98 100644
--- a/man/fmt_datetime.Rd
+++ b/man/fmt_datetime.Rd
@@ -966,6 +966,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_duration.Rd b/man/fmt_duration.Rd
index a1832f727..b54ac037d 100644
--- a/man/fmt_duration.Rd
+++ b/man/fmt_duration.Rd
@@ -279,6 +279,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_email.Rd b/man/fmt_email.Rd
index 93ef63db8..3f32e39b2 100644
--- a/man/fmt_email.Rd
+++ b/man/fmt_email.Rd
@@ -337,6 +337,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_engineering.Rd b/man/fmt_engineering.Rd
index d9176daf1..27b495fbe 100644
--- a/man/fmt_engineering.Rd
+++ b/man/fmt_engineering.Rd
@@ -349,6 +349,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_flag.Rd b/man/fmt_flag.Rd
index 6a656c92a..91202579d 100644
--- a/man/fmt_flag.Rd
+++ b/man/fmt_flag.Rd
@@ -301,6 +301,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_fraction.Rd b/man/fmt_fraction.Rd
index cd3680095..1613704ad 100644
--- a/man/fmt_fraction.Rd
+++ b/man/fmt_fraction.Rd
@@ -318,6 +318,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_icon.Rd b/man/fmt_icon.Rd
index 2436e9546..a4f235ef2 100644
--- a/man/fmt_icon.Rd
+++ b/man/fmt_icon.Rd
@@ -425,6 +425,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_image.Rd b/man/fmt_image.Rd
index 1ff2c9c1d..4d02ab544 100644
--- a/man/fmt_image.Rd
+++ b/man/fmt_image.Rd
@@ -229,6 +229,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_index.Rd b/man/fmt_index.Rd
index dd926ca6d..cbb05e945 100644
--- a/man/fmt_index.Rd
+++ b/man/fmt_index.Rd
@@ -201,6 +201,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_integer.Rd b/man/fmt_integer.Rd
index 9757700d0..1fe432113 100644
--- a/man/fmt_integer.Rd
+++ b/man/fmt_integer.Rd
@@ -349,6 +349,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_markdown.Rd b/man/fmt_markdown.Rd
index b97dd2611..9313dc104 100644
--- a/man/fmt_markdown.Rd
+++ b/man/fmt_markdown.Rd
@@ -244,6 +244,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_number.Rd b/man/fmt_number.Rd
index b19bf3629..5f3b99455 100644
--- a/man/fmt_number.Rd
+++ b/man/fmt_number.Rd
@@ -449,6 +449,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_number_si.Rd b/man/fmt_number_si.Rd
index f6560af83..cf050e033 100644
--- a/man/fmt_number_si.Rd
+++ b/man/fmt_number_si.Rd
@@ -390,6 +390,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_partsper.Rd b/man/fmt_partsper.Rd
index 3d99c3dce..8cfc04a27 100644
--- a/man/fmt_partsper.Rd
+++ b/man/fmt_partsper.Rd
@@ -339,6 +339,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_passthrough.Rd b/man/fmt_passthrough.Rd
index 6e9869c0f..3ddfc9019 100644
--- a/man/fmt_passthrough.Rd
+++ b/man/fmt_passthrough.Rd
@@ -156,6 +156,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_percent.Rd b/man/fmt_percent.Rd
index 63a4f7284..847d3f70c 100644
--- a/man/fmt_percent.Rd
+++ b/man/fmt_percent.Rd
@@ -338,6 +338,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_roman.Rd b/man/fmt_roman.Rd
index ae01f7011..59d4dd130 100644
--- a/man/fmt_roman.Rd
+++ b/man/fmt_roman.Rd
@@ -177,6 +177,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_scientific.Rd b/man/fmt_scientific.Rd
index 9d1b70ba1..ff3696402 100644
--- a/man/fmt_scientific.Rd
+++ b/man/fmt_scientific.Rd
@@ -391,6 +391,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_spelled_num.Rd b/man/fmt_spelled_num.Rd
index fc0f9b89b..b4bbe75ce 100644
--- a/man/fmt_spelled_num.Rd
+++ b/man/fmt_spelled_num.Rd
@@ -285,6 +285,7 @@ Other data formatting functions:
\code{\link{fmt_scientific}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_tf.Rd b/man/fmt_tf.Rd
index f867ca971..4bab7532c 100644
--- a/man/fmt_tf.Rd
+++ b/man/fmt_tf.Rd
@@ -447,6 +447,7 @@ Other data formatting functions:
\code{\link{fmt_scientific}()},
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_time.Rd b/man/fmt_time.Rd
index ebdd93be8..e0a036e5d 100644
--- a/man/fmt_time.Rd
+++ b/man/fmt_time.Rd
@@ -278,6 +278,7 @@ Other data formatting functions:
\code{\link{fmt_scientific}()},
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/fmt_typst.Rd b/man/fmt_typst.Rd
new file mode 100644
index 000000000..c401d6b32
--- /dev/null
+++ b/man/fmt_typst.Rd
@@ -0,0 +1,96 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/format_data.R
+\name{fmt_typst}
+\alias{fmt_typst}
+\title{Format cells with raw Typst markup}
+\usage{
+fmt_typst(data, columns = everything(), rows = everything())
+}
+\arguments{
+\item{data}{\emph{The gt table data object}
+
+\verb{obj:} // \strong{required}
+
+This is the \strong{gt} table object that is commonly created through use of the
+\code{\link[=gt]{gt()}} function.}
+
+\item{columns}{\emph{Columns to target}
+
+\code{\link[=rows-columns]{}} // \emph{default:} \code{everything()}
+
+Can either be a series of column names provided in \code{c()}, a vector of
+column indices, or a select helper function (e.g. \code{\link[=starts_with]{starts_with()}},
+\code{\link[=ends_with]{ends_with()}}, \code{\link[=contains]{contains()}}, \code{\link[=matches]{matches()}}, \code{\link[=num_range]{num_range()}} and \code{\link[=everything]{everything()}}).}
+
+\item{rows}{\emph{Rows to target}
+
+\code{\link[=rows-columns]{}} // \emph{default:} \code{everything()}
+
+In conjunction with \code{columns}, we can specify which of their rows should
+undergo formatting. The default \code{\link[=everything]{everything()}} results in all rows in
+\code{columns} being formatted. Alternatively, we can supply a vector of row
+captions within \code{c()}, a vector of row indices, or a select helper
+function (e.g. \code{\link[=starts_with]{starts_with()}}, \code{\link[=ends_with]{ends_with()}}, \code{\link[=contains]{contains()}}, \code{\link[=matches]{matches()}},
+\code{\link[=num_range]{num_range()}}, and \code{\link[=everything]{everything()}}). We can also use expressions to filter
+down to the rows we need (e.g., \verb{[colname_1] > 100 & [colname_2] < 50}).}
+}
+\value{
+An object of class \code{gt_tbl}.
+}
+\description{
+\code{fmt_typst()} lets you format cell values as raw Typst markup. The content
+will pass through to Typst output without escaping, allowing you to use
+native Typst syntax in table cells. In non-Typst output contexts, the
+content is passed through as plain text.
+}
+\section{Function ID}{
+
+3-50
+}
+
+\section{Function Introduced}{
+
+\code{v0.13.0} (2026-03-26)
+}
+
+\seealso{
+Other data formatting functions:
+\code{\link{data_color}()},
+\code{\link{fmt}()},
+\code{\link{fmt_auto}()},
+\code{\link{fmt_bins}()},
+\code{\link{fmt_bytes}()},
+\code{\link{fmt_chem}()},
+\code{\link{fmt_country}()},
+\code{\link{fmt_currency}()},
+\code{\link{fmt_date}()},
+\code{\link{fmt_datetime}()},
+\code{\link{fmt_duration}()},
+\code{\link{fmt_email}()},
+\code{\link{fmt_engineering}()},
+\code{\link{fmt_flag}()},
+\code{\link{fmt_fraction}()},
+\code{\link{fmt_icon}()},
+\code{\link{fmt_image}()},
+\code{\link{fmt_index}()},
+\code{\link{fmt_integer}()},
+\code{\link{fmt_markdown}()},
+\code{\link{fmt_number}()},
+\code{\link{fmt_number_si}()},
+\code{\link{fmt_partsper}()},
+\code{\link{fmt_passthrough}()},
+\code{\link{fmt_percent}()},
+\code{\link{fmt_roman}()},
+\code{\link{fmt_scientific}()},
+\code{\link{fmt_spelled_num}()},
+\code{\link{fmt_tf}()},
+\code{\link{fmt_time}()},
+\code{\link{fmt_units}()},
+\code{\link{fmt_url}()},
+\code{\link{sub_large_vals}()},
+\code{\link{sub_missing}()},
+\code{\link{sub_small_vals}()},
+\code{\link{sub_values}()},
+\code{\link{sub_zero}()}
+}
+\concept{data formatting functions}
diff --git a/man/fmt_units.Rd b/man/fmt_units.Rd
index 8150971f4..5c930d5bc 100644
--- a/man/fmt_units.Rd
+++ b/man/fmt_units.Rd
@@ -195,6 +195,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
\code{\link{sub_missing}()},
diff --git a/man/fmt_url.Rd b/man/fmt_url.Rd
index 19a1fe9ba..996d17c88 100644
--- a/man/fmt_url.Rd
+++ b/man/fmt_url.Rd
@@ -372,6 +372,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{sub_large_vals}()},
\code{\link{sub_missing}()},
diff --git a/man/from_column.Rd b/man/from_column.Rd
index ee64f7e5b..e926ddadc 100644
--- a/man/from_column.Rd
+++ b/man/from_column.Rd
@@ -172,6 +172,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/google_font.Rd b/man/google_font.Rd
index 53f2ad137..d3025eb3d 100644
--- a/man/google_font.Rd
+++ b/man/google_font.Rd
@@ -116,6 +116,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/gt_latex_dependencies.Rd b/man/gt_latex_dependencies.Rd
index 9c4a05f82..6df958daf 100644
--- a/man/gt_latex_dependencies.Rd
+++ b/man/gt_latex_dependencies.Rd
@@ -75,6 +75,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/gtsave.Rd b/man/gtsave.Rd
index f93fd8e8a..4fb0ada8c 100644
--- a/man/gtsave.Rd
+++ b/man/gtsave.Rd
@@ -19,7 +19,8 @@ This is the \strong{gt} table object that is commonly created through use of the
\verb{scalar} // \strong{required}
The file name to create on disk. Ensure that an extension compatible with
-the output types is provided (\code{.html}, \code{.tex}, \code{.ltx}, \code{.rtf}, \code{.docx}). If
+the output types is provided (\code{.html}, \code{.tex}, \code{.ltx}, \code{.rtf}, \code{.typ},
+\code{.docx}). If
a custom save function is provided then the file extension is disregarded.}
\item{path}{\emph{Output path}
@@ -41,7 +42,7 @@ The file name (invisibly) if the export process is successful.
\description{
\code{gtsave()} makes it easy to save a \strong{gt} table to a file. The function
guesses the file type by the extension provided in the output filename,
-producing either an HTML, PDF, PNG, LaTeX, RTF, or Word (.docx) file.
+producing either an HTML, PDF, PNG, LaTeX, RTF, Typst, or Word (.docx) file.
}
\details{
Output filenames with either the \code{.html} or \code{.htm} extensions will produce an
@@ -75,6 +76,10 @@ LaTeX document is produced. An output filename of \code{.rtf} will generate an R
document. The LaTeX and RTF saving functions don't have any options to pass
to \code{...}.
+If the output filename extension is \code{.typ}, a Typst source file is produced.
+This file can be compiled to PDF using Typst directly or used in Quarto
+documents with \code{format: typst}.
+
If the output filename extension is \code{.docx}, a Word document file is
produced. This process is facilitated by the \strong{rmarkdown} package, so this
package needs to be installed before attempting to save any table as a
@@ -148,6 +153,7 @@ Other table export functions:
\code{\link{as_latex}()},
\code{\link{as_raw_html}()},
\code{\link{as_rtf}()},
+\code{\link{as_typst}()},
\code{\link{as_word}()},
\code{\link{extract_body}()},
\code{\link{extract_cells}()},
diff --git a/man/html.Rd b/man/html.Rd
index ac41d3485..635a57f97 100644
--- a/man/html.Rd
+++ b/man/html.Rd
@@ -81,6 +81,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/latex.Rd b/man/latex.Rd
index 2d222b16d..c17cc55ae 100644
--- a/man/latex.Rd
+++ b/man/latex.Rd
@@ -72,6 +72,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/md.Rd b/man/md.Rd
index 7ab9955d5..d8805d79e 100644
--- a/man/md.Rd
+++ b/man/md.Rd
@@ -75,6 +75,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/nanoplot_options.Rd b/man/nanoplot_options.Rd
index 02de92c5a..b094c0825 100644
--- a/man/nanoplot_options.Rd
+++ b/man/nanoplot_options.Rd
@@ -318,6 +318,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/pct.Rd b/man/pct.Rd
index ff9abdf1a..c6b4cc888 100644
--- a/man/pct.Rd
+++ b/man/pct.Rd
@@ -79,6 +79,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/px.Rd b/man/px.Rd
index b1b025af0..343b7075e 100644
--- a/man/px.Rd
+++ b/man/px.Rd
@@ -77,6 +77,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/random_id.Rd b/man/random_id.Rd
index f2793459b..f99c7cbda 100644
--- a/man/random_id.Rd
+++ b/man/random_id.Rd
@@ -52,6 +52,7 @@ Other helper functions:
\code{\link{row_group}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/row_group.Rd b/man/row_group.Rd
index 521139e26..70876429e 100644
--- a/man/row_group.Rd
+++ b/man/row_group.Rd
@@ -85,6 +85,7 @@ Other helper functions:
\code{\link{random_id}()},
\code{\link{stub}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/stub.Rd b/man/stub.Rd
index df79177ec..c1413888f 100644
--- a/man/stub.Rd
+++ b/man/stub.Rd
@@ -116,6 +116,7 @@ Other helper functions:
\code{\link{random_id}()},
\code{\link{row_group}()},
\code{\link{system_fonts}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/sub_large_vals.Rd b/man/sub_large_vals.Rd
index 9e3c51349..2b73b4ce1 100644
--- a/man/sub_large_vals.Rd
+++ b/man/sub_large_vals.Rd
@@ -188,6 +188,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_missing}()},
diff --git a/man/sub_missing.Rd b/man/sub_missing.Rd
index e63cfdbc5..05f1b5178 100644
--- a/man/sub_missing.Rd
+++ b/man/sub_missing.Rd
@@ -126,6 +126,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/sub_small_vals.Rd b/man/sub_small_vals.Rd
index 74a12d747..4da24295d 100644
--- a/man/sub_small_vals.Rd
+++ b/man/sub_small_vals.Rd
@@ -192,6 +192,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/sub_values.Rd b/man/sub_values.Rd
index fdc7ce0bf..e312f5eed 100644
--- a/man/sub_values.Rd
+++ b/man/sub_values.Rd
@@ -209,6 +209,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/sub_zero.Rd b/man/sub_zero.Rd
index 3b7b811b3..9b6b23edf 100644
--- a/man/sub_zero.Rd
+++ b/man/sub_zero.Rd
@@ -131,6 +131,7 @@ Other data formatting functions:
\code{\link{fmt_spelled_num}()},
\code{\link{fmt_tf}()},
\code{\link{fmt_time}()},
+\code{\link{fmt_typst}()},
\code{\link{fmt_units}()},
\code{\link{fmt_url}()},
\code{\link{sub_large_vals}()},
diff --git a/man/system_fonts.Rd b/man/system_fonts.Rd
index 70b25f00b..4be076a90 100644
--- a/man/system_fonts.Rd
+++ b/man/system_fonts.Rd
@@ -263,6 +263,7 @@ Other helper functions:
\code{\link{random_id}()},
\code{\link{row_group}()},
\code{\link{stub}()},
+\code{\link{typst}()},
\code{\link{unit_conversion}()}
}
\concept{helper functions}
diff --git a/man/typst.Rd b/man/typst.Rd
new file mode 100644
index 000000000..0b0480821
--- /dev/null
+++ b/man/typst.Rd
@@ -0,0 +1,60 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/helpers.R
+\name{typst}
+\alias{typst}
+\title{Interpret input text as Typst-formatted text}
+\usage{
+typst(text)
+}
+\arguments{
+\item{text}{\emph{Typst text}
+
+\verb{scalar} // \strong{required}
+
+The text that is understood to be Typst markup, which is to be preserved in
+the Typst output context.}
+}
+\value{
+A character object of class \code{from_typst}. It's tagged as a Typst
+fragment that is not to be sanitized.
+}
+\description{
+For certain pieces of text (like in column labels or table headings) we may
+want to express them as raw Typst markup. The \code{typst()} function will guard
+the input from being escaped in the Typst output context.
+}
+\section{Function ID}{
+
+8-32
+}
+
+\section{Function Introduced}{
+
+\code{v0.13.0} (2026-03-26)
+}
+
+\seealso{
+Other helper functions:
+\code{\link{adjust_luminance}()},
+\code{\link{cell_borders}()},
+\code{\link{cell_fill}()},
+\code{\link{cell_text}()},
+\code{\link{currency}()},
+\code{\link{default_fonts}()},
+\code{\link{escape_latex}()},
+\code{\link{from_column}()},
+\code{\link{google_font}()},
+\code{\link{gt_latex_dependencies}()},
+\code{\link{html}()},
+\code{\link{latex}()},
+\code{\link{md}()},
+\code{\link{nanoplot_options}()},
+\code{\link{pct}()},
+\code{\link{px}()},
+\code{\link{random_id}()},
+\code{\link{row_group}()},
+\code{\link{stub}()},
+\code{\link{system_fonts}()},
+\code{\link{unit_conversion}()}
+}
+\concept{helper functions}
diff --git a/man/unit_conversion.Rd b/man/unit_conversion.Rd
index bd942079e..1880695ab 100644
--- a/man/unit_conversion.Rd
+++ b/man/unit_conversion.Rd
@@ -175,6 +175,7 @@ Other helper functions:
\code{\link{random_id}()},
\code{\link{row_group}()},
\code{\link{stub}()},
-\code{\link{system_fonts}()}
+\code{\link{system_fonts}()},
+\code{\link{typst}()}
}
\concept{helper functions}
diff --git a/tests/testthat/_snaps/as_typst.md b/tests/testthat/_snaps/as_typst.md
new file mode 100644
index 000000000..11262255a
--- /dev/null
+++ b/tests/testthat/_snaps/as_typst.md
@@ -0,0 +1,209 @@
+# as_typst() snapshot for basic table
+
+ Code
+ cat(as_typst(gt(head(exibble))))
+ Output
+ #set text(font: ("Segoe UI", "Roboto", "Helvetica", "Arial", "sans-serif"))
+ #set text(fill: rgb("#333333"))
+ #table(
+ columns: 9,
+ align: (right, left, center, right, right, right, right, left, left),
+ stroke: (x: none, y: 0.75pt + rgb("#D3D3D3")),
+ inset: (x: 3.75pt, y: 6.0pt),
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ table.header(
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ [num], [char], [fctr], [date], [time], [datetime], [currency], [row], [group],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ ),
+ [0.1111], [apricot], [one], [2015-01-15], [13:35], [2018-01-01 02:22], [49.950], [row\_1], [grp\_a],
+ [2.2220], [banana], [two], [2015-02-15], [14:40], [2018-02-02 14:33], [17.950], [row\_2], [grp\_a],
+ [33.3300], [coconut], [three], [2015-03-15], [15:45], [2018-03-03 03:44], [1.390], [row\_3], [grp\_a],
+ [444.4000], [durian], [four], [2015-04-15], [16:50], [2018-04-04 15:55], [65100.000], [row\_4], [grp\_a],
+ [5550.0000], [NA], [five], [2015-05-15], [17:55], [2018-05-05 04:00], [1325.810], [row\_5], [grp\_b],
+ [NA], [fig], [six], [2015-06-15], [NA], [2018-06-06 16:11], [13.255], [row\_6], [grp\_b],
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ )
+
+# as_typst() snapshot for table with heading
+
+ Code
+ cat(as_typst(tab_header(gt(head(exibble)), title = "Title Here", subtitle = "Subtitle Here")))
+ Output
+ #set text(font: ("Segoe UI", "Roboto", "Helvetica", "Arial", "sans-serif"))
+ #set text(fill: rgb("#333333"))
+ #table(
+ columns: 9,
+ align: (right, left, center, right, right, right, right, left, left),
+ stroke: (x: none, y: 0.75pt + rgb("#D3D3D3")),
+ inset: (x: 3.75pt, y: 6.0pt),
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ table.header(
+ table.cell(colspan: 9, align: center, inset: (x: 3.75pt, y: 3.0pt))[#text(size: 1.25em, weight: "bold")[Title Here] \ #text(size: 0.85em)[Subtitle Here]],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ [num], [char], [fctr], [date], [time], [datetime], [currency], [row], [group],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ ),
+ [0.1111], [apricot], [one], [2015-01-15], [13:35], [2018-01-01 02:22], [49.950], [row\_1], [grp\_a],
+ [2.2220], [banana], [two], [2015-02-15], [14:40], [2018-02-02 14:33], [17.950], [row\_2], [grp\_a],
+ [33.3300], [coconut], [three], [2015-03-15], [15:45], [2018-03-03 03:44], [1.390], [row\_3], [grp\_a],
+ [444.4000], [durian], [four], [2015-04-15], [16:50], [2018-04-04 15:55], [65100.000], [row\_4], [grp\_a],
+ [5550.0000], [NA], [five], [2015-05-15], [17:55], [2018-05-05 04:00], [1325.810], [row\_5], [grp\_b],
+ [NA], [fig], [six], [2015-06-15], [NA], [2018-06-06 16:11], [13.255], [row\_6], [grp\_b],
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ )
+
+# as_typst() snapshot for table with stub
+
+ Code
+ cat(as_typst(gt(head(exibble), rowname_col = "row")))
+ Output
+ #set text(font: ("Segoe UI", "Roboto", "Helvetica", "Arial", "sans-serif"))
+ #set text(fill: rgb("#333333"))
+ #table(
+ columns: 9,
+ align: (left, right, left, center, right, right, right, right, left),
+ stroke: (x: none, y: 0.75pt + rgb("#D3D3D3")),
+ inset: (x: 3.75pt, y: 6.0pt),
+ table.vline(x: 1, stroke: 1.5pt + rgb("#D3D3D3")),
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ table.header(
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ [], [num], [char], [fctr], [date], [time], [datetime], [currency], [group],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ ),
+ [row\_1], [0.1111], [apricot], [one], [2015-01-15], [13:35], [2018-01-01 02:22], [49.950], [grp\_a],
+ [row\_2], [2.2220], [banana], [two], [2015-02-15], [14:40], [2018-02-02 14:33], [17.950], [grp\_a],
+ [row\_3], [33.3300], [coconut], [three], [2015-03-15], [15:45], [2018-03-03 03:44], [1.390], [grp\_a],
+ [row\_4], [444.4000], [durian], [four], [2015-04-15], [16:50], [2018-04-04 15:55], [65100.000], [grp\_a],
+ [row\_5], [5550.0000], [NA], [five], [2015-05-15], [17:55], [2018-05-05 04:00], [1325.810], [grp\_b],
+ [row\_6], [NA], [fig], [six], [2015-06-15], [NA], [2018-06-06 16:11], [13.255], [grp\_b],
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ )
+
+# as_typst() snapshot for table with row groups
+
+ Code
+ cat(as_typst(gt(exibble, rowname_col = "row", groupname_col = "group")))
+ Output
+ #set text(font: ("Segoe UI", "Roboto", "Helvetica", "Arial", "sans-serif"))
+ #set text(fill: rgb("#333333"))
+ #table(
+ columns: 8,
+ align: (left, right, left, center, right, right, right, right),
+ stroke: (x: none, y: 0.75pt + rgb("#D3D3D3")),
+ inset: (x: 3.75pt, y: 6.0pt),
+ table.vline(x: 1, stroke: 1.5pt + rgb("#D3D3D3")),
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ table.header(
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ [], [num], [char], [fctr], [date], [time], [datetime], [currency],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ ),
+ table.cell(colspan: 8, inset: (x: 3.75pt, y: 6.0pt))[#text(weight: "bold")[grp\_a]],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ [row\_1], [1.111e-01], [apricot], [one], [2015-01-15], [13:35], [2018-01-01 02:22], [49.950],
+ [row\_2], [2.222e+00], [banana], [two], [2015-02-15], [14:40], [2018-02-02 14:33], [17.950],
+ [row\_3], [3.333e+01], [coconut], [three], [2015-03-15], [15:45], [2018-03-03 03:44], [1.390],
+ [row\_4], [4.444e+02], [durian], [four], [2015-04-15], [16:50], [2018-04-04 15:55], [65100.000],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ table.cell(colspan: 8, inset: (x: 3.75pt, y: 6.0pt))[#text(weight: "bold")[grp\_b]],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ [row\_5], [5.550e+03], [NA], [five], [2015-05-15], [17:55], [2018-05-05 04:00], [1325.810],
+ [row\_6], [NA], [fig], [six], [2015-06-15], [NA], [2018-06-06 16:11], [13.255],
+ [row\_7], [7.770e+05], [grapefruit], [seven], [NA], [19:10], [2018-07-07 05:22], [NA],
+ [row\_8], [8.880e+06], [honeydew], [eight], [2015-08-15], [20:20], [NA], [0.440],
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ )
+
+# as_typst() snapshot for table with spanners
+
+ Code
+ cat(as_typst(tab_spanner(tab_spanner(gt(head(exibble)), label = "Text Cols",
+ columns = c(char, fctr)), label = "Num Cols", columns = c(num, currency))))
+ Output
+ #set text(font: ("Segoe UI", "Roboto", "Helvetica", "Arial", "sans-serif"))
+ #set text(fill: rgb("#333333"))
+ #table(
+ columns: 9,
+ align: (right, right, left, center, right, right, right, left, left),
+ stroke: (x: none, y: 0.75pt + rgb("#D3D3D3")),
+ inset: (x: 3.75pt, y: 6.0pt),
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ table.header(
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ table.cell(colspan: 2, align: center)[Num Cols], table.cell(colspan: 2, align: center)[Text Cols], [], [], [], [], [],
+ table.hline(start: 0, end: 2, stroke: 0.75pt + rgb("#D3D3D3")), table.hline(start: 2, end: 4, stroke: 0.75pt + rgb("#D3D3D3")),
+ [num], [currency], [char], [fctr], [date], [time], [datetime], [row], [group],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ ),
+ [0.1111], [49.950], [apricot], [one], [2015-01-15], [13:35], [2018-01-01 02:22], [row\_1], [grp\_a],
+ [2.2220], [17.950], [banana], [two], [2015-02-15], [14:40], [2018-02-02 14:33], [row\_2], [grp\_a],
+ [33.3300], [1.390], [coconut], [three], [2015-03-15], [15:45], [2018-03-03 03:44], [row\_3], [grp\_a],
+ [444.4000], [65100.000], [durian], [four], [2015-04-15], [16:50], [2018-04-04 15:55], [row\_4], [grp\_a],
+ [5550.0000], [1325.810], [NA], [five], [2015-05-15], [17:55], [2018-05-05 04:00], [row\_5], [grp\_b],
+ [NA], [13.255], [fig], [six], [2015-06-15], [NA], [2018-06-06 16:11], [row\_6], [grp\_b],
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ )
+
+# as_typst() snapshot for table with source note
+
+ Code
+ cat(as_typst(tab_source_note(gt(head(exibble)), "Source: example dataset")))
+ Output
+ #set text(font: ("Segoe UI", "Roboto", "Helvetica", "Arial", "sans-serif"))
+ #set text(fill: rgb("#333333"))
+ #table(
+ columns: 9,
+ align: (right, left, center, right, right, right, right, left, left),
+ stroke: (x: none, y: 0.75pt + rgb("#D3D3D3")),
+ inset: (x: 3.75pt, y: 6.0pt),
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ table.header(
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ [num], [char], [fctr], [date], [time], [datetime], [currency], [row], [group],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ ),
+ [0.1111], [apricot], [one], [2015-01-15], [13:35], [2018-01-01 02:22], [49.950], [row\_1], [grp\_a],
+ [2.2220], [banana], [two], [2015-02-15], [14:40], [2018-02-02 14:33], [17.950], [row\_2], [grp\_a],
+ [33.3300], [coconut], [three], [2015-03-15], [15:45], [2018-03-03 03:44], [1.390], [row\_3], [grp\_a],
+ [444.4000], [durian], [four], [2015-04-15], [16:50], [2018-04-04 15:55], [65100.000], [row\_4], [grp\_a],
+ [5550.0000], [NA], [five], [2015-05-15], [17:55], [2018-05-05 04:00], [1325.810], [row\_5], [grp\_b],
+ [NA], [fig], [six], [2015-06-15], [NA], [2018-06-06 16:11], [13.255], [row\_6], [grp\_b],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ table.cell(colspan: 9, align: left, fill: rgb("#FFFFFF"), inset: (x: 3.75pt, y: 3.0pt))[#text(size: 0.90em)[Source: example dataset]],
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ )
+
+# as_typst() snapshot for table with footnotes
+
+ Code
+ cat(as_typst(tab_footnote(tab_footnote(gt(head(exibble)), footnote = "First footnote",
+ locations = cells_column_labels(columns = num)), footnote = "Second footnote",
+ locations = cells_body(columns = char, rows = 1))))
+ Output
+ #set text(font: ("Segoe UI", "Roboto", "Helvetica", "Arial", "sans-serif"))
+ #set text(fill: rgb("#333333"))
+ #table(
+ columns: 9,
+ align: (right, left, center, right, right, right, right, left, left),
+ stroke: (x: none, y: 0.75pt + rgb("#D3D3D3")),
+ inset: (x: 3.75pt, y: 6.0pt),
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ table.header(
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ [num#super[1]], [char], [fctr], [date], [time], [datetime], [currency], [row], [group],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ ),
+ [0.1111], [apricot\#super\[2\]], [one], [2015-01-15], [13:35], [2018-01-01 02:22], [49.950], [row\_1], [grp\_a],
+ [2.2220], [banana], [two], [2015-02-15], [14:40], [2018-02-02 14:33], [17.950], [row\_2], [grp\_a],
+ [33.3300], [coconut], [three], [2015-03-15], [15:45], [2018-03-03 03:44], [1.390], [row\_3], [grp\_a],
+ [444.4000], [durian], [four], [2015-04-15], [16:50], [2018-04-04 15:55], [65100.000], [row\_4], [grp\_a],
+ [5550.0000], [NA], [five], [2015-05-15], [17:55], [2018-05-05 04:00], [1325.810], [row\_5], [grp\_b],
+ [NA], [fig], [six], [2015-06-15], [NA], [2018-06-06 16:11], [13.255], [row\_6], [grp\_b],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ table.cell(colspan: 9, align: left, fill: rgb("#FFFFFF"), inset: (x: 3.75pt, y: 3.0pt))[#text(size: 0.90em)[#super[1] First footnote] \ #text(size: 0.90em)[#super[2] Second footnote]],
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ )
+
diff --git a/tests/testthat/_snaps/gtsave.md b/tests/testthat/_snaps/gtsave.md
index c9e87056a..a8bcb025c 100644
--- a/tests/testthat/_snaps/gtsave.md
+++ b/tests/testthat/_snaps/gtsave.md
@@ -11,6 +11,7 @@
* `.pdf` (PDF file)
* `.tex`, `.rnw` (LaTeX file)
* `.rtf` (RTF file)
+ * `.typ` (Typst file)
* `.docx` (Word file)
Code
gtsave(gt(exibble), filename = "exibble")
@@ -23,6 +24,7 @@
* `.pdf` (PDF file)
* `.tex`, `.rnw` (LaTeX file)
* `.rtf` (RTF file)
+ * `.typ` (Typst file)
* `.docx` (Word file)
# gtsave() creates docx files as expected
diff --git a/tests/testthat/test-as_typst.R b/tests/testthat/test-as_typst.R
new file mode 100644
index 000000000..57a0751eb
--- /dev/null
+++ b/tests/testthat/test-as_typst.R
@@ -0,0 +1,587 @@
+# Snapshot and structural tests for Typst output
+
+# -- Basic table output --------------------------------------------------------
+
+test_that("as_typst() produces valid Typst table structure", {
+
+ typst_out <- gt(head(mtcars)) |> as_typst()
+
+ # Should be a character string
+
+ expect_type(typst_out, "character")
+ expect_length(typst_out, 1)
+
+ # Must contain #table( opening
+ expect_match(typst_out, "#table(", fixed = TRUE)
+
+ # Must contain columns: specification
+ expect_match(typst_out, "columns:")
+
+ # Must contain align: specification
+ expect_match(typst_out, "align:")
+
+ # Must contain stroke specification
+ expect_match(typst_out, "stroke:", fixed = TRUE)
+
+ # Must contain table.header(
+ expect_match(typst_out, "table.header(", fixed = TRUE)
+
+ # Must contain table.hline for booktabs-style rules
+ expect_match(typst_out, "table.hline(", fixed = TRUE)
+
+ # Should contain column names from mtcars
+ expect_match(typst_out, "mpg", fixed = TRUE)
+ expect_match(typst_out, "cyl", fixed = TRUE)
+ expect_match(typst_out, "disp", fixed = TRUE)
+})
+
+test_that("as_typst() snapshot for basic table", {
+ expect_snapshot(gt(head(exibble)) |> as_typst() |> cat())
+})
+
+# -- Escaping ------------------------------------------------------------------
+
+test_that("as_typst() escapes special Typst characters in data", {
+
+ df <- data.frame(x = "a # b", y = "c $ d")
+ typst_out <- gt(df) |> as_typst()
+
+ expect_match(typst_out, "a \\\\# b", perl = TRUE)
+ expect_match(typst_out, "c \\\\\\$ d", perl = TRUE)
+})
+
+# -- Headings ------------------------------------------------------------------
+
+test_that("as_typst() renders title and subtitle", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_header(
+ title = "My Title",
+ subtitle = "My Subtitle"
+ ) |>
+ as_typst()
+
+ # Title should be present (bold, larger)
+ expect_match(typst_out, "My Title", fixed = TRUE)
+
+ # Subtitle should be present
+ expect_match(typst_out, "My Subtitle", fixed = TRUE)
+})
+
+test_that("as_typst() snapshot for table with heading", {
+ expect_snapshot(
+ gt(head(exibble)) |>
+ tab_header(title = "Title Here", subtitle = "Subtitle Here") |>
+ as_typst() |>
+ cat()
+ )
+})
+
+# -- Column alignment ----------------------------------------------------------
+
+test_that("as_typst() applies column alignment", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ cols_align(align = "right", columns = num) |>
+ cols_align(align = "center", columns = char) |>
+ as_typst()
+
+ # Should have an align: specification with right and center
+ expect_match(typst_out, "right")
+ expect_match(typst_out, "center")
+})
+
+# -- Stub (row names) ---------------------------------------------------------
+
+test_that("as_typst() renders stub column", {
+
+ typst_out <-
+ gt(exibble, rowname_col = "row") |>
+ as_typst()
+
+ # Row label values should appear in output (underscores escaped in Typst)
+ expect_match(typst_out, "row\\_1", fixed = TRUE)
+ expect_match(typst_out, "row\\_2", fixed = TRUE)
+})
+
+test_that("as_typst() snapshot for table with stub", {
+ expect_snapshot(
+ gt(head(exibble), rowname_col = "row") |>
+ as_typst() |>
+ cat()
+ )
+})
+
+# -- Row groups ----------------------------------------------------------------
+
+test_that("as_typst() renders row group labels", {
+
+ typst_out <-
+ gt(exibble, rowname_col = "row", groupname_col = "group") |>
+ as_typst()
+
+ # Group labels should appear (underscores escaped in Typst)
+ expect_match(typst_out, "grp\\_a", fixed = TRUE)
+ expect_match(typst_out, "grp\\_b", fixed = TRUE)
+
+ # Group labels should use colspan to span all columns
+ expect_match(typst_out, "colspan:", fixed = TRUE)
+})
+
+test_that("as_typst() snapshot for table with row groups", {
+ expect_snapshot(
+ gt(exibble, rowname_col = "row", groupname_col = "group") |>
+ as_typst() |>
+ cat()
+ )
+})
+
+# -- Spanners ------------------------------------------------------------------
+
+test_that("as_typst() renders spanners with colspan", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_spanner(
+ label = "Numbers",
+ columns = c(num, currency)
+ ) |>
+ as_typst()
+
+ # Spanner label should appear
+ expect_match(typst_out, "Numbers", fixed = TRUE)
+
+ # Should use table.cell(colspan: N) for spanning
+ expect_match(typst_out, "colspan:", fixed = TRUE)
+})
+
+test_that("as_typst() snapshot for table with spanners", {
+ expect_snapshot(
+ gt(head(exibble)) |>
+ tab_spanner(label = "Text Cols", columns = c(char, fctr)) |>
+ tab_spanner(label = "Num Cols", columns = c(num, currency)) |>
+ as_typst() |>
+ cat()
+ )
+})
+
+# -- Column widths -------------------------------------------------------------
+
+test_that("as_typst() applies column widths", {
+
+ typst_out <-
+ gt(head(exibble[, 1:3])) |>
+ cols_width(
+ num ~ px(200),
+ char ~ px(100),
+ fctr ~ px(150)
+ ) |>
+ as_typst()
+
+ # Column widths should appear as pt values (px * 0.75)
+ expect_match(typst_out, "150.0pt|150pt", perl = TRUE)
+ expect_match(typst_out, "75.0pt|75pt", perl = TRUE)
+ expect_match(typst_out, "112.5pt", fixed = TRUE)
+})
+
+# -- Markdown ------------------------------------------------------------------
+
+test_that("as_typst() processes markdown text", {
+
+ df <- data.frame(x = 1)
+ typst_out <-
+ gt(df) |>
+ tab_header(title = md("**Bold Title**")) |>
+ as_typst()
+
+ # Markdown bold should be converted to Typst bold
+ expect_match(typst_out, "*Bold Title*", fixed = TRUE)
+ # Should NOT have the markdown **
+ expect_no_match(typst_out, "**Bold Title**", fixed = TRUE)
+})
+
+# -- Source notes --------------------------------------------------------------
+
+test_that("as_typst() renders source notes below table", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_source_note("Source: test data") |>
+ as_typst()
+
+ expect_match(typst_out, "Source: test data", fixed = TRUE)
+})
+
+test_that("as_typst() snapshot for table with source note", {
+ expect_snapshot(
+ gt(head(exibble)) |>
+ tab_source_note("Source: example dataset") |>
+ as_typst() |>
+ cat()
+ )
+})
+
+# -- Footnotes -----------------------------------------------------------------
+
+test_that("as_typst() renders footnote marks and footer", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_footnote(
+ footnote = "A footnote about this column",
+ locations = cells_column_labels(columns = num)
+ ) |>
+ as_typst()
+
+ # Should have superscript footnote mark
+ expect_match(typst_out, "#super[", fixed = TRUE)
+
+ # Footnote text should appear
+ expect_match(typst_out, "A footnote about this column", fixed = TRUE)
+})
+
+test_that("as_typst() snapshot for table with footnotes", {
+ expect_snapshot(
+ gt(head(exibble)) |>
+ tab_footnote(
+ footnote = "First footnote",
+ locations = cells_column_labels(columns = num)
+ ) |>
+ tab_footnote(
+ footnote = "Second footnote",
+ locations = cells_body(columns = char, rows = 1)
+ ) |>
+ as_typst() |>
+ cat()
+ )
+})
+
+test_that("as_typst() renders multiple footnotes with distinct marks", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_footnote("Note A", locations = cells_column_labels(columns = num)) |>
+ tab_footnote("Note B", locations = cells_column_labels(columns = char)) |>
+ tab_footnote("Note C", locations = cells_body(columns = num, rows = 1)) |>
+ as_typst()
+
+ # Three distinct footnote marks in the footer
+ expect_match(typst_out, "#super[1]", fixed = TRUE)
+ expect_match(typst_out, "#super[2]", fixed = TRUE)
+ expect_match(typst_out, "#super[3]", fixed = TRUE)
+
+ # All footnote texts present
+ expect_match(typst_out, "Note A", fixed = TRUE)
+ expect_match(typst_out, "Note B", fixed = TRUE)
+ expect_match(typst_out, "Note C", fixed = TRUE)
+})
+
+test_that("as_typst() renders source notes and footnotes together", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_footnote("A footnote", locations = cells_column_labels(columns = num)) |>
+ tab_source_note("Source: test data") |>
+ as_typst()
+
+ # Both should appear below the table
+
+ expect_match(typst_out, "A footnote", fixed = TRUE)
+ expect_match(typst_out, "Source: test data", fixed = TRUE)
+})
+
+# -- Column merges -------------------------------------------------------------
+
+test_that("as_typst() renders merged columns", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ cols_merge_range(
+ col_begin = num,
+ col_end = currency
+ ) |>
+ as_typst()
+
+ # The merged column should show a range separator
+ expect_type(typst_out, "character")
+})
+
+# -- Summary rows --------------------------------------------------------------
+
+test_that("as_typst() renders grand summary rows", {
+
+ typst_out <-
+ gt(exibble, rowname_col = "row", groupname_col = "group") |>
+ grand_summary_rows(
+ fns = list(
+ list(label = "Mean", fn = "mean")
+ ),
+ columns = num,
+ missing_text = ""
+ ) |>
+ as_typst()
+
+ # Summary label should appear
+ expect_match(typst_out, "Mean", fixed = TRUE)
+})
+
+test_that("as_typst() renders group summary rows", {
+
+ typst_out <-
+ gt(exibble, rowname_col = "row", groupname_col = "group") |>
+ summary_rows(
+ fns = list(
+ list(label = "Total", fn = "sum")
+ ),
+ columns = num,
+ missing_text = ""
+ ) |>
+ as_typst()
+
+ expect_match(typst_out, "Total", fixed = TRUE)
+})
+
+# -- Empty table ---------------------------------------------------------------
+
+test_that("as_typst() handles empty table without error", {
+
+ empty_df <- data.frame(x = character(0), y = numeric(0))
+ expect_no_error(gt(empty_df) |> as_typst())
+})
+
+# -- gtsave with .typ extension ------------------------------------------------
+
+test_that("gtsave() writes .typ files", {
+
+ tmp <- tempfile(fileext = ".typ")
+ on.exit(unlink(tmp), add = TRUE)
+
+ gt(head(exibble)) |> gtsave(tmp)
+
+ expect_true(file.exists(tmp))
+ content <- readLines(tmp)
+ expect_true(any(grepl("#table(", content, fixed = TRUE)))
+})
+
+# -- Cell styling in Typst -----------------------------------------------------
+
+test_that("as_typst() applies cell fill styling", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_style(
+ style = cell_fill(color = "#FF0000"),
+ locations = cells_body(columns = num, rows = 1)
+ ) |>
+ as_typst()
+
+ # Should contain Typst fill with rgb color
+ expect_match(typst_out, "fill:", fixed = TRUE)
+ expect_match(typst_out, "rgb(", fixed = TRUE)
+})
+
+test_that("as_typst() applies text styling", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_style(
+ style = cell_text(weight = "bold", color = "#0000FF"),
+ locations = cells_body(columns = num, rows = 1)
+ ) |>
+ as_typst()
+
+ expect_match(typst_out, "weight:", fixed = TRUE)
+ expect_match(typst_out, "bold", fixed = TRUE)
+})
+
+# -- Stubhead ------------------------------------------------------------------
+
+test_that("as_typst() renders stubhead label", {
+
+ typst_out <-
+ gt(exibble, rowname_col = "row") |>
+ tab_stubhead(label = "Row ID") |>
+ as_typst()
+
+ expect_match(typst_out, "Row ID", fixed = TRUE)
+})
+
+# -- Units in column labels ----------------------------------------------------
+
+test_that("as_typst() renders units with superscripts", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ cols_units(num = "kg m^-2") |>
+ as_typst()
+
+ # Should contain superscript markup
+ expect_match(typst_out, "#super[", fixed = TRUE)
+})
+
+# -- typst() helper ------------------------------------------------------------
+
+test_that("typst() helper passes through raw Typst in typst context", {
+
+ typst_out <-
+ gt(data.frame(x = 1)) |>
+ tab_header(title = typst("#text(fill: red)[Red Title]")) |>
+ as_typst()
+
+ # Raw Typst content should pass through without escaping
+ expect_match(typst_out, "#text(fill: red)[Red Title]", fixed = TRUE)
+ # Should NOT have escaped the #
+ expect_no_match(typst_out, "\\#text", fixed = TRUE)
+})
+
+# -- fmt_typst() ---------------------------------------------------------------
+
+test_that("fmt_typst() passes through raw Typst in cells", {
+
+ df <- data.frame(x = c("hello", "world"))
+ typst_out <-
+ gt(df) |>
+ fmt_typst(columns = x) |>
+ as_typst()
+
+ # Content should pass through without escaping
+ expect_match(typst_out, "hello", fixed = TRUE)
+ expect_match(typst_out, "world", fixed = TRUE)
+})
+
+test_that("fmt_typst() does not escape special chars", {
+
+ df <- data.frame(x = "#text(fill: red)[danger]")
+ typst_out <-
+ gt(df) |>
+ fmt_typst(columns = x) |>
+ as_typst()
+
+ # The # and [] should NOT be escaped
+ expect_match(typst_out, "#text(fill: red)[danger]", fixed = TRUE)
+})
+
+test_that("fmt_typst() preserves Typst math mode", {
+
+ df <- data.frame(x = "$x^2 + y^2 = z^2$")
+ typst_out <-
+ gt(df) |>
+ fmt_typst(columns = x) |>
+ as_typst()
+
+ # Math mode $ should not be escaped
+ expect_match(typst_out, "$x^2 + y^2 = z^2$", fixed = TRUE)
+})
+
+test_that("fmt_typst() content is escaped without it", {
+
+ df <- data.frame(x = "#text(fill: red)[danger]")
+
+ # WITHOUT fmt_typst: special chars should be escaped
+ typst_out_escaped <- gt(df) |> as_typst()
+ expect_match(typst_out_escaped, "\\#text", fixed = TRUE)
+
+ # WITH fmt_typst: special chars should pass through
+ typst_out_raw <- gt(df) |> fmt_typst(columns = x) |> as_typst()
+ expect_match(typst_out_raw, "#text(fill: red)", fixed = TRUE)
+})
+
+# -- tab_options propagation ---------------------------------------------------
+
+test_that("as_typst() propagates table font size option", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_options(table.font.size = px(12)) |>
+ as_typst()
+
+ expect_match(typst_out, "#set text(size: 9.0pt)", fixed = TRUE)
+})
+
+test_that("as_typst() hides column labels when option set", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_options(column_labels.hidden = TRUE) |>
+ as_typst()
+
+ # Should NOT contain table.header
+ expect_no_match(typst_out, "table.header(", fixed = TRUE)
+})
+
+test_that("as_typst() propagates column label background color", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_options(column_labels.background.color = "#E0E0E0") |>
+ as_typst()
+
+ expect_match(typst_out, "fill:", fixed = TRUE)
+ expect_match(typst_out, "rgb(\"#E0E0E0\")", fixed = TRUE)
+})
+
+test_that("as_typst() propagates table font weight option", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_options(table.font.weight = "bold") |>
+ as_typst()
+
+ expect_match(typst_out, "#set text(weight: \"bold\")", fixed = TRUE)
+})
+
+test_that("as_typst() propagates table background color option", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_options(table.background.color = "#F0F0F0") |>
+ as_typst()
+
+ expect_match(typst_out, "fill:", fixed = TRUE)
+ expect_match(typst_out, "rgb(\"#F0F0F0\")", fixed = TRUE)
+})
+
+test_that("as_typst() propagates table width option", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_options(table.width = pct(80)) |>
+ as_typst()
+
+ expect_match(typst_out, "width: 80%", fixed = TRUE)
+})
+
+test_that("as_typst() propagates footnotes multiline=FALSE with separator", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_footnote("Note A", locations = cells_column_labels(columns = num)) |>
+ tab_footnote("Note B", locations = cells_column_labels(columns = char)) |>
+ tab_options(footnotes.multiline = FALSE, footnotes.sep = " | ") |>
+ as_typst()
+
+ # Both notes should be on one line
+ expect_match(typst_out, "Note A | ", fixed = TRUE)
+ expect_match(typst_out, "Note B", fixed = TRUE)
+})
+
+test_that("as_typst() propagates row striping option", {
+
+ typst_out <-
+ gt(head(exibble)) |>
+ tab_options(row.striping.include_table_body = TRUE) |>
+ as_typst()
+
+ expect_match(typst_out, "fill: (_, y) =>", fixed = TRUE)
+ expect_match(typst_out, "calc.odd", fixed = TRUE)
+})
+
+# -- as_typst() returns typst_text class ---------------------------------------
+
+test_that("as_typst() returns typst_text class", {
+
+ result <- gt(head(exibble)) |> as_typst()
+ expect_s3_class(result, "typst_text")
+})
diff --git a/tests/testthat/test-utils_render_typst.R b/tests/testthat/test-utils_render_typst.R
new file mode 100644
index 000000000..6337ccfd2
--- /dev/null
+++ b/tests/testthat/test-utils_render_typst.R
@@ -0,0 +1,122 @@
+# Unit tests for Typst rendering utilities
+
+# -- escape_typst() -----------------------------------------------------------
+
+test_that("escape_typst() escapes all Typst special characters", {
+
+ # Each of these characters needs a backslash in Typst markup mode
+ expect_equal(escape_typst("a # b"), "a \\# b")
+ expect_equal(escape_typst("a $ b"), "a \\$ b")
+ expect_equal(escape_typst("a @ b"), "a \\@ b")
+ expect_equal(escape_typst("a < b"), "a \\< b")
+ expect_equal(escape_typst("a > b"), "a \\> b")
+ expect_equal(escape_typst("a * b"), "a \\* b")
+ expect_equal(escape_typst("a _ b"), "a \\_ b")
+ expect_equal(escape_typst("a ~ b"), "a \\~ b")
+ expect_equal(escape_typst("a [ b"), "a \\[ b")
+ expect_equal(escape_typst("a ] b"), "a \\] b")
+ expect_equal(escape_typst("a \\ b"), "a \\\\ b")
+})
+
+test_that("escape_typst() escapes multiple special chars in one string", {
+ expect_equal(
+ escape_typst("price is $10 & *bold*"),
+ "price is \\$10 & \\*bold\\*"
+ )
+})
+
+test_that("escape_typst() handles empty and NA input", {
+ expect_equal(escape_typst(""), "")
+ expect_equal(escape_typst(character(0)), character(0))
+ expect_equal(escape_typst(NA_character_), NA_character_)
+})
+
+test_that("escape_typst() handles vector input", {
+ result <- escape_typst(c("a # b", "c $ d", NA_character_))
+ expect_equal(result, c("a \\# b", "c \\$ d", NA_character_))
+})
+
+test_that("escape_typst() leaves safe text unchanged", {
+ expect_equal(escape_typst("hello world"), "hello world")
+ expect_equal(escape_typst("123.45"), "123.45")
+})
+
+# -- markdown_to_typst() ------------------------------------------------------
+
+test_that("markdown_to_typst() converts bold", {
+ expect_equal(markdown_to_typst("**bold**"), "*bold*")
+ expect_equal(markdown_to_typst("__bold__"), "*bold*")
+})
+
+test_that("markdown_to_typst() converts italic", {
+ expect_equal(markdown_to_typst("*italic*"), "_italic_")
+ expect_equal(markdown_to_typst("_italic_"), "_italic_")
+})
+
+test_that("markdown_to_typst() converts bold italic", {
+ expect_equal(markdown_to_typst("***bold italic***"), "*_bold italic_*")
+})
+
+test_that("markdown_to_typst() converts strikethrough", {
+ expect_equal(markdown_to_typst("~~struck~~"), "#strike[struck]")
+})
+
+test_that("markdown_to_typst() converts links", {
+ expect_equal(
+ markdown_to_typst("[text](https://example.com)"),
+ "#link(\"https://example.com\")[text]"
+ )
+})
+
+test_that("markdown_to_typst() preserves inline code", {
+ expect_equal(markdown_to_typst("`code`"), "`code`")
+})
+
+test_that("markdown_to_typst() handles plain text (no markdown)", {
+ expect_equal(markdown_to_typst("plain text"), "plain text")
+})
+
+# -- css_color_to_typst() -----------------------------------------------------
+
+test_that("css_color_to_typst() converts hex colors", {
+ expect_equal(css_color_to_typst("#FF0000"), "rgb(\"#FF0000\")")
+ expect_equal(css_color_to_typst("#00ff00"), "rgb(\"#00ff00\")")
+})
+
+test_that("css_color_to_typst() converts 3-digit hex", {
+ result <- css_color_to_typst("#F00")
+ expect_match(result, "rgb\\(")
+})
+
+test_that("css_color_to_typst() handles rgba", {
+ result <- css_color_to_typst("rgba(255, 0, 0, 0.5)")
+ expect_match(result, "rgb\\(")
+})
+
+test_that("css_color_to_typst() handles named colors", {
+ result <- css_color_to_typst("red")
+ # Should convert to hex to avoid Typst/CSS named color mismatches
+ expect_match(result, "rgb\\(")
+})
+
+# -- css_length_to_typst() ----------------------------------------------------
+
+test_that("css_length_to_typst() converts px to pt", {
+ expect_equal(css_length_to_typst("100px"), "75.0pt")
+ expect_equal(css_length_to_typst("10px"), "7.5pt")
+})
+
+test_that("css_length_to_typst() passes through pt", {
+ expect_equal(css_length_to_typst("12pt"), "12pt")
+})
+
+test_that("css_length_to_typst() passes through other units", {
+ expect_equal(css_length_to_typst("2cm"), "2cm")
+ expect_equal(css_length_to_typst("25mm"), "25mm")
+ expect_equal(css_length_to_typst("1in"), "1in")
+ expect_equal(css_length_to_typst("1.5em"), "1.5em")
+})
+
+test_that("css_length_to_typst() handles percentages", {
+ expect_equal(css_length_to_typst("50%"), "50%")
+})