Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,7 @@ Suggests:
tibble
VignetteBuilder: knitr
Config/testthat/edition: 3
Config/testthat/parallel: true
Config/testthat/start-first: tfl_table, gt, integration, table1, flextable, rtables
URL: https://humanpred.github.io/writetfl/
Config/roxygen2/version: 8.0.0
6 changes: 4 additions & 2 deletions R/draw.R
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ draw_rule <- function(rule, y_mid_npc) {
#' Ignored for ggplot and grob content.
#' @param content_just Horizontal justification for character content:
#' `"left"`, `"right"`, or `"centre"`. Ignored for ggplot and grob content.
#' @inheritDotParams .convert_tabs tab_indent_spaces tab_infix_spaces
#' @keywords internal
#' @importFrom ggplot2 ggplot
draw_content <- function(content, vp, gp = grid::gpar(), content_just = "left") {
draw_content <- function(content, vp, gp = grid::gpar(), content_just = "left",
...) {
if (inherits(content, "ggplot")) {
grid::pushViewport(vp)
print(content, newpage = FALSE)
Expand All @@ -93,7 +95,7 @@ draw_content <- function(content, vp, gp = grid::gpar(), content_just = "left")
grid::pushViewport(vp)
text <- paste(content, collapse = "\n")
avail_w <- .width_in(grid::unit(1, "npc"))
wrapped <- .wrap_text(text, avail_w, gp)
wrapped <- .wrap_text(text, avail_w, gp, ...)
g <- grid::textGrob(
label = wrapped,
x = grid::unit(x_npc, "npc"),
Expand Down
12 changes: 6 additions & 6 deletions R/export_tfl_page.R
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,8 @@
#' not normally supplied when calling this function directly.
#' @param preview Logical. If `TRUE`, calls `grid.newpage()` and draws to the
#' currently open device without opening or closing any device.
#' @param ... Additional arguments. Currently recognised:
#' - `overlap_warn_mm`: numeric threshold in mm for near-miss overlap
#' warnings. Set to `NULL` to disable.
#' @inheritDotParams check_overlap overlap_warn_mm
#' @inheritDotParams .convert_tabs tab_indent_spaces tab_infix_spaces
#'
#' @return Invisibly returns `NULL`.
#'
Expand Down Expand Up @@ -213,9 +212,9 @@ export_tfl_page <- function(
# ---------------------------------------------------------------------------
vp_width_in <- .width_in(grid::unit(1, "npc"))
norm$caption <- wrap_normalized_text(norm$caption, resolved_gps$caption,
vp_width_in)
vp_width_in, ...)
norm$footnote <- wrap_normalized_text(norm$footnote, resolved_gps$footnote,
vp_width_in)
vp_width_in, ...)

# ---------------------------------------------------------------------------
# 6. Build all section grobs
Expand Down Expand Up @@ -347,7 +346,8 @@ export_tfl_page <- function(
just = c("left", "bottom"),
name = "content_vp"
)
draw_content(x$content, content_vp, gp = content_gp, content_just = content_just)
draw_content(x$content, content_vp, gp = content_gp,
content_just = content_just, ...)
y_cursor <- y_cursor - content_h_in

# --- Content-footnote padding ---
Expand Down
5 changes: 3 additions & 2 deletions R/normalize.R
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ normalize_text <- function(x) {
#' @param norm Output of [normalize_text()].
#' @param gp Resolved `gpar()` for this text element.
#' @param width_in Available width in inches.
#' @inheritDotParams .convert_tabs tab_indent_spaces tab_infix_spaces
#' @return A list with `$text` (wrapped string) and `$nlines` (updated count).
#' @keywords internal
wrap_normalized_text <- function(norm, gp, width_in) {
wrap_normalized_text <- function(norm, gp, width_in, ...) {
if (is.null(norm$text)) return(norm)
wrapped <- .wrap_text(norm$text, width_in, gp)
wrapped <- .wrap_text(norm$text, width_in, gp, ...)
normalize_text(wrapped)
}

Expand Down
4 changes: 2 additions & 2 deletions R/table_utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,8 @@
# user-configured break spec applies.
#
# Must be called while a viewport with the target font context is active.
.wrap_text <- function(text, available_w_in, gp) {
.wrap_string(text, available_w_in, gp, wrap_breaks_default())
.wrap_text <- function(text, available_w_in, gp, ...) {
.wrap_string(text, available_w_in, gp, wrap_breaks_default(), ...)
}

# ---------------------------------------------------------------------------
Expand Down
103 changes: 94 additions & 9 deletions R/wrap.R
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,43 @@ wrap_breaks_default <- function() {
w
}

#' Expand tab characters in one line of text to spaces
#'
#' The PDF graphics device cannot measure or render the tab glyph (0x09): it
#' warns "font width unknown" and treats the tab as zero width, so a tab in
#' cell or page text would silently collapse. Tabs are therefore expanded to
#' spaces before any measuring or drawing.
#'
#' @param s A single character string with no embedded newline.
#' @param ... Ignored.
#' @param tab_indent_spaces Number of spaces a *leading* (indentation) tab —
#' one preceded only by whitespace — is expanded to. Default `2`, matching
#' the common "a tab indents by two spaces" convention.
#' @param tab_infix_spaces Number of spaces an *in-line* tab — one with
#' non-whitespace to its left — is expanded to. Default `1`; the resulting
#' space then behaves as an ordinary breakable space.
#'
#' @return `s` with tab characters replaced by spaces. Strings containing no
#' tab are returned untouched (fast path).
#'
#' @keywords internal
.convert_tabs <- function(s, ..., tab_indent_spaces = 2L, tab_infix_spaces = 1L) {
if (!grepl("\t", s, fixed = TRUE)) return(s)
indent_fill <- strrep(" ", tab_indent_spaces)
infix_fill <- strrep(" ", tab_infix_spaces)
chars <- strsplit(s, "", fixed = TRUE)[[1L]]
in_lead <- TRUE
for (i in seq_along(chars)) {
ch <- chars[[i]]
if (ch == "\t") {
chars[[i]] <- if (in_lead) indent_fill else infix_fill
} else if (ch != " ") {
in_lead <- FALSE
}
}
paste0(chars, collapse = "")
}

#' Wrap text to fit a target width, preserving paragraph breaks.
#'
#' Greedy left-to-right packing. Paragraphs (separated by `\n` in `text`)
Expand All @@ -193,13 +230,14 @@ wrap_breaks_default <- function() {
#' @param available_w_in Numeric, available width in inches.
#' @param gp A `gpar()` for measurement font context.
#' @param breaks A `wrap_breaks` object; if `NULL`, the package default.
#' @inheritDotParams .convert_tabs tab_indent_spaces tab_infix_spaces
#'
#' @return A single character string, possibly with `\n` inserted at break
#' points.
#'
#' @keywords internal
.wrap_string <- function(text, available_w_in, gp,
breaks = wrap_breaks_default()) {
breaks = wrap_breaks_default(), ...) {
if (is.null(text) || !nzchar(text)) return(text)
if (is.null(breaks)) breaks <- wrap_breaks_default()

Expand All @@ -211,15 +249,52 @@ wrap_breaks_default <- function() {
paragraphs <- strsplit(text, "\n", fixed = TRUE)[[1L]]
wrapped <- vapply(paragraphs, function(para) {
if (!nzchar(para)) return("")
.wrap_paragraph(para, available_w_in, gp, breaks, width_cache)
.wrap_paragraph(para, available_w_in, gp, breaks, width_cache, ...)
}, character(1L))
paste(wrapped, collapse = "\n")
}

# Maximal leading run of `drop` characters at the start of `s`, as a string
# (e.g. the indentation on `" Indented label"`). Returns `""` when there is
# none. Used by `.wrap_paragraph()` to re-attach a paragraph's prefix that the
# tokenizer would otherwise consume. Fast path: a single `substr()` check
# short-circuits the common no-indentation case before any `strsplit()`.
.leading_drop_run <- function(s, drop_chars) {
if (length(drop_chars) == 0L || !nzchar(s)) return("")
if (!(substr(s, 1L, 1L) %in% drop_chars)) return("")
chars <- strsplit(s, "", fixed = TRUE)[[1L]]
n <- length(chars)
k <- 1L
while (k < n && chars[[k + 1L]] %in% drop_chars) k <- k + 1L
substr(s, 1L, k)
}

.wrap_paragraph <- function(para, available_w_in, gp, breaks,
width_cache = NULL) {
tokens <- .tokenize_for_wrap(para, breaks)
if (length(tokens) == 0L) return("")
width_cache = NULL, ...) {
# Expand tabs to spaces first so leading indentation is measurable and any
# in-line tab becomes an ordinary space (the device cannot render tabs).
para <- .convert_tabs(para, ...)

# Preserve leading indentation as a hanging indent. The tokenizer treats a
# run of `drop` characters as a between-token separator and `.wrap_paragraph()`
# drops the first token's separator, so a paragraph like `" Indented label"`
# would otherwise lose its prefix entirely. Capture the leading `drop` run,
# wrap the remaining body against the width reduced by the indent, then
# re-attach the prefix to *every* wrapped line so indented text stays
# indented across the wrap.
lead_ws <- .leading_drop_run(para, breaks$drop)
body <- if (nzchar(lead_ws)) substring(para, nchar(lead_ws) + 1L) else para

tokens <- .tokenize_for_wrap(body, breaks)
# A whitespace-only paragraph tokenizes to nothing; return the prefix so its
# spacing survives rather than collapsing to "".
if (length(tokens) == 0L) return(lead_ws)

# Width taken up by the indent shrinks the room each line has for body text.
indent_w <- if (nzchar(lead_ws)) {
.measure_text_width_in(lead_ws, gp, width_cache)
} else 0
body_w <- max(0, available_w_in - indent_w)

lines <- character(0L)
current_line <- ""
Expand All @@ -231,14 +306,15 @@ wrap_breaks_default <- function() {
}
cand <- paste0(current_line, tok$lead, tok$text)
if (.measure_text_width_in(cand, gp, width_cache) <=
available_w_in + 1e-6) {
body_w + 1e-6) {
current_line <- cand
} else {
lines <- c(lines, current_line)
current_line <- tok$text
}
}
if (nzchar(current_line)) lines <- c(lines, current_line)
if (nzchar(lead_ws)) lines <- paste0(lead_ws, lines)
paste(lines, collapse = "\n")
}

Expand Down Expand Up @@ -269,10 +345,13 @@ wrap_breaks_default <- function() {
#' Width (inches) of the widest unbreakable token across a column's strings.
#'
#' This is the wrapping floor: a column cannot be narrowed below the width
#' needed to render its longest single token.
#' needed to render its longest single token. Tabs are expanded to spaces
#' first (matching the table draw path) so the floor agrees with the rendered
#' text.
#'
#' @inheritDotParams .convert_tabs tab_indent_spaces tab_infix_spaces
#' @keywords internal
.column_min_token_width_in <- function(strings, gp, breaks) {
.column_min_token_width_in <- function(strings, gp, breaks, ...) {
if (length(strings) == 0L) return(0)
# Single shared cache across the column: tokens like "the", units, and
# other short repeats appear in many cells and would otherwise each
Expand All @@ -282,9 +361,15 @@ wrap_breaks_default <- function() {
if (!nzchar(s)) return(0)
paragraphs <- strsplit(s, "\n", fixed = TRUE)[[1L]]
max(vapply(paragraphs, function(p) {
p <- .convert_tabs(p, ...)
tokens <- .tokenize_for_wrap(p, breaks)
if (length(tokens) == 0L) return(0)
max(vapply(tokens, function(tok) {
# A hanging indent (preserved by .wrap_paragraph) widens every wrapped
# line, so the floor must leave room for indent + widest token or the
# indented lines would clip when the column is narrowed.
indent_w <- .measure_text_width_in(.leading_drop_run(p, breaks$drop),
gp, cache)
indent_w + max(vapply(tokens, function(tok) {
.measure_text_width_in(tok$text, gp, cache)
}, numeric(1L)))
}, numeric(1L)))
Expand Down
2 changes: 1 addition & 1 deletion design/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ export_tfl(x = list_of_table1, ...) [exported]
| `R/reexports.R` | `%||%` from rlang |
| `R/tfl_table.R` | `tfl_colspec()`, `tfl_table()`, `print.tfl_table()`, `.check_named_subset()` |
| `R/table_columns.R` | `resolve_col_specs()`, `compute_col_widths()`, `paginate_cols()` |
| `R/wrap.R` | `wrap_breaks()`, `wrap_breaks_default()`, `.is_wrap_breaks()`, `.tokenize_for_wrap()`, `.wrap_string()`, `.column_has_breakable_text()`, `.column_min_token_width_in()`, `.wrap_label_for_width()`, `.compute_wrapped_widths()` |
| `R/wrap.R` | `wrap_breaks()`, `wrap_breaks_default()`, `.is_wrap_breaks()`, `.tokenize_for_wrap()`, `.leading_drop_run()`, `.convert_tabs()`, `.wrap_string()`, `.column_has_breakable_text()`, `.column_min_token_width_in()`, `.wrap_label_for_width()`, `.compute_wrapped_widths()` |
| `R/table_rows.R` | `measure_row_heights_tbl()` (returns per-cell matrix), `.compute_page_row_heights()`, `paginate_rows()` |
| `R/table_draw.R` | `build_table_grob()`, `drawDetails.tfl_table_grob()`, `.compute_cell_suppression()`, `.draw_header_row()`, `.draw_cont_row()`, `.draw_cell_text()` |
| `R/table_pagelist.R` | `tfl_table_to_pagelist()`, `compute_table_content_area()` |
Expand Down
Loading
Loading