Skip to content

Commit 1b8295b

Browse files
billdenneyclaude
andauthored
Phase 5: merged cells (#125)
* Add sheet tab state with cross-sheet validation xl_sheet() gains active, selected, visible and first_tab, reaching worksheet_activate, worksheet_select, worksheet_hide and worksheet_set_first_sheet. These are the first worksheet settings whose rules span the whole workbook, so unlike everything else in the sheet plan they cannot be checked one sheet at a time. Excel requires that a hidden sheet is neither active nor selected, that at most one sheet is active, that the first sheet is not hidden unless another is made active, and that at least one sheet stays visible. libxlsxwriter enforces none of them -- hide, activate and select all return void and simply set a flag -- so a bad combination would produce a workbook Excel cannot open, with no diagnostic. .resolve_sheet_visibility() checks all four rules in write_xlsx(), where every sheet is in scope, and names the sheet at fault. Plain data frames carry no tab settings and are treated as unset throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Add hide_zero, right_to_left, selection and top_left xl_sheet() gains four more view settings, reaching worksheet_hide_zero, worksheet_right_to_left, worksheet_set_selection and worksheet_set_top_left_cell. selection and top_left go through the shared range resolver, so they take an Excel reference or a list(rows =, cols =) spec like every other range argument. Excel encodes which cell of a selection is active by the order its corners are given, so worksheet_set_selection(6, 6, 3, 3) means "G7 to D4". writexl does not expose that: the shared resolver rejects an inverted range, which is right for every other caller, and one strict range parser is worth more than a niche capability. The active cell is therefore always the range's top-left corner, which is documented and covered by a test that pins the error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Add split panes, converting from a cell reference worksheet_split_panes() does not take a row and a column. Both arguments are distances, in the units Excel uses for row height and column width, and the two units differ from each other -- 15 means one default row, 8.43 one default column. Passing 1 for "one row" would put the split a fifteenth of the way down the first row, silently. xl_sheet(split =) therefore takes a cell reference, as freeze does, and converts: the distance above the split is the summed height of the rows before it, the distance to its left the summed width of the columns before it. The sums use the sheet's real geometry -- header row height, xl_row_spec() heights, xl_col_spec() widths and auto_colwidth results -- so a split lands where it was asked for on a sheet whose rows or columns have been resized, rather than only on a default one. Tests pin that sensitivity in both directions. list(vertical =, horizontal =) still accepts the raw units. split and freeze together is an error, since Excel supports one or the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Document sheet view and navigation in the vignette and NEWS Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Add merged cells xl_merge() merges a rectangle of cells into one; xl_sheet(merge =) takes one or a list of them. worksheet_merge_range() does more than record the merge: it writes the top-left cell's text itself and blanks the rest of the range with the same format. So a merge carries its own text rather than reusing whatever the data frame put there, and it also settles when merges run. They are applied after the sheet's rows, which makes a merge over filled cells keep only the merged value -- exactly what merging does in Excel. Applying them first would instead have the row loop overwrite the merge's own blanks. Writing back over emitted rows is not possible while libxlsxwriter streams rows to disk, so any merge turns constant memory off. This is the second real trigger for the resolver added in Phase 0. Excel has no single-cell merge and libxlsxwriter returns a bare parameter-validation error for one, so that case is caught in R where the message can name the merge. Note this contradicts the roadmap's stated reason for needing the memory flip: worksheet_merge_range() has no optimize guard and streams correctly on its own. The flip is needed because of when writexl applies merges, not because merging cannot stream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 1b4d90a commit 1b8295b

27 files changed

Lines changed: 402 additions & 8 deletions

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ S3method(length,xl_cell_general)
99
S3method(print,xl_cell_general)
1010
S3method(print,xl_comment)
1111
S3method(print,xl_format)
12+
S3method(print,xl_merge)
1213
S3method(print,xl_page_setup)
1314
S3method(print,xl_properties)
1415
S3method(print,xl_rich_run)
@@ -33,6 +34,7 @@ export(xl_format)
3334
export(xl_formula)
3435
export(xl_hyperlink)
3536
export(xl_hyperlink_cell)
37+
export(xl_merge)
3638
export(xl_num_format)
3739
export(xl_page_setup)
3840
export(xl_properties)

NEWS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
sheet, no hidden-and-active sheet, at least one visible sheet) are checked
3737
before writing, naming the sheet at fault.
3838

39+
* **Merged cells** via `xl_sheet(merge = xl_merge(...))`. A merged range holds
40+
one value, so `xl_merge()` carries its own text; merging over cells the data
41+
frame filled keeps only the merged text, as it does in Excel.
42+
3943
* `xl_properties(hyperlink_format = NULL)` writes hyperlinks with no styling at
4044
all, which was previously impossible.
4145

