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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,17 @@ footer (footer_left + footer_center + footer_right)

### Text normalization — `normalize_text(x)`

Accepts: `NULL`, single string, character vector.
Accepts: `NULL`, a single `NA`, single string, character vector.
Returns: `list(text = <single string>, nlines = <integer>)`

- Character vector → collapsed with `"\n"`
- Embedded `"\n"` → counted for `nlines`
- `NULL` → `list(text = NULL, nlines = 0L)`
- A single `NA` (`.is_single_na()`, any atomic type) → treated the same as
`NULL`. This lets data-driven page construction pass `NA` for an unsupplied
annotation. The same rule applies to `page_i` (no `"Page <i>: "` prefix) and
to `normalize_rule()` (a single `NA` means no rule, i.e. `FALSE`). A longer
vector with `NA` among real values is left untouched.

### Height measurement — `measure_grob_height(grob, nlines)`

Expand Down
26 changes: 17 additions & 9 deletions R/export_tfl_page.R
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,21 @@
#' of this gap and do not consume additional space.
#' @param header_left,header_center,header_right Header text. Accepts
#' `NULL`, a single string, or a character vector (collapsed with `"\\n"`).
#' A single `NA` is treated the same as `NULL` (section element absent), so
#' that data-driven page construction can leave an element unset.
#' Horizontal justification follows the argument name (left/center/right).
#' Vertically top-justified. Overridden by `x$header_left` etc.
#' @param caption Caption text below the header and above the content. Accepts
#' `NULL`, a single string, or a character vector. Full-width; justification
#' controlled by `caption_just`. Overridden by `x$caption`.
#' `NULL`, a single `NA` (treated as `NULL`), a single string, or a character
#' vector. Full-width; justification controlled by `caption_just`. Overridden
#' by `x$caption`.
#' @param footnote Footnote text below the content. Accepts `NULL`, a single
#' string, or a character vector. Full-width; justification controlled by
#' `footnote_just`. Overridden by `x$footnote`.
#' `NA` (treated as `NULL`), a single string, or a character vector.
#' Full-width; justification controlled by `footnote_just`. Overridden by
#' `x$footnote`.
#' @param footer_left,footer_center,footer_right Footer text. Mirror of
#' header arguments. Vertically bottom-justified. Overridden by
#' `x$footer_left` etc.
#' header arguments (a single `NA` is treated as `NULL`). Vertically
#' bottom-justified. Overridden by `x$footer_left` etc.
#' @param gp Typography specification. Accepts either a single `gpar()` object
#' applied to all text, or a named list for section- or element-level
#' control. Resolution priority (highest first): element-level
Expand All @@ -46,7 +50,7 @@
#' ```
#' @param header_rule Separator rule drawn between the header and the next
#' section (caption or content), fitted within the `padding` gap. Accepts:
#' - `FALSE`: no rule
#' - `FALSE` (or a single `NA`): no rule
#' - `TRUE`: full-width rule
#' - A numeric in `(0, 1]`: rule spanning that fraction of viewport width,
#' centered
Expand Down Expand Up @@ -81,8 +85,9 @@
#' single column — or any data column combined with the row-header
#' group columns — wider than the page).
#' @param page_i Integer page index, used to prefix layout error messages with
#' `"Page <i>: "`. Set automatically by [writetfl::export_tfl()];
#' not normally supplied when calling this function directly.
#' `"Page <i>: "`. A single `NA` is treated the same as `NULL` (no prefix).
#' Set automatically by [writetfl::export_tfl()]; 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 newpage Logical. If `TRUE` (default), start the page with
Expand Down Expand Up @@ -173,6 +178,9 @@ export_tfl_page <- function(
footnote_just <- match.arg(footnote_just, c("left", "right", "centre"))
content_just <- match.arg(content_just, c("left", "right", "centre"))
overflow_action <- match.arg(overflow_action, c("error", "warn"))
# A single NA page index behaves as "unset" (no "Page <i>: " error prefix),
# matching the NA-as-NULL treatment of the annotation arguments.
if (.is_single_na(page_i)) page_i <- NULL

# ---------------------------------------------------------------------------
# 2. Normalize all text and rule inputs
Expand Down
26 changes: 22 additions & 4 deletions R/normalize.R
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
# normalize.R — Input normalization helpers
# See ARCHITECTURE.md for full contracts.

#' Test whether `x` is a single `NA` to be treated as absent (like `NULL`)
#'
#' A length-1 atomic vector holding `NA` (of any type: logical `NA`,
#' `NA_character_`, `NA_integer_`, ...). Used so that data-driven page
#' construction can pass `NA` for an unsupplied annotation or toggle and have
#' it treated the same as `NULL`. A longer vector containing `NA` among real
#' values is *not* a single `NA` and is left untouched.
#'
#' @param x Any object.
#' @return `TRUE` only when `x` is a length-1 atomic `NA`.
#' @keywords internal
.is_single_na <- function(x) {
is.atomic(x) && length(x) == 1L && is.na(x)
}

#' Normalize text input to a single string with line count
#'
#' @param x NULL, a single character string, or a character vector.
#' @param x `NULL`, a single `NA` (treated as `NULL`), a single character
#' string, or a character vector.
#' @return A list with elements `text` (single string or NULL) and
#' `nlines` (integer).
#' @keywords internal
normalize_text <- function(x) {
if (is.null(x) || (is.character(x) && length(x) == 0L)) {
if (is.null(x) || .is_single_na(x) || (is.character(x) && length(x) == 0L)) {
return(list(text = NULL, nlines = 0L))
}
text <- paste(x, collapse = "\n")
Expand Down Expand Up @@ -39,13 +55,15 @@ wrap_normalized_text <- function(norm, gp, width_in, ...) {

#' Normalize rule specification to FALSE or a grob
#'
#' @param x FALSE, TRUE, numeric in (0,1], or a grob.
#' @param x FALSE, TRUE, numeric in (0,1], or a grob. A single `NA` is treated
#' the same as `FALSE` (no rule), so that data-driven construction can leave
#' the toggle unset.
#' A `linesGrob` is the typical choice, but any grob is accepted and will be
#' drawn as-is (centered vertically in the padding gap).
#' @return FALSE or a grob.
#' @keywords internal
normalize_rule <- function(x) {
if (isFALSE(x)) {
if (isFALSE(x) || .is_single_na(x)) {
return(FALSE)
}

Expand Down
20 changes: 17 additions & 3 deletions design/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,21 +389,35 @@ export_tfl(x = list_of_table1, ...) [exported]

## Data contracts

### `.is_single_na(x)` → `logical(1)`

```
Input: any object
Output: TRUE only when x is a length-1 atomic NA (of any type)
```

Shared predicate that lets a single `NA` be treated as "absent" (like `NULL`)
across the annotation/toggle arguments, so data-driven page construction can
pass `NA` for an unsupplied value. A longer vector containing `NA` among real
values is NOT a single `NA` and is left untouched.

### `normalize_text(x)` → `list`

```
Input: NULL | character(1) | character(n)
Input: NULL | NA (single) | character(1) | character(n)
Output: list(
text = character(1) | NULL, # \n-collapsed string
nlines = integer(1) # 0 if NULL
)
```

A single `NA` (`.is_single_na()`) is treated the same as `NULL`.

### `normalize_rule(x)` → `FALSE | grob`

```
Input: FALSE | TRUE | numeric (0,1] | grob (typically linesGrob)
Output: FALSE (no rule)
Input: FALSE | NA (single) | TRUE | numeric (0,1] | grob (typically linesGrob)
Output: FALSE (no rule; also for a single NA)
| grob (ready to draw, centered in padding gap)
```

Expand Down
44 changes: 44 additions & 0 deletions design/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2126,3 +2126,47 @@ raw-byte helper (no `pdftools` dependency) and regression tests asserting
a single-page `tfl_table` yields exactly one PDF page, that a figure and a
table yield the same page count, and that multi-page tables carry no
leading blank.

---

## D-52: A single `NA` is treated as `NULL`/absent for annotation & toggle args

**Decision:** In `export_tfl_page()`, a length-1 atomic `NA` (of any type)
supplied for an annotation or presence-toggle argument is treated exactly like
`NULL` (the "absent" state). This covers `caption`, `footnote`,
`header_left/center/right`, `footer_left/center/right` (section element
absent), `page_i` (no `"Page <i>: "` error prefix), and `header_rule` /
`footer_rule` (no rule, i.e. `FALSE`). Implemented once via the shared
predicate `.is_single_na()` in `R/normalize.R`, consumed by `normalize_text()`,
`normalize_rule()`, and the `page_i` coercion in `export_tfl_page()`. Because
the annotation arguments flow through `normalize_text()` after the `x[[key]]`
override merge, this also lets a per-page `x$caption = NA` blank an
otherwise-global caption.

**Motivation:** Page lists are frequently built from data frames (one row per
page). A column feeding an annotation naturally carries `NA` for pages where
the value is not applicable; without this, `NA` renders as the literal text
`"NA"` or errors. Treating a lone `NA` as absent makes data-driven
construction ergonomic.

**Scope — arguments deliberately left to fail loudly on `NA`:**
- `padding`, `margins`, `min_content_height` — `unit` objects; an `NA` is a
malformed value, not an "unset" signal, and `checkmate::assert_class()`
rejects it.
- `caption_just`, `footnote_just`, `content_just`, `overflow_action` —
enumerated settings validated by `match.arg()`; they always have a concrete
default, so an `NA` signals a construction bug and should error rather than
silently pick a default. (Per the fail-loud principle in the global
robustness guidance.)
- `gp`, `preview`, `newpage`, `x` — not presence toggles; `NA` is not a
meaningful "absent" value for them.

**Boundary:** Only a *single* `NA` is absent. A longer vector containing `NA`
among real values (e.g. `c("a", NA, "b")`) is untouched — it collapses with
`"\n"` exactly as before, so genuine multi-line content is never silently
dropped.

**Tests:** `tests/testthat/test-normalize.R` covers `.is_single_na()`,
`normalize_text(NA)`, and `normalize_rule(NA)`; `test-export_tfl_page.R` covers
the end-to-end NA-as-absent behavior for each argument and the mixed-vector
boundary.
9 changes: 8 additions & 1 deletion design/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ One test file per source file — `tests/testthat/test-<name>.R` covers
| `test-draw.R` | `draw_content()`, `draw_rule()`, `draw_header_section()`, `draw_footer_section()`, `draw_caption_section()`, `draw_footnote_section()` |
| `test-grob_builders.R` | `build_text_grob()`, `build_section_grobs()` |
| `test-export_tfl.R` | `export_tfl()` — file validation, return values, preview mode, device lifecycle, tfl_table coercion, argument merging |
| `test-export_tfl_page.R` | `export_tfl_page()` — argument resolution from x, overlap_warn_mm, page_i prefix, section presence, rules, page-level grob overflow under `overflow_action` (issue #30) |
| `test-export_tfl_page.R` | `export_tfl_page()` — argument resolution from x, overlap_warn_mm, page_i prefix, section presence, rules, page-level grob overflow under `overflow_action` (issue #30), single-`NA`-as-absent for every annotation, `page_i`, and rule (D-52; asserts no literal `"NA"` reaches the page via a display-list label sweep) |
| `test-table_utils.R` | `.compute_group_sizes()`, `.collect_col_strings()`, `.measure_max_string_width()`, `.wrap_text()` (now a default-breaks shim) |
| `test-wrap.R` | `wrap_breaks()` constructor + validation; `.tokenize_for_wrap()` (drop / keep_before / mixed); `.leading_drop_run()`; `.convert_tabs()` (leading vs. in-line tab expansion, custom counts); `.wrap_string()` (paragraphs, single token, keep_before, leading-space preservation as a hanging indent, tab expansion); `.column_has_breakable_text()`; `.column_min_token_width_in()` (floor calculation, keep_before reduces floor); `.wrap_label_for_width()`; `.compute_wrapped_widths()` (no-eligible no-op, water-from-top widest-first, longest-token floor) |
| `test-table_draw.R` | `build_table_grob()`, `drawDetails.tfl_table_grob()` (uncached fallback, wrap branch, rotated col_cont_msg labels, first_data fallback) |
Expand All @@ -58,12 +58,19 @@ test_that("normalize_text counts embedded newlines in nlines", ...)
test_that("normalize_text counts lines in collapsed vector correctly", ...)
test_that("normalize_text handles empty string", ...)
test_that("normalize_text handles character(0)", ...)
test_that("normalize_text treats a single NA the same as NULL", ...) # D-52
test_that("normalize_text keeps NA embedded in a longer vector", ...) # D-52

# .is_single_na() — D-52
test_that(".is_single_na is TRUE only for a length-1 atomic NA", ...)
test_that(".is_single_na is FALSE for NULL, real values, multi-element NA", ...)

# normalize_rule()
test_that("normalize_rule returns FALSE for FALSE", ...)
test_that("normalize_rule returns linesGrob for TRUE", ...)
test_that("normalize_rule returns linesGrob for numeric 0.5", ...)
test_that("normalize_rule errors for numeric outside (0,1]", ...)
test_that("normalize_rule treats a single NA the same as FALSE", ...) # D-52
test_that("normalize_rule passes linesGrob through unchanged", ...)
test_that("normalize_rule errors for invalid input type", ...)
```
Expand Down
22 changes: 22 additions & 0 deletions man/dot-is_single_na.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 11 additions & 7 deletions man/export_tfl.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 14 additions & 9 deletions man/export_tfl_page.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion man/normalize_rule.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion man/normalize_text.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading