Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Authors@R: c(
comment = c(ORCID = "0000-0003-0637-4436")),
person("Alexandra", "Lauer", , "alexandralauer1@gmail.com", role = "aut",
comment = c(ORCID = "0000-0002-4191-6301")),
person("Romain", "François", , "romain@tada.science", role = "aut"),
person("JooYoung", "Seo", , "jseo1005@illinois.edu", role = "aut",
comment = c(ORCID = "0000-0002-4064-6012")),
person("Ken", "Brevoort", , "ken@brevoort.com", role = "aut",
Expand Down
201 changes: 107 additions & 94 deletions R/utils_render_xml.R
Original file line number Diff line number Diff line change
Expand Up @@ -1371,16 +1371,8 @@ create_heading_component_xml <- function(

# Obtain the number of visible columns in the built table
n_data_cols <- length(dt_boxhead_get_vars_default(data = data))

# Determine whether the stub is available
stub_available <- dt_stub_components_has_rowname(stub_components)

# If a stub is present then the effective number of columns increases by 1
if (stub_available) {
n_cols <- n_data_cols + 1
} else {
n_cols <- n_data_cols
}
n_stub_cols <- length(dt_boxhead_get_var_by_type(data, type = "stub"))
n_cols <- n_data_cols + n_stub_cols

# Get table options
table_font_color <- dt_options_get_value(data, option = "table_font_color")
Expand Down Expand Up @@ -1526,7 +1518,6 @@ create_columns_component_xml <- function(
split = FALSE,
keep_with_next = TRUE
) {

boxh <- dt_boxhead_get(data = data)
stubh <- dt_stubhead_get(data = data)
body <- dt_body_get(data = data)
Expand Down Expand Up @@ -1566,19 +1557,23 @@ create_columns_component_xml <- function(
column_labels_vlines_color <- dt_options_get_value(data = data, option = "column_labels_vlines_color")

# If `stub_available` == TRUE, then replace with a set stubhead
# label or nothing
if (isTRUE(stub_available) && length(stubh$label) > 0L) {

headings_labels <- prepend_vec(headings_labels, stubh$label)
headings_vars <- prepend_vec(headings_vars, "::stub")

# label(?s) or nothing
n_stub_cols <- length(dt_boxhead_get_var_by_type(data, type = "stub"))
n_stubh_label <- length(stubh$label)
if (isTRUE(stub_available) && n_stubh_label > 0L) {
if (n_stubh_label != n_stub_cols) {
stub_labels <- c(stubh$label, rep("", n_stub_cols - 1L))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not so sure about this stuff. This happens when there is only one stub label, but multi stub columns, so using the label for the first and then empty for the others.

Should the cells merge instead ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Word I think:

  • if there’s one stubhead label, place it in the rightmost stub column (don’t merge cells)
  • if supplied stubhead labels equals number of stubhead columns (place them right-to-left from the input order)
  • lengths of stubhead labels not 1 or n stubhead columns should error

tldr: merging cells is probably difficult in Word so don’t worry about doing that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually it's not that big deal, the "span" cell here just needs a <w:gridSpan/> node in the cell properties:

Screenshot 2025-11-13 at 12 29 42
      <w:tcPr>
        <w:tcBorders>
          <w:top w:val="single" w:sz="16" w:space="0" w:color="D3D3D3"/>
          <w:bottom w:val="single" w:sz="16" w:space="0" w:color="D3D3D3"/>
          <w:end w:val="single" w:space="0" w:color="D3D3D3"/>
        </w:tcBorders>
        <w:gridSpan w:val="2"/>   <!-- HERE -->
      </w:tcPr>
      

I guess we can easily adjust that we had "one" instead of c("one", "two", "three") for stubh$label

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did eventually go in that direction, with tests to back it up. cc @shannonhaughton @thebioengineer

So for example the "one" stub label cell in this table (last test in test_that("multicolumn stub are supported")

Screenshot 2025-11-13 at 14 54 59

is made of these two tc with these tcPr:

      <w:tcPr>
        <w:tcBorders>
          <w:top w:val="single" w:sz="16" w:space="0" w:color="D3D3D3"/>
          <w:start w:val="single" w:space="0" w:color="D3D3D3"/>
          <w:end w:val="single" w:space="0" w:color="D3D3D3"/>
        </w:tcBorders>
        <w:vMerge w:val="restart"/>
        <w:vAlign w:val="bottom"/>
        <w:gridSpan w:val="3"/>
      </w:tcPr>

and

      <w:tcPr>
        <w:tcBorders>
          <w:bottom w:val="single" w:sz="16" w:space="0" w:color="D3D3D3"/>
          <w:start w:val="single" w:space="0" w:color="D3D3D3"/>
          <w:end w:val="single" w:space="0" w:color="D3D3D3"/>
        </w:tcBorders>
        <w:vMerge w:val="continue"/>
        <w:gridSpan w:val="3"/>
      </w:tcPr>

} else (
stub_labels <- stubh$label
)
headings_labels <- prepend_vec(headings_labels, stub_labels)
headings_vars <- prepend_vec(headings_vars, rep("::stub", n_stub_cols))
} else if (isTRUE(stub_available)) {

headings_labels <- prepend_vec(headings_labels, "")
headings_vars <- prepend_vec(headings_vars, "::stub")
headings_labels <- prepend_vec(headings_labels, rep("", n_stub_cols))
headings_vars <- prepend_vec(headings_vars, rep("::stub", n_stub_cols))
}

stubhead_label_alignment <- "left"
stubhead_label_alignment <- rep("left", n_stub_cols)

table_col_headings_list <- list()

Expand All @@ -1588,50 +1583,55 @@ create_columns_component_xml <- function(
# Create the cell for the stubhead label
if (stub_available) {

# If there are spanners, make the first row an empty cell that continues merge
# If there are spanners, make the first row
# empty cells that continues merge
if (spanner_row_count < 1) {

cell_style <-
styles_tbl[styles_tbl$locname %in% "stubhead", "styles", drop = TRUE]
cell_style <- cell_style[1][[1]]

table_cell_vals[[length(table_cell_vals) + 1]] <-
xml_table_cell(
content = headings_labels[1],
font = cell_style[["cell_text"]][["font"]],
size = cell_style[["cell_text"]][["size"]] %||% 20,
color = cell_style[["cell_text"]][["color"]],
style = cell_style[["cell_text"]][["style"]],
stretch = cell_style[["cell_text"]][["stretch"]],
whitespace = cell_style[["cell_text"]][["whitespace"]],
align = cell_style[["cell_text"]][["align"]] %||% stubhead_label_alignment,
v_align = cell_style[["cell_text"]][["v_align"]],
fill = cell_style[["cell_fill"]][["color"]],
border = list(
top = cell_border(size = 16, color = column_labels_border_top_color),
bottom = cell_border(size = 16, color = column_labels_border_bottom_color),
left = cell_border(color = column_labels_vlines_color),
right = cell_border(color = column_labels_vlines_color)
),
keep_with_next = keep_with_next
)
for (stub_id in seq_len(n_stub_cols)) {
table_cell_vals[[length(table_cell_vals) + 1]] <-
xml_table_cell(
content = headings_labels[stub_id],
font = cell_style[["cell_text"]][["font"]],
size = cell_style[["cell_text"]][["size"]] %||% 20,
color = cell_style[["cell_text"]][["color"]],
style = cell_style[["cell_text"]][["style"]],
stretch = cell_style[["cell_text"]][["stretch"]],
whitespace = cell_style[["cell_text"]][["whitespace"]],
align = cell_style[["cell_text"]][["align"]] %||% stubhead_label_alignment[stub_id],
v_align = cell_style[["cell_text"]][["v_align"]],
fill = cell_style[["cell_fill"]][["color"]],
border = list(
top = cell_border(size = 16, color = column_labels_border_top_color),
bottom = cell_border(size = 16, color = column_labels_border_bottom_color),
left = cell_border(color = column_labels_vlines_color),
right = cell_border(color = column_labels_vlines_color)
),
keep_with_next = keep_with_next
)
}

} else {

table_cell_vals[[length(table_cell_vals) + 1]] <-
xml_table_cell(
row_span = "continue",
border = list(
left = cell_border(color = column_labels_vlines_color),
right = cell_border(color = column_labels_vlines_color),
bottom = cell_border(size = 16, color = column_labels_border_bottom_color)
),
keep_with_next = TRUE
)
for (stub_id in seq_len(n_stub_cols)) {
table_cell_vals[[length(table_cell_vals) + 1]] <-
xml_table_cell(
row_span = "continue",
border = list(
left = cell_border(color = column_labels_vlines_color),
right = cell_border(color = column_labels_vlines_color),
bottom = cell_border(size = 16, color = column_labels_border_bottom_color)
),
keep_with_next = TRUE
)
}
}
}

for (i in seq_len(length(headings_vars) - stub_available)) {
for (i in seq_len(length(headings_vars) - n_stub_cols)) {

cell_style <-
vctrs::vec_slice(
Expand All @@ -1644,7 +1644,7 @@ create_columns_component_xml <- function(

table_cell_vals[[length(table_cell_vals) + 1]] <-
xml_table_cell(
content = headings_labels[i + stub_available],
content = headings_labels[i + n_stub_cols],
font = cell_style[["cell_text"]][["font"]],
size = cell_style[["cell_text"]][["size"]] %||% 20,
color = cell_style[["cell_text"]][["color"]],
Expand All @@ -1658,7 +1658,7 @@ create_columns_component_xml <- function(
top = if (!spanners_present) { cell_border(size = 16, color = column_labels_border_top_color) },
bottom = cell_border(size = 16, color = column_labels_border_bottom_color),
left = if (i == 1L) { cell_border(color = column_labels_vlines_color) },
right = if (i == length(headings_vars) - stub_available) { cell_border(color = column_labels_vlines_color) }
right = if (i == length(headings_vars) - n_stub_cols) { cell_border(color = column_labels_vlines_color) }
),
keep_with_next = keep_with_next
)
Expand Down Expand Up @@ -1702,7 +1702,7 @@ create_columns_component_xml <- function(

spanner_cell_vals <- list()

# Create the cell for the stubhead label
# Create the cell for the stubhead labels

if (stub_available) {

Expand All @@ -1712,40 +1712,44 @@ create_columns_component_xml <- function(
styles_tbl[styles_tbl$locname %in% "stubhead", "styles", drop = TRUE]
cell_style <- cell_style[1][[1]]

spanner_cell_vals[[length(spanner_cell_vals) + 1]] <-
xml_table_cell(
content = headings_labels[1],
font = cell_style[["cell_text"]][["font"]] %||% "Calibri",
size = cell_style[["cell_text"]][["size"]] %||% 20,
color = cell_style[["cell_text"]][["color"]],
style = cell_style[["cell_text"]][["style"]],
weight = cell_style[["cell_text"]][["weight"]],
stretch = cell_style[["cell_text"]][["stretch"]],
whitespace = cell_style[["cell_text"]][["whitespace"]],
align = cell_style[["cell_text"]][["align"]] %||% stubhead_label_alignment,
v_align = cell_style[["cell_text"]][["v_align"]] %||% "bottom",
fill = cell_style[["cell_fill"]][["color"]],
row_span = "start",
border = list(
top = cell_border(size = 16, color = column_labels_border_top_color),
left = cell_border(color = column_labels_vlines_color),
right = cell_border(color = column_labels_vlines_color)
),
keep_with_next = TRUE
)
for (stub_id in seq_len(n_stub_cols)) {
spanner_cell_vals[[length(spanner_cell_vals) + 1]] <-
xml_table_cell(
content = headings_labels[stub_id],
font = cell_style[["cell_text"]][["font"]] %||% "Calibri",
size = cell_style[["cell_text"]][["size"]] %||% 20,
color = cell_style[["cell_text"]][["color"]],
style = cell_style[["cell_text"]][["style"]],
weight = cell_style[["cell_text"]][["weight"]],
stretch = cell_style[["cell_text"]][["stretch"]],
whitespace = cell_style[["cell_text"]][["whitespace"]],
align = cell_style[["cell_text"]][["align"]] %||% stubhead_label_alignment[stub_id],
v_align = cell_style[["cell_text"]][["v_align"]] %||% "bottom",
fill = cell_style[["cell_fill"]][["color"]],
row_span = "start",
border = list(
top = cell_border(size = 16, color = column_labels_border_top_color),
left = cell_border(color = column_labels_vlines_color),
right = cell_border(color = column_labels_vlines_color)
),
keep_with_next = TRUE
)
}

} else {

spanner_cell_vals[[length(spanner_cell_vals) + 1]] <-
xml_table_cell(
row_span = "continue",
border = list(
left = cell_border(color = column_labels_vlines_color),
right = cell_border(color = column_labels_vlines_color),
bottom = if (span_row_idx == nrow(spanners)) { cell_border(size = 16, color = column_labels_border_bottom_color) }
),
keep_with_next = TRUE
)
for (stub_id in seq_len(n_stub_cols)) {
spanner_cell_vals[[length(spanner_cell_vals) + 1]] <-
xml_table_cell(
row_span = "continue",
border = list(
left = cell_border(color = column_labels_vlines_color),
right = cell_border(color = column_labels_vlines_color),
bottom = if (span_row_idx == nrow(spanners)) { cell_border(size = 16, color = column_labels_border_bottom_color) }
),
keep_with_next = TRUE
)
}
}
}

Expand Down Expand Up @@ -1855,6 +1859,7 @@ create_body_component_xml <- function(
list_of_summaries <- dt_summary_df_get(data = data)
groups_rows_df <- dt_groups_rows_get(data = data)
stub_components <- dt_stub_components(data = data)
hierarchical_stub_info <- calculate_hierarchical_stub_rowspans(data)

# Get table styles
styles_tbl <- dt_styles_get(data = data)
Expand All @@ -1879,6 +1884,7 @@ create_body_component_xml <- function(
# Determine whether the stub is available through analysis
# of the `stub_components`
stub_available <- dt_stub_components_has_rowname(stub_components) || summaries_present
n_stub_cols <- length(dt_boxhead_get_var_by_type(data, type = "stub"))

# Obtain all of the visible (`"default"`), non-stub
# column names for the table
Expand All @@ -1889,10 +1895,8 @@ create_body_component_xml <- function(
alignment <- col_alignment

if (stub_available) {

n_cols <- n_data_cols + 1

alignment <- c("left", alignment)
n_cols <- n_data_cols + n_stub_cols
alignment <- c(rep("left", n_stub_cols), alignment)

stub_var <- dt_boxhead_get_var_stub(data = data)
all_stub_vals <- as.matrix(body[, stub_var])
Expand All @@ -1908,7 +1912,7 @@ create_body_component_xml <- function(
default_vals <- all_default_vals[i, ]

if (stub_available) {
default_vals <- c(all_stub_vals[i], default_vals)
default_vals <- c(all_stub_vals[i, ], default_vals)
}

default_vals
Expand Down Expand Up @@ -1999,8 +2003,16 @@ create_body_component_xml <- function(
row_vec <- output_df_row_as_vec(i)

for (y in seq_along(row_vec)) {
row_span <- if (!is.null(hierarchical_stub_info) && y <= length(hierarchical_stub_info)) {
info <- hierarchical_stub_info[[y]]
if (info$rowspans[row_idx] > 1) {
"start"
} else if (!info$display_mask[i]){
"continue"
}
}

style_col_idx <- ifelse(stub_available, y - 1, y)
style_col_idx <- ifelse(stub_available, y - n_stub_cols, y)

cell_style <-
vctrs::vec_slice(
Expand Down Expand Up @@ -2030,7 +2042,8 @@ create_body_component_xml <- function(
right = cell_border(color = table_body_vlines_color)
),
fill = cell_style[["cell_fill"]][["color"]],
keep_with_next = keep_with_next
keep_with_next = keep_with_next,
row_span = row_span
)
}

Expand Down
32 changes: 32 additions & 0 deletions tests/testthat/test-as_word.R
Original file line number Diff line number Diff line change
Expand Up @@ -2715,3 +2715,35 @@ test_that("sub_small_vals() and sub_large_vals() are properly encoded", {

expect_snapshot_word(tbl)
})

test_that("multicolumn stub are supported", {
test_data <- dplyr::tibble(
mfr = c("Ford", "Ford", "BMW", "BMW", "Audi"),
model = c("GT", "F-150", "X5", "X3", "A4"),
trim = c("Base", "XLT", "xDrive35i", "sDrive28i", "Premium"),
year = c(2017, 2018, 2019, 2020, 2021),
hp = c(647, 450, 300, 228, 261),
msrp = c(447000, 28000, 57000, 34000, 37000)
)

# Three-column stub
triple_stub <- gt(test_data, rowname_col = c("mfr", "model", "trim"))

# The merge cells on the first column
xml <- read_xml(as_word(triple_stub))
nodes_Ford <- xml_find_all(xml, ".//w:t[. = 'Ford']")
expect_equal(xml_attr(xml_find_all(nodes_Ford[[1]], "../../..//w:vMerge"), "val"), "restart")
expect_equal(xml_attr(xml_find_all(nodes_Ford[[2]], "../../..//w:vMerge"), "val"), "continue")

nodes_BMW <- xml_find_all(xml, ".//w:t[. = 'BMW']")
expect_equal(xml_attr(xml_find_all(nodes_BMW[[1]], "../../..//w:vMerge"), "val"), "restart")
expect_equal(xml_attr(xml_find_all(nodes_BMW[[2]], "../../..//w:vMerge"), "val"), "continue")

nodes_Audi <- xml_find_all(xml, ".//w:t[. = 'Audi']")
expect_equal(xml_length(xml_find_all(nodes_Audi[[1]], "../../..//w:vMerge")), 0)

# no other merge cells
expect_equal(xml_length(xml_find_all(xml, ".//w:vMerge")), 4)
})