Skip to content

Commit 88a7114

Browse files
committed
Merge origin/master into the charts branch
Brings in the constant_memory decision procedure (#135). One conflict, in R/write_xlsx.R: both sides inserted at the same point in the roxygen block, master adding the constant_memory and constant_memory_threshold @PARAM entries and this branch adding @family workbook settings. Both are wanted, so both are kept, with the params before the family tag as in every other file. Checked rather than assumed: all 28 @family tags survive with their feature groupings, none reverted to "writexl", and the merged files still carry the constant_memory and allow_sheet work from master.
2 parents b912389 + f314564 commit 88a7114

13 files changed

Lines changed: 298 additions & 27 deletions

R/write_xlsx.R

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,29 @@
2828
#' \code{\link{xl_properties}(header_format = )}.
2929
#' @param use_zip64 use \href{https://en.wikipedia.org/wiki/Zip_(file_format)#ZIP64}{zip64}
3030
#' to enable support for 4GB+ xlsx files. Not all platforms can read this.
31+
#' @param constant_memory stream rows to disk instead of building the whole
32+
#' workbook in memory. `NA` (the default) decides per workbook: on for large
33+
#' data, off for small, and always off when a feature needs it off. `TRUE`
34+
#' forces it on for a workbook that would otherwise be judged too small;
35+
#' `FALSE` forces it off. Features that cannot be written while streaming ---
36+
#' merged ranges, tables, embedded images and multi-cell array formulas ---
37+
#' turn it off regardless, with a warning if `TRUE` was asked for, because
38+
#' the alternative is a file that opens cleanly and is missing cells.
39+
#' @param constant_memory_threshold how much extra memory not streaming would
40+
#' have to cost, in bytes, before streaming is worth it. Default 128 MiB.
41+
#' The cost is *estimated* from the number of cells in the workbook, using a
42+
#' fixed per-cell figure calibrated against a range of data; the true cost
43+
#' varies with the data, and is lowest for text that repeats. Streaming saves
44+
#' memory but produces slightly larger files, so it is not used for workbooks
45+
#' small enough that the saving would not be noticed.
3146
#' @family workbook settings
3247
#' @examples # Roundtrip example with single excel sheet named 'mysheet'
3348
#' tmp <- write_xlsx(list(mysheet = iris))
3449
#' readxl::read_xlsx(tmp)
3550
write_xlsx <- function(x, path = tempfile(fileext = ".xlsx"), col_names = TRUE,
36-
format_headers = TRUE, use_zip64 = FALSE){
51+
format_headers = TRUE, use_zip64 = FALSE,
52+
constant_memory = NA,
53+
constant_memory_threshold = 128 * 1024^2){
3754
# Resolve the input to an xl_workbook. A bare data frame / xl_sheet / list
3855
# is wrapped in a workbook with default properties; an explicit xl_workbook
3956
# overrides col_names/format_headers.
@@ -78,7 +95,8 @@ write_xlsx <- function(x, path = tempfile(fileext = ".xlsx"), col_names = TRUE,
7895
# Ordering matters to libxlsxwriter's drawing numbering, so this spans the
7996
# whole workbook rather than any one sheet
8097
.check_drawing_order(sheets, names(dfs))
81-
cm <- .resolve_constant_memory(dfs, props, sheets)
98+
cm <- .resolve_constant_memory(dfs, props, sheets, constant_memory,
99+
constant_memory_threshold)
82100
ret <- .Call(C_write_data_frame_list, dfs, path, col_names, format_headers,
83101
use_zip64, reg$table, sheets, header_id,
84102
.properties_payload(props, cm$on))

R/xl_range.R

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@
2121
# or indexes columns of the sheet's data frame and `rows` gives 1-based
2222
# *data* row indices (row 1 is the first data row, ignoring the header).
2323
#
24+
# Either spelling may name a sheet -- "Data!A1:B5", "'My Sheet'!A1:B5", or a
25+
# `sheet` element in the list form -- but almost nothing wants one. An
26+
# autofilter, a merge, a validation, a conditional format, a table, an image
27+
# anchor and a print area all apply to the sheet they are on; a sheet name
28+
# there is a misunderstanding, not a refinement. Only a chart series can
29+
# genuinely point at another sheet.
30+
#
31+
# So the sheet is *parsed* centrally and *allowed* selectively: callers opt in
32+
# with allow_sheet = TRUE, and everything else reports the mistake instead of
33+
# ignoring it. Parsing it in one place keeps the quoting rules (and their
34+
# error messages) from being reinvented per feature.
35+
#
2436
# The header offset is applied here, so C never has to know whether a header
2537
# row was written.
2638
# -----------------------------------------------------------------------------
@@ -206,21 +218,64 @@
206218
.check_range_span(c(first_row, cols[1L] - 1L, last_row, cols[2L] - 1L), arg)
207219
}
208220

221+
# Split a sheet qualifier off a range string. Excel quotes a sheet name that
222+
# contains spaces or punctuation, and doubles an apostrophe inside it:
223+
# Data!A1:B5 'My Sheet'!A1:B5 'It''s'!A1
224+
.split_sheet_ref <- function(x) {
225+
if (startsWith(x, "'")) {
226+
m <- regmatches(x, regexec("^'((?:[^']|'')*)'!(.*)$", x))[[1L]]
227+
if (length(m) == 3L)
228+
return(list(sheet = gsub("''", "'", m[2L], fixed = TRUE), rest = m[3L]))
229+
return(list(sheet = NULL, rest = x))
230+
}
231+
i <- regexpr("!", x, fixed = TRUE)
232+
if (i > 0L)
233+
return(list(sheet = substr(x, 1L, i - 1L), rest = substring(x, i + 1L)))
234+
list(sheet = NULL, rest = x)
235+
}
236+
237+
# Reject a sheet qualifier where one cannot mean anything.
238+
.reject_sheet <- function(sheet, arg) {
239+
if (is.null(sheet)) return(invisible(NULL))
240+
stop(sprintf(paste0("`%s` may not name a sheet (got \"%s!\"): it applies to ",
241+
"the sheet it is on. Drop the sheet name."),
242+
arg, sheet), call. = FALSE)
243+
}
244+
209245
# Resolve any accepted range spelling to 0-based
210246
# c(first_row, first_col, last_row, last_col).
211247
#
212248
# `arg` names the calling argument in every error message. `df` and
213249
# `header_offset` supply the sheet context needed by the data-frame-relative
214250
# and whole-column/whole-row forms. `allow_cell = FALSE` rejects a bare cell
215251
# reference for arguments documented as taking a rectangle.
252+
# `allow_sheet = TRUE` accepts a sheet qualifier and returns it as the "sheet"
253+
# attribute of the quad; otherwise a sheet name is an error. See the note at
254+
# the top of this file for why it is opt-in.
216255
.xl_resolve_range <- function(x, arg = "range", df = NULL, header_offset = 1L,
217-
allow_cell = TRUE) {
218-
if (is.list(x) && !is.data.frame(x))
219-
return(.resolve_range_spec(x, arg, df, header_offset))
220-
if (is.character(x) && length(x) == 1L && !is.na(x))
221-
return(.resolve_range_string(x, arg, df, header_offset, allow_cell))
222-
stop(sprintf(paste0('`%s` must be an Excel range string like "A1:D51" or a ',
223-
"list(rows = , cols = ) spec"), arg), call. = FALSE)
256+
allow_cell = TRUE, allow_sheet = FALSE) {
257+
sheet <- NULL
258+
if (is.list(x) && !is.data.frame(x)) {
259+
if (!is.null(x[["sheet"]])) {
260+
sheet <- x[["sheet"]]
261+
if (!is.character(sheet) || length(sheet) != 1L || is.na(sheet))
262+
stop(sprintf("`%s$sheet` must be a single sheet name", arg),
263+
call. = FALSE)
264+
x <- x[setdiff(names(x), "sheet")]
265+
}
266+
if (!allow_sheet) .reject_sheet(sheet, arg)
267+
out <- .resolve_range_spec(x, arg, df, header_offset)
268+
} else if (is.character(x) && length(x) == 1L && !is.na(x)) {
269+
sp <- .split_sheet_ref(x)
270+
sheet <- sp$sheet
271+
if (!allow_sheet) .reject_sheet(sheet, arg)
272+
out <- .resolve_range_string(sp$rest, arg, df, header_offset, allow_cell)
273+
} else {
274+
stop(sprintf(paste0('`%s` must be an Excel range string like "A1:D51" or a ',
275+
"list(rows = , cols = ) spec"), arg), call. = FALSE)
276+
}
277+
if (allow_sheet && !is.null(sheet)) attr(out, "sheet") <- sheet
278+
out
224279
}
225280

226281
# Parse an Excel range ("A1:D51") into 0-based c(first_row, first_col,

R/xl_table.R

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,17 @@ print.xl_table <- function(x, ...) {
359359
# _write_column_function() and a calculated column in _write_column_formula() --
360360
# and never to data already written by the row loop. Left to it, setting a
361361
# format on an ordinary column would do nothing at all, silently.
362+
#
363+
# This is by design, not a bug: the other ports take the table's data as a
364+
# parameter, so the library writes the cells and can format them, while
365+
# libxlsxwriter's caller writes them first (jmcnamara/libxlsxwriter#520). The
366+
# recommended fix is exactly this -- put the format on the column.
367+
#
368+
# BOTH paths are needed and neither is redundant. The column plan formats the
369+
# data cells; the format_id passed through .table_columns_payload() becomes the
370+
# table part's dataDxfId, which upstream says is required "for strict
371+
# correctness with Excel". A test asserts both survive, because after the
372+
# column plan lands the dataDxfId looks like the removable one.
362373
.table_column_formats <- function(el, df) {
363374
out <- vector("list", length(df))
364375
if (!inherits(el, "xl_sheet")) return(out)

R/xl_workbook.R

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,30 @@ print.xl_workbook <- function(x, ...) {
187187
# Resolve the constant-memory flag for a workbook, from the sheets it is about
188188
# to write. Returns the C-side integer flag plus the reasons (if any) the mode
189189
# had to be turned off.
190-
.resolve_constant_memory <- function(dfs, props, sheets = NULL) {
190+
# The extra memory libxlsxwriter uses per cell when it is NOT streaming, in
191+
# bytes. Measured against nycflights13 at several sizes and column mixes: 37
192+
# B/cell for highly repetitive text (the shared-string table deduplicates it)
193+
# rising to 112 for mixed columns at ~10M cells. The high end is used, so the
194+
# estimate errs towards keeping streaming on -- which at scale is also the
195+
# faster path, not just the leaner one.
196+
.CM_BYTES_PER_CELL <- 110
197+
198+
# Decide whether to stream rows to disk.
199+
#
200+
# 1. constant_memory = FALSE -> off, without even looking at the rest.
201+
# 2. a blacklisted feature -> off. Nothing overrides this: each of these
202+
# either fails outright under streaming or, for a multi-cell array range,
203+
# writes a file that opens cleanly and is quietly missing cells.
204+
# 3. constant_memory = TRUE -> on, overriding the size estimate.
205+
# 4. estimated extra memory below `threshold` -> off. Streaming only pays
206+
# for itself on large data, and not streaming also yields a smaller file,
207+
# since repeated strings are shared rather than written inline.
208+
# 5. otherwise -> on.
209+
.resolve_constant_memory <- function(dfs, props, sheets = NULL, request = NA,
210+
threshold = 128 * 1024^2) {
211+
request <- .val_flag(request, "constant_memory")
212+
if (identical(request, FALSE))
213+
return(list(on = 0L, reasons = "constant_memory = FALSE"))
191214
reasons <- character(0)
192215
# A multi-cell array formula range is padded by libxlsxwriter, and it skips
193216
# that padding entirely when row streaming is on -- silently, returning
@@ -228,7 +251,36 @@ print.xl_workbook <- function(x, ...) {
228251
reasons <- c(reasons, sprintf(
229252
paste0("%d embedded image(s): an embedded image writes a cell, which ",
230253
"row streaming does not allow above the current row"), n_embed))
231-
list(on = as.integer(!length(reasons)), reasons = reasons)
254+
# Cells are counted across the whole workbook: libxlsxwriter holds every
255+
# sheet's cell table until the file is closed, not one sheet at a time.
256+
cells <- sum(vapply(dfs, function(df)
257+
as.numeric(nrow(df)) * as.numeric(ncol(df)), numeric(1)))
258+
est <- cells * .CM_BYTES_PER_CELL
259+
260+
if (length(reasons)) {
261+
if (isTRUE(request))
262+
warning("`constant_memory = TRUE` cannot be honoured: ", reasons[1L],
263+
". Row streaming is off.", call. = FALSE)
264+
# The suggestion is emitted here rather than handed back: this is the only
265+
# place that knows both why streaming was refused and what it cost, and a
266+
# caller could only re-derive that. It is worth saying only when streaming
267+
# would have saved enough to notice -- below that there is nothing for the
268+
# reader to act on.
269+
if (est >= threshold)
270+
message(sprintf(
271+
paste0("Row streaming is off because of %s. This workbook is ",
272+
"estimated to use about %.0f MB more memory as a result."),
273+
reasons[1L], est / 1024^2))
274+
return(list(on = 0L, reasons = reasons))
275+
}
276+
if (isTRUE(request))
277+
return(list(on = 1L, reasons = character(0)))
278+
if (est < threshold)
279+
return(list(on = 0L, reasons = sprintf(
280+
paste0("an estimated %.0f MB of extra memory, below the %.0f MB ",
281+
"threshold at which row streaming starts to pay"),
282+
est / 1024^2, threshold / 1024^2)))
283+
list(on = 1L, reasons = character(0))
232284
}
233285

234286
# Break a Date/POSIXct out into the fields lxw_datetime carries. The value has

man/write_xlsx.Rd

Lines changed: 20 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-cell-general.R

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -552,10 +552,10 @@ sheet_data <- function(path) {
552552
}
553553

554554
# A one-formula sheet: column A holds data, column B the formula cell.
555-
array_sheet <- function(...) {
555+
array_sheet <- function(..., .cm = NA) {
556556
df <- data.frame(x = 1:2)
557557
df$f <- xl_cell_general(formula = c("=SUM(A1:A2)", NA), ...)
558-
write_tmp(df)
558+
write_tmp(df, constant_memory = .cm)
559559
}
560560

561561
test_that("a single-cell array formula is stored as an array over its own cell", {
@@ -615,7 +615,9 @@ test_that("a multi-cell range turns row streaming off, and says why", {
615615
# observable end to end: with streaming off, strings move to the shared table
616616
expect_false(is.null(xlsx_part(write_tmp(df), "xl/sharedStrings.xml")))
617617
# a single-cell array formula leaves streaming on
618-
expect_true(is.null(xlsx_part(array_sheet(array = TRUE),
618+
# asked for explicitly: a frame this small would not stream on size alone
619+
expect_true(is.null(xlsx_part(array_sheet(array = TRUE,
620+
.cm = TRUE),
619621
"xl/sharedStrings.xml")))
620622
})
621623

tests/testthat/test-format-workbook.R

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,12 @@ test_that("de-hardcoded defaults match the previous behavior (regression)", {
151151

152152
# --- constant memory --------------------------------------------------------
153153

154-
test_that("constant memory resolves to on", {
155-
cm <- .resolve_constant_memory(list(data.frame(a = 1)), xl_properties())
154+
test_that("constant memory is off for small data, on when asked for", {
155+
# a one-cell frame saves nothing by streaming, so the default is off; the
156+
# request is what separates "nothing forbids it" from "it is worth it"
157+
small <- list(data.frame(a = 1))
158+
expect_equal(.resolve_constant_memory(small, xl_properties())$on, 0L)
159+
cm <- .resolve_constant_memory(small, xl_properties(), request = TRUE)
156160
expect_equal(cm$on, 1L)
157161
expect_equal(cm$reasons, character(0))
158162
expect_equal(.properties_payload(xl_properties())$constant_memory, 1L)
@@ -166,7 +170,8 @@ test_that("workbooks write the same content with constant memory on and off", {
166170
flag = c(TRUE, FALSE, NA),
167171
when = as.Date("2020-01-01") + 0:2,
168172
stringsAsFactors = FALSE)
169-
on_path <- write_tmp(list(D = xl_sheet(df, autofilter = TRUE, freeze = "A2")))
173+
on_path <- write_tmp(list(D = xl_sheet(df, autofilter = TRUE, freeze = "A2")),
174+
constant_memory = TRUE)
170175
# No feature writexl writes today turns the mode off, so drive the off path by
171176
# mocking the resolver. It must stay exercised: the C side and libxlsxwriter
172177
# behave differently with row streaming disabled, and a phase that needs the
@@ -276,3 +281,76 @@ test_that("the other custom property types are unaffected", {
276281
expect_match(cust, "<vt:bool>true")
277282
expect_match(cust, "<vt:filetime>2020-01-02T00:00:00Z", fixed = TRUE)
278283
})
284+
285+
test_that("the constant_memory decision follows its five rules in order", {
286+
small <- list(data.frame(a = 1:5))
287+
# ~1.4M cells, comfortably past the default threshold at 110 B/cell
288+
big <- list(data.frame(matrix(1, nrow = 130000, ncol = 12)))
289+
blocked <- list(list(merges = list(1)))
290+
clear <- list(list(merges = NULL))
291+
292+
# 1. FALSE wins outright, without consulting anything else
293+
# FALSE returns before the blacklist is even consulted, so it is silent too
294+
expect_silent(r <- .resolve_constant_memory(big, list(), blocked, FALSE))
295+
expect_equal(r$on, 0L)
296+
expect_match(r$reasons, "constant_memory = FALSE")
297+
298+
# 2. the blacklist is absolute -- TRUE cannot override it, only be told.
299+
# (These also emit the size suggestion, which its own test covers.)
300+
expect_equal(suppressMessages(
301+
.resolve_constant_memory(big, list(), blocked))$on, 0L)
302+
expect_warning(r <- suppressMessages(
303+
.resolve_constant_memory(big, list(), blocked, TRUE)), "cannot be honoured")
304+
expect_equal(r$on, 0L)
305+
306+
# 3. TRUE overrides the size estimate
307+
expect_equal(.resolve_constant_memory(small, list(), clear, TRUE)$on, 1L)
308+
309+
# 4. size decides when nothing else does
310+
expect_equal(.resolve_constant_memory(small, list(), clear)$on, 0L)
311+
expect_match(.resolve_constant_memory(small, list(), clear)$reasons,
312+
"below the .* threshold")
313+
expect_equal(.resolve_constant_memory(big, list(), clear)$on, 1L)
314+
# ... and the threshold is what moves that line
315+
expect_equal(.resolve_constant_memory(big, list(), clear, NA,
316+
threshold = 4 * 1024^3)$on, 0L)
317+
})
318+
319+
test_that("cells are counted across the whole workbook", {
320+
# libxlsxwriter holds every sheet's cell table until close, so two sheets of
321+
# half the size must decide the same way as one of the full size
322+
half <- data.frame(matrix(1, nrow = 65000, ncol = 12))
323+
clear <- list(list(merges = NULL))
324+
expect_equal(.resolve_constant_memory(list(half), list(), clear)$on, 0L)
325+
expect_equal(.resolve_constant_memory(list(half, half), list(), clear)$on, 1L)
326+
})
327+
328+
test_that("the suggestion appears only when streaming would have mattered", {
329+
small <- list(data.frame(a = 1:5))
330+
big <- list(data.frame(matrix(1, nrow = 130000, ncol = 12)))
331+
blocked <- list(list(merges = list(1)))
332+
# a small workbook blocked by a feature has nothing to act on, so says nothing
333+
expect_silent(.resolve_constant_memory(small, list(), blocked))
334+
# a large one is told what it cost, and why
335+
expect_message(.resolve_constant_memory(big, list(), blocked), "merged range")
336+
expect_message(.resolve_constant_memory(big, list(), blocked),
337+
"MB more memory")
338+
})
339+
340+
test_that("the decision is silent whenever there is nothing to report", {
341+
# write_xlsx() is called constantly on small frames; a message on every one
342+
# would be noise, so only a refusal that cost something speaks
343+
small <- list(data.frame(a = 1:5))
344+
clear <- list(list(merges = NULL))
345+
expect_silent(.resolve_constant_memory(small, list(), clear))
346+
expect_silent(.resolve_constant_memory(small, list(), clear, TRUE))
347+
expect_silent(.resolve_constant_memory(small, list(), clear, FALSE))
348+
big <- list(data.frame(matrix(1, nrow = 130000, ncol = 12)))
349+
expect_silent(.resolve_constant_memory(big, list(), clear))
350+
})
351+
352+
test_that("an invalid constant_memory request is refused", {
353+
expect_error(.resolve_constant_memory(list(data.frame(a = 1)), list(), NULL,
354+
"yes"),
355+
"must be a single logical")
356+
})

0 commit comments

Comments
 (0)