Skip to content

Commit 72d6ec0

Browse files
committed
Add xl_chart() and xl_chart_series() constructors
First commit of Phase 11a. Objects, validation and the type-feature matrix; nothing reaches a file until apply_charts() lands. Placement reuses xl_image()'s vocabulary exactly -- at, scale, offset, position, description, decorative -- because lxw_chart_options is lxw_image_options minus url, tip and cell_format, with the same field names. A test asserts all six appear in both signatures, so the correspondence cannot drift silently. Series ranges carry an optional `sheet` and are left unresolved until the whole workbook is known, following the table-name precedent: a chart may plot data from a sheet other than the one it sits on. The feature matrix exists because libxlsxwriter documents these restrictions in prose and then ignores them -- chart_set_hole_size() on a pie chart, or up-down bars on a column chart, are accepted and dropped silently by Excel. Two tests guard the matrix itself rather than only its contents: every type must map to a real family, and every feature must be both supported by some type and denied by some, since a feature nothing supports is dead code and one everything supports does not belong in a matrix. Two things the tests forced: * A series name is always literal text. It cannot be "text or a range": a series may legitimately be called "Q1!". Taking the name from a cell needs an explicit spec, which is now documented rather than implied. * The API-consistency gate fired on xl_hyperlink(format, name) against xl_chart_series(name, format). This one is a genuine false friend -- in xl_hyperlink `name` is the deprecated alias for `value`, kept last on purpose, while here it is the legend label -- so it joins the allow-list with that reasoning recorded. Verified before writing any of this, with a pure-C reprex against libxlsxwriter 1.2.4: charts and chartsheets produce a drawing part and so do not desync the drawing-id counter, they survive constant_memory, and a chart IS a victim of the desync exactly as a floating image is. That last result is why .check_drawing_order() will need to count charts, which no existing test would have caught.
1 parent 95cc250 commit 72d6ec0

31 files changed

Lines changed: 750 additions & 1 deletion

NAMESPACE

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ S3method(c,xl_cell_general)
77
S3method(format,xl_rich_string)
88
S3method(length,xl_cell_general)
99
S3method(print,xl_cell_general)
10+
S3method(print,xl_chart)
11+
S3method(print,xl_chart_series)
1012
S3method(print,xl_comment)
1113
S3method(print,xl_conditional)
1214
S3method(print,xl_filter)
@@ -33,6 +35,8 @@ export(write_xlsx)
3335
export(xl_align)
3436
export(xl_border)
3537
export(xl_cell_general)
38+
export(xl_chart)
39+
export(xl_chart_series)
3640
export(xl_col_spec)
3741
export(xl_color)
3842
export(xl_comment)

R/xl_chart.R

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
# =============================================================================
2+
# Charts
3+
# =============================================================================
4+
#
5+
# A chart is a drawing placed on a worksheet, so its placement arguments are
6+
# xl_image()'s: lxw_chart_options is lxw_image_options minus url, tip and
7+
# cell_format, with the same field names. Keeping the R names identical is not
8+
# cosmetic -- the API-consistency gate enforces it.
9+
#
10+
# A series names two ranges: the values, and optionally the categories to plot
11+
# them against. chart_series_set_values() and _set_categories() take a sheet
12+
# name plus a 0-based quad rather than an A1 string, so the shared range
13+
# resolver's output plugs straight in. A series may point at a different sheet
14+
# from the one the chart sits on, which is why ranges carry an optional `sheet`
15+
# and are resolved once the whole workbook is known.
16+
#
17+
# Charts themselves produce a drawing part, so they do not desync
18+
# libxlsxwriter's drawing-id counter -- but they are *victims* of it in the
19+
# same way a floating image is, which .check_drawing_order() accounts for.
20+
# Verified against libxlsxwriter 1.2.4 before any of this was written.
21+
# -----------------------------------------------------------------------------
22+
23+
# The chart types, named as Excel names them rather than as the enum spells it.
24+
.LXW_CHART_TYPE <- c(
25+
area = 1L, area_stacked = 2L, area_stacked_percent = 3L,
26+
bar = 4L, bar_stacked = 5L, bar_stacked_percent = 6L,
27+
column = 7L, column_stacked = 8L, column_stacked_percent = 9L,
28+
doughnut = 10L,
29+
line = 11L, line_stacked = 12L, line_stacked_percent = 13L,
30+
pie = 14L,
31+
scatter = 15L, scatter_straight = 16L, scatter_straight_markers = 17L,
32+
scatter_smooth = 18L, scatter_smooth_markers = 19L,
33+
radar = 20L, radar_markers = 21L, radar_filled = 22L
34+
)
35+
36+
.LXW_CHART_LEGEND <- c(
37+
right = 1L, left = 2L, top = 3L, bottom = 4L, top_right = 5L,
38+
overlay_right = 6L, overlay_left = 7L, overlay_top_right = 8L, none = 9L
39+
)
40+
41+
# --- Which features a chart type supports ------------------------------------
42+
#
43+
# libxlsxwriter documents these restrictions in prose and then ignores them:
44+
# chart_set_hole_size() on a pie chart, or up-down bars on a column chart, are
45+
# accepted and silently dropped by Excel. The matrix is here so the refusal
46+
# happens in R, where it can name the type.
47+
.CHART_FAMILY <- function(type) {
48+
if (type %in% c("pie", "doughnut")) return("pie")
49+
if (startsWith(type, "scatter")) return("scatter")
50+
if (startsWith(type, "radar")) return("radar")
51+
if (startsWith(type, "bar") || startsWith(type, "column")) return("bar")
52+
if (startsWith(type, "line")) return("line")
53+
if (startsWith(type, "area")) return("area")
54+
"other"
55+
}
56+
57+
# feature -> the families that support it
58+
.CHART_FEATURE_FAMILIES <- list(
59+
axes = c("scatter", "radar", "bar", "line", "area", "other"),
60+
hole_size = "pie", # doughnut only, narrowed below
61+
rotation = "pie",
62+
up_down_bars = "line",
63+
high_low_lines = "line",
64+
drop_lines = c("line", "area"),
65+
series_gap = "bar",
66+
series_overlap = "bar",
67+
smooth = c("line", "scatter")
68+
)
69+
70+
# Is `feature` legal for `type`? Two features are narrower than their family.
71+
.chart_supports <- function(type, feature) {
72+
if (feature == "hole_size") return(identical(type, "doughnut"))
73+
fam <- .CHART_FAMILY(type)
74+
fam %in% .CHART_FEATURE_FAMILIES[[feature]]
75+
}
76+
77+
.check_chart_feature <- function(type, feature, arg) {
78+
if (.chart_supports(type, feature)) return(invisible(NULL))
79+
ok <- Filter(function(t) .chart_supports(t, feature), names(.LXW_CHART_TYPE))
80+
stop(sprintf(paste0("`%s` does not apply to a \"%s\" chart; Excel drops it ",
81+
"silently.\n It applies to: %s."),
82+
arg, type, paste(ok, collapse = ", ")), call. = FALSE)
83+
}
84+
85+
# --- Ranges a series points at ------------------------------------------------
86+
#
87+
# A range may name a different sheet, so it cannot be resolved until every
88+
# sheet is known. Until then it is carried as given.
89+
.chart_range <- function(x, arg) {
90+
if (is.null(x)) return(NULL)
91+
if (is.character(x) && length(x) == 1L && !is.na(x)) return(list(spec = x))
92+
if (is.list(x)) {
93+
nms <- names(x)
94+
if (is.null(nms) || any(!nzchar(nms)))
95+
stop(sprintf(paste0("`%s` list must be named, e.g. ",
96+
"list(sheet = \"Data\", cols = \"revenue\")"), arg),
97+
call. = FALSE)
98+
unknown <- setdiff(nms, c("sheet", "rows", "cols"))
99+
if (length(unknown))
100+
stop(sprintf("unknown `%s` element(s): %s", arg,
101+
paste(unknown, collapse = ", ")), call. = FALSE)
102+
sheet <- x[["sheet"]]
103+
if (!is.null(sheet) &&
104+
(!is.character(sheet) || length(sheet) != 1L || is.na(sheet)))
105+
stop(sprintf("`%s$sheet` must be a single sheet name", arg),
106+
call. = FALSE)
107+
return(list(spec = x[setdiff(nms, "sheet")], sheet = sheet))
108+
}
109+
stop(sprintf(paste0("`%s` must be a range string or a list(sheet = , rows = ",
110+
", cols = ) spec"), arg), call. = FALSE)
111+
}
112+
113+
#' A data series within a chart
114+
#'
115+
#' @description
116+
#' `xl_chart_series()` names the values a chart plots, and optionally the
117+
#' categories to plot them against and a name for the legend.
118+
#'
119+
#' Each range may live on a different sheet from the chart, so it takes an
120+
#' optional `sheet`:
121+
#'
122+
#' * `"Data!B2:B10"` --- an A1 range, sheet-qualified;
123+
#' * `list(cols = "revenue")` --- resolved against the chart's own sheet;
124+
#' * `list(sheet = "Data", cols = "revenue")` --- against another sheet.
125+
#'
126+
#' A range that selects no data is an error rather than an empty chart.
127+
#'
128+
#' @param values The range holding the numbers to plot.
129+
#' @param categories The range holding the labels to plot them against. Omit
130+
#' for a chart that numbers its points.
131+
#' @param name The series name, shown in the legend. A string is always taken
132+
#' literally --- a series may legitimately be called `"Q1!"` --- so to take
133+
#' the name from a cell, give a range spec:
134+
#' `name = list(sheet = "Data", rows = 1, cols = 1)`.
135+
#' @param format An [xl_format] styling the series --- its line and fill. See
136+
#' [xl_chart()] for which format properties a chart can express.
137+
#' @param smooth Draw the line smoothed. Line and scatter charts only.
138+
#' @param invert_if_negative Fill negative values with the inverse colour.
139+
#' @return An `xl_chart_series` object.
140+
#' @family writexl
141+
#' @seealso [xl_chart]
142+
#' @export
143+
#' @examples
144+
#' xl_chart_series(values = list(cols = "revenue"))
145+
#' xl_chart_series(values = "Data!B2:B10", categories = "Data!A2:A10",
146+
#' name = "2024")
147+
xl_chart_series <- function(values, categories = NULL, name = NULL,
148+
format = NULL, smooth = NA,
149+
invert_if_negative = NA) {
150+
if (missing(values) || is.null(values))
151+
stop("`values` must name the range holding the numbers to plot",
152+
call. = FALSE)
153+
if (!is.null(format) && !is_xl_format(format))
154+
stop("`format` must be an xl_format object", call. = FALSE)
155+
nm <- if (is.null(name)) NULL
156+
else if (is.character(name) && length(name) == 1L && !is.na(name))
157+
list(text = name)
158+
else .chart_range(name, "name")
159+
structure(.drop_null(list(
160+
values = .chart_range(values, "values"),
161+
categories = .chart_range(categories, "categories"),
162+
name = nm, format = format,
163+
smooth = .val_flag(smooth, "smooth"),
164+
invert_if_negative = .val_flag(invert_if_negative, "invert_if_negative")
165+
)), class = "xl_chart_series")
166+
}
167+
168+
#' @export
169+
print.xl_chart_series <- function(x, ...) {
170+
p <- unclass(x)
171+
cat(sprintf("<xl_chart_series%s>\n",
172+
if (!is.null(p[["name"]][["text"]]))
173+
paste0(": ", p[["name"]][["text"]]) else ""))
174+
invisible(x)
175+
}
176+
177+
#' Add a chart to a worksheet
178+
#'
179+
#' @description
180+
#' `xl_chart()` builds a chart from one or more [xl_chart_series()] and places
181+
#' it on a sheet, anchored to a cell. Pass one or a list of them as
182+
#' `xl_sheet(chart = )`.
183+
#'
184+
#' Placement works exactly as it does for [xl_image()] --- `at`, `scale`,
185+
#' `offset`, `position`, `description` and `decorative` mean the same things,
186+
#' because libxlsxwriter describes both with the same fields.
187+
#'
188+
#' @section What a chart type supports:
189+
#' Excel silently drops options a chart type cannot use, so writexl refuses them
190+
#' instead, naming the types that would work. Pie and doughnut charts have no
191+
#' axes; only a doughnut has a hole; only pie and doughnut rotate; up-down bars
192+
#' and high-low lines are line-only; the series gap and overlap are bar and
193+
#' column only.
194+
#'
195+
#' @param type The chart type: `"column"`, `"bar"`, `"line"`, `"pie"`,
196+
#' `"doughnut"`, `"area"`, `"scatter"`, `"radar"`, and the stacked,
197+
#' percent-stacked, smoothed and marker variants.
198+
#' @param series One [xl_chart_series()], or a list of them.
199+
#' @param title The chart title: a string, or a range holding one. `FALSE`
200+
#' removes the title Excel would otherwise generate.
201+
#' @param at The cell the chart's top-left corner is anchored to.
202+
#' @param scale Scale factor: one number for both axes, or `c(x, y)`.
203+
#' @param offset Offset from the anchor cell's corner in pixels, as `c(x, y)`.
204+
#' @param position How the chart behaves when rows and columns change size; see
205+
#' [xl_image()].
206+
#' @param description Alt text, for screen readers.
207+
#' @param decorative Mark the chart as decorative, so screen readers skip it.
208+
#' @param style Excel's built-in chart style, 1--48.
209+
#' @return An `xl_chart` object.
210+
#' @family writexl
211+
#' @seealso [xl_chart_series], [xl_sheet]
212+
#' @export
213+
#' @examples
214+
#' xl_chart("column", xl_chart_series(values = list(cols = "revenue")))
215+
#' xl_chart("pie", xl_chart_series(values = "Data!B2:B5"), title = "Share")
216+
xl_chart <- function(type, series, title = NULL, at = "A1", scale = 1,
217+
offset = NULL, position = "move_and_size",
218+
description = NULL, decorative = FALSE, style = NA) {
219+
ty <- .val_enum(type, names(.LXW_CHART_TYPE), "type")
220+
if (is.null(ty))
221+
stop("`type` must name a chart type, e.g. \"column\"", call. = FALSE)
222+
if (missing(series))
223+
stop("`series` must give at least one xl_chart_series() to plot",
224+
call. = FALSE)
225+
ss <- .chart_series_list(series)
226+
if (!length(ss))
227+
stop("a chart needs at least one series", call. = FALSE)
228+
229+
for (i in seq_along(ss)) {
230+
p <- unclass(ss[[i]])
231+
if (isTRUE(p[["smooth"]]))
232+
.check_chart_feature(ty, "smooth", sprintf("series[[%d]]$smooth", i))
233+
}
234+
235+
pair <- function(x, arg, what) {
236+
if (is.null(x)) return(NULL)
237+
if (!is.numeric(x) || !length(x) %in% c(1L, 2L) || anyNA(x))
238+
stop(sprintf("`%s` must be one number or two, %s", arg, what),
239+
call. = FALSE)
240+
if (length(x) == 1L) x <- c(x, x)
241+
as.numeric(x)
242+
}
243+
sc <- pair(scale, "scale", "as c(x, y)")
244+
if (!is.null(sc) && any(sc <= 0))
245+
stop("`scale` must be positive", call. = FALSE)
246+
247+
ttl <- if (is.null(title)) NULL
248+
else if (identical(title, FALSE)) list(off = TRUE)
249+
else if (is.character(title) && length(title) == 1L && !is.na(title))
250+
list(text = title)
251+
else .chart_range(title, "title")
252+
253+
structure(.drop_null(list(
254+
type = ty, series = ss, title = ttl,
255+
at = at, scale = sc, offset = pair(offset, "offset", "of pixels as c(x, y)"),
256+
position = .val_enum(position, names(.LXW_OBJECT_POSITION), "position"),
257+
description = if (is.null(description)) NULL else {
258+
if (!is.character(description) || length(description) != 1L ||
259+
is.na(description))
260+
stop("`description` must be a single non-NA string", call. = FALSE)
261+
description
262+
},
263+
decorative = .val_flag(decorative, "decorative"),
264+
style = .val_int(style, "style", min = 1, max = 48)
265+
)), class = "xl_chart")
266+
}
267+
268+
#' @export
269+
print.xl_chart <- function(x, ...) {
270+
p <- unclass(x)
271+
cat(sprintf("<xl_chart: %s, %d series%s>\n", p[["type"]],
272+
length(p[["series"]]),
273+
if (!is.null(p[["title"]][["text"]]))
274+
paste0(", \"", p[["title"]][["text"]], "\"") else ""))
275+
invisible(x)
276+
}
277+
278+
# Normalise one series or a list of them.
279+
.chart_series_list <- function(series, arg = "series") {
280+
if (is.null(series)) return(list())
281+
ss <- if (inherits(series, "xl_chart_series")) list(series) else series
282+
if (!is.list(ss))
283+
stop(sprintf("`%s` must be an xl_chart_series object or a list of them",
284+
arg), call. = FALSE)
285+
for (i in seq_along(ss))
286+
if (!inherits(ss[[i]], "xl_chart_series"))
287+
stop(sprintf("`%s[[%d]]` must be an xl_chart_series object", arg, i),
288+
call. = FALSE)
289+
ss
290+
}
291+
292+
# Normalise one chart or a list of them.
293+
.chart_list <- function(chart, arg = "chart") {
294+
if (is.null(chart)) return(list())
295+
cs <- if (inherits(chart, "xl_chart")) list(chart) else chart
296+
if (!is.list(cs))
297+
stop(sprintf("`%s` must be an xl_chart object or a list of them", arg),
298+
call. = FALSE)
299+
for (i in seq_along(cs))
300+
if (!inherits(cs[[i]], "xl_chart"))
301+
stop(sprintf("`%s[[%d]]` must be an xl_chart object", arg, i),
302+
call. = FALSE)
303+
cs
304+
}

man/is_xl_comment.Rd

Lines changed: 2 additions & 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: 2 additions & 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: 2 additions & 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)