R/write_xlsx.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ write_xlsx <- function(x, path = tempfile(fileext = ".xlsx"), col_names = TRUE,
6868
header_offset = header_offset)
6969
sheets <- Map(function(el, df) .resolve_sheet_plan(el, df, reg, header_offset, props),
7070
elems, dfs)
71-
cm <- .resolve_constant_memory(dfs, props)
71+
cm <- .resolve_constant_memory(dfs, props, sheets)
7272
ret <- .Call(C_write_data_frame_list, dfs, path, col_names, format_headers,
7373
use_zip64, reg$table, sheets, header_id,
7474
.properties_payload(props, cm$on))

R/xl_merge.R

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# =============================================================================
2+
# Merged cells
3+
# =============================================================================
4+
#
5+
# worksheet_merge_range() does more than record a merge: it writes the top-left
6+
# cell's text itself and blanks the rest of the range with the same format. A
7+
# merge therefore carries its own content, which is why xl_merge() takes `text`
8+
# rather than reusing whatever the data frame put there.
9+
#
10+
# That also settles when merges are applied. They run *after* the sheet's rows
11+
# have been written, so a merge over cells the data frame filled discards all
12+
# but the top-left value -- which is exactly what merging does in Excel.
13+
# Applying them before the rows would instead have the row loop overwrite the
14+
# merge's own blanks.
15+
#
16+
# Writing to already-written rows is not possible while libxlsxwriter is
17+
# streaming rows to disk, so any merge turns constant memory off; see
18+
# .resolve_constant_memory().
19+
# -----------------------------------------------------------------------------
20+
21+
#' Merge a range of cells
22+
#'
23+
#' @description
24+
#' `xl_merge()` merges a rectangle of cells into one, as Excel's "Merge and
25+
#' Centre" does. Pass one or a list of them as `xl_sheet(merge = )`.
26+
#'
27+
#' A merged range holds a single value, so `xl_merge()` carries its own `text`
28+
#' rather than taking it from the data frame. Merging over cells the data frame
29+
#' filled keeps only the merged text, exactly as merging in Excel discards
30+
#' everything but the top-left value.
31+
#'
32+
#' @param range The cells to merge: an Excel range string such as `"A1:C1"`, or
33+
#' a `list(rows = , cols = )` spec. It must cover more than one cell ---
34+
#' Excel has no single-cell merge.
35+
#' @param text The text shown in the merged cell. `NULL` leaves it empty.
36+
#' @param format An optional [xl_format] applied to the whole merged range.
37+
#' Merged cells usually want `xl_align(horizontal = "center")`.
38+
#' @return An `xl_merge` object.
39+
#' @family writexl
40+
#' @seealso [xl_sheet], [xl_format]
41+
#' @export
42+
#' @examples
43+
#' xl_merge("A1:C1", "Quarterly results",
44+
#' format = xl_align(horizontal = "center") + xl_font(bold = TRUE))
45+
#'
46+
#' df <- data.frame(a = 1:3, b = 4:6)
47+
#' sheet <- xl_sheet(df, merge = xl_merge("A5:B5", "Total",
48+
#' format = xl_font(bold = TRUE)))
49+
#' tmp <- write_xlsx(list(Data = sheet))
50+
xl_merge <- function(range, text = NULL, format = NULL) {
51+
if (missing(range) || is.null(range))
52+
stop("`range` must name the cells to merge", call. = FALSE)
53+
if (!is.null(text) &&
54+
(!is.character(text) || length(text) != 1L || is.na(text)))
55+
stop("`text` must be a single non-NA string, or NULL", call. = FALSE)
56+
if (!is.null(format) && !is_xl_format(format))
57+
stop("`format` must be an xl_format object", call. = FALSE)
58+
structure(list(range = range, text = text, format = format),
59+
class = "xl_merge")
60+
}
61+
62+
#' @export
63+
print.xl_merge <- function(x, ...) {
64+
cat(sprintf("<xl_merge: %s%s>\n",
65+
if (is.character(x$range)) x$range else "<spec>",
66+
if (is.null(x$text)) "" else sprintf(" %s",
67+
encodeString(x$text, quote = '"'))))
68+
invisible(x)
69+
}
70+
71+
# Resolve a sheet's merges to the payloads C applies, registering each format.
72+
.resolve_merges <- function(el, df, reg, header_offset, props) {
73+
if (!inherits(el, "xl_sheet") || is.null(el$merge)) return(list())
74+
ms <- if (inherits(el$merge, "xl_merge")) list(el$merge) else el$merge
75+
if (!is.list(ms))
76+
stop("`merge` must be an xl_merge object or a list of them", call. = FALSE)
77+
lapply(seq_along(ms), function(i) {
78+
m <- ms[[i]]
79+
if (!inherits(m, "xl_merge"))
80+
stop(sprintf("`merge[[%d]]` must be an xl_merge object", i), call. = FALSE)
81+
arg <- sprintf("merge[[%d]] range", i)
82+
q <- .xl_resolve_range(m$range, arg = arg, df = df,
83+
header_offset = header_offset, allow_cell = FALSE)
84+
# libxlsxwriter refuses a single-cell merge, and so does Excel; catch it
85+
# here so the message names the merge rather than coming back as a bare
86+
# parameter-validation error
87+
if (q[1L] == q[3L] && q[2L] == q[4L])
88+
stop(sprintf("`%s` covers a single cell; a merge needs more than one",
89+
arg), call. = FALSE)
90+
list(kind = "merge", range = as.integer(q),
91+
text = if (is.null(m$text)) NA_character_ else m$text,
92+
format_id = .register_format(reg,
93+
merge_xl_format(props$default_format,
94+
m$format)))
95+
})
96+
}

R/xl_sheet.R

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ xl_row_spec <- function(rows, height = NA, hidden = NA, level = NA,
162162
#' the shared range parser, which rejects an inverted range.
163163
#' @param top_left The cell scrolled to the top-left of the window when the
164164
#' sheet opens, as an Excel reference such as `"A5"`.
165+
#' @param merge One [xl_merge()], or a list of them, merging rectangles of cells
166+
#' into single cells. Merges are applied after the sheet's rows are written,
167+
#' so a merge over cells the data frame filled keeps only the merged text ---
168+
#' as merging in Excel does. Any merge turns off the memory-efficient
169+
#' row-streaming mode, since it writes back over rows already emitted.
165170
#' @param split Split the sheet into scrollable panes with a visible, movable
166171
#' divider, given as the cell reference the split sits above and to the left
167172
#' of --- `"B3"` splits above row 3 and left of column B. Mutually exclusive
@@ -200,7 +205,8 @@ xl_sheet <- function(data, cols = NULL, rows = NULL, freeze = NULL,
200205
comment_author = NA, show_comments = FALSE,
201206
page = NULL, active = NA, selected = NA, visible = NA,
202207
first_tab = NA, hide_zero = NA, right_to_left = NA,
203-
selection = NULL, top_left = NULL, split = NULL) {
208+
selection = NULL, top_left = NULL, split = NULL,
209+
merge = NULL) {
204210
if (!is.data.frame(data))
205211
stop("`data` must be a data frame", call. = FALSE)
206212
if (!is.logical(auto_colwidth) || length(auto_colwidth) != 1L || is.na(auto_colwidth))
@@ -236,7 +242,8 @@ xl_sheet <- function(data, cols = NULL, rows = NULL, freeze = NULL,
236242
right_to_left = .val_flag(right_to_left, "right_to_left"),
237243
selection = selection,
238244
top_left = top_left,
239-
split = split
245+
split = split,
246+
merge = merge
240247
),
241248
class = "xl_sheet"
242249
)
@@ -500,6 +507,8 @@ print.xl_sheet <- function(x, ...) {
500507
col_width)
501508
}
502509

510+
merges <- .resolve_merges(el, df, reg, header_offset, props)
511+
503512
col_format_id <- vapply(col_fmt, function(f) .register_format(reg, f), integer(1))
504513

505514
list(
@@ -515,6 +524,7 @@ print.xl_sheet <- function(x, ...) {
515524
comment_author = as.character(comment_author),
516525
show_comments = as.integer(show_comments),
517526
page = page_payload,
518-
view = if (length(view)) view else NULL
527+
view = if (length(view)) view else NULL,
528+
merges = if (length(merges)) merges else NULL
519529
)
520530
}

R/xl_workbook.R

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,18 +188,26 @@ print.xl_workbook <- function(x, ...) {
188188
# Resolve the constant-memory flag for a workbook, from the sheets it is about
189189
# to write. Returns the C-side integer flag plus the reasons (if any) the mode
190190
# had to be turned off.
191-
.resolve_constant_memory <- function(dfs, props) {
191+
.resolve_constant_memory <- function(dfs, props, sheets = NULL) {
192192
reasons <- character(0)
193193
# A multi-cell array formula range is padded by libxlsxwriter, and it skips
194194
# that padding entirely when row streaming is on -- silently, returning
195195
# success -- so the range would be left half-written. Collected per sheet by
196196
# .resolve_sheet_formats().
197197
n_array <- sum(vapply(dfs, function(df)
198198
length(attr(df, "writexl_array_multicell")), integer(1)))
199+
n_merge <- if (is.null(sheets)) 0L else
200+
sum(vapply(sheets, function(s) length(s$merges), integer(1)))
199201
if (n_array > 0L)
200202
reasons <- c(reasons, sprintf(
201203
paste0("%d multi-cell array formula range(s): libxlsxwriter only pads an ",
202204
"array range when row streaming is off"), n_array))
205+
# Merges are applied after the sheet's rows so that Excel's own semantics
206+
# hold, which means writing back over rows already flushed to disk.
207+
if (n_merge > 0L)
208+
reasons <- c(reasons, sprintf(
209+
paste0("%d merged range(s): merges are applied after the rows are ",
210+
"written, which row streaming does not allow"), n_merge))
203211
list(on = as.integer(!length(reasons)), reasons = reasons)
204212
}
205213

man/is_xl_comment.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/is_xl_format.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/xl_cell_general.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/xl_color.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)