Skip to content

Commit 4bcde9d

Browse files
author
langbr_merck
committed
Add rtf_span_row() for native horizontal cell merging
Introduces rtf_span_row() to mark body rows that should span all columns using RTF \clmgf/\clmrg merge keywords. This replaces manual RTF injection workarounds for full-width category/group header rows in clinical tables. - New exported function rtf_span_row(tbl, span_row) - Horizontal merge logic in rtf_table_content() - Attribute threading through rtf_subset() and as_rtf_table() - Developer tests covering unit, integration, and edge cases
1 parent 25708ec commit 4bcde9d

8 files changed

Lines changed: 315 additions & 4 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ r2rtf.Rcheck/
99
r2rtf*.tar.gz
1010
r2rtf*.tgz
1111
revdep/
12+
RESEARCH-*.md

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export(rtf_read_figure)
1414
export(rtf_read_png)
1515
export(rtf_rich_text)
1616
export(rtf_source)
17+
export(rtf_span_row)
1718
export(rtf_subline)
1819
export(rtf_title)
1920
export(utf8Tortf)

R/as_rtf_table.R

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,14 @@ as_rtf_table <- function(tbl) {
9494

9595
# Remove repeated records if group_by is not null
9696
if (!is.null(group_by)) {
97+
saved_attrs <- attributes(cell_tbl)
9798
cell_tbl <- rtf_group_by_enhance(cell_tbl,
9899
group_by = group_by,
99100
page_index = page_dict$page
100101
)
102+
for (a in setdiff(names(saved_attrs), names(attributes(cell_tbl)))) {
103+
attr(cell_tbl, a) <- saved_attrs[[a]]
104+
}
101105
}
102106

103107
# Add border type for first and last row

R/rtf_span_row.R

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Copyright (c) 2022 Merck & Co., Inc., Rahway, NJ, USA and its affiliates. All rights reserved.
2+
#
3+
# This file is part of the r2rtf program.
4+
#
5+
# r2rtf is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
18+
#' @title Add Horizontal Span Row Attributes to Table
19+
#'
20+
#' @param tbl A data frame.
21+
#' @param span_row A logical vector of length \code{nrow(tbl)} indicating
22+
#' which rows should span all columns, or an integer vector of row indices.
23+
#'
24+
#' @section Specification:
25+
#' \if{latex}{
26+
#' \itemize{
27+
#' \item Validate that \code{tbl} has body attributes from \code{rtf_body()}.
28+
#' \item Normalize \code{span_row} to a logical vector of length \code{nrow(tbl)}.
29+
#' \item Set the \code{"rtf_span_row"} attribute on \code{tbl}.
30+
#' \item Return \code{tbl}.
31+
#' }
32+
#' }
33+
#' \if{html}{The contents of this section are shown in PDF user manual only.}
34+
#'
35+
#' @return the same data frame \code{tbl} with additional attributes for horizontal span rows
36+
#'
37+
#' @examples
38+
#' library(dplyr) # required to run examples
39+
#' data(r2rtf_tbl1)
40+
#' r2rtf_tbl1 %>%
41+
#' rtf_body() %>%
42+
#' rtf_span_row(span_row = c(rep(TRUE, 2), rep(FALSE, nrow(r2rtf_tbl1) - 2))) %>%
43+
#' attr("rtf_span_row")
44+
#'
45+
#' @export
46+
rtf_span_row <- function(tbl, span_row) {
47+
check_args(tbl, type = "data.frame")
48+
49+
if (is.null(attr(tbl, "border_top"))) {
50+
stop("rtf_span_row() must be called after rtf_body()")
51+
}
52+
53+
n_row <- nrow(tbl)
54+
55+
if (is.numeric(span_row) || is.integer(span_row)) {
56+
indices <- as.integer(span_row)
57+
if (any(indices < 1L | indices > n_row)) {
58+
stop("span_row indices must be between 1 and nrow(tbl)")
59+
}
60+
span_logical <- rep(FALSE, n_row)
61+
span_logical[indices] <- TRUE
62+
span_row <- span_logical
63+
}
64+
65+
check_args(span_row, type = "logical", length = n_row)
66+
67+
attr(tbl, "rtf_span_row") <- span_row
68+
tbl
69+
}

R/rtf_subset.R

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,5 +96,9 @@ rtf_subset <- function(tbl,
9696

9797
attr(tbl_sub, "col_rel_width") <- attr(tbl, "col_rel_width")[col]
9898

99+
if (!is.null(attr(tbl, "rtf_span_row"))) {
100+
attr(tbl_sub, "rtf_span_row") <- attr(tbl, "rtf_span_row")[row]
101+
}
102+
99103
tbl_sub
100104
}

R/rtf_table_content.R

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,29 @@ rtf_table_content <- function(tbl,
165165
cell_size <- cumsum(cell_width)
166166
cell_size <- foo(cell_size)
167167

168+
# Horizontal Merge (span rows)
169+
span_row <- attr(tbl, "rtf_span_row")
170+
if (!is.null(span_row) && any(span_row) && n_col > 1) {
171+
cell_h_merge <- matrix("", nrow = n_row, ncol = n_col)
172+
cell_h_merge[span_row, 1] <- "\\clmgf"
173+
cell_h_merge[span_row, 2:n_col] <- "\\clmrg"
174+
175+
# For span rows, the first cell (\clmgf) controls all visible borders.
176+
# Copy the last cell's right border onto the first cell, then clear internals.
177+
border_left_rtf <- matrix(border_left_rtf, nrow = n_row, ncol = n_col)
178+
border_right_rtf <- matrix(border_right_rtf, nrow = n_row, ncol = n_col)
179+
border_right_rtf[span_row, 1] <- border_right_rtf[span_row, n_col]
180+
border_left_rtf[span_row, 2:n_col] <- ""
181+
border_right_rtf[span_row, 2:n_col] <- ""
182+
} else {
183+
cell_h_merge <- ""
184+
}
185+
168186
# Combine Cell Attributes of cell justification, cell border type, cell border width, cell border color, cell background color and cell size.
169-
border_top_left <- matrix(paste0(border_left_rtf, border_top_rtf, text_background_color_rtf, cell_vertical_justification, "\\cellx", cell_size), nrow = n_row, ncol = n_col)
170-
border_top_left_right <- matrix(paste0(border_left_rtf, border_top_rtf, border_right_rtf, text_background_color_rtf, cell_vertical_justification, "\\cellx", cell_size), nrow = n_row, ncol = n_col)
171-
border_top_left_bottom <- matrix(paste0(border_left_rtf, border_top_rtf, border_bottom_rtf, text_background_color_rtf, cell_vertical_justification, "\\cellx", cell_size), nrow = n_row, ncol = n_col)
172-
border_all <- matrix(paste0(border_left_rtf, border_top_rtf, border_right_rtf, border_bottom_rtf, text_background_color_rtf, cell_vertical_justification, "\\cellx", cell_size), nrow = n_row, ncol = n_col)
187+
border_top_left <- matrix(paste0(border_left_rtf, border_top_rtf, text_background_color_rtf, cell_vertical_justification, cell_h_merge, "\\cellx", cell_size), nrow = n_row, ncol = n_col)
188+
border_top_left_right <- matrix(paste0(border_left_rtf, border_top_rtf, border_right_rtf, text_background_color_rtf, cell_vertical_justification, cell_h_merge, "\\cellx", cell_size), nrow = n_row, ncol = n_col)
189+
border_top_left_bottom <- matrix(paste0(border_left_rtf, border_top_rtf, border_bottom_rtf, text_background_color_rtf, cell_vertical_justification, cell_h_merge, "\\cellx", cell_size), nrow = n_row, ncol = n_col)
190+
border_all <- matrix(paste0(border_left_rtf, border_top_rtf, border_right_rtf, border_bottom_rtf, text_background_color_rtf, cell_vertical_justification, cell_h_merge, "\\cellx", cell_size), nrow = n_row, ncol = n_col)
173191

174192
if (use_border_bottom) {
175193
border_rtf <- border_top_left_bottom
@@ -179,6 +197,11 @@ rtf_table_content <- function(tbl,
179197
border_rtf[, n_col] <- border_top_left_right[, n_col]
180198
}
181199

200+
# For span rows, first cell is the only visible cell — give it all 4 borders
201+
if (!is.null(span_row) && any(span_row) && n_col > 1) {
202+
border_rtf[span_row, 1] <- border_all[span_row, 1]
203+
}
204+
182205
border_rtf <- t(border_rtf)
183206

184207
# Encode RTF Text and Paragraph
@@ -206,5 +229,10 @@ rtf_table_content <- function(tbl,
206229
cell = TRUE
207230
)
208231

232+
# Clear continuation cell content for span rows
233+
if (!is.null(span_row) && any(span_row) && n_col > 1) {
234+
cell_rtf[span_row, 2:n_col] <- "\\pard\\cell"
235+
}
236+
209237
rbind(row_begin, border_rtf, t(cell_rtf), row_end)
210238
}

man/rtf_span_row.Rd

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# =============================================================================
2+
# Unit tests for rtf_span_row
3+
# =============================================================================
4+
5+
# --- rtf_span_row() function tests ---
6+
7+
test_that("rtf_span_row sets attribute with logical vector", {
8+
tbl <- iris[1:5, ] |> rtf_body()
9+
result <- rtf_span_row(tbl, span_row = c(TRUE, FALSE, FALSE, TRUE, FALSE))
10+
expect_equal(attr(result, "rtf_span_row"), c(TRUE, FALSE, FALSE, TRUE, FALSE))
11+
})
12+
13+
test_that("rtf_span_row sets attribute with integer indices", {
14+
tbl <- iris[1:5, ] |> rtf_body()
15+
result <- rtf_span_row(tbl, span_row = c(1L, 4L))
16+
expect_equal(attr(result, "rtf_span_row"), c(TRUE, FALSE, FALSE, TRUE, FALSE))
17+
})
18+
19+
test_that("rtf_span_row errors on wrong length", {
20+
tbl <- iris[1:5, ] |> rtf_body()
21+
expect_error(rtf_span_row(tbl, span_row = c(TRUE, FALSE)))
22+
})
23+
24+
test_that("rtf_span_row errors when called before rtf_body", {
25+
expect_error(rtf_span_row(iris[1:5, ], span_row = c(TRUE, FALSE, FALSE, TRUE, FALSE)))
26+
})
27+
28+
test_that("rtf_span_row errors on out-of-range indices", {
29+
tbl <- iris[1:5, ] |> rtf_body()
30+
expect_error(rtf_span_row(tbl, span_row = c(0L, 6L)))
31+
})
32+
33+
test_that("rtf_span_row errors on non-logical non-integer input", {
34+
35+
tbl <- iris[1:5, ] |> rtf_body()
36+
expect_error(rtf_span_row(tbl, span_row = "row1"))
37+
})
38+
39+
40+
# --- rtf_table_content() with span ---
41+
42+
test_that("rtf_table_content emits clmgf and clmrg for span rows", {
43+
tbl <- iris[1:3, ] |> rtf_body() |> rtf_span_row(span_row = c(TRUE, FALSE, FALSE))
44+
result <- rtf_table_content(tbl, use_border_bottom = TRUE)
45+
46+
# result is a matrix; columns correspond to rows in the table
47+
# Row 1 (column 1 of result) should have \\clmgf and \\clmrg
48+
col1 <- paste(result[, 1], collapse = "\n")
49+
expect_true(grepl("\\\\clmgf", col1))
50+
expect_true(grepl("\\\\clmrg", col1))
51+
52+
# Row 2 (column 2) should NOT have merge codes
53+
54+
col2 <- paste(result[, 2], collapse = "\n")
55+
expect_false(grepl("\\\\clmgf", col2))
56+
expect_false(grepl("\\\\clmrg", col2))
57+
})
58+
59+
test_that("rtf_table_content empties continuation cells for span rows", {
60+
tbl <- iris[1:3, ] |> rtf_body() |> rtf_span_row(span_row = c(TRUE, FALSE, FALSE))
61+
result <- rtf_table_content(tbl, use_border_bottom = TRUE)
62+
63+
# For span row (column 1 of result matrix), continuation cells should be \\pard\\cell
64+
# The cell content rows start after row_begin + n_col border rows
65+
n_col <- ncol(iris)
66+
# Content for columns 2..n_col should be \\pard\\cell
67+
content_rows <- result[(1 + n_col + 2):(1 + n_col + n_col), 1]
68+
expect_true(all(content_rows == "\\pard\\cell"))
69+
})
70+
71+
test_that("rtf_table_content first cell retains content for span rows", {
72+
tbl <- iris[1:3, ] |> rtf_body() |> rtf_span_row(span_row = c(TRUE, FALSE, FALSE))
73+
result <- rtf_table_content(tbl, use_border_bottom = TRUE)
74+
75+
# First cell content (row after borders) should NOT be just \\pard\\cell
76+
n_col <- ncol(iris)
77+
first_cell_content <- result[1 + n_col + 1, 1]
78+
expect_false(first_cell_content == "\\pard\\cell")
79+
expect_true(grepl("5.1", first_cell_content))
80+
})
81+
82+
83+
# --- as_rtf_table() with span + group_by ---
84+
85+
test_that("as_rtf_table preserves span_row through group_by", {
86+
tbl <- iris[1:4, 4:5] |>
87+
rtf_body(group_by = "Species") |>
88+
rtf_span_row(span_row = c(TRUE, FALSE, FALSE, FALSE))
89+
90+
result <- as_rtf_table(tbl)
91+
expect_true(grepl("\\\\clmgf", result[1]))
92+
})
93+
94+
95+
# --- rtf_subset() with span ---
96+
97+
test_that("rtf_subset subsets rtf_span_row attribute", {
98+
tbl <- iris[1:5, ] |>
99+
rtf_body() |>
100+
rtf_span_row(span_row = c(TRUE, FALSE, TRUE, FALSE, TRUE))
101+
102+
sub <- rtf_subset(tbl, row = 2:4, col = 1:3)
103+
expect_equal(attr(sub, "rtf_span_row"), c(FALSE, TRUE, FALSE))
104+
})
105+
106+
107+
# --- End-to-end: rtf_encode with span ---
108+
109+
test_that("rtf_encode produces valid RTF with span rows", {
110+
tbl <- iris[1:3, ] |>
111+
rtf_body() |>
112+
rtf_span_row(span_row = c(TRUE, FALSE, FALSE)) |>
113+
rtf_encode()
114+
115+
rtf_text <- paste(unlist(tbl), collapse = "\n")
116+
expect_true(grepl("\\\\clmgf", rtf_text))
117+
expect_true(grepl("\\\\clmrg", rtf_text))
118+
})
119+
120+
test_that("rtf_encode without span_row produces no merge codes", {
121+
tbl <- iris[1:3, ] |>
122+
rtf_body() |>
123+
rtf_encode()
124+
125+
rtf_text <- paste(unlist(tbl), collapse = "\n")
126+
expect_false(grepl("\\\\clmgf", rtf_text))
127+
expect_false(grepl("\\\\clmrg", rtf_text))
128+
})
129+
130+
131+
# --- Edge cases ---
132+
133+
test_that("single-column table with span_row does not error", {
134+
tbl <- data.frame(x = 1:3) |>
135+
rtf_body() |>
136+
rtf_span_row(span_row = c(TRUE, FALSE, FALSE))
137+
138+
result <- rtf_table_content(tbl, use_border_bottom = TRUE)
139+
# Should not contain merge codes (only 1 column, merge is no-op)
140+
col1 <- paste(result[, 1], collapse = "\n")
141+
expect_false(grepl("\\\\clmgf", col1))
142+
})
143+
144+
test_that("all rows as span rows works", {
145+
tbl <- iris[1:3, ] |>
146+
rtf_body() |>
147+
rtf_span_row(span_row = c(TRUE, TRUE, TRUE)) |>
148+
rtf_encode()
149+
150+
rtf_text <- paste(unlist(tbl), collapse = "\n")
151+
expect_true(grepl("\\\\clmgf", rtf_text))
152+
})
153+
154+
test_that("span_row on first and last rows works with border_first/last", {
155+
tbl <- iris[1:5, ] |>
156+
rtf_body(border_first = "single", border_last = "single") |>
157+
rtf_span_row(span_row = c(TRUE, FALSE, FALSE, FALSE, TRUE)) |>
158+
rtf_encode()
159+
160+
rtf_text <- paste(unlist(tbl), collapse = "\n")
161+
expect_true(grepl("\\\\clmgf", rtf_text))
162+
})

0 commit comments

Comments
 (0)