From 9d2c2f42a53df474aaf9a3fb44d9be33c6fcbdf7 Mon Sep 17 00:00:00 2001 From: Davide Garolini <11279768+Melkiades@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:18:03 +0000 Subject: [PATCH 1/3] Remove vertical header frame in theme_gtsummary_roche border_outer(part = "header") drew a box around the title/study/parameter header block and left an inconsistent missing right border on the last header row. Replace it with hline_top() + hline_bottom() so the header keeps horizontal rules only, matching TLG conventions. Regenerates the theme command snapshot accordingly. Closes #272 --- NEWS.md | 2 ++ R/theme_gtsummary_roche.R | 7 ++++++- tests/testthat/_snaps/theme_gtsummary_roche.md | 18 +++++++++++------- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/NEWS.md b/NEWS.md index 0a52b139b..cbe3b0335 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # crane 0.3.3.9001 +* `theme_gtsummary_roche()` no longer draws a vertical frame around the flextable header; only horizontal rules are kept. This removes the box around the title/study/parameter block and the inconsistent missing right border. (#272) + * Fixed minor typo in the DESCRIPTION file. * `tbl_hierarchical_incidence_rate()` gains an `overall_row` argument to control whether the overall summary row is included. (#264) diff --git a/R/theme_gtsummary_roche.R b/R/theme_gtsummary_roche.R index 1bbd04d5a..f7b912850 100644 --- a/R/theme_gtsummary_roche.R +++ b/R/theme_gtsummary_roche.R @@ -96,7 +96,12 @@ theme_gtsummary_roche <- function(font_size = NULL, ), valign = list( # valign only because it will append to to last commands rlang::expr(flextable::fontsize(size = !!((font_size %||% 8) - 1), part = "footer")), # second fontsize spectbl - rlang::expr(flextable::border_outer(part = "header", border = !!border)), # second command from border + # Horizontal rules only on the header: a top and bottom rule plus the + # inner horizontal rules between header rows. Avoid border_outer() here + # so no vertical frame is drawn around the title/study/parameter block + # (which also left an inconsistent missing right border). + rlang::expr(flextable::hline_top(part = "header", border = !!border)), + rlang::expr(flextable::hline_bottom(part = "header", border = !!border)), rlang::expr(flextable::border_inner_h(part = "header", border = !!border)), # fix different line sizes rlang::expr(flextable::valign(valign = "top", part = "all")), rlang::expr(flextable::font(fontname = "Arial", part = "all")), diff --git a/tests/testthat/_snaps/theme_gtsummary_roche.md b/tests/testthat/_snaps/theme_gtsummary_roche.md index 8392e7167..64a8f3fd9 100644 --- a/tests/testthat/_snaps/theme_gtsummary_roche.md +++ b/tests/testthat/_snaps/theme_gtsummary_roche.md @@ -26,29 +26,33 @@ flextable::fontsize(size = 7, part = "footer") $user_added3[[2]] - flextable::border_outer(part = "header", border = list(width = 0.5, + flextable::hline_top(part = "header", border = list(width = 0.5, color = "#666666", style = "solid")) $user_added3[[3]] - flextable::border_inner_h(part = "header", border = list(width = 0.5, + flextable::hline_bottom(part = "header", border = list(width = 0.5, color = "#666666", style = "solid")) $user_added3[[4]] - flextable::valign(valign = "top", part = "all") + flextable::border_inner_h(part = "header", border = list(width = 0.5, + color = "#666666", style = "solid")) $user_added3[[5]] - flextable::font(fontname = "Arial", part = "all") + flextable::valign(valign = "top", part = "all") $user_added3[[6]] - flextable::padding(padding.top = 0, part = "all") + flextable::font(fontname = "Arial", part = "all") $user_added3[[7]] - flextable::padding(padding.bottom = 0, part = "all") + flextable::padding(padding.top = 0, part = "all") $user_added3[[8]] - flextable::line_spacing(space = 1, part = "all") + flextable::padding(padding.bottom = 0, part = "all") $user_added3[[9]] + flextable::line_spacing(space = 1, part = "all") + + $user_added3[[10]] flextable::set_table_properties(layout = "autofit") From fe7418eb4b21e0f9a04a2d667d9d1b85927cc42c Mon Sep 17 00:00:00 2001 From: Davide Garolini <11279768+Melkiades@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:21:26 +0000 Subject: [PATCH 2/3] Frame column labels with outer border only Address review: column labels should have an outer border (a single box) with no internal borders between header rows. Clear the inner horizontal header borders and apply border_outer(part = header) instead of the top/bottom rules. --- NEWS.md | 2 +- R/theme_gtsummary_roche.R | 14 +++++++------- .../testthat/_snaps/theme_gtsummary_roche.md | 19 +++++++------------ tests/testthat/test-theme_gtsummary_roche.R | 12 ++++++++++-- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/NEWS.md b/NEWS.md index cbe3b0335..7d761e48f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,6 @@ # crane 0.3.3.9001 -* `theme_gtsummary_roche()` no longer draws a vertical frame around the flextable header; only horizontal rules are kept. This removes the box around the title/study/parameter block and the inconsistent missing right border. (#272) +* `theme_gtsummary_roche()` now frames the flextable column labels with an outer border only, removing the internal borders between header rows and the inconsistent missing right border. (#272) * Fixed minor typo in the DESCRIPTION file. diff --git a/R/theme_gtsummary_roche.R b/R/theme_gtsummary_roche.R index f7b912850..1741af600 100644 --- a/R/theme_gtsummary_roche.R +++ b/R/theme_gtsummary_roche.R @@ -96,13 +96,13 @@ theme_gtsummary_roche <- function(font_size = NULL, ), valign = list( # valign only because it will append to to last commands rlang::expr(flextable::fontsize(size = !!((font_size %||% 8) - 1), part = "footer")), # second fontsize spectbl - # Horizontal rules only on the header: a top and bottom rule plus the - # inner horizontal rules between header rows. Avoid border_outer() here - # so no vertical frame is drawn around the title/study/parameter block - # (which also left an inconsistent missing right border). - rlang::expr(flextable::hline_top(part = "header", border = !!border)), - rlang::expr(flextable::hline_bottom(part = "header", border = !!border)), - rlang::expr(flextable::border_inner_h(part = "header", border = !!border)), # fix different line sizes + # Column labels get an outer border only: clear any internal borders + # between header rows, then frame the block with a single outer box. + rlang::expr(flextable::border_inner_h( + part = "header", + border = flextable::fp_border_default(width = 0) + )), + rlang::expr(flextable::border_outer(part = "header", border = !!border)), rlang::expr(flextable::valign(valign = "top", part = "all")), rlang::expr(flextable::font(fontname = "Arial", part = "all")), rlang::expr(flextable::padding(padding.top = 0, part = "all")), diff --git a/tests/testthat/_snaps/theme_gtsummary_roche.md b/tests/testthat/_snaps/theme_gtsummary_roche.md index 64a8f3fd9..17cee0d0b 100644 --- a/tests/testthat/_snaps/theme_gtsummary_roche.md +++ b/tests/testthat/_snaps/theme_gtsummary_roche.md @@ -26,33 +26,28 @@ flextable::fontsize(size = 7, part = "footer") $user_added3[[2]] - flextable::hline_top(part = "header", border = list(width = 0.5, - color = "#666666", style = "solid")) + flextable::border_inner_h(part = "header", border = flextable::fp_border_default(width = 0)) $user_added3[[3]] - flextable::hline_bottom(part = "header", border = list(width = 0.5, + flextable::border_outer(part = "header", border = list(width = 0.5, color = "#666666", style = "solid")) $user_added3[[4]] - flextable::border_inner_h(part = "header", border = list(width = 0.5, - color = "#666666", style = "solid")) - - $user_added3[[5]] flextable::valign(valign = "top", part = "all") - $user_added3[[6]] + $user_added3[[5]] flextable::font(fontname = "Arial", part = "all") - $user_added3[[7]] + $user_added3[[6]] flextable::padding(padding.top = 0, part = "all") - $user_added3[[8]] + $user_added3[[7]] flextable::padding(padding.bottom = 0, part = "all") - $user_added3[[9]] + $user_added3[[8]] flextable::line_spacing(space = 1, part = "all") - $user_added3[[10]] + $user_added3[[9]] flextable::set_table_properties(layout = "autofit") diff --git a/tests/testthat/test-theme_gtsummary_roche.R b/tests/testthat/test-theme_gtsummary_roche.R index 3a0f9a3b2..0e9f100d3 100644 --- a/tests/testthat/test-theme_gtsummary_roche.R +++ b/tests/testthat/test-theme_gtsummary_roche.R @@ -119,8 +119,16 @@ test_that("theme pre-conversion modifies header not to be bold and border only 0 # check no bold syntax in header expect_true(all(!grepl(tbl$header$dataset[1, -1], pattern = "\\*"))) - # check border width is 0.5 - expect_true(all(tbl$header$styles$cells$border.width.bottom$data == 0.5)) + # Column labels are framed with an outer border only (width 0.5), so the + # outer edges carry the border while inner edges between header rows are 0. + n_hdr <- nrow(tbl$header$dataset) + bottom <- tbl$header$styles$cells$border.width.bottom$data + top <- tbl$header$styles$cells$border.width.top$data + expect_true(all(top[1, ] == 0.5)) # top of the block + expect_true(all(bottom[n_hdr, ] == 0.5)) # bottom of the block + if (n_hdr > 1) { + expect_true(all(bottom[-n_hdr, ] == 0)) # no internal horizontal borders + } }) test_that("theme pre-conversion protects stat columns with non-breaking spaces", { From 6bcb4ec7c4628c4658be8b7b99800ead3edc370e Mon Sep 17 00:00:00 2001 From: Davide Garolini <11279768+Melkiades@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:18:44 +0000 Subject: [PATCH 3/3] Use style none for internal header borders border_inner_h() cleared the internal header borders with a width-0 solid border, but the docx writer still emits a visible single line for a solid border regardless of width. With multi-level headers this drew a rule between the spanner row and the column labels. Switch to fp_border(width = 0, style = none) so the line is suppressed in docx while the outer frame is kept. Regenerates the theme command snapshot accordingly. --- R/theme_gtsummary_roche.R | 6 +-- .../testthat/_snaps/theme_gtsummary_roche.md | 4 +- tests/testthat/test-theme_gtsummary_roche.R | 44 +++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/R/theme_gtsummary_roche.R b/R/theme_gtsummary_roche.R index 1741af600..9a6c767d7 100644 --- a/R/theme_gtsummary_roche.R +++ b/R/theme_gtsummary_roche.R @@ -96,11 +96,11 @@ theme_gtsummary_roche <- function(font_size = NULL, ), valign = list( # valign only because it will append to to last commands rlang::expr(flextable::fontsize(size = !!((font_size %||% 8) - 1), part = "footer")), # second fontsize spectbl - # Column labels get an outer border only: clear any internal borders - # between header rows, then frame the block with a single outer box. + # outer box only. style = "none" (not width = 0) so docx doesn't draw + # a line between a spanner and the column labels. rlang::expr(flextable::border_inner_h( part = "header", - border = flextable::fp_border_default(width = 0) + border = officer::fp_border(width = 0, style = "none") )), rlang::expr(flextable::border_outer(part = "header", border = !!border)), rlang::expr(flextable::valign(valign = "top", part = "all")), diff --git a/tests/testthat/_snaps/theme_gtsummary_roche.md b/tests/testthat/_snaps/theme_gtsummary_roche.md index 17cee0d0b..45ec25003 100644 --- a/tests/testthat/_snaps/theme_gtsummary_roche.md +++ b/tests/testthat/_snaps/theme_gtsummary_roche.md @@ -26,7 +26,8 @@ flextable::fontsize(size = 7, part = "footer") $user_added3[[2]] - flextable::border_inner_h(part = "header", border = flextable::fp_border_default(width = 0)) + flextable::border_inner_h(part = "header", border = officer::fp_border(width = 0, + style = "none")) $user_added3[[3]] flextable::border_outer(part = "header", border = list(width = 0.5, @@ -51,4 +52,3 @@ flextable::set_table_properties(layout = "autofit") - diff --git a/tests/testthat/test-theme_gtsummary_roche.R b/tests/testthat/test-theme_gtsummary_roche.R index 0e9f100d3..423c6708e 100644 --- a/tests/testthat/test-theme_gtsummary_roche.R +++ b/tests/testthat/test-theme_gtsummary_roche.R @@ -128,9 +128,53 @@ test_that("theme pre-conversion modifies header not to be bold and border only 0 expect_true(all(bottom[n_hdr, ] == 0.5)) # bottom of the block if (n_hdr > 1) { expect_true(all(bottom[-n_hdr, ] == 0)) # no internal horizontal borders + # The internal border must also be style "none": a width-0 solid border is + # still written as a visible single line in docx (regression with spanners). + style_bottom <- tbl$header$styles$cells$border.style.bottom$data + expect_true(all(style_bottom[-n_hdr, ] == "none")) } }) +test_that("theme draws no internal horizontal line between spanner and column labels in docx", { + skip_if_not_installed("officer") + skip_if_not_installed("flextable") + + tbl <- with_gtsummary_theme( + x = theme_gtsummary_roche(), + { + t1 <- gtsummary::trial |> gtsummary::tbl_summary(by = trt, include = age) + t2 <- gtsummary::trial |> gtsummary::tbl_summary(by = trt, include = grade) + gtsummary::tbl_merge( + list(t1, t2), + tab_spanner = c("**Group A**", "**Group B**") + ) |> + gtsummary::as_flex_table() + } + ) + + f <- withr::local_tempfile(fileext = ".docx") + flextable::save_as_docx(tbl, path = f) + d <- withr::local_tempdir() + utils::unzip(f, exdir = d) + xml <- paste(readLines(file.path(d, "word", "document.xml"), warn = FALSE), collapse = "") + rows <- regmatches(xml, gregexpr("", xml))[[1]] + + spanner <- rows[grepl("Group A", rows)][1] + labels <- rows[grepl("Characteristic", rows)][1] + + border_val <- function(row, side) { + m <- regmatches(row, regexpr(sprintf('