diff --git a/CHANGELOG.md b/CHANGELOG.md
index af6c939d..1bd204e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,91 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+## [v0.13.2](https://github.com/JuliaData/XLSX.jl/tree/v0.13.2) - 2026-10-17
+
+This release is largely a bug fixing release. There are a lot of small changes, mainly addressing edge cases. Some of these introduce minor behaviour changes. There is quite a long list under the fold.
+
+
+Consolidated maintainability and bug fixing changes
+
+- **Behavior change:** `openxlsx(f, source; mode="rw")` (and `mode="w"`) no longer writes `source` when the do block throws, so a failure part-way through cannot overwrite the original file with a half-edited workbook
+- **Behavior change:** defined names holding a formula (or any expression that is not a reference or constant) are now read with a leading `=`, as Excel's Name Manager shows them, e.g. `"=COUNTA(Sheet1!\$A:\$A)"`. A plain string is a text constant and is written quoted; it was written unquoted, so Excel read it as a formula, and a constant read from a file was written back as a reference to a name. Pass a string starting with `=` to `addDefinedName` to define a name by a formula
+- **Behavior change:** a negative number in a date-formatted cell is read as the number, rather than throwing and failing the read of the whole sheet
+- Shifted cell references in shared formulas are correct: `getFormula` on a shared-formula cell could return `=A2+A20` for `=A1+A10` shifted one row, and shifted function names such as `LOG10` and `ATAN2` as if they were references
+- `addImage` no longer empties a writable `IOBuffer` passed as the image. The buffer's contents and position are left as they were
+- `openxlsx(io; mode="rw") do … end` and `savexlsx` on a workbook opened from an `IO` now replace the `IO`'s content with the saved workbook. They previously threw an `ArgumentError` from ZipArchives, because the workbook was written after the bytes already read. An `IO` that can't be overwritten, such as a read-only `IOBuffer`, gives an `XLSXError`
+- Renaming a sheet no longer rewrites references to other sheets whose names contain it (renaming `Sheet1` turned `MySheet1!A1` into `MyData!A1` and lost that sheet's formulas). Deleting a sheet likewise no longer deletes or breaks formulas on such sheets, no longer throws when the deleted sheet has a shared formula, and replaces references to a sheet whose name contains characters such as `(` with `#REF!`
+- Hidden and very hidden sheets stay hidden when the workbook is saved
+- Writing a cell above or left of the sheet's range that is also below or right of it now extends the recorded dimension on every side, so `ws[:]` and the written `` include it
+- Sheet names that start with a digit or look like a cell address (`2024`, `A1`) are quoted in the references XLSX.jl writes
+- A new custom number format takes an id not already in use, and an existing identical format is reused under its own id; ids were derived from positions, which collided or pointed at the wrong format when a file's custom ids were not contiguous from 164. `setFormat` accepts built-in format names in any case (`"percentage"`)
+- `addImage` no longer overwrites an image still in use after another sheet's images were deleted
+- `setColumnWidth` splits a column definition spanning several columns rather than writing an overlapping one, and writes column definitions in order; `getColumnWidth` reads a width from a spanning definition
+- `mergeCells` inserts `` where the schema requires it (after `autoFilter`, `sheetProtection` and the like)
+- Conditional formats with `font=["bold"=>"true"]` or `["italic"=>"true"]` make text bold or italic; they wrote `val="0"`, which turns it off. `"under"` writes the requested underline (`single` or `double`) and rejects other values
+- `setConditionalFormat(…, :notContainsText / :beginsWith / :endsWith; …)` makes the rule named; all three made a `containsText` rule. A text rule's `value` may contain `"`, and a cell reference is used as one, as documented
+- Overwriting the first cell of a shared formula at least three columns wide and two rows high no longer throws a `KeyError`, and leaves the other cells' cached values unchanged
+- `copysheet!` points the copy's sheet-scoped defined names at the copy (a non-contiguous one threw), and gives formulas set on the copy shared-formula ids of their own
+- **Behavior change:** `deletesheet!` keeps defined names that refer to the deleted sheet with the value `"=#REF!"`, as Excel does. Workbook-scoped ones were deleted, and ones scoped to other sheets were left pointing at a sheet that no longer exists
+- `setFormula` no longer throws for a two-argument `IF` or an `OFFSET` whose size is not a literal number; a `GROUPBY` or `PIVOTBY` call inside a longer formula keeps the rest of the formula; `raw` and `spill` apply to every cell of a range
+- Worksheet parts not named `sheetN.xml` (e.g. `Sheet1.xml`, as some tools write) can be read and written
+- A single-run `RichTextString` with no attributes, `:under => true` or a fractional `:size` can be written to a cell; `setFont` takes a fractional `size`. `getRichTextString` reads fractional sizes and `` / `` as off, and `setFont` no longer turns an explicit `` into bold
+- Slicing a `RichTextString` uses the indices of its text, as for a `String`, so non-ASCII text slices correctly. Equal `RichTextRun`s hash equally
+- `resolveColor` resolves `indexed="64"` and `"65"` (system foreground and background colors)
+- Datetimes that round up to midnight move to the next day rather than back to the start of the same day
+- Defined names that are cell addresses in lower case (`a1`) are refused, as Excel refuses them; sheet, table and table column names are compared ignoring case, as Excel does
+- Totals-row formulas escape table column names containing `[`, `]`, `#` or `'`
+- **Behavior change:** an unrecognized keyword argument to `setFont`, `setFill`, `setAlignment`, `setFormat`, `setBorder` or their `setUniform*` versions is an `XLSXError` naming the function, the keyword and the valid keywords, rather than a `MethodError`. `outside` given to `setBorder` for a single cell, or to `setUniformBorder`, is likewise an `XLSXError`
+- The formatting setters (`setFont`, `setFill`, `setAlignment`, `setFormat`, `setBorder` and their `setUniform*` versions) compile once rather than again for each new combination of keywords, so a first call with keywords not used before takes tens of milliseconds rather than about half a second
+- The precompile workload covers the common read and write paths (`writetable`, editing and saving a file, `readtable`, `readxlsx`, `eachrow`, `readdata`, Excel Tables, cell formatting), for file names as well as `IO`, so their first call in a session is much faster (about 1 s in all rather than about 5.5 s). It no longer precompiles conditional formats. Set the PrecompileTools preference `precompile_workload = false` to turn it off
+- Faster: `getFormula` on shared-formula cells no longer re-reads the whole sheet for each call; reading an Excel Table without the cache reads the sheet once rather than once per cell; writing strings skips the illegal-character pass for clean text; filling each sheet's cache no longer scans every formula in the workbook
+- Reading a workbook on several threads no longer reads and writes the style lookup tables at the same time
+- An empty or truncated file, or a sheet index beyond the last sheet, gives an `XLSXError` rather than a `BoundsError`. A misnamed column of an Excel Table row gives an `XLSXError`
+- `partition` with `breaks` excludes `Inf` and booleans, as documented
+- The rule-based conditional formats (`:cellIs`, `:containsText`, `:top10`, `:aboveAverage`, `:timePeriod`, `:expression` and the blanks, errors, unique and duplicate rules) accept a Symbol for any string keyword, as `:containsText` already did, and a value of another type gives an `XLSXError` rather than a `MethodError`. An invalid `operator` no longer adds an unused format to the workbook's styles before the error
+- `ws[row, col] = v` with a vector `v` now writes it across from that cell, as `ws["B2"] = v` does. Most other two-index vector assignments, such as `ws[1:2, 1:2] = v` or `ws[1, [1, 3]] = v`, threw an "ambiguous" `MethodError`; they now throw an `XLSXError` saying a vector is written along one row or column, or across from one cell
+- Removed `getGapWidth`, `setGapWidth`, `getOverlap` and `setOverlap` from the `XLSX.Charts` export list. They were never defined: the functions are `getGroupGapWidth`, `setGroupGapWidth`, `getGroupOverlap` and `setGroupOverlap`
+- `SheetCellRef`, `TableStyleInfo` and `Workbook` now have docstrings
+- `setConditionalFormat(...; :iconSet)` now rejects an invalid `mid_val` (one that is neither a number nor a fixed cell reference, unless `mid_type="formula"`), as it does every other threshold value; any `mid_val` was previously accepted. Errors for `mid2_type` and `mid2_val` now name those keywords rather than `mid_type` and `mid2_type`
+- `setConditionalFormat(...; :iconSet, iconset="Custom", reverse="true")` no longer reverses the caller's `icon_list` vector in place
+- Errors that said only "Something wrong here!" now say what is wrong, e.g. which part of a malformed file is unexpected. A file part that fails to parse now reports the parser's own error alongside the part's name, and failing to save back to a source `IO` says why. Interrupting (Ctrl-C) while a file is read or saved is no longer reported as a parse or save error
+- Rich-text cells now take `vertAlign` like any other font property: `setFont(...; vertAlign)` removes the runs' own superscript/subscript so the cell's shows, where it previously had no effect on those runs, and `setUniformFont(...; vertAlign)` no longer throws a `MethodError`
+- `setUniformStyle` over rich-text cells no longer throws when the first cell's font has `vertAlign`, a fractional size, or font details that Excel writes but XLSX.jl can't set (such as the font family and scheme)
+- Formatting a rich-text cell keeps the run properties XLSX.jl can't set but which files written by Excel or other tools may contain (such as a character set or outline). Every run property not on a fixed list was previously dropped
+- `setUniformAlignment(ws, :, cols)` with a vector or step range of columns aligned every column of the sheet rather than only `cols`
+- Errors for an operation on a file that isn't writable, or whose cell cache isn't enabled, now share one wording that names the operation and says how to open the file (`mode="rw"` or `enable_cache=true`)
+- `copysheet!` gives the copy its own cells. The copy shared the original's cell cache, so a value, format, row height or formula set on either sheet also appeared in the other, and a formula set on the copy left the original with a cell that `getFormula` couldn't read
+- `getdata` (and `ws[range]`) over a range of a writable sheet whose rows were written out of order could return wrong values, or write outside its result and corrupt memory, crashing Julia later in the garbage collector. The written rows are now sorted before the range is read
+- `setUniformFont`, `setUniformBorder`, `setUniformFill`, `setUniformAlignment` and `setUniformFormat` given a whole row or column as `(ws, row, :)` or `(ws, :, col)` now apply one attribute to every cell, as the other target forms do. They previously applied the per-cell setter (`setFont` etc.), so cells that started with different formatting kept their differences
+- Chart titles, axis titles and data labels set from a `RichTextString` keep each run's formatting (bold, italic, underline, strikethrough, superscript/subscript, size, font and color), with newlines handled as for plain strings. Previously this threw an `ArgumentError`. `addChart(...; title = rt)` keeps the run formatting on top of the new title's own
+- On Julia 1.11+, the same chart text setters and `addChart`'s `title` keyword accept a StyledStrings `styled"…"` string, converted to a `RichTextString` as for cells (in the StyledStrings extension)
+- `setChartTitleText` and `setAxisTitleText` accept a `CellRef` or `SheetCellRef`, binding the title to that cell (`c:tx/c:strRef`, with the cell's text cached). `addChart`'s `title` keyword accepts the same. A bound title has one style, so a typed title's formatting is moved into `c:txPr` and a rich-text cell lends the formatting its runs share; per-run formatting can't be kept, as Excel's file format doesn't allow it
+- `ChartEx` titles: `setChartTitleText(c::ChartEx, …)` accepts a `RichTextString`, a styled string or a cell reference, and the new `setAxisTitleText(c::ChartEx, id, …)` sets an axis title's text the same ways. Run formatting is written to `cx:txPr`, which Excel renders; a newline now starts a new paragraph rather than being written inside one run. `addChartEx`'s `title` keyword accepts the same values
+- `addChart(...; title = "A\nB")` now writes one paragraph per line, as `setChartTitleText` does, rather than a newline inside one run
+- `addChart` and `addChartEx` resolve a cell-reference title before adding anything, so a bad reference leaves the workbook unchanged
+- `getRichTextString` returns `nothing` for an empty cell, as documented, rather than throwing a `FieldError`
+- Cell getters treat a cell outside the sheet's dimension as empty, as `ws[ref]` does, rather than throwing: `getFont`, `getBorder`, `getFill`, `getAlignment`, `getFormat`, `getRichTextString`, `getFormula` and `getMergedBaseCell` return `nothing`, and `isMergedCell` returns `false`. `getColumnWidth` and `getRowHeight` still throw, since a row or column beyond the dimension can't have a size
+- Fixed a missing space in `setLabelText`'s error for a deleted label, and a typo in the `RichTextRun` docstring
+- Title getters for both chart schemas: new `getAxisTitle` (plain text) and `getAxisTitleRange` (the resolved cell of a bound axis title) for `Chart` and `ChartEx`; `getAxisTitleRef` and `getChartTitleRef` now have `ChartEx` methods, and `getChartTitleRange` a `Chart` method
+- `addSeries` and cell-bound titles now write elements in the chart part's own namespace prefixes rather than assuming Excel's `c:` and `a:`; a new chart's anchor likewise takes the drawing part's prefixes
+- `addSeries(...; name_ref)` pointing at a number cell now writes the series name as a `c:strRef`; it wrote a `c:numRef`, which the schema doesn't allow there, and Excel refused to open the file
+- Chart setters work on a chart part indented with whitespace between elements, as some tools other than Excel write; they previously threw a `MethodError`
+- An empty `ChartEx` title, axis title or series name reads as `nothing`, as for a `c:` chart, rather than `""`
+- `getAxisTitleTextProps` now has a method for `c:` charts, `getAxisTitleTextProps(c::Chart, ax::ChartAxis)`, reading the axis title's `c:txPr`. It previously existed only for `ChartEx`
+- `SheetCellRef` is now exported
+- `addChart` no longer writes `lang="en-US"` on a typed title's run
+- Removed a duplicate `setAxisTitleText` from the `XLSX.Charts` export list
+- New `setLegendPos` and `setLegendOverlay` for `Chart` and `ChartEx`: place the legend at Excel's five positions (`:b`, `:t`, `:l`, `:r`, `:tr`, or `:bottom`, `:top`, `:left`, `:right`, `:topRight`), drawn over the plot area or beside it. Both create the legend if the chart has none. On a `Chart`, setting a position removes any manual legend layout, as Excel does. `getLegendPos` and `getLegendOverlay` now have `ChartEx` methods, reading `cx:legend`'s `pos`, `align` and `overlay`
+- Every chart color setter — `setSeriesFill`, the line and marker setters, and the text `:fill` and `:line` fields — accepts a theme color named as a String or Symbol (`"accent2"`, `:tx1`), as `addSeries(...; color)` already did. It is written as `a:schemeClr`, as `SchemeColor(:accent2)` is
+- `setLabelTextProp(c::ChartEx, …)` accepts Excel's field names `:font`, `:color`, `:underline` and `:strikethrough`, as the other text-property setters do; it threw an error
+- The text-property setters check `:under`, `:strike` and `:caps` values against the DrawingML vocabularies, and throw on any other word rather than writing a file Excel refuses to open. `:under` and `:strike` also take a `Bool`, and Excel's names `:single`/`:double` (and `:none` for `:strike`)
+- Chart setter docstrings state the values each argument and keyword accepts. The color values, line keywords and text-property fields are each listed in full once — at `setSeriesFill`, `setSeriesLine` and `setChartTitleTextProp` — and the `ChartEx` single-property line setters and `addSeries(c::ChartEx, …)` have docstrings. The Charts API page gains a Conventions section
+- Cell formatting (`setFont`, `setFill`, `setBorder`, their `Uniform` forms and `setOutsideBorder`), conditional formats (color scales, data bars, `setColoredDataBars` and the `font`/`fill`/`border` keywords) and `RichTextRun` colors accept every color form charts do: a hex or Colors.jl name as a String or Symbol, a Colors.jl `Colorant`, or a theme color (`"accent1"`, `:tx1` or a `SchemeColor`). A theme color is written as ``, so it follows the workbook's theme. Outside charts, a `SchemeColor` can be lightened or darkened (`lumMod`, or `lumMod` with `lumOff` adding to 100, as Excel's own palette does); other transforms throw. Existing String arguments behave as before. A new "Specifying colors" section in the formatting guide covers all of this
+- `SchemeColor` is now defined in `XLSX` (and is `public` there), and is the same type as `XLSX.Charts.SchemeColor`
+- A `RichTextRun` with an invalid `:color` now throws when it is created, not when it is written
+
+
+
## [v0.13.1](https://github.com/JuliaData/XLSX.jl/tree/v0.13.1) - 2026-10-07
- Fix [#462](https://github.com/JuliaData/XLSX.jl/issues/462) (Read-performance regression vs v0.10.4 on large worksheets)
diff --git a/Project.toml b/Project.toml
index 86c5792b..8e439a9f 100644
--- a/Project.toml
+++ b/Project.toml
@@ -1,7 +1,7 @@
name = "XLSX"
uuid = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0"
license = "MIT"
-version = "0.13.1"
+version = "0.13.2"
authors = ["Felipe Noronha "]
repo = "https://github.com/juliadata/XLSX.jl.git"
diff --git a/bench/README.md b/bench/README.md
index bb8b9026..5dbdd581 100644
--- a/bench/README.md
+++ b/bench/README.md
@@ -112,6 +112,30 @@ It needs no fixtures and no BenchmarkTools, so it is a quick repro for read-path
julia --project=envs/dev --check-bounds=yes worksheet_read.jl
+### Maintainability check
+
+`maintainability.jl` measures maintainability signals over `src/` and compares them
+with `maintainability_baseline.toml`, exiting with status 1 if any has risen. Lower
+is better for every metric:
+
+- `duplicated_lines` — lines in an 8-line block that also occurs elsewhere. A
+ method defined in short form has its own name blanked first, so a family of
+ methods copied from one function to the next counts as duplicated
+- `bare_catch`, `something_wrong`, `asserts` — error handling that hides or fails
+ to explain errors
+- `commented_out_code` — comments holding statements
+- `inline_guards` — writability or cache checks written out rather than through
+ `_require_writable`/`_require_cache`
+- `functions_over_100`, `largest_file_lines` — size
+
+It needs only the standard library and takes a few seconds:
+
+ julia bench/maintainability.jl # report; exit 1 if any metric rose
+ julia bench/maintainability.jl --update # report and store the new baseline
+
+Run it before committing. When a metric falls, store the new baseline with
+`--update`, so it can't creep back up.
+
### Fixture descriptions
Written through XLSX.jl by `generate_fixtures.jl` (pretty-printed XML):
diff --git a/bench/maintainability.jl b/bench/maintainability.jl
new file mode 100644
index 00000000..d8a94da2
--- /dev/null
+++ b/bench/maintainability.jl
@@ -0,0 +1,167 @@
+# maintainability.jl
+# Measures a few maintainability signals over src/ and compares them with the stored
+# baseline (maintainability_baseline.toml), so a rise in copy-paste or error masking
+# shows up as a number rather than in review. Lower is better for every metric.
+#
+# Usage: julia bench/maintainability.jl # report; exit 1 if any metric rose
+# julia bench/maintainability.jl --update # report and store the new baseline
+#
+# Needs only the standard library, so no environment is required.
+
+using TOML
+
+const SRC = normpath(joinpath(@__DIR__, "..", "src"))
+const BASELINE = joinpath(@__DIR__, "maintainability_baseline.toml")
+const WINDOW = 8 # lines in a duplicated block
+
+source_files() = sort!([joinpath(r, f) for (r, _, fs) in walkdir(SRC) for f in fs if endswith(f, ".jl")])
+rel(p) = replace(relpath(p, SRC), '\\' => '/')
+
+# Lines as compared for duplication: trimmed, string and number literals blanked, and
+# blank lines, comments and lone closers dropped. On a line defining a method in short
+# form, `name(...) = ...`, `name` is blanked wherever it appears, so a family of methods
+# copied from one function to the next counts as duplicated even though each names its
+# own function. Returns (line number, text) pairs.
+function code_lines(lines)
+ out = Tuple{Int,String}[]
+ for (i, l) in enumerate(lines)
+ s = replace(strip(l), r"\"[^\"]*\"" => "S", r"\b\d+(\.\d+)?\b" => "N")
+ m = match(r"^([A-Za-z_][A-Za-z0-9_]*!?)\(.*\)\s*(::[^=]+)?=\s", s)
+ if m !== nothing
+ s = replace(s, Regex("\\b" * replace(m[1], "!" => "\\!") * "(?![A-Za-z0-9_])") => "F")
+ end
+ (isempty(s) || startswith(s, "#") || s in ("end", "else", ")", "]", "\"\"\"")) && continue
+ push!(out, (i, s))
+ end
+ return out
+end
+
+# Lines lying in a block of `WINDOW` consecutive code lines that also occurs elsewhere.
+function duplicated_lines(files)
+ seen = Dict{UInt,Vector{Tuple{String,Int}}}()
+ for f in files
+ cl = code_lines(readlines(f))
+ for k in 1:length(cl)-WINDOW+1
+ h = hash(join(last.(cl[k:k+WINDOW-1]), "\n"))
+ push!(get!(seen, h, Tuple{String,Int}[]), (f, k))
+ end
+ end
+ dup = Dict{String,Set{Int}}()
+ for locs in values(seen)
+ length(locs) > 1 || continue
+ for (f, k) in locs
+ cl = code_lines(readlines(f))
+ union!(get!(dup, f, Set{Int}()), first.(cl[k:k+WINDOW-1]))
+ end
+ end
+ return dup
+end
+
+# Top-level and one-level-nested functions written `function name ... end`, with lengths.
+function long_functions(files; limit=100)
+ long = Tuple{String,String,Int}[]
+ for f in files
+ L = readlines(f)
+ for (i, l) in enumerate(L)
+ m = match(r"^( )?function ([^\s(]+)\(", l) # `(` so docstring prose doesn't match
+ (m === nothing || endswith(rstrip(l), " end")) && continue
+ closer = m[1] === nothing ? "end" : " end"
+ j = findnext(x -> rstrip(x) == closer, L, i + 1)
+ j === nothing || j - i <= limit || push!(long, (rel(f), m[2], j - i))
+ end
+ end
+ return sort!(long, by=x -> -x[3])
+end
+
+count_matching(files, re) = sum(count(l -> occursin(re, l), readlines(f)) for f in files)
+
+# Commented-out code: a comment holding a statement, with no space after `#` or with
+# several (`#foo(x)`, `# x = 1`). Prose comments have exactly one space, so don't match.
+const COMMENTED_CODE = r"^\s*#(?![=!])(?:\S|\s{2,})\s*(?:[A-Za-z_.!]+\(.*\)\s*$|[A-Za-z_.\[\]\"]+ *= [^=]|@[a-z]+\b|for [a-z_(]|if |elseif |else\b|end\b|return\b|throw\()"
+
+# Writability or cache checks that throw, written inline rather than with
+# `_require_writable`/`_require_cache`: the flag tested on a line that throws, or on
+# the line before one.
+function inline_guards(files)
+ flag = r"\.(is_writable|use_cache_for_sheet_data)\b|is_writable\("
+ n = 0
+ for f in files
+ L = readlines(f)
+ for (i, l) in enumerate(L)
+ occursin(flag, l) || continue
+ occursin("throw(", l) || (i < length(L) && occursin(r"^\s*throw\(", L[i+1])) || continue
+ # the helpers' own checks
+ (occursin("function _require_", l) || (i > 1 && occursin("function _require_", L[i-1]))) && continue
+ n += 1
+ end
+ end
+ return n
+end
+
+function measure()
+ files = source_files()
+ total = sum(length(readlines(f)) for f in files)
+ dup = duplicated_lines(files)
+ ndup = sum(length, values(dup); init=0)
+ long = long_functions(files)
+ sizes = sort!([(rel(f), length(readlines(f))) for f in files], by=x -> -x[2])
+ metrics = Dict{String,Int}(
+ "duplicated_lines" => ndup,
+ "bare_catch" => count_matching(files, r"^\s*catch\s*$"),
+ "something_wrong" => count_matching(files, r"Something wrong"),
+ "asserts" => count_matching(files, r"@assert\b"),
+ "commented_out_code" => count_matching(files, COMMENTED_CODE),
+ "inline_guards" => inline_guards(files),
+ "functions_over_100" => length(long),
+ "largest_file_lines" => first(sizes)[2],
+ )
+ return metrics, (; files, total, dup, long, sizes)
+end
+
+function report(metrics, detail, baseline)
+ println("src/: ", length(detail.files), " files, ", detail.total, " lines\n")
+ rose = String[]
+ println(rpad("metric", 22), lpad("now", 7), lpad("baseline", 10))
+ for k in sort(collect(keys(metrics)))
+ b = isnothing(baseline) ? nothing : get(baseline, k, nothing)
+ flag = (!isnothing(b) && metrics[k] > b) ? " ▲ rose" : ""
+ isempty(flag) || push!(rose, k)
+ println(rpad(k, 22), lpad(metrics[k], 7), lpad(isnothing(b) ? "-" : string(b), 10), flag)
+ end
+ pct = round(100 * metrics["duplicated_lines"] / detail.total, digits=1)
+ println("\nduplicated lines are $pct% of src/ ($(WINDOW)-line blocks)")
+ if !isempty(detail.dup)
+ println("most duplicated files:")
+ for (f, s) in first(sort(collect(detail.dup), by=x -> -length(x[2])), 5)
+ println(" ", rpad(rel(f), 40), length(s))
+ end
+ end
+ if !isempty(detail.long)
+ println("functions over 100 lines:")
+ for (f, name, n) in detail.long
+ println(" ", rpad("$f $name", 60), n)
+ end
+ end
+ println("largest files:")
+ for (f, n) in first(detail.sizes, 5)
+ println(" ", rpad(f, 40), n)
+ end
+ return rose
+end
+
+metrics, detail = measure()
+baseline = isfile(BASELINE) ? Dict{String,Int}(k => v for (k, v) in TOML.parsefile(BASELINE)) : nothing
+rose = report(metrics, detail, baseline)
+
+if "--update" in ARGS
+ open(BASELINE, "w") do io
+ println(io, "# Written by bench/maintainability.jl --update. Lower is better for every metric.")
+ TOML.print(io, metrics; sorted=true)
+ end
+ println("\nbaseline updated: ", BASELINE)
+elseif isnothing(baseline)
+ println("\nno baseline yet: run with --update to store one")
+elseif !isempty(rose)
+ println("\nrose since the baseline: ", join(rose, ", "))
+ exit(1)
+end
diff --git a/bench/maintainability_baseline.toml b/bench/maintainability_baseline.toml
new file mode 100644
index 00000000..e57b0288
--- /dev/null
+++ b/bench/maintainability_baseline.toml
@@ -0,0 +1,9 @@
+# Written by bench/maintainability.jl --update. Lower is better for every metric.
+asserts = 3
+bare_catch = 0
+commented_out_code = 0
+duplicated_lines = 1248
+functions_over_100 = 5
+inline_guards = 0
+largest_file_lines = 2973
+something_wrong = 0
diff --git a/docs/src/api/chartElements.md b/docs/src/api/chartElements.md
index 97100b51..a0786f78 100644
--- a/docs/src/api/chartElements.md
+++ b/docs/src/api/chartElements.md
@@ -13,8 +13,10 @@ getAxisPartner
getAxisShapeProps
getAxisTextProps
getAxisGridlines
+getAxisTitle
getAxisTitleText
getAxisTitleRef
+getAxisTitleRange
getAxisTitleTextProps
setAxisTitleText
setAxisTitleTextProp
diff --git a/docs/src/api/chartTypes.md b/docs/src/api/chartTypes.md
index d274d321..3266bcf5 100644
--- a/docs/src/api/chartTypes.md
+++ b/docs/src/api/chartTypes.md
@@ -33,7 +33,7 @@ FormatSite
Effective
```
-## Colours and DrawingML
+## Colors and DrawingML
```@docs
SchemeColor
diff --git a/docs/src/api/charts.md b/docs/src/api/charts.md
index 99a19b3f..7ebfdc71 100644
--- a/docs/src/api/charts.md
+++ b/docs/src/api/charts.md
@@ -18,6 +18,36 @@ The names released before the sub-module existed — `getCharts`, `getChart`,
XLSX.Charts
```
+## Conventions
+
+The setters share their argument conventions, so the docstrings below don't
+repeat them.
+
+| argument | meaning |
+|:-------------|:------------------------------------------------------------------------------------------|
+| `c` | a chart, from [`getCharts`](@ref), [`getChart`](@ref), [`addChart`](@ref) or [`addChartEx`](@ref) |
+| `i` | a series' position, counting from 1, in the order [`getChartSeries`](@ref) returns them |
+| `point` | a data point's position in its series, counting from 1 |
+| `ax` | a [`ChartAxis`](@ref), from [`getChartAxes`](@ref) or [`getChartAxis`](@ref) |
+| `g` | a [`ChartGroup`](@ref), from [`getChartGroups`](@ref) |
+| `id` | on a `ChartEx`, an axis's `id`, from [`getChartAxisIds`](@ref) — not a position |
+| `pareto` | on a `ChartEx`, `true` to act on a Pareto chart's line rather than its bars |
+
+Every setter returns `c`. Most take `:inherit` in place of a value, to remove
+the setting so that it is inherited from the chart style or from the level
+above; each docstring says where it does. That is different from `:none`, which
+some setters take to mean "draw nothing".
+
+Three vocabularies are shared by many setters, and each is listed in full once:
+
+- **Color values** — at [`setSeriesFill`](@ref): color names, hex strings,
+ `Colors.Colorant`s, theme colors such as `"accent2"`, and
+ [`SchemeColor`](@ref)s.
+- **Line keywords** (`color`, `width`, `dash`, `cap`, `compound`, `join`,
+ `miterLimit`) — at [`setSeriesLine`](@ref).
+- **Text-property fields** (`:size`, `:bold`, `:fill`, `:font`, …) — at
+ [`setChartTitleTextProp`](@ref).
+
## Finding and reading charts
```@docs
@@ -37,7 +67,9 @@ getChartTypes
getChartGroups
getChartLegend
getLegendPos
+setLegendPos
getLegendOverlay
+setLegendOverlay
getLegendShapeProps
getLegendTextProps
setLegendTextProp
diff --git a/docs/src/api/data.md b/docs/src/api/data.md
index ad3d35f4..1df4492c 100644
--- a/docs/src/api/data.md
+++ b/docs/src/api/data.md
@@ -4,6 +4,7 @@
```@docs
XLSX.CellRef
+XLSX.SheetCellRef
XLSX.row_number
XLSX.column_number
XLSX.eachrow
diff --git a/docs/src/api/files.md b/docs/src/api/files.md
index 1e7ade9d..565bc913 100644
--- a/docs/src/api/files.md
+++ b/docs/src/api/files.md
@@ -5,6 +5,7 @@
```@docs
XLSX.XLSXFile
XLSX.XLSXFile(::Any)
+XLSX.Workbook
XLSX.readxlsx
XLSX.readto
XLSX.openxlsx
diff --git a/docs/src/api/tables.md b/docs/src/api/tables.md
index 54447b00..a2fe5e78 100644
--- a/docs/src/api/tables.md
+++ b/docs/src/api/tables.md
@@ -8,6 +8,7 @@ XLSX.gettransposedtable
XLSX.readtransposedtable
XLSX.writetable!
XLSX.Table
+XLSX.TableStyleInfo
XLSX.tables
XLSX.table
XLSX.addtable!
diff --git a/docs/src/charts/chartLimitations.md b/docs/src/charts/chartLimitations.md
index a57fcfdc..28797373 100644
--- a/docs/src/charts/chartLimitations.md
+++ b/docs/src/charts/chartLimitations.md
@@ -31,7 +31,7 @@ for the `c:` kinds, their cached data. The restriction is on creating them.
A combo chart is also the one case [`addSeries`](@ref) cannot extend, since
there is no single group to add the series to.
-## Style and colour parts are not read
+## Style and color parts are not read
Excel keeps much of a chart's appearance in two parts beside the chart itself,
`style1.xml` and `colors1.xml`. Neither is read into the formatting cascade, so
@@ -100,7 +100,7 @@ A marker is a property of a line, scatter or radar series. Setting one on a bar
or pie series throws, because the schema gives `c:barSer` no `c:marker` child.
The same applies to the options [`addSeries`](@ref) takes: `smooth` is for line
and scatter series, `line` for scatter, and `color` has no meaning on a pie or
-doughnut chart, whose points are coloured individually.
+doughnut chart, whose points are colored individually.
## Resolving text properties
diff --git a/docs/src/charts/chartSheets.md b/docs/src/charts/chartSheets.md
index 6e69964b..31e6aa98 100644
--- a/docs/src/charts/chartSheets.md
+++ b/docs/src/charts/chartSheets.md
@@ -16,7 +16,7 @@ julia> using XLSX, XLSX.Charts
Copying, renaming and deleting sheets are core operations, but a sheet holding a
chart carries more than cells: a drawing part, one chart part per chart, and the
-style and colour parts beside it. This page is what happens to those.
+style and color parts beside it. This page is what happens to those.
## Copying a sheet
@@ -60,7 +60,7 @@ Cached values are untouched, since the data hasn't changed.
## Deleting a sheet
[`XLSX.deletesheet!`](@ref) removes the charts anchored to the sheet, along with
-their drawing, style and colour parts and the content-type entries for all of
+their drawing, style and color parts and the content-type entries for all of
them:
```julia
diff --git a/docs/src/charts/creatingCharts.md b/docs/src/charts/creatingCharts.md
index 6e8b6b59..db44cd66 100644
--- a/docs/src/charts/creatingCharts.md
+++ b/docs/src/charts/creatingCharts.md
@@ -57,7 +57,7 @@ schema, where the data block is required.
Each comes from a chart part Excel itself wrote, kept verbatim in the package,
so a created chart is what Excel would have produced for that kind — including
-its style and colour parts. Kinds with no template yet throw rather than
+its style and color parts. Kinds with no template yet throw rather than
approximate; see [Limitations](chartLimitations.md).
## A chart on its own sheet
@@ -145,17 +145,17 @@ julia> addSeries(c, "C2:C5"; categories = "A2:A5", name = "2025", color = "FF000
| Keyword | Applies to | Takes |
| --- | --- | --- |
-| `color` | everything but pie and doughnut | a colour name, hex, a `Colorant`, a [`SchemeColor`](@ref), or a scheme name such as `"accent4"` |
+| `color` | everything but pie and doughnut | a color name, hex, a `Colorant`, a [`SchemeColor`](@ref), or a scheme name such as `"accent4"` |
| `markers` | line, lineMarkers, radar, scatter | a symbol such as `:circle`, or `false` for none |
| `smooth` | line, lineMarkers, scatter | `true` or `false` — whether the line is curved |
| `line` | scatter | `true` to join the points |
An option that doesn't apply to the chart's kind throws rather than being
ignored, so a mistake is caught rather than silently dropped. Pie and doughnut
-charts colour their points individually rather than by series, which is why
+charts color their points individually rather than by series, which is why
`color` has no meaning there.
-Without `color`, series take the theme's accent colours in turn, as Excel does.
+Without `color`, series take the theme's accent colors in turn, as Excel does.
Anything not covered here is set afterwards with the functions in
[Formatting charts](formattingCharts.md).
@@ -192,7 +192,7 @@ XLSX.Charts.Chart "chart1" on sheet "Data" at G9:N23
[3] 2026 - Data!$D$2:$D$5 (4 pts)
```
-The new series takes the next accent colour and the chart's own style, so it
+The new series takes the next accent color and the chart's own style, so it
looks like the ones already there.
```julia
@@ -208,10 +208,10 @@ to, so `addSeries` throws on one.
## Themes
-A created chart's colours are theme references, not fixed values, so the chart
+A created chart's colors are theme references, not fixed values, so the chart
follows the workbook's theme the way Excel's own charts do.
One consequence worth knowing: a workbook from [`XLSX.newxlsx()`](@ref) carries an older
Office theme than current Excel, so charts created in a new workbook show that
-theme's colours rather than the ones Excel 365 would use. A chart added to a
+theme's colors rather than the ones Excel 365 would use. A chart added to a
workbook Excel created uses that workbook's theme and matches.
\ No newline at end of file
diff --git a/docs/src/charts/excelCharts.md b/docs/src/charts/excelCharts.md
index f4119423..7a13e89c 100644
--- a/docs/src/charts/excelCharts.md
+++ b/docs/src/charts/excelCharts.md
@@ -179,7 +179,7 @@ something off is a setting in its own right:
| You want | You write | Reads back as |
| --- | --- | --- |
-| this colour | `setSeriesFill(c, 1, "FF0000")` | a fill with that colour |
+| this color | `setSeriesFill(c, 1, "FF0000")` | a fill with that color |
| deliberately invisible | `setSeriesFill(c, 1, :none)` | a fill with `kind === :none` |
| let the style decide | `setSeriesFill(c, 1, :inherit)` | `nothing` |
@@ -209,7 +209,7 @@ full.
## Created charts follow the workbook's theme
Charts made by [`addChart`](@ref) and [`addChartEx`](@ref) start from Excel's own
-default chart parts, with the colours written as theme references. So a new
+default chart parts, with the colors written as theme references. So a new
chart looks like one Excel would have made in that workbook — which means a
chart created in a workbook from `XLSX.newxlsx()` shows that template's older
-Office colours rather than current Excel's.
\ No newline at end of file
+Office colors rather than current Excel's.
\ No newline at end of file
diff --git a/docs/src/charts/formattingCharts.md b/docs/src/charts/formattingCharts.md
index 343ddf50..ad5731f8 100644
--- a/docs/src/charts/formattingCharts.md
+++ b/docs/src/charts/formattingCharts.md
@@ -97,7 +97,7 @@ difference survives a round trip:
| --- | --- | --- |
| nothing at all | `nothing` | whatever the chart style says |
| `` | a fill with `kind === :none` | nothing |
-| `` | a fill with `kind === :solid` | that colour |
+| `` | a fill with `kind === :solid` | that color |
So `setSeriesFill(c, 1, :none)` makes a series deliberately invisible, while
`setSeriesFill(c, 1, :inherit)` removes the setting and lets the style decide.
@@ -119,24 +119,29 @@ They take a `DrawingShapeProps`, a fill or line on its own, or the
a property written at no rung comes from the chart style, which this layer does
not read.
-## Colours
+## Colors
-Anywhere a colour is set, these forms are accepted:
+Anywhere a color is set, these forms are accepted:
```julia
julia> setSeriesFill(c, 1, "FF0000"); # hex RGB, "#RRGGBB" or AARRGGBB
-julia> setSeriesFill(c, 1, "coral"); # any Colors.jl named colour
+julia> setSeriesFill(c, 1, "coral"); # any Colors.jl named color
julia> setSeriesFill(c, 1, :coral); # the same name as a symbol
-julia> setSeriesFill(c, 1, colorant"seagreen"); # a Colors.jl value
+julia> setSeriesFill(c, 1, RGBA(0.2, 0.4, 0.6, 0.5)); # a Colors.jl value, e.g. computed, here half transparent
-julia> setSeriesFill(c, 1, SchemeColor(:accent2)); # a theme colour
+julia> setSeriesFill(c, 1, "accent2"); # a theme color, by name
+
+julia> setSeriesFill(c, 1, SchemeColor(:accent2)); # a theme color
```
+These are the same forms cells accept (see [Specifying colors](@ref)), except that in
+a chart, alpha is honored and a `SchemeColor` may use any transform.
+
A [`SchemeColor`](@ref) names a slot in the workbook's theme rather than a fixed
-colour, so it follows the theme the way Excel's own formatting does. Transforms
+color, so it follows the theme the way Excel's own formatting does. Transforms
are percentages, applied in the order given:
```julia
@@ -144,7 +149,7 @@ julia> SchemeColor(:tx1; lumMod = 65, lumOff = 35)
SchemeColor(:tx1, [:lumMod => 65.0, :lumOff => 35.0])
```
-A colour read back from a file is a [`DrawingColor`](@ref), which records the
+A color read back from a file is a [`DrawingColor`](@ref), which records the
reference as authored *and* the RGB it resolves to:
```julia
@@ -154,7 +159,7 @@ julia> col.kind, col.val, col.rgb
(:scheme, "accent2", "E97132")
```
-So a theme colour stays a theme colour in the file, while `rgb` tells you what
+So a theme color stays a theme color in the file, while `rgb` tells you what
it looks like.
## The cascade
@@ -168,7 +173,7 @@ julia> setSeriesFill(c, 1, "FF0000");
julia> e = getSeriesFill(c, 1);
-julia> e.value.fgcolor.rgb # the colour now in force
+julia> e.value.fgcolor.rgb # the color now in force
"FF0000"
julia> e.site.level # which rung supplied it
@@ -201,7 +206,7 @@ Excel wrote, counting from 0 — so a point read back at `idx == 1` is `point =
here.
!!! note
- The cascade stops at the chart XML. Excel's style and colour parts
+ The cascade stops at the chart XML. Excel's style and color parts
(`style1.xml`, `colors1.xml`) are not read, so a property written at no rung
resolves to `nothing` rather than to the style's value. See
[Limitations](chartLimitations.md).
@@ -268,7 +273,7 @@ rather than once on the chart space.
The fields are those of [`DrawingRunProps`](@ref): `:size`, `:bold`, `:italic`,
`:under`, `:strike`, `:caps`, `:baseline`, `:kern`, `:spacing`, `:latin`, `:ea`,
-`:cs`, `:lang`, plus `:fill` and `:line` for the text's own colour and outline.
+`:cs`, `:lang`, plus `:fill` and `:line` for the text's own color and outline.
Excel's names are accepted as well: `:font` for `:latin`, `:color` for `:fill`,
`:underline` for `:under` and `:strikethrough` for `:strike`. Getters report the DrawingML names.
@@ -323,11 +328,91 @@ julia> getChartTitle(c)
A `"\n"` inside a single string gives the same two-line title.
+A [`RichTextString`](@ref XLSX.RichTextString) keeps each run's formatting, as it
+does in a cell. A run's attributes — bold, italic, underline, strikethrough,
+superscript and subscript, size, font and color — are written on that run; anything
+a run doesn't set is inherited from the title's own formatting. This works with
+[`setChartTitleText`](@ref), [`setAxisTitleText`](@ref), [`setLabelText`](@ref)
+and the `title` keyword of [`addChart`](@ref):
+
+```julia
+julia> rt = XLSX.RichTextString(XLSX.RichTextRun("CO"),
+ XLSX.RichTextRun("2", [:vertAlign => "subscript"]),
+ XLSX.RichTextRun(" emissions", [:bold => true, :color => "red"]));
+
+julia> setChartTitleText(c, rt);
+```
+
+On Julia 1.11 and later, a styled string from
+[StyledStrings.jl](https://github.com/JuliaLang/StyledStrings.jl) can be passed
+instead. It is converted to a `RichTextString` as it is when written to a cell:
+
+```julia
+julia> using StyledStrings
+
+julia> setChartTitleText(c, styled"{bold:Revenue} {(foreground=red):£m}");
+```
+
+A title can instead be bound to a cell, so it shows whatever the cell holds.
+Pass a `CellRef` for a cell on the chart's own sheet, or a `SheetCellRef` to name
+the sheet — which a chart on a chartsheet must, having no cells of its own. A
+string is always typed text and is never read as a reference, so `"Q1"` is a
+title, not cell Q1.
+
+```julia
+julia> setChartTitleText(c, CellRef("A1"));
+
+julia> setAxisTitleText(c, getChartAxes(c, :value)[1], SheetCellRef("Data!B1"));
+
+julia> getChartTitleRef(c)
+"Sheet1!\$A\$1"
+```
+
+A bound title can't carry rich text. This is a limit of Excel's file format,
+not of XLSX.jl: a title holds either a cell reference or typed runs, never both,
+and a bound title has a single style, its `c:txPr`. Binding a typed title moves
+its formatting there — the first paragraph's, overlaid by its first run's. If
+the cell holds a `RichTextString`, formatting that all of its runs share is
+applied to the title too; formatting that differs from run to run is not shown.
+The cell's current text is cached in the chart, as Excel does, so
+[`getChartTitle`](@ref) reads it back without opening the file in Excel. Typing
+text over a bound title replaces the reference.
+
+A [`ChartEx`](@ref) takes the same text: a string, a `RichTextString`, a styled
+string or a cell reference, for its title with `setChartTitleText(c, text)` and
+for an axis title with `setAxisTitleText(c, id, text)`, where `id` is one of
+[`getChartAxisIds`](@ref). Excel keeps a `cx:` title's plain text in
+`cx:txData/cx:v` and renders the runs in its `cx:txPr`, so typed text keeps the
+title's existing run formatting, and a `RichTextString` lays each run's own
+formatting over it. Binding a `cx:` title to a cell folds that formatting into
+the paragraph default, as for a `c:` title, and has the same limit: no rich text.
+
+```julia
+julia> setChartTitleText(cx, rt);
+
+julia> setAxisTitleText(cx, 1, CellRef("B1"));
+```
+
Legends are read through [`getChartLegend`](@ref) and its companions —
[`getLegendPos`](@ref), [`getLegendOverlay`](@ref),
[`getLegendShapeProps`](@ref), [`getLegendTextProps`](@ref) — and their text is
set with [`setLegendTextProp`](@ref).
+[`setLegendPos`](@ref) moves the legend to one of Excel's five positions: `:b`
+(bottom), `:t` (top), `:l` (left), `:r` (right) or `:tr` (top-right). Excel's
+names `:bottom`, `:top`, `:left`, `:right` and `:topRight` work too. Its
+`overlay` keyword, or [`setLegendOverlay`](@ref), chooses whether the legend is
+drawn over the plot area (`true`) or beside it, with the plot area shrunk to make
+room (`false`). Both work on a `Chart` and a [`ChartEx`](@ref), and add a legend
+if the chart has none:
+
+```julia
+julia> setLegendPos(c, :tr; overlay=true);
+
+julia> getLegendPos(c), getLegendOverlay(c)
+(:tr, true)
+```
+
A legend shows an entry for every series, in series order. Entries can be
hidden, but not reordered. [`setLegendEntryDeleted`](@ref) hides or shows the
entries of the series at the given positions, in one rebuild:
diff --git a/docs/src/examples.md b/docs/src/examples.md
index 7dc37537..531dcb0b 100644
--- a/docs/src/examples.md
+++ b/docs/src/examples.md
@@ -440,7 +440,7 @@ XLSX.writexlsx("Example_add_native_chart_out.xlsx", f, overwrite=true)

-## A diverging stacked bar chart for Likert-scale survey data
+## A diverging stacked bar chart
Survey questions answered on a five-point Likert scale, from *strongly disagree* to
*strongly agree*, are often shown as a diverging stacked bar chart. Each question gets
@@ -545,7 +545,7 @@ hi = ceil(maximum(right) * 5) / 5
The chart is a horizontal stacked bar chart, `:stackedBar`, placed below the data. Each
series takes its values from one column of proportions and its category labels from
-the question texts in column A. The two neutral halves get the same colour, so the
+the question texts in column A. The two neutral halves get the same color, so the
split can't be seen.
```julia
diff --git a/docs/src/formatting/cellFormatting.md b/docs/src/formatting/cellFormatting.md
index 75ac9459..3198337d 100644
--- a/docs/src/formatting/cellFormatting.md
+++ b/docs/src/formatting/cellFormatting.md
@@ -76,12 +76,60 @@ underline and make the font italic. However, the color, font name and size will
all remain unchanged from before. This new combination of attributes is unique,
so a new `fontId` has been created.
-Font colors (and colors in any of the other formatting functions) can be set using a
-hex RGB value or by name using any of the colors provided by [Colors.jl](https://juliagraphics.github.io/Colors.jl/stable/namedcolors/).
-
The other set attribute functions behave in similar ways. See [`XLSX.setBorder`](@ref),
[`XLSX.setFill`](@ref), [`XLSX.setFormat`](@ref) and [`XLSX.setAlignment`](@ref).
+## Specifying colors
+
+Anywhere a color is set — `setFont`, `setFill`, `setBorder` and their `Uniform` forms,
+conditional formats, rich text runs and charts — these forms are accepted:
+
+```julia
+julia> XLSX.setFont(s, "A1"; color="FF0000") # hex RGB, "#RRGGBB" or AARRGGBB
+
+julia> XLSX.setFont(s, "A1"; color="coral") # any Colors.jl named color
+
+julia> XLSX.setFont(s, "A1"; color=:coral) # the same name as a symbol
+
+julia> XLSX.setFont(s, "A1"; color=RGB(0.2, 0.4, 0.6)) # a Colors.jl value
+
+julia> XLSX.setFont(s, "A1"; color="accent2") # a theme color, by name
+
+julia> XLSX.setFont(s, "A1"; color=XLSX.SchemeColor(:accent2; lumMod=75)) # a theme color, darker
+```
+
+Named colors are any of those provided by [Colors.jl](https://juliagraphics.github.io/Colors.jl/stable/namedcolors/).
+A Colors.jl value is useful for colors computed in code, such as a palette from
+`distinguishable_colors` or `range(colorant"red", colorant"blue"; length=5)`.
+
+Excel ignores the alpha (transparency) part of a cell color, so an `AARRGGBB` value or a
+Colors.jl value with alpha is accepted but always appears opaque. Charts are different:
+there, alpha is honored.
+
+A theme color refers to a slot in the workbook's theme rather than to a fixed RGB value,
+so it changes when the theme does, as colors picked from Excel's theme palette do. Name
+it as a string or symbol — `"bg1"`, `"tx1"`, `"bg2"`, `"tx2"`, `"accent1"` … `"accent6"`,
+`"hlink"` or `"folHlink"` — or as an [`XLSX.SchemeColor`](@ref) to make it lighter or
+darker. A cell color can only hold the lighter and darker shades Excel's own palette
+offers:
+
+```julia
+julia> XLSX.SchemeColor(:accent1) # Accent 1
+
+julia> XLSX.SchemeColor(:accent1; lumMod=75) # Accent 1, Darker 25%
+
+julia> XLSX.SchemeColor(:accent1; lumMod=60, lumOff=40) # Accent 1, Lighter 40%
+```
+
+That is, `lumMod` alone (darker) or `lumMod` and `lumOff` adding to 100 (lighter). Other
+`SchemeColor` transforms, such as `shade` or `satMod`, can only be used in charts and are
+an error elsewhere.
+
+When a theme color is read back, `getFont`, `getFill` and `getBorder` report it as
+`"theme"` and `"tint"` attributes (e.g. `Dict("theme" => "4", "tint" => "0.4")`).
+[`XLSX.getRichTextString`](@ref) reports every color as its `"FFRRGGBB"` value in the
+current theme.
+
## Formatting multiple cells at once
### Applying `setAttribute` to multiple cells
diff --git a/ext/StyledStringsSstsExt.jl b/ext/StyledStringsSstsExt.jl
index d2c138f2..bdef8004 100644
--- a/ext/StyledStringsSstsExt.jl
+++ b/ext/StyledStringsSstsExt.jl
@@ -4,6 +4,7 @@ module StyledStringsSstsExt
using XLSX
import XLSX: setdata!, RichTextString, RichTextRun
+ import XLSX: Charts
import StyledStrings: StyledStrings, AnnotatedString
import StyledStrings: load_customisations!, getface, Face, FACES, SimpleColor
@@ -15,7 +16,7 @@ module StyledStringsSstsExt
const _AnnStr = Union{AnnotatedString, SubString{<:AnnotatedString}}
# Bounds chains of faces whose foreground names another face (as StyledStrings'
-# MAX_COLOR_FORWARDS does), so a cycle gives no colour rather than a stack overflow.
+# MAX_COLOR_FORWARDS does), so a cycle gives no color rather than a stack overflow.
const _SS_MAX_COLOR_FORWARDS = 12
function setdata!(sheet::Worksheet, ref::CellRef, ss::_AnnStr)
@@ -23,6 +24,22 @@ function setdata!(sheet::Worksheet, ref::CellRef, ss::_AnnStr)
return setdata!(sheet, ref, RichTextString(_ssToRuns(ss)))
end
+# Chart text: a styled string is written as the RichTextString it converts to, so
+# its formatting is kept as it is in a cell. Empty text has no runs to convert.
+_ss_rich(ss::_AnnStr) = isempty(ss) ? "" : RichTextString(_ssToRuns(ss))
+
+Charts.setChartTitleText(c::Charts.Chart, ss::_AnnStr) =
+ Charts.setChartTitleText(c, _ss_rich(ss))
+Charts.setAxisTitleText(c::Charts.Chart, ax::Charts.ChartAxis, ss::_AnnStr) =
+ Charts.setAxisTitleText(c, ax, _ss_rich(ss))
+Charts.setLabelText(c::Charts.Chart, i::Integer, point::Integer, ss::_AnnStr) =
+ Charts.setLabelText(c, i, point, _ss_rich(ss))
+Charts.setChartTitleText(c::Charts.ChartEx, ss::_AnnStr) =
+ Charts.setChartTitleText(c, _ss_rich(ss))
+Charts.setAxisTitleText(c::Charts.ChartEx, id::Integer, ss::_AnnStr) =
+ Charts.setAxisTitleText(c, id, _ss_rich(ss))
+Charts._chart_title(ss::_AnnStr) = _ss_rich(ss)
+
"""
_ssToRuns(s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}) -> Vector{XLSX.RichTextRun}
diff --git a/src/XLSX.jl b/src/XLSX.jl
index aeda7cfd..06566cd9 100644
--- a/src/XLSX.jl
+++ b/src/XLSX.jl
@@ -62,7 +62,7 @@ export
addsheet!, renamesheet!, copysheet!, deletesheet!,
addImage,
# Cells & data
- CellRef, row_number, column_number, eachtablerow,
+ CellRef, SheetCellRef, row_number, column_number, eachtablerow,
readdata, getdata, gettable, readtable, readto,
iserror, geterror,
gettransposedtable, readtransposedtable,
@@ -87,7 +87,7 @@ export
getFormat, getFont, getBorder, getFill, getAlignment,
DataTable, Table, TableStyleInfo, table, tables,
getDefinedNames, getAllDefinedNames,
- Workbook, XLSXError,
+ Workbook, XLSXError, SchemeColor,
CellErrorType, XL_NULL, XL_DIV0, XL_VALUE, XL_REF,
XL_NAME, XL_NUM, XL_NA, XL_SPILL
"""))
@@ -99,6 +99,7 @@ const EXCEL_MAX_COLS = 16_384 # total columns supported by Excel per s
const EXCEL_MAX_ROWS = 1_048_576 # total rows supported by Excel per sheet (including headers)
const ROW_CHUNKSIZE = 1_000 # number of rows to be processed in each thread
+include("scheme-color.jl") # must load before types.jl (`CellColor`)
include("types.jl")
include("xmlutil.jl")
include("xlsx-colors.jl") # must load before sst.jl and cellformat-helpers.jl
@@ -115,11 +116,25 @@ include("workbook.jl")
include("worksheet.jl")
include("cell.jl")
include("styles.jl")
-include("cellformat-helpers.jl") # must load before cellformats.jl
-include("cellformats.jl")
+include("cellformat-helpers.jl") # must load before cellformats/
+include("cellformats/font.jl")
+include("cellformats/border.jl")
+include("cellformats/fill.jl")
+include("cellformats/alignment.jl")
+include("cellformats/numberformat.jl")
+include("cellformats/uniformstyle.jl")
+include("cellformats/widthheight.jl")
+include("cellformats/mergedcells.jl")
include("panes.jl")
-include("conditional-format-helpers.jl") # must load before conditional-formats.jl
-include("conditional-formats.jl")
+include("conditional-format-helpers.jl") # must load before conditionalformats/
+include("conditionalformats/presets.jl")
+include("conditionalformats/read.jl")
+include("conditionalformats/setconditionalformat.jl")
+include("conditionalformats/rules.jl")
+include("conditionalformats/colorscale.jl")
+include("conditionalformats/iconset.jl")
+include("conditionalformats/databar.jl")
+include("conditionalformats/clear.jl")
include("images.jl")
include("write.jl")
include("fileArray.jl")
@@ -141,28 +156,93 @@ using .Charts: AbstractChart, Chart, ChartEx, ChartRef, ChartSeries,
"""))
end
+# Writes one small workbook holding each common cell type, then reads it back through
+# every read path. Compile cost grows with the number of distinct methods and types
+# run, not with the number of cells, so the data stays tiny. Paths are exercised
+# with a file name (`String`) as well as an `IOBuffer`: the entry points specialize
+# on each. Niche features (charts, images, conditional formats) are left out — they
+# cost seconds of precompile time each, paid by every user.
+# Users can turn this off with the PrecompileTools preference `precompile_workload = false`.
PCT.@setup_workload begin
let
- # Putting some things in `@setup_workload` instead of `@compile_workload` can reduce the size of the
- # precompile file and potentially make loading faster.
- s=IOBuffer()
- t=IOBuffer()
- PCT.@compile_workload begin
- # all calls in this block will be precompiled, regardless of whether
- # they belong to your package or not (on Julia 1.8 and higher)
- f=openxlsx(joinpath(@__DIR__, "data", "blank.xlsx"), mode="rw")
- f[1]["A1:Z26"] = "hello World"
- openxlsx(s, mode="w") do xf
- xf[1][1:26, 1:26] = pi
+ dir = mktempdir()
+ path = joinpath(dir, "precompile.xlsx")
+ io = IOBuffer()
+ nt = (i = [1, 2, 3], f = [1.5, 2.5, 3.5], s = ["a", "b", "c"], b = [true, false, true],
+ d = [Dates.Date(2024, 1, 1), Dates.Date(2024, 1, 2), Dates.Date(2024, 1, 3)],
+ dt = [Dates.DateTime(2024, 1, 1, 12), Dates.DateTime(2024, 1, 2, 12), Dates.DateTime(2024, 1, 3, 12)],
+ m = Union{Missing,Int}[1, missing, 3])
+ try
+ PCT.@compile_workload begin
+ # Writing
+ writetable(path, nt; overwrite=true)
+ writetable(path, "A" => nt, "B" => nt; overwrite=true)
+ xf = openxlsx(path, mode="rw")
+ sh = xf["A"]
+ # Cell assignment compiles once per value type and reference form.
+ vals = Any[1, 2.5, "x", true, Dates.Date(2024, 1, 1), Dates.DateTime(2024, 1, 1, 12), missing]
+ for (c, v) in enumerate(vals)
+ sh[string("J", c)] = v
+ sh[CellRef(2, 9 + c)] = v
+ sh[3, 9 + c] = v
+ end
+ sh[5, :] = vals
+ sh["J6:K7"] = "y"
+ writetable!(addsheet!(xf, "C"), Any[nt.i, nt.s], [:i, :s])
+ setFont(sh, "A1"; bold=true, color="red")
+ setFill(sh, "A2"; pattern="solid", fgColor="yellow")
+ setAlignment(sh, "A3"; horizontal="center", wrapText=true)
+ setFormat(sh, "B2"; format="0.00")
+ setBorder(sh, "A1:C3"; allsides=["style" => "thin", "color" => "black"])
+ setBorder(sh, "A1:C3"; outside=["style" => "thick"])
+ # The formatting setters take their keywords as one spec (see `FontSpec`),
+ # so each call here covers every set of keywords that setter is given.
+ setUniformFont(sh, "A2:C3"; italic=true)
+ setUniformFill(sh, "A2:C3"; pattern="solid", fgColor="yellow")
+ setUniformAlignment(sh, "A2:C3"; horizontal="center")
+ setUniformFormat(sh, "B2:B3"; format="0.00")
+ setUniformBorder(sh, "A2:C3"; bottom=["style" => "thin"])
+ setColumnWidth(sh, 1; width=12)
+ mergeCells(sh, "J6:K7")
+ savexlsx(xf)
+ writexlsx(io, xf)
+ openxlsx(IOBuffer(), mode="w") do xf
+ xf[1]["A1:B2"] = 1.5
+ end
+
+ # Reading
+ dt = readtable(path, 1)
+ Tables.columns(dt)
+ Tables.schema(dt)
+ readtable(path, "A", "A:C")
+ readtable(seekstart(io), 1)
+ xf = readxlsx(path)
+ sheetnames(xf)
+ sh = xf["A"]
+ sh["A1:C4"]
+ getdata(sh)
+ sh[2, 1]
+ getcell(sh, "B2")
+ openxlsx(path) do xf
+ gettable(xf[1])
+ for r in eachtablerow(xf[1])
+ r[1]
+ end
+ end
+ openxlsx(path; enable_cache=false) do xf
+ for r in eachrow(xf[1])
+ r[1]
+ end
+ end
+ readdata(path, 1, "A1:C3")
+ readdata(path, "A!A1:B2")
+
+ # Excel Tables
+ writetable(path, nt; overwrite=true, as_table=true, table_name="T")
+ readtable(path, 1; table_name="T")
end
- _ = readtable(seekstart(s), 1, "A:Z")
- f = openxlsx(seekstart(s), mode="rw")
- f[1][1:26, 1:26] = pi
- setConditionalFormat(f[1], :, :cellIs)
- setConditionalFormat(f[1], "A1:Z26", :colorScale)
- setBorder(f[1], collect(1:26), 1:26, allsides=["style"=>"thin", "color"=>"black"])
- _ = getdata(f[1], "A1:A20")
- writexlsx(t, f)
+ finally
+ rm(dir; recursive=true, force=true)
end
end
end
diff --git a/src/cell.jl b/src/cell.jl
index 4a54d463..03a57203 100644
--- a/src/cell.jl
+++ b/src/cell.jl
@@ -96,9 +96,9 @@ If there is no error, returns an empty string.
The return type depends on the type of `ref` and is the same shape as the return
type of `getcell` for the same `ref`:
-- If `ref` or (`row`, `col`) refers to a single cell, returns a single Bool.
-- If `ref` or (`rows`, `cols`) refers to a range of cells, returns a matrix of Bools.
-- If `ref` or (`rows`, `cols`) refers to a non-contiguous range of cells, returns a vector of matrices of Bools.
+- If `ref` or (`row`, `col`) refers to a single cell, returns a single String.
+- If `ref` or (`rows`, `cols`) refers to a range of cells, returns a matrix of Strings.
+- If `ref` or (`rows`, `cols`) refers to a non-contiguous range of cells, returns a vector of matrices of Strings.
# Examples
```julia
@@ -250,7 +250,7 @@ end
function _record_formula!(wb::Workbook, ws::Worksheet, ref::CellRef, f_node::XML.LazyNode,
local_formulas::Union{Nothing, Dict{SheetCellRef, AbstractFormula}})
f = parse_formula_from_element(wb, f_node)
- key = SheetCellRef(combine_sheet_ref(ws, ref))
+ key = SheetCellRef(ws.name, ref)
if isnothing(local_formulas)
lock(wb.formulas_lock) do
wb.formulas[key] = f
@@ -367,17 +367,17 @@ end
# Returns (raw_value::UInt64, datatype::CellValueType) for datetime strings,
# keeping the value in its Excel numeric form for storage in Cell.
+# A negative value is no date (Excel shows `####`) but the file is valid, so it is
+# read as the plain number it is rather than failing the whole sheet.
function _parse_excel_datetime_raw(v::AbstractString)
isempty(v) && throw(XLSXError("Cannot convert an empty string into a datetime value."))
- if occursin('.', v) || v == "0"
+ if occursin('.', v) || occursin('E', v) || occursin('e', v) || v == "0"
time_value = _parse_cell_float(v)
- time_value >= 0 || throw(XLSXError("Cannot have a datetime value < 0. Got $time_value"))
- datatype = time_value < 1.0 ? CT_TIME : CT_DATETIME
+ datatype = time_value < 0.0 ? CT_FLOAT : time_value < 1.0 ? CT_TIME : CT_DATETIME
return reinterpret(UInt64, time_value), datatype
else
date_value = parse(Int64, v)
- date_value >= 0 || throw(XLSXError("Cannot have a datetime value < 0. Got $date_value"))
- return reinterpret(UInt64, date_value), CT_DATE
+ return reinterpret(UInt64, date_value), (date_value < 0 ? CT_INT : CT_DATE)
end
end
@@ -497,9 +497,10 @@ end
function excel_value_to_datetime(x::Float64, is1904::Bool)::Dates.DateTime
x >= 0 || throw(XLSXError("Cannot have a datetime value < 0. Got $x"))
dt_part = trunc(Int64, x)
- # Round to nearest second to absorb float precision drift
- hr_ns = round(Int64, (x - dt_part) * NANOSECONDS_PER_DAY / 1_000_000_000) * 1_000_000_000
- return excel_value_to_date(dt_part, is1904) + Dates.Time(Dates.Nanosecond(hr_ns))
+ # Round to nearest second to absorb float precision drift. Added as a period, not
+ # a `Time`, so a value that rounds up to midnight moves on to the next day.
+ secs = round(Int64, (x - dt_part) * 86_400)
+ return Dates.DateTime(excel_value_to_date(dt_part, is1904)) + Dates.Second(secs)
end
function datetime_to_excel_value(dt::Dates.DateTime, is1904::Bool)::Float64
diff --git a/src/cellformat-helpers.jl b/src/cellformat-helpers.jl
index 1a099f9c..8af5d50f 100644
--- a/src/cellformat-helpers.jl
+++ b/src/cellformat-helpers.jl
@@ -93,8 +93,9 @@ const VALID_BORDER_STYLES = (
)
const VALID_DIAGONAL_DIRECTIONS = ("up", "down", "both")
-# Shared kwarg type alias for border sides
-const BorderKw = Union{Nothing,Vector{Pair{String,String}}}
+# A Markdown bullet list of `vals`, for docstrings to list the values a setter
+# validates against from the same constant, so the two can't drift apart.
+_doc_list(vals) = join(("- `$v`" for v in vals), "\n")
const VALID_HORIZONTAL_ALIGNMENTS = ("left", "center", "right", "fill", "justify", "centerContinuous", "distributed")
const VALID_VERTICAL_ALIGNMENTS = ("top", "center", "bottom", "justify", "distributed")
@@ -112,11 +113,6 @@ const EXCEL_COLUMN_WIDTH_PADDING = 0.7109375
# -- A bunch of helper functions ...
#
-function copynode(o::XML.Node)
- attrs = isnothing(o.attributes) ? nothing : copy(o.attributes)
- children = isnothing(o.children) ? nothing : XML.Node{String}[copynode(x) for x in o.children]
- return XML.Node{String}(o.nodetype, o.tag, attrs, o.value, children)
-end
function do_sheet_names_match(ws::Worksheet, rng::T) where {T<:Union{SheetCellRef,AbstractSheetCellRange}}
if ws.name == rng.sheet
return true
@@ -220,7 +216,14 @@ end
# Merges a boolean/flag font tag (e.g. "b", "i", "strike").
# Returns nothing (present, no attributes) if kept/set, or missing to omit.
function _merge_flag_tag(tag::String, new_val::Union{Nothing,Bool}, old_atts::Dict)
- (isnothing(new_val) ? haskey(old_atts, tag) : new_val) ? nothing : missing
+ (isnothing(new_val) ? _flag_set(old_atts, tag) : new_val) ? nothing : missing
+end
+
+# Whether a font's flag tag is on: present, and not `` / `val="false"`.
+function _flag_set(atts::Dict, tag::String)::Bool
+ haskey(atts, tag) || return false
+ a = atts[tag]
+ return isnothing(a) || !(get(a, "val", "1") in ("0", "false"))
end
# Merges a single-value font tag (e.g. "sz", "name", "vertAlign").
@@ -320,12 +323,12 @@ function _merge_alignment_att(
end
end
-# Coerces a Vector{Pair{String,String}} kwarg to Dict{String,String}, or returns nothing.
-_to_border_dict(v) = isnothing(v) ? nothing : Dict{String,String}(p for p in v)
+# Coerces a border-side kwarg (a vector of `String => value` pairs) to a Dict, or returns nothing.
+_to_border_dict(v) = isnothing(v) ? nothing : Dict{String,Any}(p for p in v)
# Validates that `outside` is not combined with any other border kwargs.
-function _check_outside_conflict(outside, left, right, top, bottom, diagonal, allsides)
- !isnothing(outside) && !all(isnothing, [left, right, top, bottom, diagonal, allsides]) &&
+function _check_outside_conflict(s::BorderSpec)
+ !isnothing(s.outside) && !all(isnothing, (s.left, s.right, s.top, s.bottom, s.diagonal, s.allsides)) &&
throw(XLSXError("Keyword `outside` is incompatible with any other keywords."))
end
@@ -333,7 +336,7 @@ end
# Handles style, color, and (for diagonal) direction.
function _merge_border_side(
side::String,
- new_val::Union{Nothing,Dict{String,String}},
+ new_val::Union{Nothing,AbstractDict{String}},
old_atts::Union{Nothing,Dict{String,Union{Dict{String,String},Nothing}}}
)::Dict{String,String}
@@ -354,7 +357,7 @@ function _merge_border_side(
# Color: use new if provided, else inherit all non-style keys from old
if haskey(new_val, "color")
- result["rgb"] = get_color(new_val["color"])
+ merge!(result, Dict(_color_atts(new_val["color"])))
elseif !isnothing(old_side)
for (k, v) in old_side
k != "style" && (result[k] = v)
@@ -378,32 +381,16 @@ function _merge_border_side(
end
function get_new_formatId(wb::Workbook, format::String)::Int
- if haskey(builtinFormatNames, uppercasefirst(format)) # User specified a format by name
- return builtinFormatNames[format]
- elseif haskey(builtinFormats, format) # User specified a built-in format by ID
+ for (name, id) in builtinFormatNames # User specified a format by name
+ lowercase(name) == lowercase(format) && return id
+ end
+ if haskey(builtinFormats, format) # User specified a built-in format by ID
return parse(Int64, format)
else # user specified a format code
if !is_valid_format(format)
throw(XLSXError("Specified format is not a valid numFmt: $format"))
end
-
- xroot = styles_xmlroot(wb)
- i, j = get_idces(xroot, "styleSheet", "numFmts")
- if isnothing(j) # There are no existing custom formats
- return styles_add_numFmt(wb, format)
- else
- existing_elements_count = length(xml_elements(xroot[i][j]))
- if parse(Int, xroot[i][j]["count"]) != existing_elements_count
- throw(XLSXError("Wrong number of font elements found: $existing_elements_count. Expected $(parse(Int, xroot[i][j]["count"]))."))
- end
-
- format_node = XML.Element("numFmt";
- numFmtId=string(existing_elements_count + PREDEFINED_NUMFMT_COUNT),
- formatCode=format
- )
-
- return styles_add_cell_attribute(wb, format_node, "numFmts") + PREDEFINED_NUMFMT_COUNT
- end
+ return styles_add_numFmt(wb, format) # reuses an identical format
end
end
function update_template_xf(ws::Worksheet, existing_style::CellDataFormat, attributes::Vector{String}, vals::Vector{String})::CellDataFormat
@@ -659,19 +646,14 @@ function process_get_sheetcell(f::Function, xl::XLSXFile, sheetcell::String; kw.
ref = SheetCellRef(sheetcell)
!hassheet(xl, ref.sheet) && throw(XLSXError("Sheet $(ref.sheet) not found."))
ws = getsheet(xl, ref.sheet)
- d = get_dimension(ws)
- if ref.cellref ∉ d
- throw(XLSXError("Cell specified is outside sheet dimension \"$d\""))
- end
return f(ws, ref.cellref; kw...)
end
+# A cell outside the sheet's dimension is empty, as `ws[ref]` treats it, so it has
+# no format to get. (getColumnWidth and getRowHeight keep their own check: a row or
+# column beyond the dimension can still have a size.)
function process_get_cellref(f::Function, ws::Worksheet, cellref::CellRef; kw...)
wb = get_workbook(ws)
cell = getcell(ws, cellref)
- d = get_dimension(ws)
- if cellref ∉ d
- throw(XLSXError("Cell specified is outside sheet dimension \"$d\""))
- end
if cell isa EmptyCell || cell.style == UInt64(0)
return nothing
end
@@ -686,7 +668,6 @@ function process_get_cellname(f::Function, ws::Worksheet, ref_or_rng::AbstractSt
throw(XLSXError("Can only assign attributes to cells but `$(ref_or_rng)` is a constant: $(ref_or_rng)=$v."))
elseif is_defined_name_value_a_reference(v)
new_att = f(get_xlsxfile(wb), unquoteit(string(v)); kw...)
-# new_att = f(get_xlsxfile(wb), replace(string(v), "'" => ""); kw...)
else
throw(XLSXError("Unexpected defined name value: $v."))
end
@@ -701,31 +682,20 @@ end
#
# - Used for indexing `setAttribute` family of functions
#
-function process_colon(f::Function, ws::Worksheet, row, col; kw...)
- dim = get_dimension(ws)
- @assert isnothing(row) || isnothing(col) "Something wrong here!"
- if isnothing(row) && isnothing(col)
- return f(ws, dim; kw...)
- elseif isnothing(col)
- rng = CellRange(CellRef(first(row), dim.start.column_number), CellRef(last(row), dim.stop.column_number))
- else
- rng = CellRange(CellRef(dim.start.row_number, first(col)), CellRef(dim.stop.row_number, last(col)))
-# else
-# throw(XLSXError("Something wrong here!"))
- end
+# In a `(row, col)` target, `nothing` stands for `:`. Expand it to the full extent of
+# the sheet's dimension `dim`. A target with neither a colon is not a colon form,
+# so has no method.
+_expand_colon(dim::CellRange, row, ::Nothing) = (row, dim.start.column_number:dim.stop.column_number)
+_expand_colon(dim::CellRange, ::Nothing, col) = (dim.start.row_number:dim.stop.row_number, col)
+_expand_colon(dim::CellRange, ::Nothing, ::Nothing) = (dim.start.row_number:dim.stop.row_number, dim.start.column_number:dim.stop.column_number)
- return f(ws, rng; kw...)
+function process_colon(f::Function, ws::Worksheet, row, col; kw...)
+ row, col = _expand_colon(get_dimension(ws), row, col)
+ return f(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
end
function process_veccolon(f::Function, ws::Worksheet, row, col; kw...)
dim = get_dimension(ws)
- @assert isnothing(row) || isnothing(col) "Something wrong here!"
- if isnothing(col)
- col = dim.start.column_number:dim.stop.column_number
- else
- row = dim.start.row_number:dim.stop.row_number
-# else
-# throw(XLSXError("Something wrong here!"))
- end
+ row, col = _expand_colon(dim, row, col)
isInDim(ws, dim, row, col)
if length(row) == 1 && length(col) == 1
single = true
@@ -770,11 +740,11 @@ end
#
# Most setUniform functions (but not Style or Alignment - see below)
#
-function maybe_update_font!(f::Function, ws::Worksheet, cell, cellref; kw...)
- f == setFont || return
+function maybe_update_font!(f::Function, ws::Worksheet, cell, cellref; spec)
+ f === _setFont || return
cell isa EmptyCell && return
cell.datatype == CT_STRING || return
- v = update_sharedString_font(ws, cell; kw...)
+ v = update_sharedString_font(ws, cell, spec)
cell.value = isnothing(v) ? cell.value : reinterpret(UInt64, Int64(v))
end
@@ -794,7 +764,7 @@ function process_uniform_core(f::Function, ws::Worksheet, allXfNodes::Vector{XML
end
function process_uniform_attribute(f::Function, ws::Worksheet, rng::CellRange, atts::Vector{String}; kw...)
- get_xlsxfile(ws).use_cache_for_sheet_data || throw(XLSXError("Cannot set uniform attributes because cache is not enabled."))
+ _require_cache(ws, "set uniform attributes")
allXfNodes = get_cellXfs_nodes(get_workbook(ws))
newid, first = nothing, true
isInDim(ws, get_dimension(ws), rng)
@@ -819,7 +789,6 @@ function process_uniform_ncranges(f::Function, ws::Worksheet, ncrng::NonContiguo
newid, first = nothing, true
for r in ncrng.rng
- @assert r isa CellRef || r isa CellRange "Something wrong here"
if r isa CellRef
cell = getcell(ws, r)
if cell isa EmptyCell
@@ -839,11 +808,9 @@ function process_uniform_ncranges(f::Function, ws::Worksheet, ncrng::NonContiguo
end
function process_uniform_veccolon(f::Function, ws::Worksheet, row, col, atts::Vector{String}; kw...)
- @assert isnothing(row) || isnothing(col) "Something wrong here!"
allXfNodes = get_cellXfs_nodes(get_workbook(ws))
dim = get_dimension(ws)
- isnothing(col) ? (col = dim.start.column_number:dim.stop.column_number) :
- (row = dim.start.row_number:dim.stop.row_number)
+ row, col = _expand_colon(dim, row, col)
isInDim(ws, dim, row, col)
newid, first = nothing, true
for a in row, b in col
@@ -880,7 +847,7 @@ function process_uniform_core(ws::Worksheet, cellref::CellRef, newid::Union{Int,
return newid, first, firstFont
end
if first # Get the style of the first cell in the range.
- if cell.style !== UInt64(0)
+ if cell.style != 0
newid = Int(cell.style)
end
firstFont=getFont(ws, cellref)
@@ -913,7 +880,6 @@ function process_uniform_ncranges(ws::Worksheet, ncrng::NonContiguousRange)
first = true
firstFont=nothing
for r in ncrng.rng
- @assert r isa CellRef || r isa CellRange "Something wrong here"
if r isa CellRef
if getcell(ws, r) isa EmptyCell
single && throw(XLSXError("Cannot set format for an `EmptyCell`: $(cellname(r)). Set the value first."))
@@ -924,8 +890,6 @@ function process_uniform_ncranges(ws::Worksheet, ncrng::NonContiguousRange)
for c in r
newid, first, firstFont = process_uniform_core(ws, c, newid, first, firstFont)
end
-# else
-# throw(XLSXError("Something wrong here!"))
end
end
if first
@@ -937,31 +901,10 @@ function process_uniform_ncranges(ws::Worksheet, ncrng::NonContiguousRange)
throw(XLSXError("Non-contiguous range $ncrng is out of bounds. Worksheet `$(ws.name)` only has dimension `$dim`."))
end
end
-function process_colon(ws::Worksheet, row, col)
- dim = get_dimension(ws)
- @assert isnothing(row) || isnothing(col) "Something wrong here!"
- if isnothing(row) && isnothing(col)
- return setUniformStyle(ws, dim)
- elseif isnothing(col)
- rng = CellRange(CellRef(first(row), dim.start.column_number), CellRef(last(row), dim.stop.column_number))
- else
- rng = CellRange(CellRef(dim.start.row_number, first(col)), CellRef(dim.stop.row_number, last(col)))
-# else
-# throw(XLSXError("Something wrong here!"))
- end
-
- return setUniformStyle(ws, rng)
-end
+process_colon(ws::Worksheet, row, col) = process_colon(setUniformStyle, ws, row, col)
function process_uniform_veccolon(ws::Worksheet, row, col)
dim = get_dimension(ws)
- @assert isnothing(row) || isnothing(col) "Something wrong here!"
- if isnothing(col)
- col = dim.start.column_number:dim.stop.column_number
- else
- row = dim.start.row_number:dim.stop.row_number
-# else
-# throw(XLSXError("Something wrong here!"))
- end
+ row, col = _expand_colon(dim, row, col)
isInDim(ws, dim, row, col)
let newid::Union{Int,Nothing}, first::Bool
newid = nothing
@@ -1025,9 +968,7 @@ function process_uniform_core(f::Function, ws::Worksheet, allXfNodes::Vector{XML
return newid, first, alignment_node
end
function process_uniform_attribute(f::Function, ws::Worksheet, rng::CellRange; kw...)
- if !get_xlsxfile(ws).use_cache_for_sheet_data
- throw(XLSXError("Cannot set uniform attributes because cache is not enabled."))
- end
+ _require_cache(ws, "set uniform attributes")
allXfNodes = get_cellXfs_nodes(get_workbook(ws))
let newid::Union{Int,Nothing}, first::Bool, alignment_node::Union{XML.Node,Nothing}
newid = nothing
@@ -1065,11 +1006,6 @@ function process_uniform_ncranges(f::Function, ws::Worksheet, ncrng::NonContiguo
first = true
alignment_node = nothing
for r in ncrng.rng
- @assert r isa CellRef || r isa CellRange "Something wrong here"
- if r isa CellRef && getcell(ws, r) isa EmptyCell
- single && throw(XLSXError("Cannot set format for an `EmptyCell`: $(cellname(r)). Set the value first."))
- continue
- end
if r isa CellRef
if getcell(ws, r) isa EmptyCell
single && throw(XLSXError("Cannot set format for an `EmptyCell`: $(cellname(r)). Set the value first."))
@@ -1080,8 +1016,6 @@ function process_uniform_ncranges(f::Function, ws::Worksheet, ncrng::NonContiguo
for c in r
newid, first, alignment_node = process_uniform_core(f, ws, allXfNodes, c, newid, first, alignment_node; kw...)
end
-# else
-# throw(XLSXError("Something wrong here!"))
end
end
if first
@@ -1098,22 +1032,15 @@ function process_uniform_veccolon(f::Function, ws::Worksheet, row, col; kw...)
if dim === nothing
throw(XLSXError("No worksheet dimension found"))
else
- @assert isnothing(row) || isnothing(col) "Something wrong here!"
allXfNodes = get_cellXfs_nodes(get_workbook(ws))
- if isnothing(col)
- col = dim.start.column_number:dim.stop.column_number
- else
- row = dim.start.row_number:dim.stop.row_number
-# else
-# throw(XLSXError("Something wrong here!"))
- end
+ row, col = _expand_colon(dim, row, col)
isInDim(ws, dim, row, col)
let newid::Union{Int,Nothing}, first::Bool, alignment_node::Union{XML.Node,Nothing}
newid = nothing
first = true
alignment_node = nothing
for a in row
- for b in dim.start.column_number:dim.stop.column_number
+ for b in col
cellref = CellRef(a, b)
if getcell(ws, cellref) isa EmptyCell
continue
@@ -1154,44 +1081,44 @@ function process_uniform_vecint(f::Function, ws::Worksheet, row, col; kw...)
end
+# The run properties (`` children) that name a run's typeface. A cell font that
+# sets any of them sets the typeface as a whole, so overrides all of them. XLSX.jl only
+# writes `rFont`, but files from Excel carry `family` and `scheme` beside it (and
+# `charset` for many fonts). `scheme` must go when the name changes, or Excel shows
+# the theme font in place of the new one.
+const RUN_FACE_TAGS = ("rFont", "family", "scheme", "charset")
+
+# A cell font's tags are the run properties it overrides: the same names, except the
+# cell font's `name` (or any other typeface tag) stands for all of `RUN_FACE_TAGS`.
+_run_tags(font_tag::AbstractString) = font_tag in ("name", RUN_FACE_TAGS...) ? RUN_FACE_TAGS : (font_tag,)
+
+# Each cell of a uniform range takes the first cell's font, so a rich-text cell's runs
+# lose every property that font sets.
function update_sharedString_font(ws::Worksheet, cell::Cell, firstFont::CellFont) :: Union{Nothing,Int64}
- let bold=nothing, italic=nothing, under=nothing, strike=nothing, size=nothing, color=nothing, name=nothing
- for (k, v) in firstFont.font
- if k=="b"
- bold = true
- elseif k=="i"
- italic = true
- elseif k=="strike"
- strike = true
- elseif k=="u"
- under = v === nothing ? nothing : v["val"]
- elseif k=="sz"
- size = v === nothing ? nothing : parse(Int, v["val"])
- elseif k=="color"
- color = v
- elseif k=="name"
- name = v === nothing ? nothing : v["val"]
- else
- throw(XLSXError("Something wrong here!"))
- end
- end
- return update_sharedString_font(ws, cell; bold, italic, under, strike, size, color, name)
+ tags = Set{String}()
+ for k in keys(firstFont.font)
+ union!(tags, _run_tags(k))
end
+ return _strip_run_props!(ws, cell, tags)
end
-
-function update_sharedString_font(ws::Worksheet, cell::Cell;
- bold::Union{Nothing,Bool}=nothing,
- italic::Union{Nothing,Bool}=nothing,
- under::Union{Nothing,String}=nothing,
- strike::Union{Nothing,Bool}=nothing,
- size::Union{Nothing,Int}=nothing,
- color::Union{Nothing,String,Dict{String,String}}=nothing,
- name::Union{Nothing,String}=nothing
-) :: Union{Nothing,Int64}
- # elements in a sharedString override any font attributes in the cell Style.
- # If setFont is called, we need to replace any of the attributes it is setting in the elements.
- # When this makes successive elements identical, the elements that contain them can be merged.
- # starting values
+
+# `setFont` and `setUniformFont` give the font as keywords; each one given overrides
+# the matching run properties.
+function update_sharedString_font(ws::Worksheet, cell::Cell, s::FontSpec) :: Union{Nothing,Int64}
+ tags = Set{String}()
+ for (given, font_tag) in ((!isnothing(s.bold), "b"), (!isnothing(s.italic), "i"), (!isnothing(s.strike), "strike"),
+ (!isnothing(s.under), "u"), (!isnothing(s.size), "sz"), (!isnothing(s.color), "color"),
+ (!isnothing(s.name), "name"), (!isnothing(s.vertAlign), "vertAlign"))
+ given && union!(tags, _run_tags(font_tag))
+ end
+ return _strip_run_props!(ws, cell, tags)
+end
+
+# elements in a sharedString override any font attributes in the cell Style.
+# When the cell's font changes, remove from each run the properties named in `tags`,
+# so the new cell font shows through, and keep every other property. When this makes
+# successive elements identical, the elements that contain them are merged.
+function _strip_run_props!(ws::Worksheet, cell::Cell, tags::Set{String}) :: Union{Nothing,Int64}
wb=get_workbook(ws)
index=reinterpret(Int64,cell.value)
sst=get_sst(wb)
@@ -1217,34 +1144,12 @@ function update_sharedString_font(ws::Worksheet, cell::Cell;
first_had_rPr = !isnothing(rPrs[1])
- atts = ["b", "i", "strike", "u", "vertAlign", "sz", "color", "rFont", "family", "scheme"]
-
for rPr in rPrs
isnothing(rPr) && continue # nothing to strip from an unattributed run
-
- new_rPr = fill(XML.Element("DeleteMe"), length(atts))
- for att in XML.children(rPr)
- for i in eachindex(atts)
- XML.tag(att) == atts[i] && (new_rPr[i] = att)
- end
- end
-
- !isnothing(bold) && (new_rPr[1] = XML.Element("DeleteMe"))
- !isnothing(italic) && (new_rPr[2] = XML.Element("DeleteMe"))
- !isnothing(strike) && (new_rPr[3] = XML.Element("DeleteMe"))
- !isnothing(under) && (new_rPr[4] = XML.Element("DeleteMe"))
- !isnothing(size) && (new_rPr[6] = XML.Element("DeleteMe"))
- !isnothing(color) && (new_rPr[7] = XML.Element("DeleteMe"))
- if !isnothing(name)
- new_rPr[8] = new_rPr[9] = new_rPr[10] = XML.Element("DeleteMe")
- end
-
- if !isnothing(rPr.children)
- empty!(rPr.children)
- foreach(new_rPr) do element
- XML.tag(element) != "DeleteMe" && push!(rPr.children, element)
- end
- end
+ # Whitespace between the properties is dropped too, so that a stripped-bare
+ # is empty below.
+ isnothing(rPr.children) ||
+ filter!(k -> XML.nodetype(k) == XML.Element && localname(k) ∉ tags, rPr.children)
end
# An stripped down to nothing means the same as no at all:
@@ -1274,11 +1179,12 @@ function update_sharedString_font(ws::Worksheet, cell::Cell;
new_index = add_shared_string!(wb, t[1])
- if !isnothing(atts1) && !isempty(atts1)
+ kw = _run_font_kwargs(atts1)
+ if !isempty(kw)
# Point the cell at the plain string first, otherwise the setFont
# below re-enters this function on the same rich text and recurses.
cell.value = reinterpret(UInt64, Int64(new_index))
- setFont(ws, cell.ref; atts1...)
+ setFont(ws, cell.ref; kw...)
end
else
new_r = IOBuffer()
@@ -1301,4 +1207,155 @@ function update_sharedString_font(ws::Worksheet, cell::Cell;
new_index = add_formatted_string!(wb, sst, str_formatted)
end
return new_index
+end
+
+# A rich-text run's attributes as `setFont` keywords: `:under => true` is a single
+# underline, and false or absent flags set nothing.
+function _run_font_kwargs(atts::Union{Nothing,AbstractDict})
+ kw = Pair{Symbol,Any}[]
+ isnothing(atts) && return kw
+ for (k, v) in atts
+ if k in (:bold, :italic, :strike)
+ v in (true, 1) && push!(kw, k => true)
+ elseif k === :under
+ v in (true, 1) ? push!(kw, :under => "single") : v isa AbstractString && push!(kw, :under => String(v))
+ elseif k === :name || k === :vertAlign
+ push!(kw, k => String(v))
+ else # :size, :color
+ push!(kw, k => v)
+ end
+ end
+ return kw
+end
+
+#
+# --- Address forms shared by the formatting setters
+#
+# Each setter is written by hand for one target: a `CellRef` for the per-cell
+# setters, a `CellRange` for the uniform ones. These define every other way of
+# naming the target (sheet-qualified references, strings, rows and columns,
+# colons, vectors and step ranges), each reducing to that one. Generating them
+# keeps each family identical, so a form added or a routing fixed here applies
+# to every setter in it.
+
+# Per-cell setters (`setFont`, `setFill`, ...). A setter that defines the
+# `CellRange`, `ColumnRange`, `RowRange` and `XLSXFile` forms itself, as
+# `setBorder` does for its `outside` keyword, passes `ranges=false`.
+# Each entry is (arguments, body, return type or `nothing`).
+function _percell_address_methods(F::Symbol, ranges::Bool)
+ IntOrUR = :(Union{Integer,UnitRange{<:Integer}})
+ VecOrSR = :(Union{Vector{Int},StepRange{<:Integer}})
+ ms = Any[
+ ((:(ws::Worksheet), :(ref::SheetCellRef)), :(do_sheet_names_match(ws, ref) && $F(ws, ref.cellref; kw...)), nothing),
+ ((:(ws::Worksheet), :(rng::SheetCellRange)), :(do_sheet_names_match(ws, rng) && $F(ws, rng.rng; kw...)), nothing),
+ ((:(ws::Worksheet), :(rng::SheetColumnRange)), :(do_sheet_names_match(ws, rng) && $F(ws, rng.colrng; kw...)), nothing),
+ ((:(ws::Worksheet), :(rng::SheetRowRange)), :(do_sheet_names_match(ws, rng) && $F(ws, rng.rowrng; kw...)), nothing),
+ ((:(ws::Worksheet), :(ncrng::NonContiguousRange)), :(process_ncranges($F, ws, ncrng; kw...)), :Int),
+ ((:(ws::Worksheet), :(ref_or_rng::AbstractString)), :(process_ranges($F, ws, ref_or_rng; kw...)), :Int),
+ ((:(ws::Worksheet), :(row::$IntOrUR), :(c::Colon)), :(process_colon($F, ws, row, nothing; kw...)), nothing),
+ ((:(ws::Worksheet), :(r::Colon), :(col::$IntOrUR)), :(process_colon($F, ws, nothing, col; kw...)), nothing),
+ ((:(ws::Worksheet), :(r::Colon), :(c::Colon)), :(process_colon($F, ws, nothing, nothing; kw...)), nothing),
+ ((:(ws::Worksheet), :(rc::Colon)), :(process_colon($F, ws, nothing, nothing; kw...)), nothing),
+ ((:(ws::Worksheet), :(row::$VecOrSR), :(c::Colon)), :(process_veccolon($F, ws, row, nothing; kw...)), nothing),
+ ((:(ws::Worksheet), :(r::Colon), :(col::$VecOrSR)), :(process_veccolon($F, ws, nothing, col; kw...)), nothing),
+ ((:(ws::Worksheet), :(row::$IntOrUR), :(col::$VecOrSR)), :(process_vecint($F, ws, row, col; kw...)), nothing),
+ ((:(ws::Worksheet), :(row::$VecOrSR), :(col::$IntOrUR)), :(process_vecint($F, ws, row, col; kw...)), nothing),
+ ((:(ws::Worksheet), :(row::$VecOrSR), :(col::$VecOrSR)), :(process_vecint($F, ws, row, col; kw...)), nothing),
+ ((:(ws::Worksheet), :(row::$IntOrUR), :(col::$IntOrUR)),
+ :($F(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)), nothing),
+ ]
+ ranges && append!(ms, Any[
+ ((:(ws::Worksheet), :(rng::CellRange)), :(process_cellranges($F, ws, rng; kw...)), :Int),
+ ((:(ws::Worksheet), :(colrng::ColumnRange)), :(process_columnranges($F, ws, colrng; kw...)), :Int),
+ ((:(ws::Worksheet), :(rowrng::RowRange)), :(process_rowranges($F, ws, rowrng; kw...)), :Int),
+ ((:(xl::XLSXFile), :(sheetcell::String)), :(process_sheetcell($F, xl, sheetcell; kw...)), :Int),
+ ])
+ return ms
+end
+
+function define_percell_address_methods(F::Symbol; ranges::Bool=true)
+ for (args, body, T) in _percell_address_methods(F, ranges)
+ sig = :($F($(args...); kw...))
+ isnothing(T) || (sig = :($sig::$T))
+ @eval $sig = $body
+ end
+ return nothing
+end
+
+# Defines the public setter `F` over the internal setter `G`: each method of `F`
+# gathers its keywords into one `S(:F; kw...)` and passes it to the same method
+# of `G` as the keyword `spec`. The methods of `F` are those in `methods`, so its
+# signatures, and the errors for a target it doesn't take, are those of any
+# other setter in its family; but the many helpers behind `G` see only the
+# keyword `spec`, so they compile once, rather than again for every new set of
+# keywords. An unrecognized keyword is an `XLSXError` naming `F`.
+function _define_spec_wrappers(F::Symbol, G::Symbol, S::Symbol, methods)
+ for (args, _, _) in methods
+ names = [a.args[1] for a in args]
+ @eval $F($(args...); kw...) = $G($(names...); spec=$S($(QuoteNode(F)); kw...))
+ end
+ return nothing
+end
+
+# A per-cell setter `F` taking its keywords as an `S` (see `_define_spec_wrappers`).
+# The internal setter `G` defines the `CellRef` method, taking `spec`, and with
+# `ranges=false` the range methods too.
+function define_percell_spec_methods(F::Symbol, G::Symbol, S::Symbol; ranges::Bool=true)
+ define_percell_address_methods(G; ranges)
+ cellref = ((:(ws::Worksheet), :(ref::CellRef)), nothing, nothing)
+ _define_spec_wrappers(F, G, S, [_percell_address_methods(G, true); [cellref]])
+ return nothing
+end
+
+# Uniform setters (`setUniformFont`, ...). `P` is the per-cell setter applied to
+# the first cell, and `atts` the cellXfs attributes then copied to the others
+# (e.g. `["fontId", "applyFont"]`). `setUniformAlignment` copies an alignment
+# node rather than attributes, so passes `atts=nothing`.
+# Each entry is (arguments, body, return type or `nothing`).
+function _uniform_address_methods(F::Symbol, P::Symbol, atts::Union{Nothing,Vector{String}})
+ a = isnothing(atts) ? () : (:([$(atts...)]),) # a fresh vector literal at each call
+ IntOrUR = :(Union{Integer,UnitRange{<:Integer}})
+ VecOrSR = :(Union{Vector{Int},StepRange{<:Integer}})
+ return Any[
+ ((:(ws::Worksheet), :(rng::SheetCellRange)), :(do_sheet_names_match(ws, rng) && $F(ws, rng.rng; kw...)), nothing),
+ ((:(ws::Worksheet), :(rng::SheetColumnRange)), :(do_sheet_names_match(ws, rng) && $F(ws, rng.colrng; kw...)), nothing),
+ ((:(ws::Worksheet), :(rng::SheetRowRange)), :(do_sheet_names_match(ws, rng) && $F(ws, rng.rowrng; kw...)), nothing),
+ ((:(ws::Worksheet), :(colrng::ColumnRange)), :(process_columnranges($F, ws, colrng; kw...)), :Int),
+ ((:(ws::Worksheet), :(rowrng::RowRange)), :(process_rowranges($F, ws, rowrng; kw...)), :Int),
+ ((:(ws::Worksheet), :(ncrng::NonContiguousRange)), :(process_uniform_ncranges($P, ws, ncrng, $(a...); kw...)), :Int),
+ ((:(xl::XLSXFile), :(sheetcell::AbstractString)), :(process_sheetcell($F, xl, sheetcell; kw...)), :Int),
+ ((:(ws::Worksheet), :(ref_or_rng::AbstractString)), :(process_ranges($F, ws, ref_or_rng; kw...)), :Int),
+ ((:(ws::Worksheet), :(row::$IntOrUR), :(c::Colon)), :(process_colon($F, ws, row, nothing; kw...)), nothing),
+ ((:(ws::Worksheet), :(r::Colon), :(col::$IntOrUR)), :(process_colon($F, ws, nothing, col; kw...)), nothing),
+ ((:(ws::Worksheet), :(r::Colon), :(c::Colon)), :(process_colon($F, ws, nothing, nothing; kw...)), nothing),
+ ((:(ws::Worksheet), :(rc::Colon)), :(process_colon($F, ws, nothing, nothing; kw...)), nothing),
+ ((:(ws::Worksheet), :(row::$VecOrSR), :(c::Colon)), :(process_uniform_veccolon($P, ws, row, nothing, $(a...); kw...)), nothing),
+ ((:(ws::Worksheet), :(r::Colon), :(col::$VecOrSR)), :(process_uniform_veccolon($P, ws, nothing, col, $(a...); kw...)), nothing),
+ ((:(ws::Worksheet), :(row::$IntOrUR), :(col::$VecOrSR)), :(process_uniform_vecint($P, ws, row, col, $(a...); kw...)), nothing),
+ ((:(ws::Worksheet), :(row::$VecOrSR), :(col::$IntOrUR)), :(process_uniform_vecint($P, ws, row, col, $(a...); kw...)), nothing),
+ ((:(ws::Worksheet), :(row::$VecOrSR), :(col::$VecOrSR)), :(process_uniform_vecint($P, ws, row, col, $(a...); kw...)), nothing),
+ ((:(ws::Worksheet), :(row::$IntOrUR), :(col::$IntOrUR)),
+ :($F(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)), nothing),
+ ((:(ws::Worksheet), :(rng::CellRange)), :(process_uniform_attribute($P, ws, rng, $(a...); kw...)), :Int),
+ ]
+end
+
+function define_uniform_address_methods(F::Symbol, P::Symbol, atts::Union{Nothing,Vector{String}})
+ for (args, body, T) in _uniform_address_methods(F, P, atts)
+ sig = :($F($(args...); kw...))
+ isnothing(T) || (sig = :($sig::$T))
+ @eval $sig = $body
+ end
+ return nothing
+end
+
+# A uniform setter `F` taking its keywords as an `S` (see `_define_spec_wrappers`),
+# over the internal uniform setter `G` and internal per-cell setter `P`.
+function define_uniform_spec_methods(F::Symbol, G::Symbol, P::Symbol, S::Symbol, atts::Union{Nothing,Vector{String}})
+ define_uniform_address_methods(G, P, atts)
+ _define_spec_wrappers(F, G, S, _uniform_address_methods(G, P, atts))
+ # A string naming a single cell reaches `G` as a `CellRef`, which a uniform
+ # setter doesn't take: report it against `F`, as for any other target it doesn't take.
+ @eval $G(ws::Worksheet, ref::CellRef; _...) = throw(MethodError($F, (ws, ref)))
+ return nothing
end
\ No newline at end of file
diff --git a/src/cellformats.jl b/src/cellformats.jl
deleted file mode 100644
index 251d5c1f..00000000
--- a/src/cellformats.jl
+++ /dev/null
@@ -1,2623 +0,0 @@
-
-# ==========================================================================================
-#
-# -- Get and set font attributes
-#
-
-"""
- setFont(sh::Worksheet, cr::String; kw...) -> ::Int
- setFont(xf::XLSXFile, cr::String, kw...) -> ::Int
-
- setFont(sh::Worksheet, row, col; kw...) -> ::Int
-
-Set the font used by a single cell, a cell range, a column range or
-row range or a named cell or named range in a worksheet or XLSXFile.
-Alternatively, specify the row and column using any combination of
-Integer, UnitRange, Vector{Integer} or `:`.
-
-Font attributes are specified using keyword arguments:
-- `bold::Bool = nothing` : set to `true` to make the font bold.
-- `italic::Bool = nothing` : set to `true` to make the font italic.
-- `under::String = nothing` : set to `single`, `double` or `none`.
-- `strike::Bool = nothing` : set to `true` to strike through the font.
-- `size::Int = nothing` : set the font size (0 < size < 410).
-- `color::String = nothing` : set the font color.
-- `name::String = nothing` : set the font name.
-- `vertAlign::String = nothing` : set to `superscript`, `subscript` or `baseline`.
-
-Only the attributes specified will be changed. If an attribute is not specified, the current
-value will be retained. These are the only attributes supported currently.
-
-No validation of the font names specified is performed. Available fonts will depend
-on what your system has installed. If you specify, for example, `name = "badFont"`,
-that value will be written to the XLSXFile.
-
-As an expedient to get fonts to work, the `scheme` attribute is simply dropped from
-new font definitions.
-
-The `color` attribute can be defined using 8-digit rgb values.
-- The first two digits represent transparency (α). Excel ignores transparency.
-- The next two digits give the red component.
-- The next two digits give the green component.
-- The next two digits give the blue component.
-So, FF000000 means a fully opaque black color.
-
-Alternatively, you can use the name of any named color from [Colors.jl](https://juliagraphics.github.io/Colors.jl/stable/namedcolors/).
-
-Font attributes cannot be set for `EmptyCell`s. Set a cell value first.
-If a cell range or column range includes any `EmptyCell`s, they will be
-quietly skipped and the font will be set for the remaining cells.
-
-For single cells, the value returned is the `fontId` of the font applied to the cell.
-This can be used to apply the same font to other cells or ranges.
-
-For cell ranges, column ranges and named ranges, the value returned is -1 unless
-the range resolves to a single cell, in which case that cell's id is returned.
-
-# Examples:
-```julia
-julia> setFont(sh, "A1"; bold=true, italic=true, size=12, name="Arial") # Single cell
-
-julia> setFont(xf, "Sheet1!A1"; bold=false, size=14, color="yellow") # Single cell
-
-julia> setFont(sh, "A1:B7"; name="Aptos", under="double", strike=true) # Cell range
-
-julia> setFont(xf, "Sheet1!A1:B7"; size=24, name="Berlin Sans FB Demi") # Cell range
-
-julia> setFont(sh, "A:B"; italic=true, color="green", under="single") # Column range
-
-julia> setFont(xf, "Sheet1!A:B"; italic=true, color="red", under="single") # Column range
-
-julia> setFont(xf, "Sheet1!6:12"; italic=false, color="FF8888FF", under="none") # Row range
-
-julia> setFont(sh, "bigred"; size=48, color="FF00FF00") # Named cell or range
-
-julia> setFont(xf, "bigred"; size=48, color="magenta") # Named cell or range
-
-julia> setFont(sh, 1, 2; size=48, color="magenta") # row and column as integers
-
-julia> setFont(sh, 1:3, 2; size=48, color="magenta") # row as unit range
-
-julia> setFont(sh, 6, [2, 3, 8, 12]; size=48, color="magenta") # column as vector of indices
-
-julia> setFont(sh, :, 2:6; size=48, color="lightskyblue2") # all rows, columns 2 to 6
-
-```
-"""
-function setFont end
-setFont(ws::Worksheet, ref::SheetCellRef; kw...) = do_sheet_names_match(ws, ref) && setFont(ws, ref.cellref; kw...)
-setFont(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setFont(ws, rng.rng; kw...)
-setFont(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setFont(ws, rng.colrng; kw...)
-setFont(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setFont(ws, rng.rowrng; kw...)
-setFont(ws::Worksheet, rng::CellRange; kw...)::Int = process_cellranges(setFont, ws, rng; kw...)
-setFont(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setFont, ws, colrng; kw...)
-setFont(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setFont, ws, rowrng; kw...)
-setFont(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(setFont, ws, ncrng; kw...)
-setFont(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setFont, ws, ref_or_rng; kw...)
-setFont(xl::XLSXFile, sheetcell::String; kw...)::Int = process_sheetcell(setFont, xl, sheetcell; kw...)
-#setFont(ws::Worksheet, row::Integer, col::Integer; kw...) = setFont(ws, CellRef(row, col); kw...)
-setFont(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setFont, ws, row, nothing; kw...)
-setFont(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setFont, ws, nothing, col; kw...)
-setFont(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setFont, ws, nothing, nothing; kw...)
-setFont(ws::Worksheet, ::Colon; kw...) = process_colon(setFont, ws, nothing, nothing; kw...)
-setFont(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_veccolon(setFont, ws, row, nothing; kw...)
-setFont(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setFont, ws, nothing, col; kw...)
-setFont(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setFont, ws, row, col; kw...)
-setFont(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_vecint(setFont, ws, row, col; kw...)
-setFont(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setFont, ws, row, col; kw...)
-setFont(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setFont(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-
-function setFont(sh::Worksheet, cellref::CellRef;
- bold::Union{Nothing,Bool}=nothing,
- italic::Union{Nothing,Bool}=nothing,
- under::Union{Nothing,String}=nothing,
- strike::Union{Nothing,Bool}=nothing,
- size::Union{Nothing,Int}=nothing,
- color::Union{Nothing,String}=nothing,
- name::Union{Nothing,String}=nothing,
- vertAlign::Union{Nothing,String}=nothing
-)::Int
- get_xlsxfile(sh).is_writable || throw(XLSXError("Cannot set font because XLSXFile is not writable."))
-
- # Validate before any work
- !isnothing(under) && under ∉ ("none", "single", "double") &&
- throw(XLSXError("Invalid value for under: $under. Must be one of: `none`, `single`, `double`."))
- !isnothing(size) && (size < 1 || size > 409) &&
- throw(XLSXError("Invalid size value: $size. Must be between 1 and 409."))
-
- pfx = get_prefix("xl/styles.xml", get_xlsxfile(sh))
- pfx = pfx == "" ? "" : pfx * ":"
-
- wb = get_workbook(sh)
- cell = getcell(sh, cellref)
- cell isa EmptyCell && throw(XLSXError("Cannot set font for an `EmptyCell`: $(cellname(cellref)). Set the value first."))
-
- allXfNodes = get_cellXfs_nodes(wb)
-
- if cell.style == UInt32(0)
- cell.style = get_num_style_index(sh, allXfNodes, 0).id
- end
-
- cell_style = styles_cell_xf(allXfNodes, cell.style)
- cell_font = getFont(wb, cell_style)
- old_font_atts = cell_font.font
-
- new_font_atts = Dict{String,Union{Dict{String,String},Nothing}}()
-
- # Boolean/flag tags
- for (tag, val) in (("b", bold), ("i", italic), ("strike", strike))
- result = _merge_flag_tag(tag, val, old_font_atts)
- ismissing(result) || (new_font_atts[tag] = result)
- end
-
- # Single-value tags
- for (tag, val) in (("sz", size), ("name", name), ("vertAlign", vertAlign))
- result = _merge_val_tag(tag, val, old_font_atts)
- ismissing(result) || (new_font_atts[tag] = result)
- end
-
- # Color tag
- result = _merge_dict_tag("color", color, old_font_atts, c -> Dict("rgb" => get_color(c)))
- ismissing(result) || (new_font_atts["color"] = result)
-
- # Underline: "none" explicitly removes, "single" => no attributes, "double" => val attribute
- # This doesn't fit the standard merge helpers due to the three-way new_val distinction.
- if isnothing(under)
- haskey(old_font_atts, "u") && (new_font_atts["u"] = old_font_atts["u"])
- elseif under == "single"
- new_font_atts["u"] = nothing
- elseif under == "double"
- new_font_atts["u"] = Dict("val" => "double")
- end
- # under == "none": omit "u" entirely — already absent from new_font_atts
-
- # Drop "scheme" tag — Excel adds it but we don't want to propagate it
- font_node = buildNode("font", new_font_atts, pfx)
- new_fontid = styles_add_cell_attribute(wb, font_node, "fonts")
-
- newstyle = update_template_xf(sh, allXfNodes, CellDataFormat(cell.style), ["fontId", "applyFont"], [string(new_fontid), "1"]).id
- cell.style = newstyle
-
- if cell.datatype == CT_STRING
- v = update_sharedString_font(sh, cell; bold, italic, under, strike, size, color, name)
- cell.value = isnothing(v) ? cell.value : reinterpret(UInt64, Int64(v))
- end
-
- return new_fontid
-end
-
-"""
- setUniformFont(sh::Worksheet, cr::String; kw...) -> ::Int
- setUniformFont(xf::XLSXFile, cr::String, kw...) -> ::Int
-
- setUniformFont(sh::Worksheet, rows, cols; kw...) -> ::Int
-
-Set the font used by a cell range, a column range or row range or
-a named range in a worksheet or XLSXFile to be uniformly the same font.
-Alternatively, specify the rows and columns using any combination of
-Integer, UnitRange, Vector{Integer} or `:`.
-
-First, the font attributes of the first cell in the range (the top-left cell) are
-updated according to the given `kw...` (using `setFont()`). The resultant font is
-then applied to each remaining cell in the range.
-
-As a result, every cell in the range will have a uniform font setting.
-
-This differs from `setFont()` which merges the attributes defined by `kw...` into
-the font definition used by each cell individually. For example, if you set the
-font size to 12 for a range of cells, but these cells all use different fonts names
-or colors, etc, `setFont()` will change the font size but leave the font name and
-color unchanged for each cell individually.
-
-In contrast, `setUniformFont()` will set the font size to 12 for the first cell, but
-will then apply all the font attributes from the updated first cell (ie. name, color,
-etc) to all the other cells in the range.
-
-This can be more efficient when setting the same font for a large number of cells.
-
-Applying `setUniformFont()` without any keyword arguments simply copies the `Font`
-attributes from the first cell specified to all the others.
-
-The value returned is the `fontId` of the font uniformly applied to the cells.
-If all cells in the range are `EmptyCells` the returned value is -1.
-
-For keyword definitions see [`setFont()`](@ref).
-
-# Examples:
-```julia
-julia> setUniformFont(sh, "A1:B7"; bold=true, italic=true, size=12, name="Arial") # Cell range
-
-julia> setUniformFont(xf, "Sheet1!A1:B7"; size=24, name="Berlin Sans FB Demi") # Cell range
-
-julia> setUniformFont(sh, "A:B"; italic=true, color="FF8888FF", under="single") # Column range
-
-julia> setUniformFont(xf, "Sheet1!A:B"; italic=true, color="FF8888FF", under="single") # Column range
-
-julia> setUniformFont(sh, "33"; italic=true, color="FF8888FF", under="single") # Row
-
-julia> setUniformFont(sh, "bigred"; size=48, color="FF00FF00") # Named range
-
-julia> setUniformFont(sh, 1, [2, 4, 6]; size=48, color="lightskyblue2") # vector of column indices
-
-julia> setUniformFont(sh, "B2,A5:D22") # Copy `Font` from B2 to cells in A5:D22
-
-```
-"""
-function setUniformFont end
-setUniformFont(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setUniformFont(ws, rng.rng; kw...)
-setUniformFont(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setUniformFont(ws, rng.colrng; kw...)
-setUniformFont(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setUniformFont(ws, rng.rowrng; kw...)
-setUniformFont(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setUniformFont, ws, colrng; kw...)
-setUniformFont(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setUniformFont, ws, rowrng; kw...)
-setUniformFont(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_uniform_ncranges(setFont, ws, ncrng, ["fontId", "applyFont"]; kw...)
-setUniformFont(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setUniformFont, xl, sheetcell; kw...)
-setUniformFont(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setUniformFont, ws, ref_or_rng; kw...)
-setUniformFont(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setFont, ws, row, nothing; kw...)
-setUniformFont(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setFont, ws, nothing, col; kw...)
-setUniformFont(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setUniformFont, ws, nothing, nothing; kw...)
-setUniformFont(ws::Worksheet, ::Colon; kw...) = process_colon(setUniformFont, ws, nothing, nothing; kw...)
-setUniformFont(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_uniform_veccolon(setFont, ws, row, nothing, ["fontId", "applyFont"]; kw...)
-setUniformFont(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_veccolon(setFont, ws, nothing, col, ["fontId", "applyFont"]; kw...)
-setUniformFont(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_vecint(setFont, ws, row, col, ["fontId", "applyFont"]; kw...)
-setUniformFont(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_uniform_vecint(setFont, ws, row, col, ["fontId", "applyFont"]; kw...)
-setUniformFont(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_vecint(setFont, ws, row, col, ["fontId", "applyFont"]; kw...)
-setUniformFont(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setUniformFont(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setUniformFont(ws::Worksheet, rng::CellRange; kw...)::Int = process_uniform_attribute(setFont, ws, rng, ["fontId", "applyFont"]; kw...)
-
-
-"""
- getFont(sh::Worksheet, cr::String) -> ::Union{Nothing, CellFont}
- getFont(xf::XLSXFile, cr::String) -> ::Union{Nothing, CellFont}
-
- getFont(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, CellFont}
-
-Get the font used by a single cell reference in a worksheet `sh` or XLSXFile `xf`.
-The specified cell must be within the sheet dimension.
-
-Return a `CellFont` object containing:
-- `fontId` : a 0-based index of the font in the workbook
-- `font` : a dictionary of font attributes: fontAttribute -> (attribute -> value)
-- `applyFont` : "1" or "0", indicating whether or not the font is applied to the cell.
-
-Return `nothing` if no cell font is found.
-
-Excel uses several tags to define font properties in its XML structure.
-Here's a list of some common tags and their purposes (thanks to Copilot!):
-- `b` : Indicates bold font.
-- `i` : Indicates italic font.
-- `strike` : Indicates strikethrough.
-- `condense` : Condenses the font spacing.
-- `extend` : Extends the font spacing.
-- `outline` : Specifies outline text.
-- `shadow` : Adds a shadow to the text.
-- `u` : Specifies underlining (e.g., single, double).
-- `sz` : Sets the font size.
-- `color` : Sets the font color using RGB values).
-- `name` : Specifies the font name.
-- `family` : Defines the font family.
-- `charset` : Specifies the character set code (e.g., 204 for Cyrillic).
-- `scheme` : Specifies whether the font is part of the major or minor theme.
-
-Excel defines colours in several ways. `getFont` will return the colour in any of these
-e.g. `"color" => ("theme" => "1")`.
-
-# Examples:
-```julia
-julia> getFont(sh, "A1")
-
-julia> getFont(xf, "Sheet1!A1")
-
-julia> getFont(sh, "Sheet1!A1")
-
-julia> getFont(sh, 1, 1)
-
-```
-"""
-function getFont end
-getFont(ws::Worksheet, cr::String) = process_get_cellname(getFont, ws, cr)
-getFont(xl::XLSXFile, sheetcell::String)::Union{Nothing,CellFont} = process_get_sheetcell(getFont, xl, sheetcell)
-getFont(ws::Worksheet, cellref::CellRef)::Union{Nothing,CellFont} = process_get_cellref(getFont, ws, cellref)
-getFont(ws::Worksheet, row::Integer, col::Integer) = getFont(ws, CellRef(row, col))
-getDefaultFont(ws::Worksheet) = getFont(get_workbook(ws), styles_cell_xf(get_workbook(ws), 0))
-function getFont(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellFont}
-
- if !get_xlsxfile(wb).use_cache_for_sheet_data
- throw(XLSXError("Cannot get font because cache is not enabled."))
- end
-
- if haskey(cell_style, "fontId")
- fontid = cell_style["fontId"]
- applyfont = haskey(cell_style, "applyFont") ? cell_style["applyFont"] : "0"
- font_nodes = get_fonts_nodes(wb)
- fontid_int = parse(Int, fontid)
- current_font = font_nodes[fontid_int + 1]
- font_atts = Dict{String,Union{Dict{String,String},Nothing}}()
- for c in xml_elements(current_font)
- if isnothing(XML.attributes(c)) || length(XML.attributes(c)) == 0
- font_atts[localname(c)] = nothing
- else
-# for (k, v) in XML.attributes(c)
- font_atts[localname(c)] = Dict(XML.attributes(c))
-# end
- end
- end
- return CellFont(fontid_int, font_atts, applyfont)
- end
-
- return nothing
-end
-
-#
-# -- Get and set border attributes
-#
-
-"""
- getBorder(sh::Worksheet, cr::String) -> ::Union{Nothing, CellBorder}
- getBorder(xf::XLSXFile, cr::String) -> ::Union{Nothing, CellBorder}
-
- getBorder(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, CellBorder}
-
-Get the borders used by a single cell at reference in a worksheet or XLSXFile.
-The specified cell must be within the sheet dimension.
-
-Return a `CellBorder` object containing:
-- `borderId` : a 0-based index of the border in the workbook
-- `border` : a dictionary of border attributes: borderAttribute -> (attribute -> value)
-- `applyBorder` : "1" or "0", indicating whether or not the border is applied to the cell.
-
-Return `nothing` if no cell border is found.
-
-A cell border has two attributes, `style` and `color`. A diagonal border also needs to specify
-a direction indicating whether a diagonal is needed from bottom-left to top-right ("up"),
-from top-left to bottom-left ("down") or "both"
-
-Here's a list of the available `style`s (thanks to Copilot!):
-- `none`
-- `thin`
-- `medium`
-- `dashed`
-- `dotted`
-- `thick`
-- `double`
-- `hair`
-- `mediumDashed`
-- `dashDot`
-- `mediumDashDot`
-- `dashDotDot`
-- `mediumDashDotDot`
-- `slantDashDot`
-
-A border postion element (e.g. `top` or `left`) has a `style` attribute, but `color` is a child element.
-The color element has one or two attributes (e.g. `rgb`) that define the color of the border.
-While the key for the `style` element will always be `style`, the keys for the `color` element
-will vary depending on how the color is defined (e.g. `rgb`, `indexed`, `auto`, etc.).
-Thus, for example, `"top" => Dict("style" => "thin", "rgb" => "FF000000")` would indicate a
-thin black border at the top of the cell while `"top" => Dict("style" => "thin", "auto" => "1")`
-would indicate that the color is set automatically by Excel.
-
-The `color` element can have the following attributes:
-- `auto` : Indicates that the color is automatically defined by Excel
-- `indexed` : Specifies the color using an indexed color value.
-- `rgb` : Specifies the rgb color using 8-digit hexadecimal format.
-- `theme` : Specifies the color using a theme color.
-- `tint` : Specifies the tint value to adjust the lightness or darkness of the color.
-
-`tint` can only be used in conjunction with the theme attribute to derive different shades of the theme color.
-For example: .
-
-# Examples:
-```julia
-julia> getBorder(sh, "A1")
-
-julia> getBorder(sh, 3, 6)
-
-julia> getBorder(xf, "Sheet1!A1")
-
-```
-"""
-function getBorder end
-getBorder(xl::XLSXFile, sheetcell::String)::Union{Nothing,CellBorder} = process_get_sheetcell(getBorder, xl, sheetcell)
-getBorder(ws::Worksheet, cellref::CellRef)::Union{Nothing,CellBorder} = process_get_cellref(getBorder, ws, cellref)
-getBorder(ws::Worksheet, cr::String) = process_get_cellname(getBorder, ws, cr)
-getBorder(ws::Worksheet, row::Integer, col::Integer) = getBorder(ws, CellRef(row, col))
-getDefaultBorders(ws::Worksheet) = getBorder(get_workbook(ws), styles_cell_xf(get_workbook(ws), 0))
-function getBorder(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellBorder}
-
- if !get_xlsxfile(wb).use_cache_for_sheet_data
- throw(XLSXError("Cannot get border because cache is not enabled."))
- end
-
- if haskey(cell_style, "borderId")
- borderid = cell_style["borderId"]
- applyborder = haskey(cell_style, "applyBorder") ? cell_style["applyBorder"] : "0"
- border_nodes = get_borders_nodes(wb)
- current_border = border_nodes[parse(Int, borderid)+1] # Zero based!
- diag_atts = XML.attributes(current_border)
- border_atts = Dict{String,Union{Dict{String,String},Nothing}}()
- for side in xml_elements(current_border)
- if isnothing(XML.attributes(side)) || length(XML.attributes(side)) == 0
- border_atts[localname(side)] = nothing
- else
- if length(XML.attributes(side)) != 1
- throw(XLSXError("Too many border attributes found for $(localname(side)) Expected 1, found $(length(XML.attributes(side)))."))
- end
- for (k, v) in XML.attributes(side) # style is the only possible attribute of a side
- border_atts[localname(side)] = Dict(k => v)
- if localname(side) == "diagonal" && !isnothing(diag_atts)
- if haskey(diag_atts, "diagonalUp") && haskey(diag_atts, "diagonalDown")
- border_atts[localname(side)]["direction"] = "both"
- elseif haskey(diag_atts, "diagonalUp")
- border_atts[localname(side)]["direction"] = "up"
- elseif haskey(diag_atts, "diagonalDown")
- border_atts[localname(side)]["direction"] = "down"
- else
- throw(XLSXError("No direction set for `diagonal` border"))
- end
- end
- for subc in xml_elements(side) # color is a child of a border element
- for (k, v) in XML.attributes(subc)
- border_atts[localname(side)][k] = v
- end
- end
- end
- end
- end
- return CellBorder(parse(Int, borderid), border_atts, applyborder)
- end
-
- return nothing
-end
-
-"""
- setBorder(sh::Worksheet, cr::String; kw...) -> ::Int
- setBorder(xf::XLSXFile, cr::String; kw...) -> ::Int
-
- setBorder(sh::Worksheet, row, col; kw...) -> ::Int
-
-Set the borders used used by a single cell, a cell range, a column range or
-row range or a named cell or named range in a worksheet or XLSXFile.
-Alternatively, specify the row and column using any combination of
-Integer, UnitRange, Vector{Integer} or `:`.
-
-Borders are independently defined for the keywords:
-- `left::Vector{Pair{String,String}} = nothing`
-- `right::Vector{Pair{String,String}} = nothing`
-- `top::Vector{Pair{String,String}} = nothing`
-- `bottom::Vector{Pair{String,String}} = nothing`
-- `diagonal::Vector{Pair{String,String}} = nothing`
-- `[allsides::Vector{Pair{String,String}} = nothing]`
-- `[outside::Vector{Pair{String,String}} = nothing]`
-
-These represent each of the sides of a cell . The keyword `diagonal` defines
-diagonal lines running across the cell. These lines must share the same style
-and color in any cell.
-
-An additional keyword, `allsides`, is provided for convenience. It can be used
-in place of the four side keywords to apply the same border setting to all four
-sides at once. It cannot be used in conjunction with any of the side-specific
-keywords or with `outside` but it can be used together with `diagonal`.
-
-A further keyword, `outside`, can be used to set the outside border around a
-range. Any internal borders will remain unchanged. An outside border cannot be
-set for any non-contiguous/non-rectangular range, cannot be indexed with
-vectors and cannot be used in conjunction with any other keywords.
-
-The two attributes that can be set for each keyword are `style` and `color`.
-Additionally, for diagonal borders, a third keyword, `direction` can be used.
-
-Allowed values for `style` are:
-- `none`
-- `thin`
-- `medium`
-- `dashed`
-- `dotted`
-- `thick`
-- `double`
-- `hair`
-- `mediumDashed`
-- `dashDot`
-- `mediumDashDot`
-- `dashDotDot`
-- `mediumDashDotDot`
-- `slantDashDot`
-
-The `color` attribute can be set by specifying an 8-digit hexadecimal value
-in the format "FFRRGGBB". The transparency ("FF") is ignored by Excel but
-is required.
-Alternatively, you can use the name of any named color from [Colors.jl](https://juliagraphics.github.io/Colors.jl/stable/namedcolors/).
-
-Valid values for the `direction` keyword (for diagonal borders) are:
-- `up` : diagonal border runs bottom-left to top-right
-- `down` : diagonal border runs top-left to bottom-right
-- `both` : diagonal borders run both ways
-
-Both diagonal borders share the same style and color.
-
-Setting only one of the attributes leaves the other attributes unchanged for that
-side's border. Omitting one of the keywords leaves the border definition for that
-side unchanged, only updating the other, specified sides.
-
-Border attributes cannot be set for `EmptyCell`s. Set a cell value first.
-If a cell range or column range includes any `EmptyCell`s, they will be
-quietly skipped and the border will be set for the remaining cells.
-
-For single cells, the value returned is the `borderId` of the borders applied to the cell.
-This can be used to apply the same borders to other cells or ranges.
-
-For cell ranges, column ranges and named ranges, the value returned is -1 unless
-the range resolves to a single cell, in which case that cell's id is returned.
-
-# Examples:
-```julia
-Julia> setBorder(sh, "D6"; allsides = ["style" => "thick"], diagonal = ["style" => "hair", "direction" => "up"])
-
-Julia> setBorder(sh, 2:45, 2:12; outside = ["style" => "thick", "color" => "lightskyblue2"])
-
-Julia> setBorder(xf, "Sheet1!D4"; left = ["style" => "dotted", "color" => "FF000FF0"],
- right = ["style" => "medium", "color" => "firebrick2"],
- top = ["style" => "thick", "color" => "FF230000"],
- bottom = ["style" => "medium", "color" => "goldenrod3"],
- diagonal = ["style" => "dotted", "color" => "FF00D4D4", "direction" => "both"]
- )
-
-```
-"""
-function setBorder end
-setBorder(ws::Worksheet, ref::SheetCellRef; kw...) = do_sheet_names_match(ws, ref) && setBorder(ws, ref.cellref; kw...)
-setBorder(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setBorder(ws, rng.rng; kw...)
-setBorder(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setBorder(ws, rng.colrng; kw...)
-setBorder(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setBorder(ws, rng.rowrng; kw...)
-setBorder(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(setBorder, ws, ncrng; kw...)
-setBorder(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setBorder, ws, ref_or_rng; kw...)
-#setBorder(ws::Worksheet, row::Integer, col::Integer; kw...) = setBorder(ws, CellRef(row, col); kw...)
-setBorder(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setBorder, ws, row, nothing; kw...)
-setBorder(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setBorder, ws, nothing, col; kw...)
-setBorder(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setBorder, ws, nothing, nothing; kw...)
-setBorder(ws::Worksheet, ::Colon; kw...) = process_colon(setBorder, ws, nothing, nothing; kw...)
-setBorder(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_veccolon(setBorder, ws, row, nothing; kw...)
-setBorder(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setBorder, ws, nothing, col; kw...)
-setBorder(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setBorder, ws, row, col; kw...)
-setBorder(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_vecint(setBorder, ws, row, col; kw...)
-setBorder(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setBorder, ws, row, col; kw...)
-setBorder(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setBorder(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-
-function setBorder(ws::Worksheet, rng::CellRange;
- outside::BorderKw=nothing, allsides::BorderKw=nothing,
- left::BorderKw=nothing, right::BorderKw=nothing,
- top::BorderKw=nothing, bottom::BorderKw=nothing,
- diagonal::BorderKw=nothing
-)::Int
- _check_outside_conflict(outside, left, right, top, bottom, diagonal, allsides)
- isnothing(outside) || return setOutsideBorder(ws, rng; outside)
- return process_cellranges(setBorder, ws, rng; allsides, left, right, top, bottom, diagonal)
-end
-
-function setBorder(ws::Worksheet, rng::ColumnRange;
- outside::BorderKw=nothing, allsides::BorderKw=nothing,
- left::BorderKw=nothing, right::BorderKw=nothing,
- top::BorderKw=nothing, bottom::BorderKw=nothing,
- diagonal::BorderKw=nothing
-)::Int
- _check_outside_conflict(outside, left, right, top, bottom, diagonal, allsides)
- isnothing(outside) || return process_columnranges(setOutsideBorder, ws, rng; outside)
- return process_columnranges(setBorder, ws, rng; allsides, left, right, top, bottom, diagonal)
-end
-
-function setBorder(ws::Worksheet, rng::RowRange;
- outside::BorderKw=nothing, allsides::BorderKw=nothing,
- left::BorderKw=nothing, right::BorderKw=nothing,
- top::BorderKw=nothing, bottom::BorderKw=nothing,
- diagonal::BorderKw=nothing
-)::Int
- _check_outside_conflict(outside, left, right, top, bottom, diagonal, allsides)
- isnothing(outside) || return process_rowranges(setOutsideBorder, ws, rng; outside)
- return process_rowranges(setBorder, ws, rng; allsides, left, right, top, bottom, diagonal)
-end
-
-function setBorder(xl::XLSXFile, sheetcell::String;
- outside::BorderKw=nothing, allsides::BorderKw=nothing,
- left::BorderKw=nothing, right::BorderKw=nothing,
- top::BorderKw=nothing, bottom::BorderKw=nothing,
- diagonal::BorderKw=nothing
-)::Int
- _check_outside_conflict(outside, left, right, top, bottom, diagonal, allsides)
- if isnothing(outside)
- return process_sheetcell(setBorder, xl, sheetcell; allsides, left, right, top, bottom, diagonal)
- else
- return process_sheetcell(setOutsideBorder, xl, sheetcell; outside)
- end
-end
-
-function setBorder(sh::Worksheet, cellref::CellRef;
- allsides::BorderKw=nothing,
- left::BorderKw=nothing, right::BorderKw=nothing,
- top::BorderKw=nothing, bottom::BorderKw=nothing,
- diagonal::BorderKw=nothing
-)::Int
- get_xlsxfile(sh).is_writable ||
- throw(XLSXError("Cannot set borders because XLSXFile is not writable."))
-
- # allsides expands to all four sides, then recurses — early return
- if !isnothing(allsides)
- !all(isnothing, [left, right, top, bottom]) &&
- throw(XLSXError("Keyword `allsides` is incompatible with `left`, `right`, `top`, or `bottom`."))
- return setBorder(sh, cellref; left=allsides, right=allsides, top=allsides, bottom=allsides, diagonal)
- end
-
- wb = get_workbook(sh)
- pfx = get_prefix("xl/styles.xml", get_xlsxfile(sh))
- pfx = pfx == "" ? pfx : pfx * ":"
-
- cell = getcell(sh, cellref)
- cell isa EmptyCell &&
- throw(XLSXError("Cannot set border for an `EmptyCell`: $(cellname(cellref)). Set the value first."))
-
- allXfNodes = get_cellXfs_nodes(wb)
-
- if cell.style == UInt32(0)
- cell.style = get_num_style_index(sh, allXfNodes, 0).id
- end
-
- cell_style = styles_cell_xf(allXfNodes, cell.style)
- cell_borders = getBorder(wb, cell_style)
- old_border_atts = cell_borders.border
-
- # Convert kwargs to dicts, then merge with old attributes per side
- side_kwargs = Dict(
- s => _to_border_dict(v) for (s, v) in (
- ("left", left), ("right", right), ("top", top),
- ("bottom", bottom), ("diagonal", diagonal)
- )
- )
-
- new_border_atts = Dict{String,Union{Dict{String,String},Nothing}}(
- side => _merge_border_side(side, side_kwargs[side], old_border_atts)
- for side in ("left", "right", "top", "bottom", "diagonal")
- )
-
- border_node = buildNode("border", new_border_atts, pfx)
- new_borderid = styles_add_cell_attribute(wb, border_node, "borders")
- newstyle = update_template_xf(sh, allXfNodes, CellDataFormat(cell.style), ["borderId", "applyBorder"], [string(new_borderid), "1"]).id
- cell.style = newstyle
-
- return new_borderid
-end
-
-"""
- setUniformBorder(sh::Worksheet, cr::String; kw...) -> ::Int
- setUniformBorder(xf::XLSXFile, cr::String, kw...) -> ::Int
-
- setUniformBorder(sh::Worksheet, rows, cols; kw...) -> ::Int
-
-Set the border used by a cell range, a column range or row range or
-a named range in a worksheet or XLSXFile to be uniformly the same border.
-Alternatively, specify the rows and columns using any combination of
-Integer, UnitRange, Vector{Integer} or `:`.
-
-First, the border attributes of the first cell in the range (the top-left cell) are
-updated according to the given `kw...` (using `setBorder()`). The resultant border is
-then applied to each remaining cell in the range.
-
-As a result, every cell in the range will have a uniform border setting.
-
-This differs from `setBorder()` which merges the attributes defined by `kw...` into
-the border definition used by each cell individually. For example, if you set the
-border style to `thin` for a range of cells, but these cells all use different border
-colors, `setBorder()` will change the border style but leave the border color unchanged
-for each cell individually.
-
-In contrast, `setUniformBorder()` will set the border `style` to `thin` for the first cell,
-but will then apply all the border attributes from the updated first cell (ie. both `style`
-and `color`) to all the other cells in the range.
-
-This can be more efficient when setting the same border for a large number of cells.
-
-Applying `setUniformBorder()` without any keyword arguments simply copies the `Border`
-attributes from the first cell specified to all the others.
-
-The value returned is the `borderId` of the border uniformly applied to the cells.
-If all cells in the range are `EmptyCells` the returned value is -1.
-
-For keyword definitions see [`setBorder()`](@ref).
-
-Note: `setUniformBorder` cannot be used with the `outside` keyword.
-
-# Examples:
-```julia
-Julia> setUniformBorder(sh, "B2:D6"; allsides = ["style" => "thick"], diagonal = ["style" => "hair"])
-
-Julia> setUniformBorder(sh, [1, 2, 3], [3, 5, 9]; allsides = ["style" => "thick"], diagonal = ["style" => "hair", "color" => "yellow2"])
-
-Julia> setUniformBorder(xf, "Sheet1!A1:F20"; left = ["style" => "dotted", "color" => "FF000FF0"],
- right = ["style" => "medium", "color" => "FF765000"],
- top = ["style" => "thick", "color" => "FF230000"],
- bottom = ["style" => "medium", "color" => "FF0000FF"],
- diagonal = ["style" => "none"]
- )
-
-julia> setUniformBorder(sh, "B2,A5:D22") # Copy `Border` from B2 to cells in A5:D22
-
-
-```
-"""
-function setUniformBorder end
-setUniformBorder(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setUniformBorder(ws, rng.rng; kw...)
-setUniformBorder(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setUniformBorder(ws, rng.colrng; kw...)
-setUniformBorder(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setUniformBorder(ws, rng.rowrng; kw...)
-setUniformBorder(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setUniformBorder, ws, colrng; kw...)
-setUniformBorder(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setUniformBorder, ws, rowrng; kw...)
-setUniformBorder(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_uniform_ncranges(setBorder, ws, ncrng, ["borderId", "applyBorder"]; kw...)
-setUniformBorder(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setUniformBorder, xl, sheetcell; kw...)
-setUniformBorder(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setUniformBorder, ws, ref_or_rng; kw...)
-setUniformBorder(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setBorder, ws, row, nothing; kw...)
-setUniformBorder(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setBorder, ws, nothing, col; kw...)
-setUniformBorder(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setUniformBorder, ws, nothing, nothing; kw...)
-setUniformBorder(ws::Worksheet, ::Colon; kw...) = process_colon(setUniformBorder, ws, nothing, nothing; kw...)
-setUniformBorder(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_uniform_veccolon(setBorder, ws, row, nothing, ["borderId", "applyBorder"]; kw...)
-setUniformBorder(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_veccolon(setBorder, ws, nothing, col, ["borderId", "applyBorder"]; kw...)
-setUniformBorder(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_vecint(setBorder, ws, row, col, ["borderId", "applyBorder"]; kw...)
-setUniformBorder(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_uniform_vecint(setBorder, ws, row, col, ["borderId", "applyBorder"]; kw...)
-setUniformBorder(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_vecint(setBorder, ws, row, col, ["borderId", "applyBorder"]; kw...)
-setUniformBorder(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setUniformBorder(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setUniformBorder(ws::Worksheet, rng::CellRange; kw...)::Int = process_uniform_attribute(setBorder, ws, rng, ["borderId", "applyBorder"]; kw...)
-
-"""
- setOutsideBorder(sh::Worksheet, cr::String; kw...) -> ::Int
- setOutsideBorder(xf::XLSXFile, cr::String, kw...) -> ::Int
-
- setOutsideBorder(sh::Worksheet, rows, cols; kw...) -> ::Int
-
-Set the border around the outside of a cell range, a column range or row range
-or a named range in a worksheet or XLSXFile.
-Alternatively, specify the rows and columns using integers, UnitRanges or `:`.
-
-There is one key word:
-- `outside::Union{Nothing,Vector{Pair{String,String}}}=nothing`
-
-For keyword definition see [`setBorder()`](@ref).
-
-Only the border definitions for the sides of boundary cells that are on the
-ouside edge of the range will be set to the specified style and color. The
-borders of internal edges and any diagonals will remain unchanged. Border
-settings for all internal cells in the range will remain unchanged.
-
-Top and bottom borders for column ranges and left and right borders for
-row ranges are taken from the worksheet `dimension`.
-
-An outside border cannot be set for a non-contiguous range.
-
-The value returned is -1. Since outside borders are applied across multiple edge cells, each of which
-may have different pre-existing border attributes and therefore different `borderId` values,
-there is no single representative `borderId` for the operation as a whole.
-
-# Examples:
-```julia
-Julia> setOutsideBorder(sh, "B2:D6"; outside = ["style" => "thick"])
-
-Julia> setOutsideBorder(xf, "Sheet1!A1:F20"; outside = ["style" => "dotted", "color" => "FF000FF0"])
-```
-
-"""
-function setOutsideBorder end
-setOutsideBorder(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setOutsideBorder(ws, rng.rng; kw...)
-setOutsideBorder(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setOutsideBorder(ws, rng.colrng; kw...)
-setOutsideBorder(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setOutsideBorder(ws, rng.rowrng; kw...)
-setOutsideBorder(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setOutsideBorder, ws, colrng; kw...)
-setOutsideBorder(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setOutsideBorder, ws, rowrng; kw...)
-setOutsideBorder(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setOutsideBorder, xl, sheetcell; kw...)
-setOutsideBorder(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setOutsideBorder, ws, ref_or_rng; kw...)
-setOutsideBorder(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setOutsideBorder, ws, row, nothing; kw...)
-setOutsideBorder(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setOutsideBorder, ws, nothing, col; kw...)
-setOutsideBorder(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setOutsideBorder, ws, nothing, nothing; kw...)
-setOutsideBorder(ws::Worksheet, ::Colon; kw...) = process_colon(setOutsideBorder, ws, nothing, nothing; kw...)
-setOutsideBorder(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setOutsideBorder(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-
-function setOutsideBorder(ws::Worksheet, rng::CellRange;
- outside::Union{Nothing,Vector{Pair{String,String}}}=nothing
-)::Int
- get_xlsxfile(ws).is_writable ||
- throw(XLSXError("Cannot set borders because XLSXFile is not writable."))
-
- topLeft = rng.start
- topRight = CellRef(rng.start.row_number, rng.stop.column_number)
- bottomLeft = CellRef(rng.stop.row_number, rng.start.column_number)
- bottomRight = rng.stop
-
- setBorder(ws, CellRange(topLeft, topRight); top=outside)
- setBorder(ws, CellRange(topLeft, bottomLeft); left=outside)
- setBorder(ws, CellRange(topRight, bottomRight); right=outside)
- setBorder(ws, CellRange(bottomLeft, bottomRight); bottom=outside)
-
- return -1
-
-end
-
-#
-# -- Get and set fill attributes
-#
-
-"""
- getFill(sh::Worksheet, cr::String) -> ::Union{Nothing, CellFill}
- getFill(xf::XLSXFile, cr::String) -> ::Union{Nothing, CellFill}
-
- getFill(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, CellFill}
-
-Get the fill used by a single cell at reference `cr` in a worksheet or XLSXFile.
-The specified cell must be within the sheet dimension.
-
-Return a `CellFill` object containing:
-- `fillId` : a 0-based index of the fill in the workbook
-- `fill` : a dictionary of fill attributes: borderAttribute -> (attribute -> value)
-- `applyFill` : "1" or "0", indicating whether or not the fill is applied to the cell.
-
-Return `nothing` if no cell fill is found.
-
-The `fill` element in Excel's XML schema defines the pattern and color
-properties for cell fills. The primary attributes and child elements
-in the `patternFill` element are:
-- `patternType` : Specifies the type of fill pattern (see below).
-- `fgColor` : Specifies the foreground color of the pattern.
-- `bgColor` : Specifies the background color of the pattern.
-
-The child elements `fgColor` and `bgColor` can each have one or two attributes
-of their own. These color attributes are pushed in to the `CellFill.fill` Dict
-of attributes with either `fg` or `bg` prepended to their names to support later
-reconstruction of the xml element.
-
-Thus:
-`"patternFill" => Dict("patternType" => "solid", "bgindexed" => "64", "fgtheme" => "0")`
-indicates a solid fill with a foreground color defined by theme 0 (in Excel) and
-background color defined by an indexed value. In this case (solid fill), the
-background color will be ignored.
-
-Here is a list of the `patternTypes` Excel uses (thanks to Copilot!):
-- `none`
-- `solid`
-- `mediumGray`
-- `darkGray`
-- `lightGray`
-- `darkHorizontal`
-- `darkVertical`
-- `darkDown`
-- `darkUp`
-- `darkGrid`
-- `darkTrellis`
-- `lightHorizontal`
-- `lightVertical`
-- `lightDown`
-- `lightUp`
-- `lightGrid`
-- `lightTrellis`
-- `gray125`
-- `gray0625`
-
-Definitions for neither `fgColor` (foreground color) nor `bgColor` (background color)
-are strictly necessary although certain pattern types are more visually meaningful
-when both are defined. These pattern types include those that create a pattern or grid
-effect, where the contrast between the foreground and background colors enhances the
-visual presentation.
-
-These pattern types include `darkTrellis`, `darkGrid`, `darkHorizontal`, `darkVertical`,
-`darkDown`, `darkUp`, `mediumGray`, `lightGray`, `lightTrellis`, `lightGrid`, `lightHorizontal`,
-`lightVertical`, `lightDown`, `lightUp`, `gray125` and `gray0625`.
-
-If `fgColor` (foreground color) and `bgColor` (background color) are specified when they aren't
-needed, they will simply be ignored by Excel, and the default appearance will be applied.
-
-# Examples:
-```julia
-julia> getFill(sh, "A1")
-
-julia> getFill(sh, 3, 4)
-
-julia> getFill(xf, "Sheet1!A1")
-
-```
-"""
-function getFill end
-getFill(xl::XLSXFile, sheetcell::String)::Union{Nothing,CellFill} = process_get_sheetcell(getFill, xl, sheetcell)
-getFill(ws::Worksheet, cellref::CellRef)::Union{Nothing,CellFill} = process_get_cellref(getFill, ws, cellref)
-getFill(ws::Worksheet, cr::String) = process_get_cellname(getFill, ws, cr)
-getFill(ws::Worksheet, row::Integer, col::Integer) = getFill(ws, CellRef(row, col))
-getDefaultFill(ws::Worksheet) = getFill(get_workbook(ws), styles_cell_xf(get_workbook(ws), 0))
-
-function getFill(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellFill}
- get_xlsxfile(wb).use_cache_for_sheet_data ||
- throw(XLSXError("Cannot get fill because cache is not enabled."))
-
- haskey(cell_style, "fillId") || return nothing
-
- fillid_int = parse(Int, cell_style["fillId"])
- applyfill = haskey(cell_style, "applyFill") ? cell_style["applyFill"] : "0"
- fill_nodes = get_fills_nodes(wb)
- current_fill = fill_nodes[fillid_int + 1] # fillId is zero-based
- fill_atts = Dict{String,Union{Dict{String,String},Nothing}}()
-
- for pattern in xml_elements(current_fill)
- tag = localname(pattern)
- a = XML.attributes(pattern)
- if isnothing(a) || isempty(a)
- fill_atts[tag] = nothing
- else
- fill_atts[tag] = _parse_pattern_fill(pattern)
- end
- end
-
- return CellFill(fillid_int, fill_atts, applyfill)
-end
-
-"""
- setFill(sh::Worksheet, cr::String; kw...) -> ::Int
- setFill(xf::XLSXFile, cr::String; kw...) -> ::Int
-
- setFill(sh::Worksheet, row, col; kw...) -> ::Int
-
-Set the fill used used by a single cell, a cell range, a column range or
-row range or a named cell or named range in a worksheet or XLSXFile.
-Alternatively, specify the row and column using any combination of
-Integer, UnitRange, Vector{Integer} or `:`.
-
-The following keywords are used to define a fill:
-- `pattern::String = nothing` : Sets the patternType for the fill.
-- `fgColor::String = nothing` : Sets the foreground color for the fill.
-- `bgColor::String = nothing` : Sets the background color for the fill.
-
-Here is a list of the available `pattern` values (thanks to Copilot!):
-- `none`
-- `solid`
-- `mediumGray`
-- `darkGray`
-- `lightGray`
-- `darkHorizontal`
-- `darkVertical`
-- `darkDown`
-- `darkUp`
-- `darkGrid`
-- `darkTrellis`
-- `lightHorizontal`
-- `lightVertical`
-- `lightDown`
-- `lightUp`
-- `lightGrid`
-- `lightTrellis`
-- `gray125`
-- `gray0625`
-
-The two colors may be set by specifying an 8-digit hexadecimal value for the `fgColor`
-and/or `bgColor` keywords.
-Alternatively, you can use the name of any named color from [Colors.jl](https://juliagraphics.github.io/Colors.jl/stable/namedcolors/).
-
-Setting only one or two of the attributes leaves the other attribute(s) unchanged
-for that cell's fill.
-
-Fill attributes cannot be set for `EmptyCell`s. Set a cell value first.
-If a cell range or column range includes any `EmptyCell`s, they will be
-quietly skipped and the fill will be set for the remaining cells.
-
-For single cells, the value returned is the `fillId` of the fill applied to the cell.
-This can be used to apply the same fill to other cells or ranges.
-
-For cell ranges, column ranges and named ranges, the value returned is -1 unless
-the range resolves to a single cell, in which case that cell's id is returned.
-
-# Examples:
-```julia
-Julia> setFill(sh, "B2"; pattern="gray125", bgColor = "FF000000")
-
-Julia> setFill(xf, "Sheet1!A1:F20"; pattern="none", fgColor = "darkseagreen3")
-
-Julia> setFill(sh, "11:24"; pattern="none", fgColor = "yellow2")
-
-```
-"""
-function setFill end
-setFill(ws::Worksheet, ref::SheetCellRef; kw...) = do_sheet_names_match(ws, ref) && setFill(ws, ref.cellref; kw...)
-setFill(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setFill(ws, rng.rng; kw...)
-setFill(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setFill(ws, rng.colrng; kw...)
-setFill(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setFill(ws, rng.rowrng; kw...)
-setFill(ws::Worksheet, rng::CellRange; kw...)::Int = process_cellranges(setFill, ws, rng; kw...)
-setFill(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setFill, ws, rowrng; kw...)
-setFill(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(setFill, ws, ncrng; kw...)
-setFill(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setFill, ws, colrng; kw...)
-setFill(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setFill, ws, ref_or_rng; kw...)
-setFill(xl::XLSXFile, sheetcell::String; kw...)::Int = process_sheetcell(setFill, xl, sheetcell; kw...)
-#setFill(ws::Worksheet, row::Integer, col::Integer; kw...) = setFill(ws, CellRef(row, col); kw...)
-setFill(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setFill, ws, row, nothing; kw...)
-setFill(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setFill, ws, nothing, nothing; kw...)
-setFill(ws::Worksheet, ::Colon; kw...) = process_colon(setFill, ws, nothing, nothing; kw...)
-setFill(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setFill, ws, nothing, col; kw...)
-setFill(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_veccolon(setFill, ws, row, nothing; kw...)
-setFill(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setFill, ws, nothing, col; kw...)
-setFill(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setFill, ws, row, col; kw...)
-setFill(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_vecint(setFill, ws, row, col; kw...)
-setFill(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setFill, ws, row, col; kw...)
-setFill(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setFill(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-
-function setFill(sh::Worksheet, cellref::CellRef;
- pattern::Union{Nothing,String}=nothing,
- fgColor::Union{Nothing,String}=nothing,
- bgColor::Union{Nothing,String}=nothing,
-)::Int
- get_xlsxfile(sh).is_writable || throw(XLSXError("Cannot set fill because XLSXFile is not writable."))
-
- # Validate before any work
- !isnothing(pattern) && pattern ∉ VALID_FILL_PATTERNS &&
- throw(XLSXError("Invalid pattern: $pattern. Must be one of: $(join(VALID_FILL_PATTERNS, ", "))."))
-
- wb = get_workbook(sh)
- pfx = get_prefix("xl/styles.xml", get_xlsxfile(sh))
- pfx = pfx == "" ? pfx : pfx * ":"
-
- cell = getcell(sh, cellref)
- cell isa EmptyCell && throw(XLSXError("Cannot set fill for an `EmptyCell`: $(cellname(cellref)). Set the value first."))
-
- allXfNodes = get_cellXfs_nodes(wb)
-
- if cell.style == UInt32(0)
- cell.style = get_num_style_index(sh, allXfNodes, 0).id
- end
-
- cell_style = styles_cell_xf(allXfNodes, cell.style)
- cell_fill = getFill(wb, cell_style)
- old_fill_atts = cell_fill.fill["patternFill"]
-
- patternFill = Dict{String,String}()
-
- # patternType: preserve old or apply new
- result = _merge_val_tag("patternType", pattern, old_fill_atts)
- if !ismissing(result)
- patternFill["patternType"] = result isa Dict ? result["val"] : result
- end
-
- # fg/bg colors: prefix-match to preserve all old fg*/bg* keys, or set new rgb
- for (prefix, new_color, key) in (("fg", fgColor, "fgrgb"), ("bg", bgColor, "bgrgb"))
- if isnothing(new_color)
- for (k, v) in old_fill_atts
- startswith(k, prefix) && (patternFill[k] = v)
- end
- else
- patternFill[key] = get_color(new_color)
- end
- end
-
- new_fill_atts = Dict{String,Union{Dict{String,String},Nothing}}("patternFill" => patternFill)
- fill_node = buildNode("fill", new_fill_atts, pfx)
- new_fillid = styles_add_cell_attribute(wb, fill_node, "fills")
-
- newstyle = update_template_xf(sh, allXfNodes, CellDataFormat(cell.style), ["fillId", "applyFill"], [string(new_fillid), "1"]).id
- cell.style = newstyle
-
- return new_fillid
-end
-
-"""
- setUniformFill(sh::Worksheet, cr::String; kw...) -> ::Int
- setUniformFill(xf::XLSXFile, cr::String, kw...) -> ::Int
-
- setUniformFill(sh::Worksheet, rows, cols; kw...) -> ::Int
-
-Set the fill used by a cell range, a column range or row range or a
-named range in a worksheet or XLSXFile to be uniformly the same fill.
-Alternatively, specify the rows and columns using any combination of
-Integer, UnitRange, Vector{Integer} or `:`.
-
-First, the fill attributes of the first cell in the range (the top-left cell) are
-updated according to the given `kw...` (using `setFill()`). The resultant fill is
-then applied to each remaining cell in the range.
-
-As a result, every cell in the range will have a uniform fill setting.
-
-This differs from `setFill()` which merges the attributes defined by `kw...` into
-the fill definition used by each cell individually. For example, if you set the
-fill `patern` to `darkGrid` for a range of cells, but these cells all use different fill
-`color`s, `setFill()` will change the fill `pattern` but leave the fill `color` unchanged
-for each cell individually.
-
-In contrast, `setUniformFill()` will set the fill `pattern` to `darkGrid` for the first cell,
-but will then apply all the fill attributes from the updated first cell (ie. `pattern`
-and both foreground and background colors) to all the other cells in the range.
-
-This can be more efficient when setting the same fill for a large number of cells.
-
-Applying `setUniformFill()` without any keyword arguments simply copies the `Fill`
-attributes from the first cell specified to all the others.
-
-The value returned is the `fillId` of the fill uniformly applied to the cells.
-If all cells in the range are `EmptyCells` the returned value is -1.
-
-For keyword definitions see [`setFill()`](@ref).
-
-# Examples:
-```julia
-Julia> setUniformFill(sh, "B2:D4"; pattern="gray125", bgColor = "FF000000")
-
-Julia> setUniformFill(xf, "Sheet1!A1:F20"; pattern="none", fgColor = "darkseagreen3")
-
-julia> setUniformFill(sh, "B2,A5:D22") # Copy `Fill` from B2 to cells in A5:D22
-
-```
-"""
-function setUniformFill end
-setUniformFill(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setUniformFill(ws, rng.rng; kw...)
-setUniformFill(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setUniformFill(ws, rng.colrng; kw...)
-setUniformFill(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setUniformFill(ws, rng.rowrng; kw...)
-setUniformFill(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setUniformFill, ws, colrng; kw...)
-setUniformFill(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setUniformFill, ws, rowrng; kw...)
-setUniformFill(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_uniform_ncranges(setFill, ws, ncrng, ["fillId", "applyFill"]; kw...)
-setUniformFill(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setUniformFill, xl, sheetcell; kw...)
-setUniformFill(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setUniformFill, ws, ref_or_rng; kw...)
-setUniformFill(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setFill, ws, row, nothing; kw...)
-setUniformFill(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setFill, ws, nothing, col; kw...)
-setUniformFill(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setUniformFill, ws, nothing, nothing; kw...)
-setUniformFill(ws::Worksheet, ::Colon; kw...) = process_colon(setUniformFill, ws, nothing, nothing; kw...)
-setUniformFill(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_uniform_veccolon(setFill, ws, row, nothing, ["fillId", "applyFill"]; kw...)
-setUniformFill(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_veccolon(setFill, ws, nothing, col, ["fillId", "applyFill"]; kw...)
-setUniformFill(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_vecint(setFill, ws, row, col, ["fillId", "applyFill"]; kw...)
-setUniformFill(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_uniform_vecint(setFill, ws, row, col, ["fillId", "applyFill"]; kw...)
-setUniformFill(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_vecint(setFill, ws, row, col, ["fillId", "applyFill"]; kw...)
-setUniformFill(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setUniformFill(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setUniformFill(ws::Worksheet, rng::CellRange; kw...)::Int = process_uniform_attribute(setFill, ws, rng, ["fillId", "applyFill"]; kw...)
-
-#
-# -- Get and set alignment attributes
-#
-
-"""
- getAlignment(sh::Worksheet, cr::String) -> ::Union{Nothing, CellAlignment}
- getAlignment(xf::XLSXFile, cr::String) -> ::Union{Nothing, CellAlignment}
-
- getAlignment(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, CellAlignment}
-
-Get the alignment used by a single cell at reference `cr` in a worksheet or XLSXFile.
-The specified cell must be within the sheet dimension.
-
-Return a `CellAlignment` object containing:
-- `alignment` : a dictionary of alignment attributes: alignmentAttribute -> (attribute -> value)
-- `applyAlignment` : "1" or "0", indicating whether or not the alignment is applied to the cell.
-
-Return `nothing` if no cell alignment is found.
-
-The `alignment` element in Excel's XML schema defines the following attributes:
-- `horizontal` : Specifies the horizontal alignment of the cell.
-- `vertical` : Specifies the vertical alignment of the cell.
-- `wrapText` : Specifies whether ("1") or not ("0") the cell content wraps
- in the cell.
-- `shrinkToFit` : Indicates whether ("1") or not ("0") the text should shrink to fit the cell.
-- `indent` : Specifies the number of spaces by which to indent the text (always from the left).
-- `textRotation` : Specifies the rotation angle of the text in a range -90 to 90 (positive values
- rotate the text counterclockwise).
-
-
-Excel supports the following values for the horizontal alignment:
-- `left` : Aligns the text to the left of the cell.
-- `center` : Centers the text within the cell.
-- `right` : Aligns the text to the right of the cell.
-- `fill` : Repeats the text to fill the entire width of the cell.
-- `justify` : Justifies the text, spacing it out so that it spans the entire width of the cell.
-- `centerContinuous` : Centers the text across multiple cells (specifically the currrent cell and all
- empty cells to the right) as if the text were in a merged cell.
-- `distributed` : Distributes the text evenly across the width of the cell.
-
-Excel supports the following values for the vertical alignment:
-- `top` : Aligns the text to the top of the cell.
-- `center` : Centers the text vertically within the cell.
-- `bottom` : Aligns the text to the bottom of the cell.
-- `justify` : Justifies the text vertically, spreading it out evenly within the cell.
-- `distributed` : Distributes the text evenly from top to bottom in the cell.
-
-# Examples:
-```julia
-julia> getAlignment(sh, "A1")
-
-julia> getAlignment(sh, 2, 5) # Cell E2
-
-julia> getAlignment(xf, "Sheet1!A1")
-
-```
-"""
-function getAlignment end
-getAlignment(xl::XLSXFile, sheetcell::String)::Union{Nothing,CellAlignment} = process_get_sheetcell(getAlignment, xl, sheetcell)
-getAlignment(ws::Worksheet, cellref::CellRef)::Union{Nothing,CellAlignment} = process_get_cellref(getAlignment, ws, cellref)
-getAlignment(ws::Worksheet, cr::String) = process_get_cellname(getAlignment, ws, cr)
-getAlignment(ws::Worksheet, row::Integer, col::Integer) = getAlignment(ws, CellRef(row, col))
-
-function getAlignment(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellAlignment}
- get_xlsxfile(wb).use_cache_for_sheet_data ||
- throw(XLSXError("Cannot get alignment because cache is not enabled."))
-
- # alignment is an optional child element of the cell xf node
- children = filter(n -> XML.nodetype(n) == XML.Element, XML.children(cell_style))
- alignment_node = findfirst(n -> localname(n) == "alignment", children)
- isnothing(alignment_node) && return nothing
-
- atts = Dict{String,String}(XML.attributes(children[alignment_node]))
- alignment_atts = Dict{String,Union{Dict{String,String},Nothing}}("alignment" => atts)
- applyalignment = haskey(cell_style, "applyAlignment") ? cell_style["applyAlignment"] : "0"
-
- return CellAlignment(alignment_atts, applyalignment)
-end
-
-"""
- setAlignment(sh::Worksheet, cr::String; kw...) -> ::Int}
- setAlignment(xf::XLSXFile, cr::String; kw...) -> ::Int}
-
- setAlignment(sh::Worksheet, row, col; kw...) -> ::Int}
-
-
-Set the alignment used used by a single cell, a cell range, a column range or
-row range or a named cell or named range in a worksheet or XLSXFile.
-Alternatively, specify the row and column using any combination of
-Integer, UnitRange, Vector{Integer} or `:`.
-
-The following keywords are used to define an alignment:
-- `horizontal::String = nothing` : Sets the horizontal alignment.
-- `vertical::String = nothing` : Sets the vertical alignment.
-- `wrapText::Bool = nothing` : Determines whether the cell content wraps within the cell.
-- `shrink::Bool = nothing` : Indicates whether the text should shrink to fit the cell.
-- `indent::Int = nothing` : Specifies the number of spaces by which to indent the text
- (always from the left).
-- `rotation::Int = nothing` : Specifies the rotation angle of the text in the range -90 to 90
- (positive values rotate the text counterclockwise),
-
-Here are the possible values for the `horizontal` alignment:
-- `left` : Aligns the text to the left of the cell.
-- `center` : Centers the text within the cell.
-- `right` : Aligns the text to the right of the cell.
-- `fill` : Repeats the text to fill the entire width of the cell.
-- `justify` : Justifies the text, spacing it out so that it spans the entire
- width of the cell.
-- `centerContinuous` : Centers the text across multiple cells (specifically the currrent
- cell and all empty cells to the right) as if the text were in
- a merged cell.
-- `distributed` : Distributes the text evenly across the width of the cell.
-
-Here are the possible values for the `vertical` alignment:
-- `top` : Aligns the text to the top of the cell.
-- `center` : Centers the text vertically within the cell.
-- `bottom` : Aligns the text to the bottom of the cell.
-- `justify` : Justifies the text vertically, spreading it out evenly within the cell.
-- `distributed` : Distributes the text evenly from top to bottom in the cell.
-
-For single cells, the value returned is the `styleId` of the cell.
-
-For cell ranges, column ranges and named ranges, the value returned is -1 unless
-the range resolves to a single cell, in which case that cell's id is returned.
-
-# Examples:
-```julia
-julia> setAlignment(sh, "D18"; horizontal="center", wrapText=true)
-
-julia> setAlignment(xf, "sheet1!D18"; horizontal="right", vertical="top", wrapText=true)
-
-julia> setAlignment(sh, "L6"; horizontal="center", rotation="90", shrink=true, indent="2")
-
-julia> setAlignment(sh, 1:3, 3:6; horizontal="center", rotation="90", shrink=true, indent="2")
-
-```
-"""
-function setAlignment end
-setAlignment(ws::Worksheet, ref::SheetCellRef; kw...) = do_sheet_names_match(ws, ref) && setAlignment(ws, ref.cellref; kw...)
-setAlignment(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setAlignment(ws, rng.rng; kw...)
-setAlignment(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setAlignment(ws, rng.colrng; kw...)
-setAlignment(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setAlignment(ws, rng.rowrng; kw...)
-setAlignment(ws::Worksheet, rng::CellRange; kw...)::Int = process_cellranges(setAlignment, ws, rng; kw...)
-setAlignment(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setAlignment, ws, colrng; kw...)
-setAlignment(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setAlignment, ws, rowrng; kw...)
-setAlignment(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(setAlignment, ws, ncrng; kw...)
-setAlignment(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setAlignment, ws, ref_or_rng; kw...)
-setAlignment(xl::XLSXFile, sheetcell::String; kw...)::Int = process_sheetcell(setAlignment, xl, sheetcell; kw...)
-#setAlignment(ws::Worksheet, row::Integer, col::Integer; kw...) = setAlignment(ws, CellRef(row, col); kw...)
-setAlignment(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setAlignment, ws, row, nothing; kw...)
-setAlignment(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setAlignment, ws, nothing, nothing; kw...)
-setAlignment(ws::Worksheet, ::Colon; kw...) = process_colon(setAlignment, ws, nothing, nothing; kw...)
-setAlignment(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setAlignment, ws, nothing, col; kw...)
-setAlignment(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_veccolon(setAlignment, ws, row, nothing; kw...)
-setAlignment(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setAlignment, ws, nothing, col; kw...)
-setAlignment(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setAlignment, ws, row, col; kw...)
-setAlignment(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_vecint(setAlignment, ws, row, col; kw...)
-setAlignment(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setAlignment, ws, row, col; kw...)
-setAlignment(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setAlignment(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-
-function setAlignment(sh::Worksheet, cellref::CellRef;
- horizontal::Union{Nothing,String}=nothing,
- vertical::Union{Nothing,String}=nothing,
- wrapText::Union{Nothing,Bool}=nothing,
- shrink::Union{Nothing,Bool}=nothing,
- indent::Union{Nothing,Int}=nothing,
- rotation::Union{Nothing,Int}=nothing
-)::Int
- get_xlsxfile(sh).is_writable ||
- throw(XLSXError("Cannot set alignment because XLSXFile is not writable."))
-
- # Validate before any work
- !isnothing(horizontal) && horizontal ∉ VALID_HORIZONTAL_ALIGNMENTS &&
- throw(XLSXError("Invalid horizontal alignment: $horizontal. Must be one of: $(join(VALID_HORIZONTAL_ALIGNMENTS, ", "))."))
- !isnothing(vertical) && vertical ∉ VALID_VERTICAL_ALIGNMENTS &&
- throw(XLSXError("Invalid vertical alignment: $vertical. Must be one of: $(join(VALID_VERTICAL_ALIGNMENTS, ", "))."))
- !isnothing(indent) && indent < 0 &&
- throw(XLSXError("Invalid indent value: $indent. Must be a positive integer."))
- !isnothing(rotation) && rotation ∉ -90:90 &&
- throw(XLSXError("Invalid rotation value: $rotation. Must be an integer between -90 and 90."))
-
- wb = get_workbook(sh)
- pfx = get_prefix("xl/styles.xml", get_xlsxfile(sh))
- pfx = pfx == "" ? pfx : pfx * ":"
-
- cell = getcell(sh, cellref)
- cell isa EmptyCell &&
- throw(XLSXError("Cannot set alignment for an `EmptyCell`: $(cellname(cellref)). Set the value first."))
-
- allXfNodes = get_cellXfs_nodes(wb)
-
- if cell.style == UInt32(0)
- cell.style = get_num_style_index(sh, allXfNodes, 0).id
- end
-
- cell_style = styles_cell_xf(allXfNodes, cell.style)
- cell_alignment = getAlignment(wb, cell_style)
- old_atts = isnothing(cell_alignment) ? Dict{String,String}() : cell_alignment.alignment["alignment"]
-
- atts = Dict{String,String}()
- _merge_alignment_att(atts, "horizontal", horizontal, old_atts)
- _merge_alignment_att(atts, "vertical", vertical, old_atts)
- _merge_alignment_att(atts, "wrapText", wrapText, old_atts, b -> b ? "1" : "0")
- _merge_alignment_att(atts, "shrinkToFit", shrink, old_atts, b -> b ? "1" : "0")
- _merge_alignment_att(atts, "indent", indent, old_atts)
- _merge_alignment_att(atts, "textRotation", rotation, old_atts)
-
- alignment_node = XML.Node{String}(XML.Element, "alignment", Pair{String,String}[k => v for (k,v) in atts], nothing, nothing)
- newstyle = update_template_xf(sh, allXfNodes, CellDataFormat(cell.style), alignment_node).id
- cell.style = newstyle
-
- return Int(newstyle)
-end
-
-"""
- setUniformAlignment(sh::Worksheet, cr::String; kw...) -> ::Int
- setUniformAlignment(xf::XLSXFile, cr::String, kw...) -> ::Int
-
- setUniformAlignment(sh::Worksheet, rows, cols; kw...) -> ::Int
-
-Set the alignment used by a cell range, a column range or row range or a
-named range in a worksheet or XLSXFile to be uniformly the same alignment.
-Alternatively, specify the rows and columns using any combination of
-Integer, UnitRange, Vector{Integer} or `:`.
-
-First, the alignment attributes of the first cell in the range (the top-left cell) are
-updated according to the given `kw...` (using `setAlignment()`). The resultant alignment
-is then applied to each remaining cell in the range.
-
-As a result, every cell in the range will have a uniform alignment setting.
-
-This differs from `setAlignment()` which merges the attributes defined by `kw...` into
-the alignment definition used by each cell individually. For example, if you set the
-`horizontal` alignment to `left` for a range of cells, but these cells all use different
-`vertical` alignment or `wrapText`, `setAlignment()` will change the horizontal alignment but
-leave the `vertical` alignment and `wrapText` unchanged for each cell individually.
-
-In contrast, `setUniformAlignment()` will set the `horizontal` alignment to `left` for
-the first cell, but will then apply all the alignment attributes from the updated first
-cell to all the other cells in the range.
-
-This can be more efficient when setting the same alignment for a large number of cells.
-
-Applying `setUniformAlignment()` without any keyword arguments simply copies the `Alignment`
-attributes from the first cell specified to all the others.
-
-The value returned is the `styleId` of the reference (top-left) cell, from which the
-alignment uniformly applied to the cells was taken.
-If all cells in the range are `EmptyCells`, the returned value is -1.
-
-For keyword definitions see [`setAlignment()`](@ref).
-
-# Examples:
-```julia
-Julia> setUniformAlignment(sh, "B2:D4"; horizontal="center", wrap = true)
-
-Julia> setUniformAlignment(xf, "Sheet1!A1:F20"; horizontal="center", vertical="top")
-
-Julia> setUniformAlignment(sh, :, 1:24; horizontal="center", vertical="top")
-
-julia> setUniformAlignment(sh, "B2,A5:D22") # Copy `Alignment` from B2 to cells in A5:D22
-
-```
-"""
-function setUniformAlignment end
-setUniformAlignment(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setUniformAlignment(ws, rng.rng; kw...)
-setUniformAlignment(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setUniformAlignment(ws, rng.colrng; kw...)
-setUniformAlignment(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setUniformAlignment(ws, rng.rowrng; kw...)
-setUniformAlignment(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setUniformAlignment, ws, colrng; kw...)
-setUniformAlignment(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setUniformAlignment, ws, rowrng; kw...)
-setUniformAlignment(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_uniform_ncranges(setAlignment, ws, ncrng; kw...)
-setUniformAlignment(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setUniformAlignment, xl, sheetcell; kw...)
-setUniformAlignment(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setUniformAlignment, ws, ref_or_rng; kw...)
-setUniformAlignment(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setAlignment, ws, row, nothing; kw...)
-setUniformAlignment(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setAlignment, ws, nothing, col; kw...)
-setUniformAlignment(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setUniformAlignment, ws, nothing, nothing; kw...)
-setUniformAlignment(ws::Worksheet, ::Colon; kw...) = process_colon(setUniformAlignment, ws, nothing, nothing; kw...)
-setUniformAlignment(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_uniform_veccolon(setAlignment, ws, row, nothing; kw...)
-setUniformAlignment(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_veccolon(setAlignment, ws, nothing, col; kw...)
-setUniformAlignment(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_vecint(setAlignment, ws, row, col; kw...)
-setUniformAlignment(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_uniform_vecint(setAlignment, ws, row, col; kw...)
-setUniformAlignment(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_vecint(setAlignment, ws, row, col; kw...)
-setUniformAlignment(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setUniformAlignment(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setUniformAlignment(ws::Worksheet, rng::CellRange; kw...)::Int = process_uniform_attribute(setAlignment, ws, rng; kw...)
-
-#
-# -- Get and set number format attributes
-#
-
-"""
- getFormat(sh::Worksheet, cr::String) -> ::Union{Nothing, CellFormat}
- getFormat(xf::XLSXFile, cr::String) -> ::Union{Nothing, CellFormat}
-
- getFormat(sh::Worksheet, row::Int, col::int) -> ::Union{Nothing, CellFormat}
-
-Get the format (numFmt) used by a single cell at reference `cr` in a worksheet or XLSXFile.
-The specified cell must be within the sheet dimension.
-
-Return a `CellFormat` object containing:
-- `numFmtId` : a 0-based index of the formats in the workbook. Values below 164 are
- reserved for built-in formats. Values of 164 and over are custom formats
- and are stored in the `styles.xml` file within the XLSXFile.
-- `format` : a dictionary of numFmt attributes: formatAttribute -> (attribute -> value)
-- `applyNumberFormat` : "1" or "0", indicating whether or not the format is applied to the cell.
-
-Return `nothing` if no cell format is found. This will occur when a cell uses a built-in format.
-
-The function will always find any explicitly set custom format. It will also attempt to return
-the format for built-in formats, too.
-
-# Examples:
-```julia
-julia> getFormat(sh, "A1")
-
-julia> getFormat(xf, "Sheet1!A1")
-
-julia> getFormat(sh, 1, 1)
-
-```
-"""
-function getFormat end
-getFormat(xl::XLSXFile, sheetcell::String)::Union{Nothing,CellFormat} = process_get_sheetcell(getFormat, xl, sheetcell)
-getFormat(ws::Worksheet, cellref::CellRef)::Union{Nothing,CellFormat} = process_get_cellref(getFormat, ws, cellref)
-getFormat(ws::Worksheet, cr::String) = process_get_cellname(getFormat, ws, cr)
-getFormat(ws::Worksheet, row::Integer, col::Integer) = getFormat(ws, CellRef(row, col))
-
-function getFormat(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellFormat}
- get_xlsxfile(wb).use_cache_for_sheet_data ||
- throw(XLSXError("Cannot get number formats because cache is not enabled."))
-
- haskey(cell_style, "numFmtId") || return nothing
-
- numfmtid_int = parse(Int, cell_style["numFmtId"])
- applynumberformat = haskey(cell_style, "applyNumberFormat") ? cell_style["applyNumberFormat"] : "0"
- format_atts = Dict{String,Union{Dict{String,String},Nothing}}()
-
- if numfmtid_int >= PREDEFINED_NUMFMT_COUNT
- # Custom format — look up in the cached numFmtId => formatCode map
- cache = get_numFmt_cache(wb)
- haskey(cache, numfmtid_int) || throw(XLSXError("No format definition found for numFmtId $numfmtid_int."))
-
- format_atts["numFmt"] = Dict(
- "numFmtId" => string(numfmtid_int),
- "formatCode" => cache[numfmtid_int]
- )
-
- else
- # Built-in format — validate it falls in a known range
- any(numfmtid_int ∈ r for r in BUILTIN_NUMFMT_RANGES) ||
- throw(XLSXError("Expected a built-in format ID in ranges 0:22, 37:40, or 45:49. Got $numfmtid_int."))
-
- if haskey(builtinFormats, string(numfmtid_int))
- format_atts["numFmt"] = Dict(
- "numFmtId" => string(numfmtid_int),
- "formatCode" => builtinFormats[string(numfmtid_int)]
- )
- end
- end
-
- return CellFormat(numfmtid_int, format_atts, applynumberformat)
-end
-
-"""
- setFormat(sh::Worksheet, cr::String; kw...) -> ::Int
- setFormat(xf::XLSXFile, cr::String; kw...) -> ::Int
-
- setFormat(sh::Worksheet, row, col; kw...) -> ::Int
-
-Set the number format used used by a single cell, a cell range, a column
-range or row range or a named cell or named range in a worksheet or
-XLSXFile. Alternatively, specify the row and column using any combination
-of Integer, UnitRange, Vector{Integer} or `:`.
-
-The function uses one keyword used to define a format:
-- `format::String = nothing` : Defines a built-in or custom number format
-
-The format keyword can define some built-in formats by name:
-- `General` : specifies internal format ID 0 (General)
-- `Number` : specifies internal format ID 2 (`0.00`)
-- `Currency` : specifies internal format ID 7 (`\$#,##0.00_);(\$#,##0.00)`)
-- `Percentage` : specifies internal format ID 9 (`0%`)
-- `ShortDate` : specifies internal format ID 14 (`m/d/yyyy`)
-- `LongDate` : specifies internal format ID 15 (`d-mmm-yy`)
-- `Time` : specifies internal format ID 21 (`h:mm:ss`)
-- `Scientific` : specifies internal format ID 48 (`##0.0E+0`)
-
-If `Currency` is specified, Excel will use the appropriate local currency symbol.
-
-Alternatively, the format keyword can define built-in formats by their internal
-format ID (as a string). The following built-in format IDs are supported:
-* "0" => "General"
-* "1" => "0"
-* "2" => "0.00",
-* "3" => "#,##0"
-* "4" => "#,##0.00"
-* "5" => "\$#,##0_);(\$#,##0)"
-* "6" => "\$#,##0_);Red"
-* "7" => "\$#,##0.00_);(\$#,##0.00)"
-* "8" => "\$#,##0.00_);Red"
-* "9" => "0%"
-* "10" => "0.00%"
-* "11" => "0.00E+00"
-* "12" => "# ?/?"
-* "13" => "# ??/??"
-* "14" => "m/d/yyyy"
-* "15" => "d-mmm-yy"
-* "16" => "d-mmm"
-* "17" => "mmm-yy"
-* "18" => "h:mm AM/PM"
-* "19" => "h:mm:ss AM/PM"
-* "20" => "h:mm"
-* "21" => "h:mm:ss"
-* "22" => "m/d/yyyy h:mm"
-* "37" => "#,##0_);(#,##0)"
-* "38" => "#,##0_);Red"
-* "39" => "#,##0.00_);(#,##0.00)"
-* "40" => "#,##0.00_);Red"
-* "45" => "mm:ss"
-* "46" => "[h]:mm:ss"
-* "47" => "mmss.0"
-* "48" => "##0.0E+0"
-* "49" => "@"
-
-Lastly, `format` can be used to specify any custom format directly.
-Only weak checks are made of custom formats specified - they are otherwise added
-to the `XLSXFile` verbatim.
-
-Formats may need characters that must be escaped when specified (see penultimate
-example, below).
-
-# Examples:
-```julia
-julia> XLSX.setFormat(sh, "D2"; format = "h:mm AM/PM")
-
-julia> XLSX.setFormat(xf, "Sheet1!A2"; format = "# ??/??")
-
-julia> XLSX.setFormat(sh, "F1:F5"; format = "Currency")
-
-julia> XLSX.setFormat(sh, "named_range"; format = "Percentage")
-
-julia> XLSX.setFormat(sh, "A2"; format = "_-£* #,##0.00_-;-£* #,##0.00_-;_-£* \\\"-\\\"??_-;_-@_-")
-
-julia> XLSX.setFormat(sh, "named_range"; format = "39") # specifies the built-in format "#,##0.00_);(#,##0.00)"
-
-```
-"""
-function setFormat end
-setFormat(ws::Worksheet, ref::SheetCellRef; kw...) = do_sheet_names_match(ws, ref) && setFormat(ws, ref.cellref; kw...)
-setFormat(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setFormat(ws, rng.rng; kw...)
-setFormat(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setFormat(ws, rng.colrng; kw...)
-setFormat(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setFormat(ws, rng.rowrng; kw...)
-setFormat(ws::Worksheet, rng::CellRange; kw...)::Int = process_cellranges(setFormat, ws, rng; kw...)
-setFormat(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setFormat, ws, colrng; kw...)
-setFormat(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(setFormat, ws, ncrng; kw...)
-setFormat(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setFormat, ws, rowrng; kw...)
-setFormat(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setFormat, ws, ref_or_rng; kw...)
-setFormat(xl::XLSXFile, sheetcell::String; kw...)::Int = process_sheetcell(setFormat, xl, sheetcell; kw...)
-#setFormat(ws::Worksheet, row::Integer, col::Integer; kw...) = setFormat(ws, CellRef(row, col); kw...)
-setFormat(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setFormat, ws, row, nothing; kw...)
-setFormat(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setFormat, ws, nothing, col; kw...)
-setFormat(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setFormat, ws, nothing, nothing; kw...)
-setFormat(ws::Worksheet, ::Colon; kw...) = process_colon(setFormat, ws, nothing, nothing; kw...)
-setFormat(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_veccolon(setFormat, ws, row, nothing; kw...)
-setFormat(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setFormat, ws, nothing, col; kw...)
-setFormat(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setFormat, ws, row, col; kw...)
-setFormat(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_vecint(setFormat, ws, row, col; kw...)
-setFormat(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setFormat, ws, row, col; kw...)
-setFormat(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setFormat(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-
-function setFormat(sh::Worksheet, cellref::CellRef;
- format::Union{Nothing,String}=nothing
-)::Int
- get_xlsxfile(sh).is_writable ||
- throw(XLSXError("Cannot set number format because XLSXFile is not writable."))
-
- wb = get_workbook(sh)
- cell = getcell(sh, cellref)
- cell isa EmptyCell &&
- throw(XLSXError("Cannot set format for an `EmptyCell`: $(cellname(cellref)). Set the value first."))
-
- allXfNodes = get_cellXfs_nodes(wb)
-
- if cell.style == UInt32(0)
- cell.style = get_num_style_index(sh, allXfNodes, 0).id
- end
-
- cell_style = styles_cell_xf(allXfNodes, cell.style)
- cell_format = getFormat(wb, cell_style)
-
- # No format kwarg — return existing format id, or 0 if none set
- if isnothing(format)
- return isnothing(cell_format) ? 0 : cell_format.numFmtId
- end
-
- # Resolve new format id: built-in lookup or next available custom id
- new_formatid = if haskey(BUILTIN_FORMAT_CODES, format)
- BUILTIN_FORMAT_CODES[format]
- else
- get_new_formatId(wb, format)
- end
-
- # numFmtId == 0 is the "General" default format — applyNumberFormat is redundant for it
- atts = new_formatid == 0 ?
- (["numFmtId"], [string(new_formatid)]) :
- (["numFmtId", "applyNumberFormat"], [string(new_formatid), "1"])
-
- newstyle = update_template_xf(sh, allXfNodes, CellDataFormat(cell.style), atts[1], atts[2]).id
- cell.style = newstyle
-
- return new_formatid
-end
-
-"""
- setUniformFormat(sh::Worksheet, cr::String; kw...) -> ::Int
- setUniformFormat(xf::XLSXFile, cr::String, kw...) -> ::Int
-
- setUniformFormat(sh::Worksheet, rows, cols; kw...) -> ::Int
-
-Set the number format used by a cell range, a column range or row range or a
-named range in a worksheet or XLSXFile to be to be uniformly the same format.
-Alternatively, specify the rows and columns using any combination of
-Integer, UnitRange, Vector{Integer} or `:`.
-
-First, the number format of the first cell in the range (the top-left cell) is
-updated according to the given `kw...` (using `setFormat()`). The resultant format is
-then applied to each remaining cell in the range.
-
-As a result, every cell in the range will have a uniform number format.
-
-This is functionally equivalent to applying `setFormat()` to each cell in the range
-but may be more efficient.
-
-Applying `setUniformFormat()` without any keyword arguments simply copies the `Format`
-attributes from the first cell specified to all the others.
-
-The value returned is the `numfmtId` of the format uniformly applied to the cells.
-If all cells in the range are `EmptyCells`, the returned value is -1.
-
-For keyword definitions see [`setFormat()`](@ref).
-
-# Examples:
-```julia
-julia> XLSX.setUniformFormat(xf, "Sheet1!A2:L6"; format = "# ??/??")
-
-julia> XLSX.setUniformFormat(sh, "F1:F5"; format = "Currency")
-
-julia> setUniformFormat(sh, "B2,A5:D22") # Copy `Format` from B2 to cells in A5:D22
-
-```
-"""
-function setUniformFormat end
-setUniformFormat(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setUniformFormat(ws, rng.rng; kw...)
-setUniformFormat(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setUniformFormat(ws, rng.colrng; kw...)
-setUniformFormat(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setUniformFormat(ws, rng.rowrng; kw...)
-setUniformFormat(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setUniformFormat, ws, colrng; kw...)
-setUniformFormat(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setUniformFormat, ws, rowrng; kw...)
-setUniformFormat(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_uniform_ncranges(setFormat, ws, ncrng, ["numFmtId", "applyNumberFormat"]; kw...)
-setUniformFormat(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setUniformFormat, xl, sheetcell; kw...)
-setUniformFormat(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setUniformFormat, ws, ref_or_rng; kw...)
-setUniformFormat(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setFormat, ws, row, nothing; kw...)
-setUniformFormat(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setFormat, ws, nothing, col; kw...)
-setUniformFormat(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setUniformFormat, ws, nothing, nothing; kw...)
-setUniformFormat(ws::Worksheet, ::Colon; kw...) = process_colon(setUniformFormat, ws, nothing, nothing; kw...)
-setUniformFormat(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_uniform_veccolon(setFormat, ws, row, nothing, ["numFmtId", "applyNumberFormat"]; kw...)
-setUniformFormat(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_veccolon(setFormat, ws, nothing, col, ["numFmtId", "applyNumberFormat"]; kw...)
-setUniformFormat(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_vecint(setFormat, ws, row, col, ["numFmtId", "applyNumberFormat"]; kw...)
-setUniformFormat(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_uniform_vecint(setFormat, ws, row, col, ["numFmtId", "applyNumberFormat"]; kw...)
-setUniformFormat(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_uniform_vecint(setFormat, ws, row, col, ["numFmtId", "applyNumberFormat"]; kw...)
-setUniformFormat(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setUniformFormat(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setUniformFormat(ws::Worksheet, rng::CellRange; kw...)::Int = process_uniform_attribute(setFormat, ws, rng, ["numFmtId", "applyNumberFormat"]; kw...)
-
-#
-# -- Set uniform styles
-#
-
-"""
- setUniformStyle(sh::Worksheet, cr::String) -> ::Int
- setUniformStyle(xf::XLSXFile, cr::String) -> ::Int
-
- setUniformStyle(sh::Worksheet, rows, cols) -> ::Int
-
-Set the cell `style` used by a cell range, a column range or row range
-or a named range in a worksheet or XLSXFile to be the same as that of
-the first cell in the range that is not an `EmptyCell`.
-Alternatively, specify the rows and columns using any combination of
-Integer, UnitRange, Vector{Integer} or `:`.
-
-As a result, every cell in the range will have a uniform `style`.
-
-A cell `style` consists of the collection of `format`, `alignment`, `border`,
-`font` and `fill`.
-
-If the first cell has no defined `style` (`s=""`), all cells will be given the
-same undefined `style`.
-
-The value returned is the `styleId` of the `style` uniformly applied to the cells or
-`nothing` if the style is undefined.
-If all cells in the range are `EmptyCells`, the returned value is -1.
-
-# Examples:
-```julia
-julia> XLSX.setUniformStyle(xf, "Sheet1!A2:L6")
-
-julia> XLSX.setUniformStyle(sh, "F1:F5")
-
-julia> XLSX.setUniformStyle(sh, 2:5, 5)
-
-julia> XLSX.setUniformStyle(sh, 2, :)
-
-```
-"""
-function setUniformStyle end
-setUniformStyle(ws::Worksheet, rng::SheetCellRange) = do_sheet_names_match(ws, rng) && setUniformStyle(ws, rng.rng)
-setUniformStyle(ws::Worksheet, rng::SheetColumnRange) = do_sheet_names_match(ws, rng) && setUniformStyle(ws, rng.colrng)
-setUniformStyle(ws::Worksheet, rng::SheetRowRange) = do_sheet_names_match(ws, rng) && setUniformStyle(ws, rng.rowrng)
-setUniformStyle(ws::Worksheet, colrng::ColumnRange) = process_columnranges(setUniformStyle, ws, colrng)
-setUniformStyle(ws::Worksheet, rowrng::RowRange) = process_rowranges(setUniformStyle, ws, rowrng)
-setUniformStyle(ws::Worksheet, ncrng::NonContiguousRange) = process_uniform_ncranges(ws, ncrng)
-setUniformStyle(xl::XLSXFile, sheetcell::AbstractString) = process_sheetcell(setUniformStyle, xl, sheetcell)
-setUniformStyle(ws::Worksheet, ref_or_rng::AbstractString) = process_ranges(setUniformStyle, ws, ref_or_rng)
-setUniformStyle(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon) = process_colon(ws, row, nothing)
-setUniformStyle(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}) = process_colon(ws, nothing, col)
-setUniformStyle(ws::Worksheet, ::Colon, ::Colon) = process_colon(ws, nothing, nothing)
-setUniformStyle(ws::Worksheet, ::Colon) = process_colon(ws, nothing, nothing)
-setUniformStyle(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon) = process_uniform_veccolon(ws, row, nothing)
-setUniformStyle(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}) = process_uniform_veccolon(ws, nothing, col)
-setUniformStyle(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}) = process_uniform_vecint(ws, row, col)
-setUniformStyle(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = process_uniform_vecint(ws, row, col)
-setUniformStyle(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}) = process_uniform_vecint(ws, row, col)
-setUniformStyle(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = setUniformStyle(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))))
-
-function setUniformStyle(ws::Worksheet, rng::CellRange)::Union{Nothing,Int}
- get_xlsxfile(ws).is_writable ||
- throw(XLSXError("Cannot set styles because XLSXFile is not writable."))
-
- newid::Union{Nothing,Int} = nothing
- is_first = true
- first_font = nothing
-
- for cellref in rng
- newid, is_first, first_font = process_uniform_core(ws, cellref, newid, is_first, first_font)
- end
-
- # -1 signals that all cells in the range were EmptyCells
- is_first && return -1
-
- return newid
-end
-
-#
-# -- Get and set column width
-#
-
-"""
- setColumnWidth(sh::Worksheet, cr::String; kw...) -> ::Int
- setColumnWidth(xf::XLSXFile, cr::String, kw...) -> ::Int
-
- setColumnWidth(sh::Worksheet, row, col; kw...) -> ::Int
- setColumnWidth(sh::Worksheet, col; kw...) -> ::Int
-
-Set the width of a column or column range.
-
-A standard cell reference or cell range can be used to define the column range.
-The function will use the columns and ignore the rows. Named cells and named
-ranges can similarly be used.
-Alternatively, specify the row and column using any combination of
-Integer, UnitRange, Vector{Integer} or `:`, but only the columns will be used.
-The row can be omitted and column specified alone, using an integer or colon.
-
-
-The function uses one keyword used to define a column width:
-- `width::Real = nothing` : Defines width in Excel's own (internal) units
-
-When you set a column widths interactively in Excel you can see the width
-in "internal" units and in pixels. The width stored in the xlsx file is slightly
-larger than the width shown intertactively because Excel adds some cell padding.
-The method Excel uses to calculate the padding is obscure and complex. This
-function does not attempt to replicate it, but simply adds 0.71 internal units
-to the value specified. The value set is unlikely to match the value seen
-interactivley in the resultant spreadsheet, but will be close.
-
-You can set a column width to 0.
-
-The function returns a value of 0. The return value is meaningless but required to comply with the
-range dispatch infrastructure, which expects an `Int` return from all `set*` functions.
-
-`setColumnWidth` requires a file to be open for writing as well as reading (`mode="rw"` or open as a template) but
-it can work on empty cells.
-
-# Examples:
-```julia
-julia> XLSX.setColumnWidth(xf, "Sheet1!A2"; width = 50)
-
-julia> XLSX.seColumnWidth(sh, "F1:F5"; width = 0)
-
-julia> XLSX.setColumnWidth(sh, "I"; width = 24.37)
-
-```
-"""
-function setColumnWidth end
-setColumnWidth(ws::Worksheet, ref::SheetCellRef; kw...) = do_sheet_names_match(ws, ref) && setColumnWidth(ws, ref.cellref; kw...)
-setColumnWidth(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setColumnWidth(ws, rng.rng; kw...)
-setColumnWidth(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setColumnWidth(ws, rng.colrng; kw...)
-setColumnWidth(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setColumnWidth(ws, rng.rowrng; kw...)
-setColumnWidth(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setColumnWidth, ws, colrng; kw...)
-setColumnWidth(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setColumnWidth, ws, rowrng; kw...)
-setColumnWidth(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(setColumnWidth, ws, ncrng; kw...)
-setColumnWidth(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setColumnWidth, ws, ref_or_rng; kw...)
-setColumnWidth(xl::XLSXFile, sheetcell::String; kw...)::Int = process_sheetcell(setColumnWidth, xl, sheetcell; kw...)
-setColumnWidth(ws::Worksheet, cr::CellRef; kw...)::Int = setColumnWidth(ws, CellRange(cr, cr); kw...)
-#setColumnWidth(ws::Worksheet, row::Integer, col::Integer; kw...) = setColumnWidth(ws, CellRef(row, col); kw...)
-setColumnWidth(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setColumnWidth, ws, row, nothing; kw...)
-setColumnWidth(ws::Worksheet, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setColumnWidth, ws, nothing, col; kw...) # convenience: bare column index/range
-setColumnWidth(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setColumnWidth, ws, nothing, col; kw...)
-setColumnWidth(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setColumnWidth, ws, nothing, nothing; kw...)
-setColumnWidth(ws::Worksheet, ::Colon; kw...) = process_colon(setColumnWidth, ws, nothing, nothing; kw...)
-setColumnWidth(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_veccolon(setColumnWidth, ws, row, nothing; kw...)
-setColumnWidth(ws::Worksheet, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setColumnWidth, ws, nothing, col; kw...) # convenience: bare column vector/steprange
-setColumnWidth(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setColumnWidth, ws, nothing, col; kw...)
-setColumnWidth(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setColumnWidth, ws, row, col; kw...)
-setColumnWidth(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_vecint(setColumnWidth, ws, row, col; kw...)
-setColumnWidth(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setColumnWidth, ws, row, col; kw...)
-setColumnWidth(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setColumnWidth(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-function setColumnWidth(ws::Worksheet, rng::CellRange; width::Union{Nothing,Real}=nothing)::Int
- get_xlsxfile(ws).is_writable ||
- throw(XLSXError("Cannot set column widths: `XLSXFile` is not writable."))
-
- # Validate and resolve width before any XML work
- isnothing(width) && return 0
- width < 0 && throw(XLSXError("Invalid width: $width. Must be >= 0."))
-
- wb=get_workbook(ws)
- padded_width = width + EXCEL_COLUMN_WIDTH_PADDING
-
- left = rng.start.column_number
- right = rng.stop.column_number
-
- # Find the block in the worksheet XML, inserting one before if absent
- sheetdoc = xmlroot(wb, ws.relationship_id)
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
- i, j = get_idces(sheetdoc, "worksheet", "cols")
-
- if isnothing(j)
- k, l = get_idces(sheetdoc, "worksheet", "sheetData")
- i != k && throw(XLSXError("Unexpected worksheet structure: parent index does not match index."))
- insert!(sheetdoc[k].children, l, XML.Element("$(pfx)Cols"))
- j = l
- end
-
- # Build a map of existing column definitions keyed by min (column index string)
- col_defs = Dict{String,Dict{String,String}}()
- for c in xml_elements(sheetdoc[i][j])
- col_defs[c["min"]] = Dict{String,String}(XML.attributes(c))
- end
-
- # Update or insert column definitions for each column in the range
- for col in left:right
- scol = string(col)
- if haskey(col_defs, scol)
- col_defs[scol]["width"] = string(padded_width)
- col_defs[scol]["customWidth"] = "1"
- else
- col_defs[scol] = Dict(
- "min" => scol,
- "max" => scol,
- "width" => string(padded_width),
- "customWidth" => "1"
- )
- end
- end
-
- # Rebuild the node from the updated definitions
- new_cols = unlink(sheetdoc[i][j], ("cols", "col"), pfx)
- for atts in values(col_defs)
- new_col = XML.Element("$(pfx)col")
- for (k, v) in atts
- new_col[k] = v
- end
- push!(new_cols, new_col)
- end
-
- sheetdoc[i][j] = new_cols
-
- # Working on worksheet XML directly requires updating the file cache
- update_worksheets_xml!(get_xlsxfile(ws))
-
- return 0
-end
-
-"""
- getColumnWidth(sh::Worksheet, cr::String) -> ::Union{Nothing, Real}
- getColumnWidth(xf::XLSXFile, cr::String) -> ::Union{Nothing, Real}
-
- getColumnWidth(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, Real}
-
-Get the width of a column defined by a cell reference or named cell.
-The specified cell must be within the sheet dimension.
-
-A standard cell reference or defined name may be used to define the column.
-The function will use the column number and ignore the row.
-
-The function returns the value of the column width or nothing if the column
-does not have an explicitly defined width.
-
-# Examples:
-```julia
-julia> XLSX.getColumnWidth(xf, "Sheet1!A2")
-
-julia> XLSX.getColumnWidth(sh, "F1")
-
-julia> XLSX.getColumnWidth(sh, 1, 6)
-
-```
-"""
-function getColumnWidth end
-getColumnWidth(xl::XLSXFile, sheetcell::String)::Union{Nothing,Float64} = process_get_sheetcell(getColumnWidth, xl, sheetcell)
-getColumnWidth(ws::Worksheet, cr::String) = process_get_cellname(getColumnWidth, ws, cr)
-getColumnWidth(ws::Worksheet, row::Integer, col::Integer) = getColumnWidth(ws, CellRef(row, col))
-function getColumnWidth(ws::Worksheet, cellref::CellRef)::Union{Nothing,Real}
- # May be better if column width were part of ws.cache?
-
- if !get_xlsxfile(ws).is_writable
- throw(XLSXError("Cannot get column width: `XLSXFile` is not writable."))
- end
-
- d = get_dimension(ws)
- if cellref ∉ d
- throw(XLSXError("Cell specified is outside sheet dimension `$d`"))
- end
-
- sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id)
-
- i, j = get_idces(sheetdoc, "worksheet", "cols")
-
- if isnothing(j) # There are no existing column formats defined.
- return nothing
- end
- for c in xml_elements(sheetdoc[i][j])
- if c["min"] == string(cellref.column_number)
- if haskey(c, "width")
- return parse(Float64, c["width"])
- else
- break
- end
- end
- end
-
- # Either the column definition was found but with no width attribute or not found at all.
- return nothing
-end
-
-#
-# -- Get and set row height
-#
-
-"""
- setRowHeight(sh::Worksheet, cr::String; kw...) -> ::Int
- setRowHeight(xf::XLSXFile, cr::String, kw...) -> ::Int
-
- setRowHeight(sh::Worksheet, row, col; kw...) -> ::Int
-
-Set the height of a row or row range.
-
-A standard cell reference or cell range must be used to define the row range.
-The function will use the rows and ignore the columns. Named cells and named
-ranges can similarly be used.
-Alternatively, specify the row and column using any combination of
-Integer, UnitRange, Vector{Integer} or `:`, but only the rows will be used.
-
-The function uses one keyword used to define a row height:
-- `height::Real = nothing` : Defines height in Excel's own (internal) units.
-
-When you set row heights interactively in Excel you can see the height
-in "internal" units and in pixels. The height stored in the xlsx file is slightly
-larger than the height shown interactively because Excel adds some cell padding.
-The method Excel uses to calculate the padding is obscure and complex. This
-function does not attempt to replicate it, but simply adds 0.21 internal units
-to the value specified. The value set is unlikely to match the value seen
-interactivley in the resultant spreadsheet, but it will be close.
-
-Row height cannot be set for empty rows, which will quietly be skipped.
-A row must have at least one cell containing a value before its height can be set.
-
-You can set a row height to 0.
-
-The function returns a value of 0 unless all rows are empty, in which case
-it returns a value of -1.
-
-# Examples:
-```julia
-julia> XLSX.setRowHeight(xf, "Sheet1!A2"; height = 50)
-
-julia> XLSX.setRowHeight(sh, "F1:F5"; height = 0)
-
-julia> XLSX.setRowHeight(sh, "I"; height = 24.56)
-
-```
-"""
-function setRowHeight end
-setRowHeight(ws::Worksheet, ref::SheetCellRef; kw...) = do_sheet_names_match(ws, ref) && setRowHeight(ws, ref.cellref; kw...)
-setRowHeight(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setRowHeight(ws, rng.rng; kw...)
-setRowHeight(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setRowHeight(ws, rng.colrng; kw...)
-setRowHeight(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setRowHeight(ws, rng.rowrng; kw...)
-setRowHeight(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setRowHeight, ws, colrng; kw...)
-setRowHeight(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setRowHeight, ws, rowrng; kw...)
-setRowHeight(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(setRowHeight, ws, ncrng; kw...)
-setRowHeight(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setRowHeight, ws, ref_or_rng; kw...)
-setRowHeight(xl::XLSXFile, sheetcell::String; kw...)::Int = process_sheetcell(setRowHeight, xl, sheetcell; kw...)
-setRowHeight(ws::Worksheet, cr::CellRef; kw...)::Int = setRowHeight(ws::Worksheet, CellRange(cr, cr); kw...)
-#setRowHeight(ws::Worksheet, row::Integer, col::Integer; kw...) = setRowHeight(ws, CellRef(row, col); kw...)
-setRowHeight(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setRowHeight, ws, row, nothing; kw...)
-setRowHeight(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setRowHeight, ws, row, nothing; kw...)
-setRowHeight(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setRowHeight, ws, nothing, col; kw...)
-setRowHeight(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setRowHeight, ws, nothing, nothing; kw...)
-setRowHeight(ws::Worksheet, ::Colon; kw...) = process_colon(setRowHeight, ws, nothing, nothing; kw...)
-setRowHeight(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setRowHeight, ws, row, nothing; kw...)
-setRowHeight(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_veccolon(setRowHeight, ws, row, nothing; kw...)
-setRowHeight(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setRowHeight, ws, nothing, col; kw...)
-setRowHeight(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setRowHeight, ws, row, col; kw...)
-setRowHeight(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_vecint(setRowHeight, ws, row, col; kw...)
-setRowHeight(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setRowHeight, ws, row, col; kw...)
-setRowHeight(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setRowHeight(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-function setRowHeight(ws::Worksheet, rng::CellRange; height::Union{Nothing,Real}=nothing)::Int
-
- if !get_xlsxfile(ws).is_writable
- throw(XLSXError("Cannot get row height: `XLSXFile` is not writable."))
- end
-
- top = rng.start.row_number
- bottom = rng.stop.row_number
- padded_height = isnothing(height) ? -1 : height + 0.2109375 # Excel adds cell padding to a user specified width
- if !isnothing(height) && height < 0
- throw(XLSXError("Invalid value specified for height: $height. Height must be >= 0."))
- end
-
- if isnothing(height) # No-op
- return 0
- end
-
- if isnothing(ws.cache) || !haskey(ws.cache.row_ht, rng.stop.row_number)
- _ = ws[rng.stop] # Ensure cache filled at least to include the last row in `rng`.
- end
-
- first = true
-
- for r in top:bottom
- if haskey(ws.cache.row_ht, r) # may still be missing if row is entirely empty.
- ws.cache.row_ht[r] = padded_height
- first=false
- end
- end
-
- if first == true
- return -1
- end
-
- return 0
-
-end
-
-"""
- getRowHeight(sh::Worksheet, cr::String) -> ::Union{Nothing, Real}
- getRowHeight(xf::XLSXFile, cr::String) -> ::Union{Nothing, Real}
-
- getRowHeight(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, Real}
-
-Get the height of a row defined by a cell reference or named cell.
-The specified cell must be within the sheet dimension.
-
-A standard cell reference or defined name must be used to define the row.
-The function will use the row number and ignore the column.
-
-The function returns the value of the row height or nothing if the row
-does not have an explicitly defined height.
-
-If the row is not found (an empty row), returns -1.
-
-# Examples:
-```julia
-julia> XLSX.getRowHeight(xf, "Sheet1!A2")
-
-julia> XLSX.getRowHeight(sh, "F1")
-
-julia> XLSX.getRowHeight(sh, 1, 6)
-
-```
-"""
-function getRowHeight end
-getRowHeight(xl::XLSXFile, sheetcell::String)::Union{Nothing,Real} = process_get_sheetcell(getRowHeight, xl, sheetcell)
-getRowHeight(ws::Worksheet, cr::String) = process_get_cellname(getRowHeight, ws, cr)
-getRowHeight(ws::Worksheet, row::Integer, col::Integer) = getRowHeight(ws, CellRef(row, col))
-function getRowHeight(ws::Worksheet, cellref::CellRef)::Union{Nothing,Real}
-
- if !get_xlsxfile(ws).is_writable
- throw(XLSXError("Cannot get row height: `XLSXFile` is not writable."))
- end
-
- d = get_dimension(ws)
- if cellref ∉ d
- throw(XLSXError("Cell specified is outside sheet dimension `$d`"))
- end
-
- if isnothing(ws.cache) || !haskey(ws.cache.row_ht, cellref.row_number)
- _ = ws[cellref] # Ensure cache filled at least to include the last row in `rng`.
- end
-
- if haskey(ws.cache.row_ht, cellref.row_number) # Row might still be empty
- return ws.cache.row_ht[cellref.row_number]
- end
-
- return -1 # Row specified not found (is empty)
-
-end
-
-#
-# -- Get merged cells
-#
-
-"""
- getMergedCells(ws::Worksheet) -> Union{Vector{CellRange}, Nothing}
-
-Return a vector of the `CellRange` of all merged cells in the specified worksheet.
-Return nothing if the worksheet contains no merged cells.
-
-The Excel file must be opened in write mode to work with merged cells.
-
-# Examples:
-```julia
-julia> f = XLSX.readxlsx("test.xlsx")
-XLSXFile("C:\\Users\\...\\test.xlsx") containing 1 Worksheet
- sheetname size range
--------------------------------------------------
- Sheet1 2x2 A1:B2
-
-julia> s = f["Sheet1"]
-2×2 XLSX.Worksheet: ["Sheet1"](A1:B2)
-
-julia> XLSX.getMergedCells(s)
-1-element Vector{XLSX.CellRange}:
- B1:B2
-
-```
-"""
-function getMergedCells(ws::Worksheet)::Union{Vector{CellRange},Nothing}
- # May be better if merged cells were part of ws.cache?
-
- if !get_xlsxfile(ws).is_writable
- throw(XLSXError("Cannot get merged cells: `XLSXFile` is not writable."))
- end
-
- if !get_xlsxfile(ws).use_cache_for_sheet_data
- throw(XLSXError("Cannot get merged cells because cache is not enabled."))
- end
-
- sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id)
- i, j = get_idces(sheetdoc, "worksheet", "mergeCells")
-
- if isnothing(j) # There are no existing merged cells.
- return nothing
- end
-
- c = xml_elements(sheetdoc[i][j])
- if length(c) != parse(Int, sheetdoc[i][j]["count"])
- throw(XLSXError("Unexpected number of mergeCells found: $(length(c)). Expected $(sheetdoc[i][j]["count"])."))
- end
-
- mergedCells = Vector{CellRange}()
- for cell in c
- !haskey(cell, "ref") && throw(XLSXError("No `ref` attribute found in `mergeCell` element."))
- push!(mergedCells, CellRange(cell["ref"]))
- end
-
- return mergedCells
-end
-
-"""
- isMergedCell(ws::Worksheet, cr::String) -> Bool
- isMergedCell(xf::XLSXFile, cr::String) -> Bool
-
- isMergedCell(ws::Worksheet, row::Int, col::Int) -> Bool
-
-Return `true` if a cell is part of a merged cell range and `false` if not.
-The specified cell must be within the sheet dimension.
-
-Alternatively, if you have already obtained the merged cells for the worksheet,
-you can avoid repeated determinations and pass them as a keyword argument to
-the function:
-
- isMergedCell(ws::Worksheet, cr::String; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Bool
- isMergedCell(xf::XLSXFile, cr::String; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Bool
-
- isMergedCell(ws::Worksheet, row:Int, col::Int; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Bool
-
-The Excel file must be opened in write mode to work with merged cells.
-
-# Examples:
-```julia
-julia> XLSX.isMergedCell(xf, "Sheet1!A1")
-
-julia> XLSX.isMergedCell(sh, "A1")
-
-julia> XLSX.isMergedCell(sh, 2, 4) # cell D2
-
-julia> mc = XLSX.getMergedCells(sh)
-
-julia> XLSX.isMergedCell(sh, XLSX.CellRef("A1"), mc)
-
-```
-"""
-function isMergedCell end
-isMergedCell(xl::XLSXFile, sheetcell::String; kw...)::Bool = process_get_sheetcell(isMergedCell, xl, sheetcell; kw...)
-isMergedCell(ws::Worksheet, cr::String; kw...)::Bool = process_get_cellname(isMergedCell, ws, cr; kw...)
-isMergedCell(ws::Worksheet, row::Integer, col::Integer; kw...) = isMergedCell(ws, CellRef(row, col); kw...)
-function isMergedCell(ws::Worksheet, cellref::CellRef; mergedCells::Union{Vector{CellRange},Nothing,Missing}=missing)::Bool
-
- if !get_xlsxfile(ws).is_writable
- throw(XLSXError("Cannot get merged cells: `XLSXFile` is not writable."))
- end
-
- if !get_xlsxfile(ws).use_cache_for_sheet_data
- throw(XLSXError("Cannot get merged cells because cache is not enabled."))
- end
-
- d = get_dimension(ws)
- if cellref ∉ d
- throw(XLSXError("Cell specified is outside sheet dimension `$d`"))
- end
-
- if ismissing(mergedCells) # Get mergedCells if missing
- mergedCells = getMergedCells(ws)
- end
- if isnothing(mergedCells) # No merged cells in sheet
- return false
- end
- for rng in mergedCells
- if cellref ∈ rng
- return true
- end
- end
-
- return false
-end
-
-"""
- getMergedBaseCell(ws::Worksheet, cr::String) -> Union{Nothing, NamedTuple{CellRef, Any}}
- getMergedBaseCell(xf::XLSXFile, cr::String) -> Union{Nothing, NamedTuple{CellRef, Any}}
-
- getMergedBaseCell(ws::Worksheet, row::Int, col::Int) -> Union{Nothing, NamedTuple{CellRef, Any}}
-
-Return the cell reference and cell value of the base cell of a merged cell range in a worksheet as a named tuple.
-The specified cell must be within the sheet dimension.
-If the specified cell is not part of a merged cell range, return `nothing`.
-
-The base cell is the top-left cell of the merged cell range and is the reference cell for the range.
-
-The tuple returned contains:
-- `baseCell` : the reference (`CellRef`) of the base cell
-- `baseValue` : the value of the base cell
-
-Additionally, if you have already obtained the merged cells for the worksheet,
-you can avoid repeated determinations and pass them as a keyword argument to
-the function:
-
- getMergedBaseCell(ws::Worksheet, cr::String; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Union{Nothing, NamedTuple{CellRef, Any}}
- getMergedBaseCell(xf::XLSXFile, cr::String; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Union{Nothing, NamedTuple{CellRef, Any}}
-
- getMergedBaseCell(ws::Worksheet, row::Int, col::Int; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Union{Nothing, NamedTuple{CellRef, Any}}
-
-The Excel file must be opened in write mode to work with merged cells.
-
-# Examples:
-```julia
-julia> XLSX.getMergedBaseCell(xf, "Sheet1!B2")
-(baseCell = B1, baseValue = 3)
-
-julia> XLSX.getMergedBaseCell(sh, "B2")
-(baseCell = B1, baseValue = 3)
-
-julia> XLSX.getMergedBaseCell(sh, 2, 2)
-(baseCell = B1, baseValue = 3)
-
-```
-"""
-function getMergedBaseCell end
-getMergedBaseCell(xl::XLSXFile, sheetcell::String; kw...) = process_get_sheetcell(getMergedBaseCell, xl, sheetcell; kw...)
-getMergedBaseCell(ws::Worksheet, cr::String; kw...) = process_get_cellname(getMergedBaseCell, ws, cr; kw...)
-getMergedBaseCell(ws::Worksheet, row::Integer, col::Integer; kw...) = getMergedBaseCell(ws, CellRef(row, col); kw...)
-function getMergedBaseCell(ws::Worksheet, cellref::CellRef; mergedCells::Union{Vector{CellRange},Nothing,Missing}=missing)
-
- if !get_xlsxfile(ws).is_writable
- throw(XLSXError("Cannot get merged cells: `XLSXFile` is not writable."))
- end
-
- if !get_xlsxfile(ws).use_cache_for_sheet_data
- throw(XLSXError("Cannot get merged cells because cache is not enabled."))
- end
-
- d = get_dimension(ws)
- if cellref ∉ d
- throw(XLSXError("Cell specified is outside sheet dimension `$d`"))
- end
-
- if ismissing(mergedCells) # Get mergedCells if missing
- mergedCells = getMergedCells(ws)
- end
- if isnothing(mergedCells) # No merged cells in sheet
- return nothing
- end
- for rng in mergedCells
- if cellref ∈ rng
- return (; baseCell=rng.start, baseValue=ws[rng.start])
- end
- end
- return nothing
-end
-
-"""
- mergeCells(ws::Worksheet, cr::String) -> 0
- mergeCells(xf::XLSXFile, cr::String) -> 0
-
- mergeCells(ws::Worksheet, row::Int, col::Int) -> 0
-
-Merge the cells in the range given by `cr`. The value of the merged cell
-will be the value of the first cell in the range (the base cell) prior
-to the merge. All other cells in the range will be set to `missing`,
-reflecting the behaviour of Excel itself.
-
-Merging is limited to the extent of the worksheet dimension.
-
-The specified range must not overlap with any previously merged cells, nor
-with an Excel Table (Excel discards a table that overlaps a merged range).
-
-It is not possible to merge a single cell!
-
-A non-contiguous range composed of multiple cell ranges will be processed as a
-list of separate ranges. Each range will be merged separately. No range within
-a non-contiguous range may be a single cell.
-
-The Excel file must be opened in write mode to work with merged cells.
-
-# Examples:
-```julia
-julia> XLSX.mergeCells(xf, "Sheet1!B2:D3") # Merge a cell range.
-
-julia> XLSX.mergeCells(sh, 1:3, :) # Merge rows to the extent of the dimension.
-
-julia> XLSX.mergeCells(sh, "A:D") # Merge columns to the extent of the dimension.
-
-```
-"""
-function mergeCells end
-mergeCells(ws::Worksheet, rng::SheetCellRange) = do_sheet_names_match(ws, rng) && mergeCells(ws, rng.rng)
-mergeCells(ws::Worksheet, rng::SheetColumnRange) = do_sheet_names_match(ws, rng) && mergeCells(ws, rng.colrng)
-mergeCells(ws::Worksheet, rng::SheetRowRange) = do_sheet_names_match(ws, rng) && mergeCells(ws, rng.rowrng)
-mergeCells(ws::Worksheet, colrng::ColumnRange)::Int = process_columnranges(mergeCells, ws, colrng)
-mergeCells(ws::Worksheet, rowrng::RowRange)::Int = process_rowranges(mergeCells, ws, rowrng)
-mergeCells(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(mergeCells, ws, ncrng; kw...)
-mergeCells(xl::XLSXFile, sheetcell::AbstractString)::Int = process_sheetcell(mergeCells, xl, sheetcell)
-mergeCells(ws::Worksheet, ref_or_rng::AbstractString)::Int = process_ranges(mergeCells, ws, ref_or_rng)
-mergeCells(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon) = process_colon(mergeCells, ws, row, nothing)
-mergeCells(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}) = process_colon(mergeCells, ws, nothing, col)
-mergeCells(ws::Worksheet, ::Colon, ::Colon) = process_colon(mergeCells, ws, nothing, nothing)
-mergeCells(ws::Worksheet, ::Colon) = process_colon(mergeCells, ws, nothing, nothing)
-mergeCells(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = mergeCells(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))))
-function mergeCells(ws::Worksheet, cr::CellRange)
- # May be better if merged cells were part of ws.cache?
-
- # !is_valid_cell_range(cr) && throw(XLSXError("\"$cr\" is not a valid cell range."))
-
- !issubset(cr, get_dimension(ws)) && throw(XLSXError("Range `$cr` goes outside worksheet dimension."))
-
- length(cr) == 1 && throw(XLSXError("Cannot merge a single cell: `$cr`"))
-
- if !get_xlsxfile(ws).is_writable
- throw(XLSXError("Cannot merge cells: `XLSXFile` is not writable."))
- end
-
- if !get_xlsxfile(ws).use_cache_for_sheet_data
- throw(XLSXError("Cannot get merged cells because cache is not enabled."))
- end
-
- for t in tables(ws) # Excel drops a table that overlaps a merged range
- if intersects(cr, t.ref)
- throw(XLSXError("Merged range (`$cr`) cannot overlap table `$(t.name)` (`$(t.ref)`)."))
- end
- end
-
- sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id) # find the block in the worksheet's xml file
- i, j = get_idces(sheetdoc, "worksheet", "mergeCells")
-
- if isnothing(j) # There are no existing merged cells. Insert immediately after the block and push everything else down one.
- k, l = get_idces(sheetdoc, "worksheet", "sheetData")
- len = length(sheetdoc[k])
- i != k && throw(XLSXError("Some problem here!"))
- if l != len
- insert!(sheetdoc[k].children, l+1, XML.Element("mergeCells"))
- else
- push!(sheetdoc[k], XML.Element("mergeCells"))
- end
- j = l + 1
- count = 0
- else # There are already some existing merged cells
- c = xml_elements(sheetdoc[i][j])
- count = length(c)
- if count != parse(Int, sheetdoc[i][j]["count"])
- throw(XLSXError("Unexpected number of mergeCells found: $(length(c)). Expected $(sheetdoc[i][j]["count"])."))
- end
- for child in c
- if intersects(cr, CellRange(child["ref"]))
- throw(XLSXError("Merged range (`$cr`) cannot overlap with existing merged range (`" * child["ref"] * "`)."))
- end
- end
- end
-
- push!(sheetdoc[i][j], XML.Element("mergeCell", ref=string(cr))) # Add the new merged cell range.
- count += 1
- sheetdoc[i][j]["count"] = count
-
- # All cells except the base cell are set to missing.
- let first = true
- for cell in cr
- if first
- first = false
- continue
- else
- ws[cell] = ""
- end
- end
- end
-
- update_worksheets_xml!(get_xlsxfile(ws))
-
- return 0 # meaningless return value. Int required to comply with reference decoding structure.
-end
-
-"""
- removeMergedCells(ws::Worksheet, cr::String) -> 0
- removeMergedCells(xf::XLSXFile, cr::String) -> 0
-
- removeMergedCells(ws::Worksheet, row::Int, col::Int) -> 0
-
- removeMergedCells(ws::Worksheet) -> 0
- removeMergedCells(ws::Worksheet, ::Colon) -> 0
-
-Unmerge merged cells in a worksheet.
-
-A merged range is unmerged if it fully encloses the range given by `cr`. Naming
-any single cell of a merged range is therefore enough to unmerge the whole of
-it, without needing to know its extent. A range larger than the merge encloses
-nothing and is a no-op: `removeMergedCells(sh, "D47:J47")` leaves a merged
-`D47:H47` intact.
-
-Because merged ranges cannot overlap one another, a contiguous `cr` unmerges at
-most one range. Use the non-contiguous form, or the no-argument form, to unmerge
-several at once.
-
-Called with no cell reference at all (or with a single `Colon`), every merged
-range in the worksheet is unmerged.
-
-Unmerging does not change any cell values, reflecting the behaviour of Excel
-itself.
-
-If nothing in the worksheet encloses `cr`, the worksheet is left untouched.
-Specifying a range that is not merged is not an error it is a no-op.
-
-A non-contiguous range composed of multiple cell ranges will be processed as a
-list of separate ranges. Each range will be unmerged separately.
-
-The Excel file must be opened in write mode to work with merged cells.
-
-# Examples:
-```julia
-julia> XLSX.removeMergedCells(xf, "Sheet1!B2") # Unmerge the range containing B2.
-
-julia> XLSX.removeMergedCells(sh, "B2:C3") # Unmerge a range enclosing B2:C3.
-
-julia> XLSX.removeMergedCells(sh, 4, 4) # Unmerge the range containing D4.
-
-julia> XLSX.removeMergedCells(sh, :, 10) # Unmerge a whole-column merge in J.
-
-julia> XLSX.removeMergedCells(sh) # Unmerge everything in the worksheet.
-
-```
-"""
-function removeMergedCells end
-removeMergedCells(ws::Worksheet, rng::SheetCellRange) = do_sheet_names_match(ws, rng) && removeMergedCells(ws, rng.rng)
-removeMergedCells(ws::Worksheet, rng::SheetColumnRange) = do_sheet_names_match(ws, rng) && removeMergedCells(ws, rng.colrng)
-removeMergedCells(ws::Worksheet, rng::SheetRowRange) = do_sheet_names_match(ws, rng) && removeMergedCells(ws, rng.rowrng)
-removeMergedCells(ws::Worksheet, colrng::ColumnRange)::Int = process_columnranges(removeMergedCells, ws, colrng)
-removeMergedCells(ws::Worksheet, rowrng::RowRange)::Int = process_rowranges(removeMergedCells, ws, rowrng)
-removeMergedCells(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(removeMergedCells, ws, ncrng; kw...)
-removeMergedCells(xl::XLSXFile, sheetcell::AbstractString)::Int = process_sheetcell(removeMergedCells, xl, sheetcell)
-removeMergedCells(ws::Worksheet, ref_or_rng::AbstractString)::Int = process_ranges(removeMergedCells, ws, ref_or_rng)
-removeMergedCells(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon) = process_colon(removeMergedCells, ws, row, nothing)
-removeMergedCells(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}) = process_colon(removeMergedCells, ws, nothing, col)
-removeMergedCells(ws::Worksheet, ::Colon, ::Colon) = process_colon(removeMergedCells, ws, nothing, nothing)
-removeMergedCells(ws::Worksheet, ::Colon) = process_colon(removeMergedCells, ws, nothing, nothing)
-removeMergedCells(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = removeMergedCells(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))))
-
-# Unlike `mergeCells`, a single cell is meaningful here: it identifies the merged
-# range that contains it, so `process_ranges` can hand us a bare `CellRef`.
-removeMergedCells(ws::Worksheet, cellref::CellRef)::Int = removeMergedCells(ws, CellRange(cellref, cellref))
-
-removeMergedCells(ws::Worksheet)::Int = _remove_merged_cells(ws, nothing)
-removeMergedCells(ws::Worksheet, cr::CellRange)::Int = _remove_merged_cells(ws, cr)
-
-# `cr === nothing` means "every merged range in the worksheet".
-function _remove_merged_cells(ws::Worksheet, cr::Union{CellRange,Nothing})::Int
-
- xf = get_xlsxfile(ws)
-
- if !xf.is_writable
- throw(XLSXError("Cannot remove merged cells: `XLSXFile` is not writable."))
- end
-
- if !xf.use_cache_for_sheet_data
- throw(XLSXError("Cannot remove merged cells because cache is not enabled."))
- end
-
- if !isnothing(cr) && !issubset(cr, get_dimension(ws))
- throw(XLSXError("Range `$cr` goes outside worksheet dimension."))
- end
-
- sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id) # find the block in the worksheet's xml file
- i, j = get_idces(sheetdoc, "worksheet", "mergeCells")
-
- isnothing(j) && return 0 # There are no merged cells, so nothing to remove.
-
- block = sheetdoc[i][j]
- kids = XML.children(block)
-
- # Indices of the children to drop, in ascending order (as `deleteat!` requires).
- drop = Int[]
- nrefs = 0
- for (k, child) in enumerate(kids)
- XML.nodetype(child) == XML.Element || continue
- nrefs += 1
- !haskey(child, "ref") && throw(XLSXError("No `ref` attribute found in `mergeCell` element."))
- if isnothing(cr) || issubset(cr, CellRange(child["ref"]))
- push!(drop, k)
- end
- end
-
- if nrefs != parse(Int, block["count"])
- throw(XLSXError("Unexpected number of mergeCells found: $nrefs. Expected $(block["count"])."))
- end
-
- isempty(drop) && return 0 # Nothing matched: leave the xml untouched.
-
- if length(drop) == nrefs
- # An empty is not schema-valid, so drop the whole block.
- deleteat!(sheetdoc[i].children, j)
- else
- deleteat!(kids, drop)
- block["count"] = nrefs - length(drop)
- end
-
- update_worksheets_xml!(xf)
-
- return 0 # meaningless return value. Int required to comply with reference decoding structure.
-end
diff --git a/src/cellformats/alignment.jl b/src/cellformats/alignment.jl
new file mode 100644
index 00000000..c4414dbe
--- /dev/null
+++ b/src/cellformats/alignment.jl
@@ -0,0 +1,238 @@
+#
+# -- Get and set alignment attributes
+#
+
+"""
+ getAlignment(sh::Worksheet, cr::String) -> ::Union{Nothing, CellAlignment}
+ getAlignment(xf::XLSXFile, cr::String) -> ::Union{Nothing, CellAlignment}
+
+ getAlignment(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, CellAlignment}
+
+Get the alignment used by a single cell at reference `cr` in a worksheet or XLSXFile.
+A cell outside the sheet dimension is empty, so this returns `nothing`, as for any empty cell.
+
+Return a `CellAlignment` object containing:
+- `alignment` : a dictionary of alignment attributes: alignmentAttribute -> (attribute -> value)
+- `applyAlignment` : "1" or "0", indicating whether or not the alignment is applied to the cell.
+
+Return `nothing` if no cell alignment is found.
+
+The `alignment` element in Excel's XML schema defines the following attributes:
+- `horizontal` : Specifies the horizontal alignment of the cell.
+- `vertical` : Specifies the vertical alignment of the cell.
+- `wrapText` : Specifies whether ("1") or not ("0") the cell content wraps
+ in the cell.
+- `shrinkToFit` : Indicates whether ("1") or not ("0") the text should shrink to fit the cell.
+- `indent` : Specifies the number of spaces by which to indent the text (always from the left).
+- `textRotation` : Specifies the rotation angle of the text in a range -90 to 90 (positive values
+ rotate the text counterclockwise).
+
+
+Excel supports the following values for the horizontal alignment:
+- `left` : Aligns the text to the left of the cell.
+- `center` : Centers the text within the cell.
+- `right` : Aligns the text to the right of the cell.
+- `fill` : Repeats the text to fill the entire width of the cell.
+- `justify` : Justifies the text, spacing it out so that it spans the entire width of the cell.
+- `centerContinuous` : Centers the text across multiple cells (specifically the currrent cell and all
+ empty cells to the right) as if the text were in a merged cell.
+- `distributed` : Distributes the text evenly across the width of the cell.
+
+Excel supports the following values for the vertical alignment:
+- `top` : Aligns the text to the top of the cell.
+- `center` : Centers the text vertically within the cell.
+- `bottom` : Aligns the text to the bottom of the cell.
+- `justify` : Justifies the text vertically, spreading it out evenly within the cell.
+- `distributed` : Distributes the text evenly from top to bottom in the cell.
+
+# Examples:
+```julia
+julia> getAlignment(sh, "A1")
+
+julia> getAlignment(sh, 2, 5) # Cell E2
+
+julia> getAlignment(xf, "Sheet1!A1")
+
+```
+"""
+function getAlignment end
+getAlignment(xl::XLSXFile, sheetcell::String)::Union{Nothing,CellAlignment} = process_get_sheetcell(getAlignment, xl, sheetcell)
+getAlignment(ws::Worksheet, cellref::CellRef)::Union{Nothing,CellAlignment} = process_get_cellref(getAlignment, ws, cellref)
+getAlignment(ws::Worksheet, cr::String) = process_get_cellname(getAlignment, ws, cr)
+getAlignment(ws::Worksheet, row::Integer, col::Integer) = getAlignment(ws, CellRef(row, col))
+
+function getAlignment(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellAlignment}
+ _require_cache(wb, "get alignment")
+
+ # alignment is an optional child element of the cell xf node
+ children = filter(n -> XML.nodetype(n) == XML.Element, XML.children(cell_style))
+ alignment_node = findfirst(n -> localname(n) == "alignment", children)
+ isnothing(alignment_node) && return nothing
+
+ atts = Dict{String,String}(XML.attributes(children[alignment_node]))
+ alignment_atts = Dict{String,Union{Dict{String,String},Nothing}}("alignment" => atts)
+ applyalignment = haskey(cell_style, "applyAlignment") ? cell_style["applyAlignment"] : "0"
+
+ return CellAlignment(alignment_atts, applyalignment)
+end
+
+"""
+ setAlignment(sh::Worksheet, cr::String; kw...) -> ::Int}
+ setAlignment(xf::XLSXFile, cr::String; kw...) -> ::Int}
+
+ setAlignment(sh::Worksheet, row, col; kw...) -> ::Int}
+
+
+Set the alignment used used by a single cell, a cell range, a column range or
+row range or a named cell or named range in a worksheet or XLSXFile.
+Alternatively, specify the row and column using any combination of
+Integer, UnitRange, Vector{Integer} or `:`.
+
+The following keywords are used to define an alignment:
+- `horizontal::String = nothing` : Sets the horizontal alignment.
+- `vertical::String = nothing` : Sets the vertical alignment.
+- `wrapText::Bool = nothing` : Determines whether the cell content wraps within the cell.
+- `shrink::Bool = nothing` : Indicates whether the text should shrink to fit the cell.
+- `indent::Int = nothing` : Specifies the number of spaces by which to indent the text
+ (always from the left).
+- `rotation::Int = nothing` : Specifies the rotation angle of the text in the range -90 to 90
+ (positive values rotate the text counterclockwise),
+
+Here are the possible values for the `horizontal` alignment:
+- `left` : Aligns the text to the left of the cell.
+- `center` : Centers the text within the cell.
+- `right` : Aligns the text to the right of the cell.
+- `fill` : Repeats the text to fill the entire width of the cell.
+- `justify` : Justifies the text, spacing it out so that it spans the entire
+ width of the cell.
+- `centerContinuous` : Centers the text across multiple cells (specifically the currrent
+ cell and all empty cells to the right) as if the text were in
+ a merged cell.
+- `distributed` : Distributes the text evenly across the width of the cell.
+
+Here are the possible values for the `vertical` alignment:
+- `top` : Aligns the text to the top of the cell.
+- `center` : Centers the text vertically within the cell.
+- `bottom` : Aligns the text to the bottom of the cell.
+- `justify` : Justifies the text vertically, spreading it out evenly within the cell.
+- `distributed` : Distributes the text evenly from top to bottom in the cell.
+
+For single cells, the value returned is the `styleId` of the cell.
+
+For cell ranges, column ranges and named ranges, the value returned is -1 unless
+the range resolves to a single cell, in which case that cell's id is returned.
+
+# Examples:
+```julia
+julia> setAlignment(sh, "D18"; horizontal="center", wrapText=true)
+
+julia> setAlignment(xf, "sheet1!D18"; horizontal="right", vertical="top", wrapText=true)
+
+julia> setAlignment(sh, "L6"; horizontal="center", rotation="90", shrink=true, indent="2")
+
+julia> setAlignment(sh, 1:3, 3:6; horizontal="center", rotation="90", shrink=true, indent="2")
+
+```
+"""
+function setAlignment end
+define_percell_spec_methods(:setAlignment, :_setAlignment, :AlignmentSpec)
+
+function _setAlignment(sh::Worksheet, cellref::CellRef; spec::AlignmentSpec)::Int
+ (; horizontal, vertical, wrapText, shrink, indent, rotation) = spec
+ _require_writable(sh, "set alignment")
+
+ # Validate before any work
+ !isnothing(horizontal) && horizontal ∉ VALID_HORIZONTAL_ALIGNMENTS &&
+ throw(XLSXError("Invalid horizontal alignment: $horizontal. Must be one of: $(join(VALID_HORIZONTAL_ALIGNMENTS, ", "))."))
+ !isnothing(vertical) && vertical ∉ VALID_VERTICAL_ALIGNMENTS &&
+ throw(XLSXError("Invalid vertical alignment: $vertical. Must be one of: $(join(VALID_VERTICAL_ALIGNMENTS, ", "))."))
+ !isnothing(indent) && indent < 0 &&
+ throw(XLSXError("Invalid indent value: $indent. Must be a positive integer."))
+ !isnothing(rotation) && rotation ∉ -90:90 &&
+ throw(XLSXError("Invalid rotation value: $rotation. Must be an integer between -90 and 90."))
+
+ wb = get_workbook(sh)
+ pfx = get_prefix("xl/styles.xml", get_xlsxfile(sh))
+ pfx = pfx == "" ? pfx : pfx * ":"
+
+ cell = getcell(sh, cellref)
+ cell isa EmptyCell &&
+ throw(XLSXError("Cannot set alignment for an `EmptyCell`: $(cellname(cellref)). Set the value first."))
+
+ allXfNodes = get_cellXfs_nodes(wb)
+
+ if cell.style == UInt32(0)
+ cell.style = get_num_style_index(sh, allXfNodes, 0).id
+ end
+
+ cell_style = styles_cell_xf(allXfNodes, cell.style)
+ cell_alignment = getAlignment(wb, cell_style)
+ old_atts = isnothing(cell_alignment) ? Dict{String,String}() : cell_alignment.alignment["alignment"]
+
+ atts = Dict{String,String}()
+ _merge_alignment_att(atts, "horizontal", horizontal, old_atts)
+ _merge_alignment_att(atts, "vertical", vertical, old_atts)
+ _merge_alignment_att(atts, "wrapText", wrapText, old_atts, b -> b ? "1" : "0")
+ _merge_alignment_att(atts, "shrinkToFit", shrink, old_atts, b -> b ? "1" : "0")
+ _merge_alignment_att(atts, "indent", indent, old_atts)
+ _merge_alignment_att(atts, "textRotation", rotation, old_atts)
+
+ alignment_node = XML.Node{String}(XML.Element, "alignment", Pair{String,String}[k => v for (k,v) in atts], nothing, nothing)
+ newstyle = update_template_xf(sh, allXfNodes, CellDataFormat(cell.style), alignment_node).id
+ cell.style = newstyle
+
+ return Int(newstyle)
+end
+
+"""
+ setUniformAlignment(sh::Worksheet, cr::String; kw...) -> ::Int
+ setUniformAlignment(xf::XLSXFile, cr::String, kw...) -> ::Int
+
+ setUniformAlignment(sh::Worksheet, rows, cols; kw...) -> ::Int
+
+Set the alignment used by a cell range, a column range or row range or a
+named range in a worksheet or XLSXFile to be uniformly the same alignment.
+Alternatively, specify the rows and columns using any combination of
+Integer, UnitRange, Vector{Integer} or `:`.
+
+First, the alignment attributes of the first cell in the range (the top-left cell) are
+updated according to the given `kw...` (using `setAlignment()`). The resultant alignment
+is then applied to each remaining cell in the range.
+
+As a result, every cell in the range will have a uniform alignment setting.
+
+This differs from `setAlignment()` which merges the attributes defined by `kw...` into
+the alignment definition used by each cell individually. For example, if you set the
+`horizontal` alignment to `left` for a range of cells, but these cells all use different
+`vertical` alignment or `wrapText`, `setAlignment()` will change the horizontal alignment but
+leave the `vertical` alignment and `wrapText` unchanged for each cell individually.
+
+In contrast, `setUniformAlignment()` will set the `horizontal` alignment to `left` for
+the first cell, but will then apply all the alignment attributes from the updated first
+cell to all the other cells in the range.
+
+This can be more efficient when setting the same alignment for a large number of cells.
+
+Applying `setUniformAlignment()` without any keyword arguments simply copies the `Alignment`
+attributes from the first cell specified to all the others.
+
+The value returned is the `styleId` of the reference (top-left) cell, from which the
+alignment uniformly applied to the cells was taken.
+If all cells in the range are `EmptyCells`, the returned value is -1.
+
+For keyword definitions see [`setAlignment()`](@ref).
+
+# Examples:
+```julia
+Julia> setUniformAlignment(sh, "B2:D4"; horizontal="center", wrap = true)
+
+Julia> setUniformAlignment(xf, "Sheet1!A1:F20"; horizontal="center", vertical="top")
+
+Julia> setUniformAlignment(sh, :, 1:24; horizontal="center", vertical="top")
+
+julia> setUniformAlignment(sh, "B2,A5:D22") # Copy `Alignment` from B2 to cells in A5:D22
+
+```
+"""
+function setUniformAlignment end
+define_uniform_spec_methods(:setUniformAlignment, :_setUniformAlignment, :_setAlignment, :AlignmentSpec, nothing)
diff --git a/src/cellformats/border.jl b/src/cellformats/border.jl
new file mode 100644
index 00000000..7af1b60e
--- /dev/null
+++ b/src/cellformats/border.jl
@@ -0,0 +1,397 @@
+#
+# -- Get and set border attributes
+#
+
+"""
+ getBorder(sh::Worksheet, cr::String) -> ::Union{Nothing, CellBorder}
+ getBorder(xf::XLSXFile, cr::String) -> ::Union{Nothing, CellBorder}
+
+ getBorder(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, CellBorder}
+
+Get the borders used by a single cell at reference in a worksheet or XLSXFile.
+A cell outside the sheet dimension is empty, so this returns `nothing`, as for any empty cell.
+
+Return a `CellBorder` object containing:
+- `borderId` : a 0-based index of the border in the workbook
+- `border` : a dictionary of border attributes: borderAttribute -> (attribute -> value)
+- `applyBorder` : "1" or "0", indicating whether or not the border is applied to the cell.
+
+Return `nothing` if no cell border is found.
+
+A cell border has two attributes, `style` and `color`. A diagonal border also needs to specify
+a direction indicating whether a diagonal is needed from bottom-left to top-right ("up"),
+from top-left to bottom-left ("down") or "both"
+
+The available `style`s are:
+$(_doc_list(VALID_BORDER_STYLES))
+
+A border position element (e.g. `top` or `left`) has a `style` attribute, but `color` is a child element.
+The color element has one or two attributes (e.g. `rgb`) that define the color of the border.
+While the key for the `style` element will always be `style`, the keys for the `color` element
+will vary depending on how the color is defined (e.g. `rgb`, `indexed`, `auto`, etc.).
+Thus, for example, `"top" => Dict("style" => "thin", "rgb" => "FF000000")` would indicate a
+thin black border at the top of the cell while `"top" => Dict("style" => "thin", "auto" => "1")`
+would indicate that the color is set automatically by Excel.
+
+The `color` element can have the following attributes:
+- `auto` : Indicates that the color is automatically defined by Excel
+- `indexed` : Specifies the color using an indexed color value.
+- `rgb` : Specifies the rgb color using 8-digit hexadecimal format.
+- `theme` : Specifies the color using a theme color.
+- `tint` : Specifies the tint value to adjust the lightness or darkness of the color.
+
+`tint` can only be used in conjunction with the theme attribute to derive different shades of the theme color.
+For example: .
+
+# Examples:
+```julia
+julia> getBorder(sh, "A1")
+
+julia> getBorder(sh, 3, 6)
+
+julia> getBorder(xf, "Sheet1!A1")
+
+```
+"""
+function getBorder end
+getBorder(xl::XLSXFile, sheetcell::String)::Union{Nothing,CellBorder} = process_get_sheetcell(getBorder, xl, sheetcell)
+getBorder(ws::Worksheet, cellref::CellRef)::Union{Nothing,CellBorder} = process_get_cellref(getBorder, ws, cellref)
+getBorder(ws::Worksheet, cr::String) = process_get_cellname(getBorder, ws, cr)
+getBorder(ws::Worksheet, row::Integer, col::Integer) = getBorder(ws, CellRef(row, col))
+getDefaultBorders(ws::Worksheet) = getBorder(get_workbook(ws), styles_cell_xf(get_workbook(ws), 0))
+function getBorder(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellBorder}
+
+ _require_cache(wb, "get border")
+
+ if haskey(cell_style, "borderId")
+ borderid = cell_style["borderId"]
+ applyborder = haskey(cell_style, "applyBorder") ? cell_style["applyBorder"] : "0"
+ border_nodes = get_borders_nodes(wb)
+ current_border = border_nodes[parse(Int, borderid)+1] # Zero based!
+ diag_atts = XML.attributes(current_border)
+ border_atts = Dict{String,Union{Dict{String,String},Nothing}}()
+ for side in xml_elements(current_border)
+ if isnothing(XML.attributes(side)) || length(XML.attributes(side)) == 0
+ border_atts[localname(side)] = nothing
+ else
+ if length(XML.attributes(side)) != 1
+ throw(XLSXError("Too many border attributes found for $(localname(side)) Expected 1, found $(length(XML.attributes(side)))."))
+ end
+ for (k, v) in XML.attributes(side) # style is the only possible attribute of a side
+ border_atts[localname(side)] = Dict(k => v)
+ if localname(side) == "diagonal" && !isnothing(diag_atts)
+ if haskey(diag_atts, "diagonalUp") && haskey(diag_atts, "diagonalDown")
+ border_atts[localname(side)]["direction"] = "both"
+ elseif haskey(diag_atts, "diagonalUp")
+ border_atts[localname(side)]["direction"] = "up"
+ elseif haskey(diag_atts, "diagonalDown")
+ border_atts[localname(side)]["direction"] = "down"
+ else
+ throw(XLSXError("No direction set for `diagonal` border"))
+ end
+ end
+ for subc in xml_elements(side) # color is a child of a border element
+ for (k, v) in XML.attributes(subc)
+ border_atts[localname(side)][k] = v
+ end
+ end
+ end
+ end
+ end
+ return CellBorder(parse(Int, borderid), border_atts, applyborder)
+ end
+
+ return nothing
+end
+
+"""
+ setBorder(sh::Worksheet, cr::String; kw...) -> ::Int
+ setBorder(xf::XLSXFile, cr::String; kw...) -> ::Int
+
+ setBorder(sh::Worksheet, row, col; kw...) -> ::Int
+
+Set the borders used used by a single cell, a cell range, a column range or
+row range or a named cell or named range in a worksheet or XLSXFile.
+Alternatively, specify the row and column using any combination of
+Integer, UnitRange, Vector{Integer} or `:`.
+
+Borders are independently defined for the keywords:
+- `left::Vector{<:Pair{String}} = nothing`
+- `right::Vector{<:Pair{String}} = nothing`
+- `top::Vector{<:Pair{String}} = nothing`
+- `bottom::Vector{<:Pair{String}} = nothing`
+- `diagonal::Vector{<:Pair{String}} = nothing`
+- `[allsides::Vector{<:Pair{String}} = nothing]`
+- `[outside::Vector{<:Pair{String}} = nothing]`
+
+These represent each of the sides of a cell . The keyword `diagonal` defines
+diagonal lines running across the cell. These lines must share the same style
+and color in any cell.
+
+An additional keyword, `allsides`, is provided for convenience. It can be used
+in place of the four side keywords to apply the same border setting to all four
+sides at once. It cannot be used in conjunction with any of the side-specific
+keywords or with `outside` but it can be used together with `diagonal`.
+
+A further keyword, `outside`, can be used to set the outside border around a
+range. Any internal borders will remain unchanged. An outside border cannot be
+set for any non-contiguous/non-rectangular range, cannot be indexed with
+vectors and cannot be used in conjunction with any other keywords.
+
+The two attributes that can be set for each keyword are `style` and `color`.
+Additionally, for diagonal borders, a third keyword, `direction` can be used.
+
+Allowed values for `style` are:
+$(_doc_list(VALID_BORDER_STYLES))
+
+The `color` attribute can be $COLOR_FORMS_DOC
+
+Valid values for the `direction` keyword (for diagonal borders) are:
+- `up` : diagonal border runs bottom-left to top-right
+- `down` : diagonal border runs top-left to bottom-right
+- `both` : diagonal borders run both ways
+
+Both diagonal borders share the same style and color.
+
+Setting only one of the attributes leaves the other attributes unchanged for that
+side's border. Omitting one of the keywords leaves the border definition for that
+side unchanged, only updating the other, specified sides.
+
+Border attributes cannot be set for `EmptyCell`s. Set a cell value first.
+If a cell range or column range includes any `EmptyCell`s, they will be
+quietly skipped and the border will be set for the remaining cells.
+
+For single cells, the value returned is the `borderId` of the borders applied to the cell.
+This can be used to apply the same borders to other cells or ranges.
+
+For cell ranges, column ranges and named ranges, the value returned is -1 unless
+the range resolves to a single cell, in which case that cell's id is returned.
+
+# Examples:
+```julia
+Julia> setBorder(sh, "D6"; allsides = ["style" => "thick"], diagonal = ["style" => "hair", "direction" => "up"])
+
+Julia> setBorder(sh, 2:45, 2:12; outside = ["style" => "thick", "color" => "lightskyblue2"])
+
+Julia> setBorder(xf, "Sheet1!D4"; left = ["style" => "dotted", "color" => "FF000FF0"],
+ right = ["style" => "medium", "color" => "firebrick2"],
+ top = ["style" => "thick", "color" => "FF230000"],
+ bottom = ["style" => "medium", "color" => "goldenrod3"],
+ diagonal = ["style" => "dotted", "color" => "FF00D4D4", "direction" => "both"]
+ )
+
+```
+"""
+function setBorder end
+define_percell_spec_methods(:setBorder, :_setBorder, :BorderSpec; ranges=false)
+
+# The range forms take `outside`, which is set around the range as a whole.
+function _setBorder(ws::Worksheet, rng::CellRange; spec::BorderSpec)::Int
+ _check_outside_conflict(spec)
+ isnothing(spec.outside) || return setOutsideBorder(ws, rng; spec.outside)
+ return process_cellranges(_setBorder, ws, rng; spec)
+end
+
+function _setBorder(ws::Worksheet, rng::ColumnRange; spec::BorderSpec)::Int
+ _check_outside_conflict(spec)
+ isnothing(spec.outside) || return process_columnranges(setOutsideBorder, ws, rng; spec.outside)
+ return process_columnranges(_setBorder, ws, rng; spec)
+end
+
+function _setBorder(ws::Worksheet, rng::RowRange; spec::BorderSpec)::Int
+ _check_outside_conflict(spec)
+ isnothing(spec.outside) || return process_rowranges(setOutsideBorder, ws, rng; spec.outside)
+ return process_rowranges(_setBorder, ws, rng; spec)
+end
+
+function _setBorder(xl::XLSXFile, sheetcell::String; spec::BorderSpec)::Int
+ _check_outside_conflict(spec)
+ if isnothing(spec.outside)
+ return process_sheetcell(_setBorder, xl, sheetcell; spec)
+ else
+ return process_sheetcell(setOutsideBorder, xl, sheetcell; spec.outside)
+ end
+end
+
+function _setBorder(sh::Worksheet, cellref::CellRef; spec::BorderSpec)::Int
+ (; outside, allsides, left, right, top, bottom, diagonal) = spec
+ _require_writable(sh, "set borders")
+
+ isnothing(outside) ||
+ throw(XLSXError("Keyword `outside` sets the border around a range, so cannot be used for the single cell $(cellname(cellref)) or with `setUniformBorder`."))
+
+ # allsides expands to all four sides, then recurses — early return
+ if !isnothing(allsides)
+ !all(isnothing, [left, right, top, bottom]) &&
+ throw(XLSXError("Keyword `allsides` is incompatible with `left`, `right`, `top`, or `bottom`."))
+ return _setBorder(sh, cellref; spec=BorderSpec(nothing, nothing, allsides, allsides, allsides, allsides, diagonal))
+ end
+
+ wb = get_workbook(sh)
+ pfx = get_prefix("xl/styles.xml", get_xlsxfile(sh))
+ pfx = pfx == "" ? pfx : pfx * ":"
+
+ cell = getcell(sh, cellref)
+ cell isa EmptyCell &&
+ throw(XLSXError("Cannot set border for an `EmptyCell`: $(cellname(cellref)). Set the value first."))
+
+ allXfNodes = get_cellXfs_nodes(wb)
+
+ if cell.style == UInt32(0)
+ cell.style = get_num_style_index(sh, allXfNodes, 0).id
+ end
+
+ cell_style = styles_cell_xf(allXfNodes, cell.style)
+ cell_borders = getBorder(wb, cell_style)
+ old_border_atts = cell_borders.border
+
+ # Convert kwargs to dicts, then merge with old attributes per side
+ side_kwargs = Dict(
+ s => _to_border_dict(v) for (s, v) in (
+ ("left", left), ("right", right), ("top", top),
+ ("bottom", bottom), ("diagonal", diagonal)
+ )
+ )
+
+ new_border_atts = Dict{String,Union{Dict{String,String},Nothing}}(
+ side => _merge_border_side(side, side_kwargs[side], old_border_atts)
+ for side in ("left", "right", "top", "bottom", "diagonal")
+ )
+
+ border_node = buildNode("border", new_border_atts, pfx)
+ new_borderid = styles_add_cell_attribute(wb, border_node, "borders")
+ newstyle = update_template_xf(sh, allXfNodes, CellDataFormat(cell.style), ["borderId", "applyBorder"], [string(new_borderid), "1"]).id
+ cell.style = newstyle
+
+ return new_borderid
+end
+
+"""
+ setUniformBorder(sh::Worksheet, cr::String; kw...) -> ::Int
+ setUniformBorder(xf::XLSXFile, cr::String, kw...) -> ::Int
+
+ setUniformBorder(sh::Worksheet, rows, cols; kw...) -> ::Int
+
+Set the border used by a cell range, a column range or row range or
+a named range in a worksheet or XLSXFile to be uniformly the same border.
+Alternatively, specify the rows and columns using any combination of
+Integer, UnitRange, Vector{Integer} or `:`.
+
+First, the border attributes of the first cell in the range (the top-left cell) are
+updated according to the given `kw...` (using `setBorder()`). The resultant border is
+then applied to each remaining cell in the range.
+
+As a result, every cell in the range will have a uniform border setting.
+
+This differs from `setBorder()` which merges the attributes defined by `kw...` into
+the border definition used by each cell individually. For example, if you set the
+border style to `thin` for a range of cells, but these cells all use different border
+colors, `setBorder()` will change the border style but leave the border color unchanged
+for each cell individually.
+
+In contrast, `setUniformBorder()` will set the border `style` to `thin` for the first cell,
+but will then apply all the border attributes from the updated first cell (ie. both `style`
+and `color`) to all the other cells in the range.
+
+This can be more efficient when setting the same border for a large number of cells.
+
+Applying `setUniformBorder()` without any keyword arguments simply copies the `Border`
+attributes from the first cell specified to all the others.
+
+The value returned is the `borderId` of the border uniformly applied to the cells.
+If all cells in the range are `EmptyCells` the returned value is -1.
+
+For keyword definitions see [`setBorder()`](@ref).
+
+Note: `setUniformBorder` cannot be used with the `outside` keyword.
+
+# Examples:
+```julia
+Julia> setUniformBorder(sh, "B2:D6"; allsides = ["style" => "thick"], diagonal = ["style" => "hair"])
+
+Julia> setUniformBorder(sh, [1, 2, 3], [3, 5, 9]; allsides = ["style" => "thick"], diagonal = ["style" => "hair", "color" => "yellow2"])
+
+Julia> setUniformBorder(xf, "Sheet1!A1:F20"; left = ["style" => "dotted", "color" => "FF000FF0"],
+ right = ["style" => "medium", "color" => "FF765000"],
+ top = ["style" => "thick", "color" => "FF230000"],
+ bottom = ["style" => "medium", "color" => "FF0000FF"],
+ diagonal = ["style" => "none"]
+ )
+
+julia> setUniformBorder(sh, "B2,A5:D22") # Copy `Border` from B2 to cells in A5:D22
+
+
+```
+"""
+function setUniformBorder end
+define_uniform_spec_methods(:setUniformBorder, :_setUniformBorder, :_setBorder, :BorderSpec, ["borderId", "applyBorder"])
+
+"""
+ setOutsideBorder(sh::Worksheet, cr::String; kw...) -> ::Int
+ setOutsideBorder(xf::XLSXFile, cr::String, kw...) -> ::Int
+
+ setOutsideBorder(sh::Worksheet, rows, cols; kw...) -> ::Int
+
+Set the border around the outside of a cell range, a column range or row range
+or a named range in a worksheet or XLSXFile.
+Alternatively, specify the rows and columns using integers, UnitRanges or `:`.
+
+There is one key word:
+- `outside::Union{Nothing,Vector{<:Pair{String}}}=nothing`
+
+For keyword definition see [`setBorder()`](@ref).
+
+Only the border definitions for the sides of boundary cells that are on the
+ouside edge of the range will be set to the specified style and color. The
+borders of internal edges and any diagonals will remain unchanged. Border
+settings for all internal cells in the range will remain unchanged.
+
+Top and bottom borders for column ranges and left and right borders for
+row ranges are taken from the worksheet `dimension`.
+
+An outside border cannot be set for a non-contiguous range.
+
+The value returned is -1. Since outside borders are applied across multiple edge cells, each of which
+may have different pre-existing border attributes and therefore different `borderId` values,
+there is no single representative `borderId` for the operation as a whole.
+
+# Examples:
+```julia
+Julia> setOutsideBorder(sh, "B2:D6"; outside = ["style" => "thick"])
+
+Julia> setOutsideBorder(xf, "Sheet1!A1:F20"; outside = ["style" => "dotted", "color" => "FF000FF0"])
+```
+
+"""
+function setOutsideBorder end
+setOutsideBorder(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setOutsideBorder(ws, rng.rng; kw...)
+setOutsideBorder(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setOutsideBorder(ws, rng.colrng; kw...)
+setOutsideBorder(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setOutsideBorder(ws, rng.rowrng; kw...)
+setOutsideBorder(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setOutsideBorder, ws, colrng; kw...)
+setOutsideBorder(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setOutsideBorder, ws, rowrng; kw...)
+setOutsideBorder(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setOutsideBorder, xl, sheetcell; kw...)
+setOutsideBorder(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setOutsideBorder, ws, ref_or_rng; kw...)
+setOutsideBorder(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setOutsideBorder, ws, row, nothing; kw...)
+setOutsideBorder(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setOutsideBorder, ws, nothing, col; kw...)
+setOutsideBorder(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setOutsideBorder, ws, nothing, nothing; kw...)
+setOutsideBorder(ws::Worksheet, ::Colon; kw...) = process_colon(setOutsideBorder, ws, nothing, nothing; kw...)
+setOutsideBorder(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setOutsideBorder(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
+
+function setOutsideBorder(ws::Worksheet, rng::CellRange;
+ outside::BorderKw=nothing
+)::Int
+ _require_writable(ws, "set borders")
+
+ topLeft = rng.start
+ topRight = CellRef(rng.start.row_number, rng.stop.column_number)
+ bottomLeft = CellRef(rng.stop.row_number, rng.start.column_number)
+ bottomRight = rng.stop
+
+ _setBorder(ws, CellRange(topLeft, topRight); spec=BorderSpec(:setOutsideBorder; top=outside))
+ _setBorder(ws, CellRange(topLeft, bottomLeft); spec=BorderSpec(:setOutsideBorder; left=outside))
+ _setBorder(ws, CellRange(topRight, bottomRight); spec=BorderSpec(:setOutsideBorder; right=outside))
+ _setBorder(ws, CellRange(bottomLeft, bottomRight); spec=BorderSpec(:setOutsideBorder; bottom=outside))
+
+ return -1
+
+end
diff --git a/src/cellformats/fill.jl b/src/cellformats/fill.jl
new file mode 100644
index 00000000..3b19974f
--- /dev/null
+++ b/src/cellformats/fill.jl
@@ -0,0 +1,247 @@
+#
+# -- Get and set fill attributes
+#
+
+"""
+ getFill(sh::Worksheet, cr::String) -> ::Union{Nothing, CellFill}
+ getFill(xf::XLSXFile, cr::String) -> ::Union{Nothing, CellFill}
+
+ getFill(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, CellFill}
+
+Get the fill used by a single cell at reference `cr` in a worksheet or XLSXFile.
+A cell outside the sheet dimension is empty, so this returns `nothing`, as for any empty cell.
+
+Return a `CellFill` object containing:
+- `fillId` : a 0-based index of the fill in the workbook
+- `fill` : a dictionary of fill attributes: borderAttribute -> (attribute -> value)
+- `applyFill` : "1" or "0", indicating whether or not the fill is applied to the cell.
+
+Return `nothing` if no cell fill is found.
+
+The `fill` element in Excel's XML schema defines the pattern and color
+properties for cell fills. The primary attributes and child elements
+in the `patternFill` element are:
+- `patternType` : Specifies the type of fill pattern (see below).
+- `fgColor` : Specifies the foreground color of the pattern.
+- `bgColor` : Specifies the background color of the pattern.
+
+The child elements `fgColor` and `bgColor` can each have one or two attributes
+of their own. These color attributes are pushed in to the `CellFill.fill` Dict
+of attributes with either `fg` or `bg` prepended to their names to support later
+reconstruction of the xml element.
+
+Thus:
+`"patternFill" => Dict("patternType" => "solid", "bgindexed" => "64", "fgtheme" => "0")`
+indicates a solid fill with a foreground color defined by theme 0 (in Excel) and
+background color defined by an indexed value. In this case (solid fill), the
+background color will be ignored.
+
+The `patternType`s Excel uses are:
+$(_doc_list(VALID_FILL_PATTERNS))
+
+Definitions for neither `fgColor` (foreground color) nor `bgColor` (background color)
+are strictly necessary although certain pattern types are more visually meaningful
+when both are defined. These pattern types include those that create a pattern or grid
+effect, where the contrast between the foreground and background colors enhances the
+visual presentation.
+
+These pattern types include `darkTrellis`, `darkGrid`, `darkHorizontal`, `darkVertical`,
+`darkDown`, `darkUp`, `mediumGray`, `lightGray`, `lightTrellis`, `lightGrid`, `lightHorizontal`,
+`lightVertical`, `lightDown`, `lightUp`, `gray125` and `gray0625`.
+
+If `fgColor` (foreground color) and `bgColor` (background color) are specified when they aren't
+needed, they will simply be ignored by Excel, and the default appearance will be applied.
+
+# Examples:
+```julia
+julia> getFill(sh, "A1")
+
+julia> getFill(sh, 3, 4)
+
+julia> getFill(xf, "Sheet1!A1")
+
+```
+"""
+function getFill end
+getFill(xl::XLSXFile, sheetcell::String)::Union{Nothing,CellFill} = process_get_sheetcell(getFill, xl, sheetcell)
+getFill(ws::Worksheet, cellref::CellRef)::Union{Nothing,CellFill} = process_get_cellref(getFill, ws, cellref)
+getFill(ws::Worksheet, cr::String) = process_get_cellname(getFill, ws, cr)
+getFill(ws::Worksheet, row::Integer, col::Integer) = getFill(ws, CellRef(row, col))
+getDefaultFill(ws::Worksheet) = getFill(get_workbook(ws), styles_cell_xf(get_workbook(ws), 0))
+
+function getFill(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellFill}
+ _require_cache(wb, "get fill")
+
+ haskey(cell_style, "fillId") || return nothing
+
+ fillid_int = parse(Int, cell_style["fillId"])
+ applyfill = haskey(cell_style, "applyFill") ? cell_style["applyFill"] : "0"
+ fill_nodes = get_fills_nodes(wb)
+ current_fill = fill_nodes[fillid_int + 1] # fillId is zero-based
+ fill_atts = Dict{String,Union{Dict{String,String},Nothing}}()
+
+ for pattern in xml_elements(current_fill)
+ tag = localname(pattern)
+ a = XML.attributes(pattern)
+ if isnothing(a) || isempty(a)
+ fill_atts[tag] = nothing
+ else
+ fill_atts[tag] = _parse_pattern_fill(pattern)
+ end
+ end
+
+ return CellFill(fillid_int, fill_atts, applyfill)
+end
+
+"""
+ setFill(sh::Worksheet, cr::String; kw...) -> ::Int
+ setFill(xf::XLSXFile, cr::String; kw...) -> ::Int
+
+ setFill(sh::Worksheet, row, col; kw...) -> ::Int
+
+Set the fill used used by a single cell, a cell range, a column range or
+row range or a named cell or named range in a worksheet or XLSXFile.
+Alternatively, specify the row and column using any combination of
+Integer, UnitRange, Vector{Integer} or `:`.
+
+The following keywords are used to define a fill:
+- `pattern::String = nothing` : Sets the patternType for the fill.
+- `fgColor = nothing` : Sets the foreground color for the fill.
+- `bgColor = nothing` : Sets the background color for the fill.
+
+The available `pattern` values are:
+$(_doc_list(VALID_FILL_PATTERNS))
+
+The two colors may each be $COLOR_FORMS_DOC
+
+Setting only one or two of the attributes leaves the other attribute(s) unchanged
+for that cell's fill.
+
+Fill attributes cannot be set for `EmptyCell`s. Set a cell value first.
+If a cell range or column range includes any `EmptyCell`s, they will be
+quietly skipped and the fill will be set for the remaining cells.
+
+For single cells, the value returned is the `fillId` of the fill applied to the cell.
+This can be used to apply the same fill to other cells or ranges.
+
+For cell ranges, column ranges and named ranges, the value returned is -1 unless
+the range resolves to a single cell, in which case that cell's id is returned.
+
+# Examples:
+```julia
+Julia> setFill(sh, "B2"; pattern="gray125", bgColor = "FF000000")
+
+Julia> setFill(xf, "Sheet1!A1:F20"; pattern="none", fgColor = "darkseagreen3")
+
+Julia> setFill(sh, "11:24"; pattern="none", fgColor = "yellow2")
+
+```
+"""
+function setFill end
+define_percell_spec_methods(:setFill, :_setFill, :FillSpec)
+
+function _setFill(sh::Worksheet, cellref::CellRef; spec::FillSpec)::Int
+ (; pattern, fgColor, bgColor) = spec
+ _require_writable(sh, "set fill")
+
+ # Validate before any work
+ !isnothing(pattern) && pattern ∉ VALID_FILL_PATTERNS &&
+ throw(XLSXError("Invalid pattern: $pattern. Must be one of: $(join(VALID_FILL_PATTERNS, ", "))."))
+
+ wb = get_workbook(sh)
+ pfx = get_prefix("xl/styles.xml", get_xlsxfile(sh))
+ pfx = pfx == "" ? pfx : pfx * ":"
+
+ cell = getcell(sh, cellref)
+ cell isa EmptyCell && throw(XLSXError("Cannot set fill for an `EmptyCell`: $(cellname(cellref)). Set the value first."))
+
+ allXfNodes = get_cellXfs_nodes(wb)
+
+ if cell.style == UInt32(0)
+ cell.style = get_num_style_index(sh, allXfNodes, 0).id
+ end
+
+ cell_style = styles_cell_xf(allXfNodes, cell.style)
+ cell_fill = getFill(wb, cell_style)
+ old_fill_atts = cell_fill.fill["patternFill"]
+
+ patternFill = Dict{String,String}()
+
+ # patternType: preserve old or apply new
+ result = _merge_val_tag("patternType", pattern, old_fill_atts)
+ if !ismissing(result)
+ patternFill["patternType"] = result isa Dict ? result["val"] : result
+ end
+
+ # fg/bg colors: prefix-match to preserve all old fg*/bg* keys, or set the new color
+ for (prefix, new_color) in (("fg", fgColor), ("bg", bgColor))
+ if isnothing(new_color)
+ for (k, v) in old_fill_atts
+ startswith(k, prefix) && (patternFill[k] = v)
+ end
+ else
+ for (k, v) in _color_atts(new_color)
+ patternFill[prefix * k] = v
+ end
+ end
+ end
+
+ new_fill_atts = Dict{String,Union{Dict{String,String},Nothing}}("patternFill" => patternFill)
+ fill_node = buildNode("fill", new_fill_atts, pfx)
+ new_fillid = styles_add_cell_attribute(wb, fill_node, "fills")
+
+ newstyle = update_template_xf(sh, allXfNodes, CellDataFormat(cell.style), ["fillId", "applyFill"], [string(new_fillid), "1"]).id
+ cell.style = newstyle
+
+ return new_fillid
+end
+
+"""
+ setUniformFill(sh::Worksheet, cr::String; kw...) -> ::Int
+ setUniformFill(xf::XLSXFile, cr::String, kw...) -> ::Int
+
+ setUniformFill(sh::Worksheet, rows, cols; kw...) -> ::Int
+
+Set the fill used by a cell range, a column range or row range or a
+named range in a worksheet or XLSXFile to be uniformly the same fill.
+Alternatively, specify the rows and columns using any combination of
+Integer, UnitRange, Vector{Integer} or `:`.
+
+First, the fill attributes of the first cell in the range (the top-left cell) are
+updated according to the given `kw...` (using `setFill()`). The resultant fill is
+then applied to each remaining cell in the range.
+
+As a result, every cell in the range will have a uniform fill setting.
+
+This differs from `setFill()` which merges the attributes defined by `kw...` into
+the fill definition used by each cell individually. For example, if you set the
+fill `patern` to `darkGrid` for a range of cells, but these cells all use different fill
+`color`s, `setFill()` will change the fill `pattern` but leave the fill `color` unchanged
+for each cell individually.
+
+In contrast, `setUniformFill()` will set the fill `pattern` to `darkGrid` for the first cell,
+but will then apply all the fill attributes from the updated first cell (ie. `pattern`
+and both foreground and background colors) to all the other cells in the range.
+
+This can be more efficient when setting the same fill for a large number of cells.
+
+Applying `setUniformFill()` without any keyword arguments simply copies the `Fill`
+attributes from the first cell specified to all the others.
+
+The value returned is the `fillId` of the fill uniformly applied to the cells.
+If all cells in the range are `EmptyCells` the returned value is -1.
+
+For keyword definitions see [`setFill()`](@ref).
+
+# Examples:
+```julia
+Julia> setUniformFill(sh, "B2:D4"; pattern="gray125", bgColor = "FF000000")
+
+Julia> setUniformFill(xf, "Sheet1!A1:F20"; pattern="none", fgColor = "darkseagreen3")
+
+julia> setUniformFill(sh, "B2,A5:D22") # Copy `Fill` from B2 to cells in A5:D22
+
+```
+"""
+function setUniformFill end
+define_uniform_spec_methods(:setUniformFill, :_setUniformFill, :_setFill, :FillSpec, ["fillId", "applyFill"])
diff --git a/src/cellformats/font.jl b/src/cellformats/font.jl
new file mode 100644
index 00000000..7d975264
--- /dev/null
+++ b/src/cellformats/font.jl
@@ -0,0 +1,292 @@
+# ==========================================================================================
+#
+# -- Get and set font attributes
+#
+
+"""
+ setFont(sh::Worksheet, cr::String; kw...) -> ::Int
+ setFont(xf::XLSXFile, cr::String, kw...) -> ::Int
+
+ setFont(sh::Worksheet, row, col; kw...) -> ::Int
+
+Set the font used by a single cell, a cell range, a column range or
+row range or a named cell or named range in a worksheet or XLSXFile.
+Alternatively, specify the row and column using any combination of
+Integer, UnitRange, Vector{Integer} or `:`.
+
+Font attributes are specified using keyword arguments:
+- `bold::Bool = nothing` : set to `true` to make the font bold.
+- `italic::Bool = nothing` : set to `true` to make the font italic.
+- `under::String = nothing` : set to `single`, `double` or `none`.
+- `strike::Bool = nothing` : set to `true` to strike through the font.
+- `size::Real = nothing` : set the font size (1 to 409, e.g. `11` or `10.5`).
+- `color = nothing` : set the font color.
+- `name::String = nothing` : set the font name.
+- `vertAlign::String = nothing` : set to `superscript`, `subscript` or `baseline`.
+
+Only the attributes specified will be changed. If an attribute is not specified, the current
+value will be retained. These are the only attributes supported currently.
+
+No validation of the font names specified is performed. Available fonts will depend
+on what your system has installed. If you specify, for example, `name = "badFont"`,
+that value will be written to the XLSXFile.
+
+As an expedient to get fonts to work, the `scheme` attribute is simply dropped from
+new font definitions.
+
+The `color` can be $COLOR_FORMS_DOC
+
+Font attributes cannot be set for `EmptyCell`s. Set a cell value first.
+If a cell range or column range includes any `EmptyCell`s, they will be
+quietly skipped and the font will be set for the remaining cells.
+
+For single cells, the value returned is the `fontId` of the font applied to the cell.
+This can be used to apply the same font to other cells or ranges.
+
+For cell ranges, column ranges and named ranges, the value returned is -1 unless
+the range resolves to a single cell, in which case that cell's id is returned.
+
+# Examples:
+```julia
+julia> setFont(sh, "A1"; bold=true, italic=true, size=12, name="Arial") # Single cell
+
+julia> setFont(xf, "Sheet1!A1"; bold=false, size=14, color="yellow") # Single cell
+
+julia> setFont(sh, "A1:B7"; name="Aptos", under="double", strike=true) # Cell range
+
+julia> setFont(xf, "Sheet1!A1:B7"; size=24, name="Berlin Sans FB Demi") # Cell range
+
+julia> setFont(sh, "A:B"; italic=true, color="green", under="single") # Column range
+
+julia> setFont(xf, "Sheet1!A:B"; italic=true, color="red", under="single") # Column range
+
+julia> setFont(xf, "Sheet1!6:12"; italic=false, color="FF8888FF", under="none") # Row range
+
+julia> setFont(sh, "bigred"; size=48, color="FF00FF00") # Named cell or range
+
+julia> setFont(xf, "bigred"; size=48, color="magenta") # Named cell or range
+
+julia> setFont(sh, 1, 2; size=48, color="magenta") # row and column as integers
+
+julia> setFont(sh, 1:3, 2; size=48, color="magenta") # row as unit range
+
+julia> setFont(sh, 6, [2, 3, 8, 12]; size=48, color="magenta") # column as vector of indices
+
+julia> setFont(sh, :, 2:6; size=48, color="lightskyblue2") # all rows, columns 2 to 6
+
+```
+"""
+function setFont end
+define_percell_spec_methods(:setFont, :_setFont, :FontSpec)
+
+function _setFont(sh::Worksheet, cellref::CellRef; spec::FontSpec)::Int
+ (; bold, italic, under, strike, size, color, name, vertAlign) = spec
+ _require_writable(sh, "set font")
+
+ # Validate before any work
+ !isnothing(under) && under ∉ ("none", "single", "double") &&
+ throw(XLSXError("Invalid value for under: $under. Must be one of: `none`, `single`, `double`."))
+ !isnothing(size) && (size < 1 || size > 409) &&
+ throw(XLSXError("Invalid size value: $size. Must be between 1 and 409."))
+
+ pfx = get_prefix("xl/styles.xml", get_xlsxfile(sh))
+ pfx = pfx == "" ? "" : pfx * ":"
+
+ wb = get_workbook(sh)
+ cell = getcell(sh, cellref)
+ cell isa EmptyCell && throw(XLSXError("Cannot set font for an `EmptyCell`: $(cellname(cellref)). Set the value first."))
+
+ allXfNodes = get_cellXfs_nodes(wb)
+
+ if cell.style == UInt32(0)
+ cell.style = get_num_style_index(sh, allXfNodes, 0).id
+ end
+
+ cell_style = styles_cell_xf(allXfNodes, cell.style)
+ cell_font = getFont(wb, cell_style)
+ old_font_atts = cell_font.font
+
+ new_font_atts = Dict{String,Union{Dict{String,String},Nothing}}()
+
+ # Boolean/flag tags
+ for (tag, val) in (("b", bold), ("i", italic), ("strike", strike))
+ result = _merge_flag_tag(tag, val, old_font_atts)
+ ismissing(result) || (new_font_atts[tag] = result)
+ end
+
+ # Single-value tags
+ for (tag, val) in (("sz", size), ("name", name), ("vertAlign", vertAlign))
+ result = _merge_val_tag(tag, val, old_font_atts)
+ ismissing(result) || (new_font_atts[tag] = result)
+ end
+
+ # Color tag
+ result = _merge_dict_tag("color", color, old_font_atts, c -> Dict(_color_atts(c)))
+ ismissing(result) || (new_font_atts["color"] = result)
+
+ # Underline: "none" explicitly removes, "single" => no attributes, "double" => val attribute
+ # This doesn't fit the standard merge helpers due to the three-way new_val distinction.
+ if isnothing(under)
+ haskey(old_font_atts, "u") && (new_font_atts["u"] = old_font_atts["u"])
+ elseif under == "single"
+ new_font_atts["u"] = nothing
+ elseif under == "double"
+ new_font_atts["u"] = Dict("val" => "double")
+ end
+ # under == "none": omit "u" entirely — already absent from new_font_atts
+
+ # Drop "scheme" tag — Excel adds it but we don't want to propagate it
+ font_node = buildNode("font", new_font_atts, pfx)
+ new_fontid = styles_add_cell_attribute(wb, font_node, "fonts")
+
+ newstyle = update_template_xf(sh, allXfNodes, CellDataFormat(cell.style), ["fontId", "applyFont"], [string(new_fontid), "1"]).id
+ cell.style = newstyle
+
+ if cell.datatype == CT_STRING
+ v = update_sharedString_font(sh, cell, spec)
+ cell.value = isnothing(v) ? cell.value : reinterpret(UInt64, Int64(v))
+ end
+
+ return new_fontid
+end
+
+"""
+ setUniformFont(sh::Worksheet, cr::String; kw...) -> ::Int
+ setUniformFont(xf::XLSXFile, cr::String, kw...) -> ::Int
+
+ setUniformFont(sh::Worksheet, rows, cols; kw...) -> ::Int
+
+Set the font used by a cell range, a column range or row range or
+a named range in a worksheet or XLSXFile to be uniformly the same font.
+Alternatively, specify the rows and columns using any combination of
+Integer, UnitRange, Vector{Integer} or `:`.
+
+First, the font attributes of the first cell in the range (the top-left cell) are
+updated according to the given `kw...` (using `setFont()`). The resultant font is
+then applied to each remaining cell in the range.
+
+As a result, every cell in the range will have a uniform font setting.
+
+This differs from `setFont()` which merges the attributes defined by `kw...` into
+the font definition used by each cell individually. For example, if you set the
+font size to 12 for a range of cells, but these cells all use different fonts names
+or colors, etc, `setFont()` will change the font size but leave the font name and
+color unchanged for each cell individually.
+
+In contrast, `setUniformFont()` will set the font size to 12 for the first cell, but
+will then apply all the font attributes from the updated first cell (ie. name, color,
+etc) to all the other cells in the range.
+
+This can be more efficient when setting the same font for a large number of cells.
+
+Applying `setUniformFont()` without any keyword arguments simply copies the `Font`
+attributes from the first cell specified to all the others.
+
+The value returned is the `fontId` of the font uniformly applied to the cells.
+If all cells in the range are `EmptyCells` the returned value is -1.
+
+For keyword definitions see [`setFont()`](@ref).
+
+# Examples:
+```julia
+julia> setUniformFont(sh, "A1:B7"; bold=true, italic=true, size=12, name="Arial") # Cell range
+
+julia> setUniformFont(xf, "Sheet1!A1:B7"; size=24, name="Berlin Sans FB Demi") # Cell range
+
+julia> setUniformFont(sh, "A:B"; italic=true, color="FF8888FF", under="single") # Column range
+
+julia> setUniformFont(xf, "Sheet1!A:B"; italic=true, color="FF8888FF", under="single") # Column range
+
+julia> setUniformFont(sh, "33"; italic=true, color="FF8888FF", under="single") # Row
+
+julia> setUniformFont(sh, "bigred"; size=48, color="FF00FF00") # Named range
+
+julia> setUniformFont(sh, 1, [2, 4, 6]; size=48, color="lightskyblue2") # vector of column indices
+
+julia> setUniformFont(sh, "B2,A5:D22") # Copy `Font` from B2 to cells in A5:D22
+
+```
+"""
+function setUniformFont end
+define_uniform_spec_methods(:setUniformFont, :_setUniformFont, :_setFont, :FontSpec, ["fontId", "applyFont"])
+
+
+"""
+ getFont(sh::Worksheet, cr::String) -> ::Union{Nothing, CellFont}
+ getFont(xf::XLSXFile, cr::String) -> ::Union{Nothing, CellFont}
+
+ getFont(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, CellFont}
+
+Get the font used by a single cell reference in a worksheet `sh` or XLSXFile `xf`.
+A cell outside the sheet dimension is empty, so this returns `nothing`, as for any empty cell.
+
+Return a `CellFont` object containing:
+- `fontId` : a 0-based index of the font in the workbook
+- `font` : a dictionary of font attributes: fontAttribute -> (attribute -> value)
+- `applyFont` : "1" or "0", indicating whether or not the font is applied to the cell.
+
+Return `nothing` if no cell font is found.
+
+Excel uses several tags to define font properties in its XML structure, and `font`
+can hold any of them. `setFont` sets those marked *; the others come from files
+written by Excel or other tools.
+- `b` : bold *
+- `i` : italic *
+- `strike` : strikethrough *
+- `u` : underline, e.g. single or double *
+- `sz` : font size *
+- `color` : font color *
+- `name` : font name *
+- `vertAlign` : superscript or subscript *
+- `condense` : condensed font spacing
+- `extend` : extended font spacing
+- `outline` : outline text
+- `shadow` : shadowed text
+- `family` : font family
+- `charset` : character set code (e.g. 204 for Cyrillic)
+- `scheme` : whether the font is the theme's major or minor font
+
+Excel defines colors in several ways. `getFont` will return the color in any of these
+e.g. `"color" => ("theme" => "1")`.
+
+# Examples:
+```julia
+julia> getFont(sh, "A1")
+
+julia> getFont(xf, "Sheet1!A1")
+
+julia> getFont(sh, "Sheet1!A1")
+
+julia> getFont(sh, 1, 1)
+
+```
+"""
+function getFont end
+getFont(ws::Worksheet, cr::String) = process_get_cellname(getFont, ws, cr)
+getFont(xl::XLSXFile, sheetcell::String)::Union{Nothing,CellFont} = process_get_sheetcell(getFont, xl, sheetcell)
+getFont(ws::Worksheet, cellref::CellRef)::Union{Nothing,CellFont} = process_get_cellref(getFont, ws, cellref)
+getFont(ws::Worksheet, row::Integer, col::Integer) = getFont(ws, CellRef(row, col))
+getDefaultFont(ws::Worksheet) = getFont(get_workbook(ws), styles_cell_xf(get_workbook(ws), 0))
+function getFont(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellFont}
+
+ _require_cache(wb, "get font")
+
+ if haskey(cell_style, "fontId")
+ fontid = cell_style["fontId"]
+ applyfont = haskey(cell_style, "applyFont") ? cell_style["applyFont"] : "0"
+ font_nodes = get_fonts_nodes(wb)
+ fontid_int = parse(Int, fontid)
+ current_font = font_nodes[fontid_int + 1]
+ font_atts = Dict{String,Union{Dict{String,String},Nothing}}()
+ for c in xml_elements(current_font)
+ if isnothing(XML.attributes(c)) || length(XML.attributes(c)) == 0
+ font_atts[localname(c)] = nothing
+ else
+ font_atts[localname(c)] = Dict(XML.attributes(c))
+ end
+ end
+ return CellFont(fontid_int, font_atts, applyfont)
+ end
+
+ return nothing
+end
diff --git a/src/cellformats/mergedcells.jl b/src/cellformats/mergedcells.jl
new file mode 100644
index 00000000..fe55ada8
--- /dev/null
+++ b/src/cellformats/mergedcells.jl
@@ -0,0 +1,410 @@
+#
+# -- Get merged cells
+#
+
+"""
+ getMergedCells(ws::Worksheet) -> Union{Vector{CellRange}, Nothing}
+
+Return a vector of the `CellRange` of all merged cells in the specified worksheet.
+Return nothing if the worksheet contains no merged cells.
+
+The Excel file must be opened in write mode to work with merged cells.
+
+# Examples:
+```julia
+julia> f = XLSX.readxlsx("test.xlsx")
+XLSXFile("C:\\Users\\...\\test.xlsx") containing 1 Worksheet
+ sheetname size range
+-------------------------------------------------
+ Sheet1 2x2 A1:B2
+
+julia> s = f["Sheet1"]
+2×2 XLSX.Worksheet: ["Sheet1"](A1:B2)
+
+julia> XLSX.getMergedCells(s)
+1-element Vector{XLSX.CellRange}:
+ B1:B2
+
+```
+"""
+function getMergedCells(ws::Worksheet)::Union{Vector{CellRange},Nothing}
+ # May be better if merged cells were part of ws.cache?
+
+ _require_writable(ws, "get merged cells")
+
+ _require_cache(ws, "get merged cells")
+
+ sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id)
+ i, j = get_idces(sheetdoc, "worksheet", "mergeCells")
+
+ if isnothing(j) # There are no existing merged cells.
+ return nothing
+ end
+
+ c = xml_elements(sheetdoc[i][j])
+ if length(c) != parse(Int, sheetdoc[i][j]["count"])
+ throw(XLSXError("Unexpected number of mergeCells found: $(length(c)). Expected $(sheetdoc[i][j]["count"])."))
+ end
+
+ mergedCells = Vector{CellRange}()
+ for cell in c
+ !haskey(cell, "ref") && throw(XLSXError("No `ref` attribute found in `mergeCell` element."))
+ push!(mergedCells, CellRange(cell["ref"]))
+ end
+
+ return mergedCells
+end
+
+"""
+ isMergedCell(ws::Worksheet, cr::String) -> Bool
+ isMergedCell(xf::XLSXFile, cr::String) -> Bool
+
+ isMergedCell(ws::Worksheet, row::Int, col::Int) -> Bool
+
+Return `true` if a cell is part of a merged cell range and `false` if not.
+
+Alternatively, if you have already obtained the merged cells for the worksheet,
+you can avoid repeated determinations and pass them as a keyword argument to
+the function:
+
+ isMergedCell(ws::Worksheet, cr::String; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Bool
+ isMergedCell(xf::XLSXFile, cr::String; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Bool
+
+ isMergedCell(ws::Worksheet, row:Int, col::Int; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Bool
+
+The Excel file must be opened in write mode to work with merged cells.
+
+# Examples:
+```julia
+julia> XLSX.isMergedCell(xf, "Sheet1!A1")
+
+julia> XLSX.isMergedCell(sh, "A1")
+
+julia> XLSX.isMergedCell(sh, 2, 4) # cell D2
+
+julia> mc = XLSX.getMergedCells(sh)
+
+julia> XLSX.isMergedCell(sh, XLSX.CellRef("A1"), mc)
+
+```
+"""
+function isMergedCell end
+isMergedCell(xl::XLSXFile, sheetcell::String; kw...)::Bool = process_get_sheetcell(isMergedCell, xl, sheetcell; kw...)
+isMergedCell(ws::Worksheet, cr::String; kw...)::Bool = process_get_cellname(isMergedCell, ws, cr; kw...)
+isMergedCell(ws::Worksheet, row::Integer, col::Integer; kw...) = isMergedCell(ws, CellRef(row, col); kw...)
+function isMergedCell(ws::Worksheet, cellref::CellRef; mergedCells::Union{Vector{CellRange},Nothing,Missing}=missing)::Bool
+
+ _require_writable(ws, "get merged cells")
+
+ _require_cache(ws, "get merged cells")
+
+ if ismissing(mergedCells) # Get mergedCells if missing
+ mergedCells = getMergedCells(ws)
+ end
+ if isnothing(mergedCells) # No merged cells in sheet
+ return false
+ end
+ for rng in mergedCells
+ if cellref ∈ rng
+ return true
+ end
+ end
+
+ return false
+end
+
+"""
+ getMergedBaseCell(ws::Worksheet, cr::String) -> Union{Nothing, NamedTuple{CellRef, Any}}
+ getMergedBaseCell(xf::XLSXFile, cr::String) -> Union{Nothing, NamedTuple{CellRef, Any}}
+
+ getMergedBaseCell(ws::Worksheet, row::Int, col::Int) -> Union{Nothing, NamedTuple{CellRef, Any}}
+
+Return the cell reference and cell value of the base cell of a merged cell range in a worksheet as a named tuple.
+If the specified cell is not part of a merged cell range, return `nothing`.
+
+The base cell is the top-left cell of the merged cell range and is the reference cell for the range.
+
+The tuple returned contains:
+- `baseCell` : the reference (`CellRef`) of the base cell
+- `baseValue` : the value of the base cell
+
+Additionally, if you have already obtained the merged cells for the worksheet,
+you can avoid repeated determinations and pass them as a keyword argument to
+the function:
+
+ getMergedBaseCell(ws::Worksheet, cr::String; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Union{Nothing, NamedTuple{CellRef, Any}}
+ getMergedBaseCell(xf::XLSXFile, cr::String; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Union{Nothing, NamedTuple{CellRef, Any}}
+
+ getMergedBaseCell(ws::Worksheet, row::Int, col::Int; mergedCells::Union{Vector{CellRange}, Nothing, Missing}=missing) -> Union{Nothing, NamedTuple{CellRef, Any}}
+
+The Excel file must be opened in write mode to work with merged cells.
+
+# Examples:
+```julia
+julia> XLSX.getMergedBaseCell(xf, "Sheet1!B2")
+(baseCell = B1, baseValue = 3)
+
+julia> XLSX.getMergedBaseCell(sh, "B2")
+(baseCell = B1, baseValue = 3)
+
+julia> XLSX.getMergedBaseCell(sh, 2, 2)
+(baseCell = B1, baseValue = 3)
+
+```
+"""
+function getMergedBaseCell end
+getMergedBaseCell(xl::XLSXFile, sheetcell::String; kw...) = process_get_sheetcell(getMergedBaseCell, xl, sheetcell; kw...)
+getMergedBaseCell(ws::Worksheet, cr::String; kw...) = process_get_cellname(getMergedBaseCell, ws, cr; kw...)
+getMergedBaseCell(ws::Worksheet, row::Integer, col::Integer; kw...) = getMergedBaseCell(ws, CellRef(row, col); kw...)
+function getMergedBaseCell(ws::Worksheet, cellref::CellRef; mergedCells::Union{Vector{CellRange},Nothing,Missing}=missing)
+
+ _require_writable(ws, "get merged cells")
+
+ _require_cache(ws, "get merged cells")
+
+ if ismissing(mergedCells) # Get mergedCells if missing
+ mergedCells = getMergedCells(ws)
+ end
+ if isnothing(mergedCells) # No merged cells in sheet
+ return nothing
+ end
+ for rng in mergedCells
+ if cellref ∈ rng
+ return (; baseCell=rng.start, baseValue=ws[rng.start])
+ end
+ end
+ return nothing
+end
+
+"""
+ mergeCells(ws::Worksheet, cr::String) -> 0
+ mergeCells(xf::XLSXFile, cr::String) -> 0
+
+ mergeCells(ws::Worksheet, row::Int, col::Int) -> 0
+
+Merge the cells in the range given by `cr`. The value of the merged cell
+will be the value of the first cell in the range (the base cell) prior
+to the merge. All other cells in the range will be set to `missing`,
+reflecting the behaviour of Excel itself.
+
+Merging is limited to the extent of the worksheet dimension.
+
+The specified range must not overlap with any previously merged cells, nor
+with an Excel Table (Excel discards a table that overlaps a merged range).
+
+It is not possible to merge a single cell!
+
+A non-contiguous range composed of multiple cell ranges will be processed as a
+list of separate ranges. Each range will be merged separately. No range within
+a non-contiguous range may be a single cell.
+
+The Excel file must be opened in write mode to work with merged cells.
+
+# Examples:
+```julia
+julia> XLSX.mergeCells(xf, "Sheet1!B2:D3") # Merge a cell range.
+
+julia> XLSX.mergeCells(sh, 1:3, :) # Merge rows to the extent of the dimension.
+
+julia> XLSX.mergeCells(sh, "A:D") # Merge columns to the extent of the dimension.
+
+```
+"""
+function mergeCells end
+mergeCells(ws::Worksheet, rng::SheetCellRange) = do_sheet_names_match(ws, rng) && mergeCells(ws, rng.rng)
+mergeCells(ws::Worksheet, rng::SheetColumnRange) = do_sheet_names_match(ws, rng) && mergeCells(ws, rng.colrng)
+mergeCells(ws::Worksheet, rng::SheetRowRange) = do_sheet_names_match(ws, rng) && mergeCells(ws, rng.rowrng)
+mergeCells(ws::Worksheet, colrng::ColumnRange)::Int = process_columnranges(mergeCells, ws, colrng)
+mergeCells(ws::Worksheet, rowrng::RowRange)::Int = process_rowranges(mergeCells, ws, rowrng)
+mergeCells(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(mergeCells, ws, ncrng; kw...)
+mergeCells(xl::XLSXFile, sheetcell::AbstractString)::Int = process_sheetcell(mergeCells, xl, sheetcell)
+mergeCells(ws::Worksheet, ref_or_rng::AbstractString)::Int = process_ranges(mergeCells, ws, ref_or_rng)
+mergeCells(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon) = process_colon(mergeCells, ws, row, nothing)
+mergeCells(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}) = process_colon(mergeCells, ws, nothing, col)
+mergeCells(ws::Worksheet, ::Colon, ::Colon) = process_colon(mergeCells, ws, nothing, nothing)
+mergeCells(ws::Worksheet, ::Colon) = process_colon(mergeCells, ws, nothing, nothing)
+mergeCells(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = mergeCells(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))))
+function mergeCells(ws::Worksheet, cr::CellRange)
+ # May be better if merged cells were part of ws.cache?
+
+ !issubset(cr, get_dimension(ws)) && throw(XLSXError("Range `$cr` goes outside worksheet dimension."))
+
+ length(cr) == 1 && throw(XLSXError("Cannot merge a single cell: `$cr`"))
+
+ _require_writable(ws, "merge cells")
+
+ _require_cache(ws, "get merged cells")
+
+ for t in tables(ws) # Excel drops a table that overlaps a merged range
+ if intersects(cr, t.ref)
+ throw(XLSXError("Merged range (`$cr`) cannot overlap table `$(t.name)` (`$(t.ref)`)."))
+ end
+ end
+
+ sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id) # find the block in the worksheet's xml file
+ i, j = get_idces(sheetdoc, "worksheet", "mergeCells")
+
+ pfx = get_prefix(ws)
+ pfx = pfx == "" ? pfx : pfx * ":"
+ if isnothing(j) # There are no existing merged cells. Insert a block where the schema puts it.
+ root = sheetdoc[i]
+ l = insert_index(root, "mergeCells", WORKSHEET_ORDER)
+ insert!(root.children, l + 1, XML.Element("$(pfx)mergeCells"))
+ j = l + 1
+ count = 0
+ else # There are already some existing merged cells
+ c = xml_elements(sheetdoc[i][j])
+ count = length(c)
+ if count != parse(Int, sheetdoc[i][j]["count"])
+ throw(XLSXError("Unexpected number of mergeCells found: $(length(c)). Expected $(sheetdoc[i][j]["count"])."))
+ end
+ for child in c
+ if intersects(cr, CellRange(child["ref"]))
+ throw(XLSXError("Merged range (`$cr`) cannot overlap with existing merged range (`" * child["ref"] * "`)."))
+ end
+ end
+ end
+
+ push!(sheetdoc[i][j], XML.Element("$(pfx)mergeCell", ref=string(cr))) # Add the new merged cell range.
+ count += 1
+ sheetdoc[i][j]["count"] = count
+
+ # All cells except the base cell are set to missing.
+ let first = true
+ for cell in cr
+ if first
+ first = false
+ continue
+ else
+ ws[cell] = ""
+ end
+ end
+ end
+
+ update_worksheets_xml!(get_xlsxfile(ws))
+
+ return 0 # meaningless return value. Int required to comply with reference decoding structure.
+end
+
+"""
+ removeMergedCells(ws::Worksheet, cr::String) -> 0
+ removeMergedCells(xf::XLSXFile, cr::String) -> 0
+
+ removeMergedCells(ws::Worksheet, row::Int, col::Int) -> 0
+
+ removeMergedCells(ws::Worksheet) -> 0
+ removeMergedCells(ws::Worksheet, ::Colon) -> 0
+
+Unmerge merged cells in a worksheet.
+
+A merged range is unmerged if it fully encloses the range given by `cr`. Naming
+any single cell of a merged range is therefore enough to unmerge the whole of
+it, without needing to know its extent. A range larger than the merge encloses
+nothing and is a no-op: `removeMergedCells(sh, "D47:J47")` leaves a merged
+`D47:H47` intact.
+
+Because merged ranges cannot overlap one another, a contiguous `cr` unmerges at
+most one range. Use the non-contiguous form, or the no-argument form, to unmerge
+several at once.
+
+Called with no cell reference at all (or with a single `Colon`), every merged
+range in the worksheet is unmerged.
+
+Unmerging does not change any cell values, reflecting the behaviour of Excel
+itself.
+
+If nothing in the worksheet encloses `cr`, the worksheet is left untouched.
+Specifying a range that is not merged is not an error it is a no-op.
+
+A non-contiguous range composed of multiple cell ranges will be processed as a
+list of separate ranges. Each range will be unmerged separately.
+
+The Excel file must be opened in write mode to work with merged cells.
+
+# Examples:
+```julia
+julia> XLSX.removeMergedCells(xf, "Sheet1!B2") # Unmerge the range containing B2.
+
+julia> XLSX.removeMergedCells(sh, "B2:C3") # Unmerge a range enclosing B2:C3.
+
+julia> XLSX.removeMergedCells(sh, 4, 4) # Unmerge the range containing D4.
+
+julia> XLSX.removeMergedCells(sh, :, 10) # Unmerge a whole-column merge in J.
+
+julia> XLSX.removeMergedCells(sh) # Unmerge everything in the worksheet.
+
+```
+"""
+function removeMergedCells end
+removeMergedCells(ws::Worksheet, rng::SheetCellRange) = do_sheet_names_match(ws, rng) && removeMergedCells(ws, rng.rng)
+removeMergedCells(ws::Worksheet, rng::SheetColumnRange) = do_sheet_names_match(ws, rng) && removeMergedCells(ws, rng.colrng)
+removeMergedCells(ws::Worksheet, rng::SheetRowRange) = do_sheet_names_match(ws, rng) && removeMergedCells(ws, rng.rowrng)
+removeMergedCells(ws::Worksheet, colrng::ColumnRange)::Int = process_columnranges(removeMergedCells, ws, colrng)
+removeMergedCells(ws::Worksheet, rowrng::RowRange)::Int = process_rowranges(removeMergedCells, ws, rowrng)
+removeMergedCells(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(removeMergedCells, ws, ncrng; kw...)
+removeMergedCells(xl::XLSXFile, sheetcell::AbstractString)::Int = process_sheetcell(removeMergedCells, xl, sheetcell)
+removeMergedCells(ws::Worksheet, ref_or_rng::AbstractString)::Int = process_ranges(removeMergedCells, ws, ref_or_rng)
+removeMergedCells(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon) = process_colon(removeMergedCells, ws, row, nothing)
+removeMergedCells(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}) = process_colon(removeMergedCells, ws, nothing, col)
+removeMergedCells(ws::Worksheet, ::Colon, ::Colon) = process_colon(removeMergedCells, ws, nothing, nothing)
+removeMergedCells(ws::Worksheet, ::Colon) = process_colon(removeMergedCells, ws, nothing, nothing)
+removeMergedCells(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = removeMergedCells(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))))
+
+# Unlike `mergeCells`, a single cell is meaningful here: it identifies the merged
+# range that contains it, so `process_ranges` can hand us a bare `CellRef`.
+removeMergedCells(ws::Worksheet, cellref::CellRef)::Int = removeMergedCells(ws, CellRange(cellref, cellref))
+
+removeMergedCells(ws::Worksheet)::Int = _remove_merged_cells(ws, nothing)
+removeMergedCells(ws::Worksheet, cr::CellRange)::Int = _remove_merged_cells(ws, cr)
+
+# `cr === nothing` means "every merged range in the worksheet".
+function _remove_merged_cells(ws::Worksheet, cr::Union{CellRange,Nothing})::Int
+
+ xf = get_xlsxfile(ws)
+
+ _require_writable(xf, "remove merged cells")
+
+ _require_cache(xf, "remove merged cells")
+
+ if !isnothing(cr) && !issubset(cr, get_dimension(ws))
+ throw(XLSXError("Range `$cr` goes outside worksheet dimension."))
+ end
+
+ sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id) # find the block in the worksheet's xml file
+ i, j = get_idces(sheetdoc, "worksheet", "mergeCells")
+
+ isnothing(j) && return 0 # There are no merged cells, so nothing to remove.
+
+ block = sheetdoc[i][j]
+ kids = XML.children(block)
+
+ # Indices of the children to drop, in ascending order (as `deleteat!` requires).
+ drop = Int[]
+ nrefs = 0
+ for (k, child) in enumerate(kids)
+ XML.nodetype(child) == XML.Element || continue
+ nrefs += 1
+ !haskey(child, "ref") && throw(XLSXError("No `ref` attribute found in `mergeCell` element."))
+ if isnothing(cr) || issubset(cr, CellRange(child["ref"]))
+ push!(drop, k)
+ end
+ end
+
+ if nrefs != parse(Int, block["count"])
+ throw(XLSXError("Unexpected number of mergeCells found: $nrefs. Expected $(block["count"])."))
+ end
+
+ isempty(drop) && return 0 # Nothing matched: leave the xml untouched.
+
+ if length(drop) == nrefs
+ # An empty is not schema-valid, so drop the whole block.
+ deleteat!(sheetdoc[i].children, j)
+ else
+ deleteat!(kids, drop)
+ block["count"] = nrefs - length(drop)
+ end
+
+ update_worksheets_xml!(xf)
+
+ return 0 # meaningless return value. Int required to comply with reference decoding structure.
+end
diff --git a/src/cellformats/numberformat.jl b/src/cellformats/numberformat.jl
new file mode 100644
index 00000000..c172f9e8
--- /dev/null
+++ b/src/cellformats/numberformat.jl
@@ -0,0 +1,244 @@
+#
+# -- Get and set number format attributes
+#
+
+"""
+ getFormat(sh::Worksheet, cr::String) -> ::Union{Nothing, CellFormat}
+ getFormat(xf::XLSXFile, cr::String) -> ::Union{Nothing, CellFormat}
+
+ getFormat(sh::Worksheet, row::Int, col::int) -> ::Union{Nothing, CellFormat}
+
+Get the format (numFmt) used by a single cell at reference `cr` in a worksheet or XLSXFile.
+A cell outside the sheet dimension is empty, so this returns `nothing`, as for any empty cell.
+
+Return a `CellFormat` object containing:
+- `numFmtId` : a 0-based index of the formats in the workbook. Values below 164 are
+ reserved for built-in formats. Values of 164 and over are custom formats
+ and are stored in the `styles.xml` file within the XLSXFile.
+- `format` : a dictionary of numFmt attributes: formatAttribute -> (attribute -> value)
+- `applyNumberFormat` : "1" or "0", indicating whether or not the format is applied to the cell.
+
+Return `nothing` if no cell format is found. This will occur when a cell uses a built-in format.
+
+The function will always find any explicitly set custom format. It will also attempt to return
+the format for built-in formats, too.
+
+# Examples:
+```julia
+julia> getFormat(sh, "A1")
+
+julia> getFormat(xf, "Sheet1!A1")
+
+julia> getFormat(sh, 1, 1)
+
+```
+"""
+function getFormat end
+getFormat(xl::XLSXFile, sheetcell::String)::Union{Nothing,CellFormat} = process_get_sheetcell(getFormat, xl, sheetcell)
+getFormat(ws::Worksheet, cellref::CellRef)::Union{Nothing,CellFormat} = process_get_cellref(getFormat, ws, cellref)
+getFormat(ws::Worksheet, cr::String) = process_get_cellname(getFormat, ws, cr)
+getFormat(ws::Worksheet, row::Integer, col::Integer) = getFormat(ws, CellRef(row, col))
+
+function getFormat(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellFormat}
+ _require_cache(wb, "get number formats")
+
+ haskey(cell_style, "numFmtId") || return nothing
+
+ numfmtid_int = parse(Int, cell_style["numFmtId"])
+ applynumberformat = haskey(cell_style, "applyNumberFormat") ? cell_style["applyNumberFormat"] : "0"
+ format_atts = Dict{String,Union{Dict{String,String},Nothing}}()
+
+ if numfmtid_int >= PREDEFINED_NUMFMT_COUNT
+ # Custom format — look up in the cached numFmtId => formatCode map
+ cache = get_numFmt_cache(wb)
+ haskey(cache, numfmtid_int) || throw(XLSXError("No format definition found for numFmtId $numfmtid_int."))
+
+ format_atts["numFmt"] = Dict(
+ "numFmtId" => string(numfmtid_int),
+ "formatCode" => cache[numfmtid_int]
+ )
+
+ else
+ # Built-in format — validate it falls in a known range
+ any(numfmtid_int ∈ r for r in BUILTIN_NUMFMT_RANGES) ||
+ throw(XLSXError("Expected a built-in format ID in ranges 0:22, 37:40, or 45:49. Got $numfmtid_int."))
+
+ if haskey(builtinFormats, string(numfmtid_int))
+ format_atts["numFmt"] = Dict(
+ "numFmtId" => string(numfmtid_int),
+ "formatCode" => builtinFormats[string(numfmtid_int)]
+ )
+ end
+ end
+
+ return CellFormat(numfmtid_int, format_atts, applynumberformat)
+end
+
+"""
+ setFormat(sh::Worksheet, cr::String; kw...) -> ::Int
+ setFormat(xf::XLSXFile, cr::String; kw...) -> ::Int
+
+ setFormat(sh::Worksheet, row, col; kw...) -> ::Int
+
+Set the number format used used by a single cell, a cell range, a column
+range or row range or a named cell or named range in a worksheet or
+XLSXFile. Alternatively, specify the row and column using any combination
+of Integer, UnitRange, Vector{Integer} or `:`.
+
+The function uses one keyword used to define a format:
+- `format::String = nothing` : Defines a built-in or custom number format
+
+The format keyword can define some built-in formats by name:
+- `General` : specifies internal format ID 0 (General)
+- `Number` : specifies internal format ID 2 (`0.00`)
+- `Currency` : specifies internal format ID 7 (`\$#,##0.00_);(\$#,##0.00)`)
+- `Percentage` : specifies internal format ID 9 (`0%`)
+- `ShortDate` : specifies internal format ID 14 (`m/d/yyyy`)
+- `LongDate` : specifies internal format ID 15 (`d-mmm-yy`)
+- `Time` : specifies internal format ID 21 (`h:mm:ss`)
+- `Scientific` : specifies internal format ID 48 (`##0.0E+0`)
+
+If `Currency` is specified, Excel will use the appropriate local currency symbol.
+
+Alternatively, the format keyword can define built-in formats by their internal
+format ID (as a string). The following built-in format IDs are supported:
+* "0" => "General"
+* "1" => "0"
+* "2" => "0.00",
+* "3" => "#,##0"
+* "4" => "#,##0.00"
+* "5" => "\$#,##0_);(\$#,##0)"
+* "6" => "\$#,##0_);Red"
+* "7" => "\$#,##0.00_);(\$#,##0.00)"
+* "8" => "\$#,##0.00_);Red"
+* "9" => "0%"
+* "10" => "0.00%"
+* "11" => "0.00E+00"
+* "12" => "# ?/?"
+* "13" => "# ??/??"
+* "14" => "m/d/yyyy"
+* "15" => "d-mmm-yy"
+* "16" => "d-mmm"
+* "17" => "mmm-yy"
+* "18" => "h:mm AM/PM"
+* "19" => "h:mm:ss AM/PM"
+* "20" => "h:mm"
+* "21" => "h:mm:ss"
+* "22" => "m/d/yyyy h:mm"
+* "37" => "#,##0_);(#,##0)"
+* "38" => "#,##0_);Red"
+* "39" => "#,##0.00_);(#,##0.00)"
+* "40" => "#,##0.00_);Red"
+* "45" => "mm:ss"
+* "46" => "[h]:mm:ss"
+* "47" => "mmss.0"
+* "48" => "##0.0E+0"
+* "49" => "@"
+
+Lastly, `format` can be used to specify any custom format directly.
+Only weak checks are made of custom formats specified - they are otherwise added
+to the `XLSXFile` verbatim.
+
+Formats may need characters that must be escaped when specified (see penultimate
+example, below).
+
+# Examples:
+```julia
+julia> XLSX.setFormat(sh, "D2"; format = "h:mm AM/PM")
+
+julia> XLSX.setFormat(xf, "Sheet1!A2"; format = "# ??/??")
+
+julia> XLSX.setFormat(sh, "F1:F5"; format = "Currency")
+
+julia> XLSX.setFormat(sh, "named_range"; format = "Percentage")
+
+julia> XLSX.setFormat(sh, "A2"; format = "_-£* #,##0.00_-;-£* #,##0.00_-;_-£* \\\"-\\\"??_-;_-@_-")
+
+julia> XLSX.setFormat(sh, "named_range"; format = "39") # specifies the built-in format "#,##0.00_);(#,##0.00)"
+
+```
+"""
+function setFormat end
+define_percell_spec_methods(:setFormat, :_setFormat, :FormatSpec)
+
+function _setFormat(sh::Worksheet, cellref::CellRef; spec::FormatSpec)::Int
+ (; format) = spec
+ _require_writable(sh, "set number format")
+
+ wb = get_workbook(sh)
+ cell = getcell(sh, cellref)
+ cell isa EmptyCell &&
+ throw(XLSXError("Cannot set format for an `EmptyCell`: $(cellname(cellref)). Set the value first."))
+
+ allXfNodes = get_cellXfs_nodes(wb)
+
+ if cell.style == UInt32(0)
+ cell.style = get_num_style_index(sh, allXfNodes, 0).id
+ end
+
+ cell_style = styles_cell_xf(allXfNodes, cell.style)
+ cell_format = getFormat(wb, cell_style)
+
+ # No format kwarg — return existing format id, or 0 if none set
+ if isnothing(format)
+ return isnothing(cell_format) ? 0 : cell_format.numFmtId
+ end
+
+ # Resolve new format id: built-in lookup or next available custom id
+ new_formatid = if haskey(BUILTIN_FORMAT_CODES, format)
+ BUILTIN_FORMAT_CODES[format]
+ else
+ get_new_formatId(wb, format)
+ end
+
+ # numFmtId == 0 is the "General" default format — applyNumberFormat is redundant for it
+ atts = new_formatid == 0 ?
+ (["numFmtId"], [string(new_formatid)]) :
+ (["numFmtId", "applyNumberFormat"], [string(new_formatid), "1"])
+
+ newstyle = update_template_xf(sh, allXfNodes, CellDataFormat(cell.style), atts[1], atts[2]).id
+ cell.style = newstyle
+
+ return new_formatid
+end
+
+"""
+ setUniformFormat(sh::Worksheet, cr::String; kw...) -> ::Int
+ setUniformFormat(xf::XLSXFile, cr::String, kw...) -> ::Int
+
+ setUniformFormat(sh::Worksheet, rows, cols; kw...) -> ::Int
+
+Set the number format used by a cell range, a column range or row range or a
+named range in a worksheet or XLSXFile to be to be uniformly the same format.
+Alternatively, specify the rows and columns using any combination of
+Integer, UnitRange, Vector{Integer} or `:`.
+
+First, the number format of the first cell in the range (the top-left cell) is
+updated according to the given `kw...` (using `setFormat()`). The resultant format is
+then applied to each remaining cell in the range.
+
+As a result, every cell in the range will have a uniform number format.
+
+This is functionally equivalent to applying `setFormat()` to each cell in the range
+but may be more efficient.
+
+Applying `setUniformFormat()` without any keyword arguments simply copies the `Format`
+attributes from the first cell specified to all the others.
+
+The value returned is the `numfmtId` of the format uniformly applied to the cells.
+If all cells in the range are `EmptyCells`, the returned value is -1.
+
+For keyword definitions see [`setFormat()`](@ref).
+
+# Examples:
+```julia
+julia> XLSX.setUniformFormat(xf, "Sheet1!A2:L6"; format = "# ??/??")
+
+julia> XLSX.setUniformFormat(sh, "F1:F5"; format = "Currency")
+
+julia> setUniformFormat(sh, "B2,A5:D22") # Copy `Format` from B2 to cells in A5:D22
+
+```
+"""
+function setUniformFormat end
+define_uniform_spec_methods(:setUniformFormat, :_setUniformFormat, :_setFormat, :FormatSpec, ["numFmtId", "applyNumberFormat"])
diff --git a/src/cellformats/uniformstyle.jl b/src/cellformats/uniformstyle.jl
new file mode 100644
index 00000000..5d895c98
--- /dev/null
+++ b/src/cellformats/uniformstyle.jl
@@ -0,0 +1,76 @@
+#
+# -- Set uniform styles
+#
+
+"""
+ setUniformStyle(sh::Worksheet, cr::String) -> ::Int
+ setUniformStyle(xf::XLSXFile, cr::String) -> ::Int
+
+ setUniformStyle(sh::Worksheet, rows, cols) -> ::Int
+
+Set the cell `style` used by a cell range, a column range or row range
+or a named range in a worksheet or XLSXFile to be the same as that of
+the first cell in the range that is not an `EmptyCell`.
+Alternatively, specify the rows and columns using any combination of
+Integer, UnitRange, Vector{Integer} or `:`.
+
+As a result, every cell in the range will have a uniform `style`.
+
+A cell `style` consists of the collection of `format`, `alignment`, `border`,
+`font` and `fill`.
+
+If the first cell has no defined `style` (`s=""`), all cells will be given the
+same undefined `style`.
+
+The value returned is the `styleId` of the `style` uniformly applied to the cells or
+`nothing` if the style is undefined.
+If all cells in the range are `EmptyCells`, the returned value is -1.
+
+# Examples:
+```julia
+julia> XLSX.setUniformStyle(xf, "Sheet1!A2:L6")
+
+julia> XLSX.setUniformStyle(sh, "F1:F5")
+
+julia> XLSX.setUniformStyle(sh, 2:5, 5)
+
+julia> XLSX.setUniformStyle(sh, 2, :)
+
+```
+"""
+function setUniformStyle end
+setUniformStyle(ws::Worksheet, rng::SheetCellRange) = do_sheet_names_match(ws, rng) && setUniformStyle(ws, rng.rng)
+setUniformStyle(ws::Worksheet, rng::SheetColumnRange) = do_sheet_names_match(ws, rng) && setUniformStyle(ws, rng.colrng)
+setUniformStyle(ws::Worksheet, rng::SheetRowRange) = do_sheet_names_match(ws, rng) && setUniformStyle(ws, rng.rowrng)
+setUniformStyle(ws::Worksheet, colrng::ColumnRange) = process_columnranges(setUniformStyle, ws, colrng)
+setUniformStyle(ws::Worksheet, rowrng::RowRange) = process_rowranges(setUniformStyle, ws, rowrng)
+setUniformStyle(ws::Worksheet, ncrng::NonContiguousRange) = process_uniform_ncranges(ws, ncrng)
+setUniformStyle(xl::XLSXFile, sheetcell::AbstractString) = process_sheetcell(setUniformStyle, xl, sheetcell)
+setUniformStyle(ws::Worksheet, ref_or_rng::AbstractString) = process_ranges(setUniformStyle, ws, ref_or_rng)
+setUniformStyle(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon) = process_colon(ws, row, nothing)
+setUniformStyle(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}) = process_colon(ws, nothing, col)
+setUniformStyle(ws::Worksheet, ::Colon, ::Colon) = process_colon(ws, nothing, nothing)
+setUniformStyle(ws::Worksheet, ::Colon) = process_colon(ws, nothing, nothing)
+setUniformStyle(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon) = process_uniform_veccolon(ws, row, nothing)
+setUniformStyle(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}) = process_uniform_veccolon(ws, nothing, col)
+setUniformStyle(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}) = process_uniform_vecint(ws, row, col)
+setUniformStyle(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = process_uniform_vecint(ws, row, col)
+setUniformStyle(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}) = process_uniform_vecint(ws, row, col)
+setUniformStyle(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = setUniformStyle(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))))
+
+function setUniformStyle(ws::Worksheet, rng::CellRange)::Union{Nothing,Int}
+ _require_writable(ws, "set styles")
+
+ newid::Union{Nothing,Int} = nothing
+ is_first = true
+ first_font = nothing
+
+ for cellref in rng
+ newid, is_first, first_font = process_uniform_core(ws, cellref, newid, is_first, first_font)
+ end
+
+ # -1 signals that all cells in the range were EmptyCells
+ is_first && return -1
+
+ return newid
+end
diff --git a/src/cellformats/widthheight.jl b/src/cellformats/widthheight.jl
new file mode 100644
index 00000000..82f81775
--- /dev/null
+++ b/src/cellformats/widthheight.jl
@@ -0,0 +1,372 @@
+#
+# -- Get and set column width
+#
+
+"""
+ setColumnWidth(sh::Worksheet, cr::String; kw...) -> ::Int
+ setColumnWidth(xf::XLSXFile, cr::String, kw...) -> ::Int
+
+ setColumnWidth(sh::Worksheet, row, col; kw...) -> ::Int
+ setColumnWidth(sh::Worksheet, col; kw...) -> ::Int
+
+Set the width of a column or column range.
+
+A standard cell reference or cell range can be used to define the column range.
+The function will use the columns and ignore the rows. Named cells and named
+ranges can similarly be used.
+Alternatively, specify the row and column using any combination of
+Integer, UnitRange, Vector{Integer} or `:`, but only the columns will be used.
+The row can be omitted and column specified alone, using an integer or colon.
+
+
+The function uses one keyword used to define a column width:
+- `width::Real = nothing` : Defines width in Excel's own (internal) units
+
+When you set a column widths interactively in Excel you can see the width
+in "internal" units and in pixels. The width stored in the xlsx file is slightly
+larger than the width shown intertactively because Excel adds some cell padding.
+The method Excel uses to calculate the padding is obscure and complex. This
+function does not attempt to replicate it, but simply adds 0.71 internal units
+to the value specified. The value set is unlikely to match the value seen
+interactivley in the resultant spreadsheet, but will be close.
+
+You can set a column width to 0.
+
+The function returns a value of 0. The return value is meaningless but required to comply with the
+range dispatch infrastructure, which expects an `Int` return from all `set*` functions.
+
+`setColumnWidth` requires a file to be open for writing as well as reading (`mode="rw"` or open as a template) but
+it can work on empty cells.
+
+# Examples:
+```julia
+julia> XLSX.setColumnWidth(xf, "Sheet1!A2"; width = 50)
+
+julia> XLSX.seColumnWidth(sh, "F1:F5"; width = 0)
+
+julia> XLSX.setColumnWidth(sh, "I"; width = 24.37)
+
+```
+"""
+function setColumnWidth end
+setColumnWidth(ws::Worksheet, ref::SheetCellRef; kw...) = do_sheet_names_match(ws, ref) && setColumnWidth(ws, ref.cellref; kw...)
+setColumnWidth(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setColumnWidth(ws, rng.rng; kw...)
+setColumnWidth(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setColumnWidth(ws, rng.colrng; kw...)
+setColumnWidth(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setColumnWidth(ws, rng.rowrng; kw...)
+setColumnWidth(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setColumnWidth, ws, colrng; kw...)
+setColumnWidth(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setColumnWidth, ws, rowrng; kw...)
+setColumnWidth(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(setColumnWidth, ws, ncrng; kw...)
+setColumnWidth(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setColumnWidth, ws, ref_or_rng; kw...)
+setColumnWidth(xl::XLSXFile, sheetcell::String; kw...)::Int = process_sheetcell(setColumnWidth, xl, sheetcell; kw...)
+setColumnWidth(ws::Worksheet, cr::CellRef; kw...)::Int = setColumnWidth(ws, CellRange(cr, cr); kw...)
+setColumnWidth(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setColumnWidth, ws, row, nothing; kw...)
+setColumnWidth(ws::Worksheet, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setColumnWidth, ws, nothing, col; kw...) # convenience: bare column index/range
+setColumnWidth(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setColumnWidth, ws, nothing, col; kw...)
+setColumnWidth(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setColumnWidth, ws, nothing, nothing; kw...)
+setColumnWidth(ws::Worksheet, ::Colon; kw...) = process_colon(setColumnWidth, ws, nothing, nothing; kw...)
+setColumnWidth(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_veccolon(setColumnWidth, ws, row, nothing; kw...)
+setColumnWidth(ws::Worksheet, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setColumnWidth, ws, nothing, col; kw...) # convenience: bare column vector/steprange
+setColumnWidth(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setColumnWidth, ws, nothing, col; kw...)
+setColumnWidth(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setColumnWidth, ws, row, col; kw...)
+setColumnWidth(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_vecint(setColumnWidth, ws, row, col; kw...)
+setColumnWidth(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setColumnWidth, ws, row, col; kw...)
+setColumnWidth(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setColumnWidth(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
+function setColumnWidth(ws::Worksheet, rng::CellRange; width::Union{Nothing,Real}=nothing)::Int
+ _require_writable(ws, "set column widths")
+
+ # Validate and resolve width before any XML work
+ isnothing(width) && return 0
+ width < 0 && throw(XLSXError("Invalid width: $width. Must be >= 0."))
+
+ wb=get_workbook(ws)
+ padded_width = width + EXCEL_COLUMN_WIDTH_PADDING
+
+ left = rng.start.column_number
+ right = rng.stop.column_number
+
+ # Find the block in the worksheet XML, inserting one before if absent
+ sheetdoc = xmlroot(wb, ws.relationship_id)
+ pfx = get_prefix(ws)
+ pfx = pfx == "" ? pfx : pfx * ":"
+
+ i, j = get_idces(sheetdoc, "worksheet", "cols")
+
+ if isnothing(j)
+ k, l = get_idces(sheetdoc, "worksheet", "sheetData")
+ i != k && throw(XLSXError("Unexpected worksheet structure: parent index does not match index."))
+ insert!(sheetdoc[k].children, l, XML.Element("$(pfx)Cols"))
+ j = l
+ end
+
+ # Existing definitions each span columns min:max. Split any span at the edges of
+ # left:right, so the part inside takes the new width while keeping its other
+ # settings (style, hidden, ...), the parts outside are unchanged, and no two
+ # definitions overlap (Excel repairs a file where they do).
+ width_atts = Dict("width" => string(padded_width), "customWidth" => "1")
+ covered = falses(right - left + 1)
+ col_defs = Dict{String,String}[]
+ for c in xml_elements(sheetdoc[i][j])
+ atts = Dict{String,String}(XML.attributes(c))
+ lo, hi = parse(Int, atts["min"]), parse(Int, get(atts, "max", atts["min"]))
+ for (a, b, inside) in ((lo, min(hi, left - 1), false),
+ (max(lo, left), min(hi, right), true),
+ (max(lo, right + 1), hi, false))
+ a <= b || continue
+ d = merge(atts, Dict("min" => string(a), "max" => string(b)))
+ inside && (merge!(d, width_atts); covered[(a:b) .- (left - 1)] .= true)
+ push!(col_defs, d)
+ end
+ end
+
+ # New definitions for the columns in the range that had none
+ k = 1
+ while k <= length(covered)
+ if covered[k]
+ k += 1
+ continue
+ end
+ e = something(findnext(covered, k), length(covered) + 1) - 1
+ push!(col_defs, merge(Dict("min" => string(left + k - 1), "max" => string(left + e - 1)), width_atts))
+ k = e + 1
+ end
+
+ # Rebuild the node, in column order as the schema requires
+ sort!(col_defs; by = d -> parse(Int, d["min"]))
+ new_cols = unlink(sheetdoc[i][j], ("cols", "col"), pfx)
+ for atts in col_defs
+ new_col = XML.Element("$(pfx)col")
+ for k in ("min", "max")
+ new_col[k] = atts[k]
+ end
+ for (k, v) in atts
+ k in ("min", "max") || (new_col[k] = v)
+ end
+ push!(new_cols, new_col)
+ end
+
+ sheetdoc[i][j] = new_cols
+
+ # Working on worksheet XML directly requires updating the file cache
+ update_worksheets_xml!(get_xlsxfile(ws))
+
+ return 0
+end
+
+"""
+ getColumnWidth(sh::Worksheet, cr::String) -> ::Union{Nothing, Real}
+ getColumnWidth(xf::XLSXFile, cr::String) -> ::Union{Nothing, Real}
+
+ getColumnWidth(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, Real}
+
+Get the width of a column defined by a cell reference or named cell.
+The specified cell must be within the sheet dimension.
+
+A standard cell reference or defined name may be used to define the column.
+The function will use the column number and ignore the row.
+
+The function returns the value of the column width or nothing if the column
+does not have an explicitly defined width.
+
+# Examples:
+```julia
+julia> XLSX.getColumnWidth(xf, "Sheet1!A2")
+
+julia> XLSX.getColumnWidth(sh, "F1")
+
+julia> XLSX.getColumnWidth(sh, 1, 6)
+
+```
+"""
+function getColumnWidth end
+getColumnWidth(xl::XLSXFile, sheetcell::String)::Union{Nothing,Float64} = process_get_sheetcell(getColumnWidth, xl, sheetcell)
+getColumnWidth(ws::Worksheet, cr::String) = process_get_cellname(getColumnWidth, ws, cr)
+getColumnWidth(ws::Worksheet, row::Integer, col::Integer) = getColumnWidth(ws, CellRef(row, col))
+function getColumnWidth(ws::Worksheet, cellref::CellRef)::Union{Nothing,Real}
+ # May be better if column width were part of ws.cache?
+
+ _require_writable(ws, "get column width")
+
+ d = get_dimension(ws)
+ if cellref ∉ d
+ throw(XLSXError("Cell specified is outside sheet dimension `$d`"))
+ end
+
+ sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id)
+
+ i, j = get_idces(sheetdoc, "worksheet", "cols")
+
+ if isnothing(j) # There are no existing column formats defined.
+ return nothing
+ end
+ col = cellref.column_number
+ for c in xml_elements(sheetdoc[i][j])
+ if parse(Int, c["min"]) <= col <= parse(Int, get(c, "max", c["min"])) # a definition may span columns
+ if haskey(c, "width")
+ return parse(Float64, c["width"])
+ else
+ break
+ end
+ end
+ end
+
+ # Either the column definition was found but with no width attribute or not found at all.
+ return nothing
+end
+
+#
+# -- Get and set row height
+#
+
+"""
+ setRowHeight(sh::Worksheet, cr::String; kw...) -> ::Int
+ setRowHeight(xf::XLSXFile, cr::String, kw...) -> ::Int
+
+ setRowHeight(sh::Worksheet, row, col; kw...) -> ::Int
+
+Set the height of a row or row range.
+
+A standard cell reference or cell range must be used to define the row range.
+The function will use the rows and ignore the columns. Named cells and named
+ranges can similarly be used.
+Alternatively, specify the row and column using any combination of
+Integer, UnitRange, Vector{Integer} or `:`, but only the rows will be used.
+
+The function uses one keyword used to define a row height:
+- `height::Real = nothing` : Defines height in Excel's own (internal) units.
+
+When you set row heights interactively in Excel you can see the height
+in "internal" units and in pixels. The height stored in the xlsx file is slightly
+larger than the height shown interactively because Excel adds some cell padding.
+The method Excel uses to calculate the padding is obscure and complex. This
+function does not attempt to replicate it, but simply adds 0.21 internal units
+to the value specified. The value set is unlikely to match the value seen
+interactivley in the resultant spreadsheet, but it will be close.
+
+Row height cannot be set for empty rows, which will quietly be skipped.
+A row must have at least one cell containing a value before its height can be set.
+
+You can set a row height to 0.
+
+The function returns a value of 0 unless all rows are empty, in which case
+it returns a value of -1.
+
+# Examples:
+```julia
+julia> XLSX.setRowHeight(xf, "Sheet1!A2"; height = 50)
+
+julia> XLSX.setRowHeight(sh, "F1:F5"; height = 0)
+
+julia> XLSX.setRowHeight(sh, "I"; height = 24.56)
+
+```
+"""
+function setRowHeight end
+setRowHeight(ws::Worksheet, ref::SheetCellRef; kw...) = do_sheet_names_match(ws, ref) && setRowHeight(ws, ref.cellref; kw...)
+setRowHeight(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setRowHeight(ws, rng.rng; kw...)
+setRowHeight(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setRowHeight(ws, rng.colrng; kw...)
+setRowHeight(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setRowHeight(ws, rng.rowrng; kw...)
+setRowHeight(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setRowHeight, ws, colrng; kw...)
+setRowHeight(ws::Worksheet, rowrng::RowRange; kw...)::Int = process_rowranges(setRowHeight, ws, rowrng; kw...)
+setRowHeight(ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int = process_ncranges(setRowHeight, ws, ncrng; kw...)
+setRowHeight(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setRowHeight, ws, ref_or_rng; kw...)
+setRowHeight(xl::XLSXFile, sheetcell::String; kw...)::Int = process_sheetcell(setRowHeight, xl, sheetcell; kw...)
+setRowHeight(ws::Worksheet, cr::CellRef; kw...)::Int = setRowHeight(ws::Worksheet, CellRange(cr, cr); kw...)
+setRowHeight(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setRowHeight, ws, row, nothing; kw...)
+setRowHeight(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setRowHeight, ws, row, nothing; kw...)
+setRowHeight(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setRowHeight, ws, nothing, col; kw...)
+setRowHeight(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setRowHeight, ws, nothing, nothing; kw...)
+setRowHeight(ws::Worksheet, ::Colon; kw...) = process_colon(setRowHeight, ws, nothing, nothing; kw...)
+setRowHeight(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setRowHeight, ws, row, nothing; kw...)
+setRowHeight(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_veccolon(setRowHeight, ws, row, nothing; kw...)
+setRowHeight(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setRowHeight, ws, nothing, col; kw...)
+setRowHeight(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setRowHeight, ws, row, col; kw...)
+setRowHeight(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_vecint(setRowHeight, ws, row, col; kw...)
+setRowHeight(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setRowHeight, ws, row, col; kw...)
+setRowHeight(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setRowHeight(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
+function setRowHeight(ws::Worksheet, rng::CellRange; height::Union{Nothing,Real}=nothing)::Int
+
+ _require_writable(ws, "get row height")
+
+ top = rng.start.row_number
+ bottom = rng.stop.row_number
+ padded_height = isnothing(height) ? -1 : height + 0.2109375 # Excel adds cell padding to a user specified width
+ if !isnothing(height) && height < 0
+ throw(XLSXError("Invalid value specified for height: $height. Height must be >= 0."))
+ end
+
+ if isnothing(height) # No-op
+ return 0
+ end
+
+ if isnothing(ws.cache) || !haskey(ws.cache.row_ht, rng.stop.row_number)
+ _ = ws[rng.stop] # Ensure cache filled at least to include the last row in `rng`.
+ end
+
+ first = true
+
+ for r in top:bottom
+ if haskey(ws.cache.row_ht, r) # may still be missing if row is entirely empty.
+ ws.cache.row_ht[r] = padded_height
+ first=false
+ end
+ end
+
+ if first == true
+ return -1
+ end
+
+ return 0
+
+end
+
+"""
+ getRowHeight(sh::Worksheet, cr::String) -> ::Union{Nothing, Real}
+ getRowHeight(xf::XLSXFile, cr::String) -> ::Union{Nothing, Real}
+
+ getRowHeight(sh::Worksheet, row::Int, col::Int) -> ::Union{Nothing, Real}
+
+Get the height of a row defined by a cell reference or named cell.
+The specified cell must be within the sheet dimension.
+
+A standard cell reference or defined name must be used to define the row.
+The function will use the row number and ignore the column.
+
+The function returns the value of the row height or nothing if the row
+does not have an explicitly defined height.
+
+If the row is not found (an empty row), returns -1.
+
+# Examples:
+```julia
+julia> XLSX.getRowHeight(xf, "Sheet1!A2")
+
+julia> XLSX.getRowHeight(sh, "F1")
+
+julia> XLSX.getRowHeight(sh, 1, 6)
+
+```
+"""
+function getRowHeight end
+getRowHeight(xl::XLSXFile, sheetcell::String)::Union{Nothing,Real} = process_get_sheetcell(getRowHeight, xl, sheetcell)
+getRowHeight(ws::Worksheet, cr::String) = process_get_cellname(getRowHeight, ws, cr)
+getRowHeight(ws::Worksheet, row::Integer, col::Integer) = getRowHeight(ws, CellRef(row, col))
+function getRowHeight(ws::Worksheet, cellref::CellRef)::Union{Nothing,Real}
+
+ _require_writable(ws, "get row height")
+
+ d = get_dimension(ws)
+ if cellref ∉ d
+ throw(XLSXError("Cell specified is outside sheet dimension `$d`"))
+ end
+
+ if isnothing(ws.cache) || !haskey(ws.cache.row_ht, cellref.row_number)
+ _ = ws[cellref] # Ensure cache filled at least to include the last row in `rng`.
+ end
+
+ if haskey(ws.cache.row_ht, cellref.row_number) # Row might still be empty
+ return ws.cache.row_ht[cellref.row_number]
+ end
+
+ return -1 # Row specified not found (is empty)
+
+end
diff --git a/src/cellref.jl b/src/cellref.jl
index 5af50d83..24dc70e1 100644
--- a/src/cellref.jl
+++ b/src/cellref.jl
@@ -301,34 +301,34 @@ Base.issubset(subrng::CellRange, rng::CellRange) :: Bool = in(subrng.start, rng)
function intersects(rng1::CellRange, rng2::CellRange) :: Bool
if row_number(rng1.start) <= row_number(rng2.stop) && row_number(rng1.start) >= row_number(rng2.start) &&
column_number(rng1.stop) <= column_number(rng2.stop) && column_number(rng1.stop) >= column_number(rng2.start)
-# println("offset 1")
+ # offset 1
return true
end
if row_number(rng2.start) <= row_number(rng1.stop) && row_number(rng2.start) >= row_number(rng1.start) &&
column_number(rng2.stop) <= column_number(rng1.stop) && column_number(rng2.stop) >= column_number(rng1.start)
-# println("offset 2")
+ # offset 2
return true
end
if row_number(rng1.start)>=row_number(rng2.start) && column_number(rng1.start)<=column_number(rng2.start) &&
row_number(rng1.stop)<=row_number(rng2.stop) && column_number(rng1.stop)>=column_number(rng2.stop)
-# println("cruciform 1")
+ # cruciform 1
return true
end
if row_number(rng2.start)>=row_number(rng1.start) && column_number(rng2.start)<=column_number(rng1.start) &&
row_number(rng2.stop)<=row_number(rng1.stop) && column_number(rng2.stop)>=column_number(rng1.stop)
-# println("cruciform 2")
+ # cruciform 2
return true
end
if in(rng1.start, rng2) || in(rng1.stop, rng2)
-# println("inside corner 1")
+ # inside corner 1
return true
end
if in(rng2.start, rng1) || in(rng2.stop, rng1)
-# println("inside corner 2")
+ # inside corner 2
return true
end
if issubset(rng1, rng2) || issubset(rng2, rng1)
-# println("subset")
+ # subset
return true
end
return false
@@ -428,10 +428,6 @@ const RGX_SINGLE_ROW = r"^[1-9][0-9]*$"
end
end
-@inline function combine_sheet_ref(s::Worksheet, ref::CellRef)
- return string(quoteit(s.name), "!", encode_column_number(ref.column_number), ref.row_number)
-end
-
function is_valid_column_range(r::AbstractString) :: Bool
if occursin(RGX_SINGLE_COLUMN, r)
return true
@@ -599,7 +595,6 @@ function nc_bounds(r::NonContiguousRange)::CellRange # Smallest rectangualar `Ce
return CellRange(CellRef(top, left), CellRef(bottom, right))
end
function Base.length(r::NonContiguousRange)::Int # Number of cells in `r`, eliminating duplicates.
-# s = 0
allcells= Vector{String}()
for rng in r.rng
if rng isa CellRef
@@ -610,12 +605,6 @@ function Base.length(r::NonContiguousRange)::Int # Number of cells in `r`, elimi
end
end
end
-# if rng isa CellRef
-# s += 1
-# else
-# s += length(rng)
-# end
-# end
return length(unique(allcells))
end
diff --git a/src/charts/Charts.jl b/src/charts/Charts.jl
index 16a9662c..5db3b01d 100644
--- a/src/charts/Charts.jl
+++ b/src/charts/Charts.jl
@@ -7,6 +7,18 @@ Reading, formatting and creating Excel charts, in both chart schemas: the `c:` s
`using XLSX.Charts` brings the chart functions into scope. The handle and value types are
public but not exported; refer to them as `XLSX.Charts.ChartSeries` and so on.
+
+Conventions shared by the functions:
+
+- `i` is a series' position, counting from 1 in [`getChartSeries`](@ref) order, and
+ `point` a data point's position in the series, also from 1.
+- `ax` is a `ChartAxis` from [`getChartAxes`](@ref) or [`getChartAxis`](@ref), and `g` a
+ `ChartGroup` from [`getChartGroups`](@ref). On a `ChartEx`, an axis is named by its `id`
+ from [`getChartAxisIds`](@ref).
+- Setters return the chart. Most take `:inherit` to remove a setting so it is inherited
+ from the chart style; each docstring says where it does.
+- Color values are listed at [`setSeriesFill`](@ref), line keywords at
+ [`setSeriesLine`](@ref), and text-property fields at [`setChartTitleTextProp`](@ref).
"""
module Charts
@@ -49,9 +61,9 @@ module Charts
# the exported API.
#
# Two senses of "resolve" are kept apart. `resolve_color_base` and
-# `apply_drawingml_transforms` resolve a colour to RGB. The `get`
+# `apply_drawingml_transforms` resolve a color to RGB. The `get`
# accessors that return an `Effective` resolve a property up the inheritance
-# cascade. A cascade-resolved fill may still hold a scheme colour, which is then
+# cascade. A cascade-resolved fill may still hold a scheme color, which is then
# a separate step.
# ---------------------------------------------------------------------------
@@ -66,7 +78,7 @@ using ..XLSX:
XLSXFile, Workbook, Worksheet, XLSXError, DataTable,
CellRef, CellRange, NonContiguousRange,
SheetCellRef, SheetCellRange, SheetColumnRange, SheetRowRange,
- XL_REF, CellErrorType,
+ XL_REF, CellErrorType, RichTextString, RichTextRun,
# package parts and relationships
REL_CHART, REL_CHARTEX, REL_CHARTSHEET, MIME_CHART, MIME_CHARTEX, MIME_CHARTSHEET,
parts_with_content_type, register_content_type!, add_part_rel!,
@@ -75,12 +87,12 @@ using ..XLSX:
# drawings
ensure_drawing!, _drawing_path_for_sheet, build_two_cell_anchor, _next_shape_id,
# XML helpers
- NS_A, NS_R, _attr, _pct, child_text, child_val, elements_with_tag,
+ NS_A, NS_R, NS_XDR, _attr, _pct, child_text, child_val, elements_with_tag,
first_element_with_tag, get_attr, get_prefixed_attr, has_localname, localname,
ns_prefixes, prefixed_tag, with_attribute, xml_root_element, truncate_len,
# workbook, sheets and cells
get_workbook, get_xlsxfile, get_xml_data, get_worksheet_internal_file,
- getsheet, getdata, is_chartsheet, isdate1904, date_to_excel_value, time_to_excel_value,
+ getsheet, getdata, getRichTextString, is_chartsheet, isdate1904, date_to_excel_value, time_to_excel_value,
# references and defined names
abscell, mkabs, quoteit, ref_chooser, row_number, column_number, _parse_cell_marker,
get_defined_name_value, is_defined_name_value_a_reference, is_workbook_defined_name,
@@ -93,8 +105,9 @@ using ..XLSX:
is_valid_fixed_sheet_column_range, is_valid_fixed_sheet_row_range,
# cell errors
ERROR_STRING_TO_CODE, get_error_string,
- # theme and colours (xlsx-colors.jl)
+ # theme and colors (xlsx-colors.jl)
get_color, get_colorant, _linear_to_srgb,
+ SchemeColor, SCHEME_TOKENS, COLOR_TRANSFORMS, SCHEME_ALIASES, canonical_token,
get_theme_color_map, get_theme_fonts, resolve_theme_font, apply_drawingml_transforms
export
@@ -111,7 +124,8 @@ export
getPlotAreaShapeProps, getChartSpaceShapeProps, getChartSpaceTextProps,
getChartLegend, getLegendPos, getLegendOverlay, getLegendShapeProps, getLegendTextProps,
setChartTitleText, setChartTitleTextProp, setChartSpaceTextProp, setLegendTextProp,
- setLegendEntryDeleted, getLegendEntryDeleted, setPlotAreaLine,
+ setLegendEntryDeleted, getLegendEntryDeleted, setLegendPos, setLegendOverlay,
+ setPlotAreaLine,
# Series
getSeriesName, getSeriesNameRange, getSeriesData, getSeriesHidden,
getSeriesGroup, getSeriesAxes, getSeriesAxisIds, getSeriesLayout,
@@ -130,7 +144,8 @@ export
getGroupSeriesLines, getGroupUpDownBars, getUpBarShapeProps, getDownBarShapeProps,
setGroupLabelTextProp, getGroupGapWidth, setGroupGapWidth, getGroupOverlap, setGroupOverlap,
# Axes
- getAxisShapeProps, getAxisTextProps, getAxisTitleText, getAxisTitleRef,
+ getAxisShapeProps, getAxisTextProps, getAxisTitle, getAxisTitleText, getAxisTitleRef,
+ getAxisTitleRange,
getAxisTitleTextProps, getAxisGridlines, getAxisPartner,
getAxisNumberFormatCode, getAxisNumberFormatLinked,
getAxisMajorTickMark, getAxisMinorTickMark, getAxisTickLabelPos, getAxisOrientation,
@@ -140,7 +155,6 @@ export
setAxisTitleText, setAxisTitleTextProp, setAxisTickLabelPos,
setAxisScaling, setAxisMin, setAxisMax, setAxisOrientation,
setAxisCrosses, setAxisCrossesAt, setAxisNumberFormatCode, setAxisNumberFormatLinked,
- setAxisTitleText, getGapWidth, setGapWidth, getOverlap, setOverlap,
setAxisMajorTickMark, setAxisMinorTickMark, setAxisGridlines, setAxisTextProp, setAxisLine,
setAxisMajorUnit, setAxisMinorUnit,
# Data points, markers and labels
diff --git a/src/charts/chartexprops.jl b/src/charts/chartexprops.jl
index 65123e70..b75cc963 100644
--- a/src/charts/chartexprops.jl
+++ b/src/charts/chartexprops.jl
@@ -60,15 +60,13 @@ end
# cx:tx holds typed text or a cell binding. Excel writes both as cx:txData:
# a bound one has cx:f and a cached cx:v, a typed one only cx:v. cx:rich is
-# allowed by the schema and read if present.
+# allowed by the schema and read if present. Empty text is no text, as for a c:
+# title: a title bound to an empty cell caches an empty cx:v and reads as nothing.
function _cx_tx_text(tx)::Union{Nothing,String}
isnothing(tx) && return nothing
rich = first_element_with_tag(tx, "rich")
- if !isnothing(rich)
- t = text_content(rich)
- return isempty(t) ? nothing : t
- end
- return child_text(first_element_with_tag(tx, "txData"), "v")
+ t = isnothing(rich) ? child_text(first_element_with_tag(tx, "txData"), "v") : text_content(rich)
+ return isnothing(t) || isempty(t) ? nothing : t
end
function _cx_tx_range(xf::XLSXFile, tx)::ChartRange
@@ -191,11 +189,26 @@ getSeriesNameRange(c::ChartEx, i::Integer; pareto::Bool = false) =
"""
getChartTitleRange(c::ChartEx) -> ChartRange
+ getChartTitleRange(c::Chart) -> ChartRange
-The cell the chart title is bound to. `nothing` if the title is typed or absent.
+The cell the chart title is bound to, resolved — through a defined name where
+the formula is one. `nothing` if the title is typed or absent. See
+[`getChartTitleRef`](@ref) for the formula as written.
"""
getChartTitleRange(c::ChartEx) = _cx_tx_range(c.package, _cx_title_tx(c))
+getChartTitleRef(c::ChartEx) = _cx_tx_ref(_cx_title_tx(c))
+
+# The cx:f text of a cx:tx, as written; `nothing` for typed text.
+_cx_tx_ref(tx) = isnothing(tx) ? nothing : child_text(first_element_with_tag(tx, "txData"), "f")
+
+_cx_axis_title_tx(c::ChartEx, id::Integer) =
+ first_element_with_tag(first_element_with_tag(_cx_axis_node(c, id), "title"), "tx")
+
+getAxisTitle(c::ChartEx, id::Integer) = _cx_tx_text(_cx_axis_title_tx(c, id))
+getAxisTitleRef(c::ChartEx, id::Integer) = _cx_tx_ref(_cx_axis_title_tx(c, id))
+getAxisTitleRange(c::ChartEx, id::Integer) = _cx_tx_range(c.package, _cx_axis_title_tx(c, id))
+
getChartTitleTextProps(c::ChartEx) =
parse_drawing_text(_wb(c), first_element_with_tag(_cx_chart(c), "title"))
@@ -454,11 +467,9 @@ _cx_datapt_in(ser, point) =
"""
setSeriesFill(c::ChartEx, i, color; point=nothing, pareto=false) -> ChartEx
-Set the fill of series `i`, or of one data point of it. `color` may be a colour
-string or Symbol, a `Colors.Colorant`, a [`SchemeColor`](@ref), `:none` for an
-explicit ``, or `:inherit` to remove the fill so the chart style
-applies. With `pareto = true`, series `i`'s Pareto line instead; an error if it
-has none.
+Set the fill of series `i`, or of data point `point` of it, counting from 1.
+$(_DOC_COLOR_ARG) With `pareto = true`, series `i`'s Pareto line instead; an
+error if it has none.
Setting a point's fill creates its `cx:dataPt` if Excel never formatted that
point. `:inherit` never creates anything.
@@ -492,10 +503,10 @@ _cx_set_line!(c::ChartEx, i::Integer, point, f; create::Bool = true, pareto::Boo
setSeriesLine(c::ChartEx, i, :none; point=nothing, pareto=false)
setSeriesLine(c::ChartEx, i, :inherit; point=nothing, pareto=false)
-Set several outline properties of series `i`, or of one data point of it, in one
-rebuild of the chart part. A keyword left unspecified is left alone; pass
-`:inherit` to remove one that is set. With `pareto = true`, series `i`'s Pareto
-line instead; an error if it has none.
+Set several outline properties of series `i`, or of data point `point` of it,
+in one rebuild of the chart part. $(_DOC_LINE_KWS) A keyword left unspecified
+is left alone; pass `:inherit` to remove one that is set. With `pareto = true`,
+series `i`'s Pareto line instead; an error if it has none.
The symbol form acts on the whole outline: `:none` writes an `a:ln` whose fill is
``, and `:inherit` removes the `a:ln` so the chart style supplies it.
@@ -523,35 +534,78 @@ end
"""
setSeriesLineColor(c::ChartEx, i, color; point=nothing, pareto=false) -> ChartEx
-Set the colour of series `i`'s outline, or of one data point of it. `color` takes
-the same values as [`setSeriesFill`](@ref): `:none` writes an outline with
-``, `:inherit` removes the colour and leaves the rest of the `a:ln`.
-With `pareto = true`, series `i`'s Pareto line instead; an error if it has none.
+Set the color of series `i`'s outline, or of data point `point` of it.
+$(_DOC_COLOR_ARG) `:none` writes an outline with ``; `:inherit`
+removes the color and leaves the rest of the `a:ln`. With `pareto = true`,
+series `i`'s Pareto line instead; an error if it has none.
"""
setSeriesLineColor(c::ChartEx, i::Integer, color; point::Union{Nothing,Integer} = nothing,
pareto::Bool = false) =
_cx_set_line!(c, i, point, (ln, pfx) -> _ln_with_color(ln, color, pfx); pareto)
+"""
+ setSeriesLineWidth(c::ChartEx, i, points; point=nothing, pareto=false) -> ChartEx
+
+Set the width of series `i`'s outline, or of data point `point`'s, in points —
+a `Real` from 0 to 1584 — or `:inherit` to remove it. With `pareto = true`,
+series `i`'s Pareto line instead.
+"""
setSeriesLineWidth(c::ChartEx, i::Integer, pts; point::Union{Nothing,Integer} = nothing,
pareto::Bool = false) =
_cx_set_line!(c, i, point, (ln, _) -> _ln_with_width(ln, pts); pareto)
+"""
+ setSeriesLineDash(c::ChartEx, i, dash; point=nothing, pareto=false) -> ChartEx
+
+Set the dash pattern of series `i`'s outline, or of data point `point`'s:
+$(_doc_syms(DASH_VALUES)), or Excel's names $(_doc_aliases(DASH_ALIASES)); or
+`:inherit` to remove it. With `pareto = true`, series `i`'s Pareto line instead.
+"""
setSeriesLineDash(c::ChartEx, i::Integer, dash; point::Union{Nothing,Integer} = nothing,
pareto::Bool = false) =
_cx_set_line!(c, i, point, (ln, pfx) -> _ln_with_dash(ln, dash, pfx); pareto)
+"""
+ setSeriesLineCap(c::ChartEx, i, cap; point=nothing, pareto=false) -> ChartEx
+
+Set how series `i`'s outline ends, or data point `point`'s:
+$(_doc_syms(CAP_VALUES)), or Excel's names $(_doc_aliases(CAP_ALIASES)); or
+`:inherit` to remove it. With `pareto = true`, series `i`'s Pareto line instead.
+"""
setSeriesLineCap(c::ChartEx, i::Integer, cap; point::Union{Nothing,Integer} = nothing,
pareto::Bool = false) =
_cx_set_line!(c, i, point, (ln, _) -> _ln_with_cap(ln, cap); pareto)
+"""
+ setSeriesLineCompound(c::ChartEx, i, cmpd; point=nothing, pareto=false) -> ChartEx
+
+Set the compound line type of series `i`'s outline, or data point `point`'s:
+$(_doc_syms(CMPD_VALUES)), or Excel's names $(_doc_aliases(CMPD_ALIASES)); or
+`:inherit` to remove it. With `pareto = true`, series `i`'s Pareto line instead.
+"""
setSeriesLineCompound(c::ChartEx, i::Integer, cmpd; point::Union{Nothing,Integer} = nothing,
pareto::Bool = false) =
_cx_set_line!(c, i, point, (ln, _) -> _ln_with_compound(ln, cmpd); pareto)
+"""
+ setSeriesLineJoin(c::ChartEx, i, join; point=nothing, pareto=false) -> ChartEx
+
+Set how the segments of series `i`'s outline meet, or data point `point`'s:
+$(_doc_syms(JOIN_VALUES)); or `:inherit` to remove it. Changing the join discards
+any miter limit. With `pareto = true`, series `i`'s Pareto line instead.
+"""
setSeriesLineJoin(c::ChartEx, i::Integer, join; point::Union{Nothing,Integer} = nothing,
pareto::Bool = false) =
_cx_set_line!(c, i, point, (ln, pfx) -> _ln_with_join(ln, join, pfx); pareto)
+"""
+ setSeriesLineMiterLimit(c::ChartEx, i, limit; point=nothing, pareto=false) -> ChartEx
+
+Set the miter limit of series `i`'s outline, or data point `point`'s, as a
+`Real` multiple of the line width; or `:inherit` to remove it. The outline must
+already have a miter join ([`setSeriesLineJoin`](@ref)). With `pareto = true`,
+series `i`'s Pareto line instead.
+"""
setSeriesLineMiterLimit(c::ChartEx, i::Integer, lim; point::Union{Nothing,Integer} = nothing,
pareto::Bool = false) =
_cx_set_line!(c, i, point, (ln, _) -> _ln_with_miter_limit(ln, lim); pareto)
@@ -572,10 +626,10 @@ end
setLabelTextProp(c::ChartEx, i, field, value; pareto=false) -> ChartEx
Set one field of the data-label text formatting for series `i`, writing it on the
-series' `cx:dataLabels/cx:txPr`. `field` is a field of `DrawingRunProps`; `:fill`
-and `:line` are composite and take a colour or a named tuple of line properties.
-Pass `:inherit` to remove a field so the cascade resolves it. With
-`pareto = true`, series `i`'s Pareto line instead; an error if it has none.
+series' `cx:dataLabels/cx:txPr`. With `pareto = true`, series `i`'s Pareto line
+instead; an error if it has none.
+
+$(_DOC_TEXT_FIELD_ARG)
Creating a `cx:dataLabels` to hold formatting writes `cx:visibility` with all
three flags off, since one that names no flags shows labels with Excel's
@@ -584,9 +638,10 @@ defaults rather than none.
Returns `c`, which remains valid.
"""
function setLabelTextProp(c::ChartEx, i::Integer, field::Symbol, value; pareto::Bool = false)
- field in _RUN_PROP_FIELDS || throw(XLSXError(
- "`$field` is not a resolvable text property. Valid fields: " *
- join(_RUN_PROP_FIELDS, ", ") * "."))
+ get(_RUN_PROP_ALIASES, field, field) in _RUN_PROP_FIELDS || throw(XLSXError(
+ "`$field` is not a text property. Valid fields: " *
+ join(_RUN_PROP_FIELDS, ", ") * "; or Excel's names " *
+ join(("$k (= $v)" for (k, v) in _RUN_PROP_ALIASES), ", ") * "."))
return _cx_edit_series!(c, i, (ser, pfx) ->
rebuild_path(ser, [(NS_CX, "dataLabels") => "dataLabels"],
lbl -> _cx_label_transform(lbl, field, value, pfx);
@@ -623,6 +678,8 @@ end
Set one field of the chart title's text formatting, on `cx:title/cx:txPr`.
Creates the title if the chart has none. Returns `c`, which remains valid.
+
+$(_DOC_TEXT_FIELD_ARG)
"""
setChartTitleTextProp(c::ChartEx, field::Symbol, value) =
_cx_edit_at!(c, [(NS_CX, "chart") => "chart",
@@ -634,12 +691,51 @@ setChartTitleTextProp(c::ChartEx, field::Symbol, value) =
Set one field of the legend's text formatting, on `cx:legend/cx:txPr`.
Creates the legend if the chart has none. Returns `c`, which remains valid.
+
+$(_DOC_TEXT_FIELD_ARG)
"""
setLegendTextProp(c::ChartEx, field::Symbol, value) =
_cx_edit_at!(c, [(NS_CX, "chart") => "chart",
(NS_CX, "legend") => "legend"],
(lg, pfx) -> _txpr_with_run_prop(lg, (NS_CX, "legend"), field, value, pfx; ns = NS_CX))
+# cx:legend positions by `pos` and `align`. Top-right is the right side,
+# aligned to its start, as Excel writes it.
+const _CX_LEGEND_POS = Dict("b" => ("b", "ctr"), "t" => ("t", "ctr"), "l" => ("l", "ctr"),
+ "r" => ("r", "ctr"), "tr" => ("r", "min"))
+
+_cx_legend(c::ChartEx) = first_element_with_tag(_cx_chart(c), "legend")
+
+function getLegendPos(c::ChartEx)
+ lg = _cx_legend(c)
+ pos = _cx_attr(lg, "pos")
+ isnothing(pos) && return nothing
+ return pos == "r" && _cx_attr(lg, "align") == "min" ? :tr : Symbol(pos)
+end
+
+getLegendOverlay(c::ChartEx) = _cx_bool(_cx_legend(c), "overlay")
+
+_cx_with_overlay(lg, o) = with_attribute(lg, "overlay", o === :inherit ? nothing : o)
+
+function setLegendPos(c::ChartEx, pos::Symbol; overlay = nothing)
+ p = _legend_pos(pos)
+ o = isnothing(overlay) ? nothing : _legend_overlay(overlay)
+ pa, al = p === :inherit ? (nothing, nothing) : _CX_LEGEND_POS[p]
+ return _cx_edit_at!(c, [(NS_CX, "chart") => "chart",
+ (NS_CX, "legend") => "legend"],
+ (lg, pfx) -> begin
+ lg = with_attribute(with_attribute(lg, "pos", pa), "align", al)
+ isnothing(o) ? lg : _cx_with_overlay(lg, o)
+ end)
+end
+
+function setLegendOverlay(c::ChartEx, overlay::Union{Bool,Symbol})
+ o = _legend_overlay(overlay)
+ return _cx_edit_at!(c, [(NS_CX, "chart") => "chart",
+ (NS_CX, "legend") => "legend"],
+ (lg, pfx) -> _cx_with_overlay(lg, o))
+end
+
"""
getChartAxisIds(c::ChartEx) -> Vector{Int}
@@ -667,6 +763,8 @@ Set one field of an axis title's text formatting, on `cx:axis/cx:title/cx:txPr`.
`id` is the axis' `id` attribute, as [`getChartAxisIds`](@ref) reports it, not a
position. Creates the title if the axis has none. Returns `c`, which remains
valid.
+
+$(_DOC_TEXT_FIELD_ARG)
"""
setAxisTitleTextProp(c::ChartEx, id::Integer, field::Symbol, value) =
_cx_edit_at!(c, [_cx_axis_path(c, id)...; (NS_CX, "title") => "title"],
@@ -699,50 +797,141 @@ function _cx_tx_typed(tx::XML.Node, text::AbstractString, pfx::Dict{String,Strin
end
# Excel writes a title's text twice: in cx:txData/cx:v and as runs in the sibling
-# cx:txPr, which is what it renders. Collapse the body to a single run carrying
-# `text`, keeping the first run's formatting where there was one, so the words
-# change and the appearance does not.
+# cx:txPr, which is what it renders. Rewrite the body's runs, one paragraph per
+# line, each copying the first paragraph's pPr and endParaRPr. Every run starts
+# from the old first run's a:rPr, so the words change and the appearance does
+# not; a RichTextString's runs then lay their own formatting over it. Plain text
+# on a title with no cx:txPr leaves it without one; rich text creates it.
function _cx_txpr_retext(el::XML.Node, text::AbstractString, pfx::Dict{String,String})
tx = first_element_with_tag(el, "txPr")
- isnothing(tx) && return el
- isnothing(tx.children) && return el
- kids = map(tx.children) do p
- localname(p) != "p" && return p
- old = first_element_with_tag(p, "r")
- rpr = isnothing(old) ? nothing : first_element_with_tag(old, "rPr")
- run = XML.Element(prefixed_tag(pfx[NS_A], "r"),
- filter(!isnothing, [rpr,
- XML.Element(prefixed_tag(pfx[NS_A], "t"), XML.Text(text))])...)
- keep = filter(n -> localname(n) in ("pPr", "endParaRPr"),
- isnothing(p.children) ? XML.Node[] : p.children)
- ppr = filter(n -> localname(n) == "pPr", keep)
- epr = filter(n -> localname(n) == "endParaRPr", keep)
- return _with_children(p, [ppr; run; epr])
+ if isnothing(tx)
+ text isa RichTextString || return el
+ tx = _new_text_body("txPr", pfx, NS_CX)
+ end
+ kids = isnothing(tx.children) ? XML.Node[] : collect(XML.Node, tx.children)
+ i = findfirst(k -> localname(k) == "p", kids)
+ p = isnothing(i) ? XML.Element(prefixed_tag(pfx[NS_A], "p")) : kids[i]
+ pk = isnothing(p.children) ? XML.Node[] : p.children
+ ppr = filter(n -> localname(n) == "pPr", pk)
+ epr = filter(n -> localname(n) == "endParaRPr", pk)
+ old = first_element_with_tag(p, "r")
+ base = isnothing(old) ? nothing : first_element_with_tag(old, "rPr")
+ paras = map(_cx_lines(text)) do line
+ runs = XML.Node[_cx_run(piece, props, base, pfx) for (piece, props) in line]
+ _with_children(p, XML.Node[ppr; runs; epr])
+ end
+ body = XML.Node[k for k in kids if localname(k) != "p"]
+ tx = _with_children(tx, vcat(body, paras))
+ return insert_child(el, (NS_CX, String(localname(el))), tx)
+end
+
+# `text` as lines of (piece, props) runs. A plain string's runs carry no props.
+_cx_lines(text::AbstractString) =
+ [isempty(l) ? Tuple{String,Any}[] : Tuple{String,Any}[(String(l), nothing)]
+ for l in split(text, _NEWLINE)]
+_cx_lines(text::RichTextString) =
+ [Tuple{String,Any}[(r.text, r.props) for r in p.runs] for p in _as_paragraphs(text)]
+
+# One a:r: `base` (the old first run's a:rPr, or none) with `props` laid over it.
+function _cx_run(piece::AbstractString, props, base, pfx::Dict{String,String})
+ rpr = base
+ for (field, value) in _run_prop_pairs(props)
+ rpr = _rpr_with_prop(something(rpr, XML.Element(prefixed_tag(pfx[NS_A], "rPr"))),
+ field, value, pfx)
end
- return replace_child(el, tx, _with_children(tx, kids))
+ t = XML.Element(prefixed_tag(pfx[NS_A], "t"), XML.Text(piece))
+ return XML.Element(prefixed_tag(pfx[NS_A], "r"), (isnothing(rpr) ? (t,) : (rpr, t))...)
+end
+
+# A typed cx: title or axis title: cx:txData/cx:v holds the plain text, and
+# cx:txPr the runs Excel renders.
+function _cx_title_with_text(t::XML.Node, text::AbstractString, pfx)
+ t = rebuild_path(t, [(NS_CX, "tx") => "tx"],
+ tx -> _cx_tx_typed(tx, String(text), pfx);
+ prefixes = pfx, parent_key = (NS_CX, "title"))
+ return _cx_txpr_retext(t, text, pfx)
+end
+
+# A cx: title bound to a cell: cx:txData with the reference and its cached text,
+# as Excel writes it. Excel writes no cx:txPr on a bound title; one already there
+# loses its runs, so stale typed text cannot render, and keeps their formatting
+# in the paragraph default, with whatever a rich-text cell's runs all share
+# laid over it — as for a c: title.
+function _cx_title_with_ref(t::XML.Node, f::AbstractString, vals::Vector, cellrich,
+ wb::Workbook, pfx)
+ v = only(vals)
+ txt = ismissing(v) ? "" : string(v)
+ td = XML.Element(prefixed_tag(pfx[NS_CX], "txData"),
+ XML.Element(prefixed_tag(pfx[NS_CX], "f"), XML.Text(f)),
+ XML.Element(prefixed_tag(pfx[NS_CX], "v"), XML.Text(txt)))
+ t = rebuild_path(t, [(NS_CX, "tx") => "tx"], tx -> _with_children(tx, [td]);
+ prefixes = pfx, parent_key = (NS_CX, "title"))
+ key = (NS_CX, "title")
+ txpr = first_element_with_tag(t, "txPr")
+ isnothing(first_element_with_tag(txpr, "p")) ||
+ (t = insert_child(t, key, _txpr_from_rich(txpr, nothing, wb, pfx; ns = NS_CX)))
+ for (field, value) in _common_run_props(cellrich)
+ t = _txpr_with_run_prop(t, key, field, value, pfx; ns = NS_CX)
+ end
+ return t
end
"""
- setChartTitleText(c::ChartEx, text::AbstractString) -> ChartEx
+ setChartTitleText(c::ChartEx, text) -> ChartEx
+ setChartTitleText(c::ChartEx, ref::Union{CellRef,SheetCellRef}) -> ChartEx
Set the chart title to `text`, typed rather than bound to a cell. Creates the
-title if the chart has none, and drops any `cx:f` binding it had.
+title if the chart has none, and drops any `cx:f` binding it had. A newline
+starts a new paragraph.
Excel stores a typed title both as `cx:tx/cx:txData/cx:v` and as runs in the
title's `cx:txPr`, and renders the latter, so both are updated. Run formatting
-is preserved; use [`setChartTitleTextProp`](@ref) to change it.
+is preserved; use [`setChartTitleTextProp`](@ref) to change it. A
+`RichTextString`, or a styled string from StyledStrings.jl (Julia 1.11 and
+later), lays each run's own formatting over it.
+
+Given a `CellRef` or `SheetCellRef`, the title is bound to that cell
+(`cx:txData` with `cx:f` and the cell's text cached in `cx:v`). A bare `CellRef`
+means the chart's own sheet; a chart on a chartsheet needs a `SheetCellRef`. A
+string is always typed text, never read as a reference.
+
+A bound title can't carry rich text, a limit of Excel's file format. Binding
+keeps the title's formatting in `cx:txPr` — its first run's, folded into the
+paragraph default — and, where the cell holds a `RichTextString`, lays over it
+the formatting all of its runs share.
Returns `c`, which remains valid.
"""
setChartTitleText(c::ChartEx, text::AbstractString) =
_cx_edit_at!(c, [(NS_CX, "chart") => "chart",
(NS_CX, "title") => "title"],
- (t, pfx) -> begin
- t = rebuild_path(t, [(NS_CX, "tx") => "tx"],
- tx -> _cx_tx_typed(tx, text, pfx);
- prefixes = pfx, parent_key = (NS_CX, "title"))
- return _cx_txpr_retext(t, text, pfx)
- end)
+ (t, pfx) -> _cx_title_with_text(t, text, pfx))
+
+function setChartTitleText(c::ChartEx, ref::Union{CellRef,SheetCellRef})
+ f, vals, cellrich = _title_source(c, ref)
+ return _cx_edit_at!(c, [(NS_CX, "chart") => "chart",
+ (NS_CX, "title") => "title"],
+ (t, pfx) -> _cx_title_with_ref(t, f, vals, cellrich, _wb(c), pfx))
+end
+
+"""
+ setAxisTitleText(c::ChartEx, id::Integer, text) -> ChartEx
+ setAxisTitleText(c::ChartEx, id::Integer, ref::Union{CellRef,SheetCellRef}) -> ChartEx
+
+Set an axis title's text, or bind it to a cell, as [`setChartTitleText`](@ref)
+does for the chart title. `id` is the axis' `id` attribute, as
+[`getChartAxisIds`](@ref) reports it, not a position. Creates the title if the
+axis has none. Returns `c`, which remains valid.
+"""
+setAxisTitleText(c::ChartEx, id::Integer, text::AbstractString) =
+ _cx_edit_at!(c, [_cx_axis_path(c, id)...; (NS_CX, "title") => "title"],
+ (t, pfx) -> _cx_title_with_text(t, text, pfx))
+
+function setAxisTitleText(c::ChartEx, id::Integer, ref::Union{CellRef,SheetCellRef})
+ f, vals, cellrich = _title_source(c, ref)
+ return _cx_edit_at!(c, [_cx_axis_path(c, id)...; (NS_CX, "title") => "title"],
+ (t, pfx) -> _cx_title_with_ref(t, f, vals, cellrich, _wb(c), pfx))
+end
"""
setSeriesName(c::ChartEx, i, name::AbstractString; pareto=false) -> ChartEx
@@ -898,9 +1087,14 @@ setSeriesAggregation(c::ChartEx, i::Integer, on::Bool) =
Set histogram binning for series `i` in one rebuild. A keyword left unspecified
is left alone; pass `:inherit` to remove one that is set.
-`intervalClosed` is `:r` (right-closed) or `:l` (left-closed). `underflow` and `overflow` take a number or
-`:auto`. `binSize` and `binCount` are alternatives: setting one removes the
-other. `cx:binning` excludes `cx:aggregation`, which is removed if present.
+- `intervalClosed` — `:r` (right-closed, Excel's default) or `:l` (left-closed).
+- `underflow`, `overflow` — a `Real`: values below or above it are gathered in
+ one end bin. `:auto` lets Excel choose.
+- `binSize` — the width of each bin, a positive `Real`; or `:auto`.
+- `binCount` — the number of bins, a positive `Integer`; or `:auto`.
+
+`binSize` and `binCount` are alternatives: setting one removes the other.
+`cx:binning` excludes `cx:aggregation`, which is removed if present.
"""
function setSeriesBinning(c::ChartEx, i::Integer;
intervalClosed = nothing, underflow = nothing, overflow = nothing,
diff --git a/src/charts/chartprops.jl b/src/charts/chartprops.jl
index 668e158c..dac5637d 100644
--- a/src/charts/chartprops.jl
+++ b/src/charts/chartprops.jl
@@ -1,7 +1,7 @@
# Appearance accessors for `c:` charts.
#
# This file sits above `drawingml.jl` in the layering: that file parses
-# DrawingML — `spPr`, `txPr`, colours, fills, lines, text — for anything that
+# DrawingML — `spPr`, `txPr`, colors, fills, lines, text — for anything that
# carries it, knowing nothing about charts. This file knows the chart schema and
# finds the nodes: which element holds a series' fill, which axes a group plots
# against, where a per-point override lives. Nothing here parses DrawingML
@@ -170,9 +170,12 @@ getAxisTextProps(c::Chart, ax::ChartAxis) = parse_drawing_text(_wb(c), _axnode(
"""
getAxisTitleRef(c::Chart, ax::ChartAxis) -> Union{Nothing,String}
+ getAxisTitleRef(c::ChartEx, id::Integer) -> Union{Nothing,String}
-The formula behind an axis title that references a cell
-(`c:title/c:tx/c:strRef/c:f`). `nothing` for a literal or absent title.
+The formula behind an axis title that references a cell, as written
+(`c:title/c:tx/c:strRef/c:f`, or `cx:txData/cx:f`). `nothing` for a literal or
+absent title. Excel writes a `cx:` formula as a hidden `_xlchart.*` defined
+name; [`getAxisTitleRange`](@ref) resolves it.
"""
function getAxisTitleRef(c::Chart, ax::ChartAxis)
tx = first_element_with_tag(first_element_with_tag(_axnode(c, ax), "title"), "tx")
@@ -180,6 +183,29 @@ function getAxisTitleRef(c::Chart, ax::ChartAxis)
return isnothing(sr) ? nothing : child_text(sr, "f")
end
+"""
+ getAxisTitleRange(c::Chart, ax::ChartAxis) -> ChartRange
+ getAxisTitleRange(c::ChartEx, id::Integer) -> ChartRange
+
+The cell an axis title is bound to, resolved — through a defined name where the
+formula is one. `nothing` for a typed or absent title. See
+[`getAxisTitleRef`](@ref) for the formula as written.
+"""
+getAxisTitleRange(c::Chart, ax::ChartAxis) = _title_range(c.package, getAxisTitleRef(c, ax))
+
+# The resolved range of a title's formula, as getChartTitleRange(::ChartEx) does.
+_title_range(xf::XLSXFile, f) = isnothing(f) ? nothing : resolve_chartex_ref(xf, f)
+
+"""
+ getAxisTitle(c::Chart, ax::ChartAxis) -> Union{Nothing,String}
+ getAxisTitle(c::ChartEx, id::Integer) -> Union{Nothing,String}
+
+An axis title's text as plain text, as [`getChartTitle`](@ref) gives the chart
+title's: the typed text, or for a title bound to a cell, the text cached when
+the file was saved. `nothing` where the axis has no title text.
+"""
+getAxisTitle(c::Chart, ax::ChartAxis) = parse_chart_title(_axnode(c, ax))
+
"""
getAxisTitleText(c::Chart, ax::ChartAxis) -> Union{Nothing,DrawingText}
@@ -192,6 +218,18 @@ function getAxisTitleText(c::Chart, ax::ChartAxis)
return parse_drawing_text(_wb(c), tx; tag="rich")
end
+"""
+ getAxisTitleTextProps(c::Chart, ax::ChartAxis) -> Union{Nothing,DrawingText}
+
+Text body for the axis title (`c:title/c:txPr`) — its formatting, which applies
+whether the title is typed or bound to a cell, and is the only formatting a
+bound title has. `nothing` means the axis has no title, or the title has no
+`c:txPr`. Contrast [`getAxisTitleText`](@ref), the literal text in
+`c:tx/c:rich`, and [`getAxisTextProps`](@ref), the tick labels' formatting.
+"""
+getAxisTitleTextProps(c::Chart, ax::ChartAxis) =
+ parse_drawing_text(_wb(c), first_element_with_tag(_axnode(c, ax), "title"))
+
# An element whose presence turns a feature on, carrying an optional `c:spPr`.
#
# These are the three-state cases: absent means the feature is off, present with
@@ -406,10 +444,11 @@ getChartLegend(c::Chart) =
"""
getLegendPos(c::Chart) -> Union{Nothing,Symbol}
+ getLegendPos(c::ChartEx) -> Union{Nothing,Symbol}
`:b` (bottom), `:t` (top), `:l` (left), `:r` (right) or `:tr` (top-right) from
-`c:legendPos`. `nothing` means no legend,
-or a legend with no explicit position.
+`c:legendPos`, or from `cx:legend`'s `pos` and `align`. `nothing` means no legend,
+or a legend with no explicit position. Set with [`setLegendPos`](@ref).
"""
function getLegendPos(c::Chart)
v = _attr(first_element_with_tag(getChartLegend(c), "legendPos"), "val")
@@ -418,8 +457,9 @@ end
"""
getLegendOverlay(c::Chart) -> Union{Nothing,Bool}
+ getLegendOverlay(c::ChartEx) -> Union{Nothing,Bool}
-`c:overlay` — `true` where the legend is drawn on top of the plot area rather
+`c:overlay`, or `cx:legend`'s `overlay` attribute — `true` where the legend is drawn on top of the plot area rather
than beside it. `nothing` means the chart has no legend, or the element was not
written, in which case Excel treats it as `false`.
"""
@@ -460,9 +500,12 @@ getChartTitleText(c::Chart) =
"""
getChartTitleRef(c::Chart) -> Union{Nothing,String}
+ getChartTitleRef(c::ChartEx) -> Union{Nothing,String}
-The formula behind a title that references a cell (`c:title/c:tx/c:strRef/c:f`).
-`nothing` for a literal or absent title.
+The formula behind a title that references a cell, as written
+(`c:title/c:tx/c:strRef/c:f`, or `cx:txData/cx:f`). `nothing` for a literal or
+absent title. Excel writes a `cx:` formula as a hidden `_xlchart.*` defined
+name; [`getChartTitleRange`](@ref) resolves it.
"""
function getChartTitleRef(c::Chart)
tx = first_element_with_tag(getChartTitleNode(c), "tx")
@@ -470,6 +513,8 @@ function getChartTitleRef(c::Chart)
return isnothing(sr) ? nothing : child_text(sr, "f")
end
+getChartTitleRange(c::Chart) = _title_range(c.package, getChartTitleRef(c))
+
"""
getChartTitleShapeProps(c::Chart) -> Union{Nothing,DrawingShapeProps}
@@ -1076,6 +1121,14 @@ const _RUN_PROP_ALIASES = Dict(
:strikethrough => :strike,
)
+# The `field` argument of every text-property setter. The other docstring
+# fragments are in drawingml.jl.
+const _DOC_TEXT_FIELD_ARG = """
+`field` is one of $(_doc_syms(_RUN_PROP_FIELDS)), or Excel's names
+$(_doc_aliases(_RUN_PROP_ALIASES)). `value` depends on the field — see the
+table at [`setChartTitleTextProp`](@ref) — and `:inherit` removes the field so
+it is inherited."""
+
"""
_walk_fill(wb, chain) -> Effective{DrawingFill}
@@ -1181,7 +1234,9 @@ end
Resolve one field of the data-label text formatting for series `i`, walking
`c:txPr` from the series up through the group, plot area and chart space.
-`field` is a field of `DrawingRunProps` other than `raw`. Text inherits field by
+`field` is one of $(_doc_syms(_RUN_PROP_FIELDS)) — the DrawingML names in the
+table at [`setChartTitleTextProp`](@ref); unlike the setters, this does not take
+Excel's names. Text inherits field by
field — a size from one rung and a weight from another both apply — which is
why this resolves a named field rather than a whole `DrawingRunProps`.
@@ -1388,9 +1443,23 @@ end
"""
setSeriesFill(c, i, color) -> Chart
-Set the fill of series `i`'s graphic. `color` may be a color string or Symbol,
-a `Colors.Colorant`, a [`SchemeColor`](@ref), `:none` for an explicit
-``, or `:inherit` to remove it so the chart style applies.
+Set the fill of series `i`'s graphic — its bars, area or pie slices.
+
+# Color values
+
+Every chart setter that takes a color — fills, lines, markers, the text
+`:fill` field and `addSeries`' `color` keyword — accepts the same values:
+
+- a color name from Colors.jl, as a String or Symbol: `"red"`, `:steelblue`;
+- a hex string, `"RRGGBB"` or `"AARRGGBB"`, with or without a leading `#`:
+ `"FF0000"`, `"#FF0000"`, `"80FF0000"` (50% transparent red);
+- a `Colors.Colorant`, such as `colorant"red"` or `RGB(1, 0, 0)`;
+- a theme color, named as a String or Symbol: $(_doc_syms(SCHEME_TOKENS)).
+ It follows the workbook's theme, as Excel's palette colors do;
+- a [`SchemeColor`](@ref), for a theme color with a tint or shade applied:
+ `SchemeColor(:accent1; lumMod = 75)`;
+- `:none`, for an explicit `` — deliberately invisible;
+- `:inherit`, to remove the fill so the chart style applies.
`:none` and `:inherit` are different states. An absent fill inherits; `noFill`
does not.
@@ -1424,9 +1493,8 @@ _set_series_line(c::Chart, i::Integer, f) =
"""
setSeriesLineColor(c, i, color) -> Chart
-Set the color of series `i`'s outline. Takes the same values as
-[`setSeriesFill`](@ref): a color, a [`SchemeColor`](@ref), `:none` for an
-explicit ``, or `:inherit` to remove it.
+Set the color of series `i`'s outline — the line itself, on a line or scatter
+chart. $(_DOC_COLOR_ARG)
Note `:none` here leaves the `a:ln` in place with no fill — the line exists and
draws nothing, which is what Excel writes and is distinct from
@@ -1439,7 +1507,8 @@ setSeriesLineColor(c::Chart, i::Integer, color) =
setSeriesLineWidth(c, i, points) -> Chart
Set the width of series `i`'s outline, in points — the unit Excel's width box
-uses. `:inherit` removes the width so the chart style supplies it.
+uses — as a `Real` from 0 to 1584. `:inherit` removes the width so the chart
+style supplies it.
DrawingML caps a line width at 1584 pt; a wider one is rejected rather than
written.
@@ -1454,10 +1523,14 @@ setSeriesLineWidth(c::Chart, i::Integer, points) =
"""
setSeriesLineDash(c, i, dash) -> Chart
-Set the dash pattern of series `i`'s outline: `"solid"`, `"dash"`, `"sysDot"`
-and the other DrawingML presets, or Excel's own names for them. `:inherit`
+Set the dash pattern of series `i`'s outline to one of the DrawingML presets,
+$(_doc_syms(DASH_VALUES)), or Excel's names for them,
+$(_doc_aliases(DASH_ALIASES)). A String works as well as a Symbol. `:inherit`
removes the pattern.
+Excel's "Round Dot" is `:sysDot` and "Square Dot" is `:sysDash`; `:dot` and
+`:dash` are different patterns, which Excel's dropdown does not offer.
+
One of the single-property line setters; see [`setSeriesLine`](@ref).
"""
setSeriesLineDash(c::Chart, i::Integer, dash) =
@@ -1466,8 +1539,9 @@ setSeriesLineDash(c::Chart, i::Integer, dash) =
"""
setSeriesLineCap(c, i, cap) -> Chart
-Set how series `i`'s outline ends: `"rnd"`, `"sq"` or `"flat"`, or Excel's names
-for them. `:inherit` removes the setting.
+Set how series `i`'s outline ends: $(_doc_syms(CAP_VALUES)), or Excel's names
+$(_doc_aliases(CAP_ALIASES)). A String works as well as a Symbol. `:inherit`
+removes the setting.
One of the single-property line setters; see [`setSeriesLine`](@ref).
"""
@@ -1478,8 +1552,9 @@ setSeriesLineCap(c::Chart, i::Integer, cap) =
setSeriesLineCompound(c, i, cmpd) -> Chart
Set the compound line type of series `i`'s outline — how many parallel strokes
-it draws: `"sng"`, `"dbl"`, `"thickThin"`, `"thinThick"` or `"tri"`, or Excel's
-names for them. `:inherit` removes the setting.
+it draws: $(_doc_syms(CMPD_VALUES)), or Excel's names
+$(_doc_aliases(CMPD_ALIASES)). A String works as well as a Symbol. `:inherit`
+removes the setting.
One of the single-property line setters; see [`setSeriesLine`](@ref).
"""
@@ -1490,8 +1565,8 @@ setSeriesLineCompound(c::Chart, i::Integer, cmpd) =
setSeriesLineJoin(c, i, join) -> Chart
Set how the segments of series `i`'s outline meet: `:round`, `:bevel` or
-`:miter`. `:inherit` removes the join. Excel uses the same three words, so there
-are no aliases.
+`:miter` (or the same as a String). `:inherit` removes the join. Excel uses the
+same three words, so there are no aliases.
A miter join takes a limit as well, set separately with
[`setSeriesLineMiterLimit`](@ref); changing the join afterwards discards it.
@@ -1504,9 +1579,9 @@ setSeriesLineJoin(c::Chart, i::Integer, join) =
"""
setSeriesLineMiterLimit(c, i, limit) -> Chart
-Set the miter limit of series `i`'s outline, as a multiple of the line width:
-how far a sharp corner may extend before the join is cut off. Excel's default is
-8. `:inherit` removes the limit.
+Set the miter limit of series `i`'s outline, as a `Real` multiple of the line
+width: how far a sharp corner may extend before the join is cut off. Excel's
+default is 8. `:inherit` removes the limit.
The limit belongs to the `a:miter` element, so the series' outline must already
have a miter join ([`setSeriesLineJoin`](@ref)); otherwise this throws.
@@ -1521,8 +1596,28 @@ setSeriesLineMiterLimit(c::Chart, i::Integer, limit) =
setSeriesLine(c, i, :none)
setSeriesLine(c, i, :inherit)
-Set several line properties at once, in one rebuild of the chart part. A keyword
-left unspecified is left alone; pass `:inherit` to remove one that is set.
+Set several line properties of series `i`'s outline at once, in one rebuild of
+the chart part. A keyword left unspecified is left alone; pass `:inherit` to
+remove one that is set.
+
+# Keywords
+
+These are the line keywords of every multi-property line setter —
+[`setAxisLine`](@ref), [`setPlotAreaLine`](@ref) and the text `:line` field
+take the same. Each matches a single-property setter, whose docstring has more.
+
+| keyword | value | setter |
+|:-------------|:-------------------------------------------------------------|:------------------------------------|
+| `color` | any color value (see [`setSeriesFill`](@ref)), or `:none` | [`setSeriesLineColor`](@ref) |
+| `width` | points, a `Real` from 0 to 1584 | [`setSeriesLineWidth`](@ref) |
+| `dash` | $(_doc_syms(DASH_VALUES)); or $(_doc_aliases(DASH_ALIASES)) | [`setSeriesLineDash`](@ref) |
+| `cap` | $(_doc_syms(CAP_VALUES)); or $(_doc_aliases(CAP_ALIASES)) | [`setSeriesLineCap`](@ref) |
+| `compound` | $(_doc_syms(CMPD_VALUES)); or $(_doc_aliases(CMPD_ALIASES)) | [`setSeriesLineCompound`](@ref) |
+| `join` | $(_doc_syms(JOIN_VALUES)) | [`setSeriesLineJoin`](@ref) |
+| `miterLimit` | a `Real` multiple of the line width; Excel's default is 8 | [`setSeriesLineMiterLimit`](@ref) |
+
+Every keyword also takes `:inherit`. The symbol-valued keywords accept the same
+words as Strings.
`miterLimit` needs a miter join. Passing `join = :miter` and `miterLimit` in one
call works, since the join is applied first.
@@ -1603,8 +1698,8 @@ end
setMarkerSymbol(c, i, symbol; point = nothing) -> Chart
Set the marker shape for series `i`, or for one of its data points when `point`
-is given, counting from 1. One of `:circle`, `:dash`, `:diamond`, `:dot`,
-`:none`, `:picture`, `:plus`, `:square`, `:star`, `:triangle`, `:x` or `:auto`.
+is given, counting from 1. `symbol` is one of $(_doc_syms(MARKER_SYMBOLS)), as
+a Symbol or String.
`:none` draws no marker and `:auto` lets Excel choose; both are settings.
`:inherit` removes the element so the chart style decides.
@@ -1616,8 +1711,9 @@ setMarkerSymbol(c::Chart, i::Integer, symbol; point::Union{Nothing,Integer} = no
setMarkerSize(c, i, size; point = nothing) -> Chart
Set the marker size for series `i`, or for one of its data points when `point`
-is given, counting from 1. In points, rounded to the nearest whole point.
-DrawingML allows 2 to 72; anything outside that is rejected rather than written.
+is given, counting from 1. `size` is in points, a `Real` rounded to the nearest
+whole point. DrawingML allows 2 to 72; anything outside that is rejected rather
+than written.
`:inherit` removes the setting so the chart style decides.
One of the single-property marker setters; [`setMarker`](@ref) sets several at
@@ -1637,9 +1733,7 @@ _marker_sp(mk, pfx) = something(first_element_with_tag(mk, "spPr"),
setMarkerFill(c, i, color; point = nothing) -> Chart
Set the fill of series `i`'s marker, or of one data point's when `point` is
-given, counting from 1. Takes the same values as [`setSeriesFill`](@ref): a
-colour, a [`SchemeColor`](@ref), `:none` for an explicit ``, or
-`:inherit` to remove it.
+given, counting from 1. $(_DOC_COLOR_ARG)
One of the single-property marker setters; see [`setMarker`](@ref).
"""
@@ -1658,10 +1752,8 @@ _marker_with_line(mk::XML.Node, f, pfx::Dict{String,String}) =
"""
setMarkerLineColor(c, i, color; point = nothing) -> Chart
-Set the outline colour of series `i`'s marker, or of one data point's when
-`point` is given, counting from 1. Takes the same values as
-[`setSeriesFill`](@ref): a colour, a [`SchemeColor`](@ref), `:none` for an
-explicit ``, or `:inherit` to remove it.
+Set the outline color of series `i`'s marker, or of one data point's when
+`point` is given, counting from 1. $(_DOC_COLOR_ARG)
As with [`setSeriesLineColor`](@ref), `:none` leaves the outline in place
drawing nothing, which is not the same as removing it.
@@ -1676,7 +1768,8 @@ setMarkerLineColor(c::Chart, i::Integer, color; point::Union{Nothing,Integer} =
setMarkerLineWidth(c, i, width; point = nothing) -> Chart
Set the outline width of series `i`'s marker, or of one data point's when
-`point` is given, counting from 1. In points. `:inherit` removes the width.
+`point` is given, counting from 1. `width` is in points, a `Real` from 0 to
+1584. `:inherit` removes the width.
DrawingML caps a line width at 1584 pt; a wider one is rejected rather than
written.
@@ -1696,8 +1789,12 @@ the marker for series `i`, or overrides it for one of its data points when
A keyword left unspecified is left alone; pass `:inherit` to remove one that is
set. Each keyword takes the values its single-property setter does:
-[`setMarkerSymbol`](@ref), [`setMarkerSize`](@ref), [`setMarkerFill`](@ref),
-[`setMarkerLineColor`](@ref) and [`setMarkerLineWidth`](@ref).
+
+- `symbol` — $(_doc_syms(MARKER_SYMBOLS)) ([`setMarkerSymbol`](@ref));
+- `size` — points, 2 to 72 ([`setMarkerSize`](@ref));
+- `fill` — a color value, as for [`setSeriesFill`](@ref) ([`setMarkerFill`](@ref));
+- `lineColor` — a color value ([`setMarkerLineColor`](@ref));
+- `lineWidth` — points, 0 to 1584 ([`setMarkerLineWidth`](@ref)).
Nothing is written if every property keyword is `nothing`.
@@ -1770,9 +1867,10 @@ end
setLabelTextProp(c, i, field, value) -> Chart
setLabelTextProp(c, i, point, field, value) -> Chart
-Set one text property of series `i`'s data labels, or of one individual label.
-`field` is a field of `DrawingRunProps` other than `raw` — the same vocabulary
-[`getLabelTextProp`](@ref) resolves. `:inherit` removes it.
+Set one text property of series `i`'s data labels, or of the individual label of
+data point `point`, counting from 1.
+
+$(_DOC_TEXT_FIELD_ARG)
Creating a `c:dLbls` where none existed writes every `show*` flag as off, so
formatting a series' labels does not make them appear. A `c:dLbls` that names no
@@ -1784,11 +1882,6 @@ in index order and carrying the same `show*` flags as its `c:dLbls`, so it
displays exactly what its sibling labels display. Formatting one label on a
series whose labels are off does not make that label appear.
-Two fields take compound values. `:fill` accepts anything
-[`setSeriesFill`](@ref) does — a color, a [`SchemeColor`](@ref), `:none` or
-`:inherit`. `:line` accepts a color, or a `NamedTuple` of the
-[`setSeriesLine`](@ref) keywords: `(color = "red", width = 1.5)`.
-
A label that has been deleted carries only `c:delete` and cannot be formatted —
undeleting it is a separate operation.
"""
@@ -1823,7 +1916,9 @@ end
setGroupLabelTextProp(c, g::ChartGroup, field, value) -> Chart
Set one text property of a chart group's data labels — the rung every series in
-the group inherits from.
+the group inherits from. `g` is a group from [`getChartGroups`](@ref).
+
+$(_DOC_TEXT_FIELD_ARG)
"""
function setGroupLabelTextProp(c::Chart, g::ChartGroup, field::Symbol, value)
root = chart_root(c)
@@ -1847,6 +1942,9 @@ end
Set one text property on `c:chartSpace/c:txPr` — the bottom rung of the text
cascade, which every text body in the chart inherits from unless it overrides.
+This is the way to set one font or size for the whole chart.
+
+$(_DOC_TEXT_FIELD_ARG)
"""
function setChartSpaceTextProp(c::Chart, field::Symbol, value)
root = chart_root(c)
@@ -1860,17 +1958,33 @@ end
"""
setChartTitleText(c, text) -> Chart
+ setChartTitleText(c, ref::Union{CellRef,SheetCellRef}) -> Chart
Replace the chart title's text and all its formatting. `text` may be a
-`DrawingText` or a string, which becomes a one-run title inheriting its
-formatting from the chart style.
+`DrawingText`, a string, which becomes a one-run title inheriting its formatting
+from the chart style, or a `RichTextString`, whose runs keep their own
+formatting and inherit the rest. A newline starts a new paragraph. A styled
+string from StyledStrings.jl (`styled"…"`, Julia 1.11 and later) is converted to
+a `RichTextString` as it is when written to a cell.
This replaces rather than merges, like writing a `RichTextString` to a cell —
use [`setChartTitleTextProp`](@ref) to change one property and leave the rest.
-A title bound to a cell (`c:tx/c:strRef`) is replaced by literal text; the
-reference is lost. `c:autoTitleDeleted` is not touched, so a chart with the
-title switched off stays that way.
+Given a `CellRef` or `SheetCellRef`, the title is bound to that cell
+(`c:tx/c:strRef`) and shows its text, with the cell's current value cached as
+Excel does. A bare `CellRef` means the chart's own sheet; a chart on a
+chartsheet needs a `SheetCellRef`. A string is always typed text, never read as
+a reference — `"Q1"` is a valid title.
+
+A bound title can't carry rich text: this is a limit of Excel's file format,
+in which `c:tx` holds either a reference or typed runs, never both. Its one
+style is `c:title/c:txPr`. Binding a typed title moves its formatting there —
+the first paragraph's, overlaid by its first run's — and, where the cell holds a
+`RichTextString`, the formatting all of its runs share is laid over that.
+Formatting that varies between the cell's runs is not shown.
+
+Typing text over a bound title replaces the reference. `c:autoTitleDeleted` is
+not touched, so a chart with the title switched off stays that way.
A title created here gets `c:overlay val="0"`, as Excel writes on every title
it creates, so it sits beside the plot area rather than over it.
@@ -1890,24 +2004,103 @@ end
setChartTitleText(c::Chart, text::AbstractString) =
setChartTitleText(c, DrawingText(text))
- # A title with no c:overlay is drawn over the plot area; Excel writes
+function setChartTitleText(c::Chart, ref::Union{CellRef,SheetCellRef})
+ strref, cellrich = _title_ref(c, ref)
+ root = chart_root(c)
+ pfx = ns_prefixes(root)
+ new = rebuild_path(root,
+ [(NS_C, "chart") => "chart",
+ (NS_C, "title") => "title"],
+ t -> _title_with_ref(t, strref, cellrich, _wb(c), pfx);
+ prefixes = pfx)
+ set_chart_root!(c, new)
+ return c
+end
+
+_title_with_text(t::XML.Node, text::DrawingText, pfx) =
+ _title_with_tx(t, _text_from(text, "rich", pfx), pfx)
+
+# Put `content` — a c:rich or a c:strRef — in the title's c:tx, replacing either.
+# A title with no c:overlay is drawn over the plot area; Excel writes
# overlay=0 on every title it creates, so a new title gets the same.
-function _title_with_text(t::XML.Node, text::DrawingText, pfx)
+function _title_with_tx(t::XML.Node, content::XML.Node, pfx)
tkey = (NS_C, "title")
tx = something(first_element_with_tag(t, "tx"), XML.Element(prefixed_tag(pfx[NS_C], "tx")))
- tx = insert_child(remove_choice(tx, (NS_C, "tx"), TX_GROUP), (NS_C, "tx"),
- _text_from(text, "rich", pfx))
+ tx = insert_child(remove_choice(tx, (NS_C, "tx"), TX_GROUP), (NS_C, "tx"), content)
t = insert_child(t, tkey, tx)
return isnothing(first_element_with_tag(t, "overlay")) ?
insert_child(t, tkey, _val_el(pfx, "overlay", "0")) : t
end
+# A title bound to a cell has one style, c:txPr: c:tx holds c:strRef or c:rich,
+# never both. Typed formatting is kept by moving it into c:txPr; then whatever
+# formatting every run of a rich-text cell shares is laid over it.
+function _title_with_ref(t::XML.Node, strref::XML.Node, cellrich, wb::Workbook, pfx)
+ tkey = (NS_C, "title")
+ rich = first_element_with_tag(first_element_with_tag(t, "tx"), "rich")
+ isnothing(rich) ||
+ (t = insert_child(t, tkey, _txpr_from_rich(rich, first_element_with_tag(t, "txPr"), wb, pfx)))
+ for (field, value) in _common_run_props(cellrich)
+ t = _txpr_with_run_prop(t, tkey, field, value, pfx)
+ end
+ return _title_with_tx(t, strref, pfx)
+end
+
+# A c:txPr carrying a typed body's formatting, laid over the title's existing
+# c:txPr where it has one: one paragraph whose default run props are the old
+# c:txPr's, overlaid by the typed first paragraph's, then by its first run's. A
+# bound title has a single style, so later runs cannot be kept.
+function _txpr_from_rich(rich::XML.Node, txpr::Union{Nothing,XML.Node}, wb::Workbook, pfx;
+ ns::AbstractString = NS_C)
+ text = parse_drawing_text(wb, rich; tag = String(localname(rich)))
+ old = parse_drawing_text(wb, txpr)
+ base = something(old, text)
+ p1 = first(text.paragraphs)
+ run1 = findfirst(r -> r.kind === :run, p1.runs)
+ oldp = isnothing(old) || isempty(old.paragraphs) ? nothing : first(old.paragraphs).props
+ pp = something(oldp, p1.props, DrawingParaProps())
+ def = _overlay_run_props(isnothing(oldp) ? nothing : oldp.defprops,
+ isnothing(p1.props) ? nothing : p1.props.defprops)
+ def = _overlay_run_props(def, isnothing(run1) ? nothing : p1.runs[run1].props)
+ pp = DrawingParaProps(pp.align, pp.level, pp.marginleft, pp.marginright, pp.indent,
+ pp.rtl, pp.linespacing, pp.spacebefore, pp.spaceafter, def, nothing)
+ para = DrawingParagraph(pp, DrawingRun[], nothing, nothing)
+ return _text_from(DrawingText(base.body, base.liststyle, [para], nothing), "txPr", pfx, ns)
+end
+
+# Field by field, `top` where it sets a value and `base` elsewhere.
+function _overlay_run_props(base, top)
+ isnothing(top) && return base
+ isnothing(base) && return top
+ return DrawingRunProps((f === :raw ? nothing :
+ something(getfield(top, f), Some(getfield(base, f)))
+ for f in fieldnames(DrawingRunProps))...)
+end
+
+# The (field, value) pairs every run of a RichTextString agrees on, as run-prop
+# setters take them. A run with no attributes agrees on nothing.
+function _common_run_props(rts)
+ isnothing(rts) && return Pair{Symbol,Any}[]
+ ps = [Dict(_run_prop_pairs(_rich_run_props(r.atts))) for r in rts.runs]
+ return Pair{Symbol,Any}[f => v for (f, v) in first(ps) if all(p -> get(p, f, nothing) == v, ps)]
+end
+
+# The fields a RichTextRun's props set, as `field => value` pairs in the form
+# the run-prop setters take: a solid fill is its color.
+_run_prop_pairs(::Nothing) = Pair{Symbol,Any}[]
+_run_prop_pairs(p::DrawingRunProps) =
+ Pair{Symbol,Any}[f => (f === :fill ? p.fill.fgcolor : getfield(p, f))
+ for f in fieldnames(DrawingRunProps) if f !== :raw && !isnothing(getfield(p, f))]
+
"""
setAxisTitleText(c, ax::ChartAxis, text) -> Chart
+ setAxisTitleText(c, ax::ChartAxis, ref::Union{CellRef,SheetCellRef}) -> Chart
-Replace an axis title's text and all its formatting. As
-[`setChartTitleText`](@ref), including that a cell reference is replaced by
-literal text.
+Replace an axis title's text and all its formatting, or bind it to a cell. `ax`
+is an axis from [`getChartAxes`](@ref) or [`getChartAxis`](@ref). `text` is as
+for [`setChartTitleText`](@ref): a string, a `DrawingText`, or a
+`RichTextString` or styled string, which keeps its run formatting. A `CellRef`
+or `SheetCellRef` binds the title, and a bound title can't carry rich text.
A title created here gets `c:overlay val="0"`, as Excel writes on every title
it creates, so it sits beside the plot area rather than over it.
@@ -1925,11 +2118,24 @@ end
setAxisTitleText(c::Chart, ax::ChartAxis, text::AbstractString) =
setAxisTitleText(c, ax, DrawingText(text))
+function setAxisTitleText(c::Chart, ax::ChartAxis, ref::Union{CellRef,SheetCellRef})
+ strref, cellrich = _title_ref(c, ref)
+ root = chart_root(c)
+ pfx = ns_prefixes(root)
+ steps, _, _ = _axis_path(c, root, ax)
+ new = rebuild_path(root, [steps...; (NS_C, "title") => "title"],
+ t -> _title_with_ref(t, strref, cellrich, _wb(c), pfx); prefixes = pfx)
+ set_chart_root!(c, new)
+ return c
+end
+
"""
setLabelText(c, i, point, text) -> Chart
-Replace one data label's typed-over text and all its formatting, so the label
-shows `text` instead of its value.
+Replace the text of the data label of point `point` (counting from 1) of series
+`i`, and all its formatting, so the label shows `text` instead of its value.
+`text` is a `DrawingText`, a string, or a `RichTextString` or styled string,
+whose runs keep their own formatting.
If the point has no `c:dLbl`, one is created with the same `show*` flags as its
`c:dLbls`. Typed text is displayed whatever those flags say, so this shows the
@@ -1941,7 +2147,7 @@ function setLabelText(c::Chart, i::Integer, point::Integer, text::DrawingText)
steps, _, ser = _series_path(c, root, i)
dl = _dlbl_node(ser, point)
!isnothing(dl) && _bool_val(dl, "delete") === true && throw(XLSXError(
- "Label $point of series $i is deleted, so it has no text to set." *
+ "Label $point of series $i is deleted, so it has no text to set. " *
"Undelete it first with `setLabelDeleted(c, $i, $point, false)`."))
settx(lbl) = rebuild_path(lbl, [(NS_C, "tx") => "tx"],
tx -> insert_child(remove_choice(tx, (NS_C, "tx"), TX_GROUP),
@@ -1960,15 +2166,49 @@ setLabelText(c::Chart, i::Integer, point::Integer, text::AbstractString) =
"""
setChartTitleTextProp(c, field, value) -> Chart
-Set one text property of the chart title. `field` is a field of
-`DrawingRunProps` other than `raw`; `:inherit` removes it.
+Set one text property of the chart title, such as its size or color, leaving
+the others as they are.
+
+# Fields
+
+These are the fields and values of every text-property setter:
+[`setLabelTextProp`](@ref), [`setGroupLabelTextProp`](@ref),
+[`setChartSpaceTextProp`](@ref), [`setAxisTitleTextProp`](@ref),
+[`setAxisTextProp`](@ref) and [`setLegendTextProp`](@ref). Each field is a field
+of [`DrawingRunProps`](@ref), and getters such as [`getLabelTextProp`](@ref)
+report it under that name; the Excel names in brackets are accepted as well.
+
+| field | value |
+|:----------------------------|:------------------------------------------------------------------------|
+| `:size` | font size in points, a `Real`: `11`, `10.5` |
+| `:bold`, `:italic` | `Bool` |
+| `:under` (`:underline`) | $(_doc_syms(UNDER_VALUES)); or $(_doc_aliases(UNDER_ALIASES)); or a `Bool` (`true` = `:sng`, `false` = `:none`) |
+| `:strike` (`:strikethrough`) | $(_doc_syms(STRIKE_VALUES)); or $(_doc_aliases(STRIKE_ALIASES)); or a `Bool` (`true` = `:sngStrike`, `false` = `:noStrike`) |
+| `:caps` | $(_doc_syms(CAPS_VALUES)) |
+| `:baseline` | vertical offset as a fraction of the font size: `0.3` for superscript, `-0.25` for subscript, `0` for none |
+| `:kern` | smallest font size, in points, at which to kern; `0` for none |
+| `:spacing` | extra space between characters, in points; negative to condense |
+| `:fill` (`:color`) | the text color — any color value accepted by [`setSeriesFill`](@ref) |
+| `:line` | the text outline: a color, or a `NamedTuple` of [`setSeriesLine`](@ref)'s keywords, `(color = "red", width = 0.5)` |
+| `:latin` (`:font`) | the font name: `"Arial"`, or a theme font, `"+mn-lt"` (body) or `"+mj-lt"` (headings) |
+| `:ea`, `:cs` | the font for East Asian and complex scripts, named as for `:latin` |
+| `:lang` | the text's language, a tag such as `"en-US"` |
+
+Every field also takes `:inherit`, which removes it so it is inherited from the
+next level up — for a title, the chart space, then the chart style. The
+`:under`, `:strike` and `:caps` words may be Strings as well as Symbols; any
+other word is rejected, since Excel will not open a file containing one.
A title carries formatting in `c:title/c:txPr` and, when it holds literal text,
in `c:title/c:tx/c:rich` as well — Excel renders the `rich` one, so both are
written. Contrast [`setChartTitleText`](@ref), which replaces the text and all
its formatting.
+# Example
+ setChartTitleTextProp(c, :size, 16)
+ setChartTitleTextProp(c, :color, "accent2")
+ setChartTitleTextProp(c, :font, "Georgia")
"""
function setChartTitleTextProp(c::Chart, field::Symbol, value)
root = chart_root(c)
@@ -1985,7 +2225,11 @@ end
"""
setAxisTitleTextProp(c, ax::ChartAxis, field, value) -> Chart
-Set one text property of an axis title. As [`setChartTitleTextProp`](@ref).
+Set one text property of an axis title, as [`setChartTitleTextProp`](@ref)
+does for the chart title. `ax` is an axis from [`getChartAxes`](@ref) or
+[`getChartAxis`](@ref).
+
+$(_DOC_TEXT_FIELD_ARG)
"""
function setAxisTitleTextProp(c::Chart, ax::ChartAxis, field::Symbol, value)
root = chart_root(c)
@@ -2009,6 +2253,8 @@ end
Set one text property of the legend (`c:legend/c:txPr`). A legend takes its text
from the series names, so there is no `c:tx` to replace — only its formatting is
settable.
+
+$(_DOC_TEXT_FIELD_ARG)
"""
function setLegendTextProp(c::Chart, field::Symbol, value)
root = chart_root(c)
@@ -2193,8 +2439,8 @@ Set several `c:scaling` properties in one rebuild. A keyword left unspecified is
left alone; `:inherit` removes it.
- `orientation` — `:minMax`, or `:maxMin` to plot the axis in reverse order.
-- `min`, `max` — fixed bounds, in the axis's own units. `:inherit` returns a
- bound to automatic.
+- `min`, `max` — fixed bounds, as finite `Real`s in the axis's own units (on a
+ date axis, Excel date serial numbers). `:inherit` returns a bound to automatic.
Throws if the resulting bounds would have `min ≥ max`. Checking once all
keywords are applied means a range can be moved in one call past its old
@@ -2225,16 +2471,18 @@ end
"""
setAxisMin(c, ax, value) -> Chart
-Fix the axis's lower bound (`c:scaling/c:min`); `:inherit` returns it to
-automatic. See [`setAxisScaling`](@ref) to set both bounds at once.
+Fix the axis's lower bound (`c:scaling/c:min`) at `value`, a finite `Real` in
+the axis's own units; `:inherit` returns it to automatic. See
+[`setAxisScaling`](@ref) to set both bounds at once.
"""
setAxisMin(c::Chart, ax::ChartAxis, v) = setAxisScaling(c, ax; min = v)
"""
setAxisMax(c, ax, value) -> Chart
-Fix the axis's upper bound (`c:scaling/c:max`); `:inherit` returns it to
-automatic. See [`setAxisScaling`](@ref) to set both bounds at once.
+Fix the axis's upper bound (`c:scaling/c:max`) at `value`, a finite `Real` in
+the axis's own units; `:inherit` returns it to automatic. See
+[`setAxisScaling`](@ref) to set both bounds at once.
"""
setAxisMax(c::Chart, ax::ChartAxis, v) = setAxisScaling(c, ax; max = v)
@@ -2271,7 +2519,8 @@ end
setAxisCrossesAt(c, ax, value) -> Chart
Set where this axis crosses its partner as a value on the partner's scale
-(`c:crossesAt`). Removes any `c:crosses`. `:inherit` removes `c:crossesAt` only.
+(`c:crossesAt`) — `value` is a finite `Real`. Removes any `c:crosses`.
+`:inherit` removes `c:crossesAt` only.
"""
function setAxisCrossesAt(c::Chart, ax::ChartAxis, value)
v = _finite(value, "An axis crossing")
@@ -2285,7 +2534,8 @@ end
Set the axis's number format (`c:numFmt/@formatCode`) and unlink it from the
source cells (`sourceLinked="0"`), without which Excel ignores the code.
-`:inherit` removes `c:numFmt`.
+`code` is an Excel number format string, as for a cell: `"0.0%"`, `"#,##0"`,
+`"\$#,##0.00"`, `"mmm-yy"`. `:inherit` removes `c:numFmt`.
A two-section code such as `"0%;0%"` shows negative values without a minus sign.
"""
@@ -2362,8 +2612,9 @@ end
"""
setGroupGapWidth(c, g, percent) -> Chart
-Set the group's gap width (0–500, as Excel's "Gap Width" box), or `:inherit`
-to remove it.
+Set the group's gap width, an `Integer` percentage of a bar's width from 0 to
+500, as Excel's "Gap Width" box; or `:inherit` to remove it. `g` is a group
+from [`getChartGroups`](@ref); bar and bar-of-pie groups only.
"""
function setGroupGapWidth(c::Chart, g::ChartGroup, pct)
v = _check_pct(pct, 0, 500, "A gap width")
@@ -2389,8 +2640,10 @@ end
"""
setGroupOverlap(c, g, percent) -> Chart
-Set the group's overlap (-100 to 100), or `:inherit` to remove it. Stacked
-groups need 100 for each series to sit on the one before it.
+Set the group's overlap, an `Integer` percentage from -100 to 100, as Excel's
+"Series Overlap" box; or `:inherit` to remove it. `g` is a group from
+[`getChartGroups`](@ref); 2-D bar groups only. Stacked groups need 100 for each
+series to sit on the one before it.
"""
function setGroupOverlap(c::Chart, g::ChartGroup, pct)
v = _check_pct(pct, -100, 100, "An overlap")
@@ -2480,6 +2733,71 @@ function setLegendEntryDeleted(c::Chart, positions, deleted::Union{Bool,Symbol})
return c
end
+# ── Legend position ───────────────────────────────────────────────────────────
+
+const _LEGEND_POS = (:b, :t, :l, :r, :tr)
+const _LEGEND_POS_ALIASES = Dict(:bottom => :b, :top => :t, :left => :l, :right => :r,
+ :topRight => :tr)
+
+_legend_pos(p::Symbol) = _check_sym(get(_LEGEND_POS_ALIASES, p, p), _LEGEND_POS, "legend position")
+
+function _legend_overlay(v)
+ v isa Bool && return v ? "1" : "0"
+ v === :inherit && return :inherit
+ throw(XLSXError("`$(repr(v))` is not a legend overlay; use `true`, `false` or `:inherit`."))
+end
+
+# Apply `f(lg, key, pfx)` to `c:legend`, creating it if absent, in one rebuild.
+function _set_legend(c::Chart, f)
+ root = chart_root(c)
+ pfx = ns_prefixes(root)
+ lkey = (NS_C, "legend")
+ new = rebuild_path(root, [(NS_C, "chart") => "chart", lkey => "legend"],
+ lg -> f(lg, lkey, pfx); prefixes = pfx)
+ set_chart_root!(c, new)
+ return c
+end
+
+"""
+ setLegendPos(c::Chart, pos; overlay=nothing) -> Chart
+ setLegendPos(c::ChartEx, pos; overlay=nothing) -> ChartEx
+
+Set where the legend is drawn: `:b` (bottom), `:t` (top), `:l` (left), `:r`
+(right) or `:tr` (top-right) — Excel's five legend positions. `:bottom`, `:top`,
+`:left`, `:right` and `:topRight` are accepted too. `:inherit` removes the
+position, which Excel reads as `:r`.
+
+`overlay` also sets [`setLegendOverlay`](@ref): `true` draws the legend over the
+plot area, `false` beside it, shrinking the plot area to make room. `nothing`
+leaves it as it is.
+
+Creates the legend if the chart has none. On a `Chart`, any manual layout on the
+legend (`c:legend/c:layout`, written when a legend is dragged in Excel) is
+removed so that the position takes effect, as picking a position in Excel does.
+"""
+function setLegendPos(c::Chart, pos::Symbol; overlay = nothing)
+ p = _legend_pos(pos)
+ o = isnothing(overlay) ? nothing : _legend_overlay(overlay)
+ return _set_legend(c, (lg, key, pfx) -> begin
+ lg = _with_val(remove_child(lg, "layout"), key, pfx, "legendPos", p)
+ isnothing(o) ? lg : _with_val(lg, key, pfx, "overlay", o)
+ end)
+end
+
+"""
+ setLegendOverlay(c::Chart, overlay) -> Chart
+ setLegendOverlay(c::ChartEx, overlay) -> ChartEx
+
+`true` draws the legend over the plot area; `false` draws it beside the plot
+area, which shrinks to make room — Excel's "Show the legend without overlapping
+the chart". `:inherit` removes the setting, which Excel reads as `false`.
+Creates the legend if the chart has none.
+"""
+function setLegendOverlay(c::Chart, overlay::Union{Bool,Symbol})
+ o = _legend_overlay(overlay)
+ return _set_legend(c, (lg, key, pfx) -> _with_val(lg, key, pfx, "overlay", o))
+end
+
const _TICK_MARKS = (:cross, :in, :out, :none)
const _TICK_MARK_ALIASES = Dict(:inside => :in, :outside => :out) # Excel's names
@@ -2536,8 +2854,9 @@ end
"""
setAxisTextProp(c, ax, field, value) -> Chart
-Set one text property of the axis's tick labels (`c:txPr` on the axis). Takes
-the same fields and values as [`setLegendTextProp`](@ref).
+Set one text property of the axis's tick labels (`c:txPr` on the axis).
+
+$(_DOC_TEXT_FIELD_ARG)
"""
setAxisTextProp(c::Chart, ax::ChartAxis, field::Symbol, value) =
_set_axis(c, ax, (el, key, pfx) -> _txpr_with_run_prop(el, key, field, value, pfx))
@@ -2573,6 +2892,13 @@ _plotarea_steps(_) = [(NS_C, "chart") => "chart", (NS_C, "plotArea") => "plotAre
Set the axis line, as [`setSeriesLine`](@ref) sets a series outline: several
properties in one rebuild, `:none` for a line that draws nothing, `:inherit` to
remove it so the chart style supplies it.
+
+$(_DOC_LINE_KWS) A keyword left unspecified is left alone.
+
+# Example
+
+ valax = first(getChartAxes(c, :value))
+ setAxisLine(c, valax; color = "gray", width = 0.75, dash = :sysDash)
"""
function setAxisLine(c::Chart, ax::ChartAxis;
color = nothing, width = nothing, dash = nothing, cap = nothing,
@@ -2590,7 +2916,11 @@ setAxisLine(c::Chart, ax::ChartAxis, what::Symbol) =
setPlotAreaLine(c, :inherit)
Set the border of the plot area (`c:plotArea/c:spPr/a:ln`), as
-[`setSeriesLine`](@ref) sets a series outline.
+[`setSeriesLine`](@ref) sets a series outline: several properties in one
+rebuild, `:none` for a border that draws nothing, `:inherit` to remove it so the
+chart style supplies it.
+
+$(_DOC_LINE_KWS) A keyword left unspecified is left alone.
"""
function setPlotAreaLine(c::Chart;
color = nothing, width = nothing, dash = nothing, cap = nothing,
@@ -2613,8 +2943,9 @@ end
"""
setAxisMajorUnit(c, ax, value) -> Chart
-Set the interval between major ticks and gridlines (`c:majorUnit`), in the
-axis's own units; `:inherit` returns it to automatic. Value and date axes only.
+Set the interval between major ticks and gridlines (`c:majorUnit`) to `value`,
+a positive `Real` in the axis's own units; `:inherit` returns it to automatic.
+Value and date axes only.
Ticks start at the axis minimum, so a minimum that is a multiple of the unit
puts a tick at zero.
diff --git a/src/charts/chartschema.jl b/src/charts/chartschema.jl
index c7e0c6a1..616c9786 100644
--- a/src/charts/chartschema.jl
+++ b/src/charts/chartschema.jl
@@ -169,7 +169,7 @@ const CHILD_ORDER = Dict{Tuple{String,String},Vector{Union{String,Vector{String}
# above: the four cx element names shared by two complex types (title,
# visibility, lvl, pt) have identical child orders in both. cx:spPr, cx:txPr
# and cx:rich are DrawingML types and use the NS_A entries.
- # Omitted: region-map geography, value colours and print settings.
+ # Omitted: region-map geography, value colors and print settings.
(NS_CX, "chartSpace") => ["chartData","chart","spPr","txPr","clrMapOvr","fmtOvrs",
"printSettings","extLst"],
(NS_CX, "chartData") => ["externalData","data","extLst"],
@@ -487,8 +487,11 @@ function _slot(order, tag::AbstractString)
return nothing
end
+# Where a child in slot `pos` goes among `kids`. Only elements have a slot: the
+# whitespace text an indented part carries between them is passed over.
function _schema_position(order, kids, pos::Integer)
for (i, k) in enumerate(kids)
+ XML.nodetype(k) == XML.Element || continue
p = _slot(order, localname(k))
isnothing(p) && continue
p > pos && return i
diff --git a/src/charts/creation.jl b/src/charts/creation.jl
index 235912cb..dc6f54f8 100644
--- a/src/charts/creation.jl
+++ b/src/charts/creation.jl
@@ -58,7 +58,7 @@ end
"""
_add_chart_part!(ws, xml, style_id; anchor, cx_requires=nothing) -> String
-Add `xml` as a new chart part on `ws`, with its style and colour parts, and place
+Add `xml` as a new chart part on `ws`, with its style and color parts, and place
it in the sheet's drawing. `anchor` is a cell range for a worksheet, or `nothing`
for a chartsheet, whose chart is absolutely anchored and fills the sheet.
`cx_requires` is `nothing` for a `c:` chart, or the `Requires` prefix (`"cx1"`,
@@ -86,15 +86,29 @@ function _add_chart_part!(ws::Worksheet, xml::Union{AbstractString,XML.Node}, st
id = _next_shape_id(root)
content = _chart_frame(rid, id, "Chart $(id - 1)"; cx_requires, locked = isnothing(anchor))
- push!(root, isnothing(anchor) ?
+ push!(root, _in_drawing_prefixes(isnothing(anchor) ?
build_absolute_anchor(content) :
build_two_cell_anchor(
column_number(anchor.start) - 1, row_number(anchor.start) - 1, # 0-based inclusive
column_number(anchor.stop), row_number(anchor.stop), # 0-based exclusive
- content))
+ content), root))
return path
end
+# The anchor is built with Excel's `xdr` and `a`; a drawing part read from a file
+# may bind those namespaces to other prefixes. Rename into the drawing's own, and
+# where the drawing declares one of them not at all, declare it on the anchor.
+const _DRAWING_NS = Dict("xdr" => NS_XDR, "a" => NS_A)
+
+function _in_drawing_prefixes(anchor::XML.Node, root::XML.Node)
+ pfx = ns_prefixes(root)
+ anchor = _in_prefixes(anchor, pfx; from = _DRAWING_NS)
+ for (p, ns) in _DRAWING_NS
+ haskey(pfx, ns) || (anchor = with_attribute(anchor, "xmlns:$p", ns))
+ end
+ return anchor
+end
+
# Rebuild `node` bottom-up, replacing each element `e` with `f(e)` once its
# children have been rebuilt.
function _rewrite(f, node::XML.Node)
@@ -134,16 +148,33 @@ function _chart_shell(template::AbstractString; title)
end
# A typed title in the template's formatting: c:txPr's body, with its end-of-
-# paragraph properties replaced by a run.
-function _title_with_text(t::XML.Node, text::AbstractString)
+# paragraph properties replaced by a run. A newline starts a new paragraph, as
+# it does in setChartTitleText.
+_title_with_text(t::XML.Node, text::AbstractString) =
+ _title_from_template(t, [isempty(line) ? XML.Node[] :
+ XML.Node[XML.Element("a:r", XML.Element("a:t", XML.Text(line)))]
+ for line in split(text, _NEWLINE)])
+
+# A RichTextString title: one copy of the template's paragraph per line, its runs
+# carrying only their own formatting so the rest still comes from the template.
+function _title_with_text(t::XML.Node, text::RichTextString)
+ pfx = Dict(NS_A => "a")
+ paras = map(_as_paragraphs(text)) do p
+ kids = XML.Node[_run_node(r, pfx) for r in p.runs]
+ isnothing(p.endprops) || push!(kids, _run_props_node(p.endprops, "endParaRPr", pfx))
+ kids
+ end
+ return _title_from_template(t, paras)
+end
+
+# The template's c:txPr turned into the title's c:rich, its paragraph copied once
+# per entry of `paras` with the end-of-paragraph properties replaced by that entry.
+function _title_from_template(t::XML.Node, paras::Vector{Vector{XML.Node}})
txpr = first_element_with_tag(t, "txPr")
p = first_element_with_tag(txpr, "p")
- run = XML.Element("a:r", XML.Element("a:rPr"; lang = "en-US"),
- XML.Element("a:t", XML.Text(text)))
- pk = [k for k in p.children if !_is_el(k, "endParaRPr")]
- push!(pk, run)
- newp = _with_children(p, pk)
- rich = XML.Element("c:rich", [k === p ? newp : k for k in txpr.children]...)
+ keep = XML.Node[k for k in p.children if !_is_el(k, "endParaRPr")]
+ newp = [_with_children(p, vcat(keep, kids)) for kids in paras]
+ rich = XML.Element("c:rich", [n for k in txpr.children for n in (k === p ? newp : (k,))]...)
return insert_child(t, (NS_C, "title"), XML.Element("c:tx", rich))
end
@@ -160,20 +191,33 @@ The chart starts as Excel's own default for that kind, formatted by the
workbook's theme.
`title` is `nothing` for Excel's automatic title, a string for typed text, or
-`false` for no title.
+`false` for no title. A `RichTextString` keeps each run's formatting, on top of
+the formatting Excel gives a new title, as does a styled string from
+StyledStrings.jl (Julia 1.11 and later). A `CellRef` or `SheetCellRef` binds the
+title to that cell, as [`setChartTitleText`](@ref) does; a string is always
+typed text, never read as a cell reference.
"""
function addChart(ws::Worksheet, kind::Symbol;
anchor::Union{AbstractString,CellRange},
- title::Union{Nothing,Bool,AbstractString} = nothing)::Chart
- haskey(C_KINDS, kind) || throw(XLSXError(
- "Unknown chart kind `:$kind`. Use one of " * join((":$k" for k in keys(C_KINDS)), ", ") * "."))
- title === true && throw(XLSXError("`title` is a string, `nothing` for Excel's automatic title, or `false` for none."))
- k = C_KINDS[kind]
- path = _add_chart_part!(ws, _chart_shell(CHART_KIND_TEMPLATES[k.template]; title), k.style;
- anchor = anchor isa CellRange ? anchor : CellRange(anchor))
- return getChart(ws, path)
+ title::Union{Nothing,Bool,AbstractString,CellRef,SheetCellRef} = nothing)::Chart
+ title = _chart_title(title)
+ k = _check_chart_args(kind, title)
+ _check_title_ref(get_xlsxfile(ws), ws.name, title)
+ path = _add_chart_part!(ws, _chart_shell(CHART_KIND_TEMPLATES[k.template]; title = _shell_title(title)),
+ k.style; anchor = anchor isa CellRange ? anchor : CellRange(anchor))
+ return _bind_title(getChart(ws, path), title)
end
+# The `title` keyword as the chart is built from it. The StyledStrings extension
+# adds a method turning a styled string into a RichTextString; a keyword can't be
+# dispatched on, so addChart calls this rather than the extension overloading it.
+_chart_title(title) = title
+
+# A bound title starts as the template's own title, keeping its formatting, and
+# is bound once the chart exists and the reference can be resolved.
+_shell_title(title) = title isa Union{CellRef,SheetCellRef} ? nothing : title
+_bind_title(c, title) = title isa Union{CellRef,SheetCellRef} ? setChartTitleText(c, title) : c
+
const CHARTSHEET_TEMPLATE = """
"""
@@ -185,11 +229,11 @@ build_absolute_anchor(content::XML.Node)::XML.Node =
content,
XML.Element("xdr:clientData"))
- function _check_chart_args(kind::Symbol, title)
+function _check_chart_args(kind::Symbol, title)
haskey(C_KINDS, kind) || throw(XLSXError(
"Unknown chart kind `:$kind`. Use one of " * join((":$k" for k in keys(C_KINDS)), ", ") * "."))
title === true && throw(XLSXError(
- "`title` is a string, `nothing` for Excel's automatic title, or `false` for none."))
+ "`title` is a string, a cell reference, `nothing` for Excel's automatic title, or `false` for none."))
return C_KINDS[kind]
end
@@ -198,17 +242,20 @@ end
Add an empty chart of `kind` on a new chartsheet, placed after the existing
sheets. `sheetname` defaults to `Chart1`, `Chart2`, … as Excel names them. See
-the worksheet method for `kind` and `title`.
+the worksheet method for `kind` and `title`. A title bound to a cell must name
+its sheet — a `SheetCellRef`, not a `CellRef` — since a chartsheet has no cells.
"""
function addChart(xf::XLSXFile, kind::Symbol; sheetname::AbstractString = "",
- title::Union{Nothing,Bool,AbstractString} = nothing)::Chart
+ title::Union{Nothing,Bool,AbstractString,CellRef,SheetCellRef} = nothing)::Chart
+ title = _chart_title(title)
k = _check_chart_args(kind, title)
+ _check_title_ref(xf, nothing, title) # before the sheet exists
ws = _register_sheet!(get_workbook(xf), parse(CHARTSHEET_TEMPLATE, XML.Node), sheetname;
dir = "xl/chartsheets", reltype = REL_CHARTSHEET,
mime = MIME_CHARTSHEET, default_name = "Chart")
- path = _add_chart_part!(ws, _chart_shell(CHART_KIND_TEMPLATES[k.template]; title), k.style;
- anchor = nothing)
- return getChart(xf, path)
+ path = _add_chart_part!(ws, _chart_shell(CHART_KIND_TEMPLATES[k.template]; title = _shell_title(title)),
+ k.style; anchor = nothing)
+ return _bind_title(getChart(xf, path), title)
end
# The c:f text for a resolved reference: sheet-qualified and absolute, as a chart
@@ -293,24 +340,62 @@ function _cache_kind(vals::Vector, xf::XLSXFile)
return ("numRef", "numCache", fmt)
end
+# A c: element in the part's own prefix, as _el builds an a: one. Chart parts
+# Excel writes use `c`, but the prefix is the part's choice.
+_c_el(pfx, tag, kids...; attrs...) = XML.Element(prefixed_tag(pfx[NS_C], tag), kids...; attrs...)
+
# ……. Missing values are
-# omitted, as Excel does; ptCount still spans the whole range.
-function _cache_ref(f::AbstractString, vals::Vector, xf::XLSXFile)
- reftag, cachetag, fmt = _cache_kind(vals, xf)
+# omitted, as Excel does; ptCount still spans the whole range. `str` forces a
+# strRef, for an element such as a title's c:tx that allows no other. `pfx` is
+# the chart part's prefixes.
+function _cache_ref(f::AbstractString, vals::Vector, xf::XLSXFile, pfx; str::Bool = false)
+ reftag, cachetag, fmt = str ? ("strRef", "strCache", string) : _cache_kind(vals, xf)
cache = XML.Node[]
- reftag == "numRef" && push!(cache, XML.Element("c:formatCode", XML.Text("General")))
- push!(cache, XML.Element("c:ptCount"; val = string(length(vals))))
+ reftag == "numRef" && push!(cache, _c_el(pfx, "formatCode", XML.Text("General")))
+ push!(cache, _c_el(pfx, "ptCount"; val = string(length(vals))))
for (i, v) in enumerate(vals)
ismissing(v) && continue
- push!(cache, XML.Element("c:pt", XML.Element("c:v", XML.Text(fmt(v))); idx = string(i - 1)))
+ push!(cache, _c_el(pfx, "pt", _c_el(pfx, "v", XML.Text(fmt(v))); idx = string(i - 1)))
end
- return XML.Element("c:$reftag", XML.Element("c:f", XML.Text(f)), XML.Element("c:$cachetag", cache...))
+ return _c_el(pfx, reftag, _c_el(pfx, "f", XML.Text(f)), _c_el(pfx, cachetag, cache...))
+end
+
+_throw_unqualified_title(ref::CellRef) = throw(XLSXError(
+ "`$ref` names no sheet. A chart on a chartsheet has no cells of its own, so a " *
+ "title bound to a cell must name the sheet holding it: pass a `SheetCellRef`, " *
+ "such as `SheetCellRef(\"Data!$ref\")`."))
+
+# Resolve a title's cell reference, if it is one, before a chart is built, so a bad
+# one leaves the workbook untouched. `sheet` is `nothing` for a chartsheet.
+function _check_title_ref(xf::XLSXFile, sheet, title)
+ title isa Union{CellRef,SheetCellRef} || return nothing
+ isnothing(sheet) && title isa CellRef && _throw_unqualified_title(title)
+ _resolve_ref(xf, sheet, title)
+ return nothing
+end
+
+# The cell a title is bound to: its formula, its value (a one-element vector) and
+# its RichTextString if it holds one (`nothing` otherwise).
+function _title_source(c::AbstractChart, ref::Union{CellRef,SheetCellRef})
+ sheet = _data_sheet(c)
+ isnothing(sheet) && ref isa CellRef && _throw_unqualified_title(ref)
+ f, vals = _series_ref(c, ref)
+ ws, cr = ref isa SheetCellRef ? (getsheet(c.package, ref.sheet), ref.cellref) :
+ (getsheet(c.package, sheet), ref)
+ return f, vals, getRichTextString(ws, cr)
+end
+
+# A c: title bound to `ref`: the c:strRef, always a string cache whatever the cell
+# holds, and the cell's RichTextString.
+function _title_ref(c::Chart, ref::Union{CellRef,SheetCellRef})
+ f, vals, cellrich = _title_source(c, ref)
+ return _cache_ref(f, vals, c.package, ns_prefixes(chart_root(c)); str = true), cellrich
end
# A data child: …, and its value count.
-function _data_child(c::Chart, tag::AbstractString, ref)
+function _data_child(c::Chart, tag::AbstractString, ref, pfx)
f, vals = _series_ref(c, ref)
- return XML.Element("c:$tag", _cache_ref(f, vals, c.package)), length(vals)
+ return _c_el(pfx, tag, _cache_ref(f, vals, c.package, pfx)), length(vals)
end
"""
@@ -352,37 +437,47 @@ _accent(i::Integer) = "accent" * string(mod1(i, 6))
# A scatter series joined by a line: the template's noFill a:ln replaced with a
# solid accent1 line, which _with_accent then retargets. 1.5 pt, as Excel's
# Scatter with Straight Lines writes.
-function _with_scatter_line(sp::XML.Node)
+function _with_scatter_line(sp::XML.Node, pfx)
ln = first_element_with_tag(sp, "ln")
- new = XML.Element("a:ln",
- XML.Element("a:solidFill", XML.Element("a:schemeClr"; val = "accent1")),
- XML.Element("a:round"); w = "19050", cap = "rnd")
+ new = _el(pfx, "ln",
+ _el(pfx, "solidFill", _el(pfx, "schemeClr"; val = "accent1")),
+ _el(pfx, "round"); w = "19050", cap = "rnd")
return isnothing(ln) ? insert_child(sp, (NS_A, "spPr"), new) : replace_child(sp, ln, new)
end
-const _SCHEME_NAMES = r"^(accent[1-6]|tx[12]|bg[12]|dk[12]|lt[12]|hlink|folHlink)$"
-
-# The colour element for `color`, built as the series setters build it, with the
-# template's child transforms (a bubble's a:alpha) carried across. A scheme name
-# string becomes an a:schemeClr; anything else goes through the package's colour
-# resolver, so it accepts exactly what setSeriesFill does.
+# The color element for `color`, built as the series setters build it, with the
+# template's child transforms (a bubble's a:alpha) carried across. Built by
+# _color_node, so it accepts exactly what setSeriesFill does.
function _series_color_node(color, template_clr::XML.Node, pfx)
- node = color isa SchemeColor ? _scheme_color_node(color, pfx) :
- color isa AbstractString && occursin(_SCHEME_NAMES, color) ?
- XML.Element(prefixed_tag(pfx[NS_A], "schemeClr"); val = color) :
- _srgb_color_node(color isa Symbol ? String(color) : color, pfx)
+ node = _color_node(color, pfx)
extra = XML.children(template_clr)
(isnothing(extra) || isempty(extra)) && return node
return _with_children(node, vcat(something(XML.children(node), XML.Node[]), extra))
end
-# Every accent1 in a template fragment, recoloured.
+# Every accent1 in a template fragment, recolored.
_with_color(n::XML.Node, color, pfx) =
_rewrite(n) do e
localname(e) == "schemeClr" && _attr(e, "val") == "accent1" || return e
return _series_color_node(color, e, pfx)
end
+# A fragment built with Excel's usual prefixes — `from` maps each to its namespace
+# — renamed into a part's own prefixes `pfx`. Any other prefix is left alone, as
+# is one whose namespace `pfx` lacks.
+const _TEMPLATE_NS = Dict("c" => NS_C, "a" => NS_A)
+
+_in_prefixes(node::XML.Node, pfx; from = _TEMPLATE_NS) =
+ _rewrite(node) do e
+ tag = XML.tag(e)
+ i = findfirst(':', tag)
+ isnothing(i) && return e
+ ns = get(from, tag[1:prevind(tag, i)], nothing)
+ (isnothing(ns) || !haskey(pfx, ns)) && return e
+ new = prefixed_tag(pfx[ns], tag[nextind(tag, i):end])
+ return new == tag ? e : typeof(e)(XML.nodetype(e), new, e.attributes, XML.value(e), e.children)
+ end
+
# The formatting children of a kind's template series: spPr, marker, smooth,
# invertIfNegative, bubble3D, and for pie and doughnut the per-point dPt list.
function _series_pattern(kind::Symbol)
@@ -422,19 +517,22 @@ neither.
To plot a table column, pass gettablerange(t, column) for either `values` or `categories`
-`markers` (line, lineMarkers, radar and scatter) is a symbol such as `:circle`,
-or `false` for none. `smooth` (line, lineMarkers and scatter) curves the line.
-`line` (scatter) joins the points.
+`markers` (line, lineMarkers, radar and scatter) is a marker Symbol such as
+`:circle`, `:square` or `:triangle` — any that [`setMarkerSymbol`](@ref)
+takes — or `true` for `:circle`, or `false` for none. Markers on a `:line`
+chart make it `:lineMarkers`. `smooth` (line, lineMarkers and scatter) is a `Bool`:
+`true` curves the line. `line` (scatter) is a `Bool`: `true` joins the points.
-`color` overrides the series' automatic colour, and takes the same values as
-[`setSeriesFill`](@ref): a colour name (`"red"` or `:red`), a hex colour
-(`"FF0000"`, `"#FF0000"` or `"FFFF0000"`), a `Colors.Colorant`, a
-[`SchemeColor`](@ref), or a scheme name such as `"accent4"`. It does not apply
-to pie and doughnut charts, whose points are coloured individually.
+`color` overrides the series' automatic color. It is a color name (`"red"` or
+`:red`), a hex string (`"FF0000"`, `"#FF0000"` or `"80FF0000"` with alpha), a
+`Colors.Colorant`, a theme color named as a String or Symbol (`"accent2"`,
+`:tx1`) or a [`SchemeColor`](@ref) — the color values of
+[`setSeriesFill`](@ref) other than `:none` and `:inherit`. It does not apply to
+pie and doughnut charts, whose points are colored individually.
The cells' current values are cached in the chart, as Excel does, so the series
reads back without opening the file in Excel. Series take the theme's accent
-colours in turn; on a pie or doughnut chart each point does instead.
+colors in turn; on a pie or doughnut chart each point does instead.
"""
function addSeries(c::Chart, values;
categories = nothing, name = nothing, name_ref = nothing,
@@ -476,15 +574,16 @@ function addSeries(c::Chart, values;
idx = maximum((something(_int_val(s, "idx"), -1) for s in sers); init = -1) + 1
ord = maximum((something(_int_val(s, "order"), -1) for s in sers); init = -1) + 1
- kids = XML.Node[XML.Element("c:idx"; val = string(idx)),
- XML.Element("c:order"; val = string(ord))]
- isnothing(name) || push!(kids, XML.Element("c:tx", XML.Element("c:v", XML.Text(name))))
+ kids = XML.Node[_c_el(pfx, "idx"; val = string(idx)),
+ _c_el(pfx, "order"; val = string(ord))]
+ isnothing(name) || push!(kids, _c_el(pfx, "tx", _c_el(pfx, "v", XML.Text(name))))
if !isnothing(name_ref)
nf, nv = _series_ref(c, name_ref)
- push!(kids, XML.Element("c:tx", _cache_ref(nf, nv, c.package)))
+ # A series name is text: ser/tx allows a strRef, never a numRef, even for a number cell.
+ push!(kids, _c_el(pfx, "tx", _cache_ref(nf, nv, c.package, pfx; str = true)))
end
- pattern = _series_pattern(kind)
+ pattern = [_in_prefixes(k, pfx) for k in _series_pattern(kind)]
col = something(color, _accent(n + 1))
for k in pattern
t = localname(k)
@@ -494,26 +593,26 @@ function addSeries(c::Chart, values;
elseif t == "smooth" && !isnothing(smooth)
k = with_attribute(k, "val", smooth ? "1" : "0")
elseif t == "spPr" && line === true
- k = _with_scatter_line(k)
+ k = _with_scatter_line(k, pfx)
end
push!(kids, _varies_by_point(kind) ? k : _with_color(k, col, pfx))
end
- val, npts = _data_child(c, isxy ? "yVal" : "val", values)
+ val, npts = _data_child(c, isxy ? "yVal" : "val", values, pfx)
if !isnothing(categories)
- cat, ncat = _data_child(c, isxy ? "xVal" : "cat", categories)
+ cat, ncat = _data_child(c, isxy ? "xVal" : "cat", categories, pfx)
push!(kids, cat)
npts = max(npts, ncat)
end
push!(kids, val)
- isnothing(bubble_sizes) || push!(kids, first(_data_child(c, "bubbleSize", bubble_sizes)))
+ isnothing(bubble_sizes) || push!(kids, first(_data_child(c, "bubbleSize", bubble_sizes, pfx)))
if _varies_by_point(kind)
proto = pattern[findfirst(k -> localname(k) == "dPt", pattern)]
append!(kids, _vary_color_points(proto, npts, pfx))
end
- ser = XML.Element("c:ser", kids...)
+ ser = _c_el(pfx, "ser", kids...)
gtag = String(localname(g))
new = rebuild_path(root,
[(NS_C, "chart") => "chart",
@@ -654,15 +753,19 @@ layout.
"""
function addChartEx(ws::Worksheet, kind::Symbol, values; anchor::Union{AbstractString,CellRange},
categories = nothing, name = nothing, name_ref = nothing,
- title::Union{Nothing,Bool,AbstractString} = nothing)::ChartEx
+ title::Union{Nothing,Bool,AbstractString,CellRef,SheetCellRef} = nothing)::ChartEx
+ title = _chart_title(title)
+ _check_title_ref(get_xlsxfile(ws), ws.name, title)
doc = _chartex_doc(get_xlsxfile(ws), ws.name, kind, values; categories, name, name_ref, title)
return _place_chartex!(ws, doc, kind, anchor isa CellRange ? anchor : CellRange(anchor), title)
end
function addChartEx(xf::XLSXFile, kind::Symbol, values; sheetname::AbstractString = "",
categories = nothing, name = nothing, name_ref = nothing,
- title::Union{Nothing,Bool,AbstractString} = nothing)::ChartEx
+ title::Union{Nothing,Bool,AbstractString,CellRef,SheetCellRef} = nothing)::ChartEx
+ title = _chart_title(title)
_require_qualified(values, categories, name_ref) # a chartsheet holds no cells
+ _check_title_ref(xf, nothing, title)
doc = _chartex_doc(xf, nothing, kind, values; categories, name, name_ref, title)
ws = _register_sheet!(get_workbook(xf), parse(CHARTSHEET_TEMPLATE, XML.Node), sheetname;
dir = "xl/chartsheets", reltype = REL_CHARTSHEET,
@@ -677,7 +780,8 @@ function _chartex_doc(xf::XLSXFile, sheet, kind::Symbol, values;
categories, name, name_ref, title)
haskey(CX_KINDS, kind) || throw(XLSXError(
"Unknown chartEx kind `:$kind`. Use one of " * join((":$k" for k in keys(CX_KINDS)), ", ") * "."))
- title === true && throw(XLSXError("`title` is a string, `nothing` for the default, or `false` for none."))
+ title === true && throw(XLSXError(
+ "`title` is a string, a cell reference, `nothing` for the default, or `false` for none."))
doc = _chartex_shell(kind; title)
root = xml_root_element(doc)
return replace_child(doc, root,
@@ -688,10 +792,25 @@ function _place_chartex!(ws::Worksheet, doc::XML.Node, kind::Symbol, anchor, tit
k = CX_KINDS[kind]
path = _add_chart_part!(ws, doc, k.style; anchor, cx_requires = k.requires)
c = getChart(ws, path)
- title isa AbstractString && setChartTitleText(c, title)
+ title isa Union{AbstractString,CellRef,SheetCellRef} && setChartTitleText(c, title)
return c
end
+"""
+ addSeries(c::ChartEx, values; categories=nothing, name=nothing, name_ref=nothing) -> ChartEx
+
+Add a series to a box & whisker chart, plotting `values`. Every other `chartEx`
+layout holds one series, given to [`addChartEx`](@ref).
+
+References, `categories`, `name` and `name_ref` are as for the `Chart` method: a
+range string (`"B2:B9"`), a defined name or a reference object, with an
+unqualified range meaning the chart's own sheet and a chart on a chartsheet
+needing a qualified one (`"Data!B2:B9"`). `name` is literal text for the series
+name, `name_ref` a cell to take it from.
+
+Format the new series afterwards with [`setSeriesFill`](@ref) and
+[`setSeriesLine`](@ref).
+"""
function addSeries(c::ChartEx, values; categories = nothing, name = nothing, name_ref = nothing)::ChartEx
sheet = _data_sheet(c) # nothing on a chartsheet
isnothing(sheet) && _require_qualified(values, categories, name_ref)
diff --git a/src/charts/drawingml.jl b/src/charts/drawingml.jl
index 4387c2d3..e0ec4828 100644
--- a/src/charts/drawingml.jl
+++ b/src/charts/drawingml.jl
@@ -4,8 +4,8 @@
# Shared DrawingML parsing: the `a:` namespace elements that appear throughout
# chart parts, drawings and themes.
#
-# Colours first. Every fill, line and text run in DrawingML resolves to one of
-# six colour elements, optionally modified by transforms. Parsing this once and
+# Colors first. Every fill, line and text run in DrawingML resolves to one of
+# six color elements, optionally modified by transforms. Parsing this once and
# reusing it is what keeps the chart formatting work tractable: `spPr` alone
# appears on the chart space, plot area, every series, every data point, every
# axis, the legend, the title, data labels, gridlines, trendlines and error
@@ -15,7 +15,7 @@
# images or other drawing objects need it, it can move to the core or become a
# sub-module of its own.
-# The six colour elements. All carry a `val` attribute except `scrgbClr`, which
+# The six color elements. All carry a `val` attribute except `scrgbClr`, which
# uses separate r/g/b percentage attributes.
const DML_COLOR_TAGS = ("srgbClr", "schemeClr", "sysClr", "prstClr", "hslClr", "scrgbClr")
@@ -30,13 +30,13 @@ const DML_COLOR_KIND = Dict(
# Transforms we resolve. Others (hueMod, red, green, blue, gamma, inv, gray,
# comp) are recorded but not applied: they are vanishingly rare in chart parts,
-# and applying them wrongly would be worse than leaving the base colour.
+# and applying them wrongly would be worse than leaving the base color.
const DML_APPLIED_TRANSFORMS = (:lumMod, :lumOff, :satMod, :shade, :tint, :alpha)
"""
-The DrawingML colour element among the children of `node`, or `nothing`.
+The DrawingML color element among the children of `node`, or `nothing`.
-A fill, line or run-property element holds at most one colour child, so this
+A fill, line or run-property element holds at most one color child, so this
returns the first match rather than a list.
"""
function color_element(node::Union{Nothing,XML.Node})::Union{Nothing,XML.Node}
@@ -48,11 +48,11 @@ function color_element(node::Union{Nothing,XML.Node})::Union{Nothing,XML.Node}
end
"""
-Resolve a colour reference to `"RRGGBB"`, before any transforms are applied.
+Resolve a color reference to `"RRGGBB"`, before any transforms are applied.
`schemeClr` goes through the workbook theme; `sysClr` prefers the `lastClr`
-attribute Excel writes alongside the system colour name, since the name itself
-depends on OS settings; `prstClr` goes through the Colors.jl named colours.
+attribute Excel writes alongside the system color name, since the name itself
+depends on OS settings; `prstClr` goes through the Colors.jl named colors.
"""
function resolve_color_base(wb::Workbook, node::XML.Node)::String
tag = localname(node)
@@ -115,11 +115,11 @@ end
"""
parse_drawing_color(wb, node) -> Union{Nothing,DrawingColor}
-Parse the DrawingML colour held by `node` - a fill, a line, a run property -
+Parse the DrawingML color held by `node` - a fill, a line, a run property -
into a [`DrawingColor`](@ref), or `nothing` where it holds none.
-Pass the colour element itself, or its parent: a parent is searched for its
-colour child.
+Pass the color element itself, or its parent: a parent is searched for its
+color child.
"""
function parse_drawing_color(wb::Workbook, node::Union{Nothing,XML.Node})::Union{Nothing,DrawingColor}
isnothing(node) && return nothing
@@ -703,7 +703,7 @@ text_runs(t::DrawingText) = [r for p in t.paragraphs for r in p.runs]
# differing only by spellcheck language are the same formatting.
#
# Two conventions shared with `Base.:(==)(::RichTextRun, ::RichTextRun)`:
-# colours compare on their resolved value rather than how they were written
+# colors compare on their resolved value rather than how they were written
# (as that does via `get_color`), and an absent property is distinct from one
# set to a default (as that does by comparing key sets).
_color_key(c) = c === nothing ? nothing : (c.rgb, c.alpha)
@@ -841,12 +841,25 @@ resolves it.
"""
function _sp_with_fill(sp::XML.Node, key::SchemaKey, color, pfx::Dict{String,String})
color === :inherit && return remove_choice(sp, key, FILL_GROUP)
- fill = color === :none ? XML.Element(prefixed_tag(pfx[NS_A], "noFill")) :
- color isa SchemeColor ? _solid_fill_node(_scheme_color_node(color, pfx), pfx) :
- _solid_fill_node(_srgb_color_node(color, pfx), pfx)
+ fill = color === :none ? XML.Element(prefixed_tag(pfx[NS_A], "noFill")) :
+ _solid_fill_node(_color_node(color, pfx), pfx)
return insert_child(remove_choice(sp, key, FILL_GROUP), key, fill)
end
+"""
+ _color_node(color, pfx) -> XML.Node
+
+The color element for any color value the setters take. A [`SchemeColor`](@ref),
+or the name of a theme color slot as a string or Symbol (`"accent2"`, `:tx1`),
+becomes an `a:schemeClr`; anything else — a color name, hex string or
+`Colors.Colorant` — an `a:srgbClr`.
+"""
+_color_node(color::SchemeColor, pfx::Dict{String,String}) = _scheme_color_node(color, pfx)
+_color_node(color::Union{AbstractString,Symbol}, pfx::Dict{String,String}) =
+ Symbol(color) in SCHEME_TOKENS ? _scheme_color_node(SchemeColor(Symbol(color)), pfx) :
+ _srgb_color_node(color, pfx)
+_color_node(color, pfx::Dict{String,String}) = _srgb_color_node(color, pfx)
+
"""
_sp_with_line(sp, key, f, pfx) -> XML.Node
@@ -947,9 +960,8 @@ end
function _ln_with_color(ln, color, pfx)
color === :inherit && return remove_choice(ln, (NS_A, "ln"), FILL_GROUP)
- fill = color === :none ? XML.Element(prefixed_tag(pfx[NS_A], "noFill")) :
- color isa SchemeColor ? _solid_fill_node(_scheme_color_node(color, pfx), pfx) :
- _solid_fill_node(_srgb_color_node(color, pfx), pfx)
+ fill = color === :none ? XML.Element(prefixed_tag(pfx[NS_A], "noFill")) :
+ _solid_fill_node(_color_node(color, pfx), pfx)
return insert_child(remove_choice(ln, (NS_A, "ln"), FILL_GROUP), (NS_A, "ln"), fill)
end
@@ -964,6 +976,39 @@ const CMPD_VALUES = (:sng, :dbl, :thickThin, :thinThick, :tri)
const JOIN_VALUES = (:round, :bevel, :miter)
+# ST_TextUnderlineType, ST_TextStrikeType and ST_TextCapsType (dml-main.xsd), the
+# run-property values written as attributes. Written unchecked, another string
+# makes a part Excel refuses to open.
+const UNDER_VALUES = (:none, :words, :sng, :dbl, :heavy, :dotted, :dottedHeavy,
+ :dash, :dashHeavy, :dashLong, :dashLongHeavy, :dotDash,
+ :dotDashHeavy, :dotDotDash, :dotDotDashHeavy,
+ :wavy, :wavyHeavy, :wavyDbl)
+const STRIKE_VALUES = (:noStrike, :sngStrike, :dblStrike)
+const CAPS_VALUES = (:none, :small, :all)
+
+# Excel's Font dialog names for the commonest of them.
+const UNDER_ALIASES = Dict{Symbol,Symbol}(:single => :sng, :double => :dbl)
+const STRIKE_ALIASES = Dict{Symbol,Symbol}(:none => :noStrike, :single => :sngStrike,
+ :double => :dblStrike)
+
+# Docstring fragments, built from the vocabularies the setters check so that a
+# value added to one appears in the other. Defined here, ahead of creation.jl
+# and the property files whose docstrings interpolate them.
+_doc_syms(xs) = join(("`:$x`" for x in xs), ", ")
+_doc_aliases(d) = join(("`:$k` (= `:$v`)" for (k, v) in sort!(collect(d); by = string ∘ first)), ", ")
+
+# The `color` argument of every color setter.
+const _DOC_COLOR_ARG = """
+`color` is a color name (`"red"` or `:red`), a hex string (`"FF0000"`,
+`"#FF0000"` or `"80FF0000"` with alpha), a `Colors.Colorant`, a theme color
+named as a String or Symbol (`"accent2"`, `:tx1`), a [`SchemeColor`](@ref), or
+`:none` or `:inherit` — see [`setSeriesFill`](@ref)."""
+
+# The keywords of every multi-property line setter.
+const _DOC_LINE_KWS = """
+Keywords are `color`, `width`, `dash`, `cap`, `compound`, `join` and
+`miterLimit`, each taking the values listed at [`setSeriesLine`](@ref)."""
+
"""
_ln_with_join(ln, join, pfx) -> XML.Node
@@ -1024,7 +1069,7 @@ function _color_node_from(dc::DrawingColor, pfx::Dict{String,String})
return _el(pfx, tag, kids...; val = dc.val)
end
_color_node_from(sc::SchemeColor, pfx::Dict{String,String}) = _scheme_color_node(sc, pfx)
-_color_node_from(s::AbstractString, pfx::Dict{String,String}) = _srgb_color_node(s, pfx)
+_color_node_from(s::AbstractString, pfx::Dict{String,String}) = _color_node(s, pfx)
"""
_fill_node_from(fill::DrawingFill, pfx) -> XML.Node
@@ -1077,7 +1122,6 @@ end
# which is what makes a constructed DrawingText say only what the caller asked.
_el(pfx, tag, kids...; attrs...) = XML.Element(prefixed_tag(pfx[NS_A], tag), kids...; attrs...)
-#_el(pfx, tag; kw...) = XML.Element(prefixed_tag(pfx[NS_A], tag); kw...)
_emu(points) = round(Int, points * EMU_PER_POINT)
@@ -1087,14 +1131,25 @@ const _RUN_PROP_ATTRS = (
:size => ("sz", v -> string(round(Int, v * 100))),
:bold => ("b", v -> v ? "1" : "0"),
:italic => ("i", v -> v ? "1" : "0"),
- :under => ("u", string),
- :strike => ("strike", string),
- :caps => ("cap", string),
+ :under => ("u", v -> _run_enum(v, UNDER_VALUES, "underline", UNDER_ALIASES, (:sng, :none))),
+ :strike => ("strike", v -> _run_enum(v, STRIKE_VALUES, "strike", STRIKE_ALIASES, (:sngStrike, :noStrike))),
+ :caps => ("cap", v -> _run_enum(v, CAPS_VALUES, "caps")),
:baseline => ("baseline", v -> string(round(Int, v * 100000))),
:kern => ("kern", v -> string(round(Int, v * 100))),
:spacing => ("spc", v -> string(round(Int, v * 100))),
)
+# One of `allowed` (or an alias) as a String, from a Symbol or String. Where
+# `onoff` is given, `true` and `false` stand for its two values, as a rich-text
+# cell's `:under` and `:strike` flags do.
+function _run_enum(v, allowed, what, aliases = Dict{Symbol,Symbol}(), onoff = nothing)
+ v isa Bool && !isnothing(onoff) && return String(v ? onoff[1] : onoff[2])
+ v isa Union{Symbol,AbstractString} || throw(XLSXError(
+ "A $what value is a Symbol or String" * (isnothing(onoff) ? "" : ", or a Bool") *
+ "; got $(repr(v))."))
+ return String(_check(v, allowed, what, aliases))
+end
+
# Field -> typeface element tag.
const _RUN_PROP_FONTS = Dict{Symbol,String}(
:latin => "latin", :ea => "ea", :cs => "cs",
diff --git a/src/charts/types.jl b/src/charts/types.jl
index f701cfc5..00d0d4eb 100644
--- a/src/charts/types.jl
+++ b/src/charts/types.jl
@@ -216,8 +216,8 @@ One data dimension of a chartEx data block: a `cx:numDim` or `cx:strDim`.
# Fields
- `kind` - `:num` or `:str`.
- `type` - the dimension's role as written, e.g. `:val` (values), `:cat`
- (categories), `:size`, `:x`, `:y`, `:colorVal` (numeric-colour), `:colorStr`
- (text-colour), `:entityId` (region).
+ (categories), `:size`, `:x`, `:y`, `:colorVal` (numeric-color), `:colorStr`
+ (text-color), `:entityId` (region).
- `formula` - the text of `cx:f`, usually a hidden `_xlchart.*` defined name;
`nothing` if the dimension has no formula.
- `range` - the resolved source range; `nothing` if unresolved.
@@ -259,97 +259,13 @@ struct ChartExBinning
binCount::Union{Nothing,Int,Symbol}
end
-const SCHEME_TOKENS = (:bg1, :tx1, :bg2, :tx2, :accent1, :accent2, :accent3,
- :accent4, :accent5, :accent6, :hlink, :folHlink,
- :lt1, :dk1, :lt2, :dk2)
-
-const COLOR_TRANSFORMS = (:lumMod, :lumOff, :shade, :tint, :satMod, :alpha)
-
-# bg1/lt1, tx1/dk1, bg2/lt2 and tx2/dk2 name the same slot. The field keeps the
-# token as written so a round trip preserves it; equality and hashing normalize
-# so two spellings of one color compare equal.
-const SCHEME_ALIASES = Dict(:lt1 => :bg1, :dk1 => :tx1, :lt2 => :bg2, :dk2 => :tx2)
-
-"""
- SchemeColor(token; lumMod = nothing, lumOff = nothing, ...)
- SchemeColor(token, transforms)
-
-A DrawingML theme color: a token naming a slot in the workbook's color scheme,
-plus any transforms applied to it. Distinct from the spreadsheet
-`` mechanism, which is index-ordered and uses a different
-tint algorithm — see `get_theme_colors` for that.
-
-`token` is one of `:bg1` (background), `:tx1` (text), `:bg2`, `:tx2` (likewise),
-`:accent1` … `:accent6`, `:hlink` (hyperlink), `:folHlink` (visited-hyperlink),
-or the aliases `:lt1` (light), `:dk1` (dark), `:lt2`, `:dk2`.
-
-The `:lt1`, `:dk1`, `:lt2` and `:dk2` aliases are preserved as written, so a
-round trip keeps the spelling the file used, but two spellings of one slot
-compare and hash equal.
-
-Transforms are held in document order, because DrawingML applies them in
-sequence and `lumMod` before `lumOff` is not the same as the reverse. Equality
-is positional for the same reason: two `SchemeColor`s with the same transforms
-in different orders are not equal, because they do not render the same. Values
-are percentages, not the hundredths DrawingML writes: `lumMod = 75` is 75%.
-
-The keyword constructor emits transforms in the conventional order — `lumMod`
-before `lumOff`, shade or tint last. Any other order needs the vector form.
-
-# Examples
-
- SchemeColor(:accent1)
- SchemeColor(:accent1; lumMod = 75) # 104862 against the Office theme
- SchemeColor(:tx1; lumMod = 65, lumOff = 35) # 595959
- SchemeColor(:accent2, [:shade => 50.0, :alpha => 80.0])
-"""
-struct SchemeColor
- token::Symbol
- transforms::Vector{Pair{Symbol,Float64}}
-
- function SchemeColor(token::Symbol, transforms::Vector{Pair{Symbol,Float64}})
- token in SCHEME_TOKENS || throw(XLSXError(
- "`$token` is not a theme color token. Valid tokens: " *
- join(SCHEME_TOKENS, ", ") * "."))
- for (name, _) in transforms
- name in COLOR_TRANSFORMS || throw(XLSXError(
- "`$name` is not a color transform. Valid transforms: " *
- join(COLOR_TRANSFORMS, ", ") * "."))
- end
- return new(token, transforms)
- end
-end
-
-function SchemeColor(token::Symbol; lumMod = nothing, lumOff = nothing,
- satMod = nothing, shade = nothing, tint = nothing,
- alpha = nothing)
- t = Pair{Symbol,Float64}[]
- isnothing(lumMod) || push!(t, :lumMod => Float64(lumMod))
- isnothing(lumOff) || push!(t, :lumOff => Float64(lumOff))
- isnothing(satMod) || push!(t, :satMod => Float64(satMod))
- isnothing(shade) || push!(t, :shade => Float64(shade))
- isnothing(tint) || push!(t, :tint => Float64(tint))
- isnothing(alpha) || push!(t, :alpha => Float64(alpha))
- return SchemeColor(token, t)
-end
-
-canonical_token(t::Symbol) = get(SCHEME_ALIASES, t, t)
-
-Base.:(==)(a::SchemeColor, b::SchemeColor) =
- canonical_token(a.token) == canonical_token(b.token) && a.transforms == b.transforms
-
-Base.hash(c::SchemeColor, h::UInt) =
- hash(c.transforms, hash(canonical_token(c.token), hash(:SchemeColor, h)))
-
-Base.isequal(a::SchemeColor, b::SchemeColor) = a == b
-
"""
DrawingColor
-A DrawingML colour: the element as written, plus the RGB it resolves to.
+A DrawingML color: the element as written, plus the RGB it resolves to.
-`kind` and `val` record the reference as authored - a theme colour stays a
-theme colour - and `transforms` the modifications applied to it, in document
+`kind` and `val` record the reference as authored - a theme color stays a
+theme color - and `transforms` the modifications applied to it, in document
order. `rgb` and `alpha` give the resolved result for anyone who just wants to
know what it looks like.
@@ -361,7 +277,7 @@ know what it looks like.
in thousandths of a percent, in the order DrawingML applies them. A
[`SchemeColor`](@ref) built in code holds the same transforms as percentages,
so `60000` here is `lumMod = 60` there.
-- `rgb::String` - the resolved colour as `"RRGGBB"`.
+- `rgb::String` - the resolved color as `"RRGGBB"`.
- `alpha::Float64` - `1.0` unless an `alpha` transform applies.
`rgb` and `alpha` are resolved values, not source, and are not written — the
@@ -393,7 +309,7 @@ const ColorSpec = Union{DrawingColor, SchemeColor, String}
A DrawingML fill: ``, ``, ``, ``
or ``.
-A solid fill resolves to one colour in `fgcolor`. A pattern fill resolves to
+A solid fill resolves to one color in `fgcolor`. A pattern fill resolves to
two, `fgcolor` and `bgcolor`, with the pattern itself in `preset` - matching
how cell fills are exposed. Gradient and picture fills are identified by `kind`
but not modelled further: `raw` holds the element as read, so nothing is lost
@@ -402,7 +318,7 @@ on write.
# Fields
- `kind::Symbol` - `:none`, `:solid`, `:gradient`, `:pattern`, `:blip` (picture)
or `:group`.
-- `fgcolor` - the colour of a solid fill, or a pattern's foreground.
+- `fgcolor` - the color of a solid fill, or a pattern's foreground.
- `bgcolor` - a pattern's background; `nothing` otherwise.
- `preset::Union{Nothing,String}` - a pattern's `prst` attribute, e.g. `"pct25"`,
`"ltUpDiag"`.
@@ -437,7 +353,7 @@ DrawingFill(kind::Symbol; fgcolor = nothing, bgcolor = nothing, preset = nothing
A DrawingML outline: ``.
-The stroke colour lives in `fill`, since a line is filled the same way a shape
+The stroke color lives in `fill`, since a line is filled the same way a shape
is - solid, gradient, pattern or none.
# Fields
@@ -482,7 +398,7 @@ series, data points, the plot area, the chart area, axis lines, legend, gridline
|---------------------------------|------------------------|------------------------|
| no fill element | `nothing` | inherit from the style |
| `` | `kind == :none` | deliberately invisible |
-| `…` | `kind == :solid` | this colour |
+| `…` | `kind == :solid` | this color |
The same applies to `line.fill`: `` is how Excel writes
"no border", which is not the same as omitting `a:ln` entirely.
@@ -511,12 +427,27 @@ end
"""
DrawingRunProps
-Character-level properties: `a:rPr`, `a:defRPr` or `a:endParaRPr`.
-
-Sizes are points (the file stores 1/100 pt), `baseline` is a fraction of the font
-size, and `under` / `strike` / `caps` keep the DrawingML vocabulary as written
-(`"sng"`, `"noStrike"`, `"small"`). Typefaces may be theme references —
-`"+mn-lt"` for the minor latin font, `"+mj-lt"` for major.
+Character-level properties: `a:rPr`, `a:defRPr` or `a:endParaRPr`. A field that
+is `nothing` was not written, and is inherited.
+
+- `lang::String` — language tag, `"en-US"`.
+- `size::Float64` — font size in points (the file stores 1/100 pt).
+- `bold`, `italic::Bool`.
+- `under`, `strike`, `caps::String` — the DrawingML vocabulary as written:
+ `"sng"`, `"noStrike"`, `"small"` and so on.
+- `baseline::Float64` — vertical offset as a fraction of the font size;
+ positive is superscript.
+- `kern::Float64` — smallest font size, in points, at which to kern.
+- `spacing::Float64` — extra space between characters, in points.
+- `fill::DrawingFill` — the text color.
+- `line::DrawingLine` — the text outline.
+- `latin`, `ea`, `cs::String` — typefaces for Latin, East Asian and complex
+ scripts. They may be theme references — `"+mn-lt"` for the minor (body) Latin
+ font, `"+mj-lt"` for major (headings).
+- `raw` — the element this was parsed from.
+
+To set these on a chart, see the table of fields at
+[`setChartTitleTextProp`](@ref), which every text-property setter shares.
"""
struct DrawingRunProps
lang::Union{Nothing,String}
@@ -647,6 +578,78 @@ function _as_runs(s::AbstractString)
return out
end
+# A RichTextString keeps its runs' formatting: each RichTextRun becomes runs
+# carrying its props, split at newlines as a plain string is. In a paragraph a
+# newline is an a:br, itself carrying the props, so the line keeps its height.
+function _as_runs(rts::RichTextString)
+ out = DrawingRun[]
+ for r in rts.runs
+ props = _rich_run_props(r.atts)
+ for (i, piece) in enumerate(split(r.text, _NEWLINE))
+ i > 1 && push!(out, DrawingRun("\n"; kind = :br, props))
+ isempty(piece) || push!(out, DrawingRun(piece; props))
+ end
+ end
+ return out
+end
+
+# In a text body a newline starts a new paragraph. A paragraph left with no text
+# takes the props of the run its newline fell in as its end-of-paragraph props.
+function _as_paragraphs(rts::RichTextString)
+ paras = DrawingParagraph[]
+ runs = DrawingRun[]
+ props = nothing
+ for r in rts.runs
+ props = _rich_run_props(r.atts)
+ for (i, piece) in enumerate(split(r.text, _NEWLINE))
+ if i > 1
+ push!(paras, DrawingParagraph(nothing, runs, isempty(runs) ? props : nothing, nothing))
+ runs = DrawingRun[]
+ end
+ isempty(piece) || push!(runs, DrawingRun(piece; props))
+ end
+ end
+ push!(paras, DrawingParagraph(nothing, runs, isempty(runs) ? props : nothing, nothing))
+ return paras
+end
+
+_rich_flag(v, name) = v in (true, 1) ? true : v in (false, 0) ? false :
+ throw(XLSXError("Rich text attribute `:$name` is `true` or `false`, not `$(repr(v))`."))
+
+const _VERTALIGN_BASELINE = Dict("superscript" => 0.30, "subscript" => -0.25, "baseline" => 0.0)
+
+"""
+ _rich_run_props(atts) -> Union{Nothing,DrawingRunProps}
+
+The DrawingML run properties for a `RichTextRun`'s attributes. A run with none
+gets `nothing`, so it inherits. `false` is written as an explicit "off", so a
+run reads as written even where the element's default is, say, bold.
+"""
+function _rich_run_props(atts)
+ (isnothing(atts) || isempty(atts)) && return nothing
+ kw = Dict{Symbol,Any}()
+ haskey(atts, :bold) && (kw[:bold] = _rich_flag(atts[:bold], :bold))
+ haskey(atts, :italic) && (kw[:italic] = _rich_flag(atts[:italic], :italic))
+ haskey(atts, :under) && (kw[:under] = _rich_flag(atts[:under], :under) ? "sng" : "none")
+ haskey(atts, :strike) && (kw[:strike] = _rich_flag(atts[:strike], :strike) ? "sngStrike" : "noStrike")
+ if haskey(atts, :vertAlign)
+ va = string(atts[:vertAlign])
+ haskey(_VERTALIGN_BASELINE, va) || throw(XLSXError(
+ "Rich text attribute `:vertAlign` is \"superscript\", \"subscript\" or \"baseline\", not `$(repr(atts[:vertAlign]))`."))
+ kw[:baseline] = _VERTALIGN_BASELINE[va]
+ end
+ haskey(atts, :size) && (kw[:size] = convert(Float64, atts[:size]))
+ haskey(atts, :name) && (kw[:latin] = string(atts[:name]))
+ haskey(atts, :color) && (kw[:fill] = DrawingFill(:solid; fgcolor = _rich_fgcolor(atts[:color])))
+ return DrawingRunProps(; kw...)
+end
+
+# A run color as a chart fill color: theme colors stay theme colors.
+_rich_fgcolor(c::SchemeColor) = c
+_rich_fgcolor(c::Union{AbstractString,Symbol}) =
+ Symbol(c) in SCHEME_TOKENS ? SchemeColor(Symbol(c)) : get_color(c)
+_rich_fgcolor(c) = get_color(c)
+
"""
DrawingBodyProps
@@ -959,6 +962,9 @@ struct Effective{T}
value::Union{Nothing,T}
site::Union{Nothing,FormatSite}
chain::Vector{FormatSite}
+ # Inner constructor only: the default `Effective(value, ...)` cannot bind `T`
+ # when `value` is `nothing`, which Aqua reports as an unbound type parameter.
+ Effective{T}(value, site, chain) where {T} = new{T}(value, site, chain)
end
"""
diff --git a/src/conditional-format-helpers.jl b/src/conditional-format-helpers.jl
index 9f46b1cd..94bdf46f 100644
--- a/src/conditional-format-helpers.jl
+++ b/src/conditional-format-helpers.jl
@@ -47,17 +47,40 @@ function process_cf_vecint(f::Function, ws::Worksheet, row, col; kw...)
end
function process_cf_veccolon(f::Function, ws::Worksheet, row, col; kw...)
dim = get_dimension(ws)
- @assert isnothing(row) || isnothing(col) "Something wrong here!"
- if isnothing(col)
- col = dim.start.column_number:dim.stop.column_number
- else
- row = dim.start.row_number:dim.stop.row_number
- end
+ row, col = _expand_colon(dim, row, col)
isInDim(ws, dim, row, col)
cells = [CellRef(a, b) for a in row for b in col]
return f(ws, NonContiguousRange(ws.name, _compress(cells)); kw...)
end
+# Each `setCf*` setter is written by hand for a `CfRange`. This defines every
+# other way of naming the target, each reducing to that one, so that all the
+# setters accept the same forms.
+function define_cf_address_methods(F::Symbol)
+ @eval begin
+ $F(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon($F, ws, row, nothing; kw...)
+ $F(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon($F, ws, nothing, col; kw...)
+ $F(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon($F, ws, nothing, nothing; kw...)
+ $F(ws::Worksheet, ::Colon; kw...) = process_colon($F, ws, nothing, nothing; kw...)
+ $F(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = $F(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
+ $F(ws::Worksheet, cell::CellRef; kw...) = $F(ws, CellRange(cell, cell); kw...)
+ $F(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && $F(ws, CellRange(cell.cellref, cell.cellref); kw...)
+ $F(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && $F(ws, rng.rng; kw...)
+ $F(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && $F(ws, rng.colrng; kw...)
+ $F(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && $F(ws, rng.rowrng; kw...)
+ $F(ws::Worksheet, rng::RowRange; kw...) = process_rowranges($F, ws, rng; kw...)
+ $F(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges($F, ws, rng; kw...)
+ $F(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell($F, xl, sheetcell; kw...)
+ $F(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges($F, ws, ref_or_rng; kw...)
+ $F(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon($F, ws, row, nothing; kw...)
+ $F(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon($F, ws, nothing, col; kw...)
+ $F(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint($F, ws, row, col; kw...)
+ $F(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint($F, ws, row, col; kw...)
+ $F(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint($F, ws, row, col; kw...)
+ end
+ return nothing
+end
+
function allCfs(ws::Worksheet)::Vector{XML.Node}
wb = get_workbook(ws)
xf = get_xlsxfile(ws)
@@ -243,6 +266,19 @@ function _normalise_cfvo_val(ws, val)
end
return uppercase_unquoted(val)
end
+
+# Check one threshold (`cfvo`) of a color scale, data bar or icon set: its type must be
+# one of `types` (or, if `optional`, absent), and its value, unless the type is
+# `formula`, a fixed cell reference or a number. `name` is the keywords' prefix
+# (`min`, `mid`, ...), so errors name the keyword at fault.
+function _check_cfvo(name::AbstractString, type, val, types; optional::Bool=true)
+ (optional && isnothing(type)) || type in types ||
+ throw(XLSXError("Invalid $(name)_type: $type. Valid options are: $(join(types, ", "))."))
+ (!isnothing(type) && type == "formula") || isnothing(val) || is_valid_fixed_cellname(val) ||
+ is_valid_fixed_sheet_cellname(val) || !isnothing(tryparse(Float64, val)) ||
+ throw(XLSXError("Invalid $(name)_val: `$val`. Valid options (unless $(name)_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number."))
+ return nothing
+end
#
# ---- Formatting (styles) definitions for conditional formats
#
@@ -281,15 +317,15 @@ function Add_Cf_Dx(wb::Workbook, new_dx::XML.Node)::DxFormat
return DxFormat(existing_dxf_elements_count) # turns out this is the new index (because it's zero-based)
end
-function get_dx(dxStyle::Union{Nothing,String}, format::Union{Nothing,Vector{Pair{String,String}}}, font::Union{Nothing,Vector{Pair{String,String}}}, border::Union{Nothing,Vector{Pair{String,String}}}, fill::Union{Nothing,Vector{Pair{String,String}}})::Dict{String,Dict{String,String}}
+function get_dx(dxStyle::Union{Nothing,String}, format::Union{Nothing,Vector{<:Pair{String}}}, font::Union{Nothing,Vector{<:Pair{String}}}, border::Union{Nothing,Vector{<:Pair{String}}}, fill::Union{Nothing,Vector{<:Pair{String}}})::AbstractDict{String,<:AbstractDict{String}}
if isnothing(dxStyle)
if all(isnothing.([border, fill, font, format]))
dx = highlights["redfilltext"]
else
- dx = Dict{String,Dict{String,String}}()
+ dx = Dict{String,Dict{String,Any}}()
for att in ["font" => font, "fill" => fill, "border" => border, "format" => format]
if !isnothing(last(att))
- dxx = Dict{String,String}()
+ dxx = Dict{String,Any}()
for i in last(att)
push!(dxx, first(i) => last(i))
end
@@ -304,7 +340,7 @@ function get_dx(dxStyle::Union{Nothing,String}, format::Union{Nothing,Vector{Pai
end
return dx
end
-function get_new_dx(wb::Workbook, dx::Dict{String,Dict{String,String}})::XML.Node
+function get_new_dx(wb::Workbook, dx::AbstractDict{String,<:AbstractDict{String}})::XML.Node
new_dx = XML.Element("dxf")
for k in ["font", "format", "fill", "border"]
@@ -317,7 +353,7 @@ function get_new_dx(wb::Workbook, dx::Dict{String,Dict{String,String}})::XML.Nod
for (y, z) in v
y in ["pattern", "bgColor", "fgColor"] || throw(XLSXError("Invalid fill attribute: $y. Valid options are: `pattern`, `bgColor`, `fgColor`."))
if y in ["fgColor", "bgColor"]
- push!(patterndx, XML.Element(y, rgb=get_color(z)))
+ push!(patterndx, _color_element(y, z))
elseif y == "pattern" && z != "none"
patterndx["patternType"] = z
end
@@ -329,10 +365,13 @@ function get_new_dx(wb::Workbook, dx::Dict{String,Dict{String,String}})::XML.Nod
fontdx = XML.Element("font")
for (y, z) in v
y in ["color", "bold", "italic", "under", "strike"] || throw(XLSXError("Invalid font attribute: $y. Valid options are: `color`, `bold`, `italic`, `under`, `strike`."))
- if y == "color" ; push!(fontdx, XML.Element(y, rgb=get_color(z)))
- elseif y == "bold" && z == "true" ; push!(fontdx, XML.Element("b", val="0"))
- elseif y == "italic" && z == "true" ; push!(fontdx, XML.Element("i", val="0"))
- elseif y == "under" && z != "none" ; push!(fontdx, XML.Element("u"; val="v"))
+ if y == "color" ; push!(fontdx, _color_element(y, z))
+ elseif y == "bold" && z == "true" ; push!(fontdx, XML.Element("b")) # `val="0"` would mean not bold
+ elseif y == "italic" && z == "true" ; push!(fontdx, XML.Element("i"))
+ elseif y == "under"
+ z ∈ ("none", "single", "double") || throw(XLSXError("Invalid font attribute value: under=$z. Valid options are: `none`, `single`, `double`."))
+ z == "single" && push!(fontdx, XML.Element("u"))
+ z == "double" && push!(fontdx, XML.Element("u"; val="double"))
elseif y == "strike" && z == "true" ; push!(fontdx, XML.Element(y))
end
end
@@ -341,7 +380,7 @@ function get_new_dx(wb::Workbook, dx::Dict{String,Dict{String,String}})::XML.Nod
elseif k == "border"
all(y in ["color", "style"] for y in keys(v)) || throw(XLSXError("Invalid border attribute. Valid options are: `color`, `style`."))
borderdx = XML.Element("border")
- cdx = haskey(v, "color") ? XML.Element("color", rgb=get_color(v["color"])) : nothing
+ cdx = haskey(v, "color") ? _color_element("color", v["color"]) : nothing
sdx = get(v, "style", nothing)
for side in ["left", "right", "top", "bottom"]
el = XML.Element(side)
@@ -407,7 +446,7 @@ function _check_cf_range(ws::Worksheet, ncr::NonContiguousRange)
end
# ---------------------------------------------------------------------------
-# Colour bands
+# Color bands
# ---------------------------------------------------------------------------
# "FFRRGGBB" -> Colorant, for interpolation. `get_color` guarantees 8 hex digits,
@@ -424,13 +463,18 @@ end
# Interpolation is done in LCHab rather than RGB: an RGB path from green to red
# passes through a muddy olive, which defeats the point of visually ordered bands.
-function _band_colors(spec, n::Int)::Vector{String}
- cols = get_color.(spec isa Union{AbstractString,Symbol} ? [spec] : collect(spec))
+# RGB colors come back as "AARRGGBB"; theme colors are kept as given, so they stay
+# theme colors, but cannot be interpolated between.
+function _band_colors(spec, n::Int)::Vector
+ cols = [_is_theme_color(c) ? (_check_color(c); c) : get_color(c)
+ for c in (spec isa CellColor ? [spec] : spec)]
if length(cols) == n
return cols
elseif length(cols) == 2
n == 1 && return [cols[1]]
+ any(_is_theme_color, cols) && throw(XLSXError(
+ "Theme colors cannot be interpolated between. Give RGB colors, or one color per band."))
c1, c2 = convert.(Colors.LCHab, _to_colorant.(cols))
return _from_colorant.(range(c1, c2; length=n))
end
diff --git a/src/conditional-formats.jl b/src/conditional-formats.jl
deleted file mode 100644
index ef7f83da..00000000
--- a/src/conditional-formats.jl
+++ /dev/null
@@ -1,2852 +0,0 @@
-const highlights::Dict{String,Dict{String,Dict{String,String}}} = Dict(
- "redfilltext" => Dict(
- "font" => Dict("color" => "FF9C0006"),
- "fill" => Dict("pattern" => "solid", "bgColor" => "FFFFC7CE")
- ),
- "yellowfilltext" => Dict(
- "font" => Dict("color" => "FF9C5700"),
- "fill" => Dict("pattern" => "solid", "bgColor" => "FFFFEB9C")
- ),
- "greenfilltext" => Dict(
- "font" => Dict("color" => "FF006100"),
- "fill" => Dict("pattern" => "solid", "bgColor" => "FFC6EFCE")
- ),
- "redfill" => Dict(
- "fill" => Dict("pattern" => "solid", "bgColor" => "FFFFC7CE")
- ),
- "redtext" => Dict(
- "font" => Dict("color" => "FF9C0006"),
- ),
- "redborder" => Dict(
- "border" => Dict("color" => "FF9C0006", "style" => "thin")
- )
-)
-const databars::Dict{String,Dict{String,String}} = Dict(
- "bluegrad" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FF638EC6",
- "borders" => "true",
- "sameNegBorders" => "false",
- "border_col" => "FF638EC6",
- "neg_fill_col" => "FFFF0000",
- "neg_border_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "greengrad" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FF63C384",
- "borders" => "true",
- "sameNegBorders" => "false",
- "border_col" => "FF63C384",
- "neg_fill_col" => "FFFF0000",
- "neg_border_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "redgrad" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FFFF555A",
- "borders" => "true",
- "sameNegBorders" => "false",
- "border_col" => "FFFF555A",
- "neg_fill_col" => "FFFF0000",
- "neg_border_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "orangegrad" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FFFFB628",
- "borders" => "true",
- "sameNegBorders" => "false",
- "border_col" => "FFFFB628",
- "neg_fill_col" => "FFFF0000",
- "neg_border_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "lightbluegrad" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FF008AEF",
- "borders" => "true",
- "sameNegBorders" => "false",
- "border_col" => "FF008AEF",
- "neg_fill_col" => "FFFF0000",
- "neg_border_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "purplegrad" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FFD6007B",
- "borders" => "true",
- "sameNegBorders" => "false",
- "border_col" => "FFD6007B",
- "neg_fill_col" => "FFFF0000",
- "neg_border_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "blue" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FF638EC6",
- "gradient" => "false",
- "neg_fill_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "green" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FF63C384",
- "gradient" => "false",
- "neg_fill_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "red" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FFFF555A",
- "gradient" => "false",
- "neg_fill_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "orange" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FFFFB628",
- "gradient" => "false",
- "neg_fill_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "lightblue" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FF008AEF",
- "gradient" => "false",
- "neg_fill_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
- "purple" => Dict(
- "min_type" => "automatic",
- "max_type" => "automatic",
- "fill_col" => "FFD6007B",
- "gradient" => "false",
- "neg_fill_col" => "FFFF0000",
- "axis_col" => "FF000000"
- ),
-)
-const colorscales::Dict{String,XML.Node} = Dict( # Defines the 12 standard, built-in Excel color scales for conditional formatting.
- "greenyellowred" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="percentile", val="50"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FFF8696B"),
- XML.h.color(rgb="FFFFEB84"),
- XML.h.color(rgb="FF63BE7B")
- )
- ),
- "redyellowgreen" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="percentile", val="50"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FF63BE7B"),
- XML.h.color(rgb="FFFFEB84"),
- XML.h.color(rgb="FFF8696B")
- )
- ),
- "greenwhitered" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="percentile", val="50"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FFF8696B"),
- XML.h.color(rgb="FFFCFCFF"),
- XML.h.color(rgb="FF63BE7B")
- )
- ),
- "redwhitegreen" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="percentile", val="50"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FF63BE7B"),
- XML.h.color(rgb="FFFCFCFF"),
- XML.h.color(rgb="FFF8696B")
- )
- ),
- "bluewhitered" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="percentile", val="50"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FFF8696B"),
- XML.h.color(rgb="FFFCFCFF"),
- XML.h.color(rgb="FF5A8AC6")
- )
- ),
- "redwhiteblue" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="percentile", val="50"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FF5A8AC6"),
- XML.h.color(rgb="FFFCFCFF"),
- XML.h.color(rgb="FFF8696B")
- )
- ),
- "whitered" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FFF8696B"),
- XML.h.color(rgb="FFFCFCFF")
- )
- ),
- "redwhite" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FFFCFCFF"),
- XML.h.color(rgb="FFF8696B")
- )
- ),
- "greenwhite" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FFFCFCFF"),
- XML.h.color(rgb="FF63BE7B")
- )
- ),
- "whitegreen" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FF63BE7B"),
- XML.h.color(rgb="FFFCFCFF")
- )
- ),
- "greenyellow" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FFFFEF9C"),
- XML.h.color(rgb="FF63BE7B")
- )
- ),
- "yellowgreen" => XML.h.cfRule(type="colorScale", priority="1",
- XML.h.colorScale(
- XML.h.cfvo(type="min"),
- XML.h.cfvo(type="max"),
- XML.h.color(rgb="FF63BE7B"),
- XML.h.color(rgb="FFFFEF9C")
- )
- )
-)
-const iconsets::Dict{String,XML.Node} = Dict( # Defines the 20 standard, built-in Excel icon sets for conditional formatting.
- "3Arrows" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="3Arrows",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="33"),
- XML.h.cfvo(type="percent", val="67"),
- )
- ),
- "4Arrows" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="4Arrows",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="25"),
- XML.h.cfvo(type="percent", val="50"),
- XML.h.cfvo(type="percent", val="75"),
- )
- ),
- "5Arrows" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="5Arrows",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="20"),
- XML.h.cfvo(type="percent", val="40"),
- XML.h.cfvo(type="percent", val="60"),
- XML.h.cfvo(type="percent", val="80"),
- )
- ),
- "3ArrowsGray" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="3ArrowsGray",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="33"),
- XML.h.cfvo(type="percent", val="67"),
- )
- ),
- "4ArrowsGray" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="4ArrowsGray",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="25"),
- XML.h.cfvo(type="percent", val="50"),
- XML.h.cfvo(type="percent", val="75"),
- )
- ),
- "5ArrowsGray" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="5ArrowsGray",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="20"),
- XML.h.cfvo(type="percent", val="40"),
- XML.h.cfvo(type="percent", val="60"),
- XML.h.cfvo(type="percent", val="80"),
- )
- ),
- "3TrafficLights" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="3TrafficLights",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="33"),
- XML.h.cfvo(type="percent", val="67"),
- )
- ),
- "3Signs" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="3Signs",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="33"),
- XML.h.cfvo(type="percent", val="67"),
- )
- ),
- "4RedToBlack" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="4RedToBlack",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="25"),
- XML.h.cfvo(type="percent", val="50"),
- XML.h.cfvo(type="percent", val="75"),
- )
- ),
- "3TrafficLights2" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="3TrafficLights2",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="33"),
- XML.h.cfvo(type="percent", val="67"),
- )
- ),
- "4TrafficLights" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="4TraficLights",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="25"),
- XML.h.cfvo(type="percent", val="50"),
- XML.h.cfvo(type="percent", val="75"),
- )
- ),
- "3Symbols" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="3Symbols",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="33"),
- XML.h.cfvo(type="percent", val="67"),
- )
- ),
- "3Symbols2" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="3Symbols2",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="33"),
- XML.h.cfvo(type="percent", val="67"),
- )
- ),
- "3Flags" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="3Flags",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="33"),
- XML.h.cfvo(type="percent", val="67"),
- )
- ),
- "5Quarters" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="5Quarters",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="20"),
- XML.h.cfvo(type="percent", val="40"),
- XML.h.cfvo(type="percent", val="60"),
- XML.h.cfvo(type="percent", val="80"),
- )
- ),
- "4Rating" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="4Rating",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="25"),
- XML.h.cfvo(type="percent", val="50"),
- XML.h.cfvo(type="percent", val="75"),
- )
- ),
- "5Rating" => XML.h.cfRule(type="iconSet", priority="1",
- XML.h.iconSet(iconSet="5Rating",
- XML.h.cfvo(type="percent", val="0"),
- XML.h.cfvo(type="percent", val="20"),
- XML.h.cfvo(type="percent", val="40"),
- XML.h.cfvo(type="percent", val="60"),
- XML.h.cfvo(type="percent", val="80"),
- )
- ),
- # These three require Excel 2010 extensions and will be ignored by earlier versions of Excel.
- "3Triangles" => get_x14_icon("3Triangles"),
- "3Stars" => get_x14_icon("3Stars"),
- "5Boxes" => get_x14_icon("5Boxes"),
- "Custom" => get_x14_icon("Custom")
-)
-const allIcons::Dict{String,Tuple{String,String}} = Dict(
- "1" => ("3Arrows", "0"),
- "2" => ("3Arrows", "1"),
- "3" => ("3Arrows", "2"),
- "4" => ("4Arrows", "1"),
- "5" => ("4Arrows", "2"),
- "6" => ("3ArrowsGray", "0"),
- "7" => ("3ArrowsGray", "1"),
- "8" => ("3ArrowsGray", "2"),
- "9" => ("4ArrowsGray", "1"),
- "10" => ("4ArrowsGray", "2"),
- "11" => ("3Flags", "0"),
- "12" => ("3Flags", "1"),
- "13" => ("3Flags", "2"),
- "14" => ("3TrafficLights1", "0"),
- "15" => ("3TrafficLights1", "1"),
- "16" => ("3TrafficLights1", "2"),
- "17" => ("3TrafficLights2", "0"),
- "18" => ("3TrafficLights2", "1"),
- "19" => ("3TrafficLights2", "2"),
- "20" => ("4TrafficLights", "0"),
- "21" => ("3Signs", "0"),
- "22" => ("3Signs", "1"),
- "23" => ("3Symbols", "0"),
- "24" => ("3Symbols", "1"),
- "25" => ("3Symbols", "2"),
- "26" => ("3Symbols2", "0"),
- "27" => ("3Symbols2", "1"),
- "28" => ("3Symbols2", "2"),
- "29" => ("4RedToBlack", "0"),
- "30" => ("4RedToBlack", "1"),
- "31" => ("4RedToBlack", "2"),
- "32" => ("4RedToBlack", "3"),
- "33" => ("5Quarters", "0"),
- "34" => ("5Quarters", "1"),
- "35" => ("5Quarters", "2"),
- "36" => ("5Quarters", "3"),
- "37" => ("5Rating", "0"),
- "38" => ("5Rating", "1"),
- "39" => ("5Rating", "2"),
- "40" => ("5Rating", "3"),
- "41" => ("5Rating", "4"),
- "42" => ("3Stars", "0"),
- "43" => ("3Stars", "1"),
- "44" => ("3Stars", "2"),
- "45" => ("3Triangles", "0"),
- "46" => ("3Triangles", "1"),
- "47" => ("3Triangles", "2"),
- "48" => ("5Boxes", "0"),
- "49" => ("5Boxes", "1"),
- "50" => ("5Boxes", "2"),
- "51" => ("5Boxes", "3"),
- "52" => ("5Boxes", "4")
-)
-
-const timeperiods::Dict{String,String} = Dict(
- "last7Days" => "AND(TODAY()-FLOOR(__CR__,1)<=6,FLOOR(__CR__,1)<=TODAY())",
- "yesterday" => "FLOOR(__CR__,1)=TODAY()-1",
- "today" => "FLOOR(__CR__,1)=TODAY()",
- "tomorrow" => "FLOOR(__CR__,1)=TODAY()+1",
- "lastWeek" => "AND(TODAY()-ROUNDDOWN(__CR__,0)>=(WEEKDAY(TODAY())),TODAY()-ROUNDDOWN(__CR__,0)<(WEEKDAY(TODAY())+7))",
- "thisWeek" => "AND(TODAY()-ROUNDDOWN(__CR__,0)<=WEEKDAY(TODAY())-1,ROUNDDOWN(__CR__,0)-TODAY()<=7-WEEKDAY(TODAY()))",
- "nextWeek" => "AND(ROUNDDOWN(__CR__,0)-TODAY()>(7-WEEKDAY(TODAY())),ROUNDDOWN(__CR__,0)-TODAY()<(15-WEEKDAY(TODAY())))",
- "lastMonth" => "AND(MONTH(__CR__)=MONTH(EDATE(TODAY(),0-1)),YEAR(__CR__)=YEAR(EDATE(TODAY(),0-1)))",
- "thisMonth" => "AND(MONTH(__CR__)=MONTH(TODAY()),YEAR(__CR__)=YEAR(TODAY()))",
- "nextMonth" => "AND(MONTH(__CR__)=MONTH(EDATE(TODAY(),0+1)),YEAR(__CR__)=YEAR(EDATE(TODAY(),0+1)))"
-)
-
-const CfKey = Union{CellRange,NonContiguousRange}
-
-"""
- getConditionalFormats(ws::Worksheet)
-
-Get the conditional formats for a worksheet.
-
-# Arguments
-- `ws::Worksheet`: The worksheet for which to get the conditional formats.
-
-Return a vector of pairs: CellRange => NamedTuple{type::String, priority::Int}}.
-
-
-"""
-getConditionalFormats(ws::Worksheet) = append!(getConditionalFormats(ws, allCfs(ws)), getConditionalExtFormats(ws, allExtCfs(ws)))
-function getConditionalFormats(ws::Worksheet, allcfnodes::Vector{XML.Node})::Vector{Pair{CfKey,NamedTuple{(:type, :priority),Tuple{String,Int64}}}}
- allcfs = Vector{Pair{CfKey,NamedTuple{(:type, :priority),Tuple{String,Int64}}}}()
- wb = get_workbook(ws)
- for cf in allcfnodes
- for child in XML.children(cf)
- if localname(child) == "cfRule"
- push!(allcfs, _cf_range(ws, cf["sqref"]) => (type=child["type"], priority=parse(Int, child["priority"]))) end
- end
- end
- return allcfs
-end
-function getConditionalExtFormats(ws::Worksheet, allcfnodes::Vector{XML.Node})::Vector{Pair{CfKey,NamedTuple{(:type, :priority),Tuple{String,Int64}}}}
- wb = get_workbook(ws)
- allcfs = Vector{Pair{CfKey,NamedTuple{(:type, :priority),Tuple{String,Int64}}}}()
- for cf in allcfnodes
- let t, p, r, rule = false, ref = false
- @assert localname(cf) == "conditionalFormatting" "Something wrong here"
- # Children may be interleaved with whitespace text nodes from the
- # writer's indentation, so locate `xm:sqref` by name rather than
- # assuming it is the last child.
- cf_elements = xml_elements(cf)
- sqref = findlast(c -> localname(c) == "sqref", cf_elements)
- if !isnothing(sqref)
- r = XML.simple_value(cf_elements[sqref])
- ref = true
- end
- for child in cf_elements
- if localname(child) == "cfRule"
- t = child["type"]
- if t != "dataBar" # This is the other half of a dataBar definition - don't count twice!
- p = parse(Int, child["priority"])
- rule = true
- end
- end
- if rule && ref
- push!(allcfs, _cf_range(ws, r) => (type=t, priority=p))
- rule = false
- end
- end
- end
- end
- return allcfs
-end
-
-"""
- setConditionalFormat(ws::Worksheet, cr::String, type::Symbol; kw...) -> ::Int
- setConditionalFormat(xf::XLSXFile, cr::String, type::Symbol; kw...) -> ::Int
-
- setConditionalFormat(ws::Worksheet, rows, cols, type::Symbol; kw...) -> ::Int
-
-Add a new conditional format to a cell range, row range or column range in a
-worksheet or `XLSXFile`. Alternatively, ranges can be specified by giving rows
-and columns separately.
-
-There are many options for applying differnt types of custom format. For a basic guide,
-refer to the section on [Conditional formats](@ref) in the Formatting Guide.
-
-The `type` argument specifies which of Excel's conditional format types will be applied.
-
-Valid options for `type` are:
-- `:cellIs`
-- `:top10`
-- `:aboveAverage`
-- `:containsText`
-- `:notContainsText`
-- `:beginsWith`
-- `:endsWith`
-- `:timePeriod`
-- `:containsErrors`
-- `:notContainsErrors`
-- `:containsBlanks`
-- `:notContainsBlanks`
-- `:uniqueValues`
-- `:duplicateValues`
-- `:dataBar`
-- `:colorScale`
-- `:iconSet`
-
-Keyword options differ according to the `type` specified, as set out below.
-
-# type = :cellIs
-
-Defines a conditional format based on the value of each cell in a range.
-
-Valid keywords are:
-- `operator` : Defines the comparison to make.
-- `value` : defines the first value to compare against. This can be a cell reference (e.g. `"A1"`) or a number.
-- `value2` : defines the second value to compare against. This can be a cell reference (e.g. `"A1"`) or a number.
-- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
-- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply
-- `format` : defines the numFmt to apply if opting for a custom format.
-- `font` : defines the font to apply if opting for a custom format.
-- `border` : defines the border to apply if opting for a custom format.
-- `fill` : defines the fill to apply if opting for a custom format.
-
-All keywords are defined using Strings (e.g. `value = "2"` or `value = "A2"`).
-
-The keyword `operator` defines the comparison to use in the conditional formatting.
-If the condition is met, the format is applied.
-Valid options are:
-
-- `greaterThan` (cell > `value`) (default)
-- `greaterEqual` (cell >= `value`)
-- `lessThan` (cell < `value`)
-- `lessEqual` (cell <= `value`)
-- `equal` (cell == `value`)
-- `notEqual` (cell != `value`)
-- `between` (cell between `value` and `value2`)
-- `notBetween` (cell not between `value` and `value2`)
-
-If not specified (when required), `value` will be the arithmetic average of the
-(non-missing) cell values in the range if values are numeric. If the cell values
-are non-numeric, an error is thrown.
-
-Formatting to be applied if the condition is met can be defined in one of two ways.
-Use the keyword `dxStyle` to select one of the built-in Excel formats.
-Valid options are:
-
-- `redfilltext` (light red fill, dark red text) (default)
-- `yellowfilltext` (light yellow fill, dark yellow text)
-- `greenfilltext` (light green fill, dark green text)
-- `redfill` (light red fill)
-- `redtext` (dark red text)
-- `redborder` (dark red cell borders)
-
-Alternatively, you can define a custom format by using the keywords `format`, `font`,
-`border`, and `fill` which each take a vector of pairs of strings. The first string
-is the name of the attribute to set and the second is the value to set it to.
-Valid attributes for each keyword are:
-
-- `format` : `format`
-- `font` : `color`, `bold`, `italic`, `under`, `strike`
-- `fill` : `pattern`, `bgColor`, `fgColor`
-- `border` : `style`, `color`
-
-Refer to [`setFormat()`](@ref), [`setFont()`](@ref), [`setFill()`](@ref) and [`setBorder()`](@ref) for
-more details on the valid attributes and values.
-
-!!! note
-
- Excel limits the formatting attributes that can be set in a conditional format.
- It is not possible to set the size or name of a font and neither is it possible to set
- any of the cell alignment attributes. Diagonal borders cannot be set either.
-
- Although it is not a limitation of Excel, this function sets all the border attributes
- for each side of a cell to be the same.
-
-If both `dxStyle` and custom formatting keywords are specified, `dxStyle` will be used
-and the custom formatting will be ignored.
-If neither `dxStyle` nor custom formatting keywords are specified, the default
-is `dxStyle="redfilltext"`.
-
-# Examples
-
-```julia
-julia> XLSX.setConditionalFormat(s, "B1:B5", :cellIs) # Defaults to `operator="greaterThan"`, `dxStyle="redfilltext"` and `value` set to the arithmetic agverage of cell values in `rng`.
-
-julia> XLSX.setConditionalFormat(s, "B1:B5", :cellIs;
- operator="between",
- value="2",
- value2="3",
- fill = ["pattern" => "none", "bgColor"=>"FFFFC7CE"],
- format = ["format"=>"0.00%"],
- font = ["color"=>"blue", "bold"=>"true"]
- )
-
-julia> XLSX.setConditionalFormat(s, "B1:B5", :cellIs;
- operator="greaterThan",
- value="4",
- fill = ["pattern" => "none", "bgColor"=>"green"],
- format = ["format"=>"0.0"],
- font = ["color"=>"red", "italic"=>"true"]
- )
-
-julia> XLSX.setConditionalFormat(s, "B1:B5", :cellIs;
- operator="lessThan",
- value="2",
- fill = ["pattern" => "none", "bgColor"=>"yellow"],
- format = ["format"=>"0.0"],
- font = ["color"=>"green"],
- border = ["style"=>"thick", "color"=>"coral"]
- )
-
-```
-
-# type = :top10
-
-This conditional format can be used to highlight cells in the top (bottom) n within the
-range or in the top (bottom) n% (ie in the top 5 or in the top 5% of values in the range).
-
-The available keywords are:
-
-- `operator` : Defines the comparison to make.
-- `value` : Gives the for comparison or a cell reference (e.g. `"A1"`).
-- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
-- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply.
-- `format` : defines the numFmt to apply if opting for a custom format.
-- `font` : defines the font to apply if opting for a custom format.
-- `border` : defines the border to apply if opting for a custom format.
-- `fill` : defines the fill to apply if opting for a custom format.
-
-Valid values for the `operator` keyword are the following:
-
-- `topN` (cell is in the top n (= `value`) values of the range)
-- `bottomN` (cell is in the bottom n (= `value`) values of the range)
-- `topN%` (cell is in the top n% (= `value`) values of the range)
-- `bottomN%` (cell is in the bottom n% (= `value`) values of the range)
-
-Default keyowrds are `operator="TopN"` and `value="10"`.
-
-Multiple conditional formats may be applied to the same or overlapping cell ranges.
-If `stopIfTrue=true` the first condition that is met will be applied but all subsequent
-conditional formats for that cell will be skipped. If `stopIfTrue=false` (default) all
-relevant conditional formats will be applied to the cell in turn.
-
-For example usage of the `stopIfTrue` keyword, refer to [Overlaying conditional formats](@ref)
-in the Formatting Guide.
-
-The remaining keywords are defined as above for `type = :cellIs`.
-
-# Examples
-
-```julia
-julia> f=XLSX.newxlsx()
-XLSXFile("C:\\...\\blank.xlsx") containing 1 Worksheet
- sheetname size range
--------------------------------------------------
- Sheet1 1x1 A1:A1
-
-
-julia> s=f[1]
-1×1 XLSX.Worksheet: ["Sheet1"](A1:A1)
-
-julia> for i=1:10;for j=1:10; s[i,j]=i*j;end;end
-
-julia> s[:]
-10×10 Matrix{Any}:
- 1 2 3 4 5 6 7 8 9 10
- 2 4 6 8 10 12 14 16 18 20
- 3 6 9 12 15 18 21 24 27 30
- 4 8 12 16 20 24 28 32 36 40
- 5 10 15 20 25 30 35 40 45 50
- 6 12 18 24 30 36 42 48 54 60
- 7 14 21 28 35 42 49 56 63 70
- 8 16 24 32 40 48 56 64 72 80
- 9 18 27 36 45 54 63 72 81 90
- 10 20 30 40 50 60 70 80 90 100
-
-julia> XLSX.setConditionalFormat(s, "A1:J10", :top10; operator="bottomN", value="1", stopIfTrue="true", dxStyle="redfilltext")
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:J10", :top10; operator="topN", value="1", stopIfTrue="true", dxStyle="greenfilltext")
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:J10", :top10;
- operator="topN%",
- value="20",
- fill=["pattern"=>"solid", "bgColor"=>"cyan"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:J10", :top10;
- operator="bottomN%",
- value="20",
- fill=["pattern"=>"solid", "bgColor"=>"yellow"])
-0
-
-```
-
-
-# type = :aboveAverage
-
-This conditional format can be used to compare cell values in the range with the
-average value for the range.
-
-The available keywords are:
-
-- `operator` : Defines the comparison to make.
-- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
-- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply.
-- `format` : defines the numFmt to apply if opting for a custom format.
-- `font` : defines the font to apply if opting for a custom format.
-- `border` : defines the border to apply if opting for a custom format.
-- `fill` : defines the fill to apply if opting for a custom format.
-
-Valid values for the `operator` keyword are the following:
-
-- `aboveAverage` (cell is above the average of the range) (default)
-- `aboveEqAverage` (cell is above or equal to the average of the range)
-- `plus1StdDev` (cell is above the average of the range + 1 standard deviation)
-- `plus2StdDev` (cell is above the average of the range + 2 standard deviations)
-- `plus3StdDev` (cell is above the average of the range + 3 standard deviations)
-- `belowAverage` (cell is below the average of the range)
-- `belowEqAverage` (cell is below or equal to the average of the range)
-- `minus1StdDev` (cell is below the average of the range - 1 standard deviation)
-- `minus2StdDev` (cell is below the average of the range - 2 standard deviations)
-- `minus3StdDev` (cell is below the average of the range - 3 standard deviations)
-
-The remaining keywords are defined as above for `type = :cellIs`.
-
-# Examples
-
-```julia
-julia> using Random, Distributions
-
-julia> d=Normal()
-Normal{Float64}(μ=0.0, σ=1.0)
-
-julia> columns=rand(d,1000)
-1000-element Vector{Float64}:
--1.5515478694605092
- 0.36859583733587165
- 1.5349535865662158
- -0.2352610551087202
- 0.12355875388105911
- 0.5859222303845908
- -0.6326662651426166
- 1.0610118292961683
- -0.7891578831398097
- 0.031022172414689787
- -0.5534440118018843
- -2.3538883599955023
- ⋮
- 0.4813001892130465
- 0.03871017417416217
- 0.7224728281160403
- -1.1265372949908539
- 1.5714393857211955
- 0.31438739499933255
- 0.4852591013082452
- 0.5363388236349432
- 1.1268430910133729
- 0.7691442442244849
- 1.0061732938516454
-
-julia> f=XLSX.newxlsx()
-XLSXFile("C:\\...\\blank.xlsx") containing 1 Worksheet
- sheetname size range
--------------------------------------------------
- Sheet1 1x1 A1:A1
-
-
-julia> s=f[1]
-1×1 XLSX.Worksheet: ["Sheet1"](A1:A1)
-
-julia> XLSX.writetable!(s, [columns], ["normal"])
-
-julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
- operator="plus3StdDev",
- stopIfTrue="true",
- fill = ["pattern"=>"solid", "bgColor"=>"red"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
- operator="minus3StdDev",
- stopIfTrue="true",
- fill = ["pattern"=>"solid", "bgColor"=>"red"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
- operator="plus2StdDev",
- stopIfTrue="true",
- fill = ["pattern"=>"solid", "bgColor"=>"tomato"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
- operator="minus2StdDev",
- stopIfTrue="true",
- fill = ["pattern"=>"solid", "bgColor"=>"tomato"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
- operator="minus1StdDev",
- stopIfTrue="true",
- fill = ["pattern"=>"solid", "bgColor"=>"pink"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
- operator="plus1StdDev",
- stopIfTrue = "true",
- fill = ["pattern"=>"solid", "bgColor"=>"pink"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
- operator="belowEqAverage",
- fill = ["pattern"=>"solid", "bgColor"=>"green"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
- operator="aboveEqAverage",
- fill = ["pattern"=>"solid", "bgColor"=>"green"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-```
-
-# type = :containsText, :notContainsText, :beginsWith or :endsWith
-
-Highlight cells in the range that contain (or do not contain), begin or end with
-a specific text string. The default is `containsText`.
-
-Valid keywords are:
-
-- `value` : Gives the literal text to match or provides a cell reference (e.g. `"A1"`).
-- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
-- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply.
-- `format` : defines the numFmt to apply if opting for a custom format.
-- `font` : defines the font to apply if opting for a custom format.
-- `border` : defines the border to apply if opting for a custom format.
-- `fill` : defines the fill to apply if opting for a custom format.
-
-The keyword `value` gives the literal text to compare (eg. "Hello World") or provides a cell reference
-(e.g. `"A1"`). It is a required keyword with no default value.
-
-The remaining keywords are optional and are defined as above for `type = :cellIs`.
-
-# Examples
-
-```julia
-julia> s[:]
-4×1 Matrix{Any}:
- "Hello World"
- "Life the universe and everything"
- "Once upon a time"
- "In America"
-
-julia> XLSX.setConditionalFormat(s, "A1:A4", :containsText;
- value="th",
- fill = ["pattern"=>"solid", "bgColor"=>"cyan"],
- font = ["color"=>"black", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:A4", :notContainsText;
- value="i",
- fill = ["pattern"=>"solid", "bgColor"=>"green"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:A4", :beginsWith ;
- value="On",
- fill = ["pattern"=>"solid", "bgColor"=>"red"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:A4", :endsWith ;
- value="ica",
- fill = ["pattern"=>"solid", "bgColor"=>"blue"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-```
-
-
-# type = :timePeriod
-
-When cells contain dates, this conditional format can be used to highlight cells.
-The available keywords are:
-
-- `operator` : Defines the comparison to make.
-- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
-- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply
-- `format` : defines the numFmt to apply if opting for a custom format.
-- `font` : defines the font to apply if opting for a custom format.
-- `border` : defines the border to apply if opting for a custom format.
-- `fill` : defines the fill to apply if opting for a custom format.
-
-Valid values for the keyword `operator` are the following:
-
-- `yesterday`
-- `today`
-- `tomorrow`
-- `last7Days` (default)
-- `lastWeek`
-- `thisWeek`
-- `nextWeek`
-- `lastMonth`
-- `thisMonth`
-- `nextMonth`
-
-The remaining keywords are defined as above for `type = :cellIs`.
-
-# Examples
-
-```julia
-julia> s[1:13, 1]
-13×1 Matrix{Any}:
- "Dates"
- 2024-11-20
- 2024-12-20
- 2025-01-08
- 2025-02-08
- 2025-03-08
- 2025-04-08
- 2025-05-08
- 2025-05-09
- 2025-05-10
- 2025-05-14
- 2025-06-08
- 2025-07-08
-
-julia> XLSX.setConditionalFormat(s, "A1:A13", :timePeriod; operator="today", dxStyle = "greenfilltext")
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:A13", :timePeriod; operator="tomorrow", dxStyle = "yellowfilltext")
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:A13", :timePeriod; operator="nextMonth", dxStyle = "redfilltext")
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:A13", :timePeriod;
- operator="lastMonth",
- fill = ["pattern"=>"solid", "bgColor"=>"blue"],
- font = ["color"=>"yellow", "bold"=>"true"])
-0
-
-```
-
-
-
-# type = :containsErrors, :notContainsErrors, :containsBlanks, :notContainsBlanks, :uniqueValues or :duplicateValues
-
-These conditional formatting options highlight cells that contain or don't contain errors,
-are blank (default) or not blank, are unique in the range or are duplicates within the range.
-The available keywords are:
-
-- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
-- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply
-- `format` : defines the numFmt to apply if opting for a custom format.
-- `font` : defines the font to apply if opting for a custom format.
-- `border` : defines the border to apply if opting for a custom format.
-- `fill` : defines the fill to apply if opting for a custom format.
-
-These keywords are defined as above for the `:cellIs` conditional format type.
-
-# Examples
-
-```julia
-julia> XLSX.setConditionalFormat(s, "A1:A7", :containsErrors;
- stopIfTrue="true",
- fill = ["pattern"=>"solid", "bgColor"=>"blue"],
- font = ["color"=>"white", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:A7", :containsBlanks;
- stopIfTrue="true",
- fill = ["pattern"=>"solid", "bgColor"=>"green"],
- font = ["color"=>"black", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:A7", :uniqueValues;
- stopIfTrue="true",
- fill = ["pattern"=>"solid", "bgColor"=>"yellow"],
- font = ["color"=>"black", "bold"=>"true"])
-0
-
-julia> XLSX.setConditionalFormat(s, "A1:A7", :duplicateValues;
- fill = ["pattern"=>"solid", "bgColor"=>"cyan"],
- font = ["color"=>"black", "bold"=>"true"])
-0
-
-```
-
-
-# type = :expressiom
-
-Set a conditional format when an expression evaluated in each cell is `true`.
-
-The available keywords are:
-
-- `formula` : Specifies the formula to use. This must be a valid Excel formula.
-- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
-- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply
-- `format` : defines the numFmt to apply if opting for a custom format.
-- `font` : defines the font to apply if opting for a custom format.
-- `border` : defines the border to apply if opting for a custom format.
-- `fill` : defines the fill to apply if opting for a custom format.
-
-The keyword `formula` is required and there is no default value. Formulae must be valid
-Excel formulae and written in US english with comma separators. Cell references may be
-absolute or relative references in either the row or the column or both.
-
-The remaining keywords are defined as above for `type = :cellIs`.
-
-# Examples
-
-```julia
-julia> XLSX.setConditionalFormat(s, "A1:C4", :expression; formula = "A1 < 16", dxStyle="greenfilltext")
-
-julia> XLSX.setConditionalFormat(s, 1:5, 1:4, :expression;
- formula="A1=1",
- fill=["pattern" => "none", "bgColor" => "yellow"],
- format=["format" => "0.0"],
- font=["color" => "green"],
- border=["style" => "thick", "color" => "coral"]
- )
-
-julia> XLSX.setConditionalFormat(s, "B2:D11", :expression; formula = "average(B\$2:B\$11) > average(A\$2:A\$11)", dxStyle = "greenfilltext")
-
-julia> XLSX.setConditionalFormat(s, "A1:E5", :expression; formula = "E5<50", dxStyle = "redfilltext")
-
-```
-
-# type = :dataBar
-
-Apply data bars to cells in a range depending on their values. The keyword `databar`
-can be used to select one of 12 built-in databars Excel provides by name. Valid names are:
-- `bluegrad` (default)
-- `greengrad`
-- `redgrad`
-- `orangegrad`
-- `lightbluegrad`
-- `purplegrad`
-- `blue`
-- `green`
-- `red`
-- `orange`
-- `lightblue`
-- `purple`
-
-The first six (with a `grad` suffix) yield bars with a color gradient while the remainder
-yield bars of solid color. By default, all built in data bars define their range from the
-minumum and maximum values in the range and negative values are given a red bar. These default
-settings can each be modified using the other keyword options available.
-
-Remaining keyword options provided are:
-- `showVal` - set to "false" to show databars only and hide cell values
-- `gradient` - set to "false" to use a solid color bar rather than a gradient fill
-- `borders` - set to "true" to show borders around each bar
-- `sameNegFill` - set to "true" to use the same fill color on negative bars as positive.
-- `sameNegBorders` - set to "false" to use the same border color on negative bars as positive
-- `direction` - determines the direction of the bars from the axis, "leftToRight" or "rightToLeft"
-- `min_type` - Defines how the minimum of the bar scale is defined ("num", "min", "percent", percentile", "formula" or "automatic")
-- `min_val` - Defines the minimum value for the data bar scale. May be a number(as a string), a cell reference or a formula (if type="formula").
-- `max_type` - Defines how the maximum of the bar scale is defined ("num", "max", "percent", percentile", "formula" or "automatic")
-- `max_val` - Defines the maximum value for the data bar scale. May be a number(as a string), a cell reference or a formula (if type="formula").
-- `fill_col` - Defines the color of the fill for positive bars (8 digit hex or by name)
-- `border_col` - Defines the color of the border for positive bars (8 digit hex or by name)
-- `neg_fill_col` - Defines the color of the fill for negative bars (8 digit hex or by name)
-- `neg_border_col` - Defines the color of the border for negative bars (8 digit hex or by name)
-- `axis_pos` - Defines the position of the axis ("middle" or "none")
-- `axis_col` - Defines the color of the axis (8 digit hex or by name)
-
-# Examples
-```julia
-julia> XLSX.setConditionalFormat(s, "A1:A11", :dataBar)
-
-julia> XLSX.setConditionalFormat(s, "B1:B11", :dataBar; databar="purple")
-
-julia> XLSX.setConditionalFormat(s, "D1:D11", :dataBar;
- gradient="true",
- direction="rightToLeft",
- axis_pos="none",
- showVal="false"
- )
-
-julia> XLSX.setConditionalFormat(s, "F1:F11", :dataBar;
- gradient="false",
- sameNegFill="true",
- sameNegBorders="true"
- )
-
-julia> XLSX.setConditionalFormat(f, "Sheet1!G1:G11", :dataBar;
- fill_col="coral", border_col = "cyan",
- neg_fill_col="cyan", neg_border_col = "coral"
- )
-
-julia> XLSX.setConditionalFormat(f, "Sheet1!J1:J11", :dataBar; axis_col="magenta")
-
-julia> XLSX.setConditionalFormat(s, 15:25, 1, :dataBar;
- min_type="least", max_type="highest"
- )
-
-julia> XLSX.setConditionalFormat(s, 15:25, 2, :dataBar;
- databar="purple",
- min_type="percent", max_type="percent",
- min_val="20", max_val="60"
- )
-
-julia> XLSX.setConditionalFormat(s, "C15:C25", :dataBar;
- databar="blue",
- min_type="num", max_type="num",
- min_val="-1", max_val="6",
- gradient="true",
- direction="leftToRight",
- axis_pos="none"
- )
-
-julia> XLSX.setConditionalFormat(s, "E15:E25", :dataBar;
- gradient="true",
- min_type="percentile", max_type="percentile",
- min_val="20", max_val="80",
- direction="rightToLeft",
- axis_pos="middle"
- )
-
-julia> XLSX.setConditionalFormat(s, "G15:G25", :dataBar;
- min_type="num", max_type="formula",
- min_val="\$L\$1", max_val="\$M\$1 * \$N\$1 + 3",
- fill_col="coral", border_col = "cyan",
- neg_fill_col="cyan", neg_border_col = "coral"
- )
-
-```
-
-# type = :colorScale
-
-Define a 2-color or 3-color colorscale conditional format.
-
-Use the keyword `colorscale` to choose one of the 12 built-in Excel colorscales:
-
-- `"redyellowgreen"`: Red, Yellow, Green 3-color scale.
-- `"greenyellowred"`: Green, Yellow, Red 3-color scale.
-- `"redwhitegreen"` : Red, White, Green 3-color scale.
-- `"greenwhitered"` : Green, White, Red 3-color scale.
-- `"redwhiteblue"` : Red, White, Blue 3-color scale.
-- `"bluewhitered"` : Blue, White, Red 3-color scale.
-- `"redwhite"` : Red, White 2-color scale.
-- `"whitered"` : White, Red 2-color scale.
-- `"whitegreen"` : White, Green 2-color scale.
-- `"greenwhite"` : Green, White 2-color scale.
-- `"yellowgreen"` : Yellow, Green 2-color scale.
-- `"greenyellow"` : Green, Yellow 2-color scale (default).
-
-Alternatively, you can define a custom color scale by omitting the `colorscale` keyword and
-instead using the following keywords:
-
-- `min_type`: The type of the minimum value. Valid values are: `min`, `percentile`, `percent`, `num` or `formula`.
-- `min_val` : The value of the minimum. Omit if `min_type="min"`.
-- `min_col` : The color of the minimum value.
-- `mid_type`: Valid values are: `percentile`, `percent`, `num` or `formula`. Omit for a 2-color scale.
-- `mid_val` : The value of the scale mid point. Omit for a 2-color scale.
-- `mid_col` : The color of the mid point. Omit for a 2-color scale.
-- `max_type`: The type of the maximum value. Valid values are: `max`, `percentile`, `percent`, `num` or `formula`.
-- `max_val` : The value of the maximum value. Omit if `max_type="max"`.
-- `max_col` : The color of the maximum value.
-
-The keywords `min_val`, `mid_val`, and `max_val` can be a number or cell reference (e.g. `"\$A\$1"`) for any value
-of the related type keyword or, if the related type keyword is set to `formula`, may be a valid Excel formula that
-calculates a number. Cell references used in a formula must be specified as absolute references.
-
-Colors can be specified using an 8-digit hex string (e.g. `FF0000FF` for blue) or any named
-color from [Colors.jl](https://juliagraphics.github.io/Colors.jl/stable/namedcolors/).
-
-# Examples
-
-```julia
-julia> XLSX.setConditionalFormat(f["Sheet1"], "A1:F12", :colorScale) # Defaults to the `greenyellow` built-in scale.
-0
-
-julia> XLSX.setConditionalFormat(f["Sheet1"], "A13:C18", :colorScale; colorscale="whitered")
-0
-
-julia> XLSX.setConditionalFormat(f["Sheet1"], "D13:F18", :colorScale; colorscale="bluewhitered")
-0
-
-julia> XLSX.setConditionalFormat(f["Sheet1"], "A13:F22", :colorScale;
- min_type="num",
- min_val="2",
- min_col="tomato",
- mid_type="num",
- mid_val="6",
- mid_col="lawngreen",
- max_type="num",
- max_val="10",
- max_col="cadetblue"
- )
-0
-
-```
-
-# type = :iconSet
-
-Apply a set of icons to cells in a range depending on their values. The keyword `iconset`
-can be used to select one of 20 built-in icon sets Excel provides by name. Valid names are:
-- `3Arrows`
-- `5ArrowsGray`
-- `3TrafficLights` (default)
-- `3Flags`
-- `5Quarters`
-- `4Rating`
-- `5Rating`
-- `3Symbols`
-- `3Symbols2`
-- `3Signs`
-- `3TrafficLights2`
-- `4TrafficLights`
-- `4BlackToRed`
-- `4Arrows`
-- `5Arrows`
-- `3ArrowsGray`
-- `4ArrowsGray`
-- `3Triangles`
-- `3Stars`
-- `5Boxes`
-
-The digit prefix to the name indicates how many icons there are in a set, and therefore
-how the cell values with be binned by value. Bin boundaries may optionally be specified
-by the following keywords to override the default values for each icon set:
-
-- `min_type` = "percent" (default), "percentile", "num" or "formula"
-- `min_val` (default: "33" (3 icons), "25" (4 icons) or "20" (5 icons))
-- `mid_type` = "percent" (default), "percentile", "num" or "formula"
-- `mid_val` (default: "50" (4 icons), "40" (5 icons))
-- `mid2_type` = "percent" (default), "percentile", "num" or "formula"
-- `mid2_val` (default: "60" (5 icons))
-- `max_type` = "percent" (default), "percentile", "num" or "formula"
-- `max_val` (default: "67" (3 icons), "75" (4 icons) or "80" (5 icons))
-
-The keywords `min_val`, `mid_val`, `mid2_val` and `max_val` may contain numbers (as strings)
-or valid cell references. If `formula` is specified for the related type keyword, a valid
-Excel formula can be provided to evaluate to the bin threshold value to be used.
-Three-icon sets require two thresholds (`min_type`/`min_val` and `max_type`/`max_val`),
-four-icon sets require three thresholds (with the addition of `mid_type`/`mid_val`) and
-five-icon sets require four thresholds (adding `mid2_type`/`mid2_val`). Thresholds defined
-(using val and type keywords) that are unnecessary are simply ignored.
-
-Each value can be tested using `>=` (default) or `>`. To change from the default,
-optionally set `min_gte`, `mid_gte`, `mid2_gte` and/or `max_gte` to `"false"` to
-use `>` in the comparison. Any other value for these gte keywords will be ignored
-and the default `>=` comparison used.
-
-The built-in icon sets Excel provides are composed of 52 individual icons. It is
-possible to mix and match any of these to make a custom 3-icon, 4-icon or 5-icon
-set by specifying `iconset = "Custom"`. The number of icons in the set will be
-determined by whether the `mid_val`/`mid_type` keywords and `mid2_val`/`mid2_type`
-keywords are provided.
-
-The icons that will be used in a `Custom` iconset are defined using the `icon_list`
-keyword which takes a vector of integers in the range from 1 to 52. For a key relating
-integers to the icons they represent, see the [Icon Set](@ref) section in the Formatting
-Guide.
-
-The order in which the symbols are appiled can be reversed from the default order (or, for
-`Custom` icon sets, the order given in `icon_list`), by optionally setting `reverse = "true"`.
-Any other value provided for `reverse` will be ignored, and the default order applied.
-
-The cell value can be suppressed, so that only the icon is shown in the Excel cell by
-optionally specifying `showVal = "false"`. Any other value provided for `showVal` will be
-ignored, and the cell value will be displayed with the icon.
-
-# Examples
-```julia
-XLSX.setConditionalFormat(s, "F2:F11", :iconSet; iconset="3Arrows")
-
-XLSX.setConditionalFormat(s, 2, :, :iconSet; iconset = "5Boxes",
- reverse = "true",
- showVal = "false",
- min_type="num", mid_type="percentile", mid2_type="percentile", max_type="num",
- min_val="3", mid_val="45", mid2_val="65", max_val="8",
- min_gte="false", mid_gte="false", mid2_gte="false", max_gte="false")
-
-XLSX.setConditionalFormat(s, "A2:A11", :iconSet;
- iconset = "Custom",
- icon_list = [31,24],
- min_type="num", max_type="formula",
- min_val="3", max_val="if(\$G\$4=\"y\", \$G\$1+5, 10)")
-
-```
-
-"""
-function setConditionalFormat(f, r, type::Symbol; kw...)
- _allkws = Dict{Symbol,Any}(k => v for (k, v) in kw)
- haskey(_allkws, :priority) && throw(XLSXError("Invalid keyword argument: `priority`. Conditional format rules are prioritised in the order they are applied."))
- if type == :colorScale
- setCfColorScale(f, r; allkws=_allkws)
- elseif type == :cellIs
- setCfCellIs(f, r; allkws=_allkws)
- elseif type == :top10
- setCfTop10(f, r; allkws=_allkws)
- elseif type == :aboveAverage
- setCfAboveAverage(f, r; allkws=_allkws)
- elseif type == :timePeriod
- setCfTimePeriod(f, r; allkws=_allkws)
- elseif type ∈ [:containsText, :notContainsText, :beginsWith, :endsWith]
- setCfContainsText(f, r; allkws=_allkws)
- elseif type ∈ [:containsBlanks, :notContainsBlanks, :containsErrors, :notContainsErrors, :duplicateValues, :uniqueValues]
- push!(_allkws, :operator => string(type))
- setCfContainsBlankErrorUniqDup(f, r; allkws=_allkws)
- elseif type == :expression
- setCfFormula(f, r; allkws=_allkws)
- elseif type == :iconSet
- setCfIconSet(f, r; allkws=_allkws)
- elseif type == :dataBar
- setCfDataBar(f, r; allkws=_allkws)
- else
- throw(XLSXError("Invalid conditional format type: $type."))
- end
-end
-
-function setConditionalFormat(f, r, c, type::Symbol; kw...)
- _allkws = Dict{Symbol,Any}(k => v for (k, v) in kw)
- haskey(_allkws, :priority) && throw(XLSXError("Invalid keyword argument: `priority`. Conditional format rules are prioritised in the order they are applied."))
- if type == :colorScale
- setCfColorScale(f, r, c; allkws=_allkws)
- elseif type == :cellIs
- setCfCellIs(f, r, c; allkws=_allkws)
- elseif type == :top10
- setCfTop10(f, r, c; allkws=_allkws)
- elseif type == :aboveAverage
- setCfAboveAverage(f, r, c; allkws=_allkws)
- elseif type == :timePeriod
- setCfTimePeriod(f, r, c; allkws=_allkws)
- elseif type ∈ [:containsText, :notContainsText, :beginsWith, :endsWith]
- setCfContainsText(f, r, c; allkws=_allkws)
- elseif type ∈ [:containsBlanks, :notContainsBlanks, :containsErrors, :notContainsErrors, :duplicateValues, :uniqueValues]
- push!(_allkws, :operator => string(type))
- setCfContainsBlankErrorUniqDup(f, r, c; allkws=_allkws)
- elseif type == :expression
- setCfFormula(f, r, c; allkws=_allkws)
- elseif type == :iconSet
- setCfIconSet(f, r, c; allkws=_allkws)
- elseif type == :dataBar
- setCfDataBar(f, r, c; allkws=_allkws)
- else
- throw(XLSXError("Invalid conditional format type: $type."))
- end
-end
-
-setCfCellIs(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setCfCellIs, ws, row, nothing; kw...)
-setCfCellIs(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setCfCellIs, ws, nothing, col; kw...)
-setCfCellIs(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setCfCellIs, ws, nothing, nothing; kw...)
-setCfCellIs(ws::Worksheet, ::Colon; kw...) = process_colon(setCfCellIs, ws, nothing, nothing; kw...)
-setCfCellIs(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setCfCellIs(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setCfCellIs(ws::Worksheet, cell::CellRef; kw...) = setCfCellIs(ws, CellRange(cell, cell); kw...)
-setCfCellIs(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && setCfCellIs(ws, CellRange(cell.cellref, cell.cellref); kw...)
-setCfCellIs(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setCfCellIs(ws, rng.rng; kw...)
-setCfCellIs(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setCfCellIs(ws, rng.colrng; kw...)
-setCfCellIs(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setCfCellIs(ws, rng.rowrng; kw...)
-setCfCellIs(ws::Worksheet, rng::RowRange; kw...) = process_rowranges(setCfCellIs, ws, rng; kw...)
-setCfCellIs(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges(setCfCellIs, ws, rng; kw...)
-setCfCellIs(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setCfCellIs, xl, sheetcell; kw...)
-setCfCellIs(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setCfCellIs, ws, ref_or_rng; kw...)
-setCfCellIs(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon(setCfCellIs, ws, row, nothing; kw...)
-setCfCellIs(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon(setCfCellIs, ws, nothing, col; kw...)
-setCfCellIs(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfCellIs, ws, row, col; kw...)
-setCfCellIs(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint(setCfCellIs, ws, row, col; kw...)
-setCfCellIs(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfCellIs, ws, row, col; kw...)
-function setCfCellIs(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=())::Int
-
- operator::Union{Nothing,String}="greaterThan"
- value::Union{Nothing,String}=nothing
- value2::Union{Nothing,String}=nothing
- stopIfTrue::Union{Nothing,String}=nothing
- dxStyle::Union{Nothing,String}=nothing
- format::Union{Nothing,Vector{Pair{String,String}}}=nothing
- font::Union{Nothing,Vector{Pair{String,String}}}=nothing
- border::Union{Nothing,Vector{Pair{String,String}}}=nothing
- fill::Union{Nothing,Vector{Pair{String,String}}}=nothing
-
- for (k, v) in allkws
- if k == :operator
- operator = v
- elseif k == :value
- value = v
- elseif k == :value2
- value2 = v
- elseif k == :stopIfTrue
- stopIfTrue = v
- elseif k == :dxStyle
- dxStyle = v
- elseif k == :format
- format = v
- elseif k == :font
- font = v
- elseif k == :border
- border = v
- elseif k == :fill
- fill = v
- else
- throw(XLSXError("Invalid keyword argument: $k. Valid keywords are: `operator`, `value`, `value2`, `stopIfTrue`, `dxStyle`, `format`, `font`, `border` and `fill`."))
- end
- end
-
- _check_cf_range(ws, rng)
-
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
-
- !isnothing(value) && !is_valid_cellname(value) && !is_valid_fixed_cellname(value) && isnothing(tryparse(Float64, value)) && throw(XLSXError("Invalid `value`: $value. Must be a number or a CellRef."))
- !isnothing(value2) && !is_valid_cellname(value2) && !is_valid_fixed_cellname(value2) && isnothing(tryparse(Float64, value2)) && throw(XLSXError("Invalid `value2`: $value2. Must be a number or a CellRef."))
- !isnothing(operator) && operator ∉ ["greaterThan", "greaterEqual", "lessThan", "lessEqual", "equal", "notEqual", "between", "notBetween"] && throw(XLSXError("Invalid `operator`: $operator. Valid options are: `greaterThan`, `greaterEqual`, `lessThan`, `lessEqual`, `equal`, `notEqual`, `between`, `notBetween`"))
- wb = get_workbook(ws)
- dx = get_dx(dxStyle, format, font, border, fill)
- new_dx = get_new_dx(wb, dx)
- dxid = Add_Cf_Dx(wb, new_dx)
-
- if isnothing(value)
- # The average of the range's numbers, as Excel's AVERAGE takes it: text,
- # booleans and empty cells are ignored.
- nums = filter(v -> v isa Real && !(v isa Bool), _cf_values(ws, rng))
- value = isempty(nums) ? nothing : string(sum(nums) / length(nums))
- end
-
- cfx = XML.Element("$(pfx)cfRule"; type="cellIs", dxfId=string(dxid.id))
- cfx["priority"] = next_cf_priority!(ws)
- if !isnothing(stopIfTrue) && stopIfTrue == "true"
- cfx["stopIfTrue"] = "1"
- end
- cfx["operator"] = operator
- push!(cfx, XML.Element("$(pfx)formula", XML.Text(value)))
- if !isnothing(value2) && operator ∈ ["between", "notBetween"]
-
- push!(cfx, XML.Element("$(pfx)formula", XML.Text(value2)))
- end
-
- update_worksheet_cfx!(cfx, ws, rng)
-
- return 0
-end
-
-setCfContainsText(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setCfContainsText, ws, row, nothing; kw...)
-setCfContainsText(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setCfContainsText, ws, nothing, col; kw...)
-setCfContainsText(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setCfContainsText, ws, nothing, nothing; kw...)
-setCfContainsText(ws::Worksheet, ::Colon; kw...) = process_colon(setCfContainsText, ws, nothing, nothing; kw...)
-setCfContainsText(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setCfContainsText(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setCfContainsText(ws::Worksheet, cell::CellRef; kw...) = setCfContainsText(ws, CellRange(cell, cell); kw...)
-setCfContainsText(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && setCfContainsText(ws, CellRange(cell.cellref, cell.cellref); kw...)
-setCfContainsText(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setCfContainsText(ws, rng.rng; kw...)
-setCfContainsText(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setCfContainsText(ws, rng.colrng; kw...)
-setCfContainsText(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setCfContainsText(ws, rng.rowrng; kw...)
-setCfContainsText(ws::Worksheet, rng::RowRange; kw...) = process_rowranges(setCfContainsText, ws, rng; kw...)
-setCfContainsText(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges(setCfContainsText, ws, rng; kw...)
-setCfContainsText(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setCfContainsText, xl, sheetcell; kw...)
-setCfContainsText(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setCfContainsText, ws, ref_or_rng; kw...)
-setCfContainsText(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon(setCfContainsText, ws, row, nothing; kw...)
-setCfContainsText(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon(setCfContainsText, ws, nothing, col; kw...)
-setCfContainsText(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfContainsText, ws, row, col; kw...)
-setCfContainsText(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint(setCfContainsText, ws, row, col; kw...)
-setCfContainsText(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfContainsText, ws, row, col; kw...)
-function setCfContainsText(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=())::Int
-
- operator::Union{Nothing,String}="containsText"
- value::Union{Nothing,String}=nothing
- stopIfTrue::Union{Nothing,String}=nothing
- dxStyle::Union{Nothing,String}=nothing
- format::Union{Nothing,Vector{Pair{String,String}}}=nothing
- font::Union{Nothing,Vector{Pair{String,String}}}=nothing
- border::Union{Nothing,Vector{Pair{String,String}}}=nothing
- fill::Union{Nothing,Vector{Pair{String,String}}}=nothing
- for (k, v) in allkws
- if k == :operator
- operator = String(v)
- elseif k == :value
- value = String(v)
- elseif k == :stopIfTrue
- stopIfTrue = String(v)
- elseif k == :dxStyle
- dxStyle = String(v)
- elseif k == :format
- format = v
- elseif k == :font
- font = v
- elseif k == :border
- border = v
- elseif k == :fill
- fill = v
- else
- throw(XLSXError("Invalid keyword argument: $k. Valid options are: `operator`, `value`, `stopIfTrue`, `dxStyle`, `format`, `font`, `border`, `fill`."))
- end
- end
-
- _check_cf_range(ws, rng)
-
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
-
- isnothing(value) && throw(XLSXError("Invalid `value`: $value. Must contain text or a CellRef."))
-
- wb = get_workbook(ws)
- dx = get_dx(dxStyle, format, font, border, fill)
- new_dx = get_new_dx(wb, dx)
- dxid = Add_Cf_Dx(wb, new_dx)
-
- type = operator
- if operator == "containsText"
- formula = "NOT(ISERROR(SEARCH(\"__txt__\",__CR__)))"
- elseif operator == "notContainsText"
- operator = "notContains"
- formula = "ISERROR(SEARCH(\"__txt__\",__CR__))"
- elseif operator == "beginsWith"
- # operator = "beginsWith"
- formula = "LEFT(__CR__,LEN(\"__txt__\"))=\"__txt__\""
- elseif operator == "endsWith"
- # operator = "endsWith"
- formula = "RIGHT(__CR__,LEN(\"__txt__\"))=\"__txt__\""
- else
- throw(XLSXError("Invalid operator: $type. Valid options are: `containsText`, `notContainsText`, `beginsWith`, `endsWith`."))
- end
- formula = replace(formula, "__txt__" => value, "__CR__" => string(_cf_anchor(rng)))
-
- cfx = XML.Element("$(pfx)cfRule"; type=type, dxfId=string(dxid.id))
- cfx["priority"] = next_cf_priority!(ws)
- if !isnothing(stopIfTrue) && stopIfTrue == "true"
- cfx["stopIfTrue"] = "1"
- end
- cfx["operator"] = operator
- cfx["text"] = value
- push!(cfx, XML.Element("$(pfx)formula", XML.Text(formula)))
-
- update_worksheet_cfx!(cfx, ws, rng)
-
- return 0
-end
-
-setCfTop10(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setCfTop10, ws, row, nothing; kw...)
-setCfTop10(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setCfTop10, ws, nothing, col; kw...)
-setCfTop10(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setCfTop10, ws, nothing, nothing; kw...)
-setCfTop10(ws::Worksheet, ::Colon; kw...) = process_colon(setCfTop10, ws, nothing, nothing; kw...)
-setCfTop10(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setCfTop10(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setCfTop10(ws::Worksheet, cell::CellRef; kw...) = setCfTop10(ws, CellRange(cell, cell); kw...)
-setCfTop10(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && setCfTop10(ws, CellRange(cell.cellref, cell.cellref); kw...)
-setCfTop10(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setCfTop10(ws, rng.rng; kw...)
-setCfTop10(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setCfTop10(ws, rng.colrng; kw...)
-setCfTop10(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setCfTop10(ws, rng.rowrng; kw...)
-setCfTop10(ws::Worksheet, rng::RowRange; kw...) = process_rowranges(setCfTop10, ws, rng; kw...)
-setCfTop10(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges(setCfTop10, ws, rng; kw...)
-setCfTop10(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setCfTop10, xl, sheetcell; kw...)
-setCfTop10(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setCfTop10, ws, ref_or_rng; kw...)
-setCfTop10(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon(setCfTop10, ws, row, nothing; kw...)
-setCfTop10(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon(setCfTop10, ws, nothing, col; kw...)
-setCfTop10(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfTop10, ws, row, col; kw...)
-setCfTop10(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint(setCfTop10, ws, row, col; kw...)
-setCfTop10(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfTop10, ws, row, col; kw...)
-function setCfTop10(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=())::Int
-
- operator::Union{Nothing,String}="topN"
- value::Union{Nothing,String}="10"
- stopIfTrue::Union{Nothing,String}=nothing
- dxStyle::Union{Nothing,String}=nothing
- format::Union{Nothing,Vector{Pair{String,String}}}=nothing
- font::Union{Nothing,Vector{Pair{String,String}}}=nothing
- border::Union{Nothing,Vector{Pair{String,String}}}=nothing
- fill::Union{Nothing,Vector{Pair{String,String}}}=nothing
- for (k, v) in allkws
- if k == :operator
- operator = v
- elseif k == :value
- value = v
- elseif k == :stopIfTrue
- stopIfTrue = v
- elseif k == :dxStyle
- dxStyle = v
- elseif k == :format
- format = v
- elseif k == :font
- font = v
- elseif k == :border
- border = v
- elseif k == :fill
- fill = v
- else
- throw(XLSXError("Invalid keyword argument: $k. Valid options are: `operator`, `value`, `stopIfTrue`, `dxStyle`, `format`, `font`, `border`, `fill`."))
- end
- end
-
- _check_cf_range(ws, rng)
-
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
-
- !isnothing(value) && !is_valid_cellname(value) && !is_valid_fixed_cellname(value) && isnothing(tryparse(Float64, value)) && throw(XLSXError("Invalid `value`: $value. Must be a number or a CellRef."))
-
- wb = get_workbook(ws)
- dx = get_dx(dxStyle, format, font, border, fill)
- new_dx = get_new_dx(wb, dx)
- dxid = Add_Cf_Dx(wb, new_dx)
-
- percent = ""
- bottom = ""
- cfx = XML.Element("$(pfx)cfRule"; type="top10", dxfId=string(dxid.id))
- if operator == "topN"
- elseif operator == "topN%"
- percent = "1"
- elseif operator == "bottomN"
- bottom = "1"
- elseif operator == "bottomN%"
- percent = "1"
- bottom = "1"
- else
- throw(XLSXError("Invalid operator: $operator. Valid options are: `topN`, `topN%`, `bottomN`, `bottomN%`."))
- end
-
- cfx["priority"] = next_cf_priority!(ws)
- if !isnothing(stopIfTrue) && stopIfTrue == "true"
- cfx["stopIfTrue"] = "1"
- end
- if percent != ""
- cfx["percent"] = percent
- end
- if bottom != ""
- cfx["bottom"] = bottom
- end
- cfx["rank"] = value
-
- update_worksheet_cfx!(cfx, ws, rng)
-
- return 0
-end
-
-setCfAboveAverage(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setCfAboveAverage, ws, row, nothing; kw...)
-setCfAboveAverage(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setCfAboveAverage, ws, nothing, col; kw...)
-setCfAboveAverage(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setCfAboveAverage, ws, nothing, nothing; kw...)
-setCfAboveAverage(ws::Worksheet, ::Colon; kw...) = process_colon(setCfAboveAverage, ws, nothing, nothing; kw...)
-setCfAboveAverage(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setCfAboveAverage(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setCfAboveAverage(ws::Worksheet, cell::CellRef; kw...) = setCfAboveAverage(ws, CellRange(cell, cell); kw...)
-setCfAboveAverage(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && setCfAboveAverage(ws, CellRange(cell.cellref, cell.cellref); kw...)
-setCfAboveAverage(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setCfAboveAverage(ws, rng.rng; kw...)
-setCfAboveAverage(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setCfAboveAverage(ws, rng.colrng; kw...)
-setCfAboveAverage(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setCfAboveAverage(ws, rng.rowrng; kw...)
-setCfAboveAverage(ws::Worksheet, rng::RowRange; kw...) = process_rowranges(setCfAboveAverage, ws, rng; kw...)
-setCfAboveAverage(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges(setCfAboveAverage, ws, rng; kw...)
-setCfAboveAverage(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setCfAboveAverage, xl, sheetcell; kw...)
-setCfAboveAverage(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setCfAboveAverage, ws, ref_or_rng; kw...)
-setCfAboveAverage(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon(setCfAboveAverage, ws, row, nothing; kw...)
-setCfAboveAverage(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon(setCfAboveAverage, ws, nothing, col; kw...)
-setCfAboveAverage(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfAboveAverage, ws, row, col; kw...)
-setCfAboveAverage(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint(setCfAboveAverage, ws, row, col; kw...)
-setCfAboveAverage(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfAboveAverage, ws, row, col; kw...)
-function setCfAboveAverage(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=())::Int
-
- operator::Union{Nothing,String}="aboveAverage"
- stopIfTrue::Union{Nothing,String}=nothing
- dxStyle::Union{Nothing,String}=nothing
- format::Union{Nothing,Vector{Pair{String,String}}}=nothing
- font::Union{Nothing,Vector{Pair{String,String}}}=nothing
- border::Union{Nothing,Vector{Pair{String,String}}}=nothing
- fill::Union{Nothing,Vector{Pair{String,String}}}=nothing
- for (k, v) in allkws
- if k == :operator
- operator = v
- elseif k == :stopIfTrue
- stopIfTrue = v
- elseif k == :dxStyle
- dxStyle = v
- elseif k == :format
- format = v
- elseif k == :font
- font = v
- elseif k == :border
- border = v
- elseif k == :fill
- fill = v
- else
- throw(XLSXError("Invalid keyword argument: $k. Valid options are: `operator`, `stopIfTrue`, `dxStyle`, `format`, `font`, `border`, `fill`."))
- end
- end
-
- _check_cf_range(ws, rng)
-
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
-
- wb = get_workbook(ws)
- dx = get_dx(dxStyle, format, font, border, fill)
- new_dx = get_new_dx(wb, dx)
- dxid = Add_Cf_Dx(wb, new_dx)
-
- if operator == "aboveAverage"
- cfx = XML.Element("$(pfx)cfRule"; type=operator, dxfId=string(dxid.id), priority="1")
- elseif operator == "aboveEqAverage"
- cfx = XML.Element("$(pfx)cfRule"; type="aboveAverage", dxfId=string(dxid.id), priority="1", equalAverage="1")
- elseif operator == "plus1StdDev"
- cfx = XML.Element("$(pfx)cfRule"; type="aboveAverage", dxfId=string(dxid.id), priority="1", stdDev="1")
- elseif operator == "plus2StdDev"
- cfx = XML.Element("$(pfx)cfRule"; type="aboveAverage", dxfId=string(dxid.id), priority="1", stdDev="2")
- elseif operator == "plus3StdDev"
- cfx = XML.Element("$(pfx)cfRule"; type="aboveAverage", dxfId=string(dxid.id), priority="1", stdDev="3")
- elseif operator == "belowAverage"
- cfx = XML.Element("$(pfx)cfRule"; type="aboveAverage", dxfId=string(dxid.id), priority="1", aboveAverage="0")
- elseif operator == "belowEqAverage"
- cfx = XML.Element("$(pfx)cfRule"; type="aboveAverage", dxfId=string(dxid.id), priority="1", aboveAverage="0", equalAverage="1")
- elseif operator == "minus1StdDev"
- cfx = XML.Element("$(pfx)cfRule"; type="aboveAverage", dxfId=string(dxid.id), priority="1", aboveAverage="0", stdDev="1")
- elseif operator == "minus2StdDev"
- cfx = XML.Element("$(pfx)cfRule"; type="aboveAverage", dxfId=string(dxid.id), priority="1", aboveAverage="0", stdDev="2")
- elseif operator == "minus3StdDev"
- cfx = XML.Element("$(pfx)cfRule"; type="aboveAverage", dxfId=string(dxid.id), priority="1", aboveAverage="0", stdDev="3")
- else
- throw(XLSXError("Invalid operator: $operator. Valid options are: `aboveAverage`, `aboveEqAverage`, `plus1sStdDev`, `plus2StdDev`, `plus3StdDev`, `belowAverage`, `belowEqAverage`, `minus1StdDev`, `minus2StdDev`, `minus3StdDev`."))
- end
-
- cfx["priority"] = next_cf_priority!(ws)
- if !isnothing(stopIfTrue) && stopIfTrue == "true"
- cfx["stopIfTrue"] = "1"
- end
-
- update_worksheet_cfx!(cfx, ws, rng)
-
- return 0
-end
-
-setCfTimePeriod(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setCfTimePeriod, ws, row, nothing; kw...)
-setCfTimePeriod(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setCfTimePeriod, ws, nothing, col; kw...)
-setCfTimePeriod(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setCfTimePeriod, ws, nothing, nothing; kw...)
-setCfTimePeriod(ws::Worksheet, ::Colon; kw...) = process_colon(setCfTimePeriod, ws, nothing, nothing; kw...)
-setCfTimePeriod(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setCfTimePeriod(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setCfTimePeriod(ws::Worksheet, cell::CellRef; kw...) = setCfTimePeriod(ws, CellRange(cell, cell); kw...)
-setCfTimePeriod(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && setCfTimePeriod(ws, CellRange(cell.cellref, cell.cellref); kw...)
-setCfTimePeriod(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setCfTimePeriod(ws, rng.rng; kw...)
-setCfTimePeriod(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setCfTimePeriod(ws, rng.colrng; kw...)
-setCfTimePeriod(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setCfTimePeriod(ws, rng.rowrng; kw...)
-setCfTimePeriod(ws::Worksheet, rng::RowRange; kw...) = process_rowranges(setCfTimePeriod, ws, rng; kw...)
-setCfTimePeriod(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges(setCfTimePeriod, ws, rng; kw...)
-setCfTimePeriod(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setCfTimePeriod, xl, sheetcell; kw...)
-setCfTimePeriod(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setCfTimePeriod, ws, ref_or_rng; kw...)
-setCfTimePeriod(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon(setCfTimePeriod, ws, row, nothing; kw...)
-setCfTimePeriod(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon(setCfTimePeriod, ws, nothing, col; kw...)
-setCfTimePeriod(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfTimePeriod, ws, row, col; kw...)
-setCfTimePeriod(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint(setCfTimePeriod, ws, row, col; kw...)
-setCfTimePeriod(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfTimePeriod, ws, row, col; kw...)
-function setCfTimePeriod(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=())::Int
-
- operator::Union{Nothing,String}="last7Days"
- stopIfTrue::Union{Nothing,String}=nothing
- dxStyle::Union{Nothing,String}=nothing
- format::Union{Nothing,Vector{Pair{String,String}}}=nothing
- font::Union{Nothing,Vector{Pair{String,String}}}=nothing
- border::Union{Nothing,Vector{Pair{String,String}}}=nothing
- fill::Union{Nothing,Vector{Pair{String,String}}}=nothing
- for (k, v) in allkws
- if k == :operator
- operator = v
- elseif k == :stopIfTrue
- stopIfTrue = v
- elseif k == :dxStyle
- dxStyle = v
- elseif k == :format
- format = v
- elseif k == :font
- font = v
- elseif k == :border
- border = v
- elseif k == :fill
- fill = v
- else
- throw(XLSXError("Invalid keyword argument: $k. Valid options are: `operator`, `stopIfTrue`, `dxStyle`, `format`, `font`, `border`, `fill`."))
- end
- end
-
- _check_cf_range(ws, rng)
-
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
-
- if haskey(timeperiods, operator)
- formula = timeperiods[operator]
- else
- throw(XLSXError("Invalid operator: $operator. Valid options are: `yesterday`, `today`, `tomorrow`, `last7Days`, `lastWeek`, `thisWeek`, `nextWeek`, `lastMonth`, `thisMonth`, `nextMonth`."))
- end
-
- formula = replace(formula, "__CR__" => string(_cf_anchor(rng)))
-
- wb = get_workbook(ws)
- dx = get_dx(dxStyle, format, font, border, fill)
- new_dx = get_new_dx(wb, dx)
- dxid = Add_Cf_Dx(wb, new_dx)
-
- cfx = XML.Element("$(pfx)cfRule"; type="timePeriod", dxfId=string(dxid.id))
- cfx["priority"] = next_cf_priority!(ws)
- if !isnothing(stopIfTrue) && stopIfTrue == "true"
- cfx["stopIfTrue"] = "1"
- end
- cfx["timePeriod"] = operator
-
- push!(cfx, XML.Element("$(pfx)formula", XML.Text(formula)))
-
- update_worksheet_cfx!(cfx, ws, rng)
-
- return 0
-end
-
-setCfContainsBlankErrorUniqDup(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setCfContainsBlankErrorUniqDup, ws, row, nothing; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setCfContainsBlankErrorUniqDup, ws, nothing, col; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setCfContainsBlankErrorUniqDup, ws, nothing, nothing; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, ::Colon; kw...) = process_colon(setCfContainsBlankErrorUniqDup, ws, nothing, nothing; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setCfContainsBlankErrorUniqDup(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, cell::CellRef; kw...) = setCfContainsBlankErrorUniqDup(ws, CellRange(cell, cell); kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && setCfContainsBlankErrorUniqDup(ws, CellRange(cell.cellref, cell.cellref); kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setCfContainsBlankErrorUniqDup(ws, rng.rng; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setCfContainsBlankErrorUniqDup(ws, rng.colrng; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setCfContainsBlankErrorUniqDup(ws, rng.rowrng; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, rng::RowRange; kw...) = process_rowranges(setCfContainsBlankErrorUniqDup, ws, rng; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges(setCfContainsBlankErrorUniqDup, ws, rng; kw...)
-setCfContainsBlankErrorUniqDup(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setCfContainsBlankErrorUniqDup, xl, sheetcell; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setCfContainsBlankErrorUniqDup, ws, ref_or_rng; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon(setCfContainsBlankErrorUniqDup, ws, row, nothing; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon(setCfContainsBlankErrorUniqDup, ws, nothing, col; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfContainsBlankErrorUniqDup, ws, row, col; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint(setCfContainsBlankErrorUniqDup, ws, row, col; kw...)
-setCfContainsBlankErrorUniqDup(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfContainsBlankErrorUniqDup, ws, row, col; kw...)
-function setCfContainsBlankErrorUniqDup(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=())::Int
-
- operator::Union{Nothing,String}="containsBlanks"
- stopIfTrue::Union{Nothing,String}=nothing
- dxStyle::Union{Nothing,String}=nothing
- format::Union{Nothing,Vector{Pair{String,String}}}=nothing
- font::Union{Nothing,Vector{Pair{String,String}}}=nothing
- border::Union{Nothing,Vector{Pair{String,String}}}=nothing
- fill::Union{Nothing,Vector{Pair{String,String}}}=nothing
- for (k, v) in allkws
- if k == :operator
- operator = v
- elseif k == :stopIfTrue
- stopIfTrue = v
- elseif k == :dxStyle
- dxStyle = v
- elseif k == :format
- format = v
- elseif k == :font
- font = v
- elseif k == :border
- border = v
- elseif k == :fill
- fill = v
- else
- throw(XLSXError("Invalid keyword argument: $k. Valid options are: `operator`, `stopIfTrue`, `dxStyle`, `format`, `font`, `border`, `fill`."))
- end
- end
-
- _check_cf_range(ws, rng)
-
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
-
- if operator == "containsBlanks"
- formula = "LEN(TRIM(__CR__))=0"
- elseif operator == "notContainsBlanks"
- formula = "LEN(TRIM(__CR__))>0"
- elseif operator == "containsErrors"
- formula = "ISERROR(__CR__)"
- elseif operator == "notContainsErrors"
- formula = "NOT(ISERROR(__CR__))"
- elseif operator == "uniqueValues"
- formula = ""
- elseif operator == "duplicateValues"
- formula = ""
- end
- formula = replace(formula, "__CR__" => string(_cf_anchor(rng)))
-
- wb = get_workbook(ws)
- dx = get_dx(dxStyle, format, font, border, fill)
- new_dx = get_new_dx(wb, dx)
- dxid = Add_Cf_Dx(wb, new_dx)
-
- cfx = XML.Element("$(pfx)cfRule"; type=operator, dxfId=string(dxid.id))
- cfx["priority"] = next_cf_priority!(ws)
- if !isnothing(stopIfTrue) && stopIfTrue == "true"
- cfx["stopIfTrue"] = "1"
- end
- formula != "" && push!(cfx, XML.Element("$(pfx)formula", XML.Text(formula)))
-
- update_worksheet_cfx!(cfx, ws, rng)
-
- return 0
-end
-
-setCfFormula(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setCfFormula, ws, row, nothing; kw...)
-setCfFormula(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setCfFormula, ws, nothing, col; kw...)
-setCfFormula(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setCfFormula, ws, nothing, nothing; kw...)
-setCfFormula(ws::Worksheet, ::Colon; kw...) = process_colon(setCfFormula, ws, nothing, nothing; kw...)
-setCfFormula(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setCfFormula(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setCfFormula(ws::Worksheet, cell::CellRef; kw...) = setCfFormula(ws, CellRange(cell, cell); kw...)
-setCfFormula(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && setCfFormula(ws, CellRange(cell.cellref, cell.cellref); kw...)
-setCfFormula(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setCfFormula(ws, rng.rng; kw...)
-setCfFormula(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setCfFormula(ws, rng.colrng; kw...)
-setCfFormula(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setCfFormula(ws, rng.rowrng; kw...)
-setCfFormula(ws::Worksheet, rng::RowRange; kw...) = process_rowranges(setCfFormula, ws, rng; kw...)
-setCfFormula(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges(setCfFormula, ws, rng; kw...)
-setCfFormula(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setCfFormula, xl, sheetcell; kw...)
-setCfFormula(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setCfFormula, ws, ref_or_rng; kw...)
-setCfFormula(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon(setCfFormula, ws, row, nothing; kw...)
-setCfFormula(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon(setCfFormula, ws, nothing, col; kw...)
-setCfFormula(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfFormula, ws, row, col; kw...)
-setCfFormula(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint(setCfFormula, ws, row, col; kw...)
-setCfFormula(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfFormula, ws, row, col; kw...)
-function setCfFormula(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=())::Int
-
- formula::Union{Nothing,String}=nothing
- stopIfTrue::Union{Nothing,String}=nothing
- dxStyle::Union{Nothing,String}=nothing
- format::Union{Nothing,Vector{Pair{String,String}}}=nothing
- font::Union{Nothing,Vector{Pair{String,String}}}=nothing
- border::Union{Nothing,Vector{Pair{String,String}}}=nothing
- fill::Union{Nothing,Vector{Pair{String,String}}}=nothing
- for (k, v) in allkws
- if k == :formula
- formula = v
- elseif k == :stopIfTrue
- stopIfTrue = v
- elseif k == :dxStyle
- dxStyle = v
- elseif k == :format
- format = v
- elseif k == :font
- font = v
- elseif k == :border
- border = v
- elseif k == :fill
- fill = v
- else
- throw(XLSXError("Invalid keyword argument: $k. Valid options are: `formula`, `stopIfTrue`, `dxStyle`, `format`, `font`, `border`, `fill`."))
- end
- end
- isnothing(formula) && throw(XLSXError("A `formula` must be provided as a keyword argument."))
-
- _check_cf_range(ws, rng)
-
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
-
- wb = get_workbook(ws)
- dx = get_dx(dxStyle, format, font, border, fill)
- new_dx = get_new_dx(wb, dx)
- dxid = Add_Cf_Dx(wb, new_dx)
-
- cfx = XML.Element("$(pfx)cfRule"; type="expression", dxfId=string(dxid.id))
- cfx["priority"] = next_cf_priority!(ws)
- if !isnothing(stopIfTrue) && stopIfTrue == "true"
- cfx["stopIfTrue"] = "1"
- end
-
- push!(cfx, XML.Element("$(pfx)formula", XML.Text("(" * uppercase_unquoted(formula) * ")")))
-
- update_worksheet_cfx!(cfx, ws, rng)
-
- return 0
-end
-
-setCfColorScale(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setCfColorScale, ws, row, nothing; kw...)
-setCfColorScale(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setCfColorScale, ws, nothing, col; kw...)
-setCfColorScale(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setCfColorScale, ws, nothing, nothing; kw...)
-setCfColorScale(ws::Worksheet, ::Colon; kw...) = process_colon(setCfColorScale, ws, nothing, nothing; kw...)
-setCfColorScale(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setCfColorScale(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setCfColorScale(ws::Worksheet, cell::CellRef; kw...) = setCfColorScale(ws, CellRange(cell, cell); kw...)
-setCfColorScale(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && setCfColorScale(ws, CellRange(cell.cellref, cell.cellref); kw...)
-setCfColorScale(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setCfColorScale(ws, rng.rng; kw...)
-setCfColorScale(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setCfColorScale(ws, rng.colrng; kw...)
-setCfColorScale(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setCfColorScale(ws, rng.rowrng; kw...)
-setCfColorScale(ws::Worksheet, rng::RowRange; kw...) = process_rowranges(setCfColorScale, ws, rng; kw...)
-setCfColorScale(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges(setCfColorScale, ws, rng; kw...)
-setCfColorScale(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setCfColorScale, xl, sheetcell; kw...)
-setCfColorScale(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setCfColorScale, ws, ref_or_rng; kw...)
-setCfColorScale(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon(setCfColorScale, ws, row, nothing; kw...)
-setCfColorScale(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon(setCfColorScale, ws, nothing, col; kw...)
-setCfColorScale(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfColorScale, ws, row, col; kw...)
-setCfColorScale(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint(setCfColorScale, ws, row, col; kw...)
-setCfColorScale(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfColorScale, ws, row, col; kw...)
-function setCfColorScale(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=())::Int
- colorscale::Union{Nothing,String}=nothing
- min_type::Union{Nothing,String}="min"
- min_val::Union{Nothing,String}=nothing
- min_col::Union{Nothing,String}="FFF8696B"
- mid_type::Union{Nothing,String}=nothing
- mid_val::Union{Nothing,String}=nothing
- mid_col::Union{Nothing,String}="FFFCFCFF"
- max_type::Union{Nothing,String}="max"
- max_val::Union{Nothing,String}=nothing
- max_col::Union{Nothing,String}="FFFFEB84"
-
- for (k, v) in allkws
- if k == :colorscale
- colorscale = v
- elseif k == :min_type
- min_type = v
- elseif k == :min_val
- min_val = v
- elseif k == :min_col
- min_col = v
- elseif k == :mid_type
- mid_type = v
- elseif k == :mid_val
- mid_val = v
- elseif k == :mid_col
- mid_col = v
- elseif k == :max_type
- max_type = v
- elseif k == :max_val
- max_val = v
- elseif k == :max_col
- max_col = v
- else
- throw(XLSXError("Invalid keyword argument: $k. Valid options are: `colorscale`, `min_type`, `min_val`, `min_col`, `mid_type`, `mid_val`, `mid_col`, `max_type`, `max_val`, `max_col`."))
- end
- end
-
- _check_cf_range(ws, rng)
-
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
-
- let new_pr, new_cf
-
- new_pr = string(next_cf_priority!(ws))
-
- if isnothing(colorscale)
-
- min_type in ["min", "percentile", "percent", "num", "formula"] || throw(XLSXError("Invalid min_type: $min_type. Valid options are: min, percentile, percent, num, formula."))
- if min_type == "min"
- min_val = nothing
- end
- min_type == "formula" || isnothing(min_val) || is_valid_fixed_cellname(min_val) || is_valid_fixed_sheet_cellname(min_val) || !isnothing(tryparse(Float64, min_val)) || throw(XLSXError("Invalid min_val: `$min_val`. Valid options (unless min_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number."))
-
- isnothing(mid_type) || mid_type in ["percentile", "percent", "num", "formula"] || throw(XLSXError("Invalid mid_type: $mid_type. Valid options are: percentile, percent, num, formula."))
- (!isnothing(mid_type) && mid_type == "formula") || isnothing(mid_val) || is_valid_fixed_cellname(mid_val) || is_valid_fixed_sheet_cellname(mid_val) || !isnothing(tryparse(Float64, mid_val)) || throw(XLSXError("Invalid mid_val: `$mid_val`. Valid options (unless mid_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number."))
-
- max_type in ["max", "percentile", "percent", "num", "formula"] || throw(XLSXError("Invalid max_type: $max_type. Valid options are: max, percentile, percent, num, formula."))
- if max_type == "max"
- max_val = nothing
- end
- max_type == "formula" || isnothing(max_val) || is_valid_fixed_cellname(max_val) || is_valid_fixed_sheet_cellname(max_val) || !isnothing(tryparse(Float64, max_val)) || throw(XLSXError("Invalid max_val: `$max_val`. Valid options (unless max_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number."))
-
- min_val = _normalise_cfvo_val(ws, min_val)
- mid_val = _normalise_cfvo_val(ws, mid_val)
- max_val = _normalise_cfvo_val(ws, max_val)
-
- cfx = XML.Element("$(pfx)cfRule"; type="colorScale", priority=new_pr)
- csc = XML.Element("$(pfx)colorScale")
- push!(csc,
- isnothing(min_val) ?
- XML.Element("$(pfx)cfvo"; type=min_type) :
- XML.Element("$(pfx)cfvo"; type=min_type, val=min_val)
- )
-
- !isnothing(mid_type) && push!(csc, XML.Element("$(pfx)cfvo"; type=mid_type, val=mid_val))
-
- push!(csc,
- isnothing(max_val) ?
- XML.Element("$(pfx)cfvo"; type=max_type) :
- XML.Element("$(pfx)cfvo"; type=max_type, val=max_val)
- )
-
- push!(csc, XML.Element("$(pfx)color"; rgb=get_color(min_col)))
- !isnothing(mid_type) && push!(csc, XML.Element("$(pfx)color"; rgb=get_color(mid_col)))
- push!(csc, XML.Element("$(pfx)color"; rgb=get_color(max_col)))
- push!(cfx, csc)
- else
- if !haskey(colorscales, colorscale)
- throw(XLSXError("Invalid colorscale option chosen: $colorscale. Valid options are: $(keys(colorscales))."))
- end
- cfx = copynode(colorscales[colorscale])
- cfx["priority"] = new_pr
- end
-
- update_worksheet_cfx!(cfx, ws, rng)
-
- end
-
- return 0
-end
-
-setCfIconSet(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setCfIconSet, ws, row, nothing; kw...)
-setCfIconSet(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setCfIconSet, ws, nothing, col; kw...)
-setCfIconSet(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setCfIconSet, ws, nothing, nothing; kw...)
-setCfIconSet(ws::Worksheet, ::Colon; kw...) = process_colon(setCfIconSet, ws, nothing, nothing; kw...)
-setCfIconSet(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setCfIconSet(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setCfIconSet(ws::Worksheet, cell::CellRef; kw...) = setCfIconSet(ws, CellRange(cell, cell); kw...)
-setCfIconSet(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && setCfIconSet(ws, CellRange(cell.cellref, cell.cellref); kw...)
-setCfIconSet(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setCfIconSet(ws, rng.rng; kw...)
-setCfIconSet(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setCfIconSet(ws, rng.colrng; kw...)
-setCfIconSet(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setCfIconSet(ws, rng.rowrng; kw...)
-setCfIconSet(ws::Worksheet, rng::RowRange; kw...) = process_rowranges(setCfIconSet, ws, rng; kw...)
-setCfIconSet(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges(setCfIconSet, ws, rng; kw...)
-setCfIconSet(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setCfIconSet, xl, sheetcell; kw...)
-setCfIconSet(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setCfIconSet, ws, ref_or_rng; kw...)
-setCfIconSet(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon(setCfIconSet, ws, row, nothing; kw...)
-setCfIconSet(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon(setCfIconSet, ws, nothing, col; kw...)
-setCfIconSet(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfIconSet, ws, row, col; kw...)
-setCfIconSet(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint(setCfIconSet, ws, row, col; kw...)
-setCfIconSet(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfIconSet, ws, row, col; kw...)
-function setCfIconSet(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=())::Int
-
- iconset::Union{Nothing,String} = "3TrafficLights"
- reverse::Union{Nothing,String} = nothing
- showVal::Union{Nothing,String} = nothing
- min_type::Union{Nothing,String} = nothing
- min_val::Union{Nothing,String} = nothing
- min_gte::Union{Nothing,String} = nothing
- mid_type::Union{Nothing,String} = nothing
- mid_val::Union{Nothing,String} = nothing
- mid_gte::Union{Nothing,String} = nothing
- mid2_type::Union{Nothing,String} = nothing
- mid2_val::Union{Nothing,String} = nothing
- mid2_gte::Union{Nothing,String} = nothing
- max_type::Union{Nothing,String} = nothing
- max_val::Union{Nothing,String} = nothing
- max_gte::Union{Nothing,String} = nothing
- icon_list::Union{Nothing,Vector{Int64}}=nothing
-
- for (k, v) in allkws
- if k == :iconset
- iconset = v
- elseif k == :reverse
- reverse = v
- elseif k == :showVal
- showVal = v
- elseif k == :min_type
- min_type = v
- elseif k == :min_val
- min_val = v
- elseif k == :min_gte
- min_gte = v
- elseif k == :mid_type
- mid_type = v
- elseif k == :mid_val
- mid_val = v
- elseif k == :mid_gte
- mid_gte = v
- elseif k == :mid2_type
- mid2_type = v
- elseif k == :mid2_val
- mid2_val = v
- elseif k == :mid2_gte
- mid2_gte = v
- elseif k == :max_type
- max_type = v
- elseif k == :max_val
- max_val = v
- elseif k == :max_gte
- max_gte = v
- elseif k == :icon_list
- icon_list = v
- else
- throw(XLSXError("Invalid keyword argument: $k. Valid options are: `iconset`, `reverse`, `showVal`, `min_type`, `min_val`, `min_gte`, `mid_type`, `mid_val`, `mid_gte`, `mid2_type`, `mid2_val`, `mid2_gte`, `max_type`, `max_val`, `max_gte`, `icon_list`."))
- end
- end
-
- _check_cf_range(ws, rng)
-
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
-
- let new_pr, new_cf
-
- new_pr = string(next_cf_priority!(ws))
-
- isnothing(min_type) || min_type in ["percentile", "percent", "num", "formula"] || throw(XLSXError("Invalid min_type: $min_type. Valid options are: percentile, percent, num, formula."))
- (!isnothing(min_type) && min_type == "formula") || isnothing(min_val) || is_valid_fixed_cellname(min_val) || is_valid_fixed_sheet_cellname(min_val) || !isnothing(tryparse(Float64, min_val)) || throw(XLSXError("Invalid min_val: `$min_val`. Valid options (unless min_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number."))
- isnothing(mid_type) || mid_type in ["percentile", "percent", "num", "formula"] || throw(XLSXError("Invalid mid_type: $mid_type. Valid options are: percentile, percent, num, formula."))
- (!isnothing(mid_type) && mid_type == "formula") || isnothing(mid_val) || is_valid_fixed_cellname(mid_val) || !is_valid_fixed_sheet_cellname(mid_val) || !isnothing(tryparse(Float64, mid_val)) || throw(XLSXError("Invalid mid_val: `$mid_val`. Valid options (unless mid_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number."))
- isnothing(mid2_type) || mid2_type in ["percentile", "percent", "num", "formula"] || throw(XLSXError("Invalid mid_type: $mid2_type. Valid options are: percentile, percent, num, formula."))
- (!isnothing(mid2_type) && mid2_type == "formula") || isnothing(mid2_val) || is_valid_fixed_cellname(mid2_val) || is_valid_fixed_sheet_cellname(mid2_val) || !isnothing(tryparse(Float64, mid2_val)) || throw(XLSXError("Invalid mid2_type: `$mid2_val`. Valid options (unless mid2_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number."))
- isnothing(max_type) || max_type in ["percentile", "percent", "num", "formula"] || throw(XLSXError("Invalid max_type: $max_type. Valid options are: percentile, percent, num, formula."))
- (!isnothing(max_type) && max_type == "formula") || isnothing(max_val) || is_valid_fixed_cellname(max_val) || is_valid_fixed_sheet_cellname(max_val) || !isnothing(tryparse(Float64, max_val)) || throw(XLSXError("Invalid max_val: `$max_val`. Valid options (unless max_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number."))
-
- min_val = _normalise_cfvo_val(ws, min_val)
- mid_val = _normalise_cfvo_val(ws, mid_val)
- mid2_val = _normalise_cfvo_val(ws, mid2_val)
- max_val = _normalise_cfvo_val(ws, max_val)
-
- if !haskey(iconsets, iconset)
- throw(XLSXError("Invalid iconset option chosen: $iconset. Valid options are: $(keys(iconsets))"))
- end
- l = first(iconset)
- cfx = copynode(iconsets[iconset])
- if l == 'C'
- cfvo = XML.Element("x14:cfvo", type="percent")
- push!(cfvo, XML.Element("xm:f", XML.Text("dummy")))
- push!(cfx[1], copynode(cfvo)) # for min_val
- push!(cfx[1], copynode(cfvo)) # for max_val
- if isnothing(min_type) || isnothing(min_val) || isnothing(max_type) || isnothing(max_val)
- throw(XLSXError("No type or val keywords defined. Must define at least `min_type`, `min_val`, `max_type` and `max_val` for a custom iconSet"))
- elseif isnothing(mid_type) || isnothing(mid_val)
- list = [(min_type, min_val, min_gte), (max_type, max_val, max_gte)]
- nicons = 3
- elseif isnothing(mid2_type) || isnothing(mid2_val)
- push!(cfx[1], copynode(cfvo)) # for mid_val
- cfx[1]["iconSet"] = "4Arrows"
- nicons = 4
- list = [(min_type, min_val, min_gte), (mid_type, mid_val, mid_gte), (max_type, max_val, max_gte)]
- else
- push!(cfx[1], copynode(cfvo)) # for mid_val
- push!(cfx[1], copynode(cfvo)) # for mid2_val
- cfx[1]["iconSet"] = "5Quarters"
- nicons = 5
- list = [(min_type, min_val, min_gte), (mid_type, mid_val, mid_gte), (mid2_type, mid2_val, mid2_gte), (max_type, max_val, max_gte)]
- end
- elseif l == '5'
- list = [(min_type, min_val, min_gte), (mid_type, mid_val, mid_gte), (mid2_type, mid2_val, mid2_gte), (max_type, max_val, max_gte)]
- elseif l == '4'
- list = [(min_type, min_val, min_gte), (mid_type, mid_val, mid_gte), (max_type, max_val, max_gte)]
- else
- list = [(min_type, min_val, min_gte), (max_type, max_val, max_gte)]
- end
- if iconset in ["3Triangles", "3Stars", "5Boxes", "Custom"]
- cfx["id"] = _unique_guid(ws.package.uuid_rng, _x14_cf_ids(ws))
- cfx["priority"] = new_pr
- if !isnothing(showVal) && showVal == "false"
- cfx[1]["showValue"] = "0"
- end
- if !isnothing(reverse) && reverse == "true"
- if iconset == "Custom"
- reverse!(icon_list)
- else
- cfx[1]["reverse"] = "1"
- end
- end
- for (i, (type, val, gte)) in enumerate(list)
- if !isnothing(type)
- cfx[1][i+1]["type"] = type # Need +1 because the first is always 0 percent.
- end
- if !isnothing(val)
- if !isnothing(type) && type == "formula"
- c = XML.Element("xm:f", XML.Text("(" * val * ")"))
- else
- c = XML.Element("xm:f", XML.Text(val))
- end
- if isempty(XML.children(cfx[1][i+1]))
- old = cfx[1][i+1]
- cfx[1][i+1] = XML.Node{String}(XML.Element, XML.tag(old), old.attributes, nothing, XML.Node{String}[c])
- else
- cfx[1][i+1][1] = c
- end
- end
- if !isnothing(gte) && gte == "false"
- cfx[1][i+1]["gte"] = "0"
- end
- end
- if iconset == "Custom"
- if isnothing(icon_list)
- throw(XLSXError("No custom icons specified. Must specify between two and four icons."))
- elseif length(icon_list) < nicons
- throw(XLSXError("Too few custom icons specified: $(length(icon_list)). Expected $nicons"))
- end
- for (count, icon) in enumerate(string.(icon_list))
- if !isnothing(icon)
- if !haskey(allIcons, icon)
- throw(XLSXError("Invalid custom icon specified: $icon. Valid values are \"1\" to \"52\"."))
- end
- i = allIcons[icon]
- push!(cfx[1], XML.Element("x14:cfIcon", iconSet=first(i), iconId=last(i)))
- count == nicons && break
- end
- end
- end
- update_worksheet_ext_cfx!(cfx, ws, rng)
- else
- cfx["priority"] = new_pr
- if !isnothing(showVal) && showVal == "false"
- cfx[1]["showValue"] = "0"
- end
- if !isnothing(reverse) && reverse == "true"
- cfx[1]["reverse"] = "1"
- end
- for (i, (type, val, gte)) in enumerate(list)
- if !isnothing(val)
- if !isnothing(type) && type == "formula"
- cfx[1][i+1]["val"] = "(" * val * ")"
- else
- cfx[1][i+1]["val"] = val
- end
- end
- if !isnothing(type)
- cfx[1][i+1]["type"] = type
- end
- if !isnothing(gte) && gte == "false"
- cfx[1][i+1]["gte"] = "0"
- end
- end
- update_worksheet_cfx!(cfx, ws, rng)
- end
-
- end
-
- return 0
-end
-
-setCfDataBar(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw...) = process_colon(setCfDataBar, ws, row, nothing; kw...)
-setCfDataBar(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setCfDataBar, ws, nothing, col; kw...)
-setCfDataBar(ws::Worksheet, ::Colon, ::Colon; kw...) = process_colon(setCfDataBar, ws, nothing, nothing; kw...)
-setCfDataBar(ws::Worksheet, ::Colon; kw...) = process_colon(setCfDataBar, ws, nothing, nothing; kw...)
-setCfDataBar(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = setCfDataBar(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))); kw...)
-setCfDataBar(ws::Worksheet, cell::CellRef; kw...) = setCfDataBar(ws, CellRange(cell, cell); kw...)
-setCfDataBar(ws::Worksheet, cell::SheetCellRef; kw...) = do_sheet_names_match(ws, cell) && setCfDataBar(ws, CellRange(cell.cellref, cell.cellref); kw...)
-setCfDataBar(ws::Worksheet, rng::SheetCellRange; kw...) = do_sheet_names_match(ws, rng) && setCfDataBar(ws, rng.rng; kw...)
-setCfDataBar(ws::Worksheet, rng::SheetColumnRange; kw...) = do_sheet_names_match(ws, rng) && setCfDataBar(ws, rng.colrng; kw...)
-setCfDataBar(ws::Worksheet, rng::SheetRowRange; kw...) = do_sheet_names_match(ws, rng) && setCfDataBar(ws, rng.rowrng; kw...)
-setCfDataBar(ws::Worksheet, rng::RowRange; kw...) = process_rowranges(setCfDataBar, ws, rng; kw...)
-setCfDataBar(ws::Worksheet, rng::ColumnRange; kw...) = process_columnranges(setCfDataBar, ws, rng; kw...)
-setCfDataBar(xl::XLSXFile, sheetcell::AbstractString; kw...)::Int = process_sheetcell(setCfDataBar, xl, sheetcell; kw...)
-setCfDataBar(ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int = process_ranges(setCfDataBar, ws, ref_or_rng; kw...)
-setCfDataBar(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_cf_veccolon(setCfDataBar, ws, row, nothing; kw...)
-setCfDataBar(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_veccolon(setCfDataBar, ws, nothing, col; kw...)
-setCfDataBar(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfDataBar, ws, row, col; kw...)
-setCfDataBar(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_cf_vecint(setCfDataBar, ws, row, col; kw...)
-setCfDataBar(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_cf_vecint(setCfDataBar, ws, row, col; kw...)
-function setCfDataBar(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=())::Int
-
- databar::Union{Nothing,String}="bluegrad"
- priority::Union{Nothing,Int}=nothing # internal only - see `new_pr` below
- showVal::Union{Nothing,String}=nothing
- gradient::Union{Nothing,String}=nothing
- borders::Union{Nothing,String}=nothing
- sameNegFill::Union{Nothing,String}=nothing
- sameNegBorders::Union{Nothing,String}=nothing
- direction::Union{Nothing,String}=nothing
- axis_pos::Union{Nothing,String}=nothing
- axis_col::Union{Nothing,String}=nothing
- min_type::Union{Nothing,String}=nothing
- min_val::Union{Nothing,String}=nothing
- max_type::Union{Nothing,String}=nothing
- max_val::Union{Nothing,String}=nothing
- fill_col::Union{Nothing,String}=nothing
- border_col::Union{Nothing,String}=nothing
- neg_fill_col::Union{Nothing,String}=nothing
- neg_border_col::Union{Nothing,String}=nothing
-
- for (k, v) in allkws
- if k == :databar
- databar = v
- elseif k == :priority
- priority = v
- elseif k == :showVal
- showVal = v
- elseif k == :gradient
- gradient = v
- elseif k == :borders
- borders = v
- elseif k == :sameNegFill
- sameNegFill = v
- elseif k == :sameNegBorders
- sameNegBorders = v
- elseif k == :direction
- direction = v
- elseif k == :axis_pos
- axis_pos = v
- elseif k == :axis_col
- axis_col = v
- elseif k == :min_type
- min_type = v
- elseif k == :min_val
- min_val = v
- elseif k == :max_type
- max_type = v
- elseif k == :max_val
- max_val = v
- elseif k == :fill_col
- fill_col = v
- elseif k == :border_col
- border_col = v
- elseif k == :neg_fill_col
- neg_fill_col = v
- elseif k == :neg_border_col
- neg_border_col = v
- else
- throw(XLSXError("Invalid keyword argument: $k. Valid options are: `databar`, `showVal`, `gradient`, `borders`, `sameNegFill`, `sameNegBorders`, `direction`, `axis_pos`, `axis_col`, `min_type`, `min_val`, `max_type`, `max_val`, `fill_col`, `border_col`, `neg_fill_col`, `neg_border_col`."))
- end
- end
-
- _check_cf_range(ws, rng)
-
- pfx = get_prefix(ws)
- pfx = pfx == "" ? pfx : pfx * ":"
-
-
- let new_pr, new_cf
-
- # `priority` is for internal callers only and is deliberately undocumented: it is
- # omitted from the valid-keyword list above so a user passing it gets an error.
- # `setColoredDataBars` reserves a contiguous block of priorities up front and passes
- # them in band order, so the bands are numbered predictably rather than in whatever
- # order the partition happens to yield. Note it bypasses `next_cf_priority!`, so a
- # caller supplying it owns the number: no uniqueness check, and the counter does not
- # advance.
- new_pr = string(something(priority, next_cf_priority!(ws)))
-
- isnothing(min_type) || min_type in ["least", "percentile", "percent", "num", "formula", "automatic"] || throw(XLSXError("Invalid min_type: $min_type. Valid options are: least, percentile, percent, num, formula."))
- if min_type in ["least", "automatic"]
- min_val = nothing
- end
- (!isnothing(min_type) && min_type == "formula") || isnothing(min_val) || is_valid_fixed_cellname(min_val) || is_valid_fixed_sheet_cellname(min_val) || !isnothing(tryparse(Float64, min_val)) || throw(XLSXError("Invalid min_val: `$min_val`. Valid options (unless min_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number."))
- isnothing(max_type) || max_type in ["highest", "percentile", "percent", "num", "formula", "automatic"] || throw(XLSXError("Invalid max_type: $max_type. Valid options are: highest, percentile, percent, num, formula."))
- if max_type in ["highest", "automatic"]
- max_val = nothing
- end
- (!isnothing(max_type) && max_type == "formula") || isnothing(max_val) || is_valid_fixed_cellname(max_val) || is_valid_fixed_sheet_cellname(max_val) || !isnothing(tryparse(Float64, max_val)) || throw(XLSXError("Invalid max_val: `$max_val`. Valid options (unless max_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number."))
-
- min_val = _normalise_cfvo_val(ws, min_val)
- max_val = _normalise_cfvo_val(ws, max_val)
-
- if !haskey(databars, databar)
- throw(XLSXError("Invalid dataBar option chosen: $databar. Valid options are: $(keys(databars))"))
- end
-
- allkws::Dict{String,Union{String,Nothing}} = Dict(
- "showVal" => showVal,
- "gradient" => gradient,
- "borders" => borders,
- "sameNegFill" => sameNegFill,
- "sameNegBorders" => sameNegBorders,
- "direction" => direction,
- "min_type" => min_type,
- "min_val" => min_val,
- "max_type" => max_type,
- "max_val" => max_val,
- "fill_col" => fill_col,
- "border_col" => border_col,
- "neg_fill_col" => neg_fill_col,
- "neg_border_col" => neg_border_col,
- "axis_pos" => axis_pos,
- "axis_col" => axis_col
- )
-
- for (k, w) in allkws # Allow user input to override any default value
- if isnothing(w)
- if haskey(databars[databar], k)
- allkws[k] = databars[databar][k]
- end
- end
- end
- for kw in ["showVal", "gradient", "borders", "sameNegFill", "sameNegBorders"]
- haskey(allkws, kw) && isValidKw(kw, allkws[kw], ["true", "false"])
- end
- haskey(allkws, "direction") && isValidKw("direction", allkws["direction"], ["leftToRight", "rightToLeft"])
- haskey(allkws, "axis_pos") && isValidKw("axis_pos", allkws["axis_pos"], ["middle", "none"])
-
- # Define basic elements of dataBar definition
- id = _unique_guid(ws.package.uuid_rng, _x14_cf_ids(ws))
- mnt = allkws["min_type"] ∈ ["automatic", "least"] ? "min" : allkws["min_type"]
- mxt = allkws["max_type"] ∈ ["automatic", "highest"] ? "max" : allkws["max_type"]
-
- cfx = XML.Element("$(pfx)cfRule"; type="dataBar", priority=new_pr)
- cdb = XML.Element("$(pfx)dataBar")
- push!(cdb, isnothing(allkws["min_val"]) ?
- XML.Element("$(pfx)cfvo"; type=mnt) :
- XML.Element("$(pfx)cfvo"; type=mnt, val=allkws["min_val"])
- )
-
- push!(cdb, isnothing(allkws["max_val"]) ?
- XML.Element("$(pfx)cfvo"; type=mxt) :
- XML.Element("$(pfx)cfvo"; type=mxt, val=allkws["max_val"])
- )
-
- push!(cdb, XML.Element("$(pfx)color"; rgb=get_color(allkws["fill_col"])))
-
- push!(cfx, cdb)
-
- if haskey(allkws, "showVal") && !isnothing(allkws["showVal"]) && allkws["showVal"] == "false"
- cdb["showValue"] = "0"
- end
- # Links the 2007 rule to its x14 counterpart by id. Excel expects the in an
- # inside , as a sibling following - not inside
- # itself, where it is silently discarded on open along with the x14 rule it links to.
- cfx_ext = XML.Element("$(pfx)ext")
- cfx_ext["uri"] = "{B025F937-C7B1-47D3-B67F-A62EFF666E3E}"
- cfx_ext["xmlns:x14"] = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"
- push!(cfx_ext, XML.Element("x14:id", XML.Text(id)))
- cfx_extlst = XML.Element("$(pfx)extLst")
- push!(cfx_extlst, cfx_ext)
- push!(cfx, cfx_extlst)
-
- # Define extension elements of dataBar definition
- emnt = allkws["min_type"] == "automatic" ? "autoMin" : allkws["min_type"] == "least" ? "min" : allkws["min_type"]
- emxt = allkws["max_type"] == "automatic" ? "autoMax" : allkws["max_type"] == "highest" ? "max" : allkws["max_type"]
- emnv = allkws["min_type"] == "formula" ? "(" * allkws["min_val"] * ")" : allkws["min_val"]
- emxv = allkws["max_type"] == "formula" ? "(" * allkws["max_val"] * ")" : allkws["max_val"]
- ext_cfx = XML.Element("x14:cfRule", type="dataBar", id=id)
- ext_db = XML.Element("x14:dataBar", minLength="0", maxLength="100")
- valmin = XML.Element("x14:cfvo", type=emnt)
- !isnothing(emnv) && push!(valmin, XML.Element("xm:f", XML.Text(emnv)))
- valmax = XML.Element("x14:cfvo", type=emxt)
- !isnothing(emxv) && push!(valmax, XML.Element("xm:f", XML.Text(emxv)))
- push!(ext_db, valmin)
- push!(ext_db, valmax)
- if allkws["gradient"] == "false"
- ext_db["gradient"] = "0"
- end
- do_borders = haskey(allkws, "borders") && allkws["borders"] == "true"
- if do_borders
- ext_db["border"] = "1"
- if haskey(allkws, "border_col") && !isnothing(allkws["border_col"])
- push!(ext_db, XML.Element("x14:borderColor", rgb=get_color(allkws["border_col"])))
- else
- push!(ext_db, XML.Element("x14:borderColor", rgb=get_color("FF638EC6"))) # Default colour
- end
- end
- if haskey(allkws, "direction")
- if allkws["direction"] == "leftToRight"
- ext_db["direction"] = "leftToRight"
- elseif allkws["direction"] == "rightToLeft"
- ext_db["direction"] = "rightToLeft"
- end
- end
- if haskey(allkws, "sameNegFill") && allkws["sameNegFill"] == "true"
- ext_db["negativeBarColorSameAsPositive"] = "1"
- else
- if haskey(allkws, "neg_fill_col") && !isnothing(allkws["neg_fill_col"])
- push!(ext_db, XML.Element("x14:negativeFillColor", rgb=get_color(allkws["neg_fill_col"])))
- else
- push!(ext_db, XML.Element("x14:negativeFillColor", rgb=get_color("FFFF0000"))) # Default colour
- end
- end
- if do_borders && haskey(allkws, "sameNegBorders") && allkws["sameNegBorders"] == "false"
- ext_db["negativeBarBorderColorSameAsPositive"] = "0"
- if haskey(allkws, "neg_border_col") && !isnothing(allkws["neg_border_col"])
- push!(ext_db, XML.Element("x14:negativeBorderColor", rgb=get_color(allkws["neg_border_col"])))
- else
- push!(ext_db, XML.Element("x14:negativeBorderColor", rgb=get_color("FFFF0000"))) # Default colour
- end
- end
- if haskey(allkws, "axis_pos")
- if allkws["axis_pos"] == "none"
- ext_db["axisPosition"] = "none"
- elseif allkws["axis_pos"] == "middle"
- ext_db["axisPosition"] = "middle"
- end
- end
- haskey(allkws, "axis_col") && push!(ext_db, XML.Element("x14:axisColor", rgb=get_color(allkws["axis_col"])))
- push!(ext_cfx, ext_db)
-
- update_worksheet_cfx!(cfx, ws, rng) # Add basic elements to worksheet xml file
- update_worksheet_ext_cfx!(ext_cfx, ws, rng) # Add extension elements to worksheet xml file
- end
- return 0
-end
-
-# ---------------------------------------------------------------------------
-# Non-contiguous range support for conditional formats
-# ---------------------------------------------------------------------------
-
-
-# ---------------------------------------------------------------------------
-# setColoredDataBars
-# ---------------------------------------------------------------------------
-
-"""
- setColoredDataBars(sheet, rng; [bands], [colors], [breaks], [gte], [showVal],
- [direction], [min_type], [min_val], [max_type], [max_val],
- [clear]) -> Vector{Pair}
-
-Apply data bars to `rng` whose colour changes with value as well as their length.
-
-Excel has no such conditional format: a `dataBar` rule carries a single colour and
-applies to every cell in its range. `setColoredDataBars` splits `rng` into value-based
-bands and writes one data bar rule per band, each over the non-contiguous range of
-cells falling in that band. The result is a valid workbook that Excel reads without
-complaint, but it is not something Excel's own interface can produce, and it is not
-maintained by Excel: see the note on editing below.
-
-`bands` (default 5) sets how many bands to use, with boundaries placed at equal
-intervals between the smallest and largest value in `rng`. Give `breaks` instead to
-place the boundaries yourself, as a sorted vector of `length(bands)-1` values; `breaks`
-overrides `bands`. By default a cell whose value equals a break falls in the band above
-it; set `gte=false` to place it in the band below, or give a vector of `Bool` with one
-entry per break.
-
-`colors` accepts either one colour per band, or exactly two colours to interpolate
-between (in LCHab, so intermediate hues stay clean). Colours may be names or 8-digit
-`AARRGGBB` strings, as elsewhere in XLSX.jl. Note that Colors.jl's named colormaps are
-not accepted; for a red-amber-green ramp give the three colours explicitly.
-
-`showVal` and `direction` are passed through to each band's rule unchanged. Bars are
-drawn with a solid fill rather than a gradient.
-
-`min_type`, `min_val`, `max_type` and `max_val` set the value axis shared by every band,
-and default to `num` with the minimum and maximum of `rng`. Only `num` and `formula` are
-accepted: the relative types (`percent`, `percentile`, `min`, `max`, `automatic`) are
-evaluated over each rule's own cells, which for a band is only that band's values, so
-every band would span the full bar width and bar lengths would carry no meaning.
-
-By default (clear=true) any conditional format lying entirely within rng is removed
-before the new bands are written, so repeated calls replace rather than accumulate. This
-matters when re-banding after the data have changed: bands from an earlier call name the
-cells they covered then, and left in place they would overlay the new ones. Set
-clear=false to add the bands alongside whatever is already there.
-
-Returns the band label => range pairs written. Bands containing no cells are dropped, so
-the result may be shorter than bands. Labels keep their position on the scale rather
-than being renumbered: banding data with only two distinct values across bands=5 may
-return labels 1 and 5, using the first and last colours of the ramp.
-
-!!! note
- `setColoredDataBars` is a static formatting function. The bands reflect cell values
- at the time of writing. If a value is later edited in Excel its bar length updates,
- but its color does not, because the cell stays in the band it was originally
- assigned to. Rewrite the conditional formatting to re-band it.
-
- This is a limitation of Excel itself, which does not support dataBars whose color
- varies with value. `setColoredDataBars` is a workaround for this.
-
-
-!!! note "Experimental"
-
- This function is experimental. It has a full set of tests and Documentation is
- published but it is not yet marked as public. It may be better included in
- future only as a documented example rather than as a package function.
-
-# Examples
-
-```julia
-julia> XLSX.setColoredDataBars(sheet, "B2:B101")
-
-julia> XLSX.setColoredDataBars(sheet, "B2:B101"; bands=3, colors=["green", "orange", "red"])
-
-julia> XLSX.setColoredDataBars(sheet, "B2:B101"; breaks=[10, 50], colors=["green", "red"])
-
-julia> XLSX.setColoredDataBars(sheet, "B2:B101"; bands=8, min_val="0", max_val="100")
-```
-
-See also [`setConditionalFormat`](@ref), [`clearConditionalFormats`](@ref).
-"""
-function setColoredDataBars end
-
-setColoredDataBars(ws::Worksheet, rng::AbstractString; kw...) =
- setColoredDataBars(ws, CellRange(rng); kw...)
-setColoredDataBars(ws::Worksheet, cell::CellRef; kw...) =
- setColoredDataBars(ws, CellRange(cell, cell); kw...)
-setColoredDataBars(ws::Worksheet, rng::SheetCellRange; kw...) =
- do_sheet_names_match(ws, rng) && setColoredDataBars(ws, rng.rng; kw...)
-
-function setColoredDataBars(ws::Worksheet, rng::CellRange;
- bands::Int=5,
- colors=["green", "red"],
- breaks=nothing,
- gte::Union{Bool,AbstractVector{Bool}}=true,
- showVal::Union{Nothing,String}=nothing,
- direction::Union{Nothing,String}=nothing,
- min_type::Union{Nothing,String}=nothing,
- min_val::Union{Nothing,String}=nothing,
- max_type::Union{Nothing,String}=nothing,
- max_val::Union{Nothing,String}=nothing,
- clear::Bool=true)
-
- _check_cf_areas(ws, rng)
-
- clear && clearConditionalFormats(ws, rng)
-
- vals = filter(v -> v isa Real && !isnan(v), vec(getdata(ws, rng)))
- isempty(vals) && throw(XLSXError("No numeric values in `$rng` to band."))
- lo, hi = float(minimum(vals)), float(maximum(vals))
-
- brk = if isnothing(breaks)
- bands < 1 && throw(XLSXError("`bands` must be at least 1, got $bands."))
- bands == 1 && return _single_bar(ws, rng, colors, lo, hi,
- showVal, direction, min_type, min_val, max_type, max_val)
- hi > lo || throw(XLSXError("All numeric values in `$rng` are equal; cannot form bands."))
- collect(range(lo, hi; length=bands + 1))[2:end-1]
- else
- bands = length(breaks) + 1
- collect(breaks)
- end
-
- cols = _band_colors(colors, bands)
-
- for (t, name) in ((min_type, "min_type"), (max_type, "max_type"))
- isnothing(t) || t in ["num", "formula"] || throw(XLSXError(
- "`$name` must be `num` or `formula` for coloured data bars; `$t` is evaluated " *
- "separately for each band, which makes bar lengths incomparable between bands."))
- end
- mnt = something(min_type, "num")
- mxt = something(max_type, "num")
- mnv = something(min_val, string(lo))
- mxv = something(max_val, string(hi))
-
- p = partition(ws, rng, brk; labels=1:bands, gte=gte)
-
- base = next_cf_priority!(ws)
- for _ in 2:length(p)
- next_cf_priority!(ws) # reserve the rest
- end
-
- for (n, (i, ncr)) in enumerate(p)
- kws = Dict{Symbol,Any}(
- :priority => base + n - 1,
- :fill_col => cols[i],
- :gradient => "false",
- :borders => "false",
- :sameNegFill => "true",
- :min_type => mnt, :min_val => mnv,
- :max_type => mxt, :max_val => mxv,
- )
- isnothing(showVal) || (kws[:showVal] = showVal)
- isnothing(direction) || (kws[:direction] = direction)
- setCfDataBar(ws, ncr; allkws=kws)
- end
- return p
-end
-
-# `bands=1` degenerates to an ordinary data bar over the whole contiguous range:
-# no partition, no non-contiguous sqref.
-function _single_bar(ws, rng, colors, lo, hi, showVal, direction,
- min_type, min_val, max_type, max_val)
- kws = Dict{Symbol,Any}(
- :fill_col => only(_band_colors(colors, 1)),
- :gradient => "false",
- :borders => "false",
- :sameNegFill => "true",
- :min_type => something(min_type, "num"), :min_val => something(min_val, string(lo)),
- :max_type => something(max_type, "num"), :max_val => something(max_val, string(hi)),
- )
- isnothing(showVal) || (kws[:showVal] = showVal)
- isnothing(direction) || (kws[:direction] = direction)
- setCfDataBar(ws, rng; allkws=kws)
- return [1 => rng]
-end
-
-"""
- clearConditionalFormats(ws::Worksheet, rng) -> Int
-
-Remove every conditional format whose range falls entirely within `rng`, returning
-the number of rules removed.
-
-A rule is removed when all of the cells it applies to lie inside `rng`. A rule
-spanning a wider range is left alone, even where it overlaps.
-
-!!! note "Experimental"
-
- This function is experimental and is not exported. In particular the rule for which
- conditional formats are removed — currently, those lying entirely within rng — may
- change.
-
-"""
-function clearConditionalFormats end
-
-clearConditionalFormats(ws::Worksheet, rng::AbstractString) =
- clearConditionalFormats(ws, CellRange(rng))
-
-function clearConditionalFormats(ws::Worksheet, rng::CfRange)
- sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id)
- n = 0
-
- # 2007 blocks: direct children of
- ws_el = sheetdoc[find_child_index(XML.children(sheetdoc), "worksheet")]
- for i in reverse(eachindex(XML.children(ws_el)))
- c = ws_el[i]
- localname(c) == "conditionalFormatting" || continue
- haskey(c, "sqref") || continue
- if _cf_within(ws, c["sqref"], rng)
- n += length(xml_elements(c))
- deleteat!(ws_el.children, i)
- end
- end
-
- # x14 counterparts under
- blk = _x14_cf_block(sheetdoc)
- if !isnothing(blk)
- for q in reverse(eachindex(XML.children(blk)))
- b = blk[q]
- els = xml_elements(b)
- s = findlast(e -> localname(e) == "sqref", els)
- isnothing(s) && continue
- if _cf_within(ws, XML.simple_value(els[s]), rng)
- deleteat!(blk.children, q)
- end
- end
- # Excel refuses to open a file with an empty ``.
- if isempty(xml_elements(blk))
- i, j = get_idces(sheetdoc, "worksheet", "extLst")
- ext = _x14_cf_ext(sheetdoc[i][j])
- filter!(c -> c !== blk, ext.children)
- end
- end
-
- update_worksheets_xml!(get_xlsxfile(ws))
- return n
-end
-
-# Every area named by `sqref` lies inside `rng`.
-function _cf_within(ws::Worksheet, sqref::AbstractString, rng::CfRange)
- inner = _cf_range(ws, sqref)
- outer = [a isa CellRef ? CellRange(a, a) : a for a in _cf_areas(rng)]
- for a in _cf_areas(inner)
- r = a isa CellRef ? CellRange(a, a) : a
- any(o -> issubset(r, o), outer) || return false
- end
- return true
-end
\ No newline at end of file
diff --git a/src/conditionalformats/clear.jl b/src/conditionalformats/clear.jl
new file mode 100644
index 00000000..d34e95ce
--- /dev/null
+++ b/src/conditionalformats/clear.jl
@@ -0,0 +1,71 @@
+"""
+ clearConditionalFormats(ws::Worksheet, rng) -> Int
+
+Remove every conditional format whose range falls entirely within `rng`, returning
+the number of rules removed.
+
+A rule is removed when all of the cells it applies to lie inside `rng`. A rule
+spanning a wider range is left alone, even where it overlaps.
+
+!!! note "Experimental"
+
+ This function is experimental and is not exported. In particular the rule for which
+ conditional formats are removed — currently, those lying entirely within rng — may
+ change.
+
+"""
+function clearConditionalFormats end
+
+clearConditionalFormats(ws::Worksheet, rng::AbstractString) =
+ clearConditionalFormats(ws, CellRange(rng))
+
+function clearConditionalFormats(ws::Worksheet, rng::CfRange)
+ sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id)
+ n = 0
+
+ # 2007 blocks: direct children of
+ ws_el = sheetdoc[find_child_index(XML.children(sheetdoc), "worksheet")]
+ for i in reverse(eachindex(XML.children(ws_el)))
+ c = ws_el[i]
+ localname(c) == "conditionalFormatting" || continue
+ haskey(c, "sqref") || continue
+ if _cf_within(ws, c["sqref"], rng)
+ n += length(xml_elements(c))
+ deleteat!(ws_el.children, i)
+ end
+ end
+
+ # x14 counterparts under
+ blk = _x14_cf_block(sheetdoc)
+ if !isnothing(blk)
+ for q in reverse(eachindex(XML.children(blk)))
+ b = blk[q]
+ els = xml_elements(b)
+ s = findlast(e -> localname(e) == "sqref", els)
+ isnothing(s) && continue
+ if _cf_within(ws, XML.simple_value(els[s]), rng)
+ deleteat!(blk.children, q)
+ end
+ end
+ # Excel refuses to open a file with an empty ``.
+ if isempty(xml_elements(blk))
+ i, j = get_idces(sheetdoc, "worksheet", "extLst")
+ ext = _x14_cf_ext(sheetdoc[i][j])
+ filter!(c -> c !== blk, ext.children)
+ end
+ end
+
+ update_worksheets_xml!(get_xlsxfile(ws))
+ return n
+end
+
+# Every area named by `sqref` lies inside `rng`.
+function _cf_within(ws::Worksheet, sqref::AbstractString, rng::CfRange)
+ inner = _cf_range(ws, sqref)
+ outer = [a isa CellRef ? CellRange(a, a) : a for a in _cf_areas(rng)]
+ for a in _cf_areas(inner)
+ r = a isa CellRef ? CellRange(a, a) : a
+ any(o -> issubset(r, o), outer) || return false
+ end
+ return true
+end
diff --git a/src/conditionalformats/colorscale.jl b/src/conditionalformats/colorscale.jl
new file mode 100644
index 00000000..c80a1f16
--- /dev/null
+++ b/src/conditionalformats/colorscale.jl
@@ -0,0 +1,94 @@
+define_cf_address_methods(:setCfColorScale)
+function setCfColorScale(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=Dict{Symbol,Any}())::Int
+ colorscale::Union{Nothing,String}=nothing
+ min_type::Union{Nothing,String}="min"
+ min_val::Union{Nothing,String}=nothing
+ min_col::Union{Nothing,CellColor}="FFF8696B"
+ mid_type::Union{Nothing,String}=nothing
+ mid_val::Union{Nothing,String}=nothing
+ mid_col::Union{Nothing,CellColor}="FFFCFCFF"
+ max_type::Union{Nothing,String}="max"
+ max_val::Union{Nothing,String}=nothing
+ max_col::Union{Nothing,CellColor}="FFFFEB84"
+
+ for (k, v) in allkws
+ if k == :colorscale
+ colorscale = v
+ elseif k == :min_type
+ min_type = v
+ elseif k == :min_val
+ min_val = v
+ elseif k == :min_col
+ min_col = v
+ elseif k == :mid_type
+ mid_type = v
+ elseif k == :mid_val
+ mid_val = v
+ elseif k == :mid_col
+ mid_col = v
+ elseif k == :max_type
+ max_type = v
+ elseif k == :max_val
+ max_val = v
+ elseif k == :max_col
+ max_col = v
+ else
+ throw(XLSXError("Invalid keyword argument: $k. Valid options are: `colorscale`, `min_type`, `min_val`, `min_col`, `mid_type`, `mid_val`, `mid_col`, `max_type`, `max_val`, `max_col`."))
+ end
+ end
+
+ _check_cf_range(ws, rng)
+
+ pfx = _cf_prefix(ws)
+
+
+ let new_pr, new_cf
+
+ new_pr = string(next_cf_priority!(ws))
+
+ if isnothing(colorscale)
+
+ min_type == "min" && (min_val = nothing)
+ max_type == "max" && (max_val = nothing)
+ _check_cfvo("min", min_type, min_val, ("min", "percentile", "percent", "num", "formula"); optional=false)
+ _check_cfvo("mid", mid_type, mid_val, ("percentile", "percent", "num", "formula"))
+ _check_cfvo("max", max_type, max_val, ("max", "percentile", "percent", "num", "formula"); optional=false)
+
+ min_val = _normalise_cfvo_val(ws, min_val)
+ mid_val = _normalise_cfvo_val(ws, mid_val)
+ max_val = _normalise_cfvo_val(ws, max_val)
+
+ cfx = XML.Element("$(pfx)cfRule"; type="colorScale", priority=new_pr)
+ csc = XML.Element("$(pfx)colorScale")
+ push!(csc,
+ isnothing(min_val) ?
+ XML.Element("$(pfx)cfvo"; type=min_type) :
+ XML.Element("$(pfx)cfvo"; type=min_type, val=min_val)
+ )
+
+ !isnothing(mid_type) && push!(csc, XML.Element("$(pfx)cfvo"; type=mid_type, val=mid_val))
+
+ push!(csc,
+ isnothing(max_val) ?
+ XML.Element("$(pfx)cfvo"; type=max_type) :
+ XML.Element("$(pfx)cfvo"; type=max_type, val=max_val)
+ )
+
+ push!(csc, _color_element("$(pfx)color", min_col))
+ !isnothing(mid_type) && push!(csc, _color_element("$(pfx)color", mid_col))
+ push!(csc, _color_element("$(pfx)color", max_col))
+ push!(cfx, csc)
+ else
+ if !haskey(colorscales, colorscale)
+ throw(XLSXError("Invalid colorscale option chosen: $colorscale. Valid options are: $(keys(colorscales))."))
+ end
+ cfx = copynode(colorscales[colorscale])
+ cfx["priority"] = new_pr
+ end
+
+ update_worksheet_cfx!(cfx, ws, rng)
+
+ end
+
+ return 0
+end
diff --git a/src/conditionalformats/databar.jl b/src/conditionalformats/databar.jl
new file mode 100644
index 00000000..4f98de93
--- /dev/null
+++ b/src/conditionalformats/databar.jl
@@ -0,0 +1,389 @@
+define_cf_address_methods(:setCfDataBar)
+function setCfDataBar(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=Dict{Symbol,Any}())::Int
+ kw = _databar_keywords(allkws)
+
+ _check_cf_range(ws, rng)
+
+ pfx = _cf_prefix(ws)
+
+ # `priority` is for internal callers only and is deliberately undocumented: it is
+ # omitted from the valid-keyword list so a user passing it gets an error.
+ # `setColoredDataBars` reserves a contiguous block of priorities up front and passes
+ # them in band order, so the bands are numbered predictably rather than in whatever
+ # order the partition happens to yield. Note it bypasses `next_cf_priority!`, so a
+ # caller supplying it owns the number: no uniqueness check, and the counter does not
+ # advance.
+ new_pr = string(something(kw.priority, next_cf_priority!(ws)))
+
+ settings = _databar_settings(ws, kw)
+
+ # The 2007 rule and its x14 extension are linked by this id.
+ id = _unique_guid(ws.package.uuid_rng, _x14_cf_ids(ws))
+
+ update_worksheet_cfx!(_databar_rule(pfx, settings, new_pr, id), ws, rng) # Add basic elements to worksheet xml file
+ update_worksheet_ext_cfx!(_databar_ext_rule(settings, id), ws, rng) # Add extension elements to worksheet xml file
+ return 0
+end
+
+# The keywords `setCfDataBar` accepts (`priority` aside: see `setCfDataBar`).
+const DATABAR_KEYWORDS = (:databar, :showVal, :gradient, :borders, :sameNegFill, :sameNegBorders,
+ :direction, :axis_pos, :axis_col, :min_type, :min_val, :max_type, :max_val,
+ :fill_col, :border_col, :neg_fill_col, :neg_border_col)
+
+# Read `setCfDataBar`'s keywords from `allkws`, rejecting any it doesn't accept.
+function _databar_keywords(allkws)
+ for k in keys(allkws)
+ k in DATABAR_KEYWORDS || k == :priority ||
+ throw(XLSXError("Invalid keyword argument: $k. Valid options are: $(join(("`$v`" for v in DATABAR_KEYWORDS), ", "))."))
+ end
+ databar::Union{Nothing,String} = get(allkws, :databar, "bluegrad")
+ priority::Union{Nothing,Int} = get(allkws, :priority, nothing)
+ showVal::Union{Nothing,String} = get(allkws, :showVal, nothing)
+ gradient::Union{Nothing,String} = get(allkws, :gradient, nothing)
+ borders::Union{Nothing,String} = get(allkws, :borders, nothing)
+ sameNegFill::Union{Nothing,String} = get(allkws, :sameNegFill, nothing)
+ sameNegBorders::Union{Nothing,String} = get(allkws, :sameNegBorders, nothing)
+ direction::Union{Nothing,String} = get(allkws, :direction, nothing)
+ axis_pos::Union{Nothing,String} = get(allkws, :axis_pos, nothing)
+ axis_col::Union{Nothing,CellColor} = get(allkws, :axis_col, nothing)
+ min_type::Union{Nothing,String} = get(allkws, :min_type, nothing)
+ min_val::Union{Nothing,String} = get(allkws, :min_val, nothing)
+ max_type::Union{Nothing,String} = get(allkws, :max_type, nothing)
+ max_val::Union{Nothing,String} = get(allkws, :max_val, nothing)
+ fill_col::Union{Nothing,CellColor} = get(allkws, :fill_col, nothing)
+ border_col::Union{Nothing,CellColor} = get(allkws, :border_col, nothing)
+ neg_fill_col::Union{Nothing,CellColor} = get(allkws, :neg_fill_col, nothing)
+ neg_border_col::Union{Nothing,CellColor} = get(allkws, :neg_border_col, nothing)
+ return (; databar, priority, showVal, gradient, borders, sameNegFill, sameNegBorders,
+ direction, axis_pos, axis_col, min_type, min_val, max_type, max_val,
+ fill_col, border_col, neg_fill_col, neg_border_col)
+end
+
+# Validate the keywords and fill any not given from the `databar` preset, returning the
+# settings the rule is built from, keyed by name.
+function _databar_settings(ws::Worksheet, kw)
+ (; databar, min_type, min_val, max_type, max_val) = kw
+
+ min_type in ("least", "automatic") && (min_val = nothing)
+ max_type in ("highest", "automatic") && (max_val = nothing)
+ _check_cfvo("min", min_type, min_val, ("least", "percentile", "percent", "num", "formula", "automatic"))
+ _check_cfvo("max", max_type, max_val, ("highest", "percentile", "percent", "num", "formula", "automatic"))
+
+ min_val = _normalise_cfvo_val(ws, min_val)
+ max_val = _normalise_cfvo_val(ws, max_val)
+
+ if !haskey(databars, databar)
+ throw(XLSXError("Invalid dataBar option chosen: $databar. Valid options are: $(keys(databars))"))
+ end
+
+ settings = Dict{String,Any}(
+ "showVal" => kw.showVal,
+ "gradient" => kw.gradient,
+ "borders" => kw.borders,
+ "sameNegFill" => kw.sameNegFill,
+ "sameNegBorders" => kw.sameNegBorders,
+ "direction" => kw.direction,
+ "min_type" => min_type,
+ "min_val" => min_val,
+ "max_type" => max_type,
+ "max_val" => max_val,
+ "fill_col" => kw.fill_col,
+ "border_col" => kw.border_col,
+ "neg_fill_col" => kw.neg_fill_col,
+ "neg_border_col" => kw.neg_border_col,
+ "axis_pos" => kw.axis_pos,
+ "axis_col" => kw.axis_col
+ )
+
+ for (k, w) in settings # Allow user input to override any default value
+ if isnothing(w)
+ if haskey(databars[databar], k)
+ settings[k] = databars[databar][k]
+ end
+ end
+ end
+ for k in ["showVal", "gradient", "borders", "sameNegFill", "sameNegBorders"]
+ haskey(settings, k) && isValidKw(k, settings[k], ["true", "false"])
+ end
+ haskey(settings, "direction") && isValidKw("direction", settings["direction"], ["leftToRight", "rightToLeft"])
+ haskey(settings, "axis_pos") && isValidKw("axis_pos", settings["axis_pos"], ["middle", "none"])
+ return settings
+end
+
+# The 2007 ``: the bar's range and fill color, and an `` linking it by
+# `id` to the x14 rule that carries everything else.
+function _databar_rule(pfx::String, s::Dict{String,Any}, priority::String, id::AbstractString)
+ mnt = s["min_type"] ∈ ["automatic", "least"] ? "min" : s["min_type"]
+ mxt = s["max_type"] ∈ ["automatic", "highest"] ? "max" : s["max_type"]
+
+ cfx = XML.Element("$(pfx)cfRule"; type="dataBar", priority=priority)
+ cdb = XML.Element("$(pfx)dataBar")
+ push!(cdb, isnothing(s["min_val"]) ?
+ XML.Element("$(pfx)cfvo"; type=mnt) :
+ XML.Element("$(pfx)cfvo"; type=mnt, val=s["min_val"])
+ )
+
+ push!(cdb, isnothing(s["max_val"]) ?
+ XML.Element("$(pfx)cfvo"; type=mxt) :
+ XML.Element("$(pfx)cfvo"; type=mxt, val=s["max_val"])
+ )
+
+ push!(cdb, _color_element("$(pfx)color", s["fill_col"]))
+
+ push!(cfx, cdb)
+
+ if haskey(s, "showVal") && !isnothing(s["showVal"]) && s["showVal"] == "false"
+ cdb["showValue"] = "0"
+ end
+ # Links the 2007 rule to its x14 counterpart by id. Excel expects the in an
+ # inside , as a sibling following - not inside
+ # itself, where it is silently discarded on open along with the x14 rule it links to.
+ cfx_ext = XML.Element("$(pfx)ext")
+ cfx_ext["uri"] = "{B025F937-C7B1-47D3-B67F-A62EFF666E3E}"
+ cfx_ext["xmlns:x14"] = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"
+ push!(cfx_ext, XML.Element("x14:id", XML.Text(id)))
+ cfx_extlst = XML.Element("$(pfx)extLst")
+ push!(cfx_extlst, cfx_ext)
+ push!(cfx, cfx_extlst)
+ return cfx
+end
+
+# The x14 ``: gradient, borders, direction, negative bars and axis.
+function _databar_ext_rule(s::Dict{String,Any}, id::AbstractString)
+ emnt = s["min_type"] == "automatic" ? "autoMin" : s["min_type"] == "least" ? "min" : s["min_type"]
+ emxt = s["max_type"] == "automatic" ? "autoMax" : s["max_type"] == "highest" ? "max" : s["max_type"]
+ emnv = s["min_type"] == "formula" ? "(" * s["min_val"] * ")" : s["min_val"]
+ emxv = s["max_type"] == "formula" ? "(" * s["max_val"] * ")" : s["max_val"]
+ ext_cfx = XML.Element("x14:cfRule", type="dataBar", id=id)
+ ext_db = XML.Element("x14:dataBar", minLength="0", maxLength="100")
+ valmin = XML.Element("x14:cfvo", type=emnt)
+ !isnothing(emnv) && push!(valmin, XML.Element("xm:f", XML.Text(emnv)))
+ valmax = XML.Element("x14:cfvo", type=emxt)
+ !isnothing(emxv) && push!(valmax, XML.Element("xm:f", XML.Text(emxv)))
+ push!(ext_db, valmin)
+ push!(ext_db, valmax)
+ if s["gradient"] == "false"
+ ext_db["gradient"] = "0"
+ end
+ do_borders = haskey(s, "borders") && s["borders"] == "true"
+ if do_borders
+ ext_db["border"] = "1"
+ if haskey(s, "border_col") && !isnothing(s["border_col"])
+ push!(ext_db, _color_element("x14:borderColor", s["border_col"]))
+ else
+ push!(ext_db, _color_element("x14:borderColor", "FF638EC6")) # Default color
+ end
+ end
+ if haskey(s, "direction")
+ if s["direction"] == "leftToRight"
+ ext_db["direction"] = "leftToRight"
+ elseif s["direction"] == "rightToLeft"
+ ext_db["direction"] = "rightToLeft"
+ end
+ end
+ if haskey(s, "sameNegFill") && s["sameNegFill"] == "true"
+ ext_db["negativeBarColorSameAsPositive"] = "1"
+ else
+ if haskey(s, "neg_fill_col") && !isnothing(s["neg_fill_col"])
+ push!(ext_db, _color_element("x14:negativeFillColor", s["neg_fill_col"]))
+ else
+ push!(ext_db, _color_element("x14:negativeFillColor", "FFFF0000")) # Default color
+ end
+ end
+ if do_borders && haskey(s, "sameNegBorders") && s["sameNegBorders"] == "false"
+ ext_db["negativeBarBorderColorSameAsPositive"] = "0"
+ if haskey(s, "neg_border_col") && !isnothing(s["neg_border_col"])
+ push!(ext_db, _color_element("x14:negativeBorderColor", s["neg_border_col"]))
+ else
+ push!(ext_db, _color_element("x14:negativeBorderColor", "FFFF0000")) # Default color
+ end
+ end
+ if haskey(s, "axis_pos")
+ if s["axis_pos"] == "none"
+ ext_db["axisPosition"] = "none"
+ elseif s["axis_pos"] == "middle"
+ ext_db["axisPosition"] = "middle"
+ end
+ end
+ haskey(s, "axis_col") && push!(ext_db, _color_element("x14:axisColor", s["axis_col"]))
+ push!(ext_cfx, ext_db)
+ return ext_cfx
+end
+
+
+
+# ---------------------------------------------------------------------------
+# setColoredDataBars
+# ---------------------------------------------------------------------------
+
+"""
+ setColoredDataBars(sheet, rng; [bands], [colors], [breaks], [gte], [showVal],
+ [direction], [min_type], [min_val], [max_type], [max_val],
+ [clear]) -> Vector{Pair}
+
+Apply data bars to `rng` whose color changes with value as well as their length.
+
+Excel has no such conditional format: a `dataBar` rule carries a single color and
+applies to every cell in its range. `setColoredDataBars` splits `rng` into value-based
+bands and writes one data bar rule per band, each over the non-contiguous range of
+cells falling in that band. The result is a valid workbook that Excel reads without
+complaint, but it is not something Excel's own interface can produce, and it is not
+maintained by Excel: see the note on editing below.
+
+`bands` (default 5) sets how many bands to use, with boundaries placed at equal
+intervals between the smallest and largest value in `rng`. Give `breaks` instead to
+place the boundaries yourself, as a sorted vector of `length(bands)-1` values; `breaks`
+overrides `bands`. By default a cell whose value equals a break falls in the band above
+it; set `gte=false` to place it in the band below, or give a vector of `Bool` with one
+entry per break.
+
+`colors` accepts either one color per band, or exactly two colors to interpolate
+between (in LCHab, so intermediate hues stay clean). Colors may be given in any form
+described in [Specifying colors](@ref), but theme colors can only be used one per band,
+not interpolated between. Note that Colors.jl's named colormaps are not accepted; for a
+red-amber-green ramp give the three colors explicitly.
+
+`showVal` and `direction` are passed through to each band's rule unchanged. Bars are
+drawn with a solid fill rather than a gradient.
+
+`min_type`, `min_val`, `max_type` and `max_val` set the value axis shared by every band,
+and default to `num` with the minimum and maximum of `rng`. Only `num` and `formula` are
+accepted: the relative types (`percent`, `percentile`, `min`, `max`, `automatic`) are
+evaluated over each rule's own cells, which for a band is only that band's values, so
+every band would span the full bar width and bar lengths would carry no meaning.
+
+By default (clear=true) any conditional format lying entirely within rng is removed
+before the new bands are written, so repeated calls replace rather than accumulate. This
+matters when re-banding after the data have changed: bands from an earlier call name the
+cells they covered then, and left in place they would overlay the new ones. Set
+clear=false to add the bands alongside whatever is already there.
+
+Returns the band label => range pairs written. Bands containing no cells are dropped, so
+the result may be shorter than bands. Labels keep their position on the scale rather
+than being renumbered: banding data with only two distinct values across bands=5 may
+return labels 1 and 5, using the first and last colors of the ramp.
+
+!!! note
+ `setColoredDataBars` is a static formatting function. The bands reflect cell values
+ at the time of writing. If a value is later edited in Excel its bar length updates,
+ but its color does not, because the cell stays in the band it was originally
+ assigned to. Rewrite the conditional formatting to re-band it.
+
+ This is a limitation of Excel itself, which does not support dataBars whose color
+ varies with value. `setColoredDataBars` is a workaround for this.
+
+
+!!! note "Experimental"
+
+ This function is experimental. It has a full set of tests and Documentation is
+ published but it is not yet marked as public. It may be better included in
+ future only as a documented example rather than as a package function.
+
+# Examples
+
+```julia
+julia> XLSX.setColoredDataBars(sheet, "B2:B101")
+
+julia> XLSX.setColoredDataBars(sheet, "B2:B101"; bands=3, colors=["green", "orange", "red"])
+
+julia> XLSX.setColoredDataBars(sheet, "B2:B101"; breaks=[10, 50], colors=["green", "red"])
+
+julia> XLSX.setColoredDataBars(sheet, "B2:B101"; bands=8, min_val="0", max_val="100")
+```
+
+See also [`setConditionalFormat`](@ref), [`clearConditionalFormats`](@ref).
+"""
+function setColoredDataBars end
+
+setColoredDataBars(ws::Worksheet, rng::AbstractString; kw...) =
+ setColoredDataBars(ws, CellRange(rng); kw...)
+setColoredDataBars(ws::Worksheet, cell::CellRef; kw...) =
+ setColoredDataBars(ws, CellRange(cell, cell); kw...)
+setColoredDataBars(ws::Worksheet, rng::SheetCellRange; kw...) =
+ do_sheet_names_match(ws, rng) && setColoredDataBars(ws, rng.rng; kw...)
+
+function setColoredDataBars(ws::Worksheet, rng::CellRange;
+ bands::Int=5,
+ colors=["green", "red"],
+ breaks=nothing,
+ gte::Union{Bool,AbstractVector{Bool}}=true,
+ showVal::Union{Nothing,String}=nothing,
+ direction::Union{Nothing,String}=nothing,
+ min_type::Union{Nothing,String}=nothing,
+ min_val::Union{Nothing,String}=nothing,
+ max_type::Union{Nothing,String}=nothing,
+ max_val::Union{Nothing,String}=nothing,
+ clear::Bool=true)
+
+ _check_cf_areas(ws, rng)
+
+ clear && clearConditionalFormats(ws, rng)
+
+ vals = filter(v -> v isa Real && !isnan(v), vec(getdata(ws, rng)))
+ isempty(vals) && throw(XLSXError("No numeric values in `$rng` to band."))
+ lo, hi = float(minimum(vals)), float(maximum(vals))
+
+ brk = if isnothing(breaks)
+ bands < 1 && throw(XLSXError("`bands` must be at least 1, got $bands."))
+ bands == 1 && return _single_bar(ws, rng, colors, lo, hi,
+ showVal, direction, min_type, min_val, max_type, max_val)
+ hi > lo || throw(XLSXError("All numeric values in `$rng` are equal; cannot form bands."))
+ collect(range(lo, hi; length=bands + 1))[2:end-1]
+ else
+ bands = length(breaks) + 1
+ collect(breaks)
+ end
+
+ cols = _band_colors(colors, bands)
+
+ for (t, name) in ((min_type, "min_type"), (max_type, "max_type"))
+ isnothing(t) || t in ["num", "formula"] || throw(XLSXError(
+ "`$name` must be `num` or `formula` for colored data bars; `$t` is evaluated " *
+ "separately for each band, which makes bar lengths incomparable between bands."))
+ end
+ mnt = something(min_type, "num")
+ mxt = something(max_type, "num")
+ mnv = something(min_val, string(lo))
+ mxv = something(max_val, string(hi))
+
+ p = partition(ws, rng, brk; labels=1:bands, gte=gte)
+
+ base = next_cf_priority!(ws)
+ for _ in 2:length(p)
+ next_cf_priority!(ws) # reserve the rest
+ end
+
+ for (n, (i, ncr)) in enumerate(p)
+ kws = Dict{Symbol,Any}(
+ :priority => base + n - 1,
+ :fill_col => cols[i],
+ :gradient => "false",
+ :borders => "false",
+ :sameNegFill => "true",
+ :min_type => mnt, :min_val => mnv,
+ :max_type => mxt, :max_val => mxv,
+ )
+ isnothing(showVal) || (kws[:showVal] = showVal)
+ isnothing(direction) || (kws[:direction] = direction)
+ setCfDataBar(ws, ncr; allkws=kws)
+ end
+ return p
+end
+
+# `bands=1` degenerates to an ordinary data bar over the whole contiguous range:
+# no partition, no non-contiguous sqref.
+function _single_bar(ws, rng, colors, lo, hi, showVal, direction,
+ min_type, min_val, max_type, max_val)
+ kws = Dict{Symbol,Any}(
+ :fill_col => only(_band_colors(colors, 1)),
+ :gradient => "false",
+ :borders => "false",
+ :sameNegFill => "true",
+ :min_type => something(min_type, "num"), :min_val => something(min_val, string(lo)),
+ :max_type => something(max_type, "num"), :max_val => something(max_val, string(hi)),
+ )
+ isnothing(showVal) || (kws[:showVal] = showVal)
+ isnothing(direction) || (kws[:direction] = direction)
+ setCfDataBar(ws, rng; allkws=kws)
+ return [1 => rng]
+end
diff --git a/src/conditionalformats/iconset.jl b/src/conditionalformats/iconset.jl
new file mode 100644
index 00000000..2d6433de
--- /dev/null
+++ b/src/conditionalformats/iconset.jl
@@ -0,0 +1,191 @@
+define_cf_address_methods(:setCfIconSet)
+function setCfIconSet(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=Dict{Symbol,Any}())::Int
+ kw = _iconset_keywords(allkws)
+
+ _check_cf_range(ws, rng)
+
+ new_pr = string(next_cf_priority!(ws))
+
+ types = ("percentile", "percent", "num", "formula")
+ _check_cfvo("min", kw.min_type, kw.min_val, types)
+ _check_cfvo("mid", kw.mid_type, kw.mid_val, types)
+ _check_cfvo("mid2", kw.mid2_type, kw.mid2_val, types)
+ _check_cfvo("max", kw.max_type, kw.max_val, types)
+
+ # Each threshold as (type, value, gte), lowest first.
+ lo = (kw.min_type, _normalise_cfvo_val(ws, kw.min_val), kw.min_gte)
+ m1 = (kw.mid_type, _normalise_cfvo_val(ws, kw.mid_val), kw.mid_gte)
+ m2 = (kw.mid2_type, _normalise_cfvo_val(ws, kw.mid2_val), kw.mid2_gte)
+ hi = (kw.max_type, _normalise_cfvo_val(ws, kw.max_val), kw.max_gte)
+
+ if !haskey(iconsets, kw.iconset)
+ throw(XLSXError("Invalid iconset option chosen: $(kw.iconset). Valid options are: $(keys(iconsets))"))
+ end
+ cfx, thresholds, nicons = _iconset_template(kw.iconset, lo, m1, m2, hi)
+
+ if kw.iconset in X14_ICONSETS
+ cfx["id"] = _unique_guid(ws.package.uuid_rng, _x14_cf_ids(ws))
+ cfx["priority"] = new_pr
+ _set_x14_iconset!(cfx, kw, thresholds, nicons)
+ update_worksheet_ext_cfx!(cfx, ws, rng)
+ else
+ cfx["priority"] = new_pr
+ _set_iconset!(cfx, kw, thresholds)
+ update_worksheet_cfx!(cfx, ws, rng)
+ end
+ return 0
+end
+
+# Icon sets Excel stores only in the x14 extension; the rest are 2007 rules.
+const X14_ICONSETS = ("3Triangles", "3Stars", "5Boxes", "Custom")
+
+# The keywords `setCfIconSet` accepts.
+const ICONSET_KEYWORDS = (:iconset, :reverse, :showVal, :min_type, :min_val, :min_gte,
+ :mid_type, :mid_val, :mid_gte, :mid2_type, :mid2_val, :mid2_gte,
+ :max_type, :max_val, :max_gte, :icon_list)
+
+# Read `setCfIconSet`'s keywords from `allkws`, rejecting any it doesn't accept.
+function _iconset_keywords(allkws)
+ for k in keys(allkws)
+ k in ICONSET_KEYWORDS ||
+ throw(XLSXError("Invalid keyword argument: $k. Valid options are: $(join(("`$v`" for v in ICONSET_KEYWORDS), ", "))."))
+ end
+ iconset::Union{Nothing,String} = get(allkws, :iconset, "3TrafficLights")
+ reverse::Union{Nothing,String} = get(allkws, :reverse, nothing)
+ showVal::Union{Nothing,String} = get(allkws, :showVal, nothing)
+ min_type::Union{Nothing,String} = get(allkws, :min_type, nothing)
+ min_val::Union{Nothing,String} = get(allkws, :min_val, nothing)
+ min_gte::Union{Nothing,String} = get(allkws, :min_gte, nothing)
+ mid_type::Union{Nothing,String} = get(allkws, :mid_type, nothing)
+ mid_val::Union{Nothing,String} = get(allkws, :mid_val, nothing)
+ mid_gte::Union{Nothing,String} = get(allkws, :mid_gte, nothing)
+ mid2_type::Union{Nothing,String} = get(allkws, :mid2_type, nothing)
+ mid2_val::Union{Nothing,String} = get(allkws, :mid2_val, nothing)
+ mid2_gte::Union{Nothing,String} = get(allkws, :mid2_gte, nothing)
+ max_type::Union{Nothing,String} = get(allkws, :max_type, nothing)
+ max_val::Union{Nothing,String} = get(allkws, :max_val, nothing)
+ max_gte::Union{Nothing,String} = get(allkws, :max_gte, nothing)
+ icon_list::Union{Nothing,Vector{Int64}} = get(allkws, :icon_list, nothing)
+ return (; iconset, reverse, showVal, min_type, min_val, min_gte, mid_type, mid_val, mid_gte,
+ mid2_type, mid2_val, mid2_gte, max_type, max_val, max_gte, icon_list)
+end
+
+# The rule for `iconset`, copied from its preset, with the thresholds it uses and, for a
+# custom set, the number of icons. A custom set takes as many thresholds as are given
+# (two to four), and the preset's ``s are added to match.
+function _iconset_template(iconset, lo, m1, m2, hi)
+ cfx = copynode(iconsets[iconset])
+ nicons = nothing
+ l = first(iconset)
+ if l == 'C'
+ cfvo = XML.Element("x14:cfvo", type="percent")
+ push!(cfvo, XML.Element("xm:f", XML.Text("dummy")))
+ push!(cfx[1], copynode(cfvo)) # for min_val
+ push!(cfx[1], copynode(cfvo)) # for max_val
+ if isnothing(lo[1]) || isnothing(lo[2]) || isnothing(hi[1]) || isnothing(hi[2])
+ throw(XLSXError("No type or val keywords defined. Must define at least `min_type`, `min_val`, `max_type` and `max_val` for a custom iconSet"))
+ elseif isnothing(m1[1]) || isnothing(m1[2])
+ thresholds = [lo, hi]
+ nicons = 3
+ elseif isnothing(m2[1]) || isnothing(m2[2])
+ push!(cfx[1], copynode(cfvo)) # for mid_val
+ cfx[1]["iconSet"] = "4Arrows"
+ nicons = 4
+ thresholds = [lo, m1, hi]
+ else
+ push!(cfx[1], copynode(cfvo)) # for mid_val
+ push!(cfx[1], copynode(cfvo)) # for mid2_val
+ cfx[1]["iconSet"] = "5Quarters"
+ nicons = 5
+ thresholds = [lo, m1, m2, hi]
+ end
+ elseif l == '5'
+ thresholds = [lo, m1, m2, hi]
+ elseif l == '4'
+ thresholds = [lo, m1, hi]
+ else
+ thresholds = [lo, hi]
+ end
+ return cfx, thresholds, nicons
+end
+
+# Apply the options to an x14 icon set rule: thresholds are `` children, and a
+# custom set lists its icons.
+function _set_x14_iconset!(cfx, kw, thresholds, nicons)
+ if !isnothing(kw.showVal) && kw.showVal == "false"
+ cfx[1]["showValue"] = "0"
+ end
+ icon_list = kw.icon_list
+ if !isnothing(kw.reverse) && kw.reverse == "true"
+ if kw.iconset == "Custom"
+ isnothing(icon_list) || (icon_list = reverse(icon_list)) # leaves the caller's vector alone
+ else
+ cfx[1]["reverse"] = "1"
+ end
+ end
+ for (i, (type, val, gte)) in enumerate(thresholds)
+ if !isnothing(type)
+ cfx[1][i+1]["type"] = type # Need +1 because the first is always 0 percent.
+ end
+ if !isnothing(val)
+ if !isnothing(type) && type == "formula"
+ c = XML.Element("xm:f", XML.Text("(" * val * ")"))
+ else
+ c = XML.Element("xm:f", XML.Text(val))
+ end
+ if isempty(XML.children(cfx[1][i+1]))
+ old = cfx[1][i+1]
+ cfx[1][i+1] = XML.Node{String}(XML.Element, XML.tag(old), old.attributes, nothing, XML.Node{String}[c])
+ else
+ cfx[1][i+1][1] = c
+ end
+ end
+ if !isnothing(gte) && gte == "false"
+ cfx[1][i+1]["gte"] = "0"
+ end
+ end
+ if kw.iconset == "Custom"
+ if isnothing(icon_list)
+ throw(XLSXError("No custom icons specified. Must specify between two and four icons."))
+ elseif length(icon_list) < nicons
+ throw(XLSXError("Too few custom icons specified: $(length(icon_list)). Expected $nicons"))
+ end
+ for (count, icon) in enumerate(string.(icon_list))
+ if !isnothing(icon)
+ if !haskey(allIcons, icon)
+ throw(XLSXError("Invalid custom icon specified: $icon. Valid values are \"1\" to \"52\"."))
+ end
+ i = allIcons[icon]
+ push!(cfx[1], XML.Element("x14:cfIcon", iconSet=first(i), iconId=last(i)))
+ count == nicons && break
+ end
+ end
+ end
+ return cfx
+end
+
+# Apply the options to a 2007 icon set rule: thresholds are `val` attributes.
+function _set_iconset!(cfx, kw, thresholds)
+ if !isnothing(kw.showVal) && kw.showVal == "false"
+ cfx[1]["showValue"] = "0"
+ end
+ if !isnothing(kw.reverse) && kw.reverse == "true"
+ cfx[1]["reverse"] = "1"
+ end
+ for (i, (type, val, gte)) in enumerate(thresholds)
+ if !isnothing(val)
+ if !isnothing(type) && type == "formula"
+ cfx[1][i+1]["val"] = "(" * val * ")"
+ else
+ cfx[1][i+1]["val"] = val
+ end
+ end
+ if !isnothing(type)
+ cfx[1][i+1]["type"] = type
+ end
+ if !isnothing(gte) && gte == "false"
+ cfx[1][i+1]["gte"] = "0"
+ end
+ end
+ return cfx
+end
diff --git a/src/conditionalformats/presets.jl b/src/conditionalformats/presets.jl
new file mode 100644
index 00000000..3fdf7a65
--- /dev/null
+++ b/src/conditionalformats/presets.jl
@@ -0,0 +1,457 @@
+const highlights::Dict{String,Dict{String,Dict{String,String}}} = Dict(
+ "redfilltext" => Dict(
+ "font" => Dict("color" => "FF9C0006"),
+ "fill" => Dict("pattern" => "solid", "bgColor" => "FFFFC7CE")
+ ),
+ "yellowfilltext" => Dict(
+ "font" => Dict("color" => "FF9C5700"),
+ "fill" => Dict("pattern" => "solid", "bgColor" => "FFFFEB9C")
+ ),
+ "greenfilltext" => Dict(
+ "font" => Dict("color" => "FF006100"),
+ "fill" => Dict("pattern" => "solid", "bgColor" => "FFC6EFCE")
+ ),
+ "redfill" => Dict(
+ "fill" => Dict("pattern" => "solid", "bgColor" => "FFFFC7CE")
+ ),
+ "redtext" => Dict(
+ "font" => Dict("color" => "FF9C0006"),
+ ),
+ "redborder" => Dict(
+ "border" => Dict("color" => "FF9C0006", "style" => "thin")
+ )
+)
+const databars::Dict{String,Dict{String,String}} = Dict(
+ "bluegrad" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FF638EC6",
+ "borders" => "true",
+ "sameNegBorders" => "false",
+ "border_col" => "FF638EC6",
+ "neg_fill_col" => "FFFF0000",
+ "neg_border_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "greengrad" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FF63C384",
+ "borders" => "true",
+ "sameNegBorders" => "false",
+ "border_col" => "FF63C384",
+ "neg_fill_col" => "FFFF0000",
+ "neg_border_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "redgrad" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FFFF555A",
+ "borders" => "true",
+ "sameNegBorders" => "false",
+ "border_col" => "FFFF555A",
+ "neg_fill_col" => "FFFF0000",
+ "neg_border_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "orangegrad" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FFFFB628",
+ "borders" => "true",
+ "sameNegBorders" => "false",
+ "border_col" => "FFFFB628",
+ "neg_fill_col" => "FFFF0000",
+ "neg_border_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "lightbluegrad" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FF008AEF",
+ "borders" => "true",
+ "sameNegBorders" => "false",
+ "border_col" => "FF008AEF",
+ "neg_fill_col" => "FFFF0000",
+ "neg_border_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "purplegrad" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FFD6007B",
+ "borders" => "true",
+ "sameNegBorders" => "false",
+ "border_col" => "FFD6007B",
+ "neg_fill_col" => "FFFF0000",
+ "neg_border_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "blue" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FF638EC6",
+ "gradient" => "false",
+ "neg_fill_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "green" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FF63C384",
+ "gradient" => "false",
+ "neg_fill_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "red" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FFFF555A",
+ "gradient" => "false",
+ "neg_fill_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "orange" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FFFFB628",
+ "gradient" => "false",
+ "neg_fill_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "lightblue" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FF008AEF",
+ "gradient" => "false",
+ "neg_fill_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+ "purple" => Dict(
+ "min_type" => "automatic",
+ "max_type" => "automatic",
+ "fill_col" => "FFD6007B",
+ "gradient" => "false",
+ "neg_fill_col" => "FFFF0000",
+ "axis_col" => "FF000000"
+ ),
+)
+const colorscales::Dict{String,XML.Node} = Dict( # Defines the 12 standard, built-in Excel color scales for conditional formatting.
+ "greenyellowred" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="percentile", val="50"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FFF8696B"),
+ XML.h.color(rgb="FFFFEB84"),
+ XML.h.color(rgb="FF63BE7B")
+ )
+ ),
+ "redyellowgreen" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="percentile", val="50"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FF63BE7B"),
+ XML.h.color(rgb="FFFFEB84"),
+ XML.h.color(rgb="FFF8696B")
+ )
+ ),
+ "greenwhitered" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="percentile", val="50"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FFF8696B"),
+ XML.h.color(rgb="FFFCFCFF"),
+ XML.h.color(rgb="FF63BE7B")
+ )
+ ),
+ "redwhitegreen" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="percentile", val="50"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FF63BE7B"),
+ XML.h.color(rgb="FFFCFCFF"),
+ XML.h.color(rgb="FFF8696B")
+ )
+ ),
+ "bluewhitered" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="percentile", val="50"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FFF8696B"),
+ XML.h.color(rgb="FFFCFCFF"),
+ XML.h.color(rgb="FF5A8AC6")
+ )
+ ),
+ "redwhiteblue" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="percentile", val="50"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FF5A8AC6"),
+ XML.h.color(rgb="FFFCFCFF"),
+ XML.h.color(rgb="FFF8696B")
+ )
+ ),
+ "whitered" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FFF8696B"),
+ XML.h.color(rgb="FFFCFCFF")
+ )
+ ),
+ "redwhite" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FFFCFCFF"),
+ XML.h.color(rgb="FFF8696B")
+ )
+ ),
+ "greenwhite" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FFFCFCFF"),
+ XML.h.color(rgb="FF63BE7B")
+ )
+ ),
+ "whitegreen" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FF63BE7B"),
+ XML.h.color(rgb="FFFCFCFF")
+ )
+ ),
+ "greenyellow" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FFFFEF9C"),
+ XML.h.color(rgb="FF63BE7B")
+ )
+ ),
+ "yellowgreen" => XML.h.cfRule(type="colorScale", priority="1",
+ XML.h.colorScale(
+ XML.h.cfvo(type="min"),
+ XML.h.cfvo(type="max"),
+ XML.h.color(rgb="FF63BE7B"),
+ XML.h.color(rgb="FFFFEF9C")
+ )
+ )
+)
+const iconsets::Dict{String,XML.Node} = Dict( # Defines the 20 standard, built-in Excel icon sets for conditional formatting.
+ "3Arrows" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="3Arrows",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="33"),
+ XML.h.cfvo(type="percent", val="67"),
+ )
+ ),
+ "4Arrows" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="4Arrows",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="25"),
+ XML.h.cfvo(type="percent", val="50"),
+ XML.h.cfvo(type="percent", val="75"),
+ )
+ ),
+ "5Arrows" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="5Arrows",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="20"),
+ XML.h.cfvo(type="percent", val="40"),
+ XML.h.cfvo(type="percent", val="60"),
+ XML.h.cfvo(type="percent", val="80"),
+ )
+ ),
+ "3ArrowsGray" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="3ArrowsGray",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="33"),
+ XML.h.cfvo(type="percent", val="67"),
+ )
+ ),
+ "4ArrowsGray" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="4ArrowsGray",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="25"),
+ XML.h.cfvo(type="percent", val="50"),
+ XML.h.cfvo(type="percent", val="75"),
+ )
+ ),
+ "5ArrowsGray" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="5ArrowsGray",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="20"),
+ XML.h.cfvo(type="percent", val="40"),
+ XML.h.cfvo(type="percent", val="60"),
+ XML.h.cfvo(type="percent", val="80"),
+ )
+ ),
+ "3TrafficLights" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="3TrafficLights",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="33"),
+ XML.h.cfvo(type="percent", val="67"),
+ )
+ ),
+ "3Signs" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="3Signs",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="33"),
+ XML.h.cfvo(type="percent", val="67"),
+ )
+ ),
+ "4RedToBlack" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="4RedToBlack",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="25"),
+ XML.h.cfvo(type="percent", val="50"),
+ XML.h.cfvo(type="percent", val="75"),
+ )
+ ),
+ "3TrafficLights2" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="3TrafficLights2",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="33"),
+ XML.h.cfvo(type="percent", val="67"),
+ )
+ ),
+ "4TrafficLights" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="4TraficLights",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="25"),
+ XML.h.cfvo(type="percent", val="50"),
+ XML.h.cfvo(type="percent", val="75"),
+ )
+ ),
+ "3Symbols" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="3Symbols",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="33"),
+ XML.h.cfvo(type="percent", val="67"),
+ )
+ ),
+ "3Symbols2" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="3Symbols2",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="33"),
+ XML.h.cfvo(type="percent", val="67"),
+ )
+ ),
+ "3Flags" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="3Flags",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="33"),
+ XML.h.cfvo(type="percent", val="67"),
+ )
+ ),
+ "5Quarters" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="5Quarters",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="20"),
+ XML.h.cfvo(type="percent", val="40"),
+ XML.h.cfvo(type="percent", val="60"),
+ XML.h.cfvo(type="percent", val="80"),
+ )
+ ),
+ "4Rating" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="4Rating",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="25"),
+ XML.h.cfvo(type="percent", val="50"),
+ XML.h.cfvo(type="percent", val="75"),
+ )
+ ),
+ "5Rating" => XML.h.cfRule(type="iconSet", priority="1",
+ XML.h.iconSet(iconSet="5Rating",
+ XML.h.cfvo(type="percent", val="0"),
+ XML.h.cfvo(type="percent", val="20"),
+ XML.h.cfvo(type="percent", val="40"),
+ XML.h.cfvo(type="percent", val="60"),
+ XML.h.cfvo(type="percent", val="80"),
+ )
+ ),
+ # These three require Excel 2010 extensions and will be ignored by earlier versions of Excel.
+ "3Triangles" => get_x14_icon("3Triangles"),
+ "3Stars" => get_x14_icon("3Stars"),
+ "5Boxes" => get_x14_icon("5Boxes"),
+ "Custom" => get_x14_icon("Custom")
+)
+const allIcons::Dict{String,Tuple{String,String}} = Dict(
+ "1" => ("3Arrows", "0"),
+ "2" => ("3Arrows", "1"),
+ "3" => ("3Arrows", "2"),
+ "4" => ("4Arrows", "1"),
+ "5" => ("4Arrows", "2"),
+ "6" => ("3ArrowsGray", "0"),
+ "7" => ("3ArrowsGray", "1"),
+ "8" => ("3ArrowsGray", "2"),
+ "9" => ("4ArrowsGray", "1"),
+ "10" => ("4ArrowsGray", "2"),
+ "11" => ("3Flags", "0"),
+ "12" => ("3Flags", "1"),
+ "13" => ("3Flags", "2"),
+ "14" => ("3TrafficLights1", "0"),
+ "15" => ("3TrafficLights1", "1"),
+ "16" => ("3TrafficLights1", "2"),
+ "17" => ("3TrafficLights2", "0"),
+ "18" => ("3TrafficLights2", "1"),
+ "19" => ("3TrafficLights2", "2"),
+ "20" => ("4TrafficLights", "0"),
+ "21" => ("3Signs", "0"),
+ "22" => ("3Signs", "1"),
+ "23" => ("3Symbols", "0"),
+ "24" => ("3Symbols", "1"),
+ "25" => ("3Symbols", "2"),
+ "26" => ("3Symbols2", "0"),
+ "27" => ("3Symbols2", "1"),
+ "28" => ("3Symbols2", "2"),
+ "29" => ("4RedToBlack", "0"),
+ "30" => ("4RedToBlack", "1"),
+ "31" => ("4RedToBlack", "2"),
+ "32" => ("4RedToBlack", "3"),
+ "33" => ("5Quarters", "0"),
+ "34" => ("5Quarters", "1"),
+ "35" => ("5Quarters", "2"),
+ "36" => ("5Quarters", "3"),
+ "37" => ("5Rating", "0"),
+ "38" => ("5Rating", "1"),
+ "39" => ("5Rating", "2"),
+ "40" => ("5Rating", "3"),
+ "41" => ("5Rating", "4"),
+ "42" => ("3Stars", "0"),
+ "43" => ("3Stars", "1"),
+ "44" => ("3Stars", "2"),
+ "45" => ("3Triangles", "0"),
+ "46" => ("3Triangles", "1"),
+ "47" => ("3Triangles", "2"),
+ "48" => ("5Boxes", "0"),
+ "49" => ("5Boxes", "1"),
+ "50" => ("5Boxes", "2"),
+ "51" => ("5Boxes", "3"),
+ "52" => ("5Boxes", "4")
+)
+
+const timeperiods::Dict{String,String} = Dict(
+ "last7Days" => "AND(TODAY()-FLOOR(__CR__,1)<=6,FLOOR(__CR__,1)<=TODAY())",
+ "yesterday" => "FLOOR(__CR__,1)=TODAY()-1",
+ "today" => "FLOOR(__CR__,1)=TODAY()",
+ "tomorrow" => "FLOOR(__CR__,1)=TODAY()+1",
+ "lastWeek" => "AND(TODAY()-ROUNDDOWN(__CR__,0)>=(WEEKDAY(TODAY())),TODAY()-ROUNDDOWN(__CR__,0)<(WEEKDAY(TODAY())+7))",
+ "thisWeek" => "AND(TODAY()-ROUNDDOWN(__CR__,0)<=WEEKDAY(TODAY())-1,ROUNDDOWN(__CR__,0)-TODAY()<=7-WEEKDAY(TODAY()))",
+ "nextWeek" => "AND(ROUNDDOWN(__CR__,0)-TODAY()>(7-WEEKDAY(TODAY())),ROUNDDOWN(__CR__,0)-TODAY()<(15-WEEKDAY(TODAY())))",
+ "lastMonth" => "AND(MONTH(__CR__)=MONTH(EDATE(TODAY(),0-1)),YEAR(__CR__)=YEAR(EDATE(TODAY(),0-1)))",
+ "thisMonth" => "AND(MONTH(__CR__)=MONTH(TODAY()),YEAR(__CR__)=YEAR(TODAY()))",
+ "nextMonth" => "AND(MONTH(__CR__)=MONTH(EDATE(TODAY(),0+1)),YEAR(__CR__)=YEAR(EDATE(TODAY(),0+1)))"
+)
+
+const CfKey = Union{CellRange,NonContiguousRange}
diff --git a/src/conditionalformats/read.jl b/src/conditionalformats/read.jl
new file mode 100644
index 00000000..93a34357
--- /dev/null
+++ b/src/conditionalformats/read.jl
@@ -0,0 +1,57 @@
+"""
+ getConditionalFormats(ws::Worksheet)
+
+Get the conditional formats for a worksheet.
+
+# Arguments
+- `ws::Worksheet`: The worksheet for which to get the conditional formats.
+
+Return a vector of pairs: CellRange => NamedTuple{type::String, priority::Int}}.
+
+
+"""
+getConditionalFormats(ws::Worksheet) = append!(getConditionalFormats(ws, allCfs(ws)), getConditionalExtFormats(ws, allExtCfs(ws)))
+function getConditionalFormats(ws::Worksheet, allcfnodes::Vector{XML.Node})::Vector{Pair{CfKey,NamedTuple{(:type, :priority),Tuple{String,Int64}}}}
+ allcfs = Vector{Pair{CfKey,NamedTuple{(:type, :priority),Tuple{String,Int64}}}}()
+ wb = get_workbook(ws)
+ for cf in allcfnodes
+ for child in XML.children(cf)
+ if localname(child) == "cfRule"
+ push!(allcfs, _cf_range(ws, cf["sqref"]) => (type=child["type"], priority=parse(Int, child["priority"]))) end
+ end
+ end
+ return allcfs
+end
+function getConditionalExtFormats(ws::Worksheet, allcfnodes::Vector{XML.Node})::Vector{Pair{CfKey,NamedTuple{(:type, :priority),Tuple{String,Int64}}}}
+ wb = get_workbook(ws)
+ allcfs = Vector{Pair{CfKey,NamedTuple{(:type, :priority),Tuple{String,Int64}}}}()
+ for cf in allcfnodes
+ let t, p, r, rule = false, ref = false
+ localname(cf) == "conditionalFormatting" ||
+ throw(XLSXError("Malformed worksheet `$(ws.name)`: its x14 conditional formats contain a `$(localname(cf))` element, not `conditionalFormatting`."))
+ # Children may be interleaved with whitespace text nodes from the
+ # writer's indentation, so locate `xm:sqref` by name rather than
+ # assuming it is the last child.
+ cf_elements = xml_elements(cf)
+ sqref = findlast(c -> localname(c) == "sqref", cf_elements)
+ if !isnothing(sqref)
+ r = XML.simple_value(cf_elements[sqref])
+ ref = true
+ end
+ for child in cf_elements
+ if localname(child) == "cfRule"
+ t = child["type"]
+ if t != "dataBar" # This is the other half of a dataBar definition - don't count twice!
+ p = parse(Int, child["priority"])
+ rule = true
+ end
+ end
+ if rule && ref
+ push!(allcfs, _cf_range(ws, r) => (type=t, priority=p))
+ rule = false
+ end
+ end
+ end
+ end
+ return allcfs
+end
diff --git a/src/conditionalformats/rules.jl b/src/conditionalformats/rules.jl
new file mode 100644
index 00000000..68124390
--- /dev/null
+++ b/src/conditionalformats/rules.jl
@@ -0,0 +1,253 @@
+#
+# -- Rule-based conditional formats: each matches cells by a rule and gives them a
+# differential format (`dxStyle`, or `format`, `font`, `border` and `fill`)
+#
+
+# The keywords every rule-based setter takes, after its own: whether later rules are
+# skipped once this one applies, and the format given to matching cells.
+const CF_FORMAT_KEYWORDS = (:stopIfTrue, :dxStyle, :format, :font, :border, :fill)
+
+# A string keyword's value, given as a string or Symbol.
+function _cf_string(k::Symbol, v)
+ (isnothing(v) || v isa String) && return v
+ (v isa AbstractString || v isa Symbol) && return String(v)
+ throw(XLSXError("`$k` must be a string, not $(repr(v))."))
+end
+
+# Read a rule setter's keywords from `allkws`. `own` holds the setter's own string
+# keywords with their defaults; the format keywords follow. Any other keyword is an error.
+function _cf_rule_keywords(allkws, own::NamedTuple)
+ valid = (keys(own)..., CF_FORMAT_KEYWORDS...)
+ for k in keys(allkws)
+ k in valid || throw(XLSXError("Invalid keyword argument: $k. Valid options are: $(join(("`$v`" for v in valid), ", "))."))
+ end
+ names = keys(own)
+ strings = NamedTuple{names}(map(k -> _cf_string(k, get(allkws, k, own[k])), names))
+ stopIfTrue = _cf_string(:stopIfTrue, get(allkws, :stopIfTrue, nothing))
+ dxStyle = _cf_string(:dxStyle, get(allkws, :dxStyle, nothing))
+ format::Union{Nothing,Vector{<:Pair{String}}} = get(allkws, :format, nothing)
+ font::Union{Nothing,Vector{<:Pair{String}}} = get(allkws, :font, nothing)
+ border::Union{Nothing,Vector{<:Pair{String}}} = get(allkws, :border, nothing)
+ fill::Union{Nothing,Vector{<:Pair{String}}} = get(allkws, :fill, nothing)
+ return (; strings..., stopIfTrue, dxStyle, format, font, border, fill)
+end
+
+# A rule's value or threshold: a number or a cell reference.
+function _check_cf_value(name, v)
+ isnothing(v) || is_valid_cellname(v) || is_valid_fixed_cellname(v) || !isnothing(tryparse(Float64, v)) ||
+ throw(XLSXError("Invalid `$name`: $v. Must be a number or a CellRef."))
+ return nothing
+end
+
+# The differential format given to the cells a rule matches, added to the workbook.
+function _cf_rule_dx(ws::Worksheet, kw)
+ wb = get_workbook(ws)
+ return Add_Cf_Dx(wb, get_new_dx(wb, get_dx(kw.dxStyle, kw.format, kw.font, kw.border, kw.fill)))
+end
+
+# A new `` of `type`, giving matching cells format `dxid`, with the next
+# priority. `attrs` follow the priority, and `stopIfTrue` comes last.
+function _cf_rule_element(ws::Worksheet, pfx, type, dxid, kw; attrs...)
+ cfx = XML.Element("$(pfx)cfRule"; type=type, dxfId=string(dxid.id))
+ cfx["priority"] = next_cf_priority!(ws)
+ for (k, v) in attrs
+ cfx[string(k)] = v
+ end
+ if !isnothing(kw.stopIfTrue) && kw.stopIfTrue == "true"
+ cfx["stopIfTrue"] = "1"
+ end
+ return cfx
+end
+
+_cf_prefix(ws::Worksheet) = (pfx = get_prefix(ws); pfx == "" ? pfx : pfx * ":")
+
+const CELLIS_OPERATORS = ("greaterThan", "greaterEqual", "lessThan", "lessEqual", "equal", "notEqual", "between", "notBetween")
+
+define_cf_address_methods(:setCfCellIs)
+function setCfCellIs(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=Dict{Symbol,Any}())::Int
+ kw = _cf_rule_keywords(allkws, (; operator="greaterThan", value=nothing, value2=nothing))
+ _check_cf_range(ws, rng)
+ pfx = _cf_prefix(ws)
+
+ _check_cf_value("value", kw.value)
+ _check_cf_value("value2", kw.value2)
+ !isnothing(kw.operator) && kw.operator ∉ CELLIS_OPERATORS &&
+ throw(XLSXError("Invalid `operator`: $(kw.operator). Valid options are: $(join(("`$o`" for o in CELLIS_OPERATORS), ", "))"))
+ dxid = _cf_rule_dx(ws, kw)
+
+ value = kw.value
+ if isnothing(value)
+ # The average of the range's numbers, as Excel's AVERAGE takes it: text,
+ # booleans and empty cells are ignored.
+ nums = filter(v -> v isa Real && !(v isa Bool), _cf_values(ws, rng))
+ value = isempty(nums) ? nothing : string(sum(nums) / length(nums))
+ end
+
+ cfx = _cf_rule_element(ws, pfx, "cellIs", dxid, kw)
+ cfx["operator"] = kw.operator
+ push!(cfx, XML.Element("$(pfx)formula", XML.Text(value)))
+ if !isnothing(kw.value2) && kw.operator ∈ ["between", "notBetween"]
+ push!(cfx, XML.Element("$(pfx)formula", XML.Text(kw.value2)))
+ end
+
+ update_worksheet_cfx!(cfx, ws, rng)
+ return 0
+end
+
+# Operator => (the operator Excel stores, the rule's formula).
+const CONTAINSTEXT_RULES = Dict(
+ "containsText" => ("containsText", "NOT(ISERROR(SEARCH(__txt__,__CR__)))"),
+ "notContainsText" => ("notContains", "ISERROR(SEARCH(__txt__,__CR__))"),
+ "beginsWith" => ("beginsWith", "LEFT(__CR__,LEN(__txt__))=__txt__"),
+ "endsWith" => ("endsWith", "RIGHT(__CR__,LEN(__txt__))=__txt__"),
+)
+
+# The rule's text operand: a cell reference as given, or literal text as a quoted
+# formula string (an embedded `"` doubled).
+_cf_text_operand(v::AbstractString) =
+ (is_valid_cellname(v) || is_valid_fixed_cellname(v)) ? String(v) : "\"" * replace(v, "\"" => "\"\"") * "\""
+
+define_cf_address_methods(:setCfContainsText)
+function setCfContainsText(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=Dict{Symbol,Any}())::Int
+ kw = _cf_rule_keywords(allkws, (; operator="containsText", value=nothing))
+ _check_cf_range(ws, rng)
+ pfx = _cf_prefix(ws)
+
+ isnothing(kw.value) && throw(XLSXError("Invalid `value`: $(kw.value). Must contain text or a CellRef."))
+ haskey(CONTAINSTEXT_RULES, kw.operator) ||
+ throw(XLSXError("Invalid operator: $(kw.operator). Valid options are: `containsText`, `notContainsText`, `beginsWith`, `endsWith`."))
+ operator, formula = CONTAINSTEXT_RULES[kw.operator]
+ operand = _cf_text_operand(kw.value)
+ formula = replace(formula, "__txt__" => operand, "__CR__" => string(_cf_anchor(rng)))
+ dxid = _cf_rule_dx(ws, kw)
+
+ cfx = _cf_rule_element(ws, pfx, kw.operator, dxid, kw)
+ cfx["operator"] = operator
+ startswith(operand, '"') && (cfx["text"] = kw.value) # Excel stores `text` only for literal text
+ push!(cfx, XML.Element("$(pfx)formula", XML.Text(formula)))
+
+ update_worksheet_cfx!(cfx, ws, rng)
+ return 0
+end
+
+# Operator => (percent, bottom) attributes; "" leaves one out.
+const TOP10_RULES = Dict(
+ "topN" => ("", ""),
+ "topN%" => ("1", ""),
+ "bottomN" => ("", "1"),
+ "bottomN%" => ("1", "1"),
+)
+
+define_cf_address_methods(:setCfTop10)
+function setCfTop10(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=Dict{Symbol,Any}())::Int
+ kw = _cf_rule_keywords(allkws, (; operator="topN", value="10"))
+ _check_cf_range(ws, rng)
+ pfx = _cf_prefix(ws)
+
+ _check_cf_value("value", kw.value)
+ haskey(TOP10_RULES, kw.operator) ||
+ throw(XLSXError("Invalid operator: $(kw.operator). Valid options are: `topN`, `topN%`, `bottomN`, `bottomN%`."))
+ percent, bottom = TOP10_RULES[kw.operator]
+ dxid = _cf_rule_dx(ws, kw)
+
+ cfx = _cf_rule_element(ws, pfx, "top10", dxid, kw)
+ percent != "" && (cfx["percent"] = percent)
+ bottom != "" && (cfx["bottom"] = bottom)
+ cfx["rank"] = kw.value
+
+ update_worksheet_cfx!(cfx, ws, rng)
+ return 0
+end
+
+# Operator => the attributes Excel stores for it on an `aboveAverage` rule.
+const ABOVEAVERAGE_RULES = Dict(
+ "aboveAverage" => (;),
+ "aboveEqAverage" => (; equalAverage="1"),
+ "plus1StdDev" => (; stdDev="1"),
+ "plus2StdDev" => (; stdDev="2"),
+ "plus3StdDev" => (; stdDev="3"),
+ "belowAverage" => (; aboveAverage="0"),
+ "belowEqAverage" => (; aboveAverage="0", equalAverage="1"),
+ "minus1StdDev" => (; aboveAverage="0", stdDev="1"),
+ "minus2StdDev" => (; aboveAverage="0", stdDev="2"),
+ "minus3StdDev" => (; aboveAverage="0", stdDev="3"),
+)
+
+define_cf_address_methods(:setCfAboveAverage)
+function setCfAboveAverage(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=Dict{Symbol,Any}())::Int
+ kw = _cf_rule_keywords(allkws, (; operator="aboveAverage"))
+ _check_cf_range(ws, rng)
+ pfx = _cf_prefix(ws)
+
+ haskey(ABOVEAVERAGE_RULES, kw.operator) ||
+ throw(XLSXError("Invalid operator: $(kw.operator). Valid options are: `aboveAverage`, `aboveEqAverage`, `plus1StdDev`, `plus2StdDev`, `plus3StdDev`, `belowAverage`, `belowEqAverage`, `minus1StdDev`, `minus2StdDev`, `minus3StdDev`."))
+ dxid = _cf_rule_dx(ws, kw)
+
+ cfx = _cf_rule_element(ws, pfx, "aboveAverage", dxid, kw; ABOVEAVERAGE_RULES[kw.operator]...)
+
+ update_worksheet_cfx!(cfx, ws, rng)
+ return 0
+end
+
+define_cf_address_methods(:setCfTimePeriod)
+function setCfTimePeriod(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=Dict{Symbol,Any}())::Int
+ kw = _cf_rule_keywords(allkws, (; operator="last7Days"))
+ _check_cf_range(ws, rng)
+ pfx = _cf_prefix(ws)
+
+ haskey(timeperiods, kw.operator) ||
+ throw(XLSXError("Invalid operator: $(kw.operator). Valid options are: `yesterday`, `today`, `tomorrow`, `last7Days`, `lastWeek`, `thisWeek`, `nextWeek`, `lastMonth`, `thisMonth`, `nextMonth`."))
+ formula = replace(timeperiods[kw.operator], "__CR__" => string(_cf_anchor(rng)))
+ dxid = _cf_rule_dx(ws, kw)
+
+ cfx = _cf_rule_element(ws, pfx, "timePeriod", dxid, kw)
+ cfx["timePeriod"] = kw.operator
+ push!(cfx, XML.Element("$(pfx)formula", XML.Text(formula)))
+
+ update_worksheet_cfx!(cfx, ws, rng)
+ return 0
+end
+
+# Operator => the rule's formula; unique and duplicate values need none.
+const BLANKERRORUNIQDUP_RULES = Dict(
+ "containsBlanks" => "LEN(TRIM(__CR__))=0",
+ "notContainsBlanks" => "LEN(TRIM(__CR__))>0",
+ "containsErrors" => "ISERROR(__CR__)",
+ "notContainsErrors" => "NOT(ISERROR(__CR__))",
+ "uniqueValues" => "",
+ "duplicateValues" => "",
+)
+
+define_cf_address_methods(:setCfContainsBlankErrorUniqDup)
+function setCfContainsBlankErrorUniqDup(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=Dict{Symbol,Any}())::Int
+ kw = _cf_rule_keywords(allkws, (; operator="containsBlanks"))
+ _check_cf_range(ws, rng)
+ pfx = _cf_prefix(ws)
+
+ haskey(BLANKERRORUNIQDUP_RULES, kw.operator) ||
+ throw(XLSXError("Invalid operator: $(kw.operator). Valid options are: containsBlanks, notContainsBlanks, containsErrors, notContainsErrors, uniqueValues, duplicateValues."))
+ formula = replace(BLANKERRORUNIQDUP_RULES[kw.operator], "__CR__" => string(_cf_anchor(rng)))
+ dxid = _cf_rule_dx(ws, kw)
+
+ cfx = _cf_rule_element(ws, pfx, kw.operator, dxid, kw)
+ formula != "" && push!(cfx, XML.Element("$(pfx)formula", XML.Text(formula)))
+
+ update_worksheet_cfx!(cfx, ws, rng)
+ return 0
+end
+
+define_cf_address_methods(:setCfFormula)
+function setCfFormula(ws::Worksheet, rng::CfRange; allkws::Dict{Symbol,Any}=Dict{Symbol,Any}())::Int
+ kw = _cf_rule_keywords(allkws, (; formula=nothing))
+ isnothing(kw.formula) && throw(XLSXError("A `formula` must be provided as a keyword argument."))
+ _check_cf_range(ws, rng)
+ pfx = _cf_prefix(ws)
+
+ dxid = _cf_rule_dx(ws, kw)
+
+ cfx = _cf_rule_element(ws, pfx, "expression", dxid, kw)
+ push!(cfx, XML.Element("$(pfx)formula", XML.Text("(" * uppercase_unquoted(kw.formula) * ")")))
+
+ update_worksheet_cfx!(cfx, ws, rng)
+ return 0
+end
diff --git a/src/conditionalformats/setconditionalformat.jl b/src/conditionalformats/setconditionalformat.jl
new file mode 100644
index 00000000..973c6bf4
--- /dev/null
+++ b/src/conditionalformats/setconditionalformat.jl
@@ -0,0 +1,899 @@
+"""
+ setConditionalFormat(ws::Worksheet, cr::String, type::Symbol; kw...) -> ::Int
+ setConditionalFormat(xf::XLSXFile, cr::String, type::Symbol; kw...) -> ::Int
+
+ setConditionalFormat(ws::Worksheet, rows, cols, type::Symbol; kw...) -> ::Int
+
+Add a new conditional format to a cell range, row range or column range in a
+worksheet or `XLSXFile`. Alternatively, ranges can be specified by giving rows
+and columns separately.
+
+There are many options for applying differnt types of custom format. For a basic guide,
+refer to the section on [Conditional formats](@ref) in the Formatting Guide.
+
+The `type` argument specifies which of Excel's conditional format types will be applied.
+
+Valid options for `type` are:
+- `:cellIs`
+- `:top10`
+- `:aboveAverage`
+- `:containsText`
+- `:notContainsText`
+- `:beginsWith`
+- `:endsWith`
+- `:timePeriod`
+- `:containsErrors`
+- `:notContainsErrors`
+- `:containsBlanks`
+- `:notContainsBlanks`
+- `:uniqueValues`
+- `:duplicateValues`
+- `:dataBar`
+- `:colorScale`
+- `:iconSet`
+
+Keyword options differ according to the `type` specified, as set out below.
+
+# type = :cellIs
+
+Defines a conditional format based on the value of each cell in a range.
+
+Valid keywords are:
+- `operator` : Defines the comparison to make.
+- `value` : defines the first value to compare against. This can be a cell reference (e.g. `"A1"`) or a number.
+- `value2` : defines the second value to compare against. This can be a cell reference (e.g. `"A1"`) or a number.
+- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
+- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply
+- `format` : defines the numFmt to apply if opting for a custom format.
+- `font` : defines the font to apply if opting for a custom format.
+- `border` : defines the border to apply if opting for a custom format.
+- `fill` : defines the fill to apply if opting for a custom format.
+
+All keywords are defined using Strings (e.g. `value = "2"` or `value = "A2"`).
+
+The keyword `operator` defines the comparison to use in the conditional formatting.
+If the condition is met, the format is applied.
+Valid options are:
+
+- `greaterThan` (cell > `value`) (default)
+- `greaterEqual` (cell >= `value`)
+- `lessThan` (cell < `value`)
+- `lessEqual` (cell <= `value`)
+- `equal` (cell == `value`)
+- `notEqual` (cell != `value`)
+- `between` (cell between `value` and `value2`)
+- `notBetween` (cell not between `value` and `value2`)
+
+If not specified (when required), `value` will be the arithmetic average of the
+(non-missing) cell values in the range if values are numeric. If the cell values
+are non-numeric, an error is thrown.
+
+Formatting to be applied if the condition is met can be defined in one of two ways.
+Use the keyword `dxStyle` to select one of the built-in Excel formats.
+Valid options are:
+
+- `redfilltext` (light red fill, dark red text) (default)
+- `yellowfilltext` (light yellow fill, dark yellow text)
+- `greenfilltext` (light green fill, dark green text)
+- `redfill` (light red fill)
+- `redtext` (dark red text)
+- `redborder` (dark red cell borders)
+
+Alternatively, you can define a custom format by using the keywords `format`, `font`,
+`border`, and `fill` which each take a vector of pairs. The first element of each pair
+is the name of the attribute to set, as a string, and the second is the value to set it
+to, also a string except for colors, which may be given in any form described in
+[Specifying colors](@ref).
+Valid attributes for each keyword are:
+
+- `format` : `format`
+- `font` : `color`, `bold`, `italic`, `under`, `strike`
+- `fill` : `pattern`, `bgColor`, `fgColor`
+- `border` : `style`, `color`
+
+Refer to [`setFormat()`](@ref), [`setFont()`](@ref), [`setFill()`](@ref) and [`setBorder()`](@ref) for
+more details on the valid attributes and values.
+
+!!! note
+
+ Excel limits the formatting attributes that can be set in a conditional format.
+ It is not possible to set the size or name of a font and neither is it possible to set
+ any of the cell alignment attributes. Diagonal borders cannot be set either.
+
+ Although it is not a limitation of Excel, this function sets all the border attributes
+ for each side of a cell to be the same.
+
+If both `dxStyle` and custom formatting keywords are specified, `dxStyle` will be used
+and the custom formatting will be ignored.
+If neither `dxStyle` nor custom formatting keywords are specified, the default
+is `dxStyle="redfilltext"`.
+
+# Examples
+
+```julia
+julia> XLSX.setConditionalFormat(s, "B1:B5", :cellIs) # Defaults to `operator="greaterThan"`, `dxStyle="redfilltext"` and `value` set to the arithmetic agverage of cell values in `rng`.
+
+julia> XLSX.setConditionalFormat(s, "B1:B5", :cellIs;
+ operator="between",
+ value="2",
+ value2="3",
+ fill = ["pattern" => "none", "bgColor"=>"FFFFC7CE"],
+ format = ["format"=>"0.00%"],
+ font = ["color"=>"blue", "bold"=>"true"]
+ )
+
+julia> XLSX.setConditionalFormat(s, "B1:B5", :cellIs;
+ operator="greaterThan",
+ value="4",
+ fill = ["pattern" => "none", "bgColor"=>"green"],
+ format = ["format"=>"0.0"],
+ font = ["color"=>"red", "italic"=>"true"]
+ )
+
+julia> XLSX.setConditionalFormat(s, "B1:B5", :cellIs;
+ operator="lessThan",
+ value="2",
+ fill = ["pattern" => "none", "bgColor"=>"yellow"],
+ format = ["format"=>"0.0"],
+ font = ["color"=>"green"],
+ border = ["style"=>"thick", "color"=>"coral"]
+ )
+
+```
+
+# type = :top10
+
+This conditional format can be used to highlight cells in the top (bottom) n within the
+range or in the top (bottom) n% (ie in the top 5 or in the top 5% of values in the range).
+
+The available keywords are:
+
+- `operator` : Defines the comparison to make.
+- `value` : Gives the for comparison or a cell reference (e.g. `"A1"`).
+- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
+- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply.
+- `format` : defines the numFmt to apply if opting for a custom format.
+- `font` : defines the font to apply if opting for a custom format.
+- `border` : defines the border to apply if opting for a custom format.
+- `fill` : defines the fill to apply if opting for a custom format.
+
+Valid values for the `operator` keyword are the following:
+
+- `topN` (cell is in the top n (= `value`) values of the range)
+- `bottomN` (cell is in the bottom n (= `value`) values of the range)
+- `topN%` (cell is in the top n% (= `value`) values of the range)
+- `bottomN%` (cell is in the bottom n% (= `value`) values of the range)
+
+Default keyowrds are `operator="TopN"` and `value="10"`.
+
+Multiple conditional formats may be applied to the same or overlapping cell ranges.
+If `stopIfTrue=true` the first condition that is met will be applied but all subsequent
+conditional formats for that cell will be skipped. If `stopIfTrue=false` (default) all
+relevant conditional formats will be applied to the cell in turn.
+
+For example usage of the `stopIfTrue` keyword, refer to [Overlaying conditional formats](@ref)
+in the Formatting Guide.
+
+The remaining keywords are defined as above for `type = :cellIs`.
+
+# Examples
+
+```julia
+julia> f=XLSX.newxlsx()
+XLSXFile("C:\\...\\blank.xlsx") containing 1 Worksheet
+ sheetname size range
+-------------------------------------------------
+ Sheet1 1x1 A1:A1
+
+
+julia> s=f[1]
+1×1 XLSX.Worksheet: ["Sheet1"](A1:A1)
+
+julia> for i=1:10;for j=1:10; s[i,j]=i*j;end;end
+
+julia> s[:]
+10×10 Matrix{Any}:
+ 1 2 3 4 5 6 7 8 9 10
+ 2 4 6 8 10 12 14 16 18 20
+ 3 6 9 12 15 18 21 24 27 30
+ 4 8 12 16 20 24 28 32 36 40
+ 5 10 15 20 25 30 35 40 45 50
+ 6 12 18 24 30 36 42 48 54 60
+ 7 14 21 28 35 42 49 56 63 70
+ 8 16 24 32 40 48 56 64 72 80
+ 9 18 27 36 45 54 63 72 81 90
+ 10 20 30 40 50 60 70 80 90 100
+
+julia> XLSX.setConditionalFormat(s, "A1:J10", :top10; operator="bottomN", value="1", stopIfTrue="true", dxStyle="redfilltext")
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:J10", :top10; operator="topN", value="1", stopIfTrue="true", dxStyle="greenfilltext")
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:J10", :top10;
+ operator="topN%",
+ value="20",
+ fill=["pattern"=>"solid", "bgColor"=>"cyan"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:J10", :top10;
+ operator="bottomN%",
+ value="20",
+ fill=["pattern"=>"solid", "bgColor"=>"yellow"])
+0
+
+```
+
+
+# type = :aboveAverage
+
+This conditional format can be used to compare cell values in the range with the
+average value for the range.
+
+The available keywords are:
+
+- `operator` : Defines the comparison to make.
+- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
+- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply.
+- `format` : defines the numFmt to apply if opting for a custom format.
+- `font` : defines the font to apply if opting for a custom format.
+- `border` : defines the border to apply if opting for a custom format.
+- `fill` : defines the fill to apply if opting for a custom format.
+
+Valid values for the `operator` keyword are the following:
+
+- `aboveAverage` (cell is above the average of the range) (default)
+- `aboveEqAverage` (cell is above or equal to the average of the range)
+- `plus1StdDev` (cell is above the average of the range + 1 standard deviation)
+- `plus2StdDev` (cell is above the average of the range + 2 standard deviations)
+- `plus3StdDev` (cell is above the average of the range + 3 standard deviations)
+- `belowAverage` (cell is below the average of the range)
+- `belowEqAverage` (cell is below or equal to the average of the range)
+- `minus1StdDev` (cell is below the average of the range - 1 standard deviation)
+- `minus2StdDev` (cell is below the average of the range - 2 standard deviations)
+- `minus3StdDev` (cell is below the average of the range - 3 standard deviations)
+
+The remaining keywords are defined as above for `type = :cellIs`.
+
+# Examples
+
+```julia
+julia> using Random, Distributions
+
+julia> d=Normal()
+Normal{Float64}(μ=0.0, σ=1.0)
+
+julia> columns=rand(d,1000)
+1000-element Vector{Float64}:
+-1.5515478694605092
+ 0.36859583733587165
+ 1.5349535865662158
+ -0.2352610551087202
+ 0.12355875388105911
+ 0.5859222303845908
+ -0.6326662651426166
+ 1.0610118292961683
+ -0.7891578831398097
+ 0.031022172414689787
+ -0.5534440118018843
+ -2.3538883599955023
+ ⋮
+ 0.4813001892130465
+ 0.03871017417416217
+ 0.7224728281160403
+ -1.1265372949908539
+ 1.5714393857211955
+ 0.31438739499933255
+ 0.4852591013082452
+ 0.5363388236349432
+ 1.1268430910133729
+ 0.7691442442244849
+ 1.0061732938516454
+
+julia> f=XLSX.newxlsx()
+XLSXFile("C:\\...\\blank.xlsx") containing 1 Worksheet
+ sheetname size range
+-------------------------------------------------
+ Sheet1 1x1 A1:A1
+
+
+julia> s=f[1]
+1×1 XLSX.Worksheet: ["Sheet1"](A1:A1)
+
+julia> XLSX.writetable!(s, [columns], ["normal"])
+
+julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
+ operator="plus3StdDev",
+ stopIfTrue="true",
+ fill = ["pattern"=>"solid", "bgColor"=>"red"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
+ operator="minus3StdDev",
+ stopIfTrue="true",
+ fill = ["pattern"=>"solid", "bgColor"=>"red"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
+ operator="plus2StdDev",
+ stopIfTrue="true",
+ fill = ["pattern"=>"solid", "bgColor"=>"tomato"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
+ operator="minus2StdDev",
+ stopIfTrue="true",
+ fill = ["pattern"=>"solid", "bgColor"=>"tomato"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
+ operator="minus1StdDev",
+ stopIfTrue="true",
+ fill = ["pattern"=>"solid", "bgColor"=>"pink"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
+ operator="plus1StdDev",
+ stopIfTrue = "true",
+ fill = ["pattern"=>"solid", "bgColor"=>"pink"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
+ operator="belowEqAverage",
+ fill = ["pattern"=>"solid", "bgColor"=>"green"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A2:A1001", :aboveAverage ;
+ operator="aboveEqAverage",
+ fill = ["pattern"=>"solid", "bgColor"=>"green"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+```
+
+# type = :containsText, :notContainsText, :beginsWith or :endsWith
+
+Highlight cells in the range that contain (or do not contain), begin or end with
+a specific text string. The default is `containsText`.
+
+Valid keywords are:
+
+- `value` : Gives the literal text to match or provides a cell reference (e.g. `"A1"`).
+- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
+- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply.
+- `format` : defines the numFmt to apply if opting for a custom format.
+- `font` : defines the font to apply if opting for a custom format.
+- `border` : defines the border to apply if opting for a custom format.
+- `fill` : defines the fill to apply if opting for a custom format.
+
+The keyword `value` gives the literal text to compare (eg. "Hello World") or provides a cell reference
+(e.g. `"A1"`). It is a required keyword with no default value.
+
+The remaining keywords are optional and are defined as above for `type = :cellIs`.
+
+# Examples
+
+```julia
+julia> s[:]
+4×1 Matrix{Any}:
+ "Hello World"
+ "Life the universe and everything"
+ "Once upon a time"
+ "In America"
+
+julia> XLSX.setConditionalFormat(s, "A1:A4", :containsText;
+ value="th",
+ fill = ["pattern"=>"solid", "bgColor"=>"cyan"],
+ font = ["color"=>"black", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:A4", :notContainsText;
+ value="i",
+ fill = ["pattern"=>"solid", "bgColor"=>"green"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:A4", :beginsWith ;
+ value="On",
+ fill = ["pattern"=>"solid", "bgColor"=>"red"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:A4", :endsWith ;
+ value="ica",
+ fill = ["pattern"=>"solid", "bgColor"=>"blue"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+```
+
+
+# type = :timePeriod
+
+When cells contain dates, this conditional format can be used to highlight cells.
+The available keywords are:
+
+- `operator` : Defines the comparison to make.
+- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
+- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply
+- `format` : defines the numFmt to apply if opting for a custom format.
+- `font` : defines the font to apply if opting for a custom format.
+- `border` : defines the border to apply if opting for a custom format.
+- `fill` : defines the fill to apply if opting for a custom format.
+
+Valid values for the keyword `operator` are the following:
+
+- `yesterday`
+- `today`
+- `tomorrow`
+- `last7Days` (default)
+- `lastWeek`
+- `thisWeek`
+- `nextWeek`
+- `lastMonth`
+- `thisMonth`
+- `nextMonth`
+
+The remaining keywords are defined as above for `type = :cellIs`.
+
+# Examples
+
+```julia
+julia> s[1:13, 1]
+13×1 Matrix{Any}:
+ "Dates"
+ 2024-11-20
+ 2024-12-20
+ 2025-01-08
+ 2025-02-08
+ 2025-03-08
+ 2025-04-08
+ 2025-05-08
+ 2025-05-09
+ 2025-05-10
+ 2025-05-14
+ 2025-06-08
+ 2025-07-08
+
+julia> XLSX.setConditionalFormat(s, "A1:A13", :timePeriod; operator="today", dxStyle = "greenfilltext")
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:A13", :timePeriod; operator="tomorrow", dxStyle = "yellowfilltext")
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:A13", :timePeriod; operator="nextMonth", dxStyle = "redfilltext")
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:A13", :timePeriod;
+ operator="lastMonth",
+ fill = ["pattern"=>"solid", "bgColor"=>"blue"],
+ font = ["color"=>"yellow", "bold"=>"true"])
+0
+
+```
+
+
+
+# type = :containsErrors, :notContainsErrors, :containsBlanks, :notContainsBlanks, :uniqueValues or :duplicateValues
+
+These conditional formatting options highlight cells that contain or don't contain errors,
+are blank (default) or not blank, are unique in the range or are duplicates within the range.
+The available keywords are:
+
+- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
+- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply
+- `format` : defines the numFmt to apply if opting for a custom format.
+- `font` : defines the font to apply if opting for a custom format.
+- `border` : defines the border to apply if opting for a custom format.
+- `fill` : defines the fill to apply if opting for a custom format.
+
+These keywords are defined as above for the `:cellIs` conditional format type.
+
+# Examples
+
+```julia
+julia> XLSX.setConditionalFormat(s, "A1:A7", :containsErrors;
+ stopIfTrue="true",
+ fill = ["pattern"=>"solid", "bgColor"=>"blue"],
+ font = ["color"=>"white", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:A7", :containsBlanks;
+ stopIfTrue="true",
+ fill = ["pattern"=>"solid", "bgColor"=>"green"],
+ font = ["color"=>"black", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:A7", :uniqueValues;
+ stopIfTrue="true",
+ fill = ["pattern"=>"solid", "bgColor"=>"yellow"],
+ font = ["color"=>"black", "bold"=>"true"])
+0
+
+julia> XLSX.setConditionalFormat(s, "A1:A7", :duplicateValues;
+ fill = ["pattern"=>"solid", "bgColor"=>"cyan"],
+ font = ["color"=>"black", "bold"=>"true"])
+0
+
+```
+
+
+# type = :expressiom
+
+Set a conditional format when an expression evaluated in each cell is `true`.
+
+The available keywords are:
+
+- `formula` : Specifies the formula to use. This must be a valid Excel formula.
+- `stopIfTrue` : Stops evaluating further conditional formats in any cell if this one is true.
+- `dxStyle` : Used optionally to select one of the built-in Excel formats to apply
+- `format` : defines the numFmt to apply if opting for a custom format.
+- `font` : defines the font to apply if opting for a custom format.
+- `border` : defines the border to apply if opting for a custom format.
+- `fill` : defines the fill to apply if opting for a custom format.
+
+The keyword `formula` is required and there is no default value. Formulae must be valid
+Excel formulae and written in US english with comma separators. Cell references may be
+absolute or relative references in either the row or the column or both.
+
+The remaining keywords are defined as above for `type = :cellIs`.
+
+# Examples
+
+```julia
+julia> XLSX.setConditionalFormat(s, "A1:C4", :expression; formula = "A1 < 16", dxStyle="greenfilltext")
+
+julia> XLSX.setConditionalFormat(s, 1:5, 1:4, :expression;
+ formula="A1=1",
+ fill=["pattern" => "none", "bgColor" => "yellow"],
+ format=["format" => "0.0"],
+ font=["color" => "green"],
+ border=["style" => "thick", "color" => "coral"]
+ )
+
+julia> XLSX.setConditionalFormat(s, "B2:D11", :expression; formula = "average(B\$2:B\$11) > average(A\$2:A\$11)", dxStyle = "greenfilltext")
+
+julia> XLSX.setConditionalFormat(s, "A1:E5", :expression; formula = "E5<50", dxStyle = "redfilltext")
+
+```
+
+# type = :dataBar
+
+Apply data bars to cells in a range depending on their values. The keyword `databar`
+can be used to select one of 12 built-in databars Excel provides by name. Valid names are:
+- `bluegrad` (default)
+- `greengrad`
+- `redgrad`
+- `orangegrad`
+- `lightbluegrad`
+- `purplegrad`
+- `blue`
+- `green`
+- `red`
+- `orange`
+- `lightblue`
+- `purple`
+
+The first six (with a `grad` suffix) yield bars with a color gradient while the remainder
+yield bars of solid color. By default, all built in data bars define their range from the
+minumum and maximum values in the range and negative values are given a red bar. These default
+settings can each be modified using the other keyword options available.
+
+Remaining keyword options provided are:
+- `showVal` - set to "false" to show databars only and hide cell values
+- `gradient` - set to "false" to use a solid color bar rather than a gradient fill
+- `borders` - set to "true" to show borders around each bar
+- `sameNegFill` - set to "true" to use the same fill color on negative bars as positive.
+- `sameNegBorders` - set to "false" to use the same border color on negative bars as positive
+- `direction` - determines the direction of the bars from the axis, "leftToRight" or "rightToLeft"
+- `min_type` - Defines how the minimum of the bar scale is defined ("num", "min", "percent", percentile", "formula" or "automatic")
+- `min_val` - Defines the minimum value for the data bar scale. May be a number(as a string), a cell reference or a formula (if type="formula").
+- `max_type` - Defines how the maximum of the bar scale is defined ("num", "max", "percent", percentile", "formula" or "automatic")
+- `max_val` - Defines the maximum value for the data bar scale. May be a number(as a string), a cell reference or a formula (if type="formula").
+- `fill_col` - Defines the color of the fill for positive bars (any color form; see [Specifying colors](@ref))
+- `border_col` - Defines the color of the border for positive bars (any color form; see [Specifying colors](@ref))
+- `neg_fill_col` - Defines the color of the fill for negative bars (any color form; see [Specifying colors](@ref))
+- `neg_border_col` - Defines the color of the border for negative bars (any color form; see [Specifying colors](@ref))
+- `axis_pos` - Defines the position of the axis ("middle" or "none")
+- `axis_col` - Defines the color of the axis (any color form; see [Specifying colors](@ref))
+
+# Examples
+```julia
+julia> XLSX.setConditionalFormat(s, "A1:A11", :dataBar)
+
+julia> XLSX.setConditionalFormat(s, "B1:B11", :dataBar; databar="purple")
+
+julia> XLSX.setConditionalFormat(s, "D1:D11", :dataBar;
+ gradient="true",
+ direction="rightToLeft",
+ axis_pos="none",
+ showVal="false"
+ )
+
+julia> XLSX.setConditionalFormat(s, "F1:F11", :dataBar;
+ gradient="false",
+ sameNegFill="true",
+ sameNegBorders="true"
+ )
+
+julia> XLSX.setConditionalFormat(f, "Sheet1!G1:G11", :dataBar;
+ fill_col="coral", border_col = "cyan",
+ neg_fill_col="cyan", neg_border_col = "coral"
+ )
+
+julia> XLSX.setConditionalFormat(f, "Sheet1!J1:J11", :dataBar; axis_col="magenta")
+
+julia> XLSX.setConditionalFormat(s, 15:25, 1, :dataBar;
+ min_type="least", max_type="highest"
+ )
+
+julia> XLSX.setConditionalFormat(s, 15:25, 2, :dataBar;
+ databar="purple",
+ min_type="percent", max_type="percent",
+ min_val="20", max_val="60"
+ )
+
+julia> XLSX.setConditionalFormat(s, "C15:C25", :dataBar;
+ databar="blue",
+ min_type="num", max_type="num",
+ min_val="-1", max_val="6",
+ gradient="true",
+ direction="leftToRight",
+ axis_pos="none"
+ )
+
+julia> XLSX.setConditionalFormat(s, "E15:E25", :dataBar;
+ gradient="true",
+ min_type="percentile", max_type="percentile",
+ min_val="20", max_val="80",
+ direction="rightToLeft",
+ axis_pos="middle"
+ )
+
+julia> XLSX.setConditionalFormat(s, "G15:G25", :dataBar;
+ min_type="num", max_type="formula",
+ min_val="\$L\$1", max_val="\$M\$1 * \$N\$1 + 3",
+ fill_col="coral", border_col = "cyan",
+ neg_fill_col="cyan", neg_border_col = "coral"
+ )
+
+```
+
+# type = :colorScale
+
+Define a 2-color or 3-color colorscale conditional format.
+
+Use the keyword `colorscale` to choose one of the 12 built-in Excel colorscales:
+
+- `"redyellowgreen"`: Red, Yellow, Green 3-color scale.
+- `"greenyellowred"`: Green, Yellow, Red 3-color scale.
+- `"redwhitegreen"` : Red, White, Green 3-color scale.
+- `"greenwhitered"` : Green, White, Red 3-color scale.
+- `"redwhiteblue"` : Red, White, Blue 3-color scale.
+- `"bluewhitered"` : Blue, White, Red 3-color scale.
+- `"redwhite"` : Red, White 2-color scale.
+- `"whitered"` : White, Red 2-color scale.
+- `"whitegreen"` : White, Green 2-color scale.
+- `"greenwhite"` : Green, White 2-color scale.
+- `"yellowgreen"` : Yellow, Green 2-color scale.
+- `"greenyellow"` : Green, Yellow 2-color scale (default).
+
+Alternatively, you can define a custom color scale by omitting the `colorscale` keyword and
+instead using the following keywords:
+
+- `min_type`: The type of the minimum value. Valid values are: `min`, `percentile`, `percent`, `num` or `formula`.
+- `min_val` : The value of the minimum. Omit if `min_type="min"`.
+- `min_col` : The color of the minimum value.
+- `mid_type`: Valid values are: `percentile`, `percent`, `num` or `formula`. Omit for a 2-color scale.
+- `mid_val` : The value of the scale mid point. Omit for a 2-color scale.
+- `mid_col` : The color of the mid point. Omit for a 2-color scale.
+- `max_type`: The type of the maximum value. Valid values are: `max`, `percentile`, `percent`, `num` or `formula`.
+- `max_val` : The value of the maximum value. Omit if `max_type="max"`.
+- `max_col` : The color of the maximum value.
+
+The keywords `min_val`, `mid_val`, and `max_val` can be a number or cell reference (e.g. `"\$A\$1"`) for any value
+of the related type keyword or, if the related type keyword is set to `formula`, may be a valid Excel formula that
+calculates a number. Cell references used in a formula must be specified as absolute references.
+
+Colors can be $COLOR_FORMS_DOC
+
+# Examples
+
+```julia
+julia> XLSX.setConditionalFormat(f["Sheet1"], "A1:F12", :colorScale) # Defaults to the `greenyellow` built-in scale.
+0
+
+julia> XLSX.setConditionalFormat(f["Sheet1"], "A13:C18", :colorScale; colorscale="whitered")
+0
+
+julia> XLSX.setConditionalFormat(f["Sheet1"], "D13:F18", :colorScale; colorscale="bluewhitered")
+0
+
+julia> XLSX.setConditionalFormat(f["Sheet1"], "A13:F22", :colorScale;
+ min_type="num",
+ min_val="2",
+ min_col="tomato",
+ mid_type="num",
+ mid_val="6",
+ mid_col="lawngreen",
+ max_type="num",
+ max_val="10",
+ max_col="cadetblue"
+ )
+0
+
+```
+
+# type = :iconSet
+
+Apply a set of icons to cells in a range depending on their values. The keyword `iconset`
+can be used to select one of 20 built-in icon sets Excel provides by name. Valid names are:
+- `3Arrows`
+- `5ArrowsGray`
+- `3TrafficLights` (default)
+- `3Flags`
+- `5Quarters`
+- `4Rating`
+- `5Rating`
+- `3Symbols`
+- `3Symbols2`
+- `3Signs`
+- `3TrafficLights2`
+- `4TrafficLights`
+- `4BlackToRed`
+- `4Arrows`
+- `5Arrows`
+- `3ArrowsGray`
+- `4ArrowsGray`
+- `3Triangles`
+- `3Stars`
+- `5Boxes`
+
+The digit prefix to the name indicates how many icons there are in a set, and therefore
+how the cell values with be binned by value. Bin boundaries may optionally be specified
+by the following keywords to override the default values for each icon set:
+
+- `min_type` = "percent" (default), "percentile", "num" or "formula"
+- `min_val` (default: "33" (3 icons), "25" (4 icons) or "20" (5 icons))
+- `mid_type` = "percent" (default), "percentile", "num" or "formula"
+- `mid_val` (default: "50" (4 icons), "40" (5 icons))
+- `mid2_type` = "percent" (default), "percentile", "num" or "formula"
+- `mid2_val` (default: "60" (5 icons))
+- `max_type` = "percent" (default), "percentile", "num" or "formula"
+- `max_val` (default: "67" (3 icons), "75" (4 icons) or "80" (5 icons))
+
+The keywords `min_val`, `mid_val`, `mid2_val` and `max_val` may contain numbers (as strings)
+or valid cell references. If `formula` is specified for the related type keyword, a valid
+Excel formula can be provided to evaluate to the bin threshold value to be used.
+Three-icon sets require two thresholds (`min_type`/`min_val` and `max_type`/`max_val`),
+four-icon sets require three thresholds (with the addition of `mid_type`/`mid_val`) and
+five-icon sets require four thresholds (adding `mid2_type`/`mid2_val`). Thresholds defined
+(using val and type keywords) that are unnecessary are simply ignored.
+
+Each value can be tested using `>=` (default) or `>`. To change from the default,
+optionally set `min_gte`, `mid_gte`, `mid2_gte` and/or `max_gte` to `"false"` to
+use `>` in the comparison. Any other value for these gte keywords will be ignored
+and the default `>=` comparison used.
+
+The built-in icon sets Excel provides are composed of 52 individual icons. It is
+possible to mix and match any of these to make a custom 3-icon, 4-icon or 5-icon
+set by specifying `iconset = "Custom"`. The number of icons in the set will be
+determined by whether the `mid_val`/`mid_type` keywords and `mid2_val`/`mid2_type`
+keywords are provided.
+
+The icons that will be used in a `Custom` iconset are defined using the `icon_list`
+keyword which takes a vector of integers in the range from 1 to 52. For a key relating
+integers to the icons they represent, see the [Icon Set](@ref) section in the Formatting
+Guide.
+
+The order in which the symbols are appiled can be reversed from the default order (or, for
+`Custom` icon sets, the order given in `icon_list`), by optionally setting `reverse = "true"`.
+Any other value provided for `reverse` will be ignored, and the default order applied.
+
+The cell value can be suppressed, so that only the icon is shown in the Excel cell by
+optionally specifying `showVal = "false"`. Any other value provided for `showVal` will be
+ignored, and the cell value will be displayed with the icon.
+
+# Examples
+```julia
+XLSX.setConditionalFormat(s, "F2:F11", :iconSet; iconset="3Arrows")
+
+XLSX.setConditionalFormat(s, 2, :, :iconSet; iconset = "5Boxes",
+ reverse = "true",
+ showVal = "false",
+ min_type="num", mid_type="percentile", mid2_type="percentile", max_type="num",
+ min_val="3", mid_val="45", mid2_val="65", max_val="8",
+ min_gte="false", mid_gte="false", mid2_gte="false", max_gte="false")
+
+XLSX.setConditionalFormat(s, "A2:A11", :iconSet;
+ iconset = "Custom",
+ icon_list = [31,24],
+ min_type="num", max_type="formula",
+ min_val="3", max_val="if(\$G\$4=\"y\", \$G\$1+5, 10)")
+
+```
+
+"""
+function setConditionalFormat end
+
+# The text rules share one setter, which takes the rule as its `operator`. With
+# `:containsText` the `operator` keyword chooses the rule (default `containsText`);
+# `:notContainsText`, `:beginsWith` and `:endsWith` are that operator, so one given
+# as well must agree. (They previously all made a `containsText` rule.)
+function _text_rule_operator!(allkws::Dict{Symbol,Any}, type::Symbol)
+ type === :containsText && return nothing
+ if haskey(allkws, :operator)
+ string(allkws[:operator]) == string(type) || throw(XLSXError(
+ "`operator=$(allkws[:operator])` conflicts with the conditional format type `:$type`. Omit `operator`: the type sets it."))
+ else
+ allkws[:operator] = string(type)
+ end
+ return nothing
+end
+
+function setConditionalFormat(f, r, type::Symbol; kw...)
+ _allkws = Dict{Symbol,Any}(k => v for (k, v) in kw)
+ haskey(_allkws, :priority) && throw(XLSXError("Invalid keyword argument: `priority`. Conditional format rules are prioritised in the order they are applied."))
+ if type == :colorScale
+ setCfColorScale(f, r; allkws=_allkws)
+ elseif type == :cellIs
+ setCfCellIs(f, r; allkws=_allkws)
+ elseif type == :top10
+ setCfTop10(f, r; allkws=_allkws)
+ elseif type == :aboveAverage
+ setCfAboveAverage(f, r; allkws=_allkws)
+ elseif type == :timePeriod
+ setCfTimePeriod(f, r; allkws=_allkws)
+ elseif type ∈ [:containsText, :notContainsText, :beginsWith, :endsWith]
+ _text_rule_operator!(_allkws, type)
+ setCfContainsText(f, r; allkws=_allkws)
+ elseif type ∈ [:containsBlanks, :notContainsBlanks, :containsErrors, :notContainsErrors, :duplicateValues, :uniqueValues]
+ push!(_allkws, :operator => string(type))
+ setCfContainsBlankErrorUniqDup(f, r; allkws=_allkws)
+ elseif type == :expression
+ setCfFormula(f, r; allkws=_allkws)
+ elseif type == :iconSet
+ setCfIconSet(f, r; allkws=_allkws)
+ elseif type == :dataBar
+ setCfDataBar(f, r; allkws=_allkws)
+ else
+ throw(XLSXError("Invalid conditional format type: $type."))
+ end
+end
+
+function setConditionalFormat(f, r, c, type::Symbol; kw...)
+ _allkws = Dict{Symbol,Any}(k => v for (k, v) in kw)
+ haskey(_allkws, :priority) && throw(XLSXError("Invalid keyword argument: `priority`. Conditional format rules are prioritised in the order they are applied."))
+ if type == :colorScale
+ setCfColorScale(f, r, c; allkws=_allkws)
+ elseif type == :cellIs
+ setCfCellIs(f, r, c; allkws=_allkws)
+ elseif type == :top10
+ setCfTop10(f, r, c; allkws=_allkws)
+ elseif type == :aboveAverage
+ setCfAboveAverage(f, r, c; allkws=_allkws)
+ elseif type == :timePeriod
+ setCfTimePeriod(f, r, c; allkws=_allkws)
+ elseif type ∈ [:containsText, :notContainsText, :beginsWith, :endsWith]
+ _text_rule_operator!(_allkws, type)
+ setCfContainsText(f, r, c; allkws=_allkws)
+ elseif type ∈ [:containsBlanks, :notContainsBlanks, :containsErrors, :notContainsErrors, :duplicateValues, :uniqueValues]
+ push!(_allkws, :operator => string(type))
+ setCfContainsBlankErrorUniqDup(f, r, c; allkws=_allkws)
+ elseif type == :expression
+ setCfFormula(f, r, c; allkws=_allkws)
+ elseif type == :iconSet
+ setCfIconSet(f, r, c; allkws=_allkws)
+ elseif type == :dataBar
+ setCfDataBar(f, r, c; allkws=_allkws)
+ else
+ throw(XLSXError("Invalid conditional format type: $type."))
+ end
+end
diff --git a/src/formula.jl b/src/formula.jl
index 3a367978..fbee3fc8 100644
--- a/src/formula.jl
+++ b/src/formula.jl
@@ -137,38 +137,36 @@ function new_ReferencedFormula_Id(ws::Worksheet)
end
function build_reference_index(ws::Worksheet)
- # Create Dict of all ReferencedFormulae in worksheet
+ # Create Dict of all ReferencedFormulae in worksheet, from the formulas recorded
+ # for it (a pass over the formulas, not over every cell of the sheet)
refs = Dict{Int,ReferencedFormula}() # Id => ReferencedFormula
- for r in eachrow(ws)
- for cell in values(r.rowcells)
- if cell.formula
- f = get_formula_from_cache(ws, cell.ref)
- if f isa ReferencedFormula
- refs[f.id] = f
- end
- end
- end
+ for (k, f) in get_workbook(ws).formulas
+ k.sheet == ws.name && f isa ReferencedFormula && (refs[f.id] = f)
end
return refs
end
+# The sheet's ReferencedFormula with shared-formula id `id`, or `nothing`.
+function _find_referenced_formula(ws::Worksheet, id::Int)::Union{Nothing,ReferencedFormula}
+ for (k, f) in get_workbook(ws).formulas
+ k.sheet == ws.name && f isa ReferencedFormula && f.id == id && return f
+ end
+ return nothing
+end
function get_referenced_formula(ws::Worksheet, cellref::CellRef; refs::Union{Nothing,Dict{Int,ReferencedFormula}}=nothing)
# find the actual formula a cell's FormulaReference refers to
- if isnothing(refs)
- refs = build_reference_index(ws)
- end
cell = getcell(ws, cellref)
if cell.formula
f = get_formula_from_cache(ws, cellref)
if isa(f, FormulaReference)
id = f.id
- haskey(refs, id) || throw(XLSXError("No ReferencedFormula found for id=$id"))
- offset = cell_offset(CellRange(refs[id].ref).start, cellref)
- f = shift_excel_references(refs[id].formula, offset)
- return f
+ master = isnothing(refs) ? _find_referenced_formula(ws, id) : get(refs, id, nothing)
+ isnothing(master) && throw(XLSXError("No ReferencedFormula found for id=$id"))
+ offset = cell_offset(CellRange(master.ref).start, cellref)
+ return shift_excel_references(master.formula, offset)
else
- throw(XLSXError("Cell `$CellRef` does not contain a formula reference!"))
+ throw(XLSXError("Cell `$cellref` does not contain a formula reference!"))
end
end
end
@@ -196,191 +194,180 @@ function rereference_formulae(ws::Worksheet, cell::Cell)
push!(ranges, CellRange(CellRef(old_range.start.row_number + 1, old_range.start.column_number), CellRef(old_range.stop.row_number, old_range.stop.column_number)))
end
+ f = get_formula_from_cache(ws, cell.ref)
for newrng in ranges
if size(newrng) == (1, 1) # replace with simple formula for single cell
- f = get_formula_from_cache(ws, cell.ref)
offset = cell_offset(cell.ref, newrng.start)
shifted = shift_excel_references(f.formula, offset)
add_formula_to_cache(ws, newrng.stop, Formula(shifted, nothing, nothing, f.unhandled))
else # update reference and re-index all referring formulas
- f = get_formula_from_cache(ws, cell.ref)
- delete!(wb.formulas, SheetCellRef(ws.name, cell.ref))
- cell.formula=false
newid = new_ReferencedFormula_Id(ws)
rereference_formulae(ws, cell, f, newrng, newid)
end
end
+ delete!(wb.formulas, SheetCellRef(ws.name, cell.ref))
+ cell.formula = false
end
+# The cells keep their cached values and styles: only the formula each refers to changes.
function rereference_formulae(ws::Worksheet, oldcell::Cell, f::AbstractFormula, newrng::CellRange, newid::Int)
- wb = get_workbook(ws)
- oldform = f.formula
oldunhandled = f.unhandled
offset = cell_offset(oldcell.ref, newrng.start)
- newform = ReferencedFormula(shift_excel_references(oldform, offset), newid, string(newrng), oldunhandled)
+ newform = ReferencedFormula(shift_excel_references(f.formula, offset), newid, string(newrng), oldunhandled)
for fr in newrng
newfr = getcell(ws, fr)
if fr != newrng.start
- if newfr.formula
+ if !(newfr isa EmptyCell) && newfr.formula
formula = get_formula_from_cache(ws, fr)
if formula isa FormulaReference && formula.id == f.id
- t = encode(newfr.datatype)
add_formula_to_cache(ws, fr, FormulaReference(newid, oldunhandled))
- setdata!(ws, Cell(wb, fr, t, string(newfr.style), string(newfr.value), string(newfr.meta), true))
end
end
else
- t = encode(oldcell.datatype)
add_formula_to_cache(ws, fr, newform)
- setdata!(ws, Cell(wb, fr, t, string(newfr.style), string(newfr.value), string(newfr.meta), true))
+ if newfr isa EmptyCell
+ setdata!(ws, Cell(fr, UInt64(0), UInt32(0), UInt16(0), CT_EMPTY, true))
+ else
+ newfr.formula = true
+ end
end
end
return nothing
end
-# shift the relative cell references in a formula when shifting a ReferencedFormula
-function shift_excel_references(formula::AbstractString, offset::Tuple{Int32,Int32})
- # Regex to match Excel-style cell references (e.g., A1, $A$1, A$1, $A1)
- pattern = r"\$?[A-Z]{1,3}\$?[1-9][0-9]*"
- row_shift, col_shift = offset
-
- initial = [string(x.match) for x in eachmatch(pattern, formula)]
- result = Vector{String}()
-
- for ref in eachmatch(pattern, formula)
- # Extract parts using regex
- m = match(r"(\$?)([A-Z]{1,3})(\$?)([1-9][0-9]*)", ref.match)
- col_abs, col_letters, row_abs, row_digits = m.captures
-
- col_num = decode_column_number(col_letters)
- row_num = parse(Int, row_digits)
-
- # Apply shifts only if not absolute
- new_col = col_abs == "\$" ? col_letters : encode_column_number(col_num + col_shift)
- new_row = row_abs == "\$" ? row_digits : string(row_num + row_shift)
-
- push!(result, col_abs * new_col * row_abs * new_row)
+# An A1 cell reference standing on its own: not part of a longer name (`LOG10`,
+# `ATAN2`, `DATA2024!`) and not a function call. Each match is replaced where it
+# stands, so `A1` never rewrites the start of `A10`.
+const RGX_SHIFT_REF = r"(? replace(code, RGX_SHIFT_REF => shift_one), formula)
end
# Replace formula references to a sheet that has been deleted with #REF errors
function update_formulas_missing_sheet!(wb::Workbook, name::String)
xf = get_xlsxfile(wb)
- quoted_name = quoteit(name)
- sheet_prefix = quoted_name * "!"
-
- # Pre-compile regex pattern
- ref_pattern = Regex(sheet_prefix * raw"\$?[A-Z]{1,3}\$?[1-9][0-9]*")
-
- deleted_formula_ids = Set{Int}()
+
+ # A reference to a cell or range on the deleted sheet: the whole of it becomes #REF!.
+ ref_pattern = Regex(_formula_sheet_ref_pattern(quoteit(name) * "!") *
+ raw"\$?[A-Z]{1,3}\$?[1-9][0-9]*(?::\$?[A-Z]{1,3}\$?[1-9][0-9]*)?")
+
error_formula_ids = Set{Int}()
formulas_to_delete = Set{SheetCellRef}()
-
+
# Single pass: process non-reference formulas
for (ref, f) in wb.formulas
- if f isa FormulaReference
- continue
- end
-
- ref_str = string(ref)
-
- # Check if formula is in the deleted sheet
- if occursin(sheet_prefix, ref_str)
- if f isa ReferencedFormula
- push!(deleted_formula_ids, f.formula.id)
- end
+ f isa FormulaReference && continue
+
+ # Formula in the deleted sheet
+ if ref.sheet == name
push!(formulas_to_delete, ref)
continue
end
-
- # Check if formula refers to the deleted sheet
- if occursin(sheet_prefix, f.formula)
- old_formula = f.formula
- # Replace references to deleted sheet
- f.formula = replace(f.formula, ref_pattern => "#REF!")
- # Update cell if formula changed
- if old_formula != f.formula
- cell = getcell(xf, ref)
- cell.datatype = CT_ERROR
- cell.value = UInt64(XL_REF)
- f isa ReferencedFormula &&push!(error_formula_ids, f.id) # all FormulaReferences to this formula need updating
- end
+
+ # Formula referring to the deleted sheet
+ old_formula = f.formula
+ f.formula = replace(f.formula, ref_pattern => "#REF!")
+ if old_formula != f.formula
+ cell = getcell(xf, ref)
+ cell.datatype = CT_ERROR
+ cell.value = UInt64(XL_REF)
+ f isa ReferencedFormula && push!(error_formula_ids, f.id) # all FormulaReferences to this formula need updating
end
end
-
- # Second pass: handle FormulaReferences
+
+ # Second pass: handle FormulaReferences. Shared-formula ids are per sheet.
for (ref, f) in wb.formulas
- if f isa FormulaReference
- if f.id ∈ error_formula_ids && f.id ∉ deleted_formula_ids
- cell = getcell(xf, ref)
- cell.datatype = CT_ERROR
- cell.value = UInt64(XL_REF)
- end
- if occursin(sheet_prefix, string(ref)) || f.id ∈ deleted_formula_ids
- push!(formulas_to_delete, ref)
- end
+ f isa FormulaReference || continue
+ if ref.sheet == name
+ push!(formulas_to_delete, ref)
+ elseif f.id ∈ error_formula_ids
+ cell = getcell(xf, ref)
+ cell.datatype = CT_ERROR
+ cell.value = UInt64(XL_REF)
end
end
-
+
# Delete all marked formulas
for ref in formulas_to_delete
delete!(wb.formulas, ref)
end
-
+
return nothing
end
+# Regex source matching a sheet prefix such as `Data!` or `'My Sheet'!` where it starts a
+# reference: not inside a longer name (`MyData!`) nor after an external workbook's `[1]`.
+_formula_sheet_ref_pattern(prefix::AbstractString) = "(? (new_ref, formula)
-
- # First pass: update formula values and identify keys that need updating
+ old_ref = Regex(_formula_sheet_ref_pattern(quoteit(old_name) * "!"))
+ new_prefix = quoteit(new_name) * "!"
+
+ formulas_to_move = Pair{SheetCellRef,AbstractFormula}[]
for (ref, f) in wb.formulas
- ref_str = string(ref)
- needs_key_update = occursin(old_prefix, ref_str)
-
- # Calculate new reference if key needs updating
- new_ref = if needs_key_update
- new_ref_str = replace(ref_str, old_quoted => new_quoted)
- SheetCellRef(new_ref_str)
- else
- nothing
- end
-
- # Update formula values for non-FormulaReference types
- if f isa ExplicitFormula
- needs_value_update = occursin(old_prefix, f.formula)
-
- if needs_value_update
- f.formula = replace(f.formula, old_prefix => new_prefix)
- end
- end
-
- # Store formulas that need key updates
- if needs_key_update
- formulas_to_move[ref] = (new_ref, f)
- end
+ f isa ExplicitFormula && (f.formula = replace(f.formula, old_ref => new_prefix))
+ ref.sheet == old_name && push!(formulas_to_move, ref => f)
end
-
- # Second pass: update keys by deleting old and inserting with new keys
- for (old_ref, (new_ref, f)) in formulas_to_move
- delete!(wb.formulas, old_ref)
- wb.formulas[new_ref] = f
+
+ for (ref, f) in formulas_to_move
+ delete!(wb.formulas, ref)
+ wb.formulas[SheetCellRef(new_name, ref.cellref)] = f
end
-
+
return nothing
end
@@ -429,6 +416,18 @@ end
# Simplified regex for cell/range references
const RGX_RANGE_RE = r"[A-Z]+\$?\d+:[A-Z]+\$?\d+"
+# Index of the `)` closing a call whose arguments start at byte `start`, or `nothing`.
+function _closing_paren(s::AbstractString, start::Int)
+ depth = 1
+ i = start
+ while i <= lastindex(s)
+ s[i] == '(' && (depth += 1)
+ s[i] == ')' && (depth -= 1; depth == 0 && return i)
+ i = nextind(s, i)
+ end
+ return nothing
+end
+
"""
needs_array_attr(fname::String, args::Vector{String}) -> Bool
@@ -436,9 +435,12 @@ Determine if a function call will spill and thus require t="array".
"""
function needs_array_attr(fname::AbstractString, args::Vector{String})
f = uppercase(fname)
+ isempty(args) && return false
# Helper: does an argument look like a range?
is_range(arg) = occursin(RGX_RANGE_RE, arg)
+ # Helper: is an argument a literal integer greater than 1? (it may be a reference)
+ over_one(arg) = something(tryparse(Int, arg), 0) > 1
# INDEX(array, row_num, [col_num])
if f == "INDEX"
@@ -453,17 +455,15 @@ function needs_array_attr(fname::AbstractString, args::Vector{String})
return true
end
# If height or width > 1, OFFSET returns a multi-cell reference
- if length(args) >= 5
- return (tryparse(Int, args[4]) |> x -> x > 1 ? true : false) ||
- (tryparse(Int, args[5]) |> x -> x > 1 ? true : false)
- end
+ length(args) >= 4 && over_one(args[4]) && return true
+ length(args) >= 5 && over_one(args[5]) && return true
return false
end
- # IF(logical_test, value_if_true, value_if_false)
+ # IF(logical_test, value_if_true, [value_if_false])
if f == "IF"
# IF spills if either branch is a range/array
- return any(is_range, args[1:3])
+ return any(is_range, args[1:min(3, end)])
end
# CHOOSE(index_num, value1, value2, ...)
@@ -628,8 +628,12 @@ function process_dynamic_array_functions(xf::XLSXFile, cellref::CellRef, val::Ab
throw(XLSXError("Currently only simple aggregation functions like sum or average are supported with `raw=false`."))
end
- # Reconstruct
- formula = fname * "(" * join(args, ",") * ")"
+ # Reconstruct the call in place, keeping the rest of the formula
+ close = _closing_paren(formula, m.offset + ncodeunits(m.match))
+ head = formula[1:prevind(formula, m.offset)]
+ head == "=" && (head = "") # a bare call is stored without its `=`, as before
+ formula = head * fname * "(" * join(args, ",") * ")" *
+ (isnothing(close) ? "" : formula[nextind(formula, close):end])
end
end
@@ -683,7 +687,7 @@ function get_external_workbook_path(xf::XLSXFile, id::Int)
isnothing(i) && throw(XLSXError("Malformed external reference in workbook. Missing externalLink node."))
isnothing(j) && throw(XLSXError("Malformed external reference in workbook. Missing externalBook node."))
k, l = get_idces(extXml[i], "externalBook", "externalBookPr")
- k == j || throw(XLSXError("Something wrong here!"))
+ k == j || throw(XLSXError("Internal error: two lookups of the externalBook node in `$rel` disagree ($j and $k)."))
# find the file name, searching in order:
# 1. externalBook filename attribute
@@ -706,7 +710,7 @@ function get_external_workbook_path(xf::XLSXFile, id::Int)
k, l = get_idces(extXml[i], "externalBook", "xxl21:alternateUrls")
if !isnothing(l)
atts = XML.attributes(extXml[i][k][l][1]) # prefer the first alternateUrls r:id if multiple
- haskey(atts, "r:id") || throw(XLSXError("Something wrong here!"))
+ haskey(atts, "r:id") || throw(XLSXError("Malformed external reference in `$rel`: an alternateUrls entry has no `r:id`."))
target = external_link_target(xf, rel, atts["r:id"])
isnothing(target) || return target
end
@@ -715,13 +719,12 @@ function get_external_workbook_path(xf::XLSXFile, id::Int)
end
function get_formula_from_cache(ws::Worksheet, ref::CellRef)
- # get_xlsxfile(ws).is_writable || throw(XLSXError("Workbook is not writable - cannot get formulae!"))
wb = get_workbook(ws)
- return wb.formulas[SheetCellRef(combine_sheet_ref(ws, ref))]
+ return wb.formulas[SheetCellRef(ws.name, ref)]
end
function add_formula_to_cache(ws::Worksheet, ref::CellRef, f::AbstractFormula)
wb = get_workbook(ws)
- wb.formulas[SheetCellRef(combine_sheet_ref(ws, ref))] = f
+ wb.formulas[SheetCellRef(ws.name, ref)] = f
end
"""
@@ -873,20 +876,14 @@ setFormula(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon; kw.
setFormula(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_colon(setFormula, ws, nothing, col; kw...)
setFormula(ws::Worksheet, ::Colon, ::Colon; kw...) = setFormula(ws, :; kw...)
setFormula(ws::Worksheet, ::Colon; kw...) = process_colon(setFormula, ws, nothing, nothing; kw...)
-# These all give rise to (potentially) non-contiguous ranges so would need special handling if implemented. The cellformat-helpers functions won't work here because the kw will need processing.
-#setFormula(ws::Worksheet, ncrng::NonContiguousRange; kw...) = process_ncranges(setFormula, ws, ncrng; kw...)
-#setFormula(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon; kw...) = process_veccolon(setFormula, ws, row, nothing; kw...)
-#setFormula(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_veccolon(setFormula, ws, nothing, col; kw...)
-#setFormula(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setFormula, ws, row, col; kw...)
-#setFormula(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}; kw...) = process_vecint(setFormula, ws, row, col; kw...)
-#setFormula(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}; kw...) = process_vecint(setFormula, ws, row, col; kw...)
+# Non-contiguous ranges, and vectors or step ranges of rows or columns, aren't supported:
+# each would need its own handling, since the cellformat-helpers functions can't process
+# setFormula's keywords.
function setFormula(ws::Worksheet, rng::CellRange; val::AbstractString, raw::Bool=false, spill::Union{Nothing,Bool}=nothing)
xf = get_xlsxfile(ws)
- if xf.is_writable == false
- throw(XLSXError("Cannot set formula because because XLSXFile is not writable."))
- end
+ _require_writable(xf, "set formula")
is_array = is_array_formula(val)
@@ -899,7 +896,7 @@ function setFormula(ws::Worksheet, rng::CellRange; val::AbstractString, raw::Boo
for c in rng
offset = (c.row_number - start.row_number, c.column_number - start.column_number)
newval = shift_excel_references(val, offset)
- f = setFormula(ws, c, newval)
+ f = setFormula(ws, c; val=newval, raw, spill)
first && (first = false; f1 = f) # return the formula from the first cell only
end
return f1
@@ -940,9 +937,7 @@ function setFormula(ws::Worksheet, cellref::CellRef; val::AbstractString, raw::B
xf = get_xlsxfile(ws)
- if xf.is_writable == false
- throw(XLSXError("Cannot set formula because because XLSXFile is not writable."))
- end
+ _require_writable(xf, "set formula")
c = getcell(ws, cellref)
@@ -965,10 +960,9 @@ end
getFormula(sh::Worksheet, row::Int, col::Int; get_external_refs::Bool=false) -> Union{String,Nothing}
Get the formula for a single cell reference in a worksheet `sh` or XLSXFile `xf`.
-The specified cell must be within the sheet dimension.
+A cell outside the sheet dimension is an `EmptyCell`.
-If the cell does not contain any formula (but is not an `EmptyCell`), return an empty string ("").
-If the cell is an EmptyCell, return `nothing`.
+If the cell is an `EmptyCell` or does not contain a formula, return `nothing`.
If the cell contains a `FormulaReference`, look up the actual formula.
@@ -1010,17 +1004,14 @@ getFormula(ws::Worksheet, cr::String; kw...) = process_get_cellname(getFormula,
getFormula(xl::XLSXFile, sheetcell::String; kw...) = process_get_sheetcell(getFormula, xl, sheetcell; kw...)
getFormula(ws::Worksheet, row::Integer, col::Integer; kw...) = getFormula(ws, CellRef(row, col); kw...)
function getFormula(ws::Worksheet, cellref::CellRef; get_external_refs::Bool=false)
- cellref ∉ get_dimension(ws) && throw(XLSXError("Cell $cellref is out of range for worksheet '$(ws.name)'"))
xf = get_xlsxfile(ws)
- if !xf.use_cache_for_sheet_data
- throw(XLSXError("Cannot get formula because cache is not enabled."))
- end
+ _require_cache(xf, "get formula")
cell = getcell(ws, cellref)
(isa(cell, EmptyCell) || !cell.formula) && return nothing
f = get_formula_from_cache(ws, cellref)
if f isa FormulaReference
# need to look up the ReferencedFormula this FormulaReference points to
- f = get_referenced_formula(ws, cell.ref; refs=build_reference_index(ws))
+ f = get_referenced_formula(ws, cell.ref)
else
f = f.formula
end
diff --git a/src/images.jl b/src/images.jl
index 59aa159f..0a3f573d 100644
--- a/src/images.jl
+++ b/src/images.jl
@@ -82,7 +82,7 @@ If a cell range is given, the `size` keyword argument is ignored.
`image::Union{AbstractString, IOBuffer}`: Specifies the image to be inserted. Either:
- a file path (`String`)
- - an `IOBuffer` containing raw image bytes
+ - an `IOBuffer` containing raw image bytes. The buffer is left unchanged (its contents and position are not consumed).
Supported formats (auto-detected): PNG, JPEG, GIF.
@@ -176,12 +176,17 @@ end
# ===========================================================================
add_media!(xf::XLSXFile, path::AbstractString) = _add_media_bytes!(xf, read(path))
-add_media!(xf::XLSXFile, io::IOBuffer) = _add_media_bytes!(xf, take!(io))
+add_media!(xf::XLSXFile, io::IOBuffer) = _add_media_bytes!(xf, take!(copy(io)))
function _add_media_bytes!(xf::XLSXFile, bytes::Vector{UInt8})::String
- ext = detect_image_ext(bytes)
- existing = count(k -> startswith(k, "xl/media/"), keys(xf.binary_data))
- name = "image$(existing + 1)$ext"
+ ext = detect_image_ext(bytes)
+ # The first free number: media names need not be contiguous (a deleted sheet's
+ # images go), and reusing a taken one would overwrite an image still in use.
+ i = 1
+ while haskey(xf.binary_data, "xl/media/image$i$ext") || haskey(xf.files, "xl/media/image$i$ext")
+ i += 1
+ end
+ name = "image$i$ext"
xf.binary_data["xl/media/$name"] = bytes
ext_no_dot = String(lstrip(ext, '.'))
register_content_type!(xf, "[Content_Types].xml";
diff --git a/src/panes.jl b/src/panes.jl
index b1421ab9..1fa1b572 100644
--- a/src/panes.jl
+++ b/src/panes.jl
@@ -104,9 +104,7 @@ end
function _apply_pane!(ws::Worksheet, kind::PaneKind, nrows::Int, ncols::Int)
- if !get_xlsxfile(ws).is_writable
- throw(XLSXError("Cannot apply pane: `XLSXFile` is not writable."))
- end
+ _require_writable(ws, "apply pane")
(nrows < 0 || ncols < 0) && throw(XLSXError("nrows and ncols must be non-negative."))
diff --git a/src/read.jl b/src/read.jl
index b0d782f1..ea980ea6 100644
--- a/src/read.jl
+++ b/src/read.jl
@@ -185,6 +185,7 @@ function check_for_xlsx_file_format(source::IO, label::AbstractString="input")
header = Base.read(source, 8)
reset(source)
+ length(header) < 4 && throw(XLSXError("`$label` is not a valid XLSX file: it is empty or too short."))
if header[1:4] == ZIP_FILE_HEADER # valid Zip file header
return
elseif header == XLS_FILE_HEADER # either an old XLS file or a password protected XLSX file
@@ -485,11 +486,15 @@ The `mode` argument controls how the file is opened. The following modes are all
!!! warning
Using do-block syntax in "rw" mode will overwrite the file you read in with the modified data when the do block ends.
+ If the do block throws an error, the file is not written and `source` is left unchanged.
Care is needed to ensure data are not inadvertantly overwritten, especially if the xlsx file contains any elements
that `XLSX.jl` cannot process (such as charts, pivot tables, etc), but that would otherwise be preserved if not
overwritten. You may avoid this risk by choosing to open files in "rw" mode without using do-block syntax, in which
case it becomes necessary explicitly to write the `XLSXFile` out again, providing the option to write to another file name.
-
+
+ If `source` is an `IO`, its content is replaced in the same way, so it must be writable and seekable
+ (e.g. `IOBuffer(bytes; read=true, write=true)`).
+
!!! note
@@ -576,20 +581,21 @@ function openxlsx(f::F, source::Union{AbstractString,IO};
xf.source = source
end
- try
- f(xf)
-
- finally
- if _write
- if xf.template_type != NotATemplate
- if isa(xf.source, AbstractString)
- ext = xf.template_type == XLTMTemplate ? ".xlsm" : ".xlsx"
- xf.source = splitext(xf.source)[1] * ext
- end
+ # Saved only if `f` returns normally: an error part-way through must not
+ # overwrite `source` with a half-edited workbook.
+ result = f(xf)
+
+ if _write
+ if xf.template_type != NotATemplate
+ if isa(xf.source, AbstractString)
+ ext = xf.template_type == XLTMTemplate ? ".xlsm" : ".xlsx"
+ xf.source = splitext(xf.source)[1] * ext
end
- writexlsx(xf.source, xf, overwrite=true)
end
+ xf.source isa IO && _reset_io_for_save!(xf.source)
+ writexlsx(xf.source, xf, overwrite=true)
end
+ return result
end
"""
@@ -663,12 +669,6 @@ function _strict_to_transitional_node!(node::XML.Node, filename::AbstractString)
else
throw(XLSXError("Unsupported strict OOXML namespace or relationship type: \"$v\" in $filename. Please open an issue at https://github.com/JuliaData/XLSX.jl/issues"))
end
-# elseif k == "Type" && startswith(v, "http://purl.oclc.org/ooxml")
-# if haskey(STRICT_TO_TRANSITIONAL, v)
-# node[k] = STRICT_TO_TRANSITIONAL[v]
-# else
-# throw(XLSXError("Unsupported strict OOXML relationship type: \"$v\" in $filename. Please open an issue at https://github.com/JuliaData/XLSX.jl/issues"))
-# end
end
end
return nothing
@@ -686,15 +686,15 @@ function convert_strict_to_transitional!(xf::XLSXFile, pass::Int)
for filename in keys(xf.files)
haskey(xf.data, filename) || continue # target_sheet: only some files are loaded
should_process = if pass == 1
- !occursin(r"xl/worksheets/sheet\d*\.xml|xl/sharedStrings\.xml", filename)
+ !_is_worksheet_part(filename) && !occursin(r"xl/sharedStrings\.xml", filename)
elseif pass == 2
occursin(r"xl/sharedStrings\.xml", filename)
else # pass == 3
- occursin(r"xl/worksheets/sheet\d*\.xml", filename)
+ _is_worksheet_part(filename)
end
if should_process
- if occursin(r"xl/worksheets/sheet\d*\.xml", filename)
+ if _is_worksheet_part(filename)
# Worksheet data (whether the full raw or the stripped stub) is
# always stored as a plain String. Fix root-element namespace
# attributes with cheap string substitution rather than parsing
@@ -762,6 +762,8 @@ function open_or_read_xlsx(source::Union{IO,AbstractString}, _read::Bool, enable
target_file = if !isnothing(target_sheet) && !_write
wb = get_workbook(xf)
ws = if target_sheet isa Integer
+ 1 <= target_sheet <= length(wb.sheets) ||
+ throw(XLSXError("Sheet $target_sheet not found: $(xf.source) has $(length(wb.sheets)) sheet(s)."))
wb.sheets[target_sheet]
else
s = findfirst(s -> s.name == target_sheet, wb.sheets)
@@ -975,8 +977,9 @@ function parse_defined_name_value(s::String)::Tuple{DefinedNameValueTypes, Any}
defined_value, isabs = SheetCellRange(unquote_sheet(s)), true
elseif is_valid_sheet_cellrange(s)
defined_value, isabs = SheetCellRange(unquote_sheet(s)), false
- elseif startswith(s, '"') && endswith(s, '"')
- inner = String(chop(s, head=1, tail=1))
+ elseif length(s) >= 2 && startswith(s, '"') && endswith(s, '"') &&
+ !occursin('"', replace(chop(s, head=1, tail=1), "\"\"" => "")) # not `"a"&"b"`
+ inner = replace(String(chop(s, head=1, tail=1)), "\"\"" => "\"")
defined_value, isabs = (isempty(inner) ? missing : inner), false
elseif (n = tryparse(Int64, s)) !== nothing
defined_value, isabs = n, false
@@ -985,7 +988,10 @@ function parse_defined_name_value(s::String)::Tuple{DefinedNameValueTypes, Any}
elseif isempty(s)
defined_value, isabs = missing, false
else
- defined_value, isabs = string(s), false
+ # Any other expression (a formula, a whole-column reference, ...) is kept as
+ # formula text with a leading `=`, as Excel's Name Manager shows it, so it
+ # stays distinct from a string constant and is written back unquoted.
+ defined_value, isabs = "=" * s, false
end
return defined_value, isabs
@@ -1026,7 +1032,8 @@ end
@inline internal_xml_file_exists(xl::XLSXFile, filename::String)::Bool = haskey(xl.files, filename)
function internal_xml_file_add!(xl::XLSXFile, filename::String)
- !(endswith(filename, ".xml") || endswith(filename, ".rels")) && throw(XLSXError("Something wrong here!"))
+ !(endswith(filename, ".xml") || endswith(filename, ".rels")) &&
+ throw(XLSXError("Internal error: `$filename` is not an XML part (`.xml` or `.rels`), so can't be registered as one."))
xl.files[filename] = false
nothing
end
@@ -1117,6 +1124,10 @@ function splitNode(xml_str::String, skipnode::String)
return stripped_xml, ""
end
+# A worksheet part. Not only `sheetN.xml`: the part name is the producer's choice
+# (`Sheet1.xml`, `data.xml`), and every stage of reading must agree on it.
+_is_worksheet_part(filename::AbstractString) = occursin(r"^xl/worksheets/[^/]+\.xml$", filename)
+
const BINARY_PREFIXES = ["customxml"] # must be lowercase
is_binary_path(filename) = any(p -> startswith(lowercase(filename), p), BINARY_PREFIXES)
@@ -1156,7 +1167,7 @@ function load_files!(xf::XLSXFile, zip_io::ZipArchives.ZipReader; pass::Int,
filtered_files = Channel{String}(1 << 20) do out
for file in all_files
is_sst = occursin(r"^xl/sharedStrings\.xml$", file)
- is_worksheet = occursin(r"^xl/worksheets/[^/]+\.xml$", file)
+ is_worksheet = _is_worksheet_part(file)
should_process = if pass == 1
!is_sst && !is_worksheet
elseif pass == 2
@@ -1230,13 +1241,15 @@ function load_files!(xf::XLSXFile, zip_io::ZipArchives.ZipReader; pass::Int,
end
end
- # Every write to xf's Dicts is done; the fills now only read them.
+ # Every write to xf's Dicts is done; the fills now only read them. That
+ # includes the style lookups each cell makes: fill them for every cell
+ # format now, since a Dict must not be read on one thread while written on another.
+ isempty(pending) || _prefill_style_buffers!(wb)
fill_tasks = [Threads.@spawn(first_cache_fill!(sheet, parse(raw, XML.LazyNode)))
for (sheet, raw) in pending]
foreach(wait, fill_tasks)
end
- #@sync for _ in 1:MAX_THREADS
nworkers = min(max_threads(), length(ZipArchives.zip_names(zip_io)))
@sync for _ in 1:max(nworkers, 1)
Threads.@spawn begin
@@ -1267,7 +1280,7 @@ function process_file(zip_io::ZipArchives.ZipReader, filename::String, split_she
if filename == "xl/sharedStrings.xml"
node = XML.Element("sst") # placeholder; SST is loaded via sst_load!
raw = xml_str
- elseif occursin(r"xl/worksheets/sheet\d*\.xml", filename)
+ elseif _is_worksheet_part(filename)
node = split_sheets ? first(splitNode(xml_str, "sheetData")) : nothing
raw = xml_str # full worksheet for LazyNode construction
else
@@ -1277,7 +1290,8 @@ function process_file(zip_io::ZipArchives.ZipReader, filename::String, split_she
bin = bytes
end
catch err
- throw(XLSXError("Failed to parse internal XML file `$filename`"))
+ err isa InterruptException && rethrow()
+ throw(XLSXError("Failed to parse internal XML file `$filename`: $(sprint(showerror, err))"))
end
return ReadFile(node, raw, bin, filename)
@@ -1443,45 +1457,7 @@ If `columns` is not given, the algorithm will find the first sequence
of consecutive non-empty cells. A valid `sheet` must be specified
when specifying `columns`.
-Use `first_row` to indicate the first row of the table.
-`first_row=5` will look for a table starting at sheet row `5`.
-If `first_row` is not given, the algorithm will look for the first
-non-empty row in the spreadsheet.
-
-`header` is a `Bool` indicating if the first row is a header.
-If `header=true` and `column_labels` is not specified, the column labels
-for the table will be read from the first row of the table.
-If `header=false` and `column_labels` is not specified, the algorithm
-will generate column labels. The default value is `header=true`.
-
-Use `column_labels` to specify names for the header of the table.
-
-Use `normalizenames=true` to normalize column names to valid Julia identifiers.
-
-Use `missing_strings` to specify strings that should be interpreted as
-`missing` values in the resulting table. `missing_strings` can be a single
-string (e.g. `"N/A"`) or a vector of strings (e.g. `["N/A", "NULL"]`).
-The default value is `missing_strings=nothing`.
-
-Use `infer_eltypes=true` to get `data` as a `Vector{Any}` of typed vectors.
-The default value is `infer_eltypes=true`.
-
-`stop_in_empty_row` is a boolean indicating whether an empty row marks the
-end of the table. If `stop_in_empty_row=false`, the `TableRowIterator` will
-continue to fetch rows until there's no more rows in the Worksheet or range.
-The default behavior is `stop_in_empty_row=true`.
-
-`stop_in_row_function` is a Function that receives a `TableRow` and returns
- a `Bool` indicating if the end of the table was reached.
-
-Example for `stop_in_row_function`:
-
-```julia
-function stop_function(r)
- v = r[:col_label]
- return !ismissing(v) && v == "unwanted value"
-end
-```
+$TABLE_READ_KEYWORDS_DOC
`enable_cache` has no effect on `readtable` and is kept for compatibility.
`readtable` reads the values it needs straight from the worksheet, without
@@ -1491,16 +1467,6 @@ table that shows where it ends), so an invalid cell anywhere else in the sheet
does not raise an error, as it does when the sheet is read through
[`XLSX.readxlsx`](@ref) or [`XLSX.openxlsx`](@ref).
-`keep_empty_rows` determines whether rows where all column values are equal
-to `missing` are kept (`true`) or dropped (`false`) from the resulting table.
-`keep_empty_rows` never affects the *bounds* of the table; the number of
-rows read from a sheet is only affected by `first_row`, `stop_in_empty_row`
-and `stop_in_row_function` (if specified).
-`keep_empty_rows` is only checked once the first and last row of the table
-have been determined, to see whether to keep or drop empty rows between the
-first and the last row.
-The default behavior is `keep_empty_rows=false`.
-
# Example
```julia
diff --git a/src/relationship.jl b/src/relationship.jl
index 67db4332..993041d6 100644
--- a/src/relationship.jl
+++ b/src/relationship.jl
@@ -28,7 +28,8 @@ function Relationship(wb::Workbook, e::XML.Node)::Relationship
end
function parse_relationship_target(prefix::String, target::String)::String
- isempty(prefix) || isempty(target) && throw(XLSXError("Something wrong here!"))
+ (isempty(prefix) || isempty(target)) &&
+ throw(XLSXError("Internal error: can't resolve a relationship target with an empty prefix (`$prefix`) or target (`$target`)."))
if target[begin] == '/'
sizeof(target) <= 1 && throw(XLSXError("Incomplete target path $target."))
return target[nextind(target, begin):end]
@@ -121,7 +122,6 @@ function delete_relationships!(xf::XLSXFile, rel::Relationship)
end
-#is_chartsheet(wb::Workbook, rid::String) = any(r.Id == rid && occursin("chartsheet", r.Type) for r in wb.relationships)
function is_chartsheet(wb::Workbook, sheetname::AbstractString)::Bool
name = unquoteit(sheetname)
xroot = xml_root_element(get_xlsxfile(wb).data["xl/workbook.xml"])
diff --git a/src/scheme-color.jl b/src/scheme-color.jl
new file mode 100644
index 00000000..9055a119
--- /dev/null
+++ b/src/scheme-color.jl
@@ -0,0 +1,105 @@
+# ===========================================================================
+# Theme colors
+#
+# `SchemeColor` is shared by charts, which write it as a DrawingML `a:schemeClr`,
+# and by cell formatting, conditional formats and rich text, which write it as a
+# spreadsheet `` (see `_color_atts` in xlsx-colors.jl).
+# ===========================================================================
+
+const SCHEME_TOKENS = (:bg1, :tx1, :bg2, :tx2, :accent1, :accent2, :accent3,
+ :accent4, :accent5, :accent6, :hlink, :folHlink,
+ :lt1, :dk1, :lt2, :dk2)
+
+const COLOR_TRANSFORMS = (:lumMod, :lumOff, :shade, :tint, :satMod, :alpha)
+
+# bg1/lt1, tx1/dk1, bg2/lt2 and tx2/dk2 name the same slot. The field keeps the
+# token as written so a round trip preserves it; equality and hashing normalize
+# so two spellings of one color compare equal.
+const SCHEME_ALIASES = Dict(:lt1 => :bg1, :dk1 => :tx1, :lt2 => :bg2, :dk2 => :tx2)
+
+"""
+ SchemeColor(token; lumMod = nothing, lumOff = nothing, ...)
+ SchemeColor(token, transforms)
+
+A theme color: a token naming a slot in the workbook's color scheme, plus any
+transforms applied to it. Because it refers to the theme rather than to a fixed
+RGB value, it changes when the workbook's theme does, as Excel's own theme
+colors do.
+
+`token` is one of `:bg1` (background), `:tx1` (text), `:bg2`, `:tx2` (likewise),
+`:accent1` … `:accent6`, `:hlink` (hyperlink), `:folHlink` (visited-hyperlink),
+or the aliases `:lt1` (light), `:dk1` (dark), `:lt2`, `:dk2`.
+
+The `:lt1`, `:dk1`, `:lt2` and `:dk2` aliases are preserved as written, so a
+round trip keeps the spelling the file used, but two spellings of one slot
+compare and hash equal.
+
+Transforms are held in document order, because DrawingML applies them in
+sequence and `lumMod` before `lumOff` is not the same as the reverse. Equality
+is positional for the same reason: two `SchemeColor`s with the same transforms
+in different orders are not equal, because they do not render the same. Values
+are percentages, not the hundredths DrawingML writes: `lumMod = 75` is 75%.
+
+The keyword constructor emits transforms in the conventional order — `lumMod`
+before `lumOff`, shade or tint last. Any other order needs the vector form.
+
+# Cells, conditional formats and rich text
+
+Charts accept any transforms. A cell color, though, can only be a theme color
+made lighter or darker by a `tint`, so outside charts a `SchemeColor` may have:
+
+- no transforms - the theme color itself;
+- `lumMod = m` alone, with `m` from 0 to 100 - darker by `100 - m`%;
+- `lumMod = m, lumOff = 100 - m` - lighter by `100 - m`%.
+
+These are the shades Excel's own palette offers ("Darker 25%" is `lumMod = 75`,
+"Lighter 40%" is `lumMod = 60, lumOff = 40`). An `alpha` transform is ignored,
+because Excel ignores transparency in cell colors. Any other transform is an
+error there; give an RGB color instead.
+
+# Examples
+
+ SchemeColor(:accent1)
+ SchemeColor(:accent1; lumMod = 75) # 104862 against the Office theme
+ SchemeColor(:tx1; lumMod = 65, lumOff = 35) # 595959
+ SchemeColor(:accent2, [:shade => 50.0, :alpha => 80.0])
+"""
+struct SchemeColor
+ token::Symbol
+ transforms::Vector{Pair{Symbol,Float64}}
+
+ function SchemeColor(token::Symbol, transforms::Vector{Pair{Symbol,Float64}})
+ token in SCHEME_TOKENS || throw(XLSXError(
+ "`$token` is not a theme color token. Valid tokens: " *
+ join(SCHEME_TOKENS, ", ") * "."))
+ for (name, _) in transforms
+ name in COLOR_TRANSFORMS || throw(XLSXError(
+ "`$name` is not a color transform. Valid transforms: " *
+ join(COLOR_TRANSFORMS, ", ") * "."))
+ end
+ return new(token, transforms)
+ end
+end
+
+function SchemeColor(token::Symbol; lumMod = nothing, lumOff = nothing,
+ satMod = nothing, shade = nothing, tint = nothing,
+ alpha = nothing)
+ t = Pair{Symbol,Float64}[]
+ isnothing(lumMod) || push!(t, :lumMod => Float64(lumMod))
+ isnothing(lumOff) || push!(t, :lumOff => Float64(lumOff))
+ isnothing(satMod) || push!(t, :satMod => Float64(satMod))
+ isnothing(shade) || push!(t, :shade => Float64(shade))
+ isnothing(tint) || push!(t, :tint => Float64(tint))
+ isnothing(alpha) || push!(t, :alpha => Float64(alpha))
+ return SchemeColor(token, t)
+end
+
+canonical_token(t::Symbol) = get(SCHEME_ALIASES, t, t)
+
+Base.:(==)(a::SchemeColor, b::SchemeColor) =
+ canonical_token(a.token) == canonical_token(b.token) && a.transforms == b.transforms
+
+Base.hash(c::SchemeColor, h::UInt) =
+ hash(c.transforms, hash(canonical_token(c.token), hash(:SchemeColor, h)))
+
+Base.isequal(a::SchemeColor, b::SchemeColor) = a == b
diff --git a/src/sst.jl b/src/sst.jl
index c7f39443..d5bc4dd4 100644
--- a/src/sst.jl
+++ b/src/sst.jl
@@ -16,7 +16,8 @@ function create_new_sst(wb::Workbook, sst::SharedStringTable)
# add Content Type
ctype_root = xml_root_element(xmlroot(get_xlsxfile(wb), "[Content_Types].xml"))
- XML.tag(ctype_root) != "Types" && throw(XLSXError("Something wrong here!"))
+ XML.tag(ctype_root) != "Types" &&
+ throw(XLSXError("Malformed `[Content_Types].xml`: its root element is `$(XML.tag(ctype_root))`, not `Types`."))
override_node = XML.Element("Override";
ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml",
PartName = "/xl/sharedStrings.xml"
@@ -237,7 +238,11 @@ function richTextRunToXML!(io::IO, run::RichTextRun, pfx)
write(props, "<$(pfx)strike/>")
end
if (v = get(atts, :color, nothing)) !== nothing
- write(props, "<$(pfx)color rgb=\"", get_color(v), "\"/>")
+ write(props, "<$(pfx)color")
+ for (k, a) in _color_atts(v)
+ write(props, " ", k, "=\"", a, "\"")
+ end
+ write(props, "/>")
end
if (v = get(atts, :size, nothing)) !== nothing
write(props, "<$(pfx)sz val=\"", string(v), "\"/>") # size read as a float, output rounded to nearest half point.
@@ -323,21 +328,10 @@ function Base.show(io::IO, rts::RichTextString)
# Convert pairs to "key=value"
parts = (":"*string(k) * " => " * sprint(show, v) for (k,v) in sort(collect(run.atts), by=first))
- s = join(parts, ", ")
-
- # Truncate if too long
-# if length(s) > maxlen_atts
-# s = s[1:prevind(s, maxlen_atts)] * "…"
-# end
- s = truncate_len(s, maxlen_atts)
+ s = truncate_len(join(parts, ", "), maxlen_atts)
end
t = truncate_len(run.text, maxlen_txt)
-# t = if length(run.text) > maxlen_txt
-# run.text[1:prevind(run.text, maxlen_txt)] * "…"
-# else
-# run.text
-# end
@printf(io, " %-24s %-66s\n", "\""*t*"\"", "["*s*"]")
end
@@ -350,32 +344,28 @@ end
# Take a substring from a RichTextString as another RichTextString
Base.getindex(rts::RichTextString, i::Int) = getindex(rts, i:i)
+# Indices are those of `rts.text`, as for a String: `r` selects the characters
+# starting at indices in `r`, which must be valid ones.
function Base.getindex(rts::RichTextString, r::UnitRange{Int})::RichTextString
substr = rts.text[r]
new_runs = RichTextRun[]
- global_char_pos = 1
+ pos = 1 # index in `rts.text` of the run's first byte
for run in rts.runs
run_text = run.text
- run_chars = length(run_text)
- run_range = global_char_pos:(global_char_pos + run_chars - 1)
+ run_first = pos
+ run_last = pos - 1 + lastindex(run_text) # start of the run's last character
- overlap_start = max(first(r), first(run_range))
- overlap_end = min(last(r), last(run_range))
+ overlap_start = max(first(r), run_first)
+ overlap_end = min(last(r), run_last)
if overlap_start <= overlap_end
- # Convert global character indices to local character indices
- local_start = overlap_start - global_char_pos + 1
- local_end = overlap_end - global_char_pos + 1
-
- # Slice using character indexing
- sliced_text = run_text[local_start:local_end]
-
+ sliced_text = run_text[(overlap_start - pos + 1):(overlap_end - pos + 1)]
push!(new_runs, RichTextRun(sliced_text, run.atts))
end
- global_char_pos += run_chars
+ pos += ncodeunits(run_text)
end
return RichTextString(substr, new_runs)
@@ -398,7 +388,7 @@ function Base.:(==)(r1::RichTextRun, r2::RichTextRun)
v1 = a1[k]
v2 = a2[k]
if k === :color
- get_color(v1) == get_color(v2) || return false
+ _cell_color_key(v1) == _cell_color_key(v2) || return false
else
v1 == v2 || return false
end
@@ -416,13 +406,15 @@ function Base.hash(r::RichTextRun, h::UInt)
return hash(0xdebdceef, h) # any constant
end
- # Order‑insensitive hash over attributes
- # (color values canonicalized via get_color)
+ # Order‑insensitive hash over attributes, as `==` compares them: each pair is
+ # hashed alone and the results combined commutatively, so the Dict's iteration
+ # order cannot matter. (color values canonicalized via _cell_color_key)
+ acc = zero(UInt)
for (k, v) in atts
- v′ = (k === :color ? get_color(v) : v)
- h = hash((k, v′), h)
+ v′ = (k === :color ? _cell_color_key(v) : v)
+ acc ⊻= hash((k, v′))
end
- return h
+ return hash(acc, h)
end
Base.length(run::RichTextRun) = length(run.text)
function Base.show(io::IO, run::RichTextRun)
@@ -434,21 +426,10 @@ function Base.show(io::IO, run::RichTextRun)
s = " "
else
parts = (":"*string(k) * " => " * sprint(show, v) for (k,v) in sort(collect(run.atts), by=first))
- s = join(parts, ", ")
-
- # Truncate if too long
-# if length(s) > maxlen_atts
-# s = s[1:prevind(s, maxlen_atts)] * "…"
-# end
- s = truncate_len(s, maxlen_atts)
- end
+ s = truncate_len(join(parts, ", "), maxlen_atts)
+ end
- t = truncate_len(run.text, maxlen_txt)
-# t = if length(run.text) > maxlen_txt
-# run.text[1:prevind(run.text, maxlen_txt)] * "…"
-# else
-# run.text
-# end
+ t = truncate_len(run.text, maxlen_txt)
print(io,"RichTextRun (","\""*t*"\" [", s,"])")
end
@@ -572,6 +553,7 @@ getRichTextString(xl::XLSXFile, sheetcell::String) = process_get_sheetcell(getRi
getRichTextString(ws::Worksheet, row::Integer, col::Integer) = getRichTextString(ws, CellRef(row, col))
function getRichTextString(s::Worksheet, c::CellRef)::Union{RichTextString, Nothing}
cell = getcell(s, c)
+ cell isa EmptyCell && return nothing
cell.datatype == CT_STRING || return nothing
sst_load!(get_workbook(s))
uss = get_sst(get_workbook(s)).shared_strings[reinterpret(Int64, cell.value)+1]
@@ -614,18 +596,21 @@ function getRichTextString(wb::Workbook, xml_string::String)::Union{RichTextStri
bold = italic = strike = under = false
size_val = color_val = name_val = vertAlign_val = nothing
+ # `` and `` switch a property off.
+ flag_on(c) = !(get(something(XML.attributes(c), Dict{String,String}()), "val", "1") in ("0", "false", "none"))
for c in XML.children(rpr_child)
ln = localname(c)
if ln == "b"
- bold = true
+ bold = flag_on(c)
elseif ln == "i"
- italic = true
+ italic = flag_on(c)
elseif ln == "strike"
- strike = true
+ strike = flag_on(c)
elseif ln == "u"
- under = true
+ under = flag_on(c)
elseif ln == "sz" && isnothing(size_val)
- size_val = parse(Int, XML.attributes(c)["val"])
+ sz = parse(Float64, XML.attributes(c)["val"]) # half points are common (10.5)
+ size_val = isinteger(sz) ? Int(sz) : sz
elseif ln == "color" && isnothing(color_val)
color_val = resolveColor(wb, XML.attributes(c))
elseif ln == "rFont" && isnothing(name_val)
diff --git a/src/stream.jl b/src/stream.jl
index 586c6c8d..dd53b28e 100644
--- a/src/stream.jl
+++ b/src/stream.jl
@@ -202,6 +202,17 @@ end
nothing
end
+# `rows_in_cache` is left unsorted while rows are added (sorting on every add is
+# costly) and `dirty` is set instead. Call this before anything relies on its order.
+function ensure_sorted!(wc::WorksheetCache)
+ if wc.dirty
+ sort!(wc.rows_in_cache)
+ wc.row_index = Dict{Int, Int}(wc.rows_in_cache[i] => i for i in eachindex(wc.rows_in_cache))
+ wc.dirty = false
+ end
+ return wc
+end
+
#
# WorksheetCache iterator
#
@@ -220,12 +231,7 @@ function Base.iterate(ws_cache::WorksheetCache, state::Union{Nothing, WorksheetC
isnothing(state) && (state=WorksheetCacheIteratorState(0))
- # the sorting operation is very costly when adding row and only needed if we use the row iterator
- if ws_cache.dirty
- sort!(ws_cache.rows_in_cache)
- ws_cache.row_index = Dict{Int, Int}(ws_cache.rows_in_cache[i] => i for i in 1:length(ws_cache.rows_in_cache))
- ws_cache.dirty = false
- end
+ ensure_sorted!(ws_cache)
# read from cache
if state.row_from_last_iteration == 0 && !isempty(ws_cache.rows_in_cache)
@@ -487,6 +493,14 @@ function first_cache_fill!(ws::Worksheet, lznode::XML.LazyNode)
ws.sst_count = sst_total
ws.unhandled_attributes = isempty(unhandled_attributes) ? nothing : unhandled_attributes
+ # Update next_formula_id from this sheet's own formulas — all of them are in
+ # `local_formulas`, so there is no need to scan the whole workbook's under the lock.
+ max_id = -1
+ for f in values(local_formulas)
+ f isa ReferencedFormula && (max_id = max(max_id, f.id))
+ end
+ max_id >= ws.next_formula_id && (ws.next_formula_id = max_id + 1)
+
# Merge local formulas into workbook dict under single lock
if !isempty(local_formulas)
lock(wb.formulas_lock) do
@@ -494,21 +508,6 @@ function first_cache_fill!(ws::Worksheet, lznode::XML.LazyNode)
end
end
- # Update next_formula_id from merged formulas
- lock(wb.formulas_lock) do
- isempty(wb.formulas) && return
- ws_name = ws.name
- max_id = -1
- for (ref, f) in wb.formulas
- if ref.sheet == ws_name && f isa ReferencedFormula
- max_id = max(max_id, f.id)
- end
- end
- if max_id >= ws.next_formula_id
- ws.next_formula_id = max_id + 1
- end
- end
-
ws.cache.is_full = true
end
diff --git a/src/styles.jl b/src/styles.jl
index cc5755ea..85e4f598 100644
--- a/src/styles.jl
+++ b/src/styles.jl
@@ -160,14 +160,23 @@ function styles_add_numFmt(wb::Workbook, format_code::AbstractString)::Integer
numfmts = numfmts[1]
end
- existing_numFmt_elements_count = length(xml_elements(numfmts))
- fmt_code = existing_numFmt_elements_count + PREDEFINED_NUMFMT_COUNT
+ # Reuse an identical format. Ids are read from the file, not derived from
+ # positions: a file's custom ids need not run contiguously from 164.
+ existing = xml_elements(numfmts)
+ max_id = PREDEFINED_NUMFMT_COUNT - 1
+ for n in existing
+ haskey(n, "numFmtId") || continue
+ id = parse(Int, n["numFmtId"])
+ haskey(n, "formatCode") && n["formatCode"] == format_code && return id
+ max_id = max(max_id, id)
+ end
+ fmt_code = max_id + 1
new_fmt = XML.Element("numFmt";
numFmtId=string(fmt_code),
formatCode=format_code
)
push!(numfmts, new_fmt)
- numfmts["count"] = string(existing_numFmt_elements_count + 1)
+ numfmts["count"] = string(length(existing) + 1)
wb.numFmt_cache !== nothing && (wb.numFmt_cache[fmt_code] = format_code)
return fmt_code
end
@@ -245,8 +254,25 @@ end
styles_is_datetime(wb::Workbook, fmt::CellDataFormat) = styles_is_datetime(wb, Int(fmt.id))
+# Fill `buffer_styles_is_datetime` and `buffer_styles_is_float` for every cell
+# format, so that cells read on several threads at once only ever read them.
+function _prefill_style_buffers!(wb::Workbook)
+ has_relationship_by_type(wb, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles") || return nothing
+ for index in 0:length(get_cellXfs_nodes(wb))-1
+ try
+ styles_is_datetime(wb, index)
+ styles_is_float(wb, index)
+ catch e
+ # A malformed format that no cell may use: leave it to fail, as before,
+ # only if a cell reads it.
+ e isa XLSXError || rethrow()
+ end
+ end
+ return nothing
+end
+
function styles_is_datetime(wb::Workbook, index::AbstractString)
- isempty(index) && throw(XLSXError("Something wrong here!"))
+ isempty(index) && throw(XLSXError("Internal error: empty style index given to `styles_is_datetime`."))
styles_is_datetime(wb, parse(Int, index))
end
@@ -284,7 +310,7 @@ function styles_is_float(wb::Workbook, index::Integer)::Bool
end
function styles_is_float(wb::Workbook, index::AbstractString)
- isempty(index) && throw(XLSXError("Something wrong here!"))
+ isempty(index) && throw(XLSXError("Internal error: empty style index given to `styles_is_float`."))
styles_is_float(wb, parse(Int, index))
end
diff --git a/src/table.jl b/src/table.jl
index 8524d0a2..c6629589 100644
--- a/src/table.jl
+++ b/src/table.jl
@@ -670,31 +670,10 @@ const _TABLE_KWARGS = """
"""
-"""
- gettable(
- sheet,
- [columns];
- [first_row],
- [column_labels],
- [header],
- [infer_eltypes],
- [stop_in_empty_row],
- [stop_in_row_function],
- [keep_empty_rows],
- [normalizenames],
- [missing_strings]
- ) -> DataTable
-
-Returns data from a spreadsheet as a struct `XLSX.DataTable` which
-can be passed directly to any function that accepts `Tables.jl` data.
-(e.g. `DataFrame` from package `DataFrames.jl`).
-
-Use `columns` argument to specify which columns to get.
-For example, `"B:D"` will select columns `B`, `C` and `D`.
-If `columns` is not given, the algorithm will find the first sequence
-of consecutive non-empty cells.
-
-Use `first_row` to indicate the first row from the table.
+# Keyword documentation shared by `gettable` and `readtable`, which find and read a
+# table the same way. Interpolated into both docstrings so the two can't drift apart.
+const TABLE_READ_KEYWORDS_DOC = """
+Use `first_row` to indicate the first row of the table.
`first_row=5` will look for a table starting at sheet row `5`.
If `first_row` is not given, the algorithm will look for the first
non-empty row in the spreadsheet.
@@ -705,24 +684,26 @@ for the table will be read from the first row of the table.
If `header=false` and `column_labels` is not specified, the algorithm
will generate column labels. The default value is `header=true`.
-Use `column_labels` as a vector of symbols to specify names for the header of the table.
+Use `column_labels` to specify names for the header of the table, as a vector
+of `Symbol`s or strings.
Use `normalizenames=true` to normalize column names to valid Julia identifiers.
-Use `missing_strings` to specify strings that should be interpreted as `missing`
-values in the resulting table. `missing_strings` can be a single string or a
-vector of strings. The default value is `missing_strings=nothing`.
+Use `missing_strings` to specify strings that should be interpreted as
+`missing` values in the resulting table. `missing_strings` can be a single
+string (e.g. `"N/A"`) or a vector of strings (e.g. `["N/A", "NULL"]`).
+The default value is `missing_strings=nothing`.
Use `infer_eltypes=true` to get `data` as a `Vector{Any}` of typed vectors.
The default value is `infer_eltypes=true`.
-`stop_in_empty_row` is a boolean indicating whether an empty row marks the end of the table.
-If `stop_in_empty_row=false`, the `TableRowIterator` will continue to fetch rows until there's no more rows in the Worksheet.
+`stop_in_empty_row` is a boolean indicating whether an empty row marks the
+end of the table. If `stop_in_empty_row=false`, the `TableRowIterator` will
+continue to fetch rows until there's no more rows in the Worksheet or range.
The default behavior is `stop_in_empty_row=true`.
-`stop_in_row_function` is a Function that receives a `TableRow` and returns a `Bool` indicating if the end of the table was reached.
-
-# Example for `stop_in_row_function`
+`stop_in_row_function` is a Function that receives a `TableRow` and returns
+a `Bool` indicating if the end of the table was reached. For example:
```julia
function stop_function(r)
@@ -731,9 +712,41 @@ function stop_function(r)
end
```
-`keep_empty_rows` determines whether rows where all column values are equal to `missing` are kept (`true`) or dropped (`false`) from the resulting table.
-`keep_empty_rows` never affects the *bounds* of the table; the number of rows read from a sheet is only affected by `first_row`, `stop_in_empty_row` and `stop_in_row_function` (if specified).
-`keep_empty_rows` is only checked once the first and last row of the table have been determined, to see whether to keep or drop empty rows between the first and the last row.
+`keep_empty_rows` determines whether rows where all column values are equal
+to `missing` are kept (`true`) or dropped (`false`) from the resulting table.
+`keep_empty_rows` never affects the *bounds* of the table; the number of
+rows read from a sheet is only affected by `first_row`, `stop_in_empty_row`
+and `stop_in_row_function` (if specified).
+`keep_empty_rows` is only checked once the first and last row of the table
+have been determined, to see whether to keep or drop empty rows between the
+first and the last row.
+The default behavior is `keep_empty_rows=false`."""
+
+"""
+ gettable(
+ sheet,
+ [columns];
+ [first_row],
+ [column_labels],
+ [header],
+ [infer_eltypes],
+ [stop_in_empty_row],
+ [stop_in_row_function],
+ [keep_empty_rows],
+ [normalizenames],
+ [missing_strings]
+ ) -> DataTable
+
+Returns data from a spreadsheet as a struct `XLSX.DataTable` which
+can be passed directly to any function that accepts `Tables.jl` data.
+(e.g. `DataFrame` from package `DataFrames.jl`).
+
+Use `columns` argument to specify which columns to get.
+For example, `"B:D"` will select columns `B`, `C` and `D`.
+If `columns` is not given, the algorithm will find the first sequence
+of consecutive non-empty cells.
+
+$TABLE_READ_KEYWORDS_DOC
# Example
@@ -1535,29 +1548,29 @@ function addtable!(sheet::Worksheet, ref::CellRange;
has_totals_row::Bool=false)::Table
xf = get_xlsxfile(sheet)
- !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable. Open Excel file with `mode=\"rw\"` instead"))
+ _require_writable(xf, "add a table")
wb = get_workbook(sheet)
- # --- name uniqueness (unchanged) ---
+ # --- name uniqueness: Excel compares table names case-insensitively ---
existing_names = Set{String}()
for ws in wb.sheets
is_chartsheet(wb, ws.name) && continue
for t in tables(ws)
- push!(existing_names, t.name)
+ push!(existing_names, uppercase(t.name))
end
end
if name == ""
i = 1
candidate = "Table$i"
- while candidate ∈ existing_names ||
+ while uppercase(candidate) ∈ existing_names ||
is_workbook_defined_name(wb, candidate) || is_worksheet_defined_name(sheet, candidate)
i += 1
candidate = "Table$i"
end
name = candidate
else
- name ∈ existing_names && throw(XLSXError("Table name `$name` is already in use."))
+ uppercase(name) ∈ existing_names && throw(XLSXError("Table name `$name` is already in use."))
(is_workbook_defined_name(wb, name) || is_worksheet_defined_name(sheet, name)) &&
throw(XLSXError("Table name `$name` collides with an existing defined name."))
end
@@ -1580,7 +1593,7 @@ function addtable!(sheet::Worksheet, ref::CellRange;
throw(XLSXError("Table header cell $(CellRef(header_row, c)) is empty; every column needs a header value before calling `addtable!`."))
push!(columns, string(v))
end
- length(unique(columns)) != length(columns) &&
+ length(unique(uppercase.(columns))) != length(columns) && # case-insensitive, as in Excel
throw(XLSXError("Table header row contains duplicate column names."))
# --- totals row: last row of `ref`, when requested, which must be empty —
@@ -1664,6 +1677,11 @@ function _check_table_overlap(sheet::Worksheet, ref::CellRange, name::AbstractSt
return nothing
end
+# `Table[Column]` as a formula writes it: `[`, `]`, `#` and `'` in a column name are
+# escaped with `'`, so `Price [USD]` gives `Sales[Price '[USD']]`.
+_structured_ref(table::AbstractString, column::AbstractString) =
+ table * "[" * replace(column, r"(['\[\]#])" => s"'\1") * "]"
+
"""
deletetable!(t::Table)
deletetable!(sheet::Worksheet, name::AbstractString)
@@ -1707,7 +1725,7 @@ function _deletetable!(t::Table)
sheet = t.sheet
name = t.name
xf = get_xlsxfile(sheet)
- !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable. Open Excel file with `mode=\"rw\"` instead"))
+ _require_writable(xf, "delete a table")
t=table(sheet, name) # throws KeyError if not found — fail fast
@@ -1741,7 +1759,7 @@ function _deletetable!(t::Table)
for (j, other) in enumerate(t.columns)
other_letter = encode_column_number(col0 + j - 1)
other_range = "$(qsheet)!\$$(other_letter)\$$(first_data):\$$(other_letter)\$$(last_data)"
- new_f = replace(new_f, "$(name)[$(other)]" => other_range)
+ new_f = replace(new_f, _structured_ref(name, other) => other_range)
end
setFormula(sheet, cell_ref; val=lstrip(new_f, '='))
@@ -1882,7 +1900,7 @@ function _settotals!(t::Table, settings::Pair...)
sheet = t.sheet
name = t.name
xf = get_xlsxfile(sheet)
- !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable. Open Excel file with `mode=\"rw\"` instead"))
+ _require_writable(xf, "set a totals row")
sheet = t.sheet
name = t.name
@@ -1940,7 +1958,7 @@ function _settotals!(t::Table, settings::Pair...)
throw(XLSXError("Unknown totals function `:$value`. Supported: $(join(sort(string.(keys(TOTALS_ROW_FUNCTIONS))), ", ")), or pass `(:custom, \"formula\")` for a custom function."))
func_name, subtotal_code = TOTALS_ROW_FUNCTIONS[value]
col_node["totalsRowFunction"] = func_name
- setFormula(sheet, cell_ref; val="SUBTOTAL($subtotal_code,$(name)[$col_name])")
+ setFormula(sheet, cell_ref; val="SUBTOTAL($subtotal_code,$(_structured_ref(name, col_name)))")
elseif value isa Tuple{Symbol,S} where {S<:AbstractString}
fn, formula_str = value
@@ -2074,9 +2092,8 @@ function _removetotals!(t::Table)
sheet = t.sheet
name = t.name
xf = get_xlsxfile(sheet)
- !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable. Open Excel file with `mode=\"rw\"` instead"))
+ _require_writable(xf, "remove a totals row")
-# t = table(sheet, name)
t.has_totals_row || return t
table_path = _table_part_path(sheet, name)
@@ -2158,12 +2175,11 @@ function gettable(t::Table;
missing_set = missing_strings === nothing ? nothing :
missing_strings isa AbstractString ? Set([missing_strings]) : Set(missing_strings)
- row_range = _first_data_row(t):_last_data_row(t)
- col0 = _col_start(t)
+ m = _table_data_matrix(t)
data = Vector{Any}(undef, length(t.columns))
for i in eachindex(t.columns)
- col = Any[getdata(t.sheet, CellRef(r, col0 + i - 1)) for r in row_range]
+ col = Vector{Any}(m[:, i])
if !isnothing(missing_set)
col = Any[(x isa AbstractString && x in missing_set) ? missing : x for x in col]
end
@@ -2211,12 +2227,10 @@ function _appendtable!(t::Table, data; check_empty::Bool=true, kw... )
sheet = t.sheet
name = t.name
xf = get_xlsxfile(sheet)
- !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable. Open Excel file with `mode=\"rw\"` instead"))
+ _require_writable(xf, "append to a table")
-# t = table(sheet, name)
ncols = length(t.columns)
rows = _normalize_append_rows(data, t, name)
-# rows = _normalize_append_rows(data, ncols, name)
n = length(rows)
n == 0 && return t
@@ -2302,12 +2316,9 @@ function _normalize_append_rows(data, t::Table, name::AbstractString)
else
# Some Tables.jl sources expose names via columnnames without
# implementing schema (e.g. anything relying on the column-access
- # interface alone). Try that before giving up and going positional.
- try
- Tables.columnnames(Tables.columns(data))
- catch
- nothing
- end
+ # interface alone). Ask a source that offers column access before
+ # giving up and going positional; asking any other is an error.
+ Tables.columnaccess(data) ? Tables.columnnames(Tables.columns(data)) : nothing
end
if isnothing(src_names)
# Unnamed Tables.jl source — fall back to positional matching.
diff --git a/src/tables_interface.jl b/src/tables_interface.jl
index d62a5d96..45fc7c63 100644
--- a/src/tables_interface.jl
+++ b/src/tables_interface.jl
@@ -157,6 +157,16 @@ _first_data_row(t::Table) = t.ref.start.row_number + (t.has_header_row ? 1 : 0)
_last_data_row(t::Table) = t.ref.stop.row_number - (t.has_totals_row ? 1 : 0)
_col_start(t::Table) = column_number(t.ref.start)
+# The table's data rows as a rows × columns matrix, read in one pass over the
+# sheet (cell by cell, an uncached sheet would be re-read for every cell).
+function _table_data_matrix(t::Table)::Matrix{Any}
+ top, bottom = _first_data_row(t), _last_data_row(t)
+ ncols = length(t.columns)
+ bottom < top && return Matrix{Any}(undef, 0, ncols)
+ col0 = _col_start(t)
+ return getdata(t.sheet, CellRange(CellRef(top, col0), CellRef(bottom, col0 + ncols - 1)))
+end
+
# Tables.jl interface — mirrors the existing TableRowIterator/TableRow pattern
Tables.istable(::Type{<:Table}) = true
@@ -182,8 +192,11 @@ Tables.schema(it::XLSXTableRowIterator) = Tables.schema(it.table)
Tables.columnnames(t::Table) = Symbol.(_resolve(t).columns)
Tables.columnnames(tr::XLSXTableRow) = Symbol.(tr.table.columns)
-Tables.getcolumn(tr::XLSXTableRow, nm::Symbol) =
- getdata(tr.table.sheet, CellRef(tr.row_number, _col_start(tr.table) + findfirst(==(nm), Symbol.(tr.table.columns)) - 1))
+function Tables.getcolumn(tr::XLSXTableRow, nm::Symbol)
+ i = findfirst(==(nm), Symbol.(tr.table.columns))
+ isnothing(i) && throw(XLSXError("Table `$(tr.table.name)` has no column `$nm`. Its columns are: " * join(tr.table.columns, ", ") * "."))
+ return getdata(tr.table.sheet, CellRef(tr.row_number, _col_start(tr.table) + i - 1))
+end
Tables.getcolumn(tr::XLSXTableRow, i::Int) =
getdata(tr.table.sheet, CellRef(tr.row_number, _col_start(tr.table) + i - 1))
@@ -197,10 +210,9 @@ function Base.iterate(it::XLSXTableRowIterator, state::Int = _first_data_row(it.
end
function Tables.columns(t::Table)
t = _resolve(t)
- row_range = _first_data_row(t):_last_data_row(t)
- col0 = _col_start(t)
+ m = _table_data_matrix(t)
NamedTuple(
- Symbol(name) => typed_column(Any[getdata(t.sheet, CellRef(r, col0 + i - 1)) for r in row_range])
+ Symbol(name) => typed_column(Vector{Any}(m[:, i]))
for (i, name) in enumerate(t.columns)
)
end
diff --git a/src/types.jl b/src/types.jl
index f1354079..77edfb75 100644
--- a/src/types.jl
+++ b/src/types.jl
@@ -116,7 +116,7 @@ mutable struct CellFont
end
end
-# A border postion element (e.g. `top` or `left`) has a style attribute, but `color` is a child element.
+# A border position element (e.g. `top` or `left`) has a style attribute, but `color` is a child element.
# The `color` element has an attribute (e.g. `rgb`) that defines the color of the border.
# These are both stored in the `border` field of `CellBorder`. The key for the color element
# will vary depending on how the color is defined (e.g. `rgb`, `indexed`, `auto`, etc.).
@@ -163,6 +163,114 @@ mutable struct CellAlignment # Alignment is part of the cell style `xf` so doesn
end
end
+# Anything that can be given as a color in cell formatting, conditional formats and rich text.
+const CellColor = Union{AbstractString,Symbol,Colors.Colorant,SchemeColor}
+
+# The value of a border side keyword, e.g. `["style" => "thin", "color" => "red"]`.
+const BorderKw = Union{Nothing,Vector{<:Pair{String}}}
+
+# The keywords of a formatting setter (`setFont`, `setFill`, ...), gathered once at
+# the public entry (see `define_percell_spec_methods`). The address methods forward
+# their keywords through many helpers, each compiled again for every new set of
+# keywords; forwarding a single `spec` lets them compile once. Each is made by
+# `S(caller; kw...)`, which takes the setter's keywords with their usual types and
+# defaults, and names `caller` in the error for an unrecognized keyword.
+struct FontSpec
+ bold::Union{Nothing,Bool}
+ italic::Union{Nothing,Bool}
+ under::Union{Nothing,String}
+ strike::Union{Nothing,Bool}
+ size::Union{Nothing,Real}
+ color::Union{Nothing,CellColor}
+ name::Union{Nothing,String}
+ vertAlign::Union{Nothing,String}
+end
+function FontSpec(caller::Symbol;
+ bold::Union{Nothing,Bool}=nothing,
+ italic::Union{Nothing,Bool}=nothing,
+ under::Union{Nothing,String}=nothing,
+ strike::Union{Nothing,Bool}=nothing,
+ size::Union{Nothing,Real}=nothing,
+ color::Union{Nothing,CellColor}=nothing,
+ name::Union{Nothing,String}=nothing,
+ vertAlign::Union{Nothing,String}=nothing,
+ kw...
+)
+ _check_keywords(caller, FontSpec, kw)
+ return FontSpec(bold, italic, under, strike, size, color, name, vertAlign)
+end
+
+struct FillSpec
+ pattern::Union{Nothing,String}
+ fgColor::Union{Nothing,CellColor}
+ bgColor::Union{Nothing,CellColor}
+end
+function FillSpec(caller::Symbol;
+ pattern::Union{Nothing,String}=nothing,
+ fgColor::Union{Nothing,CellColor}=nothing,
+ bgColor::Union{Nothing,CellColor}=nothing,
+ kw...
+)
+ _check_keywords(caller, FillSpec, kw)
+ return FillSpec(pattern, fgColor, bgColor)
+end
+
+struct AlignmentSpec
+ horizontal::Union{Nothing,String}
+ vertical::Union{Nothing,String}
+ wrapText::Union{Nothing,Bool}
+ shrink::Union{Nothing,Bool}
+ indent::Union{Nothing,Int}
+ rotation::Union{Nothing,Int}
+end
+function AlignmentSpec(caller::Symbol;
+ horizontal::Union{Nothing,String}=nothing,
+ vertical::Union{Nothing,String}=nothing,
+ wrapText::Union{Nothing,Bool}=nothing,
+ shrink::Union{Nothing,Bool}=nothing,
+ indent::Union{Nothing,Int}=nothing,
+ rotation::Union{Nothing,Int}=nothing,
+ kw...
+)
+ _check_keywords(caller, AlignmentSpec, kw)
+ return AlignmentSpec(horizontal, vertical, wrapText, shrink, indent, rotation)
+end
+
+struct FormatSpec
+ format::Union{Nothing,String}
+end
+function FormatSpec(caller::Symbol; format::Union{Nothing,String}=nothing, kw...)
+ _check_keywords(caller, FormatSpec, kw)
+ return FormatSpec(format)
+end
+
+struct BorderSpec
+ outside::BorderKw
+ allsides::BorderKw
+ left::BorderKw
+ right::BorderKw
+ top::BorderKw
+ bottom::BorderKw
+ diagonal::BorderKw
+end
+function BorderSpec(caller::Symbol;
+ outside::BorderKw=nothing, allsides::BorderKw=nothing,
+ left::BorderKw=nothing, right::BorderKw=nothing,
+ top::BorderKw=nothing, bottom::BorderKw=nothing,
+ diagonal::BorderKw=nothing,
+ kw...
+)
+ _check_keywords(caller, BorderSpec, kw)
+ return BorderSpec(outside, allsides, left, right, top, bottom, diagonal)
+end
+
+function _check_keywords(caller::Symbol, ::Type{S}, kw) where {S}
+ isempty(kw) && return nothing
+ bad = join(("`$k`" for k in keys(kw)), ", ")
+ valid = join(("`$k`" for k in fieldnames(S)), ", ")
+ throw(XLSXError("Unrecognized keyword argument$(length(kw) == 1 ? "" : "s") $bad for `$caller`. Valid keywords are $valid."))
+end
+
@enum CellValueType::UInt8 begin
CT_EMPTY = 0
CT_STRING = 1
@@ -320,6 +428,31 @@ struct RowRange <: ContiguousCellRange
end
end
+"""
+ SheetCellRef(ref::AbstractString)
+ SheetCellRef(sheet::AbstractString, cellref::CellRef)
+
+A cell on a named sheet: a [`CellRef`](@ref) together with the name of its sheet, in
+the fields `sheet` and `cellref`.
+
+`SheetCellRef("Sheet1!B2")` refers to cell `B2` on sheet `Sheet1`. A sheet name
+containing spaces or other special characters is quoted, as in Excel:
+`SheetCellRef("'Sheet 1'!B2")`, which `SheetCellRef("Sheet 1", CellRef("B2"))` also
+gives.
+
+# Examples
+
+```julia
+julia> r = XLSX.SheetCellRef("Sheet1!B2")
+Sheet1!B2
+
+julia> r.sheet
+"Sheet1"
+
+julia> r.cellref
+B2
+```
+"""
struct SheetCellRef
sheet::String
cellref::CellRef
@@ -398,7 +531,7 @@ end
mutable struct WorksheetCache{I<:SheetRowIterator} <: SheetRowIterator
is_full::Bool # false until iterator runs to completion
cells::CellCache # SheetRowNumber -> Dict{column_number, Cell}
- rows_in_cache::Vector{Int} # ordered vector with row numbers that are stored in cache
+ rows_in_cache::Vector{Int} # row numbers stored in cache; sorted only when `dirty` is false (see `ensure_sorted!`)
row_ht::Dict{Int, Union{Float64, Nothing}} # Maps a row number to a row height
row_index::Dict{Int, Int} # maps a row number to the index of the row number in rows_in_cache
stream_iterator::I
@@ -410,6 +543,17 @@ Base.isempty(wc::WorksheetCache) = isempty(wc.rows_in_cache)
# Excel Tables
+"""
+ TableStyleInfo
+
+The style of an Excel [`XLSX.Table`](@ref), as the table's `style` field holds it: the
+table style's `name` (e.g. `"TableStyleMedium2"`, or `nothing` for none) and whether
+the first column, last column, row stripes and column stripes are emphasized, in the
+fields `show_first_column`, `show_last_column`, `show_row_stripes` and
+`show_column_stripes`.
+
+Give a new table a style with the `style` keyword of [`XLSX.addtable!`](@ref).
+"""
struct TableStyleInfo
name::Union{String,Nothing}
show_first_column::Bool
@@ -531,8 +675,9 @@ Valid attributes that can be defined in `pairs` are:
- `:italic` - set `:italic => true` for this run to be italicised. Omit otherwise.
- `:under` - set `:under => true` to underline this run. Omit otherwise.
- `:strike` - set `:strike => true` to apply strikethrough to this run. Omit otherwise.
-- `:vertAlign` - whether this run is `subscript` or `superscript` (eg `:vertAling => "superscript"`). Omit otherwise.
-- `:color` - the color of this run (eg `:color => "red"`).
+- `:vertAlign` - whether this run is `subscript` or `superscript` (eg `:vertAlign => "superscript"`). Omit otherwise.
+- `:color` - the color of this run (eg `:color => "red"`), in any of the forms listed in
+ [Specifying colors](@ref). An invalid color is an error here, not when the run is written.
- `:size` - the size of the font to be used (eg `:size => 12`).
- `:name` - the name of the font to be used (e.g. `:name => "Arial"`).
@@ -585,6 +730,7 @@ struct RichTextRun
for x in keys(atts)
in(x, ValidRichTextAttributes) || throw(XLSXError("Unknown Rich Text Attribute: ':$x'. Valid attributes are :bold, :italic, :under, :strike, :vertAlign, :color, :size, :name."))
end
+ haskey(atts, :color) && _check_color(atts[:color])
new(text, atts)
end
end
@@ -669,6 +815,13 @@ end
# The `xl/workbook.xml` will need to be updated using the Workbook_names and
# worksheet_names from here when a workbook is saved in case any new defined
# names have been created.
+"""
+ Workbook
+
+The workbook part of an [`XLSXFile`](@ref): its sheets, shared strings, styles and
+defined names, parsed from `xl/workbook.xml`. An `XLSXFile` holds one, as
+`xf.workbook`. Its fields are internal: work through the file and its worksheets.
+"""
mutable struct Workbook
package::MSOfficePackage
sheets::Vector{Worksheet}
@@ -916,12 +1069,4 @@ struct FileArray <: AbstractVector{UInt8}
filename::String
offset::Int64
len::Int64
-end
-
-#=
-mutable struct Locked{T}
- value::T
- lock::ReentrantLock
- Locked(x::T) where {T} = new{T}(x, ReentrantLock())
-end
-=#
\ No newline at end of file
+end
\ No newline at end of file
diff --git a/src/workbook.jl b/src/workbook.jl
index 520a3c70..43d91342 100644
--- a/src/workbook.jl
+++ b/src/workbook.jl
@@ -32,6 +32,21 @@ Writable XLSXFile instances are opened with `XLSX.open_xlsx_template` method.
=#
is_writable(xl::XLSXFile) = xl.is_writable
+# Preconditions shared by every function that edits a file or reads its cell cache.
+# `x` is an `XLSXFile`, `Workbook` or `Worksheet`; `action` completes "Cannot ...",
+# e.g. `_require_writable(ws, "set font")`.
+function _require_writable(x, action::AbstractString)
+ is_writable(get_xlsxfile(x)) ||
+ throw(XLSXError("Cannot $action: the XLSXFile is not writable. Open it with `mode=\"rw\"`."))
+ return nothing
+end
+
+function _require_cache(x, action::AbstractString)
+ is_cache_enabled(get_xlsxfile(x)) ||
+ throw(XLSXError("Cannot $action: the XLSXFile's cell cache is not enabled. Open it with `enable_cache=true`."))
+ return nothing
+end
+
"""
sheetnames(xl::XLSXFile)
sheetnames(wb::Workbook)
@@ -82,7 +97,11 @@ function getsheet(wb::Workbook, sheetname::String)::Worksheet
throw(XLSXError("$(get_xlsxfile(wb).source) does not have a Worksheet named `$sheetname`."))
end
-@inline getsheet(wb::Workbook, sheet_index::Int)::Worksheet = wb.sheets[sheet_index]
+function getsheet(wb::Workbook, sheet_index::Int)::Worksheet
+ 1 <= sheet_index <= length(wb.sheets) ||
+ throw(XLSXError("$(get_xlsxfile(wb).source) has no sheet $sheet_index: it has $(length(wb.sheets)) sheet(s)."))
+ return wb.sheets[sheet_index]
+end
@inline getsheet(xl::XLSXFile, sheetname::String)::Worksheet = getsheet(xl.workbook, sheetname)
@inline getsheet(xl::XLSXFile, sheet_index::Int)::Worksheet = getsheet(xl.workbook, sheet_index)
@@ -91,7 +110,6 @@ function _print_sheet_table(io::IO, wb::Workbook)
println(io, "-"^(21 + 1 + 13 + 1 + 13))
for s in wb.sheets
-# sheetname = length(s.name) > 20 ? first(s.name, 20) * "…" : s.name
sheetname = truncate_len(s.name, 20)
if s.dimension !== nothing
rg = s.dimension
@@ -304,8 +322,10 @@ function Base.show(io::IO, ::MIME"text/plain", v::AbstractVector{DefinedName})
end
+# A sheet name as a formula writes it: quoted when it holds anything but word
+# characters, starts with a digit, or could be read as a cell address (`A1`, `R1C1`).
function quoteit(x::AbstractString)
- if occursin(r"[^\w]|\s", x)
+ if occursin(r"[^\w]|\s", x) || (!isempty(x) && isdigit(first(x))) || _is_cell_address_like(x)
escaped = replace(x, "'" => "''")
return "'$escaped'"
else
@@ -391,7 +411,8 @@ function is_valid_defined_name(name::AbstractString)::Bool
if isempty(name)
return false
end
- if is_valid_cellname(name) || is_valid_cellrange(name) || is_valid_non_contiguous_cellrange(name)
+ u = uppercase(name) # names are case-insensitive: `a1` is a cell address too
+ if is_valid_cellname(u) || is_valid_cellrange(u) || is_valid_non_contiguous_cellrange(u)
return false
end
_is_r1c1_address_like(name) && return false # Excel refuses a file with such a name
@@ -409,6 +430,20 @@ function is_valid_defined_name(name::AbstractString)::Bool
return true
end
+# The `isabs` stored for `value`: one flag per part of a non-contiguous range. A
+# vector, as `getDefinedNames` returns for one, is taken as given.
+function _absolute_flags(value, absolute::Union{Bool,AbstractVector{Bool}})
+ if value isa NonContiguousRange
+ absolute isa Bool && return fill(absolute, length(value.rng))
+ length(absolute) == length(value.rng) ||
+ throw(XLSXError("`absolute` has $(length(absolute)) entries, but the range has $(length(value.rng)) parts."))
+ return collect(Bool, absolute)
+ end
+ absolute isa Bool && return absolute
+ length(absolute) == 1 || throw(XLSXError("`absolute` must be a single Bool for a value that is not a non-contiguous range."))
+ return only(absolute)
+end
+
function addDefName(xf::XLSXFile, name::AbstractString, value::DefinedNameValueTypes; absolute=true, hidden::Bool=false)
if is_system_like_defined_name(name)
throw(XLSXError("`$name` is reserved for names Excel generates for its own use " *
@@ -420,11 +455,7 @@ function addDefName(xf::XLSXFile, name::AbstractString, value::DefinedNameValueT
if is_workbook_defined_name(xf, name)
throw(XLSXError("Workbook already has a defined name called `$name`."))
end
- if value isa NonContiguousRange
- abs = absolute ? fill(true, length(value.rng)) : fill(false, length(value.rng))
- else
- abs = absolute ? true : false
- end
+ abs = _absolute_flags(value, absolute)
xf.workbook.workbook_names[name] = DefinedNameValue(value, abs, hidden)
end
addDefName(xf::XLSXFile, name::AbstractString, value::Integer; absolute=true, hidden::Bool=false) =
@@ -441,11 +472,7 @@ function addDefName(ws::Worksheet, name::AbstractString, value::DefinedNameValue
if value isa NonContiguousRange || value isa SheetCellRange
value.sheet != ws.name && throw(XLSXError("Range $value is not in the given worksheet ($(ws.name))."))
end
- if value isa NonContiguousRange
- abs = absolute ? fill(true, length(value.rng)) : fill(false, length(value.rng))
- else
- abs = absolute ? true : false
- end
+ abs = _absolute_flags(value, absolute)
wb.worksheet_names[(ws.sheetId, name)] = DefinedNameValue(value, abs, hidden)
end
addDefName(ws::Worksheet, name::AbstractString, value::Integer; absolute=true, hidden::Bool=false) =
@@ -637,9 +664,15 @@ name (e.g. `Sheet1!A1:B2`).
If the new `definedName` is a cell reference or range, by default, it will be an absolute
reference (e.g. `\$A\$1:\$C\$6`). If `absolute=false` is specified, the new `definedName` will be
-a relative reference (e.g. `A1:C6`). Any `absolute` argument specified is ignored if the
+a relative reference (e.g. `A1:C6`). Any `absolute` argument specified is ignored if the
`definedName` is not a cell reference or range.
+A string `value` that is not a cell reference or range is a text constant, written to the
+file as `"..."`. To define a name by a formula instead, start `value` with `=`, as in Excel's
+Name Manager (e.g. `"=COUNTA(Sheet1!\$A:\$A)"`). Names read from a file that hold a formula
+(or any other expression that is not a reference or constant) are returned the same way, with
+a leading `=`.
+
In the context of `XLSX.jl` there is no difference between an absolute reference and a relative
reference. However, Excel treats them differently. When `definedNames` are read in as part of
an XLSXFile, we keep track of whether they are absolute or not. If the XLSXFile is subsequently
@@ -665,6 +698,8 @@ julia> XLSX.addDefinedName(xf, "Life_the_universe_and_everything", 42)
julia> XLSX.addDefinedName(xf, "first_name", "Hello World")
+julia> XLSX.addDefinedName(xf, "last_row", "=COUNTA(Sheet1!\$A:\$A)")
+
```
See also [`getDefinedNames`](@ref), [`deleteDefinedName`](@ref), [`XLSX.DefinedName`](@ref).
diff --git a/src/worksheet.jl b/src/worksheet.jl
index c4c36cbd..f8876eb8 100644
--- a/src/worksheet.jl
+++ b/src/worksheet.jl
@@ -64,7 +64,8 @@ end
function Worksheet(xf::XLSXFile, sheet_element::XML.Node)
wb = get_workbook(xf)
- localname(sheet_element) != "sheet" && throw(XLSXError("Something wrong here!"))
+ localname(sheet_element) != "sheet" &&
+ throw(XLSXError("Malformed workbook: an entry in `` is `$(localname(sheet_element))`, not `sheet`."))
a = XML.attributes(sheet_element)
sheetId = parse(Int, a["sheetId"])
relationship_id = a["r:id"]
@@ -471,7 +472,7 @@ function getdata(ws::Worksheet, rng::CellRange)::Array{Any,2}
if !isnothing(ws.cache) && is_cache_enabled(ws) && ws.cache.is_full
cells = ws.cache.cells
- rows = ws.cache.rows_in_cache # sorted Vector{Int} of populated row numbers
+ rows = ensure_sorted!(ws.cache).rows_in_cache # sorted Vector{Int} of populated row numbers
# Binary-search the populated-row range instead of hash-probing every
# integer in top:bottom — cheap for dense sheets (same row count, just
@@ -480,7 +481,7 @@ function getdata(ws::Worksheet, rng::CellRange)::Array{Any,2}
lo = searchsortedfirst(rows, top)
hi = searchsortedlast(rows, bottom)
- @inbounds for k in lo:hi
+ for k in lo:hi
r = rows[k]
row_dict = cells[r] # r came from rows_in_cache, so this key must exist
@@ -580,19 +581,7 @@ julia> XLSX.getdata(t)
See also [`XLSX.table`](@ref), [`XLSX.eachtablerow`](@ref).
"""
function getdata(t::Table)
- t = _resolve(t)
- row_range = _first_data_row(t):_last_data_row(t)
- col0 = _col_start(t)
- nrows = length(row_range)
- ncols = length(t.columns)
-
- m = Matrix{Any}(undef, nrows, ncols)
- for (ri, r) in enumerate(row_range)
- for c in 1:ncols
- m[ri, c] = getdata(t.sheet, CellRef(r, col0 + c - 1))
- end
- end
- return m
+ return _table_data_matrix(_resolve(t))
end
# Needed for definedName references
getdata(ws::Worksheet, s::SheetCellRef) = do_sheet_names_match(ws, s) && getdata(ws, s.cellref)
@@ -920,8 +909,8 @@ each break independently. This follows the `min_gte`, `mid_gte`, `mid2_gte` and
`max_gte` keywords of [`setConditionalFormat`](@ref), which control the equivalent
comparison within an icon set rule.
-Cells whose value is not a finite Real -- including empty cells, text
-and NaN -- are excluded from every band.
+Cells whose value is not a finite number -- including empty cells, text,
+booleans, `NaN` and `Inf` -- are excluded from every band.
`labels` supplies one label per band and defaults to `1:length(breaks)+1`. Unlike the
`key` method, pairs are returned in band order rather than in the order encountered.
@@ -964,7 +953,7 @@ function partition(ws::Worksheet, rng::CellRange, breaks::AbstractVector{<:Real}
throw(XLSXError("`gte` must be a Bool or have one entry per break ($(length(breaks))), got $(length(g))."))
function k(v)
- (v isa Real && !isnan(v)) || return nothing
+ (v isa Real && !(v isa Bool) && isfinite(v)) || return nothing # as documented: finite numbers only
n = 0
for i in eachindex(breaks)
(g[i] ? v >= breaks[i] : v > breaks[i]) || break
diff --git a/src/write.jl b/src/write.jl
index 9fcaf236..c2bc147f 100644
--- a/src/write.jl
+++ b/src/write.jl
@@ -13,11 +13,17 @@ An `XLSXFile` created from a native Excel template (`.xltx`) file or a macro-ena
will be saved as standard worksheet (`.xlsx`) or macro-enabled worksheet (`.xlsm`) respectively, regardless
of the extension of the original template file.
+If `f` was opened from an `IO`, the `IO`'s content is replaced: it is written from the start and
+truncated to the new workbook's length. The `IO` must be writable and seekable, such as an
+`IOBuffer(bytes; read=true, write=true)`.
+
Returns the filepath of the written file if a filename is supplied, or `nothing` if writing to an `IO`.
"""
function savexlsx(f::XLSXFile)
- if isa(f.source, AbstractString)
+ if f.source isa IO
+ _reset_io_for_save!(f.source)
+ elseif isa(f.source, AbstractString)
if f.source == "blank.xlsx"
throw(XLSXError("Can't save to a blank `XLSXFile` instance. Use `writexlsx` instead to specify a file name."))
elseif f.template_type != NotATemplate
@@ -30,6 +36,20 @@ function savexlsx(f::XLSXFile)
return writexlsx(f.source, f; overwrite=true)
end
+# Empty an `IO` source so that saving replaces its content, as saving replaces a file.
+function _reset_io_for_save!(io::IO)
+ try
+ seekstart(io)
+ truncate(io, 0)
+ catch e
+ # The error depends on the kind of `IO` (read-only buffer or file, pipe, ...);
+ # all mean it can't be rewritten. Let an interrupt through, and keep the cause.
+ e isa InterruptException && rethrow()
+ throw(XLSXError("Can't save back to the source `IO`: it must be writable and seekable. Use `writexlsx` to write to another `IO` or file. ($(sprint(showerror, e)))"))
+ end
+ return io
+end
+
"""
@@ -43,6 +63,10 @@ Returns the filepath of the written file if a filename is supplied, or `nothing`
If `overwrite=true`, `output_source` (when a filepath) will be overwritten if it exists.
+When `output_source` is an `IO`, it should be at its start (e.g. a new `IOBuffer()`), and
+`overwrite` has no effect. To save a workbook back to the `IO` it was opened from, replacing
+its content, use [`savexlsx`](@ref).
+
See also [`savexlsx`](@ref).
!!! note
@@ -56,7 +80,7 @@ See also [`savexlsx`](@ref).
"""
function writexlsx(output_source::Union{AbstractString,IO}, xf::XLSXFile; overwrite::Bool=false)
- !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable."))
+ _require_writable(xf, "write the workbook")
if !all(values(xf.files))
throw(XLSXError("Some internal files were not loaded into memory for unknown reasons."))
end
@@ -199,7 +223,7 @@ function add_node_formula!(io, f::ReferencedFormula, pfx)
end
function find_all_nodes(givenpath::String, doc::XML.Node)::Vector{XML.Node}
- XML.nodetype(doc) != XML.Document && throw(XLSXError("Something wrong here!"))
+ XML.nodetype(doc) != XML.Document && throw(XLSXError("Internal error: `find_all_nodes` needs an XML Document, got a $(XML.nodetype(doc)) node."))
found_nodes = Vector{XML.Node}()
for xp in get_node_paths(doc)
if xp.path == givenpath
@@ -210,7 +234,7 @@ function find_all_nodes(givenpath::String, doc::XML.Node)::Vector{XML.Node}
end
function get_node_paths(node::XML.Node)
- XML.nodetype(node) != XML.Document && throw(XLSXError("Something wrong here!"))
+ XML.nodetype(node) != XML.Document && throw(XLSXError("Internal error: `get_node_paths` needs an XML Document, got a $(XML.nodetype(node)) node."))
default_ns = get_default_namespace(xml_root_element(node))
xpaths = Vector{XPathInfo}()
get_node_paths!(xpaths, node, default_ns, "")
@@ -670,7 +694,21 @@ function make_absolute(dn::DefinedNameValue)
end
end
-function update_workbook_xml!(xl::XLSXFile) # Need to update and .
+# The text of a ``: a reference, formula text (held with a leading `=`,
+# which the file omits), or a constant, a string being quoted with `"` doubled.
+function _defined_name_text(v::DefinedNameValue)::String
+ x = v.value
+ x isa DefinedNameRangeTypes && return make_absolute(v)
+ ismissing(x) && return "\"\""
+ x isa Bool && return x ? "TRUE" : "FALSE"
+ if x isa AbstractString
+ startswith(x, '=') && return String(x[nextind(x, 1):end])
+ return "\"" * replace(x, "\"" => "\"\"") * "\""
+ end
+ return string(x)
+end
+
+function update_workbook_xml!(xl::XLSXFile) # Need to update and .
wb = get_workbook(xl)
pfx = get_prefix("xl/workbook.xml", xl)
@@ -709,7 +747,7 @@ function update_workbook_xml!(xl::XLSXFile) # Need to update and and and bottom || c > right
- bottom = max(r, bottom)
- right = max(c, right)
- set_dimension!(ws, CellRange(top, left, bottom, right))
+ if r < top || c < left || r > bottom || c > right
+ set_dimension!(ws, CellRange(min(r, top), min(c, left), max(r, bottom), max(c, right)))
end
nothing
end
function setdata!(ws::Worksheet, cell::Cell)
- !is_writable(get_xlsxfile(ws)) && throw(XLSXError("XLSXFile instance is not writable. Open Excel file with `mode=\"rw\"` instead"))
+ _require_writable(ws, "write to a cell")
ws.cache === nothing && throw(XLSXError("Can't write data to a Worksheet with empty cache."))
cache = ws.cache
@@ -798,7 +839,20 @@ function setdata!(ws::Worksheet, cell::Cell)
end
# Issue #284
+# Whether `c` is a character `strip_illegal_chars` removes.
+@inline function _is_illegal_xml_char(c::Char, strip_discouraged::Bool)::Bool
+ isvalid(c) || return false
+ u = UInt32(c)
+ (u <= 0x1F && u != 0x09 && u != 0x0A && u != 0x0D) && return true
+ (u == 0xFFFE || u == 0xFFFF) && return true
+ strip_discouraged || return false
+ return (0xFDD0 <= u <= 0xFDEF) || (u >= 0x1FFFE && (u & 0xFFFE) == 0xFFFE)
+end
+
function strip_illegal_chars(s::String; strip_discouraged::Bool=true)
+ # Nearly every string is clean: one pass to confirm it, rather than the
+ # many full-string `replace`s below.
+ any(c -> _is_illegal_xml_char(c, strip_discouraged), s) || return s
# remove XML 1.0 illegal C0 controls and U+0000
s = replace(s, r"[\x00-\x08\x0B\x0C\x0E-\x1F]" => "")
# remove BMP non-characters U+FFFE U+FFFF
@@ -833,29 +887,36 @@ xlsx_encode(::Worksheet, val::Int64) = (CT_INT, reinterpret(UInt64, val))
xlsx_encode(ws::Worksheet, val::Dates.Date) = (CT_DATE, reinterpret(UInt64, date_to_excel_value(val, isdate1904(get_xlsxfile(ws)))))
xlsx_encode(ws::Worksheet, val::Dates.DateTime) = (CT_DATETIME, reinterpret(UInt64, datetime_to_excel_value(val, isdate1904(get_xlsxfile(ws)))))
xlsx_encode(::Worksheet, val::Dates.Time) = (CT_TIME, reinterpret(UInt64, time_to_excel_value(val)))
-Base.setindex!(ws::Worksheet, v, row::Integer, col::Integer) = setdata!(ws, CellRef(row,col), v)
-Base.setindex!(ws::Worksheet, v, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = setdata!(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))), v)
-Base.setindex!(ws::Worksheet, v::AbstractVector, r::Union{Integer,UnitRange{<:Integer}}, c::UnitRange{T}) where {T<:Integer} = setdata!(ws, r, c, v)
-Base.setindex!(ws::Worksheet, v::AbstractVector, r::UnitRange{T}, c::Union{Integer,UnitRange{<:Integer}}) where {T<:Integer} = setdata!(ws, r, c, v)
+# `ws[ref] = v` and `ws[r, c] = v`. A vector is written along a row or column; any other
+# value goes in every cell addressed. Each has one entry point, the vector one more
+# specific, which then dispatches on the indices; typing the value and the indices on
+# the same methods made most vector forms ambiguous.
Base.setindex!(ws::Worksheet, v::AbstractVector, ref; dim::Integer=2) = setdata!(ws, ref, v, dim)
-Base.setindex!(ws::Worksheet, v::AbstractVector, r, c; dim::Integer=2) = setdata!(ws, r, c, v, dim)
Base.setindex!(ws::Worksheet, v, ref) = setdata!(ws, ref, v)
-Base.setindex!(ws::Worksheet, v, r, c) = setdata!(ws, r, c, v)
-function Base.setindex!(ws::Worksheet, v, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}})
- for a in row, b in col
- setdata!(ws, CellRef(a, b), v)
- end
-end
-function Base.setindex!(ws::Worksheet, v, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}})
- for a in row, b in col
- setdata!(ws, CellRef(a, b), v)
- end
-end
-function Base.setindex!(ws::Worksheet, v, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}})
+Base.setindex!(ws::Worksheet, v::AbstractVector, r, c; dim::Integer=2) = _setindex_vector!(ws, v, r, c, dim)
+Base.setindex!(ws::Worksheet, v, r, c) = _setindex_value!(ws, v, r, c)
+
+const _RowsOrCols = Union{Integer,UnitRange{<:Integer}}
+const _SparseRowsOrCols = Union{Vector{Int},StepRange{<:Integer}}
+
+# A vector from an anchor cell (along `dim`, as for `ws[ref] = v`), or along one row
+# or column.
+_setindex_vector!(ws::Worksheet, v, r::Integer, c::Integer, dim) = setdata!(ws, r, c, v, dim)
+_setindex_vector!(ws::Worksheet, v, r::Integer, c::Union{Colon,UnitRange{<:Integer}}, dim) = setdata!(ws, r, c, v)
+_setindex_vector!(ws::Worksheet, v, r::Union{Colon,UnitRange{<:Integer}}, c::Integer, dim) = setdata!(ws, r, c, v)
+_setindex_vector!(ws::Worksheet, v, r, c, dim) = throw(XLSXError(
+ "Can't write a vector to `ws[$r, $c]`: a vector is written along one row or column " *
+ "(`ws[row, :]`, `ws[:, col]`, `ws[row, c1:c2]`, `ws[r1:r2, col]`) or across from one cell (`ws[row, col]`)."))
+
+# Any other value, in every cell addressed.
+_setindex_value!(ws::Worksheet, v, row::Integer, col::Integer) = setdata!(ws, CellRef(row, col), v)
+_setindex_value!(ws::Worksheet, v, row::_RowsOrCols, col::_RowsOrCols) = setdata!(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))), v)
+function _setindex_value!(ws::Worksheet, v, row::Union{_RowsOrCols,_SparseRowsOrCols}, col::Union{_RowsOrCols,_SparseRowsOrCols})
for a in row, b in col
setdata!(ws, CellRef(a, b), v)
end
end
+_setindex_value!(ws::Worksheet, v, r, c) = setdata!(ws, r, c, v)
function setdata!(ws::Worksheet, ref::CellRef, val::CellFormula)
c = getcell(ws, ref)
@@ -918,7 +979,7 @@ function setdata!(ws::Worksheet, ref::CellRef, val::Union{AbstractFormula,CellCo
if !(c isa EmptyCell) && c.formula && get_formula_from_cache(ws, ref) isa ReferencedFormula
rereference_formulae(ws, c)
end
- if c isa EmptyCell || c.style == ""
+ if c isa EmptyCell || c.style == 0
if val isa AbstractFormula
return setdata!(ws, ref, CellFormula(ws, val))
else
@@ -956,12 +1017,8 @@ function setdata!(ws::Worksheet, ref::CellRef, val::Union{AbstractFormula,CellCo
end
end
function setdata!(ws::Worksheet, ref::AbstractString, value)
-# if value isa String # Don't do this! Only way to set a formula is with `setFormula` function. This should set a string value.
-# i = findfirst(!isspace, value)
-# if !isnothing(i) && length(value[i:end]) > 1 && value[i] == '=' # it's a formula!
-# value = Formula(string(last(split(value, '='))))
-# end
-# end
+ # A string starting with `=` is stored as text, not as a formula: formulas are
+ # set only with `setFormula`.
if is_worksheet_defined_name(ws, ref)
v = get_defined_name_value(ws, ref)
if is_defined_name_value_a_reference(v)
@@ -1078,8 +1135,6 @@ function setdata!(sheet::Worksheet, ref::CellRef, data::AbstractVector, dim::Int
end
end
-Base.setindex!(ws::Worksheet, v::AbstractVector, r::Union{Colon,UnitRange{T}}, c) where {T<:Integer} = setdata!(ws, r, c, v)
-Base.setindex!(ws::Worksheet, v::AbstractVector, r, c::Union{Colon,UnitRange{T}}) where {T<:Integer} = setdata!(ws, r, c, v)
setdata!(sheet::Worksheet, ::Colon, col::Integer, data::AbstractVector) = setdata!(sheet, 1, col, data, 1)
setdata!(sheet::Worksheet, row::Integer, ::Colon, data::AbstractVector) = setdata!(sheet, row, 1, data, 2)
@@ -1142,8 +1197,9 @@ function setdata!(sheet::Worksheet, ref::CellRef, rts::RichTextString)
c.datatype = CT_STRING
else # add single run as a normal cell value with a cell level font style
sheet[ref] = rts.text
- setFont(sheet, ref; pairs(rts.runs[1].atts)...)
- end
+ kw = _run_font_kwargs(rts.runs[1].atts)
+ isempty(kw) || setFont(sheet, ref; kw...)
+ end
return sheet[ref]
end
@@ -1320,7 +1376,10 @@ function renamesheet!(ws::Worksheet, name::AbstractString)
return
end
- name ∈ sheetnames(xf) && throw(XLSXError("Sheetname $name is already in use."))
+ _require_writable(xf, "rename a sheet")
+ # Excel compares sheet names case-insensitively; renaming a sheet to a new case of its own name is fine.
+ any(s -> s !== ws && _same_sheetname(s.name, name), get_workbook(xf).sheets) &&
+ throw(XLSXError("Sheetname $name is already in use."))
isempty(name) && throw(XLSXError("A sheet name cannot be empty."))
check_valid_sheetname(name)
@@ -1328,10 +1387,6 @@ function renamesheet!(ws::Worksheet, name::AbstractString)
repoint_chart_refs!(xf, p, ws.name, name)
end
- xf = get_xlsxfile(ws)
- !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable."))
- name ∈ sheetnames(xf) && throw(XLSXError("Sheetname $name is already in use."))
-
# updates XML
xroot = xml_root_element(xmlroot(xf, "xl/workbook.xml"))
for node in XML.children(xroot)
@@ -1458,7 +1513,7 @@ function copysheet!(ws::Worksheet, name::AbstractString="")::Worksheet
wb = get_workbook(ws)
is_chartsheet(wb, ws.name) && throw(XLSXError("Cannot copy a Chartsheet."))
xl = get_xlsxfile(ws)
- !is_writable(get_xlsxfile(ws)) && throw(XLSXError("XLSXFile instance is not writable."))
+ _require_writable(ws, "copy a sheet")
dim = get_dimension(ws)
# make sure cache and XML are consistent
@@ -1504,13 +1559,14 @@ function copysheet!(ws::Worksheet, name::AbstractString="")::Worksheet
# cache of copied sheet must be full
ws.cache.is_full == true || throw(XLSXError("Cannot copy worksheet that does not have a full cache"))
- # copy the original worksheet cache to the new worksheet
+ # copy the original worksheet cache to the new worksheet. Deep copies, so that
+ # writing to either sheet (values, formats, row heights) leaves the other alone.
new_cache = WorksheetCache(
true,
- ws.cache.cells,
- ws.cache.rows_in_cache,
- ws.cache.row_ht,
- ws.cache.row_index,
+ deepcopy(ws.cache.cells),
+ copy(ws.cache.rows_in_cache),
+ copy(ws.cache.row_ht),
+ copy(ws.cache.row_index),
SheetRowStreamIterator(ws), # Dummy - not needed because using full cache.
nothing,
ws.cache.dirty,
@@ -1587,13 +1643,13 @@ function copysheet!(ws::Worksheet, name::AbstractString="")::Worksheet
end
# copy defined names from the original worksheet to the new worksheet
+ # References to the original sheet are repointed at the copy; others are kept as they are.
ws_keys = [x for x in keys(wb.worksheet_names) if first(x) == ws.sheetId]
for k in ws_keys
- val = wb.worksheet_names[k].value
- val = val isa CellRange ? string(val) :
- val isa SheetCellRange ? new_ws.name * "!" * string(val.rng) :
- val
- addDefinedName(new_ws, last(k), val; absolute=wb.worksheet_names[k].isabs)
+ dnv = wb.worksheet_names[k]
+ val = dnv.value isa DefinedNameRangeTypes && dnv.value.sheet == ws.name ?
+ rename_sheet(dnv.value, new_ws.name) : dnv.value
+ wb.worksheet_names[(new_ws.sheetId, last(k))] = DefinedNameValue(val, copy(dnv.isabs), dnv.hidden)
end
# Copy the formula cache from the original worksheet to the new worksheet
@@ -1602,6 +1658,7 @@ function copysheet!(ws::Worksheet, name::AbstractString="")::Worksheet
newkey = SheetCellRef(new_ws.name, k.cellref)
wb.formulas[newkey] = copy(wb.formulas[k])
end
+ new_ws.next_formula_id = ws.next_formula_id # the copied shared formulas keep their ids
return new_ws
end
@@ -1613,9 +1670,11 @@ const REL_CHARTEX = "http://schemas.microsoft.com/office/2014/relationships/
const MIME_WORKSHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
const MIME_CHARTSHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml"
const MIME_CHART = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
-const MIME_CHART = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
const MIME_CHARTEX = "application/vnd.ms-office.chartex+xml"
+# Whether two sheet names are the same to Excel, which ignores case.
+_same_sheetname(a::AbstractString, b::AbstractString) = uppercase(a) == uppercase(b)
+
# A sheet name Excel accepts: at most 31 characters, none of : \ / ? * [ ].
function check_valid_sheetname(n::AbstractString)
max_length = 31
@@ -1660,12 +1719,12 @@ function _register_sheet!(wb::Workbook, xdoc::XML.Node, name::AbstractString;
default_name::String, pfx::String = "",
dim::CellRange = CellRange("A1:A1"))::Worksheet
xf = get_xlsxfile(wb)
- !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable."))
+ _require_writable(xf, "add a sheet")
new_name = name
existing = sheetnames(wb)
i = 1
- while new_name == "" || new_name in existing
+ while new_name == "" || any(n -> _same_sheetname(n, new_name), existing)
new_name = (name == "" ? default_name : name * " ") * string(i)
i += 1
end
@@ -1735,7 +1794,8 @@ end
add_override!(wb::Workbook, part::String, content::String) = add_override!(get_xlsxfile(wb), part, content)
function add_override!(xf::XLSXFile, part::String, content::String)
ctype_root = xml_root_element(xmlroot(xf, "[Content_Types].xml"))
- XML.tag(ctype_root) != "Types" && throw(XLSXError("Something wrong here!"))
+ XML.tag(ctype_root) != "Types" &&
+ throw(XLSXError("Malformed `[Content_Types].xml`: its root element is `$(XML.tag(ctype_root))`, not `Types`."))
override_node = XML.Element("Override";
PartName=part,
ContentType=content
@@ -1787,8 +1847,11 @@ Delete the given worksheet, the worksheet with the given name or the worksheet w
!!! note "Caution"
Cells in the other sheets that have references to the deleted sheet will fail when the sheet is deleted.
- The formulae are updated to contain a `#Ref!` error in place of each sheetcell reference.
-
+ The formulae are updated to contain a `#REF!` error in place of each sheetcell reference.
+
+ Defined names that refer to the deleted sheet are kept with the value `"=#REF!"`, as in
+ Excel. Names scoped to the deleted sheet are removed with it.
+
See also [`addsheet!`](@ref), [`renamesheet!`](@ref), [`copysheet!`](@ref)
@@ -1894,15 +1957,26 @@ function deletesheet!(wb::Workbook, name::AbstractString)::XLSXFile
deleteat!(wb.relationships, r)
deleteat!(wb.sheets, s)
- # Defined names. Excel drops a chartEx chart's _xlchart names along with the
- # sheet; note their values first, so the charts can be emptied to match.
+ # Defined names. As in Excel, a name that refers to the deleted sheet is kept
+ # as #REF!, except a chartEx chart's _xlchart names, which Excel drops along
+ # with the sheet; note their values first, so the charts can be emptied to match.
dead_xlchart = Dict{String,String}()
- for k in [k for (k, v) in wb.workbook_names if v.value isa DefinedNameRangeTypes && v.value.sheet == name]
- startswith(k, "_xlchart.") && (dead_xlchart[k] = string(wb.workbook_names[k].value))
- delete!(wb.workbook_names, k)
+ for (k, v) in collect(wb.workbook_names)
+ v.value isa DefinedNameRangeTypes && v.value.sheet == name || continue
+ if startswith(k, "_xlchart.")
+ dead_xlchart[k] = string(v.value)
+ delete!(wb.workbook_names, k)
+ else
+ wb.workbook_names[k] = DefinedNameValue("=#REF!", false, v.hidden)
+ end
end
- for k in [k for k in keys(wb.worksheet_names) if first(k) == sId]
- delete!(wb.worksheet_names, k)
+ # The sheet's own names go with it; names on other sheets that refer to it become #REF!.
+ for (k, v) in collect(wb.worksheet_names)
+ if first(k) == sId
+ delete!(wb.worksheet_names, k)
+ elseif v.value isa DefinedNameRangeTypes && v.value.sheet == name
+ wb.worksheet_names[k] = DefinedNameValue("=#REF!", false, v.hidden)
+ end
end
# Sheet-owned parts. Drawings and media keep their dedup-aware handling (media
@@ -1984,11 +2058,11 @@ function _table_name_from_sheet(wb::Workbook, sheet::Worksheet, sheetname::Abstr
for ws in wb.sheets
is_chartsheet(wb, ws.name) && continue
for t in tables(ws)
- push!(existing_names, t.name)
+ push!(existing_names, uppercase(t.name))
end
end
- if candidate ∈ existing_names ||
+ if uppercase(candidate) ∈ existing_names ||
is_workbook_defined_name(wb, candidate) || is_worksheet_defined_name(sheet, candidate)
@warn "Normalized table name `$candidate` (from sheet name `$sheetname`) collides with an existing table name or defined name; using an auto-generated table name instead."
return ""
diff --git a/src/xlsx-colors.jl b/src/xlsx-colors.jl
index a7e4d086..7a104172 100644
--- a/src/xlsx-colors.jl
+++ b/src/xlsx-colors.jl
@@ -1,3 +1,10 @@
+# The forms every color keyword accepts, completing a sentence such as "The `color`
+# can be ...". Interpolated into each setter's docstring so they describe them alike.
+const COLOR_FORMS_DOC = """a hex value (`"FF0000"`, `"#FF0000"` or 8-digit `"FFFF0000"`, whose
+first two digits, transparency, Excel ignores), the name of a [Colors.jl](https://juliagraphics.github.io/Colors.jl/stable/namedcolors/)
+color as a String or Symbol, a Colors.jl `Colorant`, or a theme color (`"accent1"`, `:tx1`
+or a [`XLSX.SchemeColor`](@ref)). See [Specifying colors](@ref)."""
+
# Check if a string is a valid named color in Colors.jl and convert to "FFRRGGBB" if it is.
get_colorant(color_symb::Symbol) = get_colorant(String(color_symb))
function get_colorant(color_string::String)
@@ -5,7 +12,8 @@ function get_colorant(color_string::String)
c = parse(Colors.Colorant, color_string)
rgb = Colors.hex(c, :RRGGBB)
return "FF" * rgb
- catch
+ catch e
+ e isa ArgumentError || rethrow() # Colors.jl's error for an unrecognised color
return nothing
end
end
@@ -17,10 +25,80 @@ function get_color(str::AbstractString)::String
s = replace(lowercase(str), "grey" => "gray")
c = get_colorant(s)
isnothing(c) && throw(XLSXError(
- "Invalid color specified: $str. Give a color name from Colors.jl, or a hex " *
- "color as RRGGBB or AARRGGBB, with or without a leading #."))
+ "Invalid color specified: $str. Give a color name from Colors.jl, a hex " *
+ "color as RRGGBB or AARRGGBB (with or without a leading #), a theme color " *
+ "name such as `accent1`, a Colors.jl `Colorant` or a `SchemeColor`."))
return c
end
+get_color(c::Colors.Colorant)::String = uppercase(Colors.hex(c, :AARRGGBB))
+
+# Spreadsheet theme-index slot for each SchemeColor token spelling.
+const _THEME_SLOT = Dict(:bg1 => "lt1", :tx1 => "dk1", :bg2 => "lt2", :tx2 => "dk2")
+
+_theme_index(token::Symbol)::Int =
+ findfirst(==(get(_THEME_SLOT, canonical_token(token), String(token))), THEME_COLOR_ORDER) - 1
+
+# The `tint` that gives `sc` in a spreadsheet ``. Only
+# the lighter/darker shades of Excel's palette have one: Excel's tint lightens
+# toward white as lumMod/lumOff do, and darkens as lumMod alone does.
+# `nothing` if `sc` has no such tint.
+function _theme_tint_or_nothing(sc::SchemeColor)::Union{Nothing,Float64}
+ t = filter(p -> first(p) !== :alpha, sc.transforms) # Excel ignores alpha in cells
+ isempty(t) && return 0.0
+ if length(t) == 1 && first(t[1]) === :lumMod && 0 <= last(t[1]) <= 100
+ return last(t[1]) / 100 - 1
+ elseif length(t) == 2 && first(t[1]) === :lumMod && first(t[2]) === :lumOff &&
+ 0 <= last(t[2]) <= 100 && isapprox(last(t[1]) + last(t[2]), 100)
+ return last(t[2]) / 100
+ end
+ return nothing
+end
+
+function _theme_tint(sc::SchemeColor)::Float64
+ t = _theme_tint_or_nothing(sc)
+ isnothing(t) || return t
+ throw(XLSXError(
+ "$(sprint(show, sc)) cannot be used here. A theme color in a cell, conditional " *
+ "format or rich text can only be made darker, with `lumMod` alone (0 to 100), or " *
+ "lighter, with `lumMod` and `lumOff` adding to 100. Give an RGB color instead."))
+end
+
+"""
+ _color_atts(color) -> Vector{Pair{String,String}}
+
+The attributes of a spreadsheet `` element for any `CellColor`:
+`["rgb" => "FFRRGGBB"]`, or `["theme" => "N"]` plus a `"tint"` for a theme color.
+A `SchemeColor`, or a theme token as a String or Symbol (`"accent2"`, `:tx1`),
+is a theme color, as in charts.
+"""
+function _color_atts(c::SchemeColor)::Vector{Pair{String,String}}
+ idx = string(_theme_index(c.token))
+ tint = _theme_tint(c)
+ return iszero(tint) ? ["theme" => idx] : ["theme" => idx, "tint" => string(tint)]
+end
+_color_atts(c::Union{AbstractString,Symbol}) =
+ Symbol(c) in SCHEME_TOKENS ? _color_atts(SchemeColor(Symbol(c))) : ["rgb" => get_color(c)]
+_color_atts(c::Colors.Colorant) = ["rgb" => get_color(c)]
+_color_atts(c) = throw(XLSXError(
+ "Invalid color specified: $(repr(c)). Give a color name from Colors.jl, a hex color " *
+ "as RRGGBB or AARRGGBB, a theme color name such as `accent1`, a Colors.jl " *
+ "`Colorant` or a `SchemeColor`."))
+
+# A spreadsheet color element, e.g. ``, for any `CellColor`.
+_color_element(tag::AbstractString, c) =
+ XML.Element(tag; (Symbol(k) => v for (k, v) in _color_atts(c))...)
+
+# Canonical form of a color for comparing and hashing: its `` attributes, so
+# "red", :red and colorant"red" are one color. A SchemeColor a cell cannot express
+# (it is still valid in a chart) is its own key rather than an error.
+_cell_color_key(c::SchemeColor) = isnothing(_theme_tint_or_nothing(c)) ? c : _color_atts(c)
+_cell_color_key(c) = _color_atts(c)
+
+_is_theme_color(c) = c isa SchemeColor ||
+ (c isa Union{AbstractString,Symbol} && Symbol(c) in SCHEME_TOKENS)
+
+# Throws if `c` is not a color in any form XLSX.jl accepts.
+_check_color(c) = (_cell_color_key(c); nothing)
# This is Excel's 64-entry legacy indexed color palette
const INDEXED_PALETTE = [
@@ -64,7 +142,7 @@ end
_themeelements_element(wb::Workbook) -> XML.Node
The theme's ``. Throws if the workbook's theme is malformed.
-Shared by the colour and font scheme lookups.
+Shared by the color and font scheme lookups.
"""
function _themeelements_element(wb::Workbook)::XML.Node
xroot = xml_root_element(theme_xmlroot(wb))
@@ -218,21 +296,14 @@ function resolveColor(wb::Workbook, atts::AbstractDict; prefix::AbstractString="
elseif haskey(atts, prefix*"theme")
rawtheme = strip(atts[prefix*"theme"])
- theme = try
- parse(Int, rawtheme)
- catch
- throw(XLSXError("Invalid theme index: $rawtheme"))
- end
+ theme = tryparse(Int, rawtheme)
+ isnothing(theme) && throw(XLSXError("Invalid theme index: $rawtheme"))
tint = 0.0
if haskey(atts, prefix*"tint")
rawt = strip(atts[prefix*"tint"])
- tint = try
- parse(Float64, rawt)
- catch
- throw(XLSXError("Invalid tint value: $rawt. Must be between -1.0 and 1.0."))
- end
- if !isfinite(tint)
+ tint = tryparse(Float64, rawt)
+ if isnothing(tint) || !isfinite(tint)
throw(XLSXError("Invalid tint value: $rawt. Must be between -1.0 and 1.0."))
end
tint = clamp(tint, -1.0, 1.0)
@@ -242,11 +313,10 @@ function resolveColor(wb::Workbook, atts::AbstractDict; prefix::AbstractString="
elseif haskey(atts, prefix*"indexed")
rawidx = strip(atts[prefix*"indexed"])
- idx = try
- parse(Int, rawidx)
- catch
- throw(XLSXError("Invalid indexed color index: $rawidx. Must be an integer between 0 and $(length(INDEXED_PALETTE)-1)."))
- end
+ idx = tryparse(Int, rawidx)
+ isnothing(idx) && throw(XLSXError("Invalid indexed color index: $rawidx. Must be an integer between 0 and $(length(INDEXED_PALETTE)-1)."))
+ idx == 64 && return "FF000000" # system foreground, which Excel writes for default borders and text
+ idx == 65 && return "FFFFFFFF" # system background
if idx < 0 || idx >= length(INDEXED_PALETTE)
throw(XLSXError("Invalid indexed color index: $idx. Must be between 0 and $(length(INDEXED_PALETTE)-1)."))
end
@@ -334,7 +404,7 @@ resolve_theme_font(wb::Workbook, typeface::AbstractString) =
startswith(typeface, '+') ? get(get_theme_fonts(wb), typeface, nothing) : typeface
"""
-Apply the DrawingML colour transforms to an "RRGGBB" hex string, in document
+Apply the DrawingML color transforms to an "RRGGBB" hex string, in document
order, returning the transformed hex and the resulting alpha.
`lumMod`, `lumOff` and `satMod` operate in HSL, which is why they are applied
diff --git a/src/xmlutil.jl b/src/xmlutil.jl
index 29bac185..b3aff283 100644
--- a/src/xmlutil.jl
+++ b/src/xmlutil.jl
@@ -65,7 +65,7 @@ _attr(node::XML.Node, key::AbstractString) = (v = get_attr(node, key); isempty(v
_attr(::Nothing, ::AbstractString) = nothing
# DrawingML percentage attributes are in thousandths of a percent. Returns a
-# fraction, not a percentage: 60000 -> 0.6. Used for colour transforms
+# fraction, not a percentage: 60000 -> 0.6. Used for color transforms
# (lumMod, satMod, shade, tint), text baseline, normAutofit scaling and line
# spacing — anything reading `_attr_pct` or `_attr_pct_opt` lands here.
@inline _pct(v::Int) = v / 100_000
@@ -136,4 +136,11 @@ function with_attribute(node::XML.Node, name::AbstractString, value)
return typeof(node)(XML.nodetype(node), XML.tag(node),
isempty(attrs) ? nothing : attrs,
XML.value(node), node.children)
+end
+
+# ── nodes ──────────────────────────────────────────────────────────────────
+function copynode(o::XML.Node)
+ attrs = isnothing(o.attributes) ? nothing : copy(o.attributes)
+ children = isnothing(o.children) ? nothing : XML.Node{String}[copynode(x) for x in o.children]
+ return XML.Node{String}(o.nodetype, o.tag, attrs, o.value, children)
end
\ No newline at end of file
diff --git a/test/Project.toml b/test/Project.toml
index 8b8d766a..b15c5bc5 100644
--- a/test/Project.toml
+++ b/test/Project.toml
@@ -1,4 +1,5 @@
[deps]
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
Colors = "5ae59095-9a9b-59fe-a467-6f913c188581"
CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
@@ -16,6 +17,7 @@ XML = "72c71f33-b9b6-44de-8c94-c961784809e2"
ZipArchives = "49080126-0e18-4c2a-b176-c102e4b3760c"
[compat]
+Aqua = "0.8"
Colors = "0.12, 0.13"
CSV = "0.10.15, 1"
Distributions = "0.25.0"
diff --git a/test/data/legend_positions.xlsx b/test/data/legend_positions.xlsx
new file mode 100644
index 00000000..cdb1b669
Binary files /dev/null and b/test/data/legend_positions.xlsx differ
diff --git a/test/runtests.jl b/test/runtests.jl
index 68dc0c9c..84dd1550 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -16,7 +16,7 @@ using Colors
# has stricter rules, rejects. By creating the output of every testset as a
# file explicitly, it is possible to confirm that this never happens in practice
# by manually opening each output file.
-# If TRUE, approx 600 files are created in `outdir`.
+# If TRUE, approx 700 files are created in `outdir`.
# This flag should be reserved for local use and **never** left as TRUE for CI.
const SAVE_FILES = false
@@ -44,87 +44,12 @@ if SAVE_FILES
end
end
-function current_testset_label()
- ts = Test.get_testset()
- return ts === nothing ? "untitled" : ts.description
-end
-
-function sanitize_filename(s::AbstractString)
- return replace(s, r"[^A-Za-z0-9_-]" => "_")
-end
-
-const outfile_counter = Ref(0)
-
-function save_outfile(xf::XLSX.XLSXFile)
- outfile_counter[] += 1
- label = sanitize_filename(current_testset_label())
- # Excel won't open a macro-enabled workbook saved with an `.xlsx` extension
- src_ext = xf.source isa AbstractString ? lowercase(splitext(xf.source)[2]) : ""
- ext = (src_ext in (".xlsm", ".xltm") || xf.template_type == XLSX.XLTMTemplate) ? ".xlsm" : ".xlsx"
- fname = "outfile_$(lpad(outfile_counter[], 3, '0'))_$(label)$(ext)"
- XLSX.writexlsx(joinpath(outdir, fname), xf)
-end
-
-function save_outfile(name::AbstractString)
- outfile_counter[] += 1
- label = sanitize_filename(current_testset_label())
- ext = splitext(name)[2] # preserves ".xlsx", ".xlsm", ".xltx", ".xltm", etc.
- fname = "outfile_$(lpad(outfile_counter[], 3, '0'))_$(label)$(ext)"
- cp(name, joinpath(outdir, fname))
-end
-
-function save_outfile(io::IO)
- outfile_counter[] += 1
- label = sanitize_filename(current_testset_label())
- fname = "outfile_$(lpad(outfile_counter[], 3, '0'))_$(label).xlsx"
- pos = position(io)
- seekstart(io)
- open(joinpath(outdir, fname), "w") do out
- write(out, io)
- end
- seek(io, pos)
-end
-
-# Checks whether `data` equals `test_data`
-function check_test_data(data::Vector{S}, test_data::Vector{T}) where {S,T}
-
- @test length(data) == length(test_data)
-
- function size_of_data(d::Vector{T}) where {T}
- isempty(d) && return (0, 0)
- return length(d[1]), length(d)
- end
-
- rows, cols = size_of_data(test_data)
-
- for col in 1:cols
- @test length(data[col]) == length(test_data[col])
- end
-
- for row in 1:rows, col in 1:cols
- test_value = test_data[col][row]
- value = data[col][row]
-
- if test_value === nothing
- @test ismissing(value)
- elseif ismissing(test_value) || (isa(test_value, AbstractString) && isempty(test_value))
- @test ismissing(value) || (isa(value, AbstractString) && isempty(value))
- else
- if isa(test_value, Integer) || isa(value, Integer)
- @test isa(test_value, Integer)
- @test isa(value, Integer)
- end
-
- if isa(test_value, Real) && !isa(test_value, Integer)
- @test isapprox(value, test_value)
- else
- @test value == test_value
- end
- end
- end
-
- nothing
-end
+# Tests write their scratch files to the working directory. Run them in a fresh
+# temporary directory, removed when the run ends, so nothing is left in the
+# repository whether a test passes or fails. Fixtures are always read through
+# `data_directory`/`src_data_directory`, which are absolute.
+const test_run_directory = pwd()
+cd(mktempdir())
#=
Tests have been split across different files each essentially focused on
@@ -133,25 +58,25 @@ be applied in combination with any others, so the perfect separation of
functions in tests is not complete and it wouuld be undesirable to make
it so.
=#
+include(joinpath(files_directory, "helpers.jl")) # shared helpers first
+include(joinpath(files_directory, "Aqua_tests.jl"))
include(joinpath(files_directory, "Cell-names_tests.jl"))
+include(joinpath(files_directory, "ChartExProps_tests.jl"))
include(joinpath(files_directory, "ChartImports_tests.jl"))
-include(joinpath(files_directory, "Charts_tests.jl"))
include(joinpath(files_directory, "ChartProps_tests.jl"))
-include(joinpath(files_directory, "ChartExProps_tests.jl"))
include(joinpath(files_directory, "ChartSchema_tests.jl"))
-include(joinpath(files_directory, "Colors-tests.jl"))
+include(joinpath(files_directory, "Charts_tests.jl"))
+include(joinpath(files_directory, "Colors_tests.jl"))
include(joinpath(files_directory, "Conditional-format_tests.jl"))
include(joinpath(files_directory, "Copy-Add_tests.jl"))
-include(joinpath(files_directory, "DrawingML_tests.jl"))
include(joinpath(files_directory, "Defined-names_tests.jl"))
include(joinpath(files_directory, "Differential_tests.jl"))
-include(joinpath(files_directory, "Dispatch-coverage_tests.jl"))
-include(joinpath(files_directory, "Edit_tests.jl"))
-include(joinpath(files_directory, "Error-branches_tests.jl"))
-include(joinpath(files_directory, "Errors_tests.jl"))
+include(joinpath(files_directory, "DrawingML_tests.jl"))
+include(joinpath(files_directory, "Error-values_tests.jl"))
include(joinpath(files_directory, "Excel_tables_tests.jl"))
include(joinpath(files_directory, "FileIO_tests.jl"))
include(joinpath(files_directory, "Filemodes_tests.jl"))
+include(joinpath(files_directory, "Fixture-files_tests.jl"))
include(joinpath(files_directory, "Formulas_tests.jl"))
include(joinpath(files_directory, "Getcell_tests.jl"))
include(joinpath(files_directory, "Getindex-Setindex_tests.jl"))
@@ -160,261 +85,13 @@ include(joinpath(files_directory, "Merge_tests.jl"))
include(joinpath(files_directory, "Namespace_tests.jl"))
include(joinpath(files_directory, "Panes_tests.jl"))
include(joinpath(files_directory, "Ranges_tests.jl"))
-include(joinpath(files_directory, "Read-coverage_tests.jl"))
+include(joinpath(files_directory, "Read-internals_tests.jl"))
include(joinpath(files_directory, "Strict-format_tests.jl"))
include(joinpath(files_directory, "Strings_tests.jl"))
include(joinpath(files_directory, "Styles_tests.jl"))
include(joinpath(files_directory, "Tables_tests.jl"))
-include(joinpath(files_directory, "Test-files_tests.jl"))
include(joinpath(files_directory, "Write-Save_tests.jl"))
include(joinpath(files_directory, "XML_tests.jl"))
-#Some residual tests still here!
-@testset "Time and DateTime" begin
- @test XLSX.excel_value_to_time(0.82291666666666663) == Dates.Time(Dates.Hour(19), Dates.Minute(45))
- @test XLSX.time_to_excel_value(XLSX.excel_value_to_time(0.2)) == 0.2
- @test XLSX.excel_value_to_datetime(43206.805447106482, false) == Date(2018, 4, 16) + Dates.Time(Dates.Hour(19), Dates.Minute(19), Dates.Second(51))
- @test XLSX.excel_value_to_datetime(XLSX.datetime_to_excel_value(Date(2018, 4, 16) + Dates.Time(Dates.Hour(19), Dates.Minute(19), Dates.Second(51)), false), false) == Date(2018, 4, 16) + Dates.Time(Dates.Hour(19), Dates.Minute(19), Dates.Second(51))
-
- dt = Date(2018, 4, 1)
- @test XLSX.excel_value_to_date(XLSX.date_to_excel_value(dt, false), false) == dt
- @test XLSX.excel_value_to_date(XLSX.date_to_excel_value(dt, true), true) == dt
-end
-
-@testset "No Dimension" begin
- @testset "Read-only range discovery" begin
- bytes = read(joinpath(data_directory, "NoDim.xlsx"))
- expected = XLSX.openxlsx(IOBuffer(bytes); mode = "rw")[1]["A1:K116"]
- factories = (XLSX.readxlsx,
- io -> XLSX.openxlsx(io; enable_cache = true),
- io -> XLSX.openxlsx(io; enable_cache = false))
- cellvalues(ws, cells) = map(c -> XLSX.getdata(ws, c), cells)
- ranges = (
- (ws -> ws[:], expected),
- (ws -> ws[:, :], expected),
- (ws -> XLSX.getdata(ws), expected),
- (ws -> ws[1:2, :], expected[1:2, :]),
- (ws -> ws[:, 1:2], expected[:, 1:2]),
- (ws -> ws[[1, 2], :], expected[1:2, :]),
- (ws -> ws[:, [1, 2]], expected[:, 1:2]),
- (ws -> ws["1:2"], expected[1:2, :]),
- (ws -> ws["A:B"], expected[:, 1:2]),
- (ws -> cellvalues(ws, XLSX.getcellrange(ws, :)), expected),
- (ws -> cellvalues(ws, XLSX.getcell(ws, :)), expected),
- (ws -> cellvalues(ws, XLSX.getcell(ws, 1:2, :)), expected[1:2, :]),
- (ws -> cellvalues(ws, XLSX.getcell(ws, :, 1:2)), expected[:, 1:2]),
- (ws -> cellvalues(ws, XLSX.getcellrange(ws, "1:2")), expected[1:2, :]),
- (ws -> cellvalues(ws, XLSX.getcellrange(ws, "A:B")), expected[:, 1:2]),
- )
- for openfile in factories
- for (readrange, reference) in ranges
- ws = openfile(IOBuffer(bytes))[1]
- @test isequal(readrange(ws), reference)
- @test ws.dimension == XLSX.CellRange("A1:K116") # recorded by the first read
- @test isequal(readrange(ws), reference)
- end
- ws = openfile(IOBuffer(bytes))[1]
- @test axes(ws, 1) == 1:116
- @test axes(ws, 2) == 1:11
- @test XLSX.get_dimension(ws) == XLSX.CellRange("A1:K116")
- !XLSX.is_cache_enabled(ws) && @test ws.cache === nothing
-
- ws = openfile(IOBuffer(bytes))[1]
- sprint(show, ws)
- @test ws.cache === nothing
- @test ws.dimension === nothing
-
- ws = openfile(IOBuffer(read(joinpath(data_directory, "simple.xlsx"))))[1]
- @test XLSX.get_dimension(ws) == XLSX.CellRange("A1:B3")
- @test ws.cache === nothing
- end
-
- # An uncached row iterator records the dimension only when it runs to the end.
- ws = XLSX.openxlsx(IOBuffer(bytes); enable_cache = false)[1]
- for _ in XLSX.eachrow(ws) end
- @test ws.dimension == XLSX.CellRange("A1:K116")
- ws = XLSX.openxlsx(IOBuffer(bytes); enable_cache = false)[1]
- for _ in XLSX.eachrow(ws)
- break
- end
- @test ws.dimension === nothing
-
- # A recorded dimension is never recomputed.
- for enable_cache in (true, false)
- ws = XLSX.openxlsx(joinpath(data_directory, "simple.xlsx"); enable_cache)[1]
- dimension = ws.dimension
- ws[:]; ws[1:2, :]; ws[:, 2]; axes(ws, 2)
- for _ in XLSX.eachrow(ws) end
- @test ws.dimension === dimension
- end
-
- sparse = Matrix{Any}(missing, 5, 3)
- sparse[1, 1] = 11
- sparse[5, 3] = 22
- styled = Matrix{Any}(missing, 3, 3)
- styled[1, 1] = 11
- inline = Matrix{Any}(missing, 2, 2)
- inline[1, 1] = "hi"
- inline[2, 2] = 5
- emptyrow = Matrix{Any}(missing, 4, 1)
- emptyrow[1, 1] = 1
- sheets = (
- ("", "A1:A1", fill(missing, 1, 1)),
- ("
", "A1:A1", fill(missing, 1, 1)),
- ("11
22
", "C3:E7", sparse),
- # a formatting-only cell extends the dimension
- ("11
", "B2:D4", styled),
- ("hi
5
", "A1:B2", inline),
- # a row without cells still extends the rows
- ("1
", "B2:B5", emptyrow),
- )
- for (sheetdata, dimension, reference) in sheets
- path = patch_xlsx_entry(joinpath(src_data_directory, "blank.xlsx"), "xl/worksheets/sheet1.xml",
- _ -> "" * sheetdata * "")
- try
- rng = XLSX.CellRange(dimension)
- top, left = rng.start.row_number, rng.start.column_number
- for openfile in factories
- ws = openfile(IOBuffer(read(path)))[1]
- @test isequal(ws[:], reference)
- @test isequal(ws[:], reference)
- @test XLSX.get_dimension(ws) == rng
- !XLSX.is_cache_enabled(ws) && @test ws.cache === nothing
-
- # partial reads take the other extent from the sheet
- ws = openfile(IOBuffer(read(path)))[1]
- @test isequal(ws[top:top, :], reference[1:1, :])
- ws = openfile(IOBuffer(read(path)))[1]
- @test isequal(ws[:, left], reference[:, 1:1])
- ws = openfile(IOBuffer(read(path)))[1]
- @test axes(ws, 1) == rng.start.row_number:rng.stop.row_number
- @test axes(ws, 2) == rng.start.column_number:rng.stop.column_number
- end
- finally
- rm(path)
- end
- end
- end
-
- noDim = XLSX.openxlsx(joinpath(data_directory, "NoDim.xlsx"), mode="rw")
- Dim = XLSX.readxlsx(joinpath(data_directory, "customXml.xlsx"))
- @test noDim[1].dimension == Dim[1].dimension
- @test noDim[2].dimension == Dim[2].dimension
-
- f = XLSX.newxlsx()
- s = f[1]
- for i = 10:20, j = 10:20
- s[i, j] = i + j
- end
- XLSX.set_dimension!(s, XLSX.CellRange(XLSX.CellRef("J10"), XLSX.CellRef("T20")))
- @test XLSX.get_dimension(s) == XLSX.CellRange(XLSX.CellRef("J10"), XLSX.CellRef("T20"))
- s["A1"] = 2
- @test XLSX.get_dimension(s) == XLSX.CellRange(XLSX.CellRef("A1"), XLSX.CellRef("T20"))
- SAVE_FILES && save_outfile(f)
-end
-
-# issue #67
-@testset "row_index" begin
- filename = "test_pr67.xlsx"
- XLSX.openxlsx(filename, mode="w") do xf
- xf[1]["A2"] = 5
- xf[1]["A1"] = 7
- end
- @test isfile(filename)
- SAVE_FILES && save_outfile(filename)
- isfile(filename) && rm(filename)
-end
-
-@testset "show xlsx" begin
- @testset "single sheet" begin
- xf = XLSX.readxlsx(joinpath(src_data_directory, "blank.xlsx"))
- io = IOBuffer()
- show(io, xf)
- lines = split(String(take!(io)), '\n')
-
- expected_tail = " sheetname size range \n-------------------------------------------------\n Sheet1 1x1 A1:A1 \n"
-
- @test join(lines[2:end], '\n') == expected_tail
- end
-
- @testset "multiple sheets" begin
- xf = XLSX.readxlsx(joinpath(data_directory, "Book1.xlsx"))
- io = IOBuffer()
- show(io, xf)
- lines = split(String(take!(io)), '\n')
-
- expected_tail = " sheetname size range \n-------------------------------------------------\n Sheet1 7x2 B2:C8 \n Sheet2 3x3 A1:C3 \n"
-
- @test join(lines[2:end], '\n') == expected_tail
- end
-
- @testset "IOBuffer source" begin
- source = IOBuffer()
- write(source, read(joinpath(data_directory, "Book1.xlsx")))
- seekstart(source)
- xf = XLSX.readxlsx(source)
- io=IOBuffer()
- show(io, xf)
- result = String(take!(io))
- expected = "XLSXFile(IOBuffer) containing 2 Worksheets\n sheetname size range \n-------------------------------------------------\n Sheet1 7x2 B2:C8 \n Sheet2 3x3 A1:C3 \n"
-
- @test result == expected
- end
-end
-
-# issues #62, #75
-@testset "relative paths" begin
- let
- xf = XLSX.readxlsx(joinpath(data_directory, "openpyxl.xlsx"))
- @test XLSX.sheetnames(xf) == ["Sheet", "Test1"]
- @test xf["Test1"]["A1"] == "One"
- @test xf["Test1"]["A2"] == 1
- show(IOBuffer(), xf)
- show(IOBuffer(), xf["Sheet"])
- show(IOBuffer(), xf["Test1"])
- end
-
- let
- dtable = XLSX.readtable(joinpath(data_directory, "openpyxl.xlsx"), "Test1")
- data, col_names = dtable.data, dtable.column_labels
- @test data == [[1, 3], [2, 4]]
- @test col_names == [:One, :Two]
- end
-end
-
-
-# issues #62, #71
-@testset "windows compatibility" begin
- xf = XLSX.open_xlsx_template(joinpath(data_directory, "issue62_71.xlsx"))
- @test xf["Sheet1"]["A1"] == "One"
- @test xf["Sheet1"]["A2"] == 1
-
- @test collect(keys(xf.binary_data)) == ["xl/printerSettings/printerSettings1.bin"]
-end
-
-@testset "stream iterator" begin
- f = XLSX.openxlsx(joinpath(data_directory, "general.xlsx"), enable_cache=false)
- s = f["table"]
- for sheetrow in XLSX.eachrow(s)
- for column in 2:4
- cell = XLSX.getcell(sheetrow, column)
- if XLSX.row_number(cell) == 2 && XLSX.column_number(cell) == 2
- @test XLSX.getdata(s, cell) == "Column B"
- end
- if XLSX.row_number(cell) == 12 && XLSX.column_number(cell) == 2
- @test XLSX.getdata(s, cell) == "trash"
- end
- end
- end
-
-# Issue 446
- f = XLSX.openxlsx(joinpath(data_directory, "general.xlsx"), enable_cache=false)
-
- # The stream iterator moves on from row to row (the failure mode was row 1
- # forever), and yields the rows the file holds, in order.
- seen = [XLSX.row_number(r) for r in XLSX.eachrow(f[1])]
- expected = [XLSX.row_number(r) for r in XLSX.eachrow(XLSX.readxlsx(joinpath(data_directory, "general.xlsx"))[1])]
- @test seen == expected
- @test length(seen) > 1
-
-end
+# leave the temporary directory so it can be removed
+cd(test_run_directory)
diff --git a/test/test_files/Aqua_tests.jl b/test/test_files/Aqua_tests.jl
new file mode 100644
index 00000000..a215bd81
--- /dev/null
+++ b/test/test_files/Aqua_tests.jl
@@ -0,0 +1,15 @@
+
+#
+# Aqua_tests.jl - package-level checks with Aqua.jl: method ambiguities, unbound type
+# parameters, undefined exports, stale or uncapped dependencies, type piracy, and
+# tasks left running when the package is loaded. All together take about 15 seconds.
+#
+# `undocumented_names` stays off: the deprecated `chartSchema` and `chartType` aliases
+# have no docstrings, deliberately.
+#
+
+import Aqua
+
+@testset "Aqua" begin
+ Aqua.test_all(XLSX; undocumented_names=false)
+end
diff --git a/test/test_files/Cell-names_tests.jl b/test/test_files/Cell-names_tests.jl
index 8766f5ed..5aa0610a 100644
--- a/test/test_files/Cell-names_tests.jl
+++ b/test/test_files/Cell-names_tests.jl
@@ -70,42 +70,42 @@
@test in(XLSX.SheetCellRef("Sheet1!B2"), ncrng_mixed) == false # matches neither piece
@test in(XLSX.SheetCellRef("Sheet2!D4"), ncrng_mixed) == false # right position, wrong sheet
- cn = XLSX.CellRef("A1")
- @test string(cn) == "A1"
- @test XLSX.column_name(cn) == "A"
- @test XLSX.row_number(cn) == 1
- @test XLSX.column_number(cn) == 1
-
- cn = XLSX.CellRef("AB1")
- @test string(cn) == "AB1"
- @test XLSX.column_name(cn) == "AB"
- @test XLSX.row_number(cn) == 1
- @test XLSX.column_number(cn) == 28
-
- cn = XLSX.CellRef("AMI1")
- @test string(cn) == "AMI1"
- @test XLSX.column_name(cn) == "AMI"
- @test XLSX.row_number(cn) == 1
- @test XLSX.column_number(cn) == 1023
-
- cn = XLSX.CellRef("XFD1048576")
- @test string(cn) == "XFD1048576"
- @test XLSX.column_name(cn) == "XFD"
- @test XLSX.row_number(cn) == XLSX.EXCEL_MAX_ROWS
- @test XLSX.column_number(cn) == XLSX.EXCEL_MAX_COLS
-
- v_column_numbers = [1, 15, 22, 23, 24, 25, 26, 27, 28, 29, 30, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 284, 285, 286, 287, 288, 289, 296, 297, 299, 300, 301, 700, 701, 702, 703, 704, 705, 706, 727, 728, 729, 730, 731, 1008, 1013, 1014, 1015, 1016, 1017, 1018, 1023, 1024, 1376, 1377, 1378, 1379, 1380, 1381, 3379, 3380, 3381, 3382, 3383, 3403, 3404, 3405, 3406, 3407, 16250, 16251, 16354, 16355, 16384]
-
- v_column_names = ["A", "O", "V", "W", "X", "Y", "Z", "AA", "AB", "AC", "AD", "AL", "AM", "AN", "AO", "AP", "AQ", "AR", "AS", "AT", "AU", "AV", "AW", "AX", "AY", "AZ", "BA", "BB", "BC", "BD", "BE", "BF", "BG", "BH", "BI", "BV", "BW", "BX", "BY", "BZ", "CA", "CB", "CC", "CD", "CE", "CF", "CG", "CH", "JX", "JY", "JZ", "KA", "KB", "KC", "KJ", "KK", "KM", "KN", "KO", "ZX", "ZY", "ZZ", "AAA", "AAB", "AAC", "AAD", "AAY", "AAZ", "ABA", "ABB", "ABC", "ALT", "ALY", "ALZ", "AMA", "AMB", "AMC", "AMD", "AMI", "AMJ", "AZX", "AZY", "AZZ", "BAA", "BAB", "BAC", "DYY", "DYZ", "DZA", "DZB", "DZC", "DZW", "DZX", "DZY", "DZZ", "EAA", "WZZ", "XAA", "XDZ", "XEA", "XFD"]
-
- @assert length(v_column_names) == length(v_column_numbers) "Test script is wrong."
-
- for i in axes(v_column_names, 1)
- @test XLSX.encode_column_number(v_column_numbers[i]) == v_column_names[i]
- @test XLSX.decode_column_number(v_column_names[i]) == v_column_numbers[i]
- end
+ @testset "CellRef and column names" begin
+ cn = XLSX.CellRef("A1")
+ @test string(cn) == "A1"
+ @test XLSX.column_name(cn) == "A"
+ @test XLSX.row_number(cn) == 1
+ @test XLSX.column_number(cn) == 1
+
+ cn = XLSX.CellRef("AB1")
+ @test string(cn) == "AB1"
+ @test XLSX.column_name(cn) == "AB"
+ @test XLSX.row_number(cn) == 1
+ @test XLSX.column_number(cn) == 28
+
+ cn = XLSX.CellRef("AMI1")
+ @test string(cn) == "AMI1"
+ @test XLSX.column_name(cn) == "AMI"
+ @test XLSX.row_number(cn) == 1
+ @test XLSX.column_number(cn) == 1023
+
+ cn = XLSX.CellRef("XFD1048576")
+ @test string(cn) == "XFD1048576"
+ @test XLSX.column_name(cn) == "XFD"
+ @test XLSX.row_number(cn) == XLSX.EXCEL_MAX_ROWS
+ @test XLSX.column_number(cn) == XLSX.EXCEL_MAX_COLS
+
+ v_column_numbers = [1, 15, 22, 23, 24, 25, 26, 27, 28, 29, 30, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 284, 285, 286, 287, 288, 289, 296, 297, 299, 300, 301, 700, 701, 702, 703, 704, 705, 706, 727, 728, 729, 730, 731, 1008, 1013, 1014, 1015, 1016, 1017, 1018, 1023, 1024, 1376, 1377, 1378, 1379, 1380, 1381, 3379, 3380, 3381, 3382, 3383, 3403, 3404, 3405, 3406, 3407, 16250, 16251, 16354, 16355, 16384]
+
+ v_column_names = ["A", "O", "V", "W", "X", "Y", "Z", "AA", "AB", "AC", "AD", "AL", "AM", "AN", "AO", "AP", "AQ", "AR", "AS", "AT", "AU", "AV", "AW", "AX", "AY", "AZ", "BA", "BB", "BC", "BD", "BE", "BF", "BG", "BH", "BI", "BV", "BW", "BX", "BY", "BZ", "CA", "CB", "CC", "CD", "CE", "CF", "CG", "CH", "JX", "JY", "JZ", "KA", "KB", "KC", "KJ", "KK", "KM", "KN", "KO", "ZX", "ZY", "ZZ", "AAA", "AAB", "AAC", "AAD", "AAY", "AAZ", "ABA", "ABB", "ABC", "ALT", "ALY", "ALZ", "AMA", "AMB", "AMC", "AMD", "AMI", "AMJ", "AZX", "AZY", "AZZ", "BAA", "BAB", "BAC", "DYY", "DYZ", "DZA", "DZB", "DZC", "DZW", "DZX", "DZY", "DZZ", "EAA", "WZZ", "XAA", "XDZ", "XEA", "XFD"]
+
+ @assert length(v_column_names) == length(v_column_numbers) "Test script is wrong."
+
+ for i in axes(v_column_names, 1)
+ @test XLSX.encode_column_number(v_column_numbers[i]) == v_column_names[i]
+ @test XLSX.decode_column_number(v_column_names[i]) == v_column_numbers[i]
+ end
- @testset "CellRef" begin
ref = XLSX.CellRef(12, 2)
@test XLSX.cellname(ref) == "B12"
show(IOBuffer(), ref)
@@ -202,4 +202,28 @@
@test !XLSX.is_valid_fixed_sheet_cellrange("named_ranges!B4:C5")
@test XLSX.SheetCellRef("named_ranges!\$A\$2") == XLSX.SheetCellRef("named_ranges!A2")
@test XLSX.SheetCellRange("named_ranges!\$B\$4:\$C\$5") == XLSX.SheetCellRange("named_ranges!B4:C5")
+
+ @testset "out-of-range and malformed references are rejected" begin
+ # out-of-range column and row numbers are rejected
+ @test_throws XLSX.XLSXError XLSX.encode_column_number(0)
+ @test_throws XLSX.XLSXError XLSX.encode_column_number(XLSX.EXCEL_MAX_COLS + 1)
+ @test XLSX.encode_column_number(XLSX.EXCEL_MAX_COLS) == "XFD"
+ @test_throws XLSX.XLSXError XLSX.is_valid_row_range(string(XLSX.EXCEL_MAX_ROWS + 1))
+ @test XLSX.is_valid_row_range(string(XLSX.EXCEL_MAX_ROWS))
+ @test_throws XLSX.XLSXError XLSX.split_cellname("A")
+
+ @test !XLSX.is_valid_row_name("0")
+ @test !XLSX.is_valid_cellrange("A0:B2") && !XLSX.is_valid_cellrange("A1:B0")
+ @test !XLSX.is_valid_column_range("A:ZZZZ")
+ @test !XLSX.is_valid_row_range("0:2")
+ @test !XLSX.is_valid_sheet_cellrange("Sheet1!A0:B2")
+ @test !XLSX.is_valid_sheet_column_range("Sheet1!A:ZZZZ")
+ @test !XLSX.is_valid_sheet_row_range("Sheet1!0:2")
+
+ @test sprint(show, XLSX.SheetRowRange("Sheet1!2:4")) == "Sheet1!2:4"
+ @test_throws XLSX.XLSXError XLSX.NonContiguousRange("Sheet1!A1,Sheet2!B2")
+ @test_throws XLSX.XLSXError XLSX.nCR("Sheet1", ["not a ref"])
+ nc = XLSX.nCR("Sheet1", ["\$A\$1", "\$B\$1:\$C\$2"])
+ @test nc.rng == [XLSX.CellRef("A1"), XLSX.CellRange("B1:C2")]
+ end
end
diff --git a/test/test_files/ChartExProps_tests.jl b/test/test_files/ChartExProps_tests.jl
index 63f5cda5..328eff30 100644
--- a/test/test_files/ChartExProps_tests.jl
+++ b/test/test_files/ChartExProps_tests.jl
@@ -278,6 +278,26 @@
isfile(tmp) && rm(tmp)
end
+ @testset "every fill and line setter reaches a ChartEx" begin
+ xf = XLSX.openxlsx(joinpath(data_directory, "chartex_kinds.xlsx"); mode="rw")
+ c = XLSX.getCharts(xf)[1]
+ XLSX.Charts.setSeriesLineCap(c, 1, :round)
+ XLSX.Charts.setSeriesLineCompound(c, 1, :double)
+ XLSX.Charts.setSeriesLineJoin(c, 1, :miter)
+ XLSX.Charts.setSeriesLineMiterLimit(c, 1, 8)
+ ln = XLSX.Charts.getSeriesLine(c, 1).value
+ @test ln.cap == "rnd"
+ @test ln.compound == "dbl"
+ @test ln.join == "miter"
+ @test ln.miter_limit ≈ 8.0
+
+ XLSX.Charts.setSeriesFill(c, 1, :red) # a colour name as a Symbol
+ @test XLSX.Charts.getSeriesFill(c, 1).value.fgcolor.rgb == "FF0000"
+ XLSX.Charts.setSeriesFill(c, 1, :inherit)
+ @test isnothing(XLSX.Charts.getSeriesFill(c, 1).value)
+ SAVE_FILES && save_outfile(xf)
+ end
+
@testset "setLabelTextProp" begin
tmp = "cx_label.xlsx"
cp(joinpath(data_directory, "chartex_layouts.xlsx"), tmp; force=true)
@@ -308,6 +328,14 @@
@test isnothing(XLSX.Charts.getLabelTextProp(c, 1, :size).value)
@test_throws XLSX.XLSXError XLSX.Charts.setLabelTextProp(c, 1, :nonsense, 1)
+ # Excel's names for fields work here as they do on a Chart
+ XLSX.Charts.setLabelTextProp(c, 1, :font, "Arial")
+ XLSX.Charts.setLabelTextProp(c, 1, :color, "accent3")
+ XLSX.Charts.setLabelTextProp(c, 1, :underline, :dbl)
+ @test XLSX.Charts.getLabelTextProp(c, 1, :latin).value == "Arial"
+ @test XLSX.Charts.getLabelTextProp(c, 1, :fill).value.fgcolor.val == "accent3"
+ @test XLSX.Charts.getLabelTextProp(c, 1, :under).value == "dbl"
+
# an existing cx:dataLabels keeps the flags Excel wrote
w = only(filter(x -> x isa XLSX.Charts.ChartEx && x.sheet == "waterfall", XLSX.Charts.getCharts(xf)))
XLSX.Charts.setLabelTextProp(w, 1, :size, 9)
@@ -356,11 +384,36 @@
@test XLSX.XML.tag(lg) == "cx:legend"
@test XLSX.Charts.default_run_props(XLSX.Charts.getLegendTextProps(fn)).size ≈ 9.0
+ # legend position: cx:legend's pos and align attributes, and overlay
+ @test XLSX.Charts.getLegendPos(w) === :t # pos="t" align="ctr"
+ @test XLSX.Charts.getLegendOverlay(w) === false
+ for (p, pa, al) in ((:b, "b", "ctr"), (:t, "t", "ctr"), (:l, "l", "ctr"),
+ (:r, "r", "ctr"), (:tr, "r", "min"))
+ XLSX.Charts.setLegendPos(w, p)
+ @test XLSX.Charts.getLegendPos(w) === p
+ lg = XLSX.first_element_with_tag(XLSX.Charts._cx_chart(w), "legend")
+ @test XLSX.get_attr(lg, "pos") == pa && XLSX.get_attr(lg, "align") == al
+ end
+ XLSX.Charts.setLegendPos(w, :bottom; overlay=true)
+ @test XLSX.Charts.getLegendPos(w) === :b
+ @test XLSX.Charts.getLegendOverlay(w) === true
+ @test_throws XLSX.XLSXError XLSX.Charts.setLegendPos(w, :br)
+ XLSX.Charts.setLegendOverlay(w, :inherit)
+ @test isnothing(XLSX.Charts.getLegendOverlay(w))
+ XLSX.Charts.setLegendPos(w, :inherit)
+ @test isnothing(XLSX.Charts.getLegendPos(w))
+ XLSX.Charts.setLegendPos(w, :topRight; overlay=true)
+ @test XLSX.Charts.default_run_props(XLSX.Charts.getLegendTextProps(w)).bold === true # txPr kept
+ XLSX.Charts.setLegendPos(fn, :l) # funnel's created legend
+ @test XLSX.Charts.getLegendPos(fn) === :l
+
out = "cx_text_out.xlsx"
XLSX.writexlsx(out, xf, overwrite=true)
d = only(filter(x -> x isa XLSX.Charts.ChartEx && x.sheet == "waterfall",
XLSX.Charts.getCharts(XLSX.readxlsx(out))))
@test XLSX.Charts.default_run_props(XLSX.Charts.getChartTitleTextProps(d)).size ≈ 16.0
+ @test XLSX.Charts.getLegendPos(d) === :tr
+ @test XLSX.Charts.getLegendOverlay(d) === true
x = XLSX.getCharts(XLSX.readxlsx(joinpath(data_directory, "chartex_kinds.xlsx")))[1]
@test_throws XLSX.XLSXError XLSX.Charts.getAxisTitleTextProps(x, 999) # no such axis id
@@ -370,6 +423,32 @@
isfile(tmp) && rm(tmp)
end
+ @testset "legend positions as Excel writes them" begin
+ # Made by Excel 16: a column chart and a treemap for each Legend.Position,
+ # in the order bottom, corner (top-right, overlaid), left, right, top.
+ # Excel writes top-right as c:legendPos="tr" and as cx:legend pos="r" align="min".
+ xf = XLSX.readxlsx(joinpath(data_directory, "legend_positions.xlsx"))
+ cs = XLSX.Charts.getCharts(xf["Sheet1"])
+ @test map(typeof, cs) == repeat([XLSX.Charts.Chart, XLSX.Charts.ChartEx], 5)
+ @test map(XLSX.Charts.getLegendPos, cs) == repeat([:b, :tr, :l, :r, :t]; inner=2)
+ @test map(XLSX.Charts.getLegendOverlay, cs) == repeat([false, true, false, false, false]; inner=2)
+
+ # The setters write the same XML
+ tmp = "legend_positions_set.xlsx"
+ cp(joinpath(data_directory, "legend_positions.xlsx"), tmp; force=true)
+ xf = XLSX.openxlsx(tmp; mode="rw")
+ cs = XLSX.Charts.getCharts(xf["Sheet1"])
+ cx = cs[2] # bottom treemap
+ XLSX.Charts.setLegendPos(cx, :tr; overlay=true)
+ @test XLSX.Charts._cx_legend(cx).attributes == XLSX.Charts._cx_legend(cs[4]).attributes
+ c = XLSX.Charts.setLegendPos(cs[1], :tr; overlay=true)
+ val(n, t) = XLSX.get_attr(XLSX.first_element_with_tag(XLSX.Charts.getChartLegend(n), t), "val")
+ @test val(c, "legendPos") == val(cs[3], "legendPos") == "tr"
+ @test val(c, "overlay") == val(cs[3], "overlay") == "1"
+ SAVE_FILES && save_outfile(xf)
+ isfile(tmp) && rm(tmp)
+ end
+
@testset "setChartTitleText and setSeriesName" begin
tmp = "cx_text_set.xlsx"
cp(joinpath(data_directory, "chartex_layouts.xlsx"), tmp; force=true)
@@ -415,6 +494,177 @@
isfile(tmp) && rm(tmp)
end
+ @testset "rich text and cell-bound titles" begin
+ tmp = "cx_rich_titles.xlsx"
+ cp(joinpath(data_directory, "chartex_layouts.xlsx"), tmp; force=true)
+ xf = XLSX.openxlsx(tmp; mode="rw")
+ cx(sheet) = only(filter(x -> x isa XLSX.Charts.ChartEx && x.sheet == sheet, XLSX.Charts.getCharts(xf)))
+ cx_title(c) = XLSX.first_element_with_tag(XLSX.Charts._cx_chart(c), "title")
+ cx_axis_title(c, id) = XLSX.first_element_with_tag(XLSX.Charts._cx_axis_node(c, id), "title")
+ cx_f(t) = XLSX.child_text(XLSX.first_element_with_tag(XLSX.first_element_with_tag(t, "tx"), "txData"), "f")
+ runs(t) = [r for p in t.paragraphs for r in p.runs]
+ rts = XLSX.RichTextString(XLSX.RichTextRun("Bold", [:bold => true]),
+ XLSX.RichTextRun(" red\nline 2", [:color => "red"]))
+ try
+ # A typed title (Excel's own): each run starts from the old first run's
+ # rPr — which carries Excel's Aptos Narrow — with its own attributes on top.
+ h = cx("histogram")
+ XLSX.Charts.setChartTitleText(h, rts)
+ @test XLSX.Charts.getChartTitle(h) == "Bold red\nline 2" # cx:v, plain
+ body = XLSX.Charts.getChartTitleTextProps(h)
+ @test length(body.paragraphs) == 2
+ r = runs(body)
+ @test [x.text for x in r] == ["Bold", " red", "line 2"]
+ @test r[1].props.bold === true && r[2].props.bold === false # the old run's b="0"
+ @test r[2].props.fill.fgcolor.val == "FF0000" && r[3].props.fill.fgcolor.val == "FF0000"
+ @test all(x -> x.props.latin == "Aptos Narrow", r)
+ @test all(x -> x.props.size ≈ 14.0, r)
+ @test body.paragraphs[2].props.align == "ctr" # pPr copied
+
+ # A plain string with a newline gives two paragraphs too.
+ XLSX.Charts.setChartTitleText(h, "One\nTwo")
+ @test [x.text for x in runs(XLSX.Charts.getChartTitleTextProps(h))] == ["One", "Two"]
+ @test XLSX.Charts.getChartTitle(h) == "One\nTwo"
+
+ # A title with no cx:txPr: rich text creates one, runs carrying only their own.
+ w = cx("waterfall")
+ @test isnothing(XLSX.Charts.getChartTitleTextProps(w))
+ XLSX.Charts.setChartTitleText(w, "Plain") # plain: still none
+ @test isnothing(XLSX.Charts.getChartTitleTextProps(w))
+ XLSX.Charts.setChartTitleText(w, rts)
+ r = runs(XLSX.Charts.getChartTitleTextProps(w))
+ @test r[1].props.bold === true && isnothing(r[1].props.size)
+ @test isnothing(r[2].props.bold)
+
+ # Binding: cx:f and the cached text, the runs folded into the paragraph default.
+ XLSX.Charts.setChartTitleText(h, rts)
+ XLSX.Charts.setChartTitleText(h, XLSX.CellRef("A1"))
+ @test cx_f(cx_title(h)) == "histogram!\$A\$1"
+ @test !isnothing(XLSX.Charts.getChartTitleRange(h))
+ @test XLSX.Charts.getChartTitle(h) == string(xf["histogram"]["A1"])
+ body = XLSX.Charts.getChartTitleTextProps(h)
+ @test isempty(runs(body)) # no stale text
+ d = XLSX.Charts.default_run_props(body)
+ @test d.bold === true && d.latin == "Aptos Narrow" && d.size ≈ 14.0
+
+ # A numeric cell is cached as text; a rich-text cell lends what its runs share.
+ ws = xf["histogram"]
+ ws["Z1"] = 2026
+ ws["Z2"] = XLSX.RichTextString(XLSX.RichTextRun("Big", [:italic => true, :color => "blue"]),
+ XLSX.RichTextRun(" blue", [:italic => true, :color => "blue", :bold => false]))
+ XLSX.Charts.setChartTitleText(h, XLSX.SheetCellRef("histogram!Z1"))
+ @test XLSX.Charts.getChartTitle(h) == "2026"
+ XLSX.Charts.setChartTitleText(h, XLSX.CellRef("Z2"))
+ d = XLSX.Charts.default_run_props(XLSX.Charts.getChartTitleTextProps(h))
+ @test d.italic === true && d.fill.fgcolor.val == "0000FF"
+ @test d.bold === true # not shared, so kept
+ @test XLSX.Charts.getChartTitle(h) == "Big blue"
+
+ # Typing again unbinds it; the new runs inherit the folded formatting.
+ XLSX.Charts.setChartTitleText(h, "Typed")
+ @test isnothing(XLSX.Charts.getChartTitleRange(h))
+ @test isnothing(only(runs(XLSX.Charts.getChartTitleTextProps(h))).props)
+
+ # Excel's own bound title: the formula is a hidden _xlchart name, which the
+ # Range getter resolves. It can be rebound, and typed over with rich text.
+ b = cx("bound")
+ @test XLSX.Charts.getChartTitleRef(b) == "_xlchart.v1.31"
+ r = XLSX.Charts.getChartTitleRange(b)
+ @test r isa XLSX.SheetCellRef && r.sheet == "bound" && r.cellref == XLSX.CellRef("D1")
+ XLSX.Charts.setChartTitleText(b, XLSX.CellRef("A1"))
+ @test XLSX.Charts.getChartTitleRef(b) == "bound!\$A\$1"
+ @test cx_f(cx_title(b)) == "bound!\$A\$1"
+ XLSX.Charts.setChartTitleText(b, rts)
+ @test isnothing(XLSX.Charts.getChartTitleRange(b))
+ @test runs(XLSX.Charts.getChartTitleTextProps(b))[1].props.bold === true
+
+ # Axis titles, by axis id, created where there are none.
+ XLSX.Charts.setAxisTitleText(w, 1, rts)
+ at = XLSX.Charts.getAxisTitleTextProps(w, 1)
+ @test XLSX.Charts.text_content(at) == "Bold red\nline 2"
+ @test runs(at)[1].props.bold === true
+ @test XLSX.Charts.getAxisTitle(w, 1) == "Bold red\nline 2"
+ @test isnothing(XLSX.Charts.getAxisTitleRef(w, 1))
+ @test isnothing(XLSX.Charts.getAxisTitle(w, 0)) # no title at all
+ xf["waterfall"]["Z1"] = "Movement"
+ xf["waterfall"]["Z2"] = "Stage"
+ XLSX.Charts.setAxisTitleText(w, 0, XLSX.SheetCellRef("waterfall!Z1"))
+ @test cx_f(cx_axis_title(w, 0)) == "waterfall!\$Z\$1"
+ @test XLSX.Charts.getAxisTitleRef(w, 0) == "waterfall!\$Z\$1"
+ r = XLSX.Charts.getAxisTitleRange(w, 0)
+ @test r isa XLSX.SheetCellRef && r.sheet == "waterfall" && r.cellref == XLSX.CellRef("Z1")
+ @test XLSX.Charts.getAxisTitle(w, 0) == "Movement"
+ XLSX.Charts.setAxisTitleText(w, 1, XLSX.CellRef("Z3")) # empty: cx:v is ""
+ @test isnothing(XLSX.Charts.getAxisTitle(w, 1)) # as for a c: title
+ XLSX.Charts.setAxisTitleText(w, 1, XLSX.CellRef("Z2"))
+ @test cx_f(cx_axis_title(w, 1)) == "waterfall!\$Z\$2"
+ @test isempty(runs(XLSX.Charts.getAxisTitleTextProps(w, 1)))
+ @test_throws XLSX.XLSXError XLSX.Charts.setAxisTitleText(w, 7, "No such axis")
+
+ # Survives a write and reopen.
+ SAVE_FILES && save_outfile(xf)
+ out = "cx_rich_titles_out.xlsx"
+ XLSX.writexlsx(out, xf; overwrite=true)
+ try
+ yf = XLSX.readxlsx(out)
+ cy(sheet) = only(filter(x -> x isa XLSX.Charts.ChartEx && x.sheet == sheet, XLSX.Charts.getCharts(yf)))
+ @test XLSX.Charts.getChartTitle(cy("histogram")) == "Typed"
+ w2 = cy("waterfall")
+ @test runs(XLSX.Charts.getChartTitleTextProps(w2))[1].props.bold === true
+ @test XLSX.Charts.getAxisTitleRef(w2, 0) == "waterfall!\$Z\$1"
+ @test XLSX.Charts.getAxisTitle(w2, 1) == "Stage"
+ @test XLSX.Charts.getChartTitleRange(cy("bound")) === nothing
+ finally
+ isfile(out) && rm(out)
+ end
+ finally
+ isfile(tmp) && rm(tmp)
+ end
+ end
+
+ @testset "addChartEx titles" begin
+ xf = XLSX.newxlsx()
+ ws = xf[1]
+ ws["A1"] = "Flow"; ws["A2"] = "a"; ws["A3"] = "b"; ws["B2"] = 3; ws["B3"] = -1
+ rts = XLSX.RichTextString(XLSX.RichTextRun("Cash", [:bold => true]), XLSX.RichTextRun(" flow"))
+ c = XLSX.Charts.addChartEx(ws, :waterfall, "B2:B3"; anchor = "D2:K16", categories = "A2:A3", title = rts)
+ @test XLSX.Charts.getChartTitle(c) == "Cash flow"
+ r = only(XLSX.Charts.getChartTitleTextProps(c).paragraphs).runs
+ @test r[1].props.bold === true
+ b = XLSX.Charts.addChartEx(ws, :waterfall, "B2:B3"; anchor = "D20:K34", title = XLSX.CellRef("A1"))
+ @test XLSX.Charts.getChartTitle(b) == "Flow"
+ @test !isnothing(XLSX.Charts.getChartTitleRange(b))
+ s = XLSX.Charts.addChartEx(ws, :waterfall, "B2:B3"; anchor = "D40:K54", title = "A1")
+ @test isnothing(XLSX.Charts.getChartTitleRange(s)) # a string is literal
+
+ # A bad reference fails before anything is added.
+ n = length(XLSX.Charts.getCharts(ws))
+ @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(ws, :waterfall, "B2:B3"; anchor = "D60:K74",
+ title = XLSX.SheetCellRef("Nope!A1"))
+ @test length(XLSX.Charts.getCharts(ws)) == n
+ @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(xf, :waterfall, "Sheet1!B2:B3"; title = XLSX.CellRef("A1"))
+ @test XLSX.sheetnames(xf) == ["Sheet1"]
+ cs = XLSX.Charts.addChartEx(xf, :waterfall, "Sheet1!B2:B3"; title = XLSX.SheetCellRef("Sheet1!A1"))
+ @test XLSX.Charts.getChartTitle(cs) == "Flow"
+ @test_throws XLSX.XLSXError XLSX.Charts.setChartTitleText(cs, XLSX.CellRef("A1"))
+
+ @static if VERSION >= v"1.11-"
+ ss = styled"{bold:Styled} {(foreground=#ff0000):title}"
+ check(t) = begin
+ r = only(t.paragraphs).runs
+ @test [x.text for x in r] == ["Styled", " ", "title"]
+ @test r[1].props.bold === true && r[3].props.fill.fgcolor.val == "FF0000"
+ end
+ t = XLSX.Charts.addChartEx(ws, :waterfall, "B2:B3"; anchor = "D80:K94", title = ss)
+ check(XLSX.Charts.getChartTitleTextProps(t))
+ XLSX.Charts.setChartTitleText(c, ss)
+ check(XLSX.Charts.getChartTitleTextProps(c))
+ XLSX.Charts.setAxisTitleText(c, first(XLSX.Charts.getChartAxisIds(c)), ss)
+ check(XLSX.Charts.getAxisTitleTextProps(c, first(XLSX.Charts.getChartAxisIds(c))))
+ end
+ SAVE_FILES && save_outfile(xf)
+ end
+
@testset "setSeriesSubtotals" begin
tmp = "cx_subtotals.xlsx"
cp(joinpath(data_directory, "chartex_layouts.xlsx"), tmp; force=true)
@@ -550,24 +800,81 @@
SAVE_FILES && save_outfile(xf)
isfile(tmp) && rm(tmp)
end
- @testset "every line setter reaches a ChartEx" begin
- xf = XLSX.openxlsx(joinpath(data_directory, "chartex_kinds.xlsx"); mode="rw")
- c = XLSX.getCharts(xf)[1]
- XLSX.Charts.setSeriesLineCap(c, 1, :round)
- XLSX.Charts.setSeriesLineCompound(c, 1, :double)
- XLSX.Charts.setSeriesLineJoin(c, 1, :miter)
- XLSX.Charts.setSeriesLineMiterLimit(c, 1, 8)
- ln = XLSX.Charts.getSeriesLine(c, 1).value
- @test ln.cap == "rnd"
- @test ln.compound == "dbl"
- @test ln.join == "miter"
- @test ln.miter_limit ≈ 8.0
- XLSX.Charts.setSeriesFill(c, 1, :red) # a colour name as a Symbol
- @test XLSX.Charts.getSeriesFill(c, 1).value.fgcolor.rgb == "FF0000"
- XLSX.Charts.setSeriesFill(c, 1, :inherit)
- @test isnothing(XLSX.Charts.getSeriesFill(c, 1).value)
+ @testset "setting a run property clears it from the runs" begin
+ tmp = "runclear.xlsx"
+ cp(joinpath(data_directory, "chartex_layouts.xlsx"), tmp; force=true)
+ xf = XLSX.openxlsx(tmp; mode="rw")
+ h = only(filter(x -> x isa XLSX.Charts.ChartEx && x.sheet == "histogram", XLSX.Charts.getCharts(xf)))
+
+ # Excel wrote this title with sz on both a:defRPr and the run's a:rPr
+ XLSX.Charts.setChartTitleTextProp(h, :size, 20)
+ tx = XLSX.first_element_with_tag(
+ XLSX.first_element_with_tag(XLSX.Charts._cx_chart(h), "title"), "txPr")
+ p = XLSX.first_element_with_tag(tx, "p")
+ def = XLSX.first_element_with_tag(XLSX.first_element_with_tag(p, "pPr"), "defRPr")
+ rpr = XLSX.first_element_with_tag(XLSX.first_element_with_tag(p, "r"), "rPr")
+
+ @test XLSX.get_attr(def, "sz") == "2000"
+ @test isempty(XLSX.get_attr(rpr, "sz")) # cleared, so the default renders
+ @test XLSX.get_attr(rpr, "lang") == "en-GB" # the run keeps everything else
+
+ # what the reader resolves now matches what Excel will render
+ @test XLSX.Charts.default_run_props(XLSX.Charts.getChartTitleTextProps(h)).size ≈ 20.0
+
+ # a composite field: the run's own solidFill must go too
+ XLSX.Charts.setChartTitleTextProp(h, :fill, "FFC00000")
+ rpr = XLSX.first_element_with_tag(XLSX.first_element_with_tag(
+ XLSX.first_element_with_tag(
+ XLSX.first_element_with_tag(XLSX.Charts._cx_chart(h), "title"), "txPr"), "p"), "r")
+ @test isnothing(XLSX.first_element_with_tag(
+ XLSX.first_element_with_tag(rpr, "rPr"), "solidFill"))
+ @test XLSX.Charts.default_run_props(XLSX.Charts.getChartTitleTextProps(h)).fill.fgcolor.rgb == "C00000"
SAVE_FILES && save_outfile(xf)
+ isfile(tmp) && rm(tmp)
end
-end
\ No newline at end of file
+ @testset "Pareto line is reached with pareto = true" begin
+ kinds = XLSX.getCharts(XLSX.readxlsx(joinpath(data_directory, "chartex_kinds.xlsx")))
+ p = kinds[6] # Pareto
+ w = kinds[1] # waterfall: no Pareto line
+
+ # One series, as in Excel's Select Data; the line belongs to it
+ @test XLSX.Charts.getChartType(p) === :pareto
+ @test XLSX.Charts.getChartSeriesCount(p) == 1
+ @test XLSX.Charts.getSeriesLayout(p, 1) === :clusteredColumn
+ @test XLSX.Charts.getSeriesLayout(p, 1; pareto=true) === :paretoLine
+ @test_throws XLSX.XLSXError XLSX.Charts.getSeriesLayout(p, 2)
+ # the line is drawn against the secondary (percentage) axis
+ @test XLSX.Charts.getSeriesAxisIds(p, 1; pareto=true) != XLSX.Charts.getSeriesAxisIds(p, 1)
+
+ # pareto = true on a chart with no Pareto line is an error, not the series itself
+ @test_throws XLSX.XLSXError XLSX.Charts.getSeriesLayout(w, 1; pareto=true)
+ @test_throws XLSX.XLSXError XLSX.Charts.getSeriesLine(w, 1; pareto=true)
+
+ # A setter with pareto = true formats the line and leaves the columns alone
+ xf = XLSX.openxlsx(joinpath(data_directory, "chartex_kinds.xlsx"); mode="rw")
+ c = XLSX.getCharts(xf)[6]
+ cols_before = XLSX.Charts.getSeriesLine(c, 1)
+ line_before = XLSX.Charts.getSeriesLine(c, 1; pareto=true)
+ XLSX.Charts.setSeriesLineColor(c, 1, "FF0000"; pareto=true)
+ XLSX.Charts.setSeriesLineWidth(c, 1, 2.5; pareto=true)
+ line_after = XLSX.Charts.getSeriesLine(c, 1; pareto=true)
+ @test line_after != line_before
+ line_after = XLSX.Charts.getSeriesLine(c, 1; pareto=true)
+ @test line_after.value != line_before.value
+ @test XLSX.Charts.getSeriesLine(c, 1).value == cols_before.value
+ @test XLSX.Charts.getChartSeriesCount(c) == 1 # formatting adds no series
+
+ f = "pareto_line.xlsx"
+ XLSX.writexlsx(f, xf; overwrite=true)
+ SAVE_FILES && save_outfile(xf)
+ yf = XLSX.openxlsx(f; mode="rw")
+ d = XLSX.getCharts(yf)[6]
+ @test XLSX.Charts.getSeriesLine(d, 1; pareto=true).value == line_after.value
+ @test XLSX.Charts.getSeriesLine(d, 1).value == cols_before.value
+ @test XLSX.Charts.getChartSeriesCount(d) == 1
+ SAVE_FILES && save_outfile(yf)
+ isfile(f) && rm(f)
+ end
+end
diff --git a/test/test_files/ChartImports_tests.jl b/test/test_files/ChartImports_tests.jl
index 8b1eb6cb..bacbfa8a 100644
--- a/test/test_files/ChartImports_tests.jl
+++ b/test/test_files/ChartImports_tests.jl
@@ -101,4 +101,12 @@ end # module ChartImportCheck
@test isempty(unseen)
# Visible in Charts but a different object from XLSX's.
@test isempty(shadowed)
+
+ @testset "XLSX-level chart names" begin
+ for n in (:AbstractChart, :Chart, :ChartEx, :ChartRef, :ChartSeries,
+ :getChartSchema, :getChartType, :getCharts, :getChart, :getChartData, :getChartRanges)
+ @test getglobal(XLSX, n) === getglobal(XLSX.Charts, n)
+ VERSION >= v"1.11" && @test Base.ispublic(XLSX, n)
+ end
+ end
end
diff --git a/test/test_files/ChartProps_tests.jl b/test/test_files/ChartProps_tests.jl
index a2af1763..bbd3604a 100644
--- a/test/test_files/ChartProps_tests.jl
+++ b/test/test_files/ChartProps_tests.jl
@@ -29,7 +29,8 @@
# catAx 1926562432 @b delete="1" no spPr, no txPr, no title
#
# No axis title is bound to a cell, so getAxisTitleRef is nothing throughout —
-# as is getChartTitleRef. The c:strRef path for titles is untested.
+# as is getChartTitleRef. Bound titles are written and read back in the
+# "rich text and cell-bound titles" testset, which builds its own workbook.
#
# Series 1: spPr accent1 (a:ln present but noFill — hasLine false because the
# line has no fill, not because the element is absent). Series-level
@@ -77,12 +78,6 @@
# ChartEx.
# ---------------------------------------------------------------------------
-
-
-
-
-
-
@testset "ChartProps" begin
f = XLSX.readxlsx(joinpath(data_directory, "chart_appearance.xlsx"))
@@ -116,7 +111,7 @@
@test sp1.fill.kind == :solid
@test sp1.fill.fgcolor.rgb == "156082" # accent1, verified in Excel
@test XLSX.Charts.hasFill(sp1)
- @test !XLSX.Charts.hasLine(sp1) # no a:ln written at all
+ @test !XLSX.Charts.hasLine(sp1) # a:ln holds only a:noFill
@test !isnothing(sp1.line)
@test sp1.line.fill.kind == :none
@@ -137,42 +132,6 @@
end
end
- @testset "DrawingFill and DrawingColor show" begin
- local xf = XLSX.openxlsx(joinpath(data_directory, "chart_basic.xlsx"); mode="rw")
- local c = XLSX.getChart(xf["Data"], "chart1")
- XLSX.Charts.setSeriesFill(c, 1, "FF0000")
- local fl = XLSX.Charts.getSeriesFill(c, 1).value
- local s = sprint(show, MIME"text/plain"(), fl)
- @test startswith(s, "XLSX.Charts.DrawingFill ")
- @test occursin("foreground: #FF0000", s)
- local cs = sprint(show, MIME"text/plain"(), fl.fgcolor)
- @test startswith(cs, "XLSX.Charts.DrawingColor ")
- @test occursin("resolves to: #FF0000", cs)
- @test !isempty(sprint(show, fl.fgcolor))
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "show paths for scheme colours and absent formatting" begin
- local xf = XLSX.openxlsx(joinpath(data_directory, "chart_basic.xlsx"); mode="rw")
- local c = XLSX.getChart(xf["Data"], "chart1")
-
- XLSX.Charts.setSeriesFill(c, 1, XLSX.Charts.SchemeColor(:accent2))
- local s = sprint(show, MIME"text/plain"(), XLSX.Charts.getSeriesFill(c, 1).value)
- @test occursin("foreground: #E97132", s) # accent2, resolved through the theme
-
- XLSX.Charts.setSeriesLine(c, 2, :none)
- local sp = XLSX.Charts.getSeriesShapeProps(c, 2)
- @test occursin("no line", sprint(show, sp))
- XLSX.Charts.setSeriesFill(c, 2, :inherit)
- XLSX.Charts.setSeriesLine(c, 2, :inherit)
- sp = XLSX.Charts.getSeriesShapeProps(c, 2)
- if !isnothing(sp)
- s = sprint(show, sp)
- @test occursin("fill inherited", s) || occursin("line inherited", s)
- end
- SAVE_FILES && save_outfile(xf)
- end
-
@testset "series data labels" begin
tx1 = XLSX.Charts.getSeriesLabelTextProps(c, 1)
@test !isnothing(tx1)
@@ -180,7 +139,7 @@
@test rp.size == 10.5 # sz="1050", hundredths -> points
@test rp.fill.fgcolor.rgb == "104862" # accent1 + lumMod 75000
- # Series 2 and 3 have no series-level dLbls.
+ # Series 2 has its own series-level dLbls; series 3 has none.
tx2 = XLSX.Charts.getSeriesLabelTextProps(c, 2)
@test !isnothing(tx2)
rp2 = XLSX.Charts.default_run_props(tx2)
@@ -467,7 +426,7 @@
@test XLSX.Charts.getErrorBarsCustomRefs(c, e) == (plus=nothing, minus=nothing)
end
- @testset "other fixtures" begin
+ @testset "titles, groups and theme colors in other fixtures" begin
fb = XLSX.readxlsx(joinpath(data_directory, "chart_basic.xlsx"))
cb = XLSX.Charts.getCharts(fb)[1]
@@ -593,6 +552,18 @@
c = XLSX.Charts.setSeriesFill(c, 3, :red)
@test XLSX.Charts.getSeriesFill(c, 3).value.fgcolor.rgb == "FF0000"
+ # a theme slot named as a string or Symbol is a scheme color, as in addSeries
+ for name in ("accent4", :accent4)
+ c = XLSX.Charts.setSeriesFill(c, 3, name)
+ fg = XLSX.Charts.getSeriesFill(c, 3).value.fgcolor
+ @test fg.kind === :scheme && fg.val == "accent4"
+ end
+ c = XLSX.Charts.setSeriesLineColor(c, 3, :tx1)
+ fg = XLSX.Charts.getSeriesLine(c, 3).value.fill.fgcolor
+ @test fg.kind === :scheme && fg.val == "tx1"
+ c = XLSX.Charts.setSeriesLineColor(c, 3, "dk2") # an alias, kept as written
+ @test XLSX.Charts.getSeriesLine(c, 3).value.fill.fgcolor.val == "dk2"
+
# series 1 is in the bar group — the other branch of SER_TYPE
c = XLSX.Charts.setSeriesFill(c, 1, "green")
@test XLSX.Charts.getSeriesFill(c, 1).value.fgcolor.rgb == "008000"
@@ -691,13 +662,13 @@
SAVE_FILES && save_outfile(xf)
isfile(tmp) && rm(tmp)
- end
- @testset "chart error branches" begin
- local xf = XLSX.openxlsx(joinpath(data_directory, "chart_basic.xlsx"); mode="rw")
- local c = XLSX.getChart(xf["Data"], "chart1")
- @test_throws XLSX.XLSXError XLSX.Charts.setSeriesLine(c, 1, :bogus) # not :none/:inherit
- SAVE_FILES && save_outfile(xf)
+ @testset "an unknown symbol is rejected" begin
+ local xf = XLSX.openxlsx(joinpath(data_directory, "chart_basic.xlsx"); mode="rw")
+ local c = XLSX.getChart(xf["Data"], "chart1")
+ @test_throws XLSX.XLSXError XLSX.Charts.setSeriesLine(c, 1, :bogus) # not :none/:inherit
+ SAVE_FILES && save_outfile(xf)
+ end
end
@testset "setMarker" begin
@@ -803,6 +774,30 @@
@test_throws XLSX.XLSXError XLSX.Charts.setLabelTextProp(c, 1, :nonsense, 1)
+ # a scheme name works for a text fill too
+ c = XLSX.Charts.setLabelTextProp(c, 1, :color, "accent2")
+ @test XLSX.Charts.getLabelTextProp(c, 1, :fill).value.fgcolor.val == "accent2"
+
+ # underline, strike and caps take their DrawingML vocabularies — as Symbol
+ # or String — plus Excel's names and, for the first two, a Bool. Anything
+ # else would make a part Excel refuses, so it throws.
+ for (field, v, want) in ((:under, :dbl, "dbl"), (:underline, "wavy", "wavy"),
+ (:under, :single, "sng"), (:under, true, "sng"),
+ (:under, false, "none"),
+ (:strike, "dblStrike", "dblStrike"), (:strikethrough, true, "sngStrike"),
+ (:strike, :none, "noStrike"),
+ (:caps, :small, "small"), (:caps, "all", "all"))
+ c = XLSX.Charts.setLabelTextProp(c, 1, field, v)
+ f = get(XLSX.Charts._RUN_PROP_ALIASES, field, field)
+ @test XLSX.Charts.getLabelTextProp(c, 1, f).value == want
+ end
+ @test_throws XLSX.XLSXError XLSX.Charts.setLabelTextProp(c, 1, :under, "single-ish")
+ @test_throws XLSX.XLSXError XLSX.Charts.setLabelTextProp(c, 1, :strike, :sng)
+ @test_throws XLSX.XLSXError XLSX.Charts.setLabelTextProp(c, 1, :caps, true)
+ @test_throws XLSX.XLSXError XLSX.Charts.setLabelTextProp(c, 1, :under, 1)
+ c = XLSX.Charts.setLabelTextProp(c, 1, :under, :inherit)
+ @test isnothing(XLSX.Charts.getLabelTextProp(c, 1, :under).value)
+
# Series 3 has no c:dLbls at all — creating one writes formatting only.
@test isnothing(XLSX.Charts.getLabelTextProp(c, 3, :size).value)
c = XLSX.Charts.setLabelTextProp(c, 3, :size, 11)
@@ -836,12 +831,6 @@
@test !isnothing(dl)
@test XLSX.Charts.default_run_props(XLSX.Charts.getDataLabelTextProps(c, dl)).size ≈ 12.0
- # Point 1 of series 2 had no individual c:dLbl; the setter now creates one.
- XLSX.Charts.setLabelTextProp(c, 2, 1, :size, 12)
- dl = XLSX.Charts.getSeriesDataLabel(c, 2, 1)
- @test !isnothing(dl)
- @test XLSX.Charts.default_run_props(XLSX.Charts.getDataLabelTextProps(c, dl)).size ≈ 12.0
-
# the group rung. Series 1's txPr sets i="0" explicitly — Excel writes the
# full attribute set — so the group cannot be reached until that is removed.
c = XLSX.Charts.setLabelTextProp(c, 1, :italic, :inherit)
@@ -910,6 +899,10 @@
c = XLSX.Charts.setAxisTitleTextProp(c, ax, :bold, true)
ax = XLSX.Charts.getChartAxis(c, 612078287)
@test XLSX.Charts.default_run_props(XLSX.Charts.getAxisTitleText(c, ax)).bold === true
+ # The title's c:txPr carries it too, and is read by getAxisTitleTextProps.
+ @test XLSX.Charts.default_run_props(XLSX.Charts.getAxisTitleTextProps(c, ax)).bold === true
+ # The deleted catAx has no title at all.
+ @test isnothing(XLSX.Charts.getAxisTitleTextProps(c, XLSX.Charts.getChartAxis(c, 1926562432)))
# The legend has txPr and no c:tx, so only formatting is settable.
c = XLSX.Charts.setLegendTextProp(c, :size, 11)
@@ -1112,7 +1105,6 @@
SAVE_FILES && save_outfile(xf)
end
-
@testset "axis, group and legend setters" begin
tmp = "chart_setters_stackedbar.xlsx"
cp(joinpath(data_directory, "chart_kinds.xlsx"), tmp; force=true)
@@ -1224,6 +1216,42 @@
@test all(i -> isnothing(XLSX.Charts.getLegendEntryDeleted(c, i)), 1:3)
c = XLSX.Charts.setLegendEntryDeleted(c, 1, true)
+ # legend position and overlay: the five positions, Excel's names as aliases
+ for p in (:b, :t, :l, :r, :tr)
+ c = XLSX.Charts.setLegendPos(c, p)
+ @test XLSX.Charts.getLegendPos(c) === p
+ end
+ for (alias, p) in (:bottom => :b, :top => :t, :left => :l, :right => :r, :topRight => :tr)
+ c = XLSX.Charts.setLegendPos(c, alias)
+ @test XLSX.Charts.getLegendPos(c) === p
+ end
+ before = snapshot(c)
+ @test_throws XLSX.XLSXError XLSX.Charts.setLegendPos(c, :bl)
+ @test_throws XLSX.XLSXError XLSX.Charts.setLegendPos(c, :t; overlay=:yes)
+ @test snapshot(c) == before
+ c = XLSX.Charts.setLegendPos(c, :t; overlay=true)
+ @test XLSX.Charts.getLegendOverlay(c) === true
+ c = XLSX.Charts.setLegendPos(c, :l) # overlay untouched
+ @test XLSX.Charts.getLegendOverlay(c) === true
+ c = XLSX.Charts.setLegendOverlay(c, false)
+ @test XLSX.Charts.getLegendOverlay(c) === false
+ c = XLSX.Charts.setLegendOverlay(c, :inherit)
+ @test isnothing(XLSX.Charts.getLegendOverlay(c))
+ c = XLSX.Charts.setLegendPos(c, :inherit)
+ @test isnothing(XLSX.Charts.getLegendPos(c))
+ # a dragged legend's manual layout gives way to the position
+ lkey = (XLSX.Charts.NS_C, "legend")
+ pfx = XLSX.Charts.ns_prefixes(XLSX.Charts.chart_root(c))
+ c = XLSX.Charts._set_legend(c, (lg, key, _) -> XLSX.Charts.insert_child(lg, key,
+ XML.Element(XLSX.Charts.prefixed_tag(pfx[XLSX.Charts.NS_C], "layout"))))
+ @test !isnothing(XLSX.first_element_with_tag(XLSX.Charts.getChartLegend(c), "layout"))
+ c = XLSX.Charts.setLegendPos(c, :tr; overlay=true)
+ lg = XLSX.Charts.getChartLegend(c)
+ @test isnothing(XLSX.first_element_with_tag(lg, "layout"))
+ @test issorted([findfirst(==(XLSX.localname(k)), XLSX.Charts.CHILD_ORDER[lkey])
+ for k in XML.eachelement(lg)])
+ @test XLSX.Charts.getLegendEntryDeleted(c, 1) === true # entries kept
+
# survives a write and reopen
SAVE_FILES && save_outfile(xf)
XLSX.writexlsx(tmp, xf; overwrite=true)
@@ -1241,139 +1269,730 @@
@test XLSX.Charts.getAxisNumberFormatCode(c2, cat2) == escaped
@test XLSX.Charts.getGroupGapWidth(c2, g2) == 50
@test XLSX.Charts.getLegendEntryDeleted(c2, 1) === true
+ @test XLSX.Charts.getLegendPos(c2) === :tr
+ @test XLSX.Charts.getLegendOverlay(c2) === true
finally
isfile(tmp) && rm(tmp)
end
- @testset "axis styling, units and title overlay" begin
- tmp = "chart_styling_stackedbar.xlsx"
- cp(joinpath(data_directory, "chart_kinds.xlsx"), tmp; force=true)
- try
- xf = XLSX.openxlsx(tmp; mode="rw")
- local c = first(XLSX.Charts.getCharts(xf["stackedbar"]))
- axs = XLSX.Charts.getChartAxes(c)
- catax = only(filter(a -> a.kind === :catAx, axs))
- valax = only(filter(a -> a.kind === :valAx, axs))
- snapshot(c) = XML.write(XLSX.Charts.chart_root(c))
- axorder(c, ax) = issorted([findfirst(==(XLSX.localname(k)),
- XLSX.Charts.CHILD_ORDER[(XLSX.Charts.NS_C, String(ax.kind))])
- for k in XML.eachelement(XLSX.Charts._axnode(c, ax))])
-
- # tick marks, in either vocabulary
- @test XLSX.Charts.getAxisMajorTickMark(c, catax) === :none
- c = XLSX.Charts.setAxisMajorTickMark(c, catax, :cross)
- @test XLSX.Charts.getAxisMajorTickMark(c, catax) === :cross
- c = XLSX.Charts.setAxisMajorTickMark(c, valax, :outside)
- @test XLSX.Charts.getAxisMajorTickMark(c, valax) === :out
- c = XLSX.Charts.setAxisMinorTickMark(c, valax, :inside)
- @test XLSX.Charts.getAxisMinorTickMark(c, valax) === :in
- before = snapshot(c)
- @test_throws XLSX.XLSXError XLSX.Charts.setAxisMajorTickMark(c, valax, :sideways)
- @test snapshot(c) == before # atomic
- c = XLSX.Charts.setAxisMinorTickMark(c, valax, :inherit)
- @test isnothing(XLSX.Charts.getAxisMinorTickMark(c, valax))
-
- # gridlines: presence is the switch
- has(c, ax, tag) = !isnothing(XLSX.first_element_with_tag(XLSX.Charts._axnode(c, ax), tag))
- @test has(c, valax, "majorGridlines")
- c = XLSX.Charts.setAxisGridlines(c, valax; major=false)
- @test !has(c, valax, "majorGridlines")
- c = XLSX.Charts.setAxisGridlines(c, valax; major=true, minor=true)
- @test has(c, valax, "majorGridlines") && has(c, valax, "minorGridlines")
- @test axorder(c, valax)
- c = XLSX.Charts.setAxisGridlines(c, valax; major=true) # already there: no-op
- @test count(k -> XLSX.localname(k) == "majorGridlines",
- XML.eachelement(XLSX.Charts._axnode(c, valax))) == 1
- @test_throws XLSX.XLSXError XLSX.Charts.setAxisGridlines(c, valax; minor=:off)
- @test XLSX.Charts.setAxisGridlines(c, valax) === c
- c = XLSX.Charts.setAxisGridlines(c, valax; major=false, minor=false)
-
- # tick label text
- c = XLSX.Charts.setAxisTextProp(c, catax, :size, 14)
- c = XLSX.Charts.setAxisTextProp(c, catax, :latin, "Comic Sans MS")
- txpr = XLSX.first_element_with_tag(XLSX.Charts._axnode(c, catax), "txPr")
- p = XLSX.first_element_with_tag(txpr, "p")
- rpr = XLSX.first_element_with_tag(XLSX.first_element_with_tag(p, "pPr"), "defRPr")
- @test XLSX.Charts._attr(rpr, "sz") == "1400"
- @test XLSX.Charts._attr(XLSX.first_element_with_tag(rpr, "latin"), "typeface") == "Comic Sans MS"
-
- # Excel's names are accepted as aliases for the DrawingML fields
- c = XLSX.Charts.setAxisTextProp(c, catax, :font, "Arial")
- rpr = XLSX.first_element_with_tag(
+ end
+
+ @testset "axis styling, units and title overlay" begin
+ tmp = "chart_styling_stackedbar.xlsx"
+ cp(joinpath(data_directory, "chart_kinds.xlsx"), tmp; force=true)
+ try
+ xf = XLSX.openxlsx(tmp; mode="rw")
+ local c = first(XLSX.Charts.getCharts(xf["stackedbar"]))
+ axs = XLSX.Charts.getChartAxes(c)
+ catax = only(filter(a -> a.kind === :catAx, axs))
+ valax = only(filter(a -> a.kind === :valAx, axs))
+ snapshot(c) = XML.write(XLSX.Charts.chart_root(c))
+ axorder(c, ax) = issorted([findfirst(==(XLSX.localname(k)),
+ XLSX.Charts.CHILD_ORDER[(XLSX.Charts.NS_C, String(ax.kind))])
+ for k in XML.eachelement(XLSX.Charts._axnode(c, ax))])
+
+ # tick marks, in either vocabulary
+ @test XLSX.Charts.getAxisMajorTickMark(c, catax) === :none
+ c = XLSX.Charts.setAxisMajorTickMark(c, catax, :cross)
+ @test XLSX.Charts.getAxisMajorTickMark(c, catax) === :cross
+ c = XLSX.Charts.setAxisMajorTickMark(c, valax, :outside)
+ @test XLSX.Charts.getAxisMajorTickMark(c, valax) === :out
+ c = XLSX.Charts.setAxisMinorTickMark(c, valax, :inside)
+ @test XLSX.Charts.getAxisMinorTickMark(c, valax) === :in
+ before = snapshot(c)
+ @test_throws XLSX.XLSXError XLSX.Charts.setAxisMajorTickMark(c, valax, :sideways)
+ @test snapshot(c) == before # atomic
+ c = XLSX.Charts.setAxisMinorTickMark(c, valax, :inherit)
+ @test isnothing(XLSX.Charts.getAxisMinorTickMark(c, valax))
+
+ # gridlines: presence is the switch
+ has(c, ax, tag) = !isnothing(XLSX.first_element_with_tag(XLSX.Charts._axnode(c, ax), tag))
+ @test has(c, valax, "majorGridlines")
+ c = XLSX.Charts.setAxisGridlines(c, valax; major=false)
+ @test !has(c, valax, "majorGridlines")
+ c = XLSX.Charts.setAxisGridlines(c, valax; major=true, minor=true)
+ @test has(c, valax, "majorGridlines") && has(c, valax, "minorGridlines")
+ @test axorder(c, valax)
+ c = XLSX.Charts.setAxisGridlines(c, valax; major=true) # already there: no-op
+ @test count(k -> XLSX.localname(k) == "majorGridlines",
+ XML.eachelement(XLSX.Charts._axnode(c, valax))) == 1
+ @test_throws XLSX.XLSXError XLSX.Charts.setAxisGridlines(c, valax; minor=:off)
+ @test XLSX.Charts.setAxisGridlines(c, valax) === c
+ c = XLSX.Charts.setAxisGridlines(c, valax; major=false, minor=false)
+
+ # tick label text
+ c = XLSX.Charts.setAxisTextProp(c, catax, :size, 14)
+ c = XLSX.Charts.setAxisTextProp(c, catax, :latin, "Comic Sans MS")
+ txpr = XLSX.first_element_with_tag(XLSX.Charts._axnode(c, catax), "txPr")
+ p = XLSX.first_element_with_tag(txpr, "p")
+ rpr = XLSX.first_element_with_tag(XLSX.first_element_with_tag(p, "pPr"), "defRPr")
+ @test XLSX.Charts._attr(rpr, "sz") == "1400"
+ @test XLSX.Charts._attr(XLSX.first_element_with_tag(rpr, "latin"), "typeface") == "Comic Sans MS"
+
+ # Excel's names are accepted as aliases for the DrawingML fields
+ c = XLSX.Charts.setAxisTextProp(c, catax, :font, "Arial")
+ rpr = XLSX.first_element_with_tag(
+ XLSX.first_element_with_tag(
XLSX.first_element_with_tag(
- XLSX.first_element_with_tag(
- XLSX.first_element_with_tag(XLSX.Charts._axnode(c, catax), "txPr"),
- "p"), "pPr"), "defRPr")
- @test XLSX.Charts._attr(XLSX.first_element_with_tag(rpr, "latin"), "typeface") == "Arial"
- c = XLSX.Charts.setAxisTextProp(c, catax, :color, "FF0000")
- @test XLSX.Charts.getAxisTextProps(c, catax) isa XLSX.Charts.DrawingText
- @test_throws XLSX.XLSXError XLSX.Charts.setAxisTextProp(c, catax, :typeface, "Arial")
-
- # axis line and plot area border
- @test !XLSX.Charts.hasLine(XLSX.Charts.getAxisShapeProps(c, valax)) # template: noFill
- c = XLSX.Charts.setAxisLine(c, valax; color="000000", width=1)
- @test XLSX.Charts.hasLine(XLSX.Charts.getAxisShapeProps(c, valax))
- @test XLSX.Charts.setAxisLine(c, valax) === c
- c = XLSX.Charts.setAxisLine(c, valax, :none)
- @test !XLSX.Charts.hasLine(XLSX.Charts.getAxisShapeProps(c, valax))
- c = XLSX.Charts.setAxisLine(c, valax, :inherit)
- @test isnothing(XLSX.first_element_with_tag(XLSX.Charts.getAxisShapeProps(c, valax).raw, "ln"))
- @test_throws XLSX.XLSXError XLSX.Charts.setAxisLine(c, valax, :dotted)
-
- @test !XLSX.Charts.hasLine(XLSX.Charts.getPlotAreaShapeProps(c))
- c = XLSX.Charts.setPlotAreaLine(c; color="000000")
- @test XLSX.Charts.hasLine(XLSX.Charts.getPlotAreaShapeProps(c))
- c = XLSX.Charts.setPlotAreaLine(c, :inherit)
- @test isnothing(XLSX.first_element_with_tag(XLSX.Charts.getPlotAreaShapeProps(c).raw, "ln"))
- c = XLSX.Charts.setPlotAreaLine(c; color="000000")
-
- # major and minor units: value and date axes only
- @test isnothing(XLSX.Charts.getAxisMajorUnit(c, valax))
- c = XLSX.Charts.setAxisMajorUnit(c, valax, 0.2)
- @test XLSX.Charts.getAxisMajorUnit(c, valax) ≈ 0.2
- c = XLSX.Charts.setAxisMinorUnit(c, valax, 0.05)
- @test XLSX.Charts.getAxisMinorUnit(c, valax) ≈ 0.05
- @test axorder(c, valax)
- @test_throws XLSX.XLSXError XLSX.Charts.setAxisMajorUnit(c, valax, 0)
- @test_throws XLSX.XLSXError XLSX.Charts.setAxisMajorUnit(c, valax, -1)
- before = snapshot(c)
- @test_throws XLSX.XLSXError XLSX.Charts.setAxisMajorUnit(c, catax, 1)
- @test snapshot(c) == before
- c = XLSX.Charts.setAxisMinorUnit(c, valax, :inherit)
- @test isnothing(XLSX.Charts.getAxisMinorUnit(c, valax))
-
- # a title created by a setter is not drawn over the plot area
- title(c, ax) = XLSX.first_element_with_tag(XLSX.Charts._axnode(c, ax), "title")
- @test isnothing(title(c, valax))
- c = XLSX.Charts.setAxisTitleText(c, valax, "Proportion of responses")
- @test XLSX.Charts._bool_val(title(c, valax), "overlay") === false
- @test [XLSX.localname(k) for k in XML.eachelement(title(c, valax))] == ["tx", "overlay"]
- c = XLSX.Charts.setAxisTitleText(c, valax, "Share of responses") # replaced, not duplicated
- @test count(k -> XLSX.localname(k) == "overlay", XML.eachelement(title(c, valax))) == 1
- @test axorder(c, valax)
- # the chart title's own overlay is kept as it was
- ov = XLSX.Charts._bool_val(XLSX.Charts.getChartTitleNode(c), "overlay")
- c = XLSX.Charts.setChartTitleText(c, "Course feedback")
- @test XLSX.Charts._bool_val(XLSX.Charts.getChartTitleNode(c), "overlay") === ov
-
- # survives a write and reopen
+ XLSX.first_element_with_tag(XLSX.Charts._axnode(c, catax), "txPr"),
+ "p"), "pPr"), "defRPr")
+ @test XLSX.Charts._attr(XLSX.first_element_with_tag(rpr, "latin"), "typeface") == "Arial"
+ c = XLSX.Charts.setAxisTextProp(c, catax, :color, "FF0000")
+ @test XLSX.Charts.getAxisTextProps(c, catax) isa XLSX.Charts.DrawingText
+ @test_throws XLSX.XLSXError XLSX.Charts.setAxisTextProp(c, catax, :typeface, "Arial")
+
+ # axis line and plot area border
+ @test !XLSX.Charts.hasLine(XLSX.Charts.getAxisShapeProps(c, valax)) # template: noFill
+ c = XLSX.Charts.setAxisLine(c, valax; color="000000", width=1)
+ @test XLSX.Charts.hasLine(XLSX.Charts.getAxisShapeProps(c, valax))
+ @test XLSX.Charts.setAxisLine(c, valax) === c
+ c = XLSX.Charts.setAxisLine(c, valax, :none)
+ @test !XLSX.Charts.hasLine(XLSX.Charts.getAxisShapeProps(c, valax))
+ c = XLSX.Charts.setAxisLine(c, valax, :inherit)
+ @test isnothing(XLSX.first_element_with_tag(XLSX.Charts.getAxisShapeProps(c, valax).raw, "ln"))
+ @test_throws XLSX.XLSXError XLSX.Charts.setAxisLine(c, valax, :dotted)
+
+ @test !XLSX.Charts.hasLine(XLSX.Charts.getPlotAreaShapeProps(c))
+ c = XLSX.Charts.setPlotAreaLine(c; color="000000")
+ @test XLSX.Charts.hasLine(XLSX.Charts.getPlotAreaShapeProps(c))
+ c = XLSX.Charts.setPlotAreaLine(c, :inherit)
+ @test isnothing(XLSX.first_element_with_tag(XLSX.Charts.getPlotAreaShapeProps(c).raw, "ln"))
+ c = XLSX.Charts.setPlotAreaLine(c; color="000000")
+
+ # major and minor units: value and date axes only
+ @test isnothing(XLSX.Charts.getAxisMajorUnit(c, valax))
+ c = XLSX.Charts.setAxisMajorUnit(c, valax, 0.2)
+ @test XLSX.Charts.getAxisMajorUnit(c, valax) ≈ 0.2
+ c = XLSX.Charts.setAxisMinorUnit(c, valax, 0.05)
+ @test XLSX.Charts.getAxisMinorUnit(c, valax) ≈ 0.05
+ @test axorder(c, valax)
+ @test_throws XLSX.XLSXError XLSX.Charts.setAxisMajorUnit(c, valax, 0)
+ @test_throws XLSX.XLSXError XLSX.Charts.setAxisMajorUnit(c, valax, -1)
+ before = snapshot(c)
+ @test_throws XLSX.XLSXError XLSX.Charts.setAxisMajorUnit(c, catax, 1)
+ @test snapshot(c) == before
+ c = XLSX.Charts.setAxisMinorUnit(c, valax, :inherit)
+ @test isnothing(XLSX.Charts.getAxisMinorUnit(c, valax))
+
+ # a title created by a setter is not drawn over the plot area
+ title(c, ax) = XLSX.first_element_with_tag(XLSX.Charts._axnode(c, ax), "title")
+ @test isnothing(title(c, valax))
+ c = XLSX.Charts.setAxisTitleText(c, valax, "Proportion of responses")
+ @test XLSX.Charts._bool_val(title(c, valax), "overlay") === false
+ @test [XLSX.localname(k) for k in XML.eachelement(title(c, valax))] == ["tx", "overlay"]
+ c = XLSX.Charts.setAxisTitleText(c, valax, "Share of responses") # replaced, not duplicated
+ @test count(k -> XLSX.localname(k) == "overlay", XML.eachelement(title(c, valax))) == 1
+ @test axorder(c, valax)
+ # the chart title's own overlay is kept as it was
+ ov = XLSX.Charts._bool_val(XLSX.Charts.getChartTitleNode(c), "overlay")
+ c = XLSX.Charts.setChartTitleText(c, "Course feedback")
+ @test XLSX.Charts._bool_val(XLSX.Charts.getChartTitleNode(c), "overlay") === ov
+
+ # survives a write and reopen
+ SAVE_FILES && save_outfile(xf)
+ XLSX.writexlsx(tmp, xf; overwrite=true)
+ xf2 = XLSX.openxlsx(tmp; mode="rw")
+ SAVE_FILES && save_outfile(xf2)
+ c2 = first(XLSX.Charts.getCharts(xf2["stackedbar"]))
+ ax2 = XLSX.Charts.getChartAxes(c2)
+ cat2 = only(filter(a -> a.kind === :catAx, ax2))
+ val2 = only(filter(a -> a.kind === :valAx, ax2))
+ @test XLSX.Charts.getAxisMajorTickMark(c2, cat2) === :cross
+ @test XLSX.Charts.getAxisMajorUnit(c2, val2) ≈ 0.2
+ @test XLSX.Charts.hasLine(XLSX.Charts.getPlotAreaShapeProps(c2))
+ @test XLSX.Charts._bool_val(title(c2, val2), "overlay") === false
+ @test isnothing(XLSX.first_element_with_tag(XLSX.Charts._axnode(c2, val2), "majorGridlines"))
+ finally
+ isfile(tmp) && rm(tmp)
+ end
+ end
+
+ @testset "rich text, cell-bound titles and part prefixes" begin
+ # A sheet with a typed, a numeric and a rich-text cell, and one column chart.
+ function rich_fixture()
+ xf = XLSX.newxlsx()
+ ws = xf[1]
+ ws["A1"] = "Q1"; ws["A2"] = 10; ws["A3"] = 20; ws["B1"] = 2026
+ ws["C1"] = XLSX.RichTextString(
+ XLSX.RichTextRun("Big", [:bold => true, :color => "red"]),
+ XLSX.RichTextRun(" red", [:bold => true, :color => "red", :italic => true]))
+ c = XLSX.Charts.addChart(ws, :column; anchor = "D2:K16")
+ XLSX.Charts.addSeries(c, "A2:A3")
+ return xf, c
+ end
+ title_node(c) = XLSX.first_element_with_tag(
+ XLSX.first_element_with_tag(XLSX.Charts.chart_root(c), "chart"), "title")
+ run_props(atts) = XLSX.Charts._rich_run_props(atts)
+
+ @testset "attribute mapping" begin
+ @test isnothing(run_props(nothing))
+ for v in (true, 1)
+ @test run_props(Dict{Symbol,Any}(:bold => v)).bold === true
+ @test run_props(Dict{Symbol,Any}(:italic => v)).italic === true
+ @test run_props(Dict{Symbol,Any}(:under => v)).under == "sng"
+ @test run_props(Dict{Symbol,Any}(:strike => v)).strike == "sngStrike"
+ end
+ for v in (false, 0) # explicit off, not absent
+ @test run_props(Dict{Symbol,Any}(:bold => v)).bold === false
+ @test run_props(Dict{Symbol,Any}(:italic => v)).italic === false
+ @test run_props(Dict{Symbol,Any}(:under => v)).under == "none"
+ @test run_props(Dict{Symbol,Any}(:strike => v)).strike == "noStrike"
+ end
+ @test_throws XLSX.XLSXError run_props(Dict{Symbol,Any}(:bold => "yes"))
+ @test run_props(Dict{Symbol,Any}(:vertAlign => "superscript")).baseline ≈ 0.30
+ @test run_props(Dict{Symbol,Any}(:vertAlign => "subscript")).baseline ≈ -0.25
+ @test run_props(Dict{Symbol,Any}(:vertAlign => "baseline")).baseline == 0.0
+ @test_throws XLSX.XLSXError run_props(Dict{Symbol,Any}(:vertAlign => "raised"))
+ @test run_props(Dict{Symbol,Any}(:size => 14)).size === 14.0
+ @test run_props(Dict{Symbol,Any}(:name => "Arial")).latin == "Arial"
+ for col in ("red", :red, "FF0000", "#FF0000", "FFFF0000", "#ffff0000")
+ f = run_props(Dict{Symbol,Any}(:color => col)).fill
+ @test f.kind === :solid && f.fgcolor == "FFFF0000"
+ end
+ @test_throws XLSX.XLSXError run_props(Dict{Symbol,Any}(:color => "notacolour"))
+ p = run_props(Dict{Symbol,Any}(:bold => true)) # unset fields inherit
+ @test isnothing(p.italic) && isnothing(p.size) && isnothing(p.fill)
+
+ # Written as Excel writes them: baseline in thousandths of a percent.
+ xf, c = rich_fixture()
+ rts = XLSX.RichTextString(XLSX.RichTextRun("x"),
+ XLSX.RichTextRun("2", [:vertAlign => "superscript"]),
+ XLSX.RichTextRun("i", [:vertAlign => "subscript"]))
+ XLSX.Charts.setChartTitleText(c, rts)
+ rprs = collect(XLSX.elements_with_tag(only(XLSX.elements_with_tag(XLSX.first_element_with_tag(
+ XLSX.first_element_with_tag(title_node(c), "tx"), "rich"), "p")), "r"))
+ @test isnothing(XLSX.first_element_with_tag(rprs[1], "rPr")) # no attributes, no rPr
+ @test [XLSX.get_attr(XLSX.first_element_with_tag(r, "rPr"), "baseline") for r in rprs[2:3]] ==
+ ["30000", "-25000"]
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "newlines" begin
+ rts = XLSX.RichTextString(XLSX.RichTextRun("one\ntwo", [:bold => true]),
+ XLSX.RichTextRun(" three\r\n", [:italic => true]),
+ XLSX.RichTextRun("\nfour"))
+ # In a text body a newline starts a paragraph.
+ t = XLSX.Charts.DrawingText(rts)
+ @test length(t.paragraphs) == 4
+ @test [r.text for r in t.paragraphs[1].runs] == ["one"]
+ @test [r.text for r in t.paragraphs[2].runs] == ["two", " three"]
+ @test t.paragraphs[2].runs[1].props.bold === true
+ @test t.paragraphs[2].runs[2].props.italic === true
+ @test isempty(t.paragraphs[3].runs)
+ @test isnothing(t.paragraphs[3].endprops) # its newline is in an unformatted run
+ @test [r.text for r in t.paragraphs[4].runs] == ["four"]
+ @test XLSX.Charts.text_content(t) == "one\ntwo three\n\nfour"
+ # A blank line keeps the formatting of the run its newline is in.
+ t2 = XLSX.Charts.DrawingText(XLSX.RichTextString(XLSX.RichTextRun("a\n\nb", [:size => 9])))
+ @test t2.paragraphs[2].endprops.size == 9.0
+
+ # In a paragraph it is an a:br, carrying the run's formatting.
+ p = XLSX.Charts.DrawingParagraph(rts)
+ @test [r.kind for r in p.runs] == [:run, :br, :run, :run, :br, :br, :run]
+ @test p.runs[2].props.bold === true
+ @test p.runs[5].props.italic === true
+ @test isnothing(p.runs[6].props)
+ end
+
+ @testset "setters keep run formatting" begin
+ xf, c = rich_fixture()
+ rts = XLSX.RichTextString(XLSX.RichTextRun("Revenue", [:bold => true, :size => 16]),
+ XLSX.RichTextRun(" £m", [:color => "blue", :italic => true]))
+ XLSX.Charts.setChartTitleText(c, rts)
+ g = XLSX.Charts.getChartTitleText(c)
+ @test XLSX.Charts.text_content(g) == "Revenue £m"
+ r1, r2 = only(g.paragraphs).runs
+ @test r1.props.bold === true && r1.props.size ≈ 16.0
+ @test r2.props.italic === true && r2.props.fill.fgcolor.val == "0000FF"
+ @test XLSX.Charts._bool_val(title_node(c), "overlay") === false
+
+ ax = first(XLSX.Charts.getChartAxes(c, :value))
+ XLSX.Charts.setAxisTitleText(c, ax, rts)
+ ax = first(XLSX.Charts.getChartAxes(c, :value))
+ ga = XLSX.Charts.getAxisTitleText(c, ax)
+ @test only(ga.paragraphs).runs[2].props.italic === true
+
+ XLSX.Charts.setLabelText(c, 1, 2, rts)
+ gl = XLSX.Charts.getDataLabelText(c, XLSX.Charts.getSeriesDataLabel(c, 1, 2))
+ @test XLSX.Charts.text_content(gl) == "Revenue £m"
+ @test only(gl.paragraphs).runs[1].props.size ≈ 16.0
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "addChart with a RichTextString title" begin
+ xf, _ = rich_fixture()
+ ws = xf[1]
+ rts = XLSX.RichTextString(XLSX.RichTextRun("Sales", [:bold => true]),
+ XLSX.RichTextRun(" 2026\nby region", [:color => "red"]))
+ c = XLSX.Charts.addChart(ws, :line; anchor = "D20:K34", title = rts)
+ g = XLSX.Charts.getChartTitleText(c)
+ @test XLSX.Charts.text_content(g) == "Sales 2026\nby region"
+ @test length(g.paragraphs) == 2
+ # The template's paragraph defaults are kept on every line ...
+ for p in g.paragraphs
+ @test p.props.defprops.size ≈ 14.0
+ @test p.props.defprops.bold === false
+ end
+ # ... and the runs carry only their own attributes, with no lang.
+ r1, r2 = g.paragraphs[1].runs
+ @test r1.props.bold === true && isnothing(r1.props.size) && isnothing(r1.props.lang)
+ @test r2.props.fill.fgcolor.val == "FF0000" && isnothing(r2.props.bold)
+ @test g.paragraphs[2].runs[1].props.fill.fgcolor.val == "FF0000"
+
+ # A plain string title keeps the template's formatting; its run has no a:rPr.
+ p = XLSX.Charts.addChart(ws, :bar; anchor = "D40:K54", title = "Plain")
+ gp = XLSX.Charts.getChartTitleText(p)
+ @test XLSX.Charts.text_content(gp) == "Plain"
+ @test only(gp.paragraphs).props.defprops.size ≈ 14.0
+ @test isnothing(only(only(gp.paragraphs).runs).props)
+
+ # A newline in a plain title starts a paragraph, as setChartTitleText does.
+ m = XLSX.Charts.addChart(ws, :bar; anchor = "D60:K74", title = "Two\nlines")
+ gm = XLSX.Charts.getChartTitleText(m)
+ @test [only(p.runs).text for p in gm.paragraphs] == ["Two", "lines"]
+ @test all(p -> p.props.defprops.size ≈ 14.0, gm.paragraphs)
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "binding a title to a cell" begin
+ xf, c = rich_fixture()
+ tmp = "chart_bound_titles.xlsx"
+ try
+ # A typed title, formatted, becomes bound: the formatting moves to c:txPr.
+ XLSX.Charts.setChartTitleText(c, XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph(
+ XLSX.Charts.DrawingRun("Typed", props = XLSX.Charts.DrawingRunProps(italic = true, size = 20.0)))))
+ atd = XLSX.Charts.getAutoTitleDeleted(c)
+ XLSX.Charts.setChartTitleText(c, XLSX.CellRef("A1"))
+ @test XLSX.Charts.getChartTitleRef(c) == "Sheet1!\$A\$1"
+ @test XLSX.Charts.getChartTitleRange(c).cellref == XLSX.CellRef("A1")
+ @test XLSX.Charts.getChartTitle(c) == "Q1"
+ @test isnothing(XLSX.Charts.getChartTitleText(c))
+ d = XLSX.Charts.default_run_props(XLSX.Charts.getChartTitleTextProps(c))
+ @test d.italic === true && d.size ≈ 20.0
+ @test !isnothing(d.fill) # the template's colour survives
+ @test XLSX.Charts.getAutoTitleDeleted(c) === atd
+ @test XLSX.Charts._bool_val(title_node(c), "overlay") === false
+
+ # A numeric cell still gives a string cache: c:tx allows only c:strRef.
+ XLSX.Charts.setChartTitleText(c, XLSX.SheetCellRef("Sheet1!B1"))
+ tx = XLSX.first_element_with_tag(title_node(c), "tx")
+ @test !isnothing(XLSX.first_element_with_tag(tx, "strRef"))
+ @test isnothing(XLSX.first_element_with_tag(tx, "numRef"))
+ @test XLSX.Charts.getChartTitle(c) == "2026"
+
+ # An empty cell binds, caching no value: ptCount 1, no c:pt.
+ XLSX.Charts.setChartTitleText(c, XLSX.CellRef("Z99"))
+ sr = XLSX.first_element_with_tag(XLSX.first_element_with_tag(title_node(c), "tx"), "strRef")
+ cache = XLSX.first_element_with_tag(sr, "strCache")
+ @test XLSX.get_attr(XLSX.first_element_with_tag(cache, "ptCount"), "val") == "1"
+ @test isnothing(XLSX.first_element_with_tag(cache, "pt"))
+ @test isnothing(XLSX.Charts.getChartTitle(c)) # as for a cx: title
+
+ # A rich-text cell lends the formatting all its runs share: bold and red,
+ # not italic, which only one run has.
+ XLSX.Charts.setChartTitleText(c, "Plain typed")
+ XLSX.Charts.setChartTitleText(c, XLSX.SheetCellRef("Sheet1!C1"))
+ d = XLSX.Charts.default_run_props(XLSX.Charts.getChartTitleTextProps(c))
+ @test d.bold === true && d.fill.fgcolor.val == "FF0000"
+ @test d.italic === true # kept from the earlier typed title
+ @test XLSX.Charts.getChartTitle(c) == "Big red"
+ # On a title with no italic, the cell's one italic run adds none.
+ b0 = XLSX.Charts.addChart(xf[1], :column; anchor = "D60:K74", title = XLSX.SheetCellRef("Sheet1!C1"))
+ d0 = XLSX.Charts.default_run_props(XLSX.Charts.getChartTitleTextProps(b0))
+ @test d0.bold === true && d0.italic === false # the template's i="0"
+
+ # Bound back to typed: c:rich returns and the reference is gone.
+ XLSX.Charts.setChartTitleText(c, "Typed again")
+ @test isnothing(XLSX.Charts.getChartTitleRef(c))
+ @test XLSX.Charts.text_content(XLSX.Charts.getChartTitleText(c)) == "Typed again"
+ tx = XLSX.first_element_with_tag(title_node(c), "tx")
+ @test isnothing(XLSX.first_element_with_tag(tx, "strRef"))
+
+ # Axis titles, created from nothing.
+ ax = first(XLSX.Charts.getChartAxes(c, :category))
+ XLSX.Charts.setAxisTitleText(c, ax, XLSX.CellRef("B1"))
+ ax = first(XLSX.Charts.getChartAxes(c, :category))
+ @test XLSX.Charts.getAxisTitleRef(c, ax) == "Sheet1!\$B\$1"
+ @test isnothing(XLSX.Charts.getAxisTitleText(c, ax))
+ @test XLSX.Charts.getAxisTitle(c, ax) == "2026" # the cached text
+ r = XLSX.Charts.getAxisTitleRange(c, ax)
+ @test r isa XLSX.SheetCellRef && r.sheet == "Sheet1" && r.cellref == XLSX.CellRef("B1")
+ vax = first(XLSX.Charts.getChartAxes(c, :value))
+ XLSX.Charts.setAxisTitleText(c, vax, "Units")
+ vax = first(XLSX.Charts.getChartAxes(c, :value))
+ @test XLSX.Charts.getAxisTitle(c, vax) == "Units"
+ @test isnothing(XLSX.Charts.getAxisTitleRange(c, vax))
+ XLSX.Charts.setAxisTitleText(c, vax, XLSX.SheetCellRef("Sheet1!C1"))
+ vax = first(XLSX.Charts.getChartAxes(c, :value))
+ @test XLSX.Charts.getAxisTitleRef(c, vax) == "Sheet1!\$C\$1"
+
+ # addChart binds too; a string is never read as a reference.
+ b = XLSX.Charts.addChart(xf[1], :column; anchor = "D20:K34", title = XLSX.CellRef("A1"))
+ @test XLSX.Charts.getChartTitleRef(b) == "Sheet1!\$A\$1"
+ @test XLSX.Charts.default_run_props(XLSX.Charts.getChartTitleTextProps(b)).size ≈ 14.0
+ s = XLSX.Charts.addChart(xf[1], :column; anchor = "D40:K54", title = "A1")
+ @test isnothing(XLSX.Charts.getChartTitleRef(s))
+ @test XLSX.Charts.text_content(XLSX.Charts.getChartTitleText(s)) == "A1"
+ # A bad reference fails before the chart is added.
+ n = length(XLSX.Charts.getCharts(xf[1]))
+ @test_throws XLSX.XLSXError XLSX.Charts.addChart(xf[1], :column; anchor = "D80:K94",
+ title = XLSX.SheetCellRef("Nope!A1"))
+ @test length(XLSX.Charts.getCharts(xf[1])) == n
+
+ # Survives a write and reopen.
+ SAVE_FILES && save_outfile(xf)
+ XLSX.writexlsx(tmp, xf; overwrite = true)
+ cs = XLSX.Charts.getCharts(XLSX.openxlsx(tmp)[1])
+ c2 = cs[findfirst(x -> XLSX.Charts.getChartTitle(x) == "Typed again", cs)]
+ ax2 = first(XLSX.Charts.getChartAxes(c2, :category))
+ @test XLSX.Charts.getAxisTitleRef(c2, ax2) == "Sheet1!\$B\$1"
+ vax2 = first(XLSX.Charts.getChartAxes(c2, :value))
+ @test XLSX.Charts.getAxisTitleRef(c2, vax2) == "Sheet1!\$C\$1"
+ d2 = XLSX.Charts.default_run_props(XLSX.Charts.getAxisTitleTextProps(c2, vax2))
+ @test d2.bold === true && d2.fill.fgcolor.val == "FF0000"
+ b2 = cs[findfirst(x -> XLSX.Charts.getChartTitleRef(x) == "Sheet1!\$A\$1", cs)]
+ @test XLSX.Charts.getChartTitle(b2) == "Q1"
+ finally
+ isfile(tmp) && rm(tmp)
+ end
+ end
+
+ @testset "rich titles survive a write and reopen" begin
+ xf, c = rich_fixture()
+ tmp = "chart_rich_titles.xlsx"
+ try
+ rts = XLSX.RichTextString(XLSX.RichTextRun("H"),
+ XLSX.RichTextRun("2", [:vertAlign => "subscript", :name => "Arial"]),
+ XLSX.RichTextRun("O", [:under => true, :strike => false]))
+ XLSX.Charts.setChartTitleText(c, rts)
+ SAVE_FILES && save_outfile(xf)
+ XLSX.writexlsx(tmp, xf; overwrite = true)
+ c2 = first(XLSX.Charts.getCharts(XLSX.openxlsx(tmp)[1]))
+ r = only(XLSX.Charts.getChartTitleText(c2).paragraphs).runs
+ @test [x.text for x in r] == ["H", "2", "O"]
+ @test isnothing(r[1].props)
+ @test r[2].props.baseline ≈ -0.25 && r[2].props.latin == "Arial"
+ @test r[3].props.under == "sng" && r[3].props.strike == "noStrike"
+ finally
+ isfile(tmp) && rm(tmp)
+ end
+ end
+
+ @static if VERSION >= v"1.11-"
+ @testset "styled strings as chart text" begin
+ xf, c = rich_fixture()
+ ss = styled"{bold:Revenue} {(foreground=#ff0000):£m}"
+ check(t) = begin
+ r = only(t.paragraphs).runs
+ @test [x.text for x in r] == ["Revenue", " ", "£m"]
+ @test r[1].props.bold === true
+ @test isnothing(r[2].props) # unstyled: inherits
+ @test r[3].props.fill.fgcolor.val == "FF0000"
+ end
+ XLSX.Charts.setChartTitleText(c, ss)
+ check(XLSX.Charts.getChartTitleText(c))
+ ax = first(XLSX.Charts.getChartAxes(c, :value))
+ XLSX.Charts.setAxisTitleText(c, ax, ss)
+ check(XLSX.Charts.getAxisTitleText(c, first(XLSX.Charts.getChartAxes(c, :value))))
+ XLSX.Charts.setLabelText(c, 1, 1, ss)
+ check(XLSX.Charts.getDataLabelText(c, XLSX.Charts.getSeriesDataLabel(c, 1, 1)))
+ a = XLSX.Charts.addChart(xf[1], :column; anchor = "D20:K34", title = ss)
+ check(XLSX.Charts.getChartTitleText(a))
+ @test only(XLSX.Charts.getChartTitleText(a).paragraphs).props.defprops.size ≈ 14.0
+ # A substring of a styled string is a styled string too.
+ XLSX.Charts.setChartTitleText(c, SubString(ss, 1, 7))
+ @test only(only(XLSX.Charts.getChartTitleText(c).paragraphs).runs).props.bold === true
+ # An empty one is plain empty text.
+ XLSX.Charts.setChartTitleText(c, styled"")
+ @test XLSX.Charts.text_content(XLSX.Charts.getChartTitleText(c)) == ""
+ SAVE_FILES && save_outfile(xf)
+ end
+ end
+
+ @testset "new elements take the part's own prefixes" begin
+ # A chart part may bind the chart and DrawingML namespaces to any prefix;
+ # Excel's `c` and `a` are a convention. Rename both, then add to the part.
+ # Renamed in the tree, not via text: XML.write indents, and whitespace
+ # between elements is not what this is testing.
+ function reprefixed(c)
+ ren(t) = startswith(t, "c:") ? "ch:" * t[3:end] : startswith(t, "a:") ? "dr:" * t[3:end] : t
+ root = XLSX.Charts._rewrite(XLSX.Charts.chart_root(c)) do e
+ typeof(e)(XML.nodetype(e), ren(XML.tag(e)), e.attributes, XML.value(e), e.children)
+ end
+ for (old, new, ns) in (("c", "ch", XLSX.Charts.NS_C), ("a", "dr", XLSX.NS_A))
+ root = XLSX.with_attribute(XLSX.with_attribute(root, "xmlns:$old", nothing), "xmlns:$new", ns)
+ end
+ XLSX.Charts.set_chart_root!(c, root)
+ return c
+ end
+ # Every element in the namespaces the part renamed, by prefix as written.
+ function prefixes_used(n, acc = Set{String}())
+ if XML.nodetype(n) == XML.Element
+ t = XML.tag(n)
+ occursin(':', t) && push!(acc, first(split(t, ':')))
+ for k in something(XML.children(n), ())
+ prefixes_used(k, acc)
+ end
+ end
+ return acc
+ end
+ xf, c = rich_fixture()
+ ws = xf[1]
+ tmp = "chart_prefixes.xlsx"
+ try
+ reprefixed(c)
+ @test XLSX.ns_prefixes(XLSX.Charts.chart_root(c))[XLSX.Charts.NS_C] == "ch"
+ XLSX.Charts.addSeries(c, "A2:A3"; name_ref = "B1")
+ XLSX.Charts.setChartTitleText(c, XLSX.CellRef("C1"))
+ XLSX.Charts.setAxisTitleText(c, first(XLSX.Charts.getChartAxes(c, :value)), XLSX.CellRef("B1"))
+ used = prefixes_used(XLSX.Charts.chart_root(c))
+ @test !("c" in used) && !("a" in used)
+ @test length(XLSX.Charts.getChartSeries(c)) == 2
+ @test XLSX.Charts.getChartTitleRef(c) == "Sheet1!\$C\$1"
+
+ # A scatter series' line is built from the template, then renamed.
+ s = XLSX.Charts.addChart(ws, :scatter; anchor = "D20:K34")
+ XLSX.Charts.addSeries(s, "A2:A3"; categories = "A2:A3")
+ reprefixed(s)
+ XLSX.Charts.addSeries(s, "A2:A3"; categories = "A2:A3", line = true)
+ used = prefixes_used(XLSX.Charts.chart_root(s))
+ @test !("c" in used) && !("a" in used)
+ @test XLSX.Charts.getSeriesLine(s, 2).value.width ≈ 1.5 # 19050 EMU
+
+ SAVE_FILES && save_outfile(xf)
+ XLSX.writexlsx(tmp, xf; overwrite = true)
+ cs = XLSX.Charts.getCharts(XLSX.openxlsx(tmp)[1])
+ c2 = cs[findfirst(x -> XLSX.Charts.getChartTitleRef(x) == "Sheet1!\$C\$1", cs)]
+ @test XLSX.Charts.getChartTitle(c2) == "Big red"
+ @test length(XLSX.Charts.getChartSeries(c2)) == 2
+ finally
+ isfile(tmp) && rm(tmp)
+ end
+ end
+
+ @testset "an indented chart part can be edited" begin
+ # Excel writes chart parts without whitespace; another tool may indent
+ # them. XML.write indents, so a round trip through it gives such a part.
+ xf, c = rich_fixture()
+ tmp = "chart_indented.xlsx"
+ try
+ XLSX.Charts.set_chart_root!(c, XLSX.xml_root_element(
+ parse(XML.write(XLSX.Charts.chart_root(c)), XML.Node)))
+ chartnode = XLSX.first_element_with_tag(XLSX.Charts.chart_root(c), "chart")
+ @test any(k -> XML.nodetype(k) == XML.Text, XML.children(chartnode)) # it is indented
+ ax = first(XLSX.Charts.getChartAxes(c, :value))
+ XLSX.Charts.setSeriesFill(c, 1, "FF0000")
+ XLSX.Charts.setChartTitleText(c, "Typed")
+ XLSX.Charts.setChartTitleText(c, XLSX.CellRef("A1"))
+ XLSX.Charts.setAxisTitleText(c, ax, "Units")
+ XLSX.Charts.setLabelText(c, 1, 1, "Hi")
+ XLSX.Charts.addSeries(c, "A2:A3")
+ t = title_node(c)
+ @test issorted([XLSX.Charts._slot(XLSX.Charts.CHILD_ORDER[(XLSX.Charts.NS_C, "title")], XLSX.localname(k))
+ for k in XML.eachelement(t)])
+ @test XLSX.Charts.getChartTitleRef(c) == "Sheet1!\$A\$1"
+ @test XLSX.Charts.getSeriesFill(c, 1).value.fgcolor.val == "FF0000"
SAVE_FILES && save_outfile(xf)
- XLSX.writexlsx(tmp, xf; overwrite=true)
- xf2 = XLSX.openxlsx(tmp; mode="rw")
- SAVE_FILES && save_outfile(xf2)
- c2 = first(XLSX.Charts.getCharts(xf2["stackedbar"]))
- ax2 = XLSX.Charts.getChartAxes(c2)
- cat2 = only(filter(a -> a.kind === :catAx, ax2))
- val2 = only(filter(a -> a.kind === :valAx, ax2))
- @test XLSX.Charts.getAxisMajorTickMark(c2, cat2) === :cross
- @test XLSX.Charts.getAxisMajorUnit(c2, val2) ≈ 0.2
- @test XLSX.Charts.hasLine(XLSX.Charts.getPlotAreaShapeProps(c2))
- @test XLSX.Charts._bool_val(title(c2, val2), "overlay") === false
- @test isnothing(XLSX.first_element_with_tag(XLSX.Charts._axnode(c2, val2), "majorGridlines"))
+ XLSX.writexlsx(tmp, xf; overwrite = true)
+ c2 = only(XLSX.Charts.getCharts(XLSX.openxlsx(tmp)[1]))
+ @test XLSX.Charts.getChartTitle(c2) == "Q1"
+ @test length(XLSX.Charts.getChartSeries(c2)) == 2
finally
isfile(tmp) && rm(tmp)
end
end
+
+ @testset "a new chart's anchor takes the drawing's prefixes" begin
+ drawing_path(xf) = XLSX._drawing_path_for_sheet(xf, XLSX.get_worksheet_internal_file(xf[1]))
+ drawing_root(xf) = XLSX.xml_root_element(xf.data[drawing_path(xf)])
+ function set_drawing_root!(xf, new)
+ doc = xf.data[drawing_path(xf)]
+ xf.data[drawing_path(xf)] = XLSX.Charts.replace_child(doc, XLSX.xml_root_element(doc), new)
+ end
+ function element_prefixes(n, acc = Set{String}())
+ if XML.nodetype(n) == XML.Element
+ t = XML.tag(n)
+ occursin(':', t) && push!(acc, first(split(t, ':')))
+ foreach(k -> element_prefixes(k, acc), something(XML.children(n), ()))
+ end
+ return acc
+ end
+ xf, _ = rich_fixture()
+ ws = xf[1]
+ tmp = "chart_drawing_prefixes.xlsx"
+ try
+ # Rename the drawing's xdr and a to wd and da, as another tool might.
+ ren(t) = startswith(t, "xdr:") ? "wd:" * t[5:end] : startswith(t, "a:") ? "da:" * t[3:end] : t
+ root = XLSX.Charts._rewrite(drawing_root(xf)) do e
+ typeof(e)(XML.nodetype(e), ren(XML.tag(e)), e.attributes, XML.value(e), e.children)
+ end
+ for (old, new, ns) in (("xdr", "wd", XLSX.NS_XDR), ("a", "da", XLSX.NS_A))
+ root = XLSX.with_attribute(XLSX.with_attribute(root, "xmlns:$old", nothing), "xmlns:$new", ns)
+ end
+ set_drawing_root!(xf, root)
+
+ XLSX.Charts.addChart(ws, :line; anchor = "D20:K34")
+ XLSX.Charts.addChartEx(ws, :waterfall, "A2:A3"; anchor = "D40:K54")
+ used = element_prefixes(drawing_root(xf))
+ @test !("xdr" in used) && !("a" in used)
+ @test "wd" in used && "da" in used
+ SAVE_FILES && save_outfile(xf)
+ XLSX.writexlsx(tmp, xf; overwrite = true)
+ @test length(XLSX.Charts.getCharts(XLSX.openxlsx(tmp)[1])) == 3
+
+ # A drawing that doesn't declare DrawingML at all: the anchor declares it.
+ root = drawing_root(xf)
+ set_drawing_root!(xf, XLSX.with_attribute(root, "xmlns:da", nothing))
+ XLSX.Charts.addChart(ws, :bar; anchor = "D60:K74")
+ anchor = last(collect(XML.eachelement(drawing_root(xf))))
+ @test XLSX.get_attr(anchor, "xmlns:a") == XLSX.NS_A
+ finally
+ isfile(tmp) && rm(tmp)
+ end
+ end
+
+ @testset "a bound title on a chartsheet must name its sheet" begin
+ xf, _ = rich_fixture()
+ @test_throws XLSX.XLSXError XLSX.Charts.addChart(xf, :column; title = XLSX.CellRef("A1"))
+ @test XLSX.sheetnames(xf) == ["Sheet1"] # nothing half-built
+ err = try
+ XLSX.Charts.addChart(xf, :column; title = XLSX.CellRef("A1"))
+ catch e
+ e
+ end
+ @test occursin("SheetCellRef", err.msg) && !occursin("series", err.msg)
+
+ c = XLSX.Charts.addChart(xf, :column; title = XLSX.SheetCellRef("Sheet1!A1"))
+ @test XLSX.Charts.getChartTitleRef(c) == "Sheet1!\$A\$1"
+ @test XLSX.Charts.getChartTitle(c) == "Q1"
+ @test_throws XLSX.XLSXError XLSX.Charts.setChartTitleText(c, XLSX.CellRef("B1"))
+ XLSX.Charts.addSeries(c, "Sheet1!A2:A3")
+ SAVE_FILES && save_outfile(xf)
+ end
+ end
+
+
+ @testset "chart_extras.xlsx: elements with no other fixture" begin
+ xf = XLSX.readxlsx(joinpath(data_directory, "chart_extras.xlsx"))
+ chart(sheet) = only(XLSX.Charts.getCharts(xf[sheet]))
+
+ # Drop lines, on a line chart's group.
+ c = chart("droplines")
+ g = only(XLSX.Charts.getChartGroups(c))
+ dl = XLSX.Charts.getGroupDropLines(c, g)
+ @test !isnothing(dl)
+ @test dl.line.width ≈ 0.75 # 9525 EMU
+ @test isnothing(XLSX.Charts.getGroupHiLowLines(c, g))
+
+ # High-low lines and up/down bars together.
+ c = chart("hilolines")
+ g = only(XLSX.Charts.getChartGroups(c))
+ @test XLSX.Charts.getGroupHiLowLines(c, g).line.width ≈ 0.75
+ @test isnothing(XLSX.Charts.getGroupDropLines(c, g))
+ b = XLSX.Charts.getGroupUpDownBars(c, g)
+ @test b.gap_width == 150
+ @test XLSX.Charts.hasFill(XLSX.Charts.getUpBarShapeProps(c, b))
+ @test XLSX.Charts.hasFill(XLSX.Charts.getDownBarShapeProps(c, b))
+
+ # Polynomial and moving-average trendlines, one per series.
+ c = chart("trends")
+ tp = only(XLSX.Charts.getSeriesTrendlines(c, 1))
+ @test tp.kind === :poly
+ @test tp.order == 2
+ @test isnothing(tp.period)
+ @test tp.disp_rsqr === false
+ tm = only(XLSX.Charts.getSeriesTrendlines(c, 2))
+ @test tm.kind === :movingAvg
+ @test tm.period == 2
+ @test isnothing(tm.order)
+ ln = XLSX.Charts.getTrendlineShapeProps(c, tp).line
+ @test ln.width ≈ 1.5
+ @test ln.dash == "sysDot"
+
+ # Custom error bars with cell references for both directions.
+ c = chart("errbars")
+ e = only(XLSX.Charts.getSeriesErrorBars(c, 1))
+ @test e.bar_type === :both
+ @test e.value_type === :cust
+ @test isnothing(e.direction) # no c:errDir written
+ @test e.no_end_cap === false
+ refs = XLSX.Charts.getErrorBarsCustomRefs(c, e)
+ @test refs.plus.ref == "errbars!\$C\$2:\$C\$5"
+ @test refs.minus.ref == "errbars!\$D\$2:\$D\$5"
+ @test refs.plus.data == [12, 18, 25, 9]
+
+ # A title bound to a cell: a reference and its cached string, no rich text.
+ c = chart("boundtitle")
+ @test XLSX.Charts.getChartTitleRef(c) == "boundtitle!\$F\$1"
+ r = XLSX.Charts.getChartTitleRange(c) # Excel's own bound title
+ @test r isa XLSX.SheetCellRef && r.sheet == "boundtitle" && r.cellref == XLSX.CellRef("F1")
+ @test XLSX.Charts.getChartTitle(c) == "My Bound Title"
+ @test isnothing(XLSX.Charts.getChartTitleText(c))
+
+ # The a:ln join group.
+ c = chart("join")
+ l = XLSX.Charts.getSeriesLine(c, 1).value
+ @test l.width ≈ 3.0
+ @test l.join == "bevel"
end
-end
\ No newline at end of file
+ @testset "gridlines written without c:spPr are still gridlines" begin
+ path = "chart_gridlines_bare.xlsx"
+ cp(joinpath(data_directory, "chart_basic.xlsx"), path; force=true)
+ xf = XLSX.openxlsx(path; mode="rw")
+ c = XLSX.Charts.getChart(xf, "chart1")
+ ax = only(XLSX.Charts.getChartAxes(c, :value))
+ root = XLSX.Charts.chart_root(c)
+ axn = XLSX.Charts._axis_node(c, root, ax)
+ new = XLSX.Charts.rebuild_path(root,
+ [(XLSX.Charts.NS_C, "chart") => "chart",
+ (XLSX.Charts.NS_C, "plotArea") => "plotArea",
+ (XLSX.Charts.NS_C, "valAx") => ("valAx", n -> n === axn),
+ (XLSX.Charts.NS_C, "majorGridlines") => "majorGridlines"],
+ gl -> XLSX.Charts.remove_child(gl, "spPr");
+ prefixes=XLSX.ns_prefixes(root))
+ XLSX.Charts.set_chart_root!(c, new)
+
+ gl = XLSX.Charts.getAxisGridlines(c, ax)
+ @test !isnothing(gl) # still there
+ @test isnothing(gl.fill) && isnothing(gl.line) # but unformatted
+ isfile(path) && rm(path)
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "Non-sequential series c:idx" begin
+ # chart_idx_gaps is chart_basic with the two series' `c:idx` changed to 3 and 7,
+ # as Excel leaves them after series have been deleted. A series *position* counts
+ # from 1 in document order; a value's `series_idx` is the `c:idx`. The two differ
+ # here, so anything that confuses them fails.
+ xf = XLSX.opentemplate(joinpath(data_directory, "chart_idx_gaps.xlsx"))
+ c = XLSX.Charts.getCharts(xf)[1]
+
+ ss = XLSX.Charts.getChartSeries(c)
+ @test length(ss) == 2
+ @test [s.idx for s in ss] == [3, 7]
+ @test [s.order for s in ss] == [0, 1]
+ @test [s.name for s in ss] == ["2024", "2025"] # document order, not idx order
+ @test length(XLSX.Charts.getChartRanges(c)) == 2
+
+ # A setter taking a position writes to that series, not to the one whose idx matches.
+ was = XLSX.Charts.getSeriesFill(c, 1).value
+ XLSX.Charts.setSeriesFill(c, 2, "FF0000")
+ @test XLSX.Charts.getSeriesFill(c, 2).value.fgcolor.rgb == "FF0000"
+ @test XLSX.Charts.getSeriesFill(c, 1).value == was # series 1 untouched
+
+ # A value carries the owning series' `c:idx`, and is resolved by it.
+ XLSX.Charts.setLabelDeleted(c, 2, 1, true) # creates a c:dLbl
+ d = only(XLSX.Charts.getSeriesDataLabels(c, 2))
+ @test d.series_idx == 7 # the key, not the position
+ @test d.idx == 0 # c:idx of the label, 0-based
+ @test d.delete === true
+
+ # The value still addresses the same label after an unrelated write.
+ XLSX.Charts.setSeriesLineColor(c, 2, "0000FF")
+ @test XLSX.Charts.getSeriesDataLabel(c, 2, 1).idx == d.idx
+ @test XLSX.Charts.getSeriesDataLabel(c, 2, 1).series_idx == 7
+
+ SAVE_FILES && save_outfile(xf)
+ end
+end
diff --git a/test/test_files/Charts_tests.jl b/test/test_files/Charts_tests.jl
index c16ffe31..167aa41f 100644
--- a/test/test_files/Charts_tests.jl
+++ b/test/test_files/Charts_tests.jl
@@ -10,1111 +10,1043 @@
#
@testset "Charts" begin
+ @testset "reading charts" begin
+ @testset "basic chart" begin # chart_basic.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_basic.xlsx"))
+
+ c = XLSX.Charts.getChart(f["Data"], "chart1")
+ @test occursin("chart1", repr(c))
+ @test occursin("series", repr(MIME"text/plain"(), c))
+ @test occursin("ChartSeries", repr(XLSX.Charts.getChartSeries(c)[1]))
+ @test occursin("pts", repr(MIME"text/plain"(), XLSX.Charts.getChartSeries(c)[1].values))
+ @test occursin("pts", sprint(show, XLSX.Charts.getChartSeries(c)[1].values))
+ @test occursin("categories", repr(MIME"text/plain"(), XLSX.Charts.getChartSeries(c)[1]))
+
+ charts = XLSX.Charts.getCharts(f)
+ @test length(charts) == 1
+
+ c = charts[1]
+ @test c.name == "chart1"
+ @test c.path == "xl/charts/chart1.xml"
+ @test c.sheet == "Data"
+ @test XLSX.Charts.getChartTitle(c) == "Revenue by Region"
+ @test XLSX.Charts.getChartTypes(c) == [:barChart]
+ @test !isnothing(c.rId)
+
+ # Anchored to cells, so both markers parse. The exact cells depend on
+ # where the chart was dropped, so only their form is asserted.
+ @test !isnothing(c.from)
+ @test !isnothing(c.to)
+ @test XLSX.CellRef(c.from) isa XLSX.CellRef
+ @test XLSX.CellRef(c.to) isa XLSX.CellRef
+
+ @test length(XLSX.Charts.getChartSeries(c)) == 2
+ @test [s.name for s in XLSX.Charts.getChartSeries(c)] == ["2024", "2025"]
+ @test [s.order for s in XLSX.Charts.getChartSeries(c)] == [0, 1]
+ @test [s.idx for s in XLSX.Charts.getChartSeries(c)] == [0, 1]
+ @test all(s -> s.charttype == :barChart, XLSX.Charts.getChartSeries(c))
+ @test all(s -> isnothing(s.bubble_sizes), XLSX.Charts.getChartSeries(c))
+
+ s1, s2 = XLSX.Charts.getChartSeries(c)
+ @test s1.categories.kind == :str
+ @test s1.categories.ref == "Data!\$A\$2:\$A\$5"
+ @test s1.categories.data == ["North", "South", "East", "West"]
+ @test s1.categories.ptCount == 4
+
+ @test s1.values.kind == :num
+ @test s1.values.ref == "Data!\$B\$2:\$B\$5"
+ @test s1.values.data == [10.0, 20.0, 15.0, 5.0]
+ @test s1.values.format_code == "General"
+ @test isempty(s1.values.errors)
+
+ @test s2.values.ref == "Data!\$C\$2:\$C\$5"
+ @test s2.values.data == [12.0, 18.0, 25.0, 9.0]
+
+ # Both series share one category reference, so one `categories` column.
+ dt = XLSX.Charts.getChartData(c)
+ @test dt.column_labels == [:categories, Symbol("2024"), Symbol("2025")]
+ @test dt.data[1] == ["North", "South", "East", "West"]
+ @test dt.data[2] == [10.0, 20.0, 15.0, 5.0]
+ @test dt.data[3] == [12.0, 18.0, 25.0, 9.0]
+
+ df = DataFrames.DataFrame(dt)
+ @test size(df) == (4, 3)
+ @test names(df) == ["categories", "2024", "2025"]
+
+ # A DataTable must report real column types, or PrettyTables trips over
+ # `Tables.Schema(names, nothing)`.
+ sch = Tables.schema(dt)
+ @test !isnothing(sch.types)
+ @test sch.types[2] == Float64
+ end
- @testset "basic chart" begin # chart_basic.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_basic.xlsx"))
-
- c = XLSX.Charts.getChart(f["Data"], "chart1")
- @test occursin("chart1", repr(c))
- @test occursin("series", repr(MIME"text/plain"(), c))
- @test occursin("ChartSeries", repr(XLSX.Charts.getChartSeries(c)[1]))
- @test occursin("pts", repr(MIME"text/plain"(), XLSX.Charts.getChartSeries(c)[1].values))
- @test occursin("pts", sprint(show, XLSX.Charts.getChartSeries(c)[1].values))
- @test occursin("categories", repr(MIME"text/plain"(), XLSX.Charts.getChartSeries(c)[1]))
-
- charts = XLSX.Charts.getCharts(f)
- @test length(charts) == 1
-
- c = charts[1]
- @test c.name == "chart1"
- @test c.path == "xl/charts/chart1.xml"
- @test c.sheet == "Data"
- @test XLSX.Charts.getChartTitle(c) == "Revenue by Region"
- @test XLSX.Charts.getChartTypes(c) == [:barChart]
- @test !isnothing(c.rId)
-
- # Anchored to cells, so both markers parse. The exact cells depend on
- # where the chart was dropped, so only their form is asserted.
- @test !isnothing(c.from)
- @test !isnothing(c.to)
- @test XLSX.CellRef(c.from) isa XLSX.CellRef
- @test XLSX.CellRef(c.to) isa XLSX.CellRef
-
- @test length(XLSX.Charts.getChartSeries(c)) == 2
- @test [s.name for s in XLSX.Charts.getChartSeries(c)] == ["2024", "2025"]
- @test [s.order for s in XLSX.Charts.getChartSeries(c)] == [0, 1]
- @test [s.idx for s in XLSX.Charts.getChartSeries(c)] == [0, 1]
- @test all(s -> s.charttype == :barChart, XLSX.Charts.getChartSeries(c))
- @test all(s -> isnothing(s.bubble_sizes), XLSX.Charts.getChartSeries(c))
-
- s1, s2 = XLSX.Charts.getChartSeries(c)
- @test s1.categories.kind == :str
- @test s1.categories.ref == "Data!\$A\$2:\$A\$5"
- @test s1.categories.data == ["North", "South", "East", "West"]
- @test s1.categories.ptCount == 4
-
- @test s1.values.kind == :num
- @test s1.values.ref == "Data!\$B\$2:\$B\$5"
- @test s1.values.data == [10.0, 20.0, 15.0, 5.0]
- @test s1.values.format_code == "General"
- @test isempty(s1.values.errors)
-
- @test s2.values.ref == "Data!\$C\$2:\$C\$5"
- @test s2.values.data == [12.0, 18.0, 25.0, 9.0]
-
- # Both series share one category reference, so one `categories` column.
- dt = XLSX.Charts.getChartData(c)
- @test dt.column_labels == [:categories, Symbol("2024"), Symbol("2025")]
- @test dt.data[1] == ["North", "South", "East", "West"]
- @test dt.data[2] == [10.0, 20.0, 15.0, 5.0]
- @test dt.data[3] == [12.0, 18.0, 25.0, 9.0]
-
- df = DataFrames.DataFrame(dt)
- @test size(df) == (4, 3)
- @test names(df) == ["categories", "2024", "2025"]
-
- # A DataTable must report real column types, or PrettyTables trips over
- # `Tables.Schema(names, nothing)`.
- sch = Tables.schema(dt)
- @test !isnothing(sch.types)
- @test sch.types[2] == Float64
- end
+ @testset "chart lookup" begin # chart_basic.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_basic.xlsx"))
+ c = XLSX.Charts.getCharts(f)[1]
- @testset "chart lookup" begin # chart_basic.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_basic.xlsx"))
- c = XLSX.Charts.getCharts(f)[1]
+ @test XLSX.Charts.getChart(f, "chart1").path == c.path
+ @test XLSX.Charts.getChart(f, "chart1.xml").path == c.path
+ @test XLSX.Charts.getChart(f, "xl/charts/chart1.xml").path == c.path
+ @test XLSX.Charts.getChart(f, c.rId).path == c.path
+ @test_throws XLSX.XLSXError XLSX.Charts.getChart(f, "nosuchchart")
- @test XLSX.Charts.getChart(f, "chart1").path == c.path
- @test XLSX.Charts.getChart(f, "chart1.xml").path == c.path
- @test XLSX.Charts.getChart(f, "xl/charts/chart1.xml").path == c.path
- @test XLSX.Charts.getChart(f, c.rId).path == c.path
- @test_throws XLSX.XLSXError XLSX.Charts.getChart(f, "nosuchchart")
+ # Worksheet-scoped discovery walks the sheet's own drawing.
+ ws_charts = XLSX.Charts.getCharts(f["Data"])
+ @test length(ws_charts) == 1
+ @test ws_charts[1].path == c.path
- # Worksheet-scoped discovery walks the sheet's own drawing.
- ws_charts = XLSX.Charts.getCharts(f["Data"])
- @test length(ws_charts) == 1
- @test ws_charts[1].path == c.path
+ @test XLSX.Charts.getChartData(f, "chart1").column_labels == XLSX.Charts.getChartData(c).column_labels
+ end
- @test XLSX.Charts.getChartData(f, "chart1").column_labels == XLSX.Charts.getChartData(c).column_labels
- end
+ @testset "metadata only" begin # chart_basic.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_basic.xlsx"))
+ c = XLSX.Charts.getCharts(f)[1]
+
+ # Metadata survives; values do not.
+ @test XLSX.Charts.getChartTitle(c) == "Revenue by Region"
+ @test [s.name for s in XLSX.Charts.getChartSeries(c; read_cached_values=false)] == ["2024", "2025"] # names come from c:tx regardless
+ @test XLSX.Charts.getChartSeries(c; read_cached_values=false)[1].values.ref == "Data!\$B\$2:\$B\$5"
+ @test XLSX.Charts.getChartSeries(c; read_cached_values=false)[1].values.format_code == "General"
+ @test XLSX.Charts.getChartSeries(c; read_cached_values=false)[1].values.ptCount == 4
+ @test isempty(XLSX.Charts.getChartSeries(c; read_cached_values=false)[1].values.data)
+ @test isempty(XLSX.Charts.getChartSeries(c; read_cached_values=false)[1].categories.data)
+
+ # Reading options belong to the accessors, not the chart, so the data table
+ # is always available.
+ @test XLSX.Charts.getChartData(c) isa XLSX.DataTable
+ @test XLSX.Charts.getChartData(f, "chart1") isa XLSX.DataTable
+ end
- @testset "metadata only" begin # chart_basic.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_basic.xlsx"))
- c = XLSX.Charts.getCharts(f)[1]
-
- # Metadata survives; values do not.
- @test XLSX.Charts.getChartTitle(c) == "Revenue by Region"
- @test [s.name for s in XLSX.Charts.getChartSeries(c; read_cached_values=false)] == ["2024", "2025"] # names come from c:tx regardless
- @test XLSX.Charts.getChartSeries(c; read_cached_values=false)[1].values.ref == "Data!\$B\$2:\$B\$5"
- @test XLSX.Charts.getChartSeries(c; read_cached_values=false)[1].values.format_code == "General"
- @test XLSX.Charts.getChartSeries(c; read_cached_values=false)[1].values.ptCount == 4
- @test isempty(XLSX.Charts.getChartSeries(c; read_cached_values=false)[1].values.data)
- @test isempty(XLSX.Charts.getChartSeries(c; read_cached_values=false)[1].categories.data)
-
- # Reading options belong to the accessors, not the chart, so the data table
- # is always available.
- @test XLSX.Charts.getChartData(c) isa XLSX.DataTable
- @test XLSX.Charts.getChartData(f, "chart1") isa XLSX.DataTable
- end
+ @testset "gaps and errors" begin # chart_gaps.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_gaps.xlsx"))
+ c = XLSX.Charts.getCharts(f)[1]
- @testset "gaps and errors" begin # chart_gaps.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_gaps.xlsx"))
- c = XLSX.Charts.getCharts(f)[1]
+ @test XLSX.Charts.getChartTypes(c) == [:lineChart]
+ @test length(XLSX.Charts.getChartSeries(c; read_cached_values=false)) == 1
- @test XLSX.Charts.getChartTypes(c) == [:lineChart]
- @test length(XLSX.Charts.getChartSeries(c; read_cached_values=false)) == 1
+ v = XLSX.Charts.getChartSeries(c)[1].values
+ @test v.ptCount == 5
+ @test length(v.data) == 5
- v = XLSX.Charts.getChartSeries(c)[1].values
- @test v.ptCount == 5
- @test length(v.data) == 5
+ # Row 3 is blank: Excel omits the point entirely, leaving a gap in `idx`.
+ # Rows 4 and 5 are errors: written as text, stored as `missing`, recorded
+ # in `errors`. Both read back as `missing`; only `errors` tells them apart.
+ @test v.data[1] == 1.0
+ @test ismissing(v.data[2])
+ @test ismissing(v.data[3])
+ @test v.data[4] == 0.0 # #DIV/0! in the source; Excel caches it as 0
+ @test v.data[5] == 5.0
- # Row 3 is blank: Excel omits the point entirely, leaving a gap in `idx`.
- # Rows 4 and 5 are errors: written as text, stored as `missing`, recorded
- # in `errors`. Both read back as `missing`; only `errors` tells them apart.
- @test v.data[1] == 1.0
- @test ismissing(v.data[2])
- @test ismissing(v.data[3])
- @test v.data[4] == 0.0 # #DIV/0! in the source; Excel caches it as 0
- @test v.data[5] == 5.0
+ @test XLSX.iserror(v) == [false, false, true, false, false]
- @test XLSX.iserror(v) == [false, false, true, false, false]
+ @test XLSX.geterror(v, 3) == "#N/A"
+ @test XLSX.geterror(v, 4) == ""
+ @test XLSX.geterror(v, 1) == "" # #DIV/0! resolves to no error
+ @test XLSX.geterror(v, 2) == "" # blank resolves to no error
+ @test XLSX.geterror(v) == ["", "", "#N/A", "", ""]
- @test XLSX.geterror(v, 3) == "#N/A"
- @test XLSX.geterror(v, 4) == ""
- @test XLSX.geterror(v, 1) == "" # #DIV/0! resolves to no error
- @test XLSX.geterror(v, 2) == "" # blank resolves to no error
- @test XLSX.geterror(v) == ["", "", "#N/A", "", ""]
+ @test length(XLSX.geterror(v)) == length(v.data)
- @test length(XLSX.geterror(v)) == length(v.data)
+ # Categories are complete even where values are not.
+ @test XLSX.Charts.getChartSeries(c)[1].categories.data == ["a", "b", "c", "d", "e"]
+ end
- # Categories are complete even where values are not.
- @test XLSX.Charts.getChartSeries(c)[1].categories.data == ["a", "b", "c", "d", "e"]
- end
+ @testset "combo chart" begin # chart_combo.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_combo.xlsx"))
+ c = XLSX.Charts.getCharts(f)[1]
- @testset "combo chart" begin # chart_combo.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_combo.xlsx"))
- c = XLSX.Charts.getCharts(f)[1]
+ # Two group elements in one plotArea. Excel writes them in schema order,
+ # so compare as a set rather than pinning the sequence.
+ @test length(XLSX.Charts.getChartTypes(c)) == 2
+ @test issetequal(XLSX.Charts.getChartTypes(c), [:barChart, :lineChart])
- # Two group elements in one plotArea. Excel writes them in schema order,
- # so compare as a set rather than pinning the sequence.
- @test length(XLSX.Charts.getChartTypes(c)) == 2
- @test issetequal(XLSX.Charts.getChartTypes(c), [:barChart, :lineChart])
+ # Series are collected across both groups, with `order` continuing rather
+ # than restarting per group.
+ @test length(XLSX.Charts.getChartSeries(c; read_cached_values=false)) == 2
+ @test sort([s.order for s in XLSX.Charts.getChartSeries(c; read_cached_values=false)]) == [0, 1]
+ @test issetequal([s.charttype for s in XLSX.Charts.getChartSeries(c; read_cached_values=false)], [:barChart, :lineChart])
- # Series are collected across both groups, with `order` continuing rather
- # than restarting per group.
- @test length(XLSX.Charts.getChartSeries(c; read_cached_values=false)) == 2
- @test sort([s.order for s in XLSX.Charts.getChartSeries(c; read_cached_values=false)]) == [0, 1]
- @test issetequal([s.charttype for s in XLSX.Charts.getChartSeries(c; read_cached_values=false)], [:barChart, :lineChart])
+ dt = XLSX.Charts.getChartData(c)
+ @test length(dt.column_labels) == 3 # shared categories + two series
+ end
- dt = XLSX.Charts.getChartData(c)
- @test length(dt.column_labels) == 3 # shared categories + two series
- end
+ @testset "scatter chart" begin # chart_scatter.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_scatter.xlsx"))
+ c = XLSX.Charts.getCharts(f)[1]
+
+ @test XLSX.Charts.getChartTypes(c) == [:scatterChart]
+ @test length(XLSX.Charts.getChartSeries(c; read_cached_values=false)) == 2
+
+ # c:xVal folds into `categories`, c:yVal into `values`.
+ s1, s2 = XLSX.Charts.getChartSeries(c)
+ @test s1.categories.data == [1.0, 2.0, 3.0, 4.0]
+ @test s1.values.data == [10.0, 20.0, 30.0, 40.0]
+ @test s2.categories.data == [2.0, 4.0, 6.0, 8.0]
+ @test s2.values.data == [5.0, 15.0, 25.0, 35.0]
+
+ # Differing x references, so no shared `categories` column: each series
+ # brings its own.
+ @test s1.categories.ref != s2.categories.ref
+ dt = XLSX.Charts.getChartData(c)
+ @test length(dt.column_labels) == 4
+ @test endswith(String(dt.column_labels[1]), "_x")
+ @test endswith(String(dt.column_labels[3]), "_x")
+ end
- @testset "scatter chart" begin # chart_scatter.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_scatter.xlsx"))
- c = XLSX.Charts.getCharts(f)[1]
-
- @test XLSX.Charts.getChartTypes(c) == [:scatterChart]
- @test length(XLSX.Charts.getChartSeries(c; read_cached_values=false)) == 2
-
- # c:xVal folds into `categories`, c:yVal into `values`.
- s1, s2 = XLSX.Charts.getChartSeries(c)
- @test s1.categories.data == [1.0, 2.0, 3.0, 4.0]
- @test s1.values.data == [10.0, 20.0, 30.0, 40.0]
- @test s2.categories.data == [2.0, 4.0, 6.0, 8.0]
- @test s2.values.data == [5.0, 15.0, 25.0, 35.0]
-
- # Differing x references, so no shared `categories` column: each series
- # brings its own.
- @test s1.categories.ref != s2.categories.ref
- dt = XLSX.Charts.getChartData(c)
- @test length(dt.column_labels) == 4
- @test endswith(String(dt.column_labels[1]), "_x")
- @test endswith(String(dt.column_labels[3]), "_x")
- end
+ @testset "getChartData with per-series x values" begin
+ # chart_scatter_xy has two scatter series on different x ranges, so the
+ # categories can't collapse into one column.
+ xf = XLSX.readxlsx(joinpath(data_directory, "chart_scatter_xy.xlsx"))
+ c = XLSX.Charts.getChart(xf, "chart1")
+
+ r = XLSX.Charts.getChartRanges(c)
+ @test r[1].categories != r[2].categories # the premise of the fixture
+
+ dt = XLSX.Charts.getChartData(c)
+ @test dt.column_labels == [:y1_x, :y1, :y2_x, :y2]
+ @test dt.data[1] == [1.0, 2.0, 3.0, 4.0] # B1_x
+ @test dt.data[2] == [10.0, 20.0, 30.0, 40.0] # B1
+ @test dt.data[3] == [2.0, 4.0, 6.0, 8.0] # D1_x
+ @test dt.data[4] == [5.0, 15.0, 25.0, 35.0] # D1
+ end
- @testset "bubble chart and document order" begin # chart_bubble.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_bubble.xlsx"))
- charts = XLSX.Charts.getCharts(f)
- @test length(charts) == 2
+ @testset "bubble chart and document order" begin # chart_bubble.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_bubble.xlsx"))
+ charts = XLSX.Charts.getCharts(f)
+ @test length(charts) == 2
- # FIXTURE: the pie chart was inserted first but positioned to the right
- # of the bubble chart. Anchor order is creation order, not reading order,
- # so the pie comes back first.
- @test XLSX.Charts.getChartTypes(charts[1]) == [:pieChart]
- @test XLSX.Charts.getChartTypes(charts[2]) == [:bubbleChart]
+ # FIXTURE: the pie chart was inserted first but positioned to the right
+ # of the bubble chart. Anchor order is creation order, not reading order,
+ # so the pie comes back first.
+ @test XLSX.Charts.getChartTypes(charts[1]) == [:pieChart]
+ @test XLSX.Charts.getChartTypes(charts[2]) == [:bubbleChart]
- bub = charts[2]
- s = XLSX.Charts.getChartSeries(bub)[1]
- @test !isnothing(s.bubble_sizes)
- @test length(s.bubble_sizes.data) == length(s.values.data)
+ bub = charts[2]
+ s = XLSX.Charts.getChartSeries(bub)[1]
+ @test !isnothing(s.bubble_sizes)
+ @test length(s.bubble_sizes.data) == length(s.values.data)
- dt = XLSX.Charts.getChartData(bub)
- @test any(l -> endswith(String(l), "_size"), dt.column_labels)
- end
+ dt = XLSX.Charts.getChartData(bub)
+ @test any(l -> endswith(String(l), "_size"), dt.column_labels)
+ end
- @testset "multi-level categories" begin # chart_multilevel.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_multilevel.xlsx"))
- c = XLSX.Charts.getCharts(f)[1]
-
- cat = XLSX.Charts.getChartSeries(c)[1].categories
- @test cat.kind == :multiLvlStr
- @test cat.ptCount == 4
- @test length(cat.data) == 2 # two levels, each a vector
-
- # FIXTURE: `c:lvl` elements are stored in document order. Confirm which
- # way round Excel wrote them by unzipping the fixture and reading
- # xl/charts/chart1.xml before trusting this - and correct the ChartRef
- # docstring if it is the other way about.
- levels = cat.data
- @test issetequal(levels[1], ["Q1", "Q2"])
- @test issetequal(levels[2], ["North", "South"])
- @test levels[1] == ["Q1", "Q2", "Q1", "Q2"]
- @test levels[2] == ["North", "North", "South", "South"]
-
- dt = XLSX.Charts.getChartData(c)
- @test dt.column_labels[1] == :categories_1
- @test dt.column_labels[2] == :categories_2
- @test length(dt.column_labels) == 3 # two levels + one series
- end
+ @testset "multi-level categories" begin # chart_multilevel.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_multilevel.xlsx"))
+ c = XLSX.Charts.getCharts(f)[1]
+
+ cat = XLSX.Charts.getChartSeries(c)[1].categories
+ @test cat.kind == :multiLvlStr
+ @test cat.ptCount == 4
+ @test length(cat.data) == 2 # two levels, each a vector
+
+ # FIXTURE: `c:lvl` elements are stored in document order. Confirm which
+ # way round Excel wrote them by unzipping the fixture and reading
+ # xl/charts/chart1.xml before trusting this - and correct the ChartRef
+ # docstring if it is the other way about.
+ levels = cat.data
+ @test issetequal(levels[1], ["Q1", "Q2"])
+ @test issetequal(levels[2], ["North", "South"])
+ @test levels[1] == ["Q1", "Q2", "Q1", "Q2"]
+ @test levels[2] == ["North", "North", "South", "South"]
+
+ dt = XLSX.Charts.getChartData(c)
+ @test dt.column_labels[1] == :categories_1
+ @test dt.column_labels[2] == :categories_2
+ @test length(dt.column_labels) == 3 # two levels + one series
+ end
- @testset "chartsheet" begin # chart_chartsheet.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_chartsheet.xlsx"))
- charts = XLSX.Charts.getCharts(f)
- @test length(charts) == 1
+ @testset "chartsheet" begin # chart_chartsheet.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_chartsheet.xlsx"))
+ charts = XLSX.Charts.getCharts(f)
+ @test length(charts) == 1
- c = charts[1]
- @test c.sheet == "TheChart"
+ c = charts[1]
+ @test c.sheet == "TheChart"
- # A chartsheet drawing uses an absoluteAnchor with pos/ext, not cell
- # markers, so there is nothing to resolve into a CellRef.
- @test isnothing(c.from)
- @test isnothing(c.to)
+ # A chartsheet drawing uses an absoluteAnchor with pos/ext, not cell
+ # markers, so there is nothing to resolve into a CellRef.
+ @test isnothing(c.from)
+ @test isnothing(c.to)
- # The data still reads exactly as when the chart was on a worksheet.
- @test length(XLSX.Charts.getChartSeries(c)) == 2
- @test XLSX.Charts.getChartSeries(c)[1].values.data == [10.0, 20.0, 15.0, 5.0]
- end
+ # The data still reads exactly as when the chart was on a worksheet.
+ @test length(XLSX.Charts.getChartSeries(c)) == 2
+ @test XLSX.Charts.getChartSeries(c)[1].values.data == [10.0, 20.0, 15.0, 5.0]
+ end
- @testset "strict OOXML" begin # chart_strict.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_strict.xlsx"))
- charts = XLSX.Charts.getCharts(f)
-
- # Strict files are remapped to transitional at read, so the transitional
- # chart relationship type still resolves and the chart is found.
- @test length(charts) == 1
- c = charts[1]
- @test c.sheet == "Data"
- @test XLSX.Charts.getChartTypes(c) == [:barChart]
- @test XLSX.Charts.getChartSeries(c)[1].categories.data == ["North", "South", "East", "West"]
- @test XLSX.Charts.getChartSeries(c)[1].values.data == [10.0, 20.0, 15.0, 5.0]
- end
+ @testset "strict OOXML" begin # chart_strict.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_strict.xlsx"))
+ charts = XLSX.Charts.getCharts(f)
+
+ # Strict files are remapped to transitional at read, so the transitional
+ # chart relationship type still resolves and the chart is found.
+ @test length(charts) == 1
+ c = charts[1]
+ @test c.sheet == "Data"
+ @test XLSX.Charts.getChartTypes(c) == [:barChart]
+ @test XLSX.Charts.getChartSeries(c)[1].categories.data == ["North", "South", "East", "West"]
+ @test XLSX.Charts.getChartSeries(c)[1].values.data == [10.0, 20.0, 15.0, 5.0]
+ end
- @testset "chartEx is read for discovery" begin
- f = XLSX.readxlsx(joinpath(data_directory, "chart_ex.xlsx")) # your path
-
- charts = @test_nowarn XLSX.Charts.getCharts(f)
- @test length(charts) == 1
-
- c = charts[1]
- @test c isa XLSX.Charts.ChartEx
- @test XLSX.Charts.getChartSchema(c) === :cx
- @test XLSX.Charts.getChartType(c) === :waterfall
- @test c.name == "chartEx1"
- @test c.sheet == "Data"
- @test c.from == "G9"
- @test length(XLSX.Charts._cx_refs(c)) == 2
- @test XLSX.Charts._cx_refs(c) == ["_xlchart.v1.0", "_xlchart.v1.1"]
- ranges = XLSX.Charts.getChartRanges(c)
- @test length(ranges) == 2
- @test all(!isnothing, ranges)
- @test string(ranges[1]) == "Data!A2:A5"
- @test string(ranges[2]) == "Data!B2:B5"
-
- # Ranges resolve; cached values do not exist.
- ranges = XLSX.Charts.getChartRanges(c)
- @test length(ranges) == 2
- @test all(!isnothing, ranges)
-
- err = @test_throws XLSX.XLSXError XLSX.Charts.getChartData(c)
- @test occursin("waterfall", err.value.msg)
- @test occursin("chartEx", err.value.msg)
-
- # Reachable by name and by rId, like a c: chart.
- @test XLSX.Charts.getChart(f, "chartEx1") isa XLSX.Charts.ChartEx
- @test XLSX.Charts.getChart(f, "chartEx1.xml") isa XLSX.Charts.ChartEx
-
- # Sheet-scoped discovery finds it too.
- @test length(XLSX.Charts.getCharts(f["Data"])) == 1
-
- rm_xf = XLSX.readxlsx(joinpath(data_directory, "chartex_regionmap.xlsx"))
- rmc = only(XLSX.getCharts(rm_xf))
- @test rmc isa XLSX.Charts.ChartEx
- @test XLSX.Charts.getChartType(rmc) === :regionMap
- # survives a round trip
- f = "regionmap_rt.xlsx"
- XLSX.writexlsx(f, XLSX.openxlsx(joinpath(data_directory, "chartex_regionmap.xlsx"); mode="rw"); overwrite=true)
- SAVE_FILES && save_outfile(f)
- @test only(XLSX.getCharts(XLSX.readxlsx(f))) isa XLSX.Charts.ChartEx
- isfile(f) && rm(f)
- end
- @testset "external reference" begin # chart_external.xlsx
- f = XLSX.readxlsx(joinpath(data_directory, "chart_external.xlsx"))
- c = XLSX.Charts.getCharts(f)[1]
-
- @test XLSX.Charts.getChartTypes(c) == [:lineChart]
- @test length(XLSX.Charts.getChartSeries(c)) == 2
-
- # Source lives in another workbook: the `[1]` indexes the workbook's
- # external references.
- @test XLSX.Charts.getChartSeries(c)[1].values.ref == "[1]Feuil1!\$A\$1:\$A\$10"
- @test XLSX.Charts.getChartSeries(c)[2].values.ref == "[1]Feuil1!\$B\$1:\$B\$10"
-
- # No c:cat at all - Excel plots against an implicit index and caches
- # nothing for it - and no c:tx, so the series are unnamed.
- @test all(s -> isnothing(s.categories), XLSX.Charts.getChartSeries(c; read_cached_values=false))
- @test all(s -> isnothing(s.name), XLSX.Charts.getChartSeries(c; read_cached_values=false))
-
- @test XLSX.Charts.getChartSeries(c)[1].values.data == collect(1.0:10.0)
- @test XLSX.Charts.getChartSeries(c)[2].values.data == [1.0, 10.0, 5.0, 2.0, 3.0, 45.0, 6.0, 8.0, 7.0, 2.0]
-
- # No categories, so no leading column, and positional series labels.
- dt = XLSX.Charts.getChartData(c)
- @test dt.column_labels == [:Series1, :Series2]
- @test length(dt.data[1]) == 10
-
- # Materialising the reference resolves `[1]` through xl/externalLinks and
- # its relationships (regression test for get_external_workbook_path,
- # which previously ignored the externalBook r:id).
- c = XLSX.Charts.getCharts(f)[1]
- @test XLSX.Charts.getChartSeries(c; get_external_refs=true)[1].values.ref == "[Test2.xlsx]Feuil1!\$A\$1:\$A\$10"
- @test XLSX.Charts.getChartSeries(c; get_external_refs=true)[1].values.data == XLSX.Charts.getChartSeries(c)[1].values.data # unchanged by materialising
- end
+ @testset "chartEx is read for discovery" begin
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_ex.xlsx"))
+
+ charts = @test_nowarn XLSX.Charts.getCharts(f)
+ @test length(charts) == 1
+
+ c = charts[1]
+ @test c isa XLSX.Charts.ChartEx
+ @test XLSX.Charts.getChartSchema(c) === :cx
+ @test XLSX.Charts.getChartType(c) === :waterfall
+ @test c.name == "chartEx1"
+ @test c.sheet == "Data"
+ @test c.from == "G9"
+ @test length(XLSX.Charts._cx_refs(c)) == 2
+ @test XLSX.Charts._cx_refs(c) == ["_xlchart.v1.0", "_xlchart.v1.1"]
+ ranges = XLSX.Charts.getChartRanges(c)
+ @test length(ranges) == 2
+ @test all(!isnothing, ranges)
+ @test string(ranges[1]) == "Data!A2:A5"
+ @test string(ranges[2]) == "Data!B2:B5"
+
+ # Ranges resolve (above); cached values do not exist.
+ err = @test_throws XLSX.XLSXError XLSX.Charts.getChartData(c)
+ @test occursin("waterfall", err.value.msg)
+ @test occursin("chartEx", err.value.msg)
+
+ # Reachable by name and by rId, like a c: chart.
+ @test XLSX.Charts.getChart(f, "chartEx1") isa XLSX.Charts.ChartEx
+ @test XLSX.Charts.getChart(f, "chartEx1.xml") isa XLSX.Charts.ChartEx
+
+ # Sheet-scoped discovery finds it too.
+ @test length(XLSX.Charts.getCharts(f["Data"])) == 1
+ end
+
+ @testset "regionMap chartEx is read and survives a round trip" begin
+ rm_xf = XLSX.readxlsx(joinpath(data_directory, "chartex_regionmap.xlsx"))
+ rmc = only(XLSX.getCharts(rm_xf))
+ @test rmc isa XLSX.Charts.ChartEx
+ @test XLSX.Charts.getChartType(rmc) === :regionMap
+ # survives a round trip
+ f = "regionmap_rt.xlsx"
+ XLSX.writexlsx(f, XLSX.openxlsx(joinpath(data_directory, "chartex_regionmap.xlsx"); mode="rw"); overwrite=true)
+ SAVE_FILES && save_outfile(f)
+ @test only(XLSX.getCharts(XLSX.readxlsx(f))) isa XLSX.Charts.ChartEx
+ isfile(f) && rm(f)
+ end
- @testset "chart cache agrees with external link cache" begin # chart_external.xlsx
- # The workbook carries two independently written copies of the same
- # numbers: the chart's c:numCache, and the externalLink's sheetDataSet.
- # Parsing the second by hand checks the first without a reference
- # implementation.
- f = XLSX.readxlsx(joinpath(data_directory, "chart_external.xlsx"))
-
- function external_column(xf, col::String)
- root = XLSX.xml_root_element(xf.data["xl/externalLinks/externalLink1.xml"])
- book = XLSX.first_element_with_tag(root, "externalBook")
- dataset = XLSX.first_element_with_tag(book, "sheetDataSet")
- sheet = XLSX.first_element_with_tag(dataset, "sheetData")
- out = Float64[]
- for row in XML.children(sheet)
- XML.nodetype(row) == XML.Element && XLSX.localname(row) == "row" || continue
- for cell in XML.children(row)
- XML.nodetype(cell) == XML.Element && XLSX.localname(cell) == "cell" || continue
- startswith(XLSX.get_attr(cell, "r"), col) || continue
- push!(out, parse(Float64, XLSX.child_text(cell, "v")))
+ @testset "external reference" begin # chart_external.xlsx
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_external.xlsx"))
+ c = XLSX.Charts.getCharts(f)[1]
+
+ @test XLSX.Charts.getChartTypes(c) == [:lineChart]
+ @test length(XLSX.Charts.getChartSeries(c)) == 2
+
+ # Source lives in another workbook: the `[1]` indexes the workbook's
+ # external references.
+ @test XLSX.Charts.getChartSeries(c)[1].values.ref == "[1]Feuil1!\$A\$1:\$A\$10"
+ @test XLSX.Charts.getChartSeries(c)[2].values.ref == "[1]Feuil1!\$B\$1:\$B\$10"
+
+ # No c:cat at all - Excel plots against an implicit index and caches
+ # nothing for it - and no c:tx, so the series are unnamed.
+ @test all(s -> isnothing(s.categories), XLSX.Charts.getChartSeries(c; read_cached_values=false))
+ @test all(s -> isnothing(s.name), XLSX.Charts.getChartSeries(c; read_cached_values=false))
+
+ @test XLSX.Charts.getChartSeries(c)[1].values.data == collect(1.0:10.0)
+ @test XLSX.Charts.getChartSeries(c)[2].values.data == [1.0, 10.0, 5.0, 2.0, 3.0, 45.0, 6.0, 8.0, 7.0, 2.0]
+
+ # No categories, so no leading column, and positional series labels.
+ dt = XLSX.Charts.getChartData(c)
+ @test dt.column_labels == [:Series1, :Series2]
+ @test length(dt.data[1]) == 10
+
+ # Materialising the reference resolves `[1]` through xl/externalLinks and
+ # its relationships (regression test for get_external_workbook_path,
+ # which previously ignored the externalBook r:id).
+ c = XLSX.Charts.getCharts(f)[1]
+ @test XLSX.Charts.getChartSeries(c; get_external_refs=true)[1].values.ref == "[Test2.xlsx]Feuil1!\$A\$1:\$A\$10"
+ @test XLSX.Charts.getChartSeries(c; get_external_refs=true)[1].values.data == XLSX.Charts.getChartSeries(c)[1].values.data # unchanged by materialising
+ end
+
+ @testset "chart cache agrees with external link cache" begin # chart_external.xlsx
+ # The workbook carries two independently written copies of the same
+ # numbers: the chart's c:numCache, and the externalLink's sheetDataSet.
+ # Parsing the second by hand checks the first without a reference
+ # implementation.
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_external.xlsx"))
+
+ function external_column(xf, col::String)
+ root = XLSX.xml_root_element(xf.data["xl/externalLinks/externalLink1.xml"])
+ book = XLSX.first_element_with_tag(root, "externalBook")
+ dataset = XLSX.first_element_with_tag(book, "sheetDataSet")
+ sheet = XLSX.first_element_with_tag(dataset, "sheetData")
+ out = Float64[]
+ for row in XML.children(sheet)
+ XML.nodetype(row) == XML.Element && XLSX.localname(row) == "row" || continue
+ for cell in XML.children(row)
+ XML.nodetype(cell) == XML.Element && XLSX.localname(cell) == "cell" || continue
+ startswith(XLSX.get_attr(cell, "r"), col) || continue
+ push!(out, parse(Float64, XLSX.child_text(cell, "v")))
+ end
end
+ return out
end
- return out
+
+ c = XLSX.Charts.getCharts(f)[1]
+ @test XLSX.Charts.getChartSeries(c)[1].values.data == external_column(f, "A")
+ @test XLSX.Charts.getChartSeries(c)[2].values.data == external_column(f, "B")
end
- c = XLSX.Charts.getCharts(f)[1]
- @test XLSX.Charts.getChartSeries(c)[1].values.data == external_column(f, "A")
- @test XLSX.Charts.getChartSeries(c)[2].values.data == external_column(f, "B")
- end
+ @testset "round trip" begin # chart_basic.xlsx
+ # Reading charts must not disturb them: the chart part should survive a
+ # save byte for byte, and read back identically.
+ original = joinpath(data_directory, "chart_basic.xlsx")
+ f = XLSX.openxlsx(original; mode="rw")
+ before = XLSX.Charts.getChartData(XLSX.Charts.getCharts(f)[1])
+
+ tmp = "chart_basic_roundtrip.xlsx"
+ XLSX.writexlsx(tmp, f; overwrite=true)
+
+ part = "xl/charts/chart1.xml"
+ # XML.jl pretty-prints where Excel writes a single line; the CRLF after the
+ # declaration differs too. Both are cosmetic — XML ignores inter-tag
+ # whitespace — so compare with it collapsed rather than byte for byte.
+ strip_ws(s) = replace(s, r">\s+<" => "><")
+ @test strip_ws(String(zip_readentry(ZipReader(read(original)), part))) ==
+ strip_ws(String(zip_readentry(ZipReader(read(tmp)), part)))
+
+ g = XLSX.readxlsx(tmp)
+ c = XLSX.Charts.getCharts(g)[1]
+ @test c.sheet == "Data"
+ @test XLSX.Charts.getChartTitle(c) == "Revenue by Region"
+ after = XLSX.Charts.getChartData(c)
+ @test after.column_labels == before.column_labels
+ @test after.data == before.data
+
+ SAVE_FILES && save_outfile(tmp)
+ isfile(tmp) && rm(tmp)
+ end
- @testset "round trip" begin # chart_basic.xlsx
- # Reading charts must not disturb them: the chart part should survive a
- # save byte for byte, and read back identically.
- original = joinpath(data_directory, "chart_basic.xlsx")
- f = XLSX.openxlsx(original; mode="rw")
- before = XLSX.Charts.getChartData(XLSX.Charts.getCharts(f)[1])
-
- tmp = "chart_basic_roundtrip.xlsx"
- XLSX.writexlsx(tmp, f; overwrite=true)
-
- part = "xl/charts/chart1.xml"
- # XML.jl pretty-prints where Excel writes a single line; the CRLF after the
- # declaration differs too. Both are cosmetic — XML ignores inter-tag
- # whitespace — so compare with it collapsed rather than byte for byte.
- strip_ws(s) = replace(s, r">\s+<" => "><")
- @test strip_ws(String(zip_readentry(ZipReader(read(original)), part))) ==
- strip_ws(String(zip_readentry(ZipReader(read(tmp)), part)))
-
- g = XLSX.readxlsx(tmp)
- c = XLSX.Charts.getCharts(g)[1]
- @test c.sheet == "Data"
- @test XLSX.Charts.getChartTitle(c) == "Revenue by Region"
- after = XLSX.Charts.getChartData(c)
- @test after.column_labels == before.column_labels
- @test after.data == before.data
-
- SAVE_FILES && save_outfile(tmp)
- isfile(tmp) && rm(tmp)
+ @testset "accessor forms and small chart helpers" begin
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_appearance.xlsx"))
+ c = XLSX.Charts.getCharts(f)[1]
+ @test XLSX.Charts.getChartSchema(c) === :c
+ @test XLSX.Charts.chartname(c) == c.name && XLSX.Charts.chartpath(c) == c.path
+ @test XLSX.Charts.sheetname(c) == c.sheet
+ @test XLSX.Charts.getLegendShapeProps(c) isa Union{Nothing,XLSX.Charts.DrawingShapeProps}
+ @test XLSX.Charts.getChartTitleShapeProps(c) isa Union{Nothing,XLSX.Charts.DrawingShapeProps}
+ g = first(XLSX.Charts.getChartGroups(c))
+ @test isnothing(XLSX.Charts.getGroupSeriesLines(c, g)) # a bar chart without c:serLines
+
+ s1 = XLSX.Charts.getChartSeries(c)[1]
+ r = s1.values
+ @test XLSX.Charts.iserror(r, 1) == XLSX.Charts.iserror(r)[1]
+ @test XLSX.Charts.no_cached_values(r) == false
+
+ @test XLSX.Charts._show_color(XLSX.Charts.SchemeColor(:accent1)) == "accent1"
+ pfx = Dict(XLSX.Charts.NS_A => "a")
+ @test XLSX.localname(XLSX.Charts._color_node_from(XLSX.Charts.SchemeColor(:accent1), pfx)) == "schemeClr"
+ @test XLSX.localname(XLSX.Charts._color_node_from("FF0000", pfx)) == "srgbClr"
+ @test XLSX.Charts._check("bar", (:bar, :col), "direction") === :bar
+
+ sh = XLSX.newxlsx()[1]
+ @test occursin("\$A\$1:\$A\$2", XLSX.Charts._chart_f(sh, XLSX.NonContiguousRange("Sheet1!A1:A2,Sheet1!C1")))
+ @test_throws XLSX.XLSXError XLSX.Charts._chart_f(sh, 42)
+
+ xf = XLSX.opentemplate(joinpath(data_directory, "chart_kinds.xlsx"))
+ lc = XLSX.Charts.getCharts(xf["linemarkers"])[1]
+ XLSX.Charts.setMarker(lc, 1; symbol=:circle, size=7)
+ m = XLSX.Charts.getSeriesMarker(lc, 1)
+ @test XLSX.localname(XLSX.Charts._node(lc, m)) == "marker"
+ SAVE_FILES && save_outfile(xf)
+ end
end
- @testset "chart_range" begin
- cr(ref) = XLSX.Charts.chart_range(XLSX.Charts.ChartRef(:num, ref, nothing, 0, Any[], Dict{Int,UInt64}()))
-
- @test cr(nothing) === nothing
- @test XLSX.Charts.chart_range(nothing) === nothing
- @test cr("[1]Sheet1!\$A\$1:\$A\$5") === nothing # external
- @test cr("MyDefinedName") === nothing # defined name
- @test cr("Sheet1!\$A\$1:\$A\$5") isa XLSX.SheetCellRange
- @test cr("Sheet1!A1:A5") isa XLSX.SheetCellRange
- @test cr("Sheet1!\$A\$1") isa XLSX.SheetCellRef
- @test cr("Sheet1!A:C") isa XLSX.SheetColumnRange
- @test cr("(Sheet1!\$A\$1:\$A\$3,Sheet1!\$C\$1:\$C\$3)") isa XLSX.NonContiguousRange
- @test cr("Sheet1!\$A\$1:\$A\$3,Sheet1!\$C\$1:\$C\$3") isa XLSX.NonContiguousRange
- end
- @testset "row-range source (ChartRange union)" begin
- rr(ref) = XLSX.Charts.ChartRef(:num, ref, nothing, 0, Any[], Dict{Int,UInt64}())
-
- @test XLSX.Charts.chart_range(rr("Sheet1!\$2:\$5")) isa XLSX.SheetRowRange
- @test XLSX.Charts.chart_range(rr("Sheet1!2:5")) isa XLSX.SheetRowRange
- @test XLSX.Charts.chart_range(rr("Sheet1!\$A:\$C")) isa XLSX.SheetColumnRange
-
- # the conversion that used to throw
- s = XLSX.Charts.ChartSeries(0, 0, :barChart, "S", nothing,
- rr("Sheet1!\$2:\$2"), rr("Sheet1!\$3:\$3"), nothing,
- XML.Element("c:ser"))
- ranges = XLSX.Charts._chart_ranges([s])
- @test ranges[1].categories isa XLSX.SheetRowRange
- @test ranges[1].values isa XLSX.SheetRowRange
- end
- @testset "ChartRange union covers chart_range" begin
- @test XLSX.SheetCellRef <: XLSX.Charts.ChartRange
- @test XLSX.SheetCellRange <: XLSX.Charts.ChartRange
- @test XLSX.SheetColumnRange <: XLSX.Charts.ChartRange
- @test XLSX.SheetRowRange <: XLSX.Charts.ChartRange
- @test XLSX.NonContiguousRange <: XLSX.Charts.ChartRange
- @test Nothing <: XLSX.Charts.ChartRange
- end
+ @testset "chart ranges" begin
+ @testset "chart_range" begin
+ cr(ref) = XLSX.Charts.chart_range(XLSX.Charts.ChartRef(:num, ref, nothing, 0, Any[], Dict{Int,UInt64}()))
+
+ @test cr(nothing) === nothing
+ @test XLSX.Charts.chart_range(nothing) === nothing
+ @test cr("[1]Sheet1!\$A\$1:\$A\$5") === nothing # external
+ @test cr("MyDefinedName") === nothing # defined name
+ @test cr("Sheet1!\$A\$1:\$A\$5") isa XLSX.SheetCellRange
+ @test cr("Sheet1!A1:A5") isa XLSX.SheetCellRange
+ @test cr("Sheet1!\$A\$1") isa XLSX.SheetCellRef
+ @test cr("Sheet1!A:C") isa XLSX.SheetColumnRange
+ @test cr("(Sheet1!\$A\$1:\$A\$3,Sheet1!\$C\$1:\$C\$3)") isa XLSX.NonContiguousRange
+ @test cr("Sheet1!\$A\$1:\$A\$3,Sheet1!\$C\$1:\$C\$3") isa XLSX.NonContiguousRange
+ end
- @testset "literal series" begin
- lit(tag, pts) = XLSX.XML.parse("""""" *
- join("$p" for (i, p) in enumerate(pts)) *
- "", XLSX.XML.Node)
- root(d) = first(XLSX.XML.eachelement(d))
-
- n = XLSX.Charts.parse_chart_ref(root(lit("numLit", [1, 2.5])))
- @test n.kind === :numLit
- @test isnothing(n.ref)
- @test n.data == [1.0, 2.5]
- @test isnothing(XLSX.Charts.chart_range(n)) # literal: no addressable range
-
- s = XLSX.Charts.parse_chart_ref(root(lit("strLit", ["a", "b"])))
- @test s.kind === :strLit
- @test s.data == ["a", "b"]
-
- m = XLSX.Charts.parse_chart_ref(root(lit("numLit", [1, 2])); read_cached_values=false)
- @test isempty(m.data)
- @test m.ptCount == 2
- end
- @testset "unique labels" begin
- labels = Symbol[]
- @test XLSX.Charts.unique_label!(labels, "Sales") === :Sales
- @test XLSX.Charts.unique_label!(labels, "Sales") === :Sales_2
- @test XLSX.Charts.unique_label!(labels, "Sales") === :Sales_3
- @test XLSX.Charts.unique_label!(labels, "") === :column
- end
+ @testset "row-range source (ChartRange union)" begin
+ rr(ref) = XLSX.Charts.ChartRef(:num, ref, nothing, 0, Any[], Dict{Int,UInt64}())
+
+ @test XLSX.Charts.chart_range(rr("Sheet1!\$2:\$5")) isa XLSX.SheetRowRange
+ @test XLSX.Charts.chart_range(rr("Sheet1!2:5")) isa XLSX.SheetRowRange
+ @test XLSX.Charts.chart_range(rr("Sheet1!\$A:\$C")) isa XLSX.SheetColumnRange
+
+ # the conversion that used to throw
+ s = XLSX.Charts.ChartSeries(0, 0, :barChart, "S", nothing,
+ rr("Sheet1!\$2:\$2"), rr("Sheet1!\$3:\$3"), nothing,
+ XML.Element("c:ser"))
+ ranges = XLSX.Charts._chart_ranges([s])
+ @test ranges[1].categories isa XLSX.SheetRowRange
+ @test ranges[1].values isa XLSX.SheetRowRange
+ end
- @testset "getChartRanges dispatch" begin
- f = XLSX.readxlsx(joinpath(data_directory, "chart_bubble.xlsx"))
-
- # workbook-wide form: Vector{@NamedTuple{chart::String, ranges::Vector{ChartRanges}}}
- all_f = XLSX.Charts.getChartRanges(f)
- @test all_f isa Vector
- @test length(all_f) == 2
- @test all(x -> x isa NamedTuple{(:chart, :ranges)}, all_f)
- @test all(x -> x.chart isa String, all_f)
- @test all(x -> x.ranges isa Vector{XLSX.Charts.ChartRanges}, all_f)
-
- # follows getCharts, per the docstring
- @test [x.chart for x in all_f] == [c.name for c in XLSX.Charts.getCharts(f)]
-
- # identify the two charts by type rather than by part name
- charts = XLSX.Charts.getCharts(f)
- bub = charts[findfirst(c -> :bubbleChart in XLSX.Charts.getChartTypes(c), charts)]
- pie = charts[findfirst(c -> :pieChart in XLSX.Charts.getChartTypes(c), charts)]
-
- # --- (x, name) form -----------------------------------------------------
- rb = XLSX.Charts.getChartRanges(f, bub.name)
- rp = XLSX.Charts.getChartRanges(f, pie.name)
- @test rb isa Vector{XLSX.Charts.ChartRanges}
- @test rp isa Vector{XLSX.Charts.ChartRanges}
-
- # parallel to XLSX.Charts.getChartSeries(bub), document order
- @test length(rb) == length(XLSX.Charts.getChartSeries(bub))
- @test [x.idx for x in rb] == [s.idx for s in XLSX.Charts.getChartSeries(bub)]
- @test [x.name for x in rb] == [s.name for s in XLSX.Charts.getChartSeries(bub)]
-
- # every field is a member of the declared union
- for x in vcat(rb, rp), fld in (:categories, :values, :bubble_sizes)
- @test getfield(x, fld) isa XLSX.Charts.ChartRange
+ @testset "ChartRange union covers chart_range" begin
+ @test XLSX.SheetCellRef <: XLSX.Charts.ChartRange
+ @test XLSX.SheetCellRange <: XLSX.Charts.ChartRange
+ @test XLSX.SheetColumnRange <: XLSX.Charts.ChartRange
+ @test XLSX.SheetRowRange <: XLSX.Charts.ChartRange
+ @test XLSX.NonContiguousRange <: XLSX.Charts.ChartRange
+ @test Nothing <: XLSX.Charts.ChartRange
end
- # the docstring's specific claim: bubble_sizes only on bubble charts
- @test any(!isnothing(x.bubble_sizes) for x in rb)
- @test all(isnothing(x.bubble_sizes) for x in rp)
+ @testset "literal series" begin
+ lit(tag, pts) = XLSX.XML.parse("""""" *
+ join("$p" for (i, p) in enumerate(pts)) *
+ "", XLSX.XML.Node)
+ root(d) = first(XLSX.XML.eachelement(d))
+
+ n = XLSX.Charts.parse_chart_ref(root(lit("numLit", [1, 2.5])))
+ @test n.kind === :numLit
+ @test isnothing(n.ref)
+ @test n.data == [1.0, 2.5]
+ @test isnothing(XLSX.Charts.chart_range(n)) # literal: no addressable range
+
+ s = XLSX.Charts.parse_chart_ref(root(lit("strLit", ["a", "b"])))
+ @test s.kind === :strLit
+ @test s.data == ["a", "b"]
+
+ m = XLSX.Charts.parse_chart_ref(root(lit("numLit", [1, 2])); read_cached_values=false)
+ @test isempty(m.data)
+ @test m.ptCount == 2
+ end
- # bubble uses xVal/yVal, which land in categories/values
- @test all(!isnothing(x.categories) for x in rb)
- @test all(!isnothing(x.values) for x in rb)
+ @testset "unique labels" begin
+ labels = Symbol[]
+ @test XLSX.Charts.unique_label!(labels, "Sales") === :Sales
+ @test XLSX.Charts.unique_label!(labels, "Sales") === :Sales_2
+ @test XLSX.Charts.unique_label!(labels, "Sales") === :Sales_3
+ @test XLSX.Charts.unique_label!(labels, "") === :column
+ end
- # name forms getChart accepts
- @test XLSX.Charts.getChartRanges(f, bub.name * ".xml") == rb
+ @testset "getChartRanges dispatch" begin
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_bubble.xlsx"))
+
+ # workbook-wide form: Vector{@NamedTuple{chart::String, ranges::Vector{ChartRanges}}}
+ all_f = XLSX.Charts.getChartRanges(f)
+ @test all_f isa Vector
+ @test length(all_f) == 2
+ @test all(x -> x isa NamedTuple{(:chart, :ranges)}, all_f)
+ @test all(x -> x.chart isa String, all_f)
+ @test all(x -> x.ranges isa Vector{XLSX.Charts.ChartRanges}, all_f)
+
+ # follows getCharts, per the docstring
+ @test [x.chart for x in all_f] == [c.name for c in XLSX.Charts.getCharts(f)]
+
+ # identify the two charts by type rather than by part name
+ charts = XLSX.Charts.getCharts(f)
+ bub = charts[findfirst(c -> :bubbleChart in XLSX.Charts.getChartTypes(c), charts)]
+ pie = charts[findfirst(c -> :pieChart in XLSX.Charts.getChartTypes(c), charts)]
+
+ # --- (x, name) form -----------------------------------------------------
+ rb = XLSX.Charts.getChartRanges(f, bub.name)
+ rp = XLSX.Charts.getChartRanges(f, pie.name)
+ @test rb isa Vector{XLSX.Charts.ChartRanges}
+ @test rp isa Vector{XLSX.Charts.ChartRanges}
+
+ # parallel to XLSX.Charts.getChartSeries(bub), document order
+ @test length(rb) == length(XLSX.Charts.getChartSeries(bub))
+ @test [x.idx for x in rb] == [s.idx for s in XLSX.Charts.getChartSeries(bub)]
+ @test [x.name for x in rb] == [s.name for s in XLSX.Charts.getChartSeries(bub)]
+
+ # every field is a member of the declared union
+ for x in vcat(rb, rp), fld in (:categories, :values, :bubble_sizes)
+ @test getfield(x, fld) isa XLSX.Charts.ChartRange
+ end
- @test_throws XLSX.XLSXError XLSX.Charts.getChartRanges(f, "nosuchchart")
+ # the docstring's specific claim: bubble_sizes only on bubble charts
+ @test any(!isnothing(x.bubble_sizes) for x in rb)
+ @test all(isnothing(x.bubble_sizes) for x in rp)
- # --- worksheet form agrees with the workbook form ------------------------
- ws = f[bub.sheet]
- all_ws = XLSX.Charts.getChartRanges(ws)
- @test all(x -> x isa NamedTuple{(:chart, :ranges)}, all_ws)
- @test issubset(Set(x.chart for x in all_ws), Set(x.chart for x in all_f))
+ # bubble uses xVal/yVal, which land in categories/values
+ @test all(!isnothing(x.categories) for x in rb)
+ @test all(!isnothing(x.values) for x in rb)
- i = findfirst(x -> x.chart == bub.name, all_f)
- @test !isnothing(i)
- @test all_f[i].ranges == rb
+ # name forms getChart accepts
+ @test XLSX.Charts.getChartRanges(f, bub.name * ".xml") == rb
- end
+ @test_throws XLSX.XLSXError XLSX.Charts.getChartRanges(f, "nosuchchart")
- @testset "mixed c: and cx: on one sheet" begin
- f = XLSX.readxlsx(joinpath(data_directory, "chart_mixed.xlsx"))
- charts = XLSX.Charts.getCharts(f)
+ # --- worksheet form agrees with the workbook form ------------------------
+ ws = f[bub.sheet]
+ all_ws = XLSX.Charts.getChartRanges(ws)
+ @test all(x -> x isa NamedTuple{(:chart, :ranges)}, all_ws)
+ @test issubset(Set(x.chart for x in all_ws), Set(x.chart for x in all_f))
- @test length(charts) == 2
- @test count(c -> c isa XLSX.Charts.Chart, charts) == 1
- @test count(c -> c isa XLSX.Charts.ChartEx, charts) == 1
- @test issetequal(XLSX.Charts.getChartSchema.(charts), [:c, :cx])
+ i = findfirst(x -> x.chart == bub.name, all_f)
+ @test !isnothing(i)
+ @test all_f[i].ranges == rb
- # Each chart keeps its own anchor — the risk is one anchor's from/to
- # being attributed to the other chart's frame.
- @test all(c -> !isnothing(c.sheet), charts)
+ end
- c = only(filter(x -> x isa XLSX.Charts.Chart, charts))
- cx = only(filter(x -> x isa XLSX.Charts.ChartEx, charts))
+ @testset "mixed c: and cx: on one sheet" begin
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_mixed.xlsx"))
+ charts = XLSX.Charts.getCharts(f)
- @test cx.from == "F1"
- @test c.from == "F17"
- @test XLSX.Charts.getChartType(c) === :barChart # adjust to what you inserted
- @test XLSX.Charts.getChartType(cx) === :waterfall
+ @test length(charts) == 2
+ @test count(c -> c isa XLSX.Charts.Chart, charts) == 1
+ @test count(c -> c isa XLSX.Charts.ChartEx, charts) == 1
+ @test issetequal(XLSX.Charts.getChartSchema.(charts), [:c, :cx])
- # Data works for one, throws for the other; ranges work for both.
- @test XLSX.Charts.getChartData(c) isa XLSX.DataTable
- @test_throws XLSX.XLSXError XLSX.Charts.getChartData(cx)
- @test !isempty(XLSX.Charts.getChartRanges(c))
- @test all(!isnothing, XLSX.Charts.getChartRanges(cx))
+ # Each chart keeps its own anchor — the risk is one anchor's from/to
+ # being attributed to the other chart's frame.
+ @test all(c -> !isnothing(c.sheet), charts)
- # Lookup by name reaches both.
- @test XLSX.Charts.getChart(f, c.name) isa XLSX.Charts.Chart
- @test XLSX.Charts.getChart(f, cx.name) isa XLSX.Charts.ChartEx
+ c = only(filter(x -> x isa XLSX.Charts.Chart, charts))
+ cx = only(filter(x -> x isa XLSX.Charts.ChartEx, charts))
- # Sheet-scoped discovery finds both.
- @test length(XLSX.Charts.getCharts(f[c.sheet])) == 2
+ @test cx.from == "F1"
+ @test c.from == "F17"
+ @test XLSX.Charts.getChartType(c) === :barChart
+ @test XLSX.Charts.getChartType(cx) === :waterfall
- # Document order within the drawing, not schema order.
- @test charts[1] isa XLSX.Charts.ChartEx
- @test charts[2] isa XLSX.Charts.Chart
- end
- @testset "c: chart kind templates match Excel" begin
- fx = joinpath(data_directory, "chart_kinds.xlsx")
- raw = XLSX.ZipArchives.ZipReader(read(fx))
- bysheet = Dict(c.sheet => c for c in XLSX.Charts.getCharts(XLSX.readxlsx(fx)))
- for (kind, t) in pairs(XLSX.Charts.C_KINDS)
- c = bysheet[String(t.template)]
- @test XLSX.ZipArchives.zip_readentry(raw, c.path, String) == XLSX.Charts.CHART_KIND_TEMPLATES[t.template]
- @test haskey(XLSX.Charts.CHART_STYLE_TEMPLATES, t.style)
+ # Data works for one, throws for the other; ranges work for both.
+ @test XLSX.Charts.getChartData(c) isa XLSX.DataTable
+ @test_throws XLSX.XLSXError XLSX.Charts.getChartData(cx)
+ @test !isempty(XLSX.Charts.getChartRanges(c))
+ @test all(!isnothing, XLSX.Charts.getChartRanges(cx))
+
+ # Lookup by name reaches both.
+ @test XLSX.Charts.getChart(f, c.name) isa XLSX.Charts.Chart
+ @test XLSX.Charts.getChart(f, cx.name) isa XLSX.Charts.ChartEx
+
+ # Sheet-scoped discovery finds both.
+ @test length(XLSX.Charts.getCharts(f[c.sheet])) == 2
+
+ # Document order within the drawing, not schema order.
+ @test charts[1] isa XLSX.Charts.ChartEx
+ @test charts[2] isa XLSX.Charts.Chart
end
end
- @testset "getChartData with per-series x values" begin
- # chart_scatter_xy has two scatter series on different x ranges, so the
- # categories can't collapse into one column.
- xf = XLSX.readxlsx(joinpath(data_directory, "chart_scatter_xy.xlsx"))
- c = XLSX.Charts.getChart(xf, "chart1")
-
- r = XLSX.Charts.getChartRanges(c)
- @test r[1].categories != r[2].categories # the premise of the fixture
-
- dt = XLSX.Charts.getChartData(c)
- @test dt.column_labels == [:y1_x, :y1, :y2_x, :y2]
- @test dt.data[1] == [1.0, 2.0, 3.0, 4.0] # B1_x
- @test dt.data[2] == [10.0, 20.0, 30.0, 40.0] # B1
- @test dt.data[3] == [2.0, 4.0, 6.0, 8.0] # D1_x
- @test dt.data[4] == [5.0, 15.0, 25.0, 35.0] # D1
- end
- @testset "cx: chart kind templates match Excel" begin
- fx = joinpath(data_directory, "chartex_kinds.xlsx")
- raw = XLSX.ZipArchives.ZipReader(read(fx))
- for c in XLSX.Charts.getCharts(XLSX.readxlsx(fx))
- c isa XLSX.Charts.ChartEx || continue
- k = XLSX.Charts.getChartType(c)
- @test haskey(XLSX.Charts.CX_KINDS, k)
- @test XLSX.ZipArchives.zip_readentry(raw, c.path, String) == XLSX.Charts.CHARTEX_KIND_TEMPLATES[k]
+
+ @testset "chart kind templates match Excel" begin
+ @testset "c: chart kind templates match Excel" begin
+ fx = joinpath(data_directory, "chart_kinds.xlsx")
+ raw = XLSX.ZipArchives.ZipReader(read(fx))
+ bysheet = Dict(c.sheet => c for c in XLSX.Charts.getCharts(XLSX.readxlsx(fx)))
+ for (kind, t) in pairs(XLSX.Charts.C_KINDS)
+ c = bysheet[String(t.template)]
+ @test XLSX.ZipArchives.zip_readentry(raw, c.path, String) == XLSX.Charts.CHART_KIND_TEMPLATES[t.template]
+ @test haskey(XLSX.Charts.CHART_STYLE_TEMPLATES, t.style)
+ end
end
- end
- @testset "chart plumbing: a verbatim template on a new sheet" begin
- path = "chart_plumbing.xlsx"
- xf = XLSX.newxlsx("column")
- ws = xf["column"]
- for (r, row) in enumerate((("Region", "Alpha", "Beta", "Gamma"),
- ("North", 10, 12, 14), ("South", 20, 18, 22),
- ("East", 15, 25, 19), ("West", 5, 9, 11)))
- for (col, v) in enumerate(row)
- ws[r, col] = v
+
+ @testset "cx: chart kind templates match Excel" begin
+ fx = joinpath(data_directory, "chartex_kinds.xlsx")
+ raw = XLSX.ZipArchives.ZipReader(read(fx))
+ for c in XLSX.Charts.getCharts(XLSX.readxlsx(fx))
+ c isa XLSX.Charts.ChartEx || continue
+ k = XLSX.Charts.getChartType(c)
+ @test haskey(XLSX.Charts.CX_KINDS, k)
+ @test XLSX.ZipArchives.zip_readentry(raw, c.path, String) == XLSX.Charts.CHARTEX_KIND_TEMPLATES[k]
end
end
+ end
- p = XLSX.Charts._add_chart_part!(ws, XLSX.Charts.CHART_KIND_TEMPLATES[:column], 201;
- anchor=XLSX.CellRange("F2:M18"))
- @test p == "xl/charts/chart1.xml"
+ @testset "creating charts" begin
+ @testset "chart plumbing: a verbatim template on a new sheet" begin
+ path = "chart_plumbing.xlsx"
+ xf = XLSX.newxlsx("column")
+ ws = xf["column"]
+ for (r, row) in enumerate((("Region", "Alpha", "Beta", "Gamma"),
+ ("North", 10, 12, 14), ("South", 20, 18, 22),
+ ("East", 15, 25, 19), ("West", 5, 9, 11)))
+ for (col, v) in enumerate(row)
+ ws[r, col] = v
+ end
+ end
- c = only(XLSX.Charts.getCharts(xf))
- @test c isa XLSX.Charts.Chart
- @test c.sheet == "column"
- @test XLSX.Charts.getChartTypes(c) == [:barChart]
- @test [s.name for s in XLSX.Charts.getChartSeries(c)] == ["Alpha", "Beta", "Gamma"]
- @test XLSX.Charts.getChartData(c).data[2] == [10, 20, 15, 5]
+ p = XLSX.Charts._add_chart_part!(ws, XLSX.Charts.CHART_KIND_TEMPLATES[:column], 201;
+ anchor=XLSX.CellRange("F2:M18"))
+ @test p == "xl/charts/chart1.xml"
- XLSX.writexlsx(path, xf, overwrite=true)
- c2 = only(XLSX.Charts.getCharts(XLSX.readxlsx(path)))
- @test XLSX.Charts.getChartTypes(c2) == [:barChart]
- @test c2.from == "F2"
+ c = only(XLSX.Charts.getCharts(xf))
+ @test c isa XLSX.Charts.Chart
+ @test c.sheet == "column"
+ @test XLSX.Charts.getChartTypes(c) == [:barChart]
+ @test [s.name for s in XLSX.Charts.getChartSeries(c)] == ["Alpha", "Beta", "Gamma"]
+ @test XLSX.Charts.getChartData(c).data[2] == [10, 20, 15, 5]
- SAVE_FILES && save_outfile(xf)
- isfile(path) && rm(path)
- end
+ XLSX.writexlsx(path, xf, overwrite=true)
+ c2 = only(XLSX.Charts.getCharts(XLSX.readxlsx(path)))
+ @test XLSX.Charts.getChartTypes(c2) == [:barChart]
+ @test c2.from == "F2"
- @testset "addChart on a worksheet" begin
- path = "chart_add.xlsx"
- xf = XLSX.newxlsx("data")
- ws = xf["data"]
-
- c = XLSX.Charts.addChart(ws, :column; anchor="F2:M18", title="Revenue")
- @test c isa XLSX.Charts.Chart
- @test XLSX.Charts.getChartTypes(c) == [:barChart]
- @test isempty(XLSX.Charts.getChartSeries(c))
- @test XLSX.Charts.getChartTitle(c) == "Revenue"
-
- # Fresh axis ids, consistently wired.
- axes = XLSX.Charts.getChartAxes(c)
- @test length(axes) == 2
- @test only(XLSX.Charts.getChartGroups(c)).axids == [ax.axid for ax in axes]
- @test XLSX.Charts.getAxisPartner(c, axes[1]).axid == axes[2].axid
-
- # Every kind builds, and the title options work.
- for (i, kind) in enumerate(keys(XLSX.Charts.C_KINDS))
- k = XLSX.Charts.addChart(ws, kind; anchor="O$(20i):V$(20i + 15)", title=i == 1 ? false : nothing)
- @test isempty(XLSX.Charts.getChartSeries(k))
+ SAVE_FILES && save_outfile(xf)
+ isfile(path) && rm(path)
end
- @test length(XLSX.Charts.getCharts(ws)) == 1 + length(XLSX.Charts.C_KINDS)
- XLSX.writexlsx(path, xf, overwrite=true)
- @test length(XLSX.Charts.getCharts(XLSX.readxlsx(path))) == 1 + length(XLSX.Charts.C_KINDS)
+ @testset "addChart on a worksheet" begin
+ path = "chart_add.xlsx"
+ xf = XLSX.newxlsx("data")
+ ws = xf["data"]
+
+ c = XLSX.Charts.addChart(ws, :column; anchor="F2:M18", title="Revenue")
+ @test c isa XLSX.Charts.Chart
+ @test XLSX.Charts.getChartTypes(c) == [:barChart]
+ @test isempty(XLSX.Charts.getChartSeries(c))
+ @test XLSX.Charts.getChartTitle(c) == "Revenue"
+
+ # Fresh axis ids, consistently wired.
+ axes = XLSX.Charts.getChartAxes(c)
+ @test length(axes) == 2
+ @test only(XLSX.Charts.getChartGroups(c)).axids == [ax.axid for ax in axes]
+ @test XLSX.Charts.getAxisPartner(c, axes[1]).axid == axes[2].axid
+
+ # Every kind builds, and the title options work.
+ for (i, kind) in enumerate(keys(XLSX.Charts.C_KINDS))
+ k = XLSX.Charts.addChart(ws, kind; anchor="O$(20i):V$(20i + 15)", title=i == 1 ? false : nothing)
+ @test isempty(XLSX.Charts.getChartSeries(k))
+ end
+ @test length(XLSX.Charts.getCharts(ws)) == 1 + length(XLSX.Charts.C_KINDS)
- SAVE_FILES && save_outfile(xf)
- isfile(path) && rm(path)
- end
+ XLSX.writexlsx(path, xf, overwrite=true)
+ @test length(XLSX.Charts.getCharts(XLSX.readxlsx(path))) == 1 + length(XLSX.Charts.C_KINDS)
- @testset "addChart as a chartsheet" begin
- path = "chart_chartsheet_add.xlsx"
- xf = XLSX.newxlsx("data")
-
- c = XLSX.Charts.addChart(xf, :line; sheetname="Trend", title="Trend")
- @test c isa XLSX.Charts.Chart
- @test XLSX.sheetnames(xf) == ["data", "Trend"]
- @test XLSX.is_chartsheet(XLSX.get_workbook(xf), "Trend")
- @test c.sheet == "Trend"
- @test isnothing(c.from)
- @test XLSX.Charts.getChartTypes(c) == [:lineChart]
-
- d = XLSX.Charts.addChart(xf, :pie)
- @test d.sheet == "Chart1"
-
- # A chartsheet chart's series take their data from a worksheet.
- xf["data"]["B2:B5"] = [8, 7, 3, 9]
- xf["data"]["A2:A5"] = ["A", "B", "C", "D"]
- XLSX.Charts.addSeries(c, "data!B2:B5"; categories="data!A2:A5", name="y")
- s = only(XLSX.Charts.getChartSeries(c))
- @test s.name == "y"
- @test s.values.ref == "data!\$B\$2:\$B\$5"
- @test length(s.values.data) == 4
-
- # An unqualified reference has no sheet to mean.
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(c, "B2:B5")
-
- # A $-fixed reference is just as unqualified.
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(c, "\$B\$2:\$B\$5")
-
- # chartEx on a chartsheet: qualified references resolve through the workbook,
- # unqualified ones are refused before anything is written.
- @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(xf, :boxWhisker, "B2:B5"; sheetname="Bad")
- @test !("Bad" in XLSX.sheetnames(xf))
- xf["data"]["C2:C5"] = [5, 6, 2, 4]
- bw = XLSX.Charts.addChartEx(xf, :boxWhisker, "data!B2:B5"; sheetname="Box")
- XLSX.Charts.addSeries(bw, "data!C2:C5")
- @test XLSX.Charts.getChartSeriesCount(bw) == 2
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(bw, "C2:C5")
- @test XLSX.Charts.getChartSeriesCount(bw) == 2
-
- XLSX.writexlsx(path, xf, overwrite=true)
- g = XLSX.readxlsx(path)
- @test XLSX.sheetnames(g) == ["data", "Trend", "Chart1", "Box"]
- @test length(XLSX.Charts.getCharts(g)) == 3
-
- SAVE_FILES && save_outfile(xf)
- isfile(path) && rm(path)
- end
+ SAVE_FILES && save_outfile(xf)
+ isfile(path) && rm(path)
+ end
- @testset "a new sheet's content type names its own part" begin
- path = "chart_chartsheet_ct.xlsx"
- cp(joinpath(data_directory, "chart_chartsheet.xlsx"), path; force=true)
- xf = XLSX.openxlsx(path; mode="rw") # sheetIds 2 and 1, one worksheet file
- ws = XLSX.addsheet!(xf, "More")
- part = XLSX.get_worksheet_internal_file(ws)
- @test ws.sheetId == 3
- @test part == "xl/worksheets/sheet2.xml"
- @test XLSX.content_type_for_part(xf, part) == XLSX.MIME_WORKSHEET
-
- SAVE_FILES && save_outfile(xf)
- isfile(path) && rm(path)
- end
+ @testset "addChart as a chartsheet" begin
+ path = "chart_chartsheet_add.xlsx"
+ xf = XLSX.newxlsx("data")
+
+ c = XLSX.Charts.addChart(xf, :line; sheetname="Trend", title="Trend")
+ @test c isa XLSX.Charts.Chart
+ @test XLSX.sheetnames(xf) == ["data", "Trend"]
+ @test XLSX.is_chartsheet(XLSX.get_workbook(xf), "Trend")
+ @test c.sheet == "Trend"
+ @test isnothing(c.from)
+ @test XLSX.Charts.getChartTypes(c) == [:lineChart]
+
+ d = XLSX.Charts.addChart(xf, :pie)
+ @test d.sheet == "Chart1"
+
+ # A chartsheet chart's series take their data from a worksheet.
+ xf["data"]["B2:B5"] = [8, 7, 3, 9]
+ xf["data"]["A2:A5"] = ["A", "B", "C", "D"]
+ XLSX.Charts.addSeries(c, "data!B2:B5"; categories="data!A2:A5", name="y")
+ s = only(XLSX.Charts.getChartSeries(c))
+ @test s.name == "y"
+ @test s.values.ref == "data!\$B\$2:\$B\$5"
+ @test length(s.values.data) == 4
+
+ # An unqualified reference has no sheet to mean.
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(c, "B2:B5")
+
+ # A $-fixed reference is just as unqualified.
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(c, "\$B\$2:\$B\$5")
+
+ # chartEx on a chartsheet: qualified references resolve through the workbook,
+ # unqualified ones are refused before anything is written.
+ @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(xf, :boxWhisker, "B2:B5"; sheetname="Bad")
+ @test !("Bad" in XLSX.sheetnames(xf))
+ xf["data"]["C2:C5"] = [5, 6, 2, 4]
+ bw = XLSX.Charts.addChartEx(xf, :boxWhisker, "data!B2:B5"; sheetname="Box")
+ XLSX.Charts.addSeries(bw, "data!C2:C5")
+ @test XLSX.Charts.getChartSeriesCount(bw) == 2
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(bw, "C2:C5")
+ @test XLSX.Charts.getChartSeriesCount(bw) == 2
+
+ XLSX.writexlsx(path, xf, overwrite=true)
+ g = XLSX.readxlsx(path)
+ @test XLSX.sheetnames(g) == ["data", "Trend", "Chart1", "Box"]
+ @test length(XLSX.Charts.getCharts(g)) == 3
+
+ SAVE_FILES && save_outfile(xf)
+ isfile(path) && rm(path)
+ end
- @testset "addSeries" begin
- path = "chart_addseries.xlsx"
- xf = XLSX.newxlsx("data")
- ws = xf["data"]
- ws["A1"] = "Region"
- ws["B1"] = "Alpha"
- ws["C1"] = "Beta"
- for (i, r) in enumerate(("North", "South", "East", "West"))
- ws[i+1, 1] = r
- ws[i+1, 2] = 10i
- ws[i+1, 3] = 5i
+ @testset "a new sheet's content type names its own part" begin
+ path = "chart_chartsheet_ct.xlsx"
+ cp(joinpath(data_directory, "chart_chartsheet.xlsx"), path; force=true)
+ xf = XLSX.openxlsx(path; mode="rw") # sheetIds 2 and 1, one worksheet file
+ ws = XLSX.addsheet!(xf, "More")
+ part = XLSX.get_worksheet_internal_file(ws)
+ @test ws.sheetId == 3
+ @test part == "xl/worksheets/sheet2.xml"
+ @test XLSX.content_type_for_part(xf, part) == XLSX.MIME_WORKSHEET
+
+ SAVE_FILES && save_outfile(xf)
+ isfile(path) && rm(path)
end
- c = XLSX.Charts.addChart(ws, :column; anchor="F2:M18")
- XLSX.Charts.addSeries(c, "B2:B5"; categories="A2:A5", name_ref="B1")
- XLSX.Charts.addSeries(c, "C2:C5"; categories="A2:A5", name_ref="C1")
+ @testset "addSeries" begin
+ path = "chart_addseries.xlsx"
+ xf = XLSX.newxlsx("data")
+ ws = xf["data"]
+ ws["A1"] = "Region"
+ ws["B1"] = "Alpha"
+ ws["C1"] = "Beta"
+ for (i, r) in enumerate(("North", "South", "East", "West"))
+ ws[i+1, 1] = r
+ ws[i+1, 2] = 10i
+ ws[i+1, 3] = 5i
+ end
- s = XLSX.Charts.getChartSeries(c)
- @test [x.name for x in s] == ["Alpha", "Beta"]
- @test [x.idx for x in s] == [0, 1]
- @test s[1].values.data == [10, 20, 30, 40]
- @test s[1].categories.data == ["North", "South", "East", "West"]
- @test XLSX.Charts.getSeriesFill(c, 1).value.fgcolor.val == "accent1"
- @test XLSX.Charts.getSeriesFill(c, 2).value.fgcolor.val == "accent2"
- @test XLSX.Charts.getChartData(c).data[2] == [10, 20, 30, 40]
+ c = XLSX.Charts.addChart(ws, :column; anchor="F2:M18")
+ XLSX.Charts.addSeries(c, "B2:B5"; categories="A2:A5", name_ref="B1")
+ XLSX.Charts.addSeries(c, "C2:C5"; categories="A2:A5", name_ref="C1")
- XLSX.writexlsx(path, xf, overwrite=true)
- @test length(XLSX.Charts.getChartSeries(only(XLSX.Charts.getCharts(XLSX.readxlsx(path))))) == 2
+ s = XLSX.Charts.getChartSeries(c)
+ @test [x.name for x in s] == ["Alpha", "Beta"]
+ @test [x.idx for x in s] == [0, 1]
+ @test s[1].values.data == [10, 20, 30, 40]
+ @test s[1].categories.data == ["North", "South", "East", "West"]
+ @test XLSX.Charts.getSeriesFill(c, 1).value.fgcolor.val == "accent1"
+ @test XLSX.Charts.getSeriesFill(c, 2).value.fgcolor.val == "accent2"
+ @test XLSX.Charts.getChartData(c).data[2] == [10, 20, 30, 40]
+
+ XLSX.writexlsx(path, xf, overwrite=true)
+ @test length(XLSX.Charts.getChartSeries(only(XLSX.Charts.getCharts(XLSX.readxlsx(path))))) == 2
+
+ SAVE_FILES && save_outfile(xf)
+ isfile(path) && rm(path)
+ end
- SAVE_FILES && save_outfile(xf)
- isfile(path) && rm(path)
- end
- @testset "addSeries for every kind" begin
- path = "chart_addseries_kinds.xlsx"
- xf = XLSX.newxlsx("data")
- ws = xf["data"]
- ws["A1"] = "Region"
- ws["B1"] = "Alpha"
- ws["C1"] = "Beta"
- ws["D1"] = "Size"
- for (i, r) in enumerate(("North", "South", "East", "West"))
- ws[i+1, 1] = r
- ws[i+1, 2] = 10i
- ws[i+1, 3] = 5i
- ws[i+1, 4] = i
+ @testset "addSeries: name_ref to a number cell" begin
+ # ser/tx allows a strRef or literal text, never a numRef: Excel refuses such a file.
+ xf = XLSX.newxlsx("data")
+ ws = xf["data"]
+ ws["A1"] = 2026
+ ws["A2"] = 10
+ ws["A3"] = 20
+ c = XLSX.Charts.addChart(ws, :column; anchor="D2:K16")
+ XLSX.Charts.addSeries(c, "A2:A3"; name_ref="A1")
+ plot = XLSX.first_element_with_tag(XLSX.first_element_with_tag(XLSX.Charts.chart_root(c), "chart"), "plotArea")
+ ser = only(XLSX.elements_with_tag(XLSX.first_element_with_tag(plot, "barChart"), "ser"))
+ tx = XLSX.first_element_with_tag(ser, "tx")
+ @test [XLSX.localname(n) for n in XML.eachelement(tx)] == ["strRef"]
+ @test only(XLSX.Charts.getChartSeries(c)).name == "2026"
+ SAVE_FILES && save_outfile(xf)
end
- row = 1
- for kind in keys(XLSX.Charts.C_KINDS)
- c = XLSX.Charts.addChart(ws, kind; anchor="F$row:M$(row + 15)", title=string(kind))
- row += 16
- if kind === :bubble
- XLSX.Charts.addSeries(c, "C2:C5"; categories="B2:B5", bubble_sizes="D2:D5", name="Bubbles")
- else
- XLSX.Charts.addSeries(c, "B2:B5"; categories="A2:A5", name_ref="B1")
- kind in (:pie, :doughnut) ||
- XLSX.Charts.addSeries(c, "C2:C5"; categories="A2:A5", name_ref="C1")
+ @testset "addSeries for every kind" begin
+ path = "chart_addseries_kinds.xlsx"
+ xf = XLSX.newxlsx("data")
+ ws = xf["data"]
+ ws["A1"] = "Region"
+ ws["B1"] = "Alpha"
+ ws["C1"] = "Beta"
+ ws["D1"] = "Size"
+ for (i, r) in enumerate(("North", "South", "East", "West"))
+ ws[i+1, 1] = r
+ ws[i+1, 2] = 10i
+ ws[i+1, 3] = 5i
+ ws[i+1, 4] = i
end
- s = XLSX.Charts.getChartSeries(c)
- @test s[1].values.data == [10, 20, 30, 40] || kind === :bubble
- @test !isnothing(s[1].categories)
- @test length(s) == (kind in (:pie, :doughnut, :bubble) ? 1 : 2)
- end
- # Per-point colours on a pie, one per category.
- pie = XLSX.Charts.getCharts(ws)[findfirst(x -> XLSX.Charts.getChartType(x) === :pieChart, XLSX.Charts.getCharts(ws))]
- @test length(XLSX.Charts.getSeriesDataPoints(pie, 1)) == 4
+ row = 1
+ for kind in keys(XLSX.Charts.C_KINDS)
+ c = XLSX.Charts.addChart(ws, kind; anchor="F$row:M$(row + 15)", title=string(kind))
+ row += 16
+ if kind === :bubble
+ XLSX.Charts.addSeries(c, "C2:C5"; categories="B2:B5", bubble_sizes="D2:D5", name="Bubbles")
+ else
+ XLSX.Charts.addSeries(c, "B2:B5"; categories="A2:A5", name_ref="B1")
+ kind in (:pie, :doughnut) ||
+ XLSX.Charts.addSeries(c, "C2:C5"; categories="A2:A5", name_ref="C1")
+ end
+ s = XLSX.Charts.getChartSeries(c)
+ @test s[1].values.data == [10, 20, 30, 40] || kind === :bubble
+ @test !isnothing(s[1].categories)
+ @test length(s) == (kind in (:pie, :doughnut, :bubble) ? 1 : 2)
+ end
- # Rejected options.
- col = XLSX.Charts.getCharts(ws)[1]
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(col, "B2:B5"; smooth=true)
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(col, "B2:B5"; bubble_sizes="D2:D5")
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(col, "B2:B5"; name="x", name_ref="B1")
+ # Per-point colours on a pie, one per category.
+ pie = XLSX.Charts.getCharts(ws)[findfirst(x -> XLSX.Charts.getChartType(x) === :pieChart, XLSX.Charts.getCharts(ws))]
+ @test length(XLSX.Charts.getSeriesDataPoints(pie, 1)) == 4
- XLSX.writexlsx(path, xf, overwrite=true)
- @test length(XLSX.Charts.getCharts(XLSX.readxlsx(path))) == length(XLSX.Charts.C_KINDS)
+ # Rejected options.
+ col = XLSX.Charts.getCharts(ws)[1]
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(col, "B2:B5"; smooth=true)
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(col, "B2:B5"; bubble_sizes="D2:D5")
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(col, "B2:B5"; name="x", name_ref="B1")
- SAVE_FILES && save_outfile(xf)
- isfile(path) && rm(path)
- end
+ XLSX.writexlsx(path, xf, overwrite=true)
+ @test length(XLSX.Charts.getCharts(XLSX.readxlsx(path))) == length(XLSX.Charts.C_KINDS)
- @testset "addSeries options" begin
- path = "chart_addseries_options.xlsx"
- xf = XLSX.newxlsx("data")
- ws = xf["data"]
- ws["A1"] = "Region"
- ws["B1"] = "Alpha"
- ws["C1"] = "Beta"
- for (i, r) in enumerate(("North", "South", "East", "West"))
- ws[i+1, 1] = r
- ws[i+1, 2] = 10i
- ws[i+1, 3] = 5i
+ SAVE_FILES && save_outfile(xf)
+ isfile(path) && rm(path)
end
- smooth_of(c, i) = XLSX.Charts._bool_val(last(XLSX.Charts._series_nodes(XLSX.Charts.chart_root(c))[i]), "smooth")
-
- # Line: markers bring in the lineMarkers pattern; the default stays plain.
- c = XLSX.Charts.addChart(ws, :line; anchor="F1:M16")
- XLSX.Charts.addSeries(c, "B2:B5"; categories="A2:A5", markers=:diamond, smooth=true)
- XLSX.Charts.addSeries(c, "C2:C5"; categories="A2:A5")
- @test XLSX.Charts.getSeriesMarker(c, 1).symbol === :diamond
- @test !isnothing(XLSX.Charts.getSeriesMarker(c, 1).shape)
- @test smooth_of(c, 1) === true
- @test XLSX.Charts.getSeriesMarker(c, 2).symbol === :none
- @test smooth_of(c, 2) === false
-
- # Scatter: a line, and no markers only when there is a line.
- s = XLSX.Charts.addChart(ws, :scatter; anchor="F18:M33")
- XLSX.Charts.addSeries(s, "C2:C5"; categories="B2:B5", line=true)
- @test XLSX.Charts.getSeriesLine(s, 1).value.fill.kind === :solid
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(s, "C2:C5"; categories="B2:B5", markers=false)
- XLSX.Charts.addSeries(s, "C2:C5"; categories="B2:B5", markers=false, line=true)
- @test XLSX.Charts.getSeriesMarker(s, 2).symbol === :none
-
- # Radar markers.
- r = XLSX.Charts.addChart(ws, :radar; anchor="F35:M50")
- XLSX.Charts.addSeries(r, "B2:B5"; categories="A2:A5", markers=:square)
- @test XLSX.Charts.getSeriesMarker(r, 1).symbol === :square
-
- # Colour, in each accepted form.
- col = XLSX.Charts.addChart(ws, :column; anchor="O1:V16")
- XLSX.Charts.addSeries(col, "B2:B5"; categories="A2:A5", color="FFFF0000")
- XLSX.Charts.addSeries(col, "C2:C5"; categories="A2:A5", color="accent6")
- @test XLSX.Charts.getSeriesFill(col, 1).value.fgcolor.rgb == "FF0000"
-
- # Rejected.
- pie = XLSX.Charts.addChart(ws, :pie; anchor="O18:V33")
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(pie, "B2:B5"; color="FF0000")
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(c, "B2:B5"; markers=:nonsense)
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(c, "B2:B5"; smooth="yes")
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(col, "B2:B5"; color="notacolor")
-
- XLSX.writexlsx(path, xf, overwrite=true)
- SAVE_FILES && save_outfile(xf)
- isfile(path) && rm(path)
- end
- @testset "addChartEx" begin
- path = "chart_addchartex.xlsx"
- xf = XLSX.newxlsx("data")
- ws = xf["data"]
- ws["A1"] = "Region"
- ws["B1"] = "Item"
- ws["C1"] = "Amount"
- ws["D1"] = "Beta"
- for (i, row) in enumerate((("Europe", "France", 30, 12), ("Europe", "Spain", 20, 15),
- ("Asia", "Japan", 50, 17), ("Asia", "India", 35, 19),
- ("Americas", "USA", 60, 21)))
- for (j, v) in enumerate(row); ws[i+1, j] = v; end
+ @testset "addSeries options" begin
+ path = "chart_addseries_options.xlsx"
+ xf = XLSX.newxlsx("data")
+ ws = xf["data"]
+ ws["A1"] = "Region"
+ ws["B1"] = "Alpha"
+ ws["C1"] = "Beta"
+ for (i, r) in enumerate(("North", "South", "East", "West"))
+ ws[i+1, 1] = r
+ ws[i+1, 2] = 10i
+ ws[i+1, 3] = 5i
+ end
+ smooth_of(c, i) = XLSX.Charts._bool_val(last(XLSX.Charts._series_nodes(XLSX.Charts.chart_root(c))[i]), "smooth")
+
+ # Line: markers bring in the lineMarkers pattern; the default stays plain.
+ c = XLSX.Charts.addChart(ws, :line; anchor="F1:M16")
+ XLSX.Charts.addSeries(c, "B2:B5"; categories="A2:A5", markers=:diamond, smooth=true)
+ XLSX.Charts.addSeries(c, "C2:C5"; categories="A2:A5")
+ @test XLSX.Charts.getSeriesMarker(c, 1).symbol === :diamond
+ @test !isnothing(XLSX.Charts.getSeriesMarker(c, 1).shape)
+ @test smooth_of(c, 1) === true
+ @test XLSX.Charts.getSeriesMarker(c, 2).symbol === :none
+ @test smooth_of(c, 2) === false
+
+ # Scatter: a line, and no markers only when there is a line.
+ s = XLSX.Charts.addChart(ws, :scatter; anchor="F18:M33")
+ XLSX.Charts.addSeries(s, "C2:C5"; categories="B2:B5", line=true)
+ @test XLSX.Charts.getSeriesLine(s, 1).value.fill.kind === :solid
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(s, "C2:C5"; categories="B2:B5", markers=false)
+ XLSX.Charts.addSeries(s, "C2:C5"; categories="B2:B5", markers=false, line=true)
+ @test XLSX.Charts.getSeriesMarker(s, 2).symbol === :none
+
+ # Radar markers.
+ r = XLSX.Charts.addChart(ws, :radar; anchor="F35:M50")
+ XLSX.Charts.addSeries(r, "B2:B5"; categories="A2:A5", markers=:square)
+ @test XLSX.Charts.getSeriesMarker(r, 1).symbol === :square
+
+ # Colour, in each accepted form.
+ col = XLSX.Charts.addChart(ws, :column; anchor="O1:V16")
+ XLSX.Charts.addSeries(col, "B2:B5"; categories="A2:A5", color="FFFF0000")
+ XLSX.Charts.addSeries(col, "C2:C5"; categories="A2:A5", color="accent6")
+ @test XLSX.Charts.getSeriesFill(col, 1).value.fgcolor.rgb == "FF0000"
+
+ # Rejected.
+ pie = XLSX.Charts.addChart(ws, :pie; anchor="O18:V33")
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(pie, "B2:B5"; color="FF0000")
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(c, "B2:B5"; markers=:nonsense)
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(c, "B2:B5"; smooth="yes")
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(col, "B2:B5"; color="notacolor")
+
+ XLSX.writexlsx(path, xf, overwrite=true)
+ SAVE_FILES && save_outfile(xf)
+ isfile(path) && rm(path)
end
- row = 1
- for kind in keys(XLSX.Charts.CX_KINDS)
- cats = kind in (:treemap, :sunburst) ? "A2:B6" :
- kind === :histogram ? nothing :
- kind === :boxWhisker ? "A2:A6" : "B2:B6"
- c = XLSX.Charts.addChartEx(ws, kind, "C2:C6"; anchor="F$row:M$(row + 15)",
- categories=cats, name_ref="C1")
- row += 16
- @test c isa XLSX.Charts.ChartEx
- @test XLSX.Charts.getChartType(c) === kind
- @test XLSX.Charts.getChartSeriesCount(c) == 1
- kind === :pareto && @test XLSX.Charts.getSeriesLayout(c, 1; pareto=true) === :paretoLine
- @test XLSX.Charts.getSeriesName(c, 1) == "Amount"
- end
+ @testset "addChartEx" begin
+ path = "chart_addchartex.xlsx"
+ xf = XLSX.newxlsx("data")
+ ws = xf["data"]
+ ws["A1"] = "Region"
+ ws["B1"] = "Item"
+ ws["C1"] = "Amount"
+ ws["D1"] = "Beta"
+ for (i, row) in enumerate((("Europe", "France", 30, 12), ("Europe", "Spain", 20, 15),
+ ("Asia", "Japan", 50, 17), ("Asia", "India", 35, 19),
+ ("Americas", "USA", 60, 21)))
+ for (j, v) in enumerate(row); ws[i+1, j] = v; end
+ end
- charts = XLSX.Charts.getCharts(ws)
- bw = charts[findfirst(c -> XLSX.Charts.getChartType(c) === :boxWhisker, charts)]
- XLSX.Charts.addSeries(bw, "D2:D6"; name_ref="D1")
- @test XLSX.Charts.getChartSeriesCount(bw) == 2
+ row = 1
+ for kind in keys(XLSX.Charts.CX_KINDS)
+ cats = kind in (:treemap, :sunburst) ? "A2:B6" :
+ kind === :histogram ? nothing :
+ kind === :boxWhisker ? "A2:A6" : "B2:B6"
+ c = XLSX.Charts.addChartEx(ws, kind, "C2:C6"; anchor="F$row:M$(row + 15)",
+ categories=cats, name_ref="C1")
+ row += 16
+ @test c isa XLSX.Charts.ChartEx
+ @test XLSX.Charts.getChartType(c) === kind
+ @test XLSX.Charts.getChartSeriesCount(c) == 1
+ kind === :pareto && @test XLSX.Charts.getSeriesLayout(c, 1; pareto=true) === :paretoLine
+ @test XLSX.Charts.getSeriesName(c, 1) == "Amount"
+ end
- wf = charts[findfirst(c -> XLSX.Charts.getChartType(c) === :waterfall, charts)]
- @test_throws XLSX.XLSXError XLSX.Charts.addSeries(wf, "D2:D6")
- @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(ws, :histogram, "C2:C6"; anchor="O1:V16", categories="B2:B6")
- @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(ws, :treemap, "C2:C6"; anchor="O1:V16")
+ charts = XLSX.Charts.getCharts(ws)
+ bw = charts[findfirst(c -> XLSX.Charts.getChartType(c) === :boxWhisker, charts)]
+ XLSX.Charts.addSeries(bw, "D2:D6"; name_ref="D1")
+ @test XLSX.Charts.getChartSeriesCount(bw) == 2
- XLSX.writexlsx(path, xf, overwrite=true)
- @test length(XLSX.Charts.getCharts(XLSX.readxlsx(path))) == length(XLSX.Charts.CX_KINDS)
+ wf = charts[findfirst(c -> XLSX.Charts.getChartType(c) === :waterfall, charts)]
+ @test_throws XLSX.XLSXError XLSX.Charts.addSeries(wf, "D2:D6")
+ @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(ws, :histogram, "C2:C6"; anchor="O1:V16", categories="B2:B6")
+ @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(ws, :treemap, "C2:C6"; anchor="O1:V16")
- SAVE_FILES && save_outfile(xf)
- isfile(path) && rm(path)
- end
- @testset "Pareto line is reached with pareto = true" begin
- kinds = XLSX.getCharts(XLSX.readxlsx(joinpath(data_directory, "chartex_kinds.xlsx")))
- p = kinds[6] # Pareto
- w = kinds[1] # waterfall: no Pareto line
-
- # One series, as in Excel's Select Data; the line belongs to it
- @test XLSX.Charts.getChartType(p) === :pareto
- @test XLSX.Charts.getChartSeriesCount(p) == 1
- @test XLSX.Charts.getSeriesLayout(p, 1) === :clusteredColumn
- @test XLSX.Charts.getSeriesLayout(p, 1; pareto=true) === :paretoLine
- @test_throws XLSX.XLSXError XLSX.Charts.getSeriesLayout(p, 2)
- # the line is drawn against the secondary (percentage) axis
- @test XLSX.Charts.getSeriesAxisIds(p, 1; pareto=true) != XLSX.Charts.getSeriesAxisIds(p, 1)
-
- # pareto = true on a chart with no Pareto line is an error, not the series itself
- @test_throws XLSX.XLSXError XLSX.Charts.getSeriesLayout(w, 1; pareto=true)
- @test_throws XLSX.XLSXError XLSX.Charts.getSeriesLine(w, 1; pareto=true)
-
- # A setter with pareto = true formats the line and leaves the columns alone
- xf = XLSX.openxlsx(joinpath(data_directory, "chartex_kinds.xlsx"); mode="rw")
- c = XLSX.getCharts(xf)[6]
- cols_before = XLSX.Charts.getSeriesLine(c, 1)
- line_before = XLSX.Charts.getSeriesLine(c, 1; pareto=true)
- XLSX.Charts.setSeriesLineColor(c, 1, "FF0000"; pareto=true)
- XLSX.Charts.setSeriesLineWidth(c, 1, 2.5; pareto=true)
- line_after = XLSX.Charts.getSeriesLine(c, 1; pareto=true)
- @test line_after != line_before
- line_after = XLSX.Charts.getSeriesLine(c, 1; pareto=true)
- @test line_after.value != line_before.value
- @test XLSX.Charts.getSeriesLine(c, 1).value == cols_before.value
- @test XLSX.Charts.getChartSeriesCount(c) == 1 # formatting adds no series
-
- f = "pareto_line.xlsx"
- XLSX.writexlsx(f, xf; overwrite=true)
- SAVE_FILES && save_outfile(xf)
- yf = XLSX.openxlsx(f; mode="rw")
- d = XLSX.getCharts(yf)[6]
- @test XLSX.Charts.getSeriesLine(d, 1; pareto=true).value == line_after.value
- @test XLSX.Charts.getSeriesLine(d, 1).value == cols_before.value
- @test XLSX.Charts.getChartSeriesCount(d) == 1
- SAVE_FILES && save_outfile(yf)
- isfile(f) && rm(f)
- end
- @testset "a failed addChartEx leaves no chartsheet behind" begin
- xf = XLSX.newxlsx("data")
- @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(xf, :waterfall, "B2:B6") # unqualified
- @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(xf, :waterfall, "Nowhere!B2:B6") # no such sheet
- @test XLSX.sheetnames(xf) == ["data"]
- @test isempty(XLSX.Charts.getCharts(xf))
- SAVE_FILES && save_outfile(xf)
- end
+ XLSX.writexlsx(path, xf, overwrite=true)
+ @test length(XLSX.Charts.getCharts(XLSX.readxlsx(path))) == length(XLSX.Charts.CX_KINDS)
- @testset "chart values compare by content" begin
- f = XLSX.readxlsx(joinpath(data_directory, "chart_gaps.xlsx")) # has error cells
- c = XLSX.Charts.getCharts(f)[1]
- a, b = XLSX.Charts.getChartSeries(c), XLSX.Charts.getChartSeries(c)
- @test a == b # missing in the cache
- @test hash(a) == hash(b)
- @test length(Set(vcat(a, b))) == length(a)
-
- fa = XLSX.readxlsx(joinpath(data_directory, "chart_appearance.xlsx"))
- ca = only(filter(x -> x isa XLSX.Charts.Chart, XLSX.Charts.getCharts(fa)))
- @test XLSX.Charts.getChartAxes(ca) == XLSX.Charts.getChartAxes(ca)
- @test XLSX.Charts.getSeriesShapeProps(ca, 1) == XLSX.Charts.getSeriesShapeProps(ca, 1)
- @test XLSX.Charts.getSeriesMarker(ca, 1) == XLSX.Charts.getSeriesMarker(ca, 1)
- end
+ SAVE_FILES && save_outfile(xf)
+ isfile(path) && rm(path)
+ end
- @testset "chart handles compare and hash by part" begin
- f = XLSX.readxlsx(joinpath(data_directory, "chart_basic.xlsx"))
- a = XLSX.getChart(f["Data"], "chart1")
- b = only(XLSX.getCharts(f))
- @test a == b
- @test hash(a) == hash(b)
- @test length(Set([a, b])) == 1
-
- g = XLSX.readxlsx(joinpath(data_directory, "chartex_kinds.xlsx"))
- x, y = XLSX.getCharts(g)[1], XLSX.getCharts(g)[1]
- @test x == y
- @test hash(x) == hash(y)
- @test x != XLSX.getCharts(g)[2]
- # the same part in a different file is a different chart
- @test x != XLSX.getCharts(XLSX.readxlsx(joinpath(data_directory, "chartex_kinds.xlsx")))[1]
- end
+ @testset "a failed addChartEx leaves no chartsheet behind" begin
+ xf = XLSX.newxlsx("data")
+ @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(xf, :waterfall, "B2:B6") # unqualified
+ @test_throws XLSX.XLSXError XLSX.Charts.addChartEx(xf, :waterfall, "Nowhere!B2:B6") # no such sheet
+ @test XLSX.sheetnames(xf) == ["data"]
+ @test isempty(XLSX.Charts.getCharts(xf))
+ SAVE_FILES && save_outfile(xf)
+ end
- @testset "ChartEx show" begin
- x = XLSX.getCharts(XLSX.readxlsx(joinpath(data_directory, "chartex_kinds.xlsx")))[1]
- s = sprint(show, MIME"text/plain"(), x)
- @test startswith(s, "XLSX.Charts.ChartEx \"chartEx1\"")
- @test occursin("type: waterfall", s)
- @test occursin("series: 1", s)
- @test occursin("refs: ", s)
- @test !isempty(sprint(show, x))
end
- @testset "a value read before a write differs from one read after" begin
- path = "chart_equality_write.xlsx"
- cp(joinpath(data_directory, "chart_basic.xlsx"), path; force=true)
- xf = XLSX.openxlsx(path; mode="rw")
- c = XLSX.Charts.getChart(xf, "chart1")
- before = XLSX.Charts.getSeriesShapeProps(c, 1)
- XLSX.Charts.setSeriesFill(c, 1, "red")
- @test XLSX.Charts.getSeriesShapeProps(c, 1) != before
- isfile(path) && rm(path)
- SAVE_FILES && save_outfile(xf)
- end
+ @testset "chart values, handles and show" begin
+ @testset "chart values compare by content" begin
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_gaps.xlsx")) # has error cells
+ c = XLSX.Charts.getCharts(f)[1]
+ a, b = XLSX.Charts.getChartSeries(c), XLSX.Charts.getChartSeries(c)
+ @test a == b # missing in the cache
+ @test hash(a) == hash(b)
+ @test length(Set(vcat(a, b))) == length(a)
+
+ fa = XLSX.readxlsx(joinpath(data_directory, "chart_appearance.xlsx"))
+ ca = only(filter(x -> x isa XLSX.Charts.Chart, XLSX.Charts.getCharts(fa)))
+ @test XLSX.Charts.getChartAxes(ca) == XLSX.Charts.getChartAxes(ca)
+ @test XLSX.Charts.getSeriesShapeProps(ca, 1) == XLSX.Charts.getSeriesShapeProps(ca, 1)
+ @test XLSX.Charts.getSeriesMarker(ca, 1) == XLSX.Charts.getSeriesMarker(ca, 1)
+ end
- @testset "chart_extras.xlsx: elements with no other fixture" begin
- xf = XLSX.readxlsx(joinpath(data_directory, "chart_extras.xlsx"))
- chart(sheet) = only(XLSX.Charts.getCharts(xf[sheet]))
-
- # Drop lines, on a line chart's group.
- c = chart("droplines")
- g = only(XLSX.Charts.getChartGroups(c))
- dl = XLSX.Charts.getGroupDropLines(c, g)
- @test !isnothing(dl)
- @test dl.line.width ≈ 0.75 # 9525 EMU
- @test isnothing(XLSX.Charts.getGroupHiLowLines(c, g))
-
- # High-low lines and up/down bars together.
- c = chart("hilolines")
- g = only(XLSX.Charts.getChartGroups(c))
- @test XLSX.Charts.getGroupHiLowLines(c, g).line.width ≈ 0.75
- @test isnothing(XLSX.Charts.getGroupDropLines(c, g))
- b = XLSX.Charts.getGroupUpDownBars(c, g)
- @test b.gap_width == 150
- @test XLSX.Charts.hasFill(XLSX.Charts.getUpBarShapeProps(c, b))
- @test XLSX.Charts.hasFill(XLSX.Charts.getDownBarShapeProps(c, b))
-
- # Polynomial and moving-average trendlines, one per series.
- c = chart("trends")
- tp = only(XLSX.Charts.getSeriesTrendlines(c, 1))
- @test tp.kind === :poly
- @test tp.order == 2
- @test isnothing(tp.period)
- @test tp.disp_rsqr === false
- tm = only(XLSX.Charts.getSeriesTrendlines(c, 2))
- @test tm.kind === :movingAvg
- @test tm.period == 2
- @test isnothing(tm.order)
- ln = XLSX.Charts.getTrendlineShapeProps(c, tp).line
- @test ln.width ≈ 1.5
- @test ln.dash == "sysDot"
-
- # Custom error bars with cell references for both directions.
- c = chart("errbars")
- e = only(XLSX.Charts.getSeriesErrorBars(c, 1))
- @test e.bar_type === :both
- @test e.value_type === :cust
- @test isnothing(e.direction) # no c:errDir written
- @test e.no_end_cap === false
- refs = XLSX.Charts.getErrorBarsCustomRefs(c, e)
- @test refs.plus.ref == "errbars!\$C\$2:\$C\$5"
- @test refs.minus.ref == "errbars!\$D\$2:\$D\$5"
- @test refs.plus.data == [12, 18, 25, 9]
-
- # A title bound to a cell: a reference and its cached string, no rich text.
- c = chart("boundtitle")
- @test XLSX.Charts.getChartTitleRef(c) == "boundtitle!\$F\$1"
- @test XLSX.Charts.getChartTitle(c) == "My Bound Title"
- @test isnothing(XLSX.Charts.getChartTitleText(c))
-
- # The a:ln join group.
- c = chart("join")
- l = XLSX.Charts.getSeriesLine(c, 1).value
- @test l.width ≈ 3.0
- @test l.join == "bevel"
- end
+ @testset "chart handles compare and hash by part" begin
+ f = XLSX.readxlsx(joinpath(data_directory, "chart_basic.xlsx"))
+ a = XLSX.getChart(f["Data"], "chart1")
+ b = only(XLSX.getCharts(f))
+ @test a == b
+ @test hash(a) == hash(b)
+ @test length(Set([a, b])) == 1
+
+ g = XLSX.readxlsx(joinpath(data_directory, "chartex_kinds.xlsx"))
+ x, y = XLSX.getCharts(g)[1], XLSX.getCharts(g)[1]
+ @test x == y
+ @test hash(x) == hash(y)
+ @test x != XLSX.getCharts(g)[2]
+ # the same part in a different file is a different chart
+ @test x != XLSX.getCharts(XLSX.readxlsx(joinpath(data_directory, "chartex_kinds.xlsx")))[1]
+ end
- @testset "gridlines written without c:spPr are still gridlines" begin
- path = "chart_gridlines_bare.xlsx"
- cp(joinpath(data_directory, "chart_basic.xlsx"), path; force=true)
- xf = XLSX.openxlsx(path; mode="rw")
- c = XLSX.Charts.getChart(xf, "chart1")
- ax = only(XLSX.Charts.getChartAxes(c, :value))
- root = XLSX.Charts.chart_root(c)
- axn = XLSX.Charts._axis_node(c, root, ax)
- new = XLSX.Charts.rebuild_path(root,
- [(XLSX.Charts.NS_C, "chart") => "chart",
- (XLSX.Charts.NS_C, "plotArea") => "plotArea",
- (XLSX.Charts.NS_C, "valAx") => ("valAx", n -> n === axn),
- (XLSX.Charts.NS_C, "majorGridlines") => "majorGridlines"],
- gl -> XLSX.Charts.remove_child(gl, "spPr");
- prefixes=XLSX.ns_prefixes(root))
- XLSX.Charts.set_chart_root!(c, new)
-
- gl = XLSX.Charts.getAxisGridlines(c, ax)
- @test !isnothing(gl) # still there
- @test isnothing(gl.fill) && isnothing(gl.line) # but unformatted
- isfile(path) && rm(path)
- SAVE_FILES && save_outfile(xf)
- end
+ @testset "a value read before a write differs from one read after" begin
+ path = "chart_equality_write.xlsx"
+ cp(joinpath(data_directory, "chart_basic.xlsx"), path; force=true)
+ xf = XLSX.openxlsx(path; mode="rw")
+ c = XLSX.Charts.getChart(xf, "chart1")
+ before = XLSX.Charts.getSeriesShapeProps(c, 1)
+ XLSX.Charts.setSeriesFill(c, 1, "red")
+ @test XLSX.Charts.getSeriesShapeProps(c, 1) != before
+ isfile(path) && rm(path)
+ SAVE_FILES && save_outfile(xf)
+ end
- @testset "XLSX-level chart names" begin
- for n in (:AbstractChart, :Chart, :ChartEx, :ChartRef, :ChartSeries,
- :getChartSchema, :getChartType, :getCharts, :getChart, :getChartData, :getChartRanges)
- @test getglobal(XLSX, n) === getglobal(XLSX.Charts, n)
- VERSION >= v"1.11" && @test Base.ispublic(XLSX, n)
+ @testset "ChartEx show" begin
+ x = XLSX.getCharts(XLSX.readxlsx(joinpath(data_directory, "chartex_kinds.xlsx")))[1]
+ s = sprint(show, MIME"text/plain"(), x)
+ @test startswith(s, "XLSX.Charts.ChartEx \"chartEx1\"")
+ @test occursin("type: waterfall", s)
+ @test occursin("series: 1", s)
+ @test occursin("refs: ", s)
+ @test !isempty(sprint(show, x))
end
end
@@ -1127,41 +1059,4 @@
@test (@test_deprecated XLSX.chartType(c)) === XLSX.Charts.getChartType(c)
@test (@test_deprecated XLSX.chartSchema(c)) === XLSX.Charts.getChartSchema(c)
end
-
- @testset "Non-sequential series c:idx" begin
- # chart_idx_gaps is chart_basic with the two series' `c:idx` changed to 3 and 7,
- # as Excel leaves them after series have been deleted. A series *position* counts
- # from 1 in document order; a value's `series_idx` is the `c:idx`. The two differ
- # here, so anything that confuses them fails.
- xf = XLSX.opentemplate(joinpath(data_directory, "chart_idx_gaps.xlsx"))
- c = XLSX.Charts.getCharts(xf)[1]
-
- ss = XLSX.Charts.getChartSeries(c)
- @test length(ss) == 2
- @test [s.idx for s in ss] == [3, 7]
- @test [s.order for s in ss] == [0, 1]
- @test [s.name for s in ss] == ["2024", "2025"] # document order, not idx order
- @test length(XLSX.Charts.getChartRanges(c)) == 2
-
- # A setter taking a position writes to that series, not to the one whose idx matches.
- was = XLSX.Charts.getSeriesFill(c, 1).value
- XLSX.Charts.setSeriesFill(c, 2, "FF0000")
- @test XLSX.Charts.getSeriesFill(c, 2).value.fgcolor.rgb == "FF0000"
- @test XLSX.Charts.getSeriesFill(c, 1).value == was # series 1 untouched
-
- # A value carries the owning series' `c:idx`, and is resolved by it.
- XLSX.Charts.setLabelDeleted(c, 2, 1, true) # creates a c:dLbl
- d = only(XLSX.Charts.getSeriesDataLabels(c, 2))
- @test d.series_idx == 7 # the key, not the position
- @test d.idx == 0 # c:idx of the label, 0-based
- @test d.delete === true
-
- # The value still addresses the same label after an unrelated write.
- XLSX.Charts.setSeriesLineColor(c, 2, "0000FF")
- @test XLSX.Charts.getSeriesDataLabel(c, 2, 1).idx == d.idx
- @test XLSX.Charts.getSeriesDataLabel(c, 2, 1).series_idx == 7
-
- SAVE_FILES && save_outfile(xf)
- end
-
end
diff --git a/test/test_files/Colors-tests.jl b/test/test_files/Colors_tests.jl
similarity index 69%
rename from test/test_files/Colors-tests.jl
rename to test/test_files/Colors_tests.jl
index 00865e28..242d696c 100644
--- a/test/test_files/Colors-tests.jl
+++ b/test/test_files/Colors_tests.jl
@@ -13,8 +13,62 @@
@test XLSX.get_color(:red) == "FFFF0000"
@test XLSX.get_color("grey") == "FF808080"
@test_throws XLSX.XLSXError XLSX.get_color("notacolour")
+ @test XLSX.get_color(colorant"red") == "FFFF0000"
+ @test XLSX.get_color(RGB(0.2, 0.4, 0.6)) == "FF336699"
+ @test XLSX.get_color(RGBA(1, 0, 0, 0.5)) == "80FF0000"
end
-
+
+ @testset "SchemeColor is shared with Charts" begin
+ @test XLSX.SchemeColor === XLSX.Charts.SchemeColor
+ end
+
+ @testset "_color_atts" begin
+ # RGB forms
+ @test XLSX._color_atts("red") == ["rgb" => "FFFF0000"]
+ @test XLSX._color_atts(:red) == ["rgb" => "FFFF0000"]
+ @test XLSX._color_atts("#336699") == ["rgb" => "FF336699"]
+ @test XLSX._color_atts(colorant"seagreen") == ["rgb" => "FF2E8B57"]
+ @test XLSX._color_atts(RGBA(1, 0, 0, 0.5)) == ["rgb" => "80FF0000"] # written; Excel ignores alpha
+
+ # Every token maps to its spreadsheet theme index (dk/lt pairs swapped)
+ for (tokens, idx) in (((:bg1, :lt1), 0), ((:tx1, :dk1), 1), ((:bg2, :lt2), 2), ((:tx2, :dk2), 3),
+ ((:accent1,), 4), ((:accent2,), 5), ((:accent3,), 6), ((:accent4,), 7),
+ ((:accent5,), 8), ((:accent6,), 9), ((:hlink,), 10), ((:folHlink,), 11))
+ for t in tokens
+ @test XLSX._color_atts(XLSX.SchemeColor(t)) == ["theme" => string(idx)]
+ @test XLSX._color_atts(t) == ["theme" => string(idx)]
+ @test XLSX._color_atts(String(t)) == ["theme" => string(idx)]
+ end
+ end
+
+ # Darker and lighter shades become a tint
+ @test XLSX._color_atts(XLSX.SchemeColor(:accent1; lumMod = 75)) == ["theme" => "4", "tint" => "-0.25"]
+ @test XLSX._color_atts(XLSX.SchemeColor(:accent1; lumMod = 60, lumOff = 40)) == ["theme" => "4", "tint" => "0.4"]
+ @test XLSX._color_atts(XLSX.SchemeColor(:tx1; lumMod = 65, lumOff = 35)) == ["theme" => "1", "tint" => "0.35"]
+ @test XLSX._color_atts(XLSX.SchemeColor(:accent1; lumMod = 100)) == ["theme" => "4"]
+ @test XLSX._color_atts(XLSX.SchemeColor(:accent1; lumMod = 0)) == ["theme" => "4", "tint" => "-1.0"]
+ @test XLSX._color_atts(XLSX.SchemeColor(:accent1; lumMod = 0, lumOff = 100)) == ["theme" => "4", "tint" => "1.0"]
+
+ # Alpha is ignored
+ @test XLSX._color_atts(XLSX.SchemeColor(:accent2; alpha = 50)) == ["theme" => "5"]
+ @test XLSX._color_atts(XLSX.SchemeColor(:accent2; lumMod = 75, alpha = 50)) == ["theme" => "5", "tint" => "-0.25"]
+
+ # Transforms a cell color cannot express
+ for sc in (XLSX.SchemeColor(:accent1; shade = 50),
+ XLSX.SchemeColor(:accent1; tint = 50),
+ XLSX.SchemeColor(:accent1; satMod = 120),
+ XLSX.SchemeColor(:accent1; lumOff = 40),
+ XLSX.SchemeColor(:accent1; lumMod = 120),
+ XLSX.SchemeColor(:accent1; lumMod = 50, lumOff = 40),
+ XLSX.SchemeColor(:accent1, [:lumOff => 40.0, :lumMod => 60.0]))
+ @test_throws XLSX.XLSXError XLSX._color_atts(sc)
+ end
+
+ # Not a color at all
+ @test_throws XLSX.XLSXError XLSX._color_atts("notacolor")
+ @test_throws XLSX.XLSXError XLSX._color_atts(42)
+ end
+
@testset "theme_xmlroot" begin
# theme1.xml parses cleanly and has the correct root element
theme_root = XLSX.theme_xmlroot(wb)
@@ -70,39 +124,39 @@
@test XLSX.resolveColor(wb, Dict("fgtheme" => "1"); prefix="fg") == "FF000000"
# theme colors are consistent with what getRichTextString resolved in the actual file
- # is.xlsx has theme="1" runs (dk1, black) and theme="9" runs (accent6, green)
+ # is.xlsx B2 has theme="1" runs, which resolve to dk1 (black)
rts = XLSX.getRichTextString(sh, "B2")
dk1_run = findfirst(r -> r.atts !== nothing && get(r.atts, :color, nothing) == "FF000000", rts.runs)
@test dk1_run !== nothing
end
@testset "resolveColor (Borders.xlsx)" begin
- f = XLSX.readxlsx(joinpath(data_directory, "Borders.xlsx"))
- s = f["Sheet1"]
- wb = XLSX.get_workbook(f)
+ bf = XLSX.readxlsx(joinpath(data_directory, "Borders.xlsx"))
+ bs = bf["Sheet1"]
+ bwb = XLSX.get_workbook(bf)
# theme_xmlroot works on a different fixture - same theme, independent cache
- theme_root = XLSX.theme_xmlroot(wb)
+ theme_root = XLSX.theme_xmlroot(bwb)
@test theme_root !== nothing
- colors = XLSX.get_theme_colors(wb)
+ colors = XLSX.get_theme_colors(bwb)
@test colors[4] == "0E2841" # dk2, index 3
# D4: theme="3" tint="0.24994659260841701" - the motivating example
- d4_top = XLSX.getBorder(s, "D4").border["top"]
- @test XLSX.resolveColor(s, d4_top) == "FF4A5E70"
+ d4_top = XLSX.getBorder(bs, "D4").border["top"]
+ @test XLSX.resolveColor(bs, d4_top) == "FF4A5E70"
# all four sides of D4 have the same color
for side in ("left", "right", "top", "bottom")
- @test XLSX.resolveColor(wb, XLSX.getBorder(s, "D4").border[side]) == "FF4A5E70"
+ @test XLSX.resolveColor(bwb, XLSX.getBorder(bs, "D4").border[side]) == "FF4A5E70"
end
# B4: explicit rgb - resolveColor passes it through unchanged
- b4_top = XLSX.getBorder(s, "B4").border["top"]
- @test XLSX.resolveColor(s, b4_top) == "FFFF0000"
+ b4_top = XLSX.getBorder(bs, "B4").border["top"]
+ @test XLSX.resolveColor(bs, b4_top) == "FFFF0000"
# B2: auto color on border
- b2_top = XLSX.getBorder(s, "B2").border["top"]
- @test XLSX.resolveColor(s, b2_top) == "FF000000"
+ b2_top = XLSX.getBorder(bs, "B2").border["top"]
+ @test XLSX.resolveColor(bs, b2_top) == "FF000000"
end
@testset "rgb normalization" begin
@@ -111,15 +165,18 @@
@test XLSX.resolveColor(wb, Dict("rgb" => "00ff00")) == "FF00FF00"
@test XLSX.resolveColor(wb, Dict("rgb" => "80AABBCC")) == "80AABBCC"
@test XLSX.resolveColor(wb, Dict("rgb" => "80aabbcc")) == "80AABBCC"
- @test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("rgb" => "ABC")) # if you choose to throw on bad format
+ @test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("rgb" => "ABC"))
end
@testset "indexed bounds" begin
maxidx = length(XLSX.INDEXED_PALETTE) - 1
@test XLSX.resolveColor(wb, Dict("indexed" => "0")) == "FF" * XLSX.INDEXED_PALETTE[1]
@test XLSX.resolveColor(wb, Dict("indexed" => string(maxidx))) == "FF" * XLSX.INDEXED_PALETTE[maxidx+1]
- # If you choose to throw on out-of-range:
- @test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("indexed" => string(maxidx+1)))
+ # 64 and 65 are the system foreground and background, which Excel writes for defaults
+ @test XLSX.resolveColor(wb, Dict("indexed" => "64")) == "FF000000"
+ @test XLSX.resolveColor(wb, Dict("indexed" => "65")) == "FFFFFFFF"
+ # out-of-range indices throw
+ @test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("indexed" => "66"))
@test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("indexed" => "-1"))
end
@@ -130,7 +187,7 @@
@test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("theme" => "3", "tint" => "nan"))
@test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("theme" => "3", "tint" => "inf"))
@test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("theme" => "3", "tint" => "notanumber"))
- @test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("indexed" => "64"))
+ @test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("indexed" => "66"))
@test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("theme" => "notanint"))
@test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("indexed" => "foo"))
@test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("theme" => "-1"))
@@ -138,35 +195,6 @@
@test_throws XLSX.XLSXError XLSX.resolveColor(wb, Dict("theme" => "notanumber"))
end
- @testset "theme colour transforms match Excel" begin
- f = XLSX.opentemplate(joinpath(data_directory, "chart_theme_colors.xlsx"))
- wb = XLSX.get_workbook(f)
- ch = XLSX.xml_root_element(f.data["xl/charts/chart1.xml"])
-
- cols = XLSX.Charts.DrawingColor[]
- walk(n) = for c in XML.eachelement(n)
- XLSX.localname(c) == "solidFill" ?
- (col = XLSX.Charts.parse_drawing_color(wb, c); isnothing(col) || push!(cols, col)) :
- walk(c)
- end
- walk(ch)
-
- # Values confirmed against what Excel reports in More Fill Colors.
- function byval(v, t)
- hits = filter(c -> c.val == v && c.transforms == t, cols)
- @test !isempty(hits)
- @test allequal(c.rgb for c in hits) # same input, same output everywhere
- return first(hits).rgb
- end
-
- @test byval("accent1", Pair{Symbol,Int}[]) == "156082"
- @test byval("accent1", [:lumMod => 60000, :lumOff => 40000]) == "46B1E1"
- @test byval("accent1", [:lumMod => 75000]) == "104862"
- @test byval("tx1", [:lumMod => 65000, :lumOff => 35000]) == "595959"
- @test byval("tx1", [:lumMod => 15000, :lumOff => 85000]) == "D9D9D9"
- SAVE_FILES && save_outfile(f)
- end
-
@testset "every colour argument takes every colour form" begin
# The same colour in each accepted form. Coral rather than red, so a
# named colour is resolved through Colors.jl rather than a short table.
@@ -189,7 +217,7 @@
tmp = "colour_forms.xlsx"
cp(joinpath(data_directory, "chart_kinds.xlsx"), tmp; force = true)
try
- xf = XLSX.openxlsx(tmp; mode = "rw")
+ local xf = XLSX.openxlsx(tmp; mode = "rw")
c = first(XLSX.Charts.getCharts(xf["stackedbar"]))
lc = first(XLSX.Charts.getCharts(xf["linemarkers"]))
catax = only(XLSX.Charts.getChartAxes(c, :category))
@@ -237,14 +265,10 @@
lc = XLSX.Charts.setMarkerLineColor(lc, 1, col)
@test iscoral(XLSX.Charts.getSeriesMarker(lc, 1).shape.line.fill.fgcolor.rgb)
- # cells: Symbol colours not yet accepted (borders need work)
+ # cells
ws["A$i"] = "x"
- if col isa Symbol
- @test_broken (XLSX.setFont(ws, "A$i"; color = col); true) # formatting setters can't take a symbol for a colour yet.
- else
- XLSX.setFont(ws, "A$i"; color = col)
- @test iscoral(XLSX.getFont(ws, "A$i").font["color"]["rgb"])
- end
+ XLSX.setFont(ws, "A$i"; color = col)
+ @test iscoral(XLSX.getFont(ws, "A$i").font["color"]["rgb"])
end
end
@@ -260,4 +284,10 @@
isfile(tmp) && rm(tmp)
end
end
-end
\ No newline at end of file
+end
+
+@testset "review fixes: system indexed colors" begin
+ f = XLSX.newxlsx()
+ @test XLSX.resolveColor(f.workbook, Dict("indexed" => "64")) == "FF000000"
+ @test XLSX.resolveColor(f.workbook, Dict("indexed" => "65")) == "FFFFFFFF"
+end
diff --git a/test/test_files/Conditional-format_tests.jl b/test/test_files/Conditional-format_tests.jl
index f947b08b..fd417747 100644
--- a/test/test_files/Conditional-format_tests.jl
+++ b/test/test_files/Conditional-format_tests.jl
@@ -1,5 +1,4 @@
@testset "Conditional Formats" begin
-
@testset "DataBar" begin
f = XLSX.newxlsx()
@@ -410,6 +409,39 @@
@test XLSX.setConditionalFormat(s, :, j, :colorScale; colorscale=k) == 0
end
SAVE_FILES && save_outfile(f)
+
+ @testset "colorScale numeric bounds and mid_col default" begin
+ xf = XLSX.newxlsx()
+ s = xf[1]
+ for i in 1:10, j in 1:3
+ s[i, j] = i * j
+ end
+
+ # explicit colours
+ @test XLSX.setConditionalFormat(s, "A1:C10", :colorScale;
+ min_type="num", min_val="2", min_col="tomato",
+ mid_type="num", mid_val="6", mid_col="lawngreen",
+ max_type="num", max_val="10", max_col="cadetblue") == 0
+
+ # mid_col omitted: defaults to white rather than throwing
+ @test XLSX.setConditionalFormat(s, "A1:C10", :colorScale;
+ min_type="num", min_val="2",
+ mid_type="num", mid_val="6",
+ max_type="num", max_val="10") == 0
+
+ f = "cf_colorscale_numeric_bounds_and.xlsx"
+ XLSX.writexlsx(f, xf; overwrite=true)
+ x = ZipArchives.zip_readentry(ZipArchives.ZipReader(read(f)),
+ "xl/worksheets/sheet1.xml", String)
+
+ # "num" types mean the values survive the `== "min"` / `== "max"` guards
+ @test occursin("", x)
+ @test occursin("", x)
+ @test occursin("", x)
+ @test occursin("rgb=\"FFFCFCFF\"", x)
+ SAVE_FILES && save_outfile(xf)
+ isfile(f) && rm(f)
+ end
end
@testset "iconSet" begin
@@ -633,21 +665,20 @@
icon_list=[1, 13, 26, 39],
min_type="percent", max_type="percent",
min_val="25"
- ) == 0
+ )
@test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:4, 1, :iconSet;
iconset="Custom",
icon_list=[1, 13, 26, 39],
min_type="percent",
min_val="25", max_val="75"
- ) == 0
+ )
@test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:4, 1, :iconSet;
iconset="Custom",
icon_list=[1, 13, 26, 39]
- ) == 0
+ )
@test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:4, 1, :iconSet;
iconset="10ThousandManiacs",
- ) == 0
-
+ )
@test sort!(XLSX.getConditionalFormats(s), by=x -> x.second.priority) == [
XLSX.CellRange("A1:A4") => (type="iconSet", priority=1),
@@ -672,7 +703,7 @@
XLSX.addDefinedName(s, "myRange", "A1:B2")
XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5")
@test XLSX.setConditionalFormat(s, "myRange", :iconSet) == 0
- @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "myNCRange", :iconSet)
+ @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "myNCRange", :iconSet) # C5 and D5 lie outside the 4-row sheet
SAVE_FILES && save_outfile(f)
f = XLSX.newxlsx()
@@ -737,7 +768,7 @@
s[i, j] = i + j
end
@test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:A5", :cellIs; notAKeyword="x")
- @test XLSX.setConditionalFormat(s, "A1,A3", :cellIs) == 0 # Non-contiguous ranges not allowed
+ @test XLSX.setConditionalFormat(s, "A1,A3", :cellIs) == 0 # Non-contiguous ranges are allowed
@test XLSX.setConditionalFormat(s, [1], 1, :cellIs) == 0 # Vectors may be non-contiguous
@test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1, 1:3:7, :cellIs) # out of range
@test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:A3", :cellIs; dxStyle="madeUp") # dxStyle invalid
@@ -840,7 +871,7 @@
XLSX.setConditionalFormat(s, "A1:A5", :cellIs)
XLSX.setConditionalFormat(s, :, 2, :cellIs; dxStyle="redborder")
XLSX.setConditionalFormat(s, "Sheet1!E:E", :cellIs; dxStyle="redfilltext")
- XLSX.setConditionalFormat(s, 1:5, 3:4, :cellIs;
+ @test XLSX.setConditionalFormat(s, 1:5, 3:4, :cellIs;
operator="between",
value="2",
value2="4",
@@ -889,7 +920,7 @@
border=["style" => "hair", "color" => "cyan"]
) == 0
XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5")
- @test XLSX.setConditionalFormat(s, "myNCRange", :cellIs; # Non-contiguous ranges not allowed
+ @test XLSX.setConditionalFormat(s, "myNCRange", :cellIs; # Non-contiguous ranges are allowed
operator="lessThan",
value="2",
fill=["pattern" => "none", "bgColor" => "yellow"],
@@ -910,7 +941,6 @@
SAVE_FILES && save_outfile(f)
end
-
@testset "containsText" begin
f = XLSX.newxlsx()
s = f[1]
@@ -1017,7 +1047,7 @@
XLSX.setConditionalFormat(s, "A1:A5", :containsText; value="a")
XLSX.setConditionalFormat(s, :, 2, :containsText; value="a", dxStyle="redborder")
XLSX.setConditionalFormat(s, "Sheet1!E:E", :containsText; value="a", dxStyle="redfilltext")
- XLSX.setConditionalFormat(s, 1:5, 3:4, :containsText;
+ @test XLSX.setConditionalFormat(s, 1:5, 3:4, :containsText;
operator="endsWith",
value="a",
fill=["pattern" => "none", "bgColor" => "yellow"],
@@ -1076,7 +1106,7 @@
format=["format" => "0.0"],
font=["color" => "green"],
border=["style" => "hair", "color" => "cyan"]
- ) == 0
+ )
XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5")
@test XLSX.setConditionalFormat(s, "myNCRange", :containsText;
operator="beginsWith",
@@ -1146,7 +1176,6 @@
XLSX.CellRange("A1:J10") => (type="top10", priority=9)
]
-
@test XLSX.setConditionalFormat(s, "A1", :top10) == 0
@test XLSX.setConditionalFormat(s, "A1:C3", :top10) == 0
@test XLSX.setConditionalFormat(s, "Sheet1!A1", :top10) == 0
@@ -1206,7 +1235,7 @@
XLSX.setConditionalFormat(s, "A1:A5", :top10)
XLSX.setConditionalFormat(s, :, 2, :top10; dxStyle="redborder")
XLSX.setConditionalFormat(s, "Sheet1!E:E", :top10; dxStyle="redfilltext")
- XLSX.setConditionalFormat(s, 1:5, 3:4, :top10;
+ @test XLSX.setConditionalFormat(s, 1:5, 3:4, :top10;
operator="topN%",
value="20",
fill=["pattern" => "none", "bgColor" => "yellow"],
@@ -1408,7 +1437,6 @@
XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=29)
]
-
SAVE_FILES && save_outfile(f)
f = XLSX.newxlsx()
@@ -1434,7 +1462,7 @@
format=["format" => "0.0"],
font=["color" => "green"],
border=["style" => "thick", "color" => "coral"]
- ) == 0
+ )
@test sort!(XLSX.getConditionalFormats(s), by=x -> x.second.priority, rev=true) == [
XLSX.CellRange("C1:D5") => (type="aboveAverage", priority=4),
@@ -1580,7 +1608,6 @@
XLSX.CellRange("A1:C10") => (type="timePeriod", priority=12)
]
-
@test XLSX.setConditionalFormat(s, "A1", :timePeriod) == 0
@test XLSX.setConditionalFormat(s, "A1:C3", :timePeriod) == 0
@test XLSX.setConditionalFormat(s, "Sheet1!A1", :timePeriod) == 0
@@ -1631,8 +1658,6 @@
XLSX.CellRange("A1:J10") => (type="timePeriod", priority=29)
]
-
-
SAVE_FILES && save_outfile(f)
f = XLSX.newxlsx()
@@ -1650,7 +1675,7 @@
XLSX.setConditionalFormat(s, "A1:A5", :timePeriod)
XLSX.setConditionalFormat(s, :, 2, :timePeriod; dxStyle="redborder")
XLSX.setConditionalFormat(s, "Sheet1!E:E", :timePeriod; dxStyle="redfilltext")
- XLSX.setConditionalFormat(s, 1:5, 3:4, :timePeriod;
+ @test XLSX.setConditionalFormat(s, 1:5, 3:4, :timePeriod;
operator="lastWeek",
fill=["pattern" => "none", "bgColor" => "yellow"],
format=["format" => "0.0"],
@@ -1709,7 +1734,7 @@
border=["style" => "medium", "color" => "cyan"]
) == 0
XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5")
- @test XLSX.setConditionalFormat(s, "myNCRange", :timePeriod; # Non-contiguous ranges not allowed
+ @test XLSX.setConditionalFormat(s, "myNCRange", :timePeriod; # Non-contiguous ranges are allowed
operator="lastWeek",
fill=["pattern" => "none", "bgColor" => "yellow"],
format=["format" => "0.0"],
@@ -1740,7 +1765,7 @@
fill=["pattern" => "none", "bgColor" => "FFFFC7CE"],
border=["style" => "thick", "color" => "coral"],
font=["color" => "blue", "bold" => "true", "strike" => "true"]
- ) == 0
+ )
@test XLSX.setConditionalFormat(s, 1:10, 1:3, :expression;
formula="A1 > 15",
stopIfTrue="true",
@@ -1781,7 +1806,6 @@
XLSX.CellRange("A1:C10") => (type="expression", priority=9)
]
-
@test XLSX.setConditionalFormat(s, "A1", :expression; formula="iseven(A1)") == 0
@test XLSX.setConditionalFormat(s, "A1:C3", :expression; formula="iseven(A1)") == 0
@test XLSX.setConditionalFormat(s, "Sheet1!A1", :expression; formula="iseven(A1)") == 0
@@ -1841,7 +1865,7 @@
XLSX.setConditionalFormat(s, "A1:A5", :expression; formula="A1=1")
XLSX.setConditionalFormat(s, :, 2, :expression; formula="A1=1", dxStyle="redborder")
XLSX.setConditionalFormat(s, "Sheet1!E:E", :expression; formula="A1=1", dxStyle="redfilltext")
- XLSX.setConditionalFormat(s, 1:5, 3:4, :expression;
+ @test XLSX.setConditionalFormat(s, 1:5, 3:4, :expression;
formula="A1=1",
fill=["pattern" => "none", "bgColor" => "yellow"],
format=["format" => "0.0"],
@@ -1892,7 +1916,7 @@
border=["style" => "medium", "color" => "cyan"]
) == 0
XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5")
- @test XLSX.setConditionalFormat(s, "myNCRange", :expression; # Non-contiguous ranges not allowed
+ @test XLSX.setConditionalFormat(s, "myNCRange", :expression; # Non-contiguous ranges are allowed
formula="C4 < myTest",
fill=["pattern" => "none", "bgColor" => "yellow"],
format=["format" => "0.0"],
@@ -1903,7 +1927,7 @@
end
- @testset "containsErrors" begin
+ @testset "errors, blanks, unique and duplicate values" begin
f = XLSX.newxlsx()
s = f[1]
for i = 1:10
@@ -1968,7 +1992,6 @@
XLSX.CellRange("A1:C10") => (type="duplicateValues", priority=11)
]
-
@test XLSX.setConditionalFormat(s, "A1", :containsErrors) == 0
@test XLSX.setConditionalFormat(s, "A1:C3", :notContainsErrors) == 0
@test XLSX.setConditionalFormat(s, "Sheet1!A1", :containsBlanks) == 0
@@ -2030,7 +2053,7 @@
XLSX.setConditionalFormat(s, "A1:A5", :containsErrors)
XLSX.setConditionalFormat(s, :, 2, :notContainsErrors; dxStyle="redborder")
XLSX.setConditionalFormat(s, "Sheet1!E:E", :containsBlanks; dxStyle="redfilltext")
- XLSX.setConditionalFormat(s, 1:5, 3:4, :uniqueValues;
+ @test XLSX.setConditionalFormat(s, 1:5, 3:4, :uniqueValues;
fill=["pattern" => "none", "bgColor" => "yellow"],
format=["format" => "0.0"],
font=["color" => "green"],
@@ -2076,7 +2099,7 @@
border=["style" => "medium", "color" => "cyan"]
) == 0
XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5")
- @test XLSX.setConditionalFormat(s, "myNCRange", :containsErrors; # Non-contiguous ranges not allowed
+ @test XLSX.setConditionalFormat(s, "myNCRange", :containsErrors; # Non-contiguous ranges are allowed
fill=["pattern" => "none", "bgColor" => "yellow"],
format=["format" => "0.0"],
font=["color" => "green"],
@@ -2086,153 +2109,47 @@
end
- @testset "getConditionalFormats does not break subsequent cell reads (issue #425)" begin
-
- function build_cf_workbook()
- path = "cf_getconditionalformats_does_not.xlsx"
- f = XLSX.newxlsx()
- s = f[1]
- for i in 1:5, j in 1:5
- s[i, j] = i + j
- end
- XLSX.setConditionalFormat(s, "A1:E1", :dataBar)
- XLSX.writexlsx(path, f; overwrite=true)
- SAVE_FILES && save_outfile(f)
- return path
- end
-
- @testset "getConditionalFormats then getdata (originally-failing order)" begin
- path = build_cf_workbook()
- XLSX.openxlsx(path) do xf
- ws = xf[1]
- cfs = XLSX.getConditionalFormats(ws)
- @test length(cfs) == 1
-
- data = XLSX.getdata(ws)
- @test data[1, 1] == 2
- @test size(data) == (5, 5)
- end
- isfile(path) && rm(path)
- end
-
- @testset "getdata then getConditionalFormats (originally-working order, must not regress)" begin
- path = build_cf_workbook()
- XLSX.openxlsx(path) do xf
- ws = xf[1]
- data = XLSX.getdata(ws)
- @test data[1, 1] == 2
-
- cfs = XLSX.getConditionalFormats(ws)
- @test length(cfs) == 1
- end
- isfile(path) && rm(path)
- end
-
- @testset "getConditionalFormats then eachrow" begin
- path = build_cf_workbook()
- XLSX.openxlsx(path) do xf
- ws = xf[1]
- XLSX.getConditionalFormats(ws)
-
- rows = collect(XLSX.eachrow(ws))
- @test length(rows) == 5
- @test XLSX.getdata(rows[1], 1) == 2
+ @testset "every conditional format type" begin
+ @testset "invalid keyword arguments" begin
+ xf = XLSX.newxlsx()
+ s = xf[1]
+ for i in 1:5, j in 1:3
+ s[i, j] = i * j
end
- isfile(path) && rm(path)
- end
-
- @testset "getConditionalFormats then readtable" begin
- path = build_cf_workbook()
- XLSX.openxlsx(path) do xf
- ws = xf[1]
- XLSX.getConditionalFormats(ws)
- tbl = XLSX.readtable(path, ws.name; header=false)
- @test length(tbl.data) == 5 # 5 columns
- @test length(tbl.data[1]) == 5 # 5 rows
+ for (t, kw) in ((:cellIs, (operator="greaterThan", value="1")),
+ (:containsText, (value="a",)),
+ (:top10, ()),
+ (:aboveAverage, ()),
+ (:timePeriod, ()),
+ (:containsErrors, ()),
+ (:expression, (formula="A1>1",)),
+ (:colorScale, ()),
+ (:iconSet, ()),
+ (:dataBar, ()))
+ @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:C5", t; kw..., notAKeyword="x")
end
- isfile(path) && rm(path)
+ SAVE_FILES && save_outfile(xf)
end
- @testset "readxlsx: getConditionalFormats then getdata" begin
- path = build_cf_workbook()
- xf = XLSX.readxlsx(path)
- ws = xf[1]
- cfs = XLSX.getConditionalFormats(ws)
- @test length(cfs) == 1
- data = XLSX.getdata(ws)
- @test data[1, 1] == 2
- isfile(path) && rm(path)
- end
-
- @testset "getConditionalExtFormats (allExtCfs) does not break subsequent reads" begin
- path = "cf_getconditionalextformats.xlsx"
- f = XLSX.newxlsx()
- s = f[1]
- for i in 1:5, j in 1:5
- s[i, j] = i + j
- end
- # icon sets / top10 / etc. route through the x14 extension block —
- # use whichever of these your API exposes as the ext-format path.
- XLSX.setConditionalFormat(s, "A1:E1", :iconSet; iconset="5Boxes")
- XLSX.writexlsx(path, f; overwrite=true)
-
- XLSX.openxlsx(path) do xf2
- ws = xf2[1]
- XLSX.getConditionalFormats(ws)
- data = XLSX.getdata(ws)
- @test data[1, 1] == 2
+ @testset "vector dispatch across all CF types" begin
+ for (t, kw) in ((:dataBar, ()), (:cellIs, (operator="greaterThan", value="1")),
+ (:colorScale, ()), (:iconSet, ()), (:top10, ()),
+ (:aboveAverage, ()), (:expression, (formula="A1>1",)),
+ (:containsErrors, ()), (:containsText, (value="a",)),
+ (:timePeriod, ()))
+ xf = XLSX.newxlsx()
+ s = xf[1]
+ for i in 1:4, j in 1:4; s[i, j] = i*j; end
+ @test XLSX.setConditionalFormat(s, [1, 3], :, t; kw...) == 0
+ @test XLSX.setConditionalFormat(s, :, [1, 3], t; kw...) == 0
+ @test XLSX.setConditionalFormat(s, [1, 3], [1, 3], t; kw...) == 0
+ SAVE_FILES && save_outfile(xf)
end
- isfile(path) && rm(path)
- SAVE_FILES && save_outfile(f)
end
-
end
- @testset "Non-contiguous conditional formats" begin
-
- C(r, c) = XLSX.CellRef(r, c)
- expand(areas) = Set(vcat([a isa XLSX.CellRef ? [a] : collect(a) for a in areas]...))
-
- @testset "_compress" begin
- a = XLSX._compress([C(1, 1)])
- @test length(a) == 1 && a[1] isa XLSX.CellRef
-
- @test XLSX._compress([C(1, 1), C(2, 1), C(3, 1)]) == [XLSX.CellRange("A1:A3")]
- @test length(XLSX._compress([C(1, 1), C(2, 1), C(5, 1)])) == 2
-
- block = vec([C(r, c) for r in 1:4, c in 1:3])
- @test XLSX._compress(block) == [XLSX.CellRange("A1:C4")]
-
- # ragged columns must not merge horizontally
- @test length(XLSX._compress([C(1, 1), C(2, 1), C(1, 2)])) == 2
-
- cb = [C(r, c) for r in 1:4 for c in 1:4 if iseven(r + c)]
- @test length(XLSX._compress(cb)) == length(cb)
-
- @test XLSX._compress([C(1, 1), C(1, 1), C(2, 1)]) == [XLSX.CellRange("A1:A2")]
- @test isempty(XLSX._compress(XLSX.CellRef[]))
-
- # compression preserves the covered cell set
- scattered = [C(r, c) for r in 1:8 for c in 1:5 if (r * c) % 3 == 0]
- @test expand(XLSX._compress(scattered)) == Set(scattered)
- end
-
- @testset "_band_colors" begin
- @test XLSX._band_colors(["green", "orange", "red"], 3) == ["FF008000", "FFFFA500", "FFFF0000"]
- @test length(XLSX._band_colors(["green", "red"], 5)) == 5
- @test XLSX._band_colors(["green", "red"], 2) == ["FF008000", "FFFF0000"]
- @test XLSX._band_colors("red", 1) == ["FFFF0000"]
- @test XLSX._band_colors(:red, 1) == ["FFFF0000"]
-
- interp = XLSX._band_colors(["FF008000", "FFFF0000"], 5)
- @test interp[1] == "FF008000" && interp[end] == "FFFF0000"
- @test all(c -> occursin(r"^FF[0-9A-F]{6}$", c), interp)
-
- @test_throws XLSX.XLSXError XLSX._band_colors(["red", "green", "blue"], 5)
- @test_throws XLSX.XLSXError XLSX._band_colors(["notacolour", "red"], 3)
- end
-
+ @testset "non-contiguous ranges" begin
@testset "_cf_sqref" begin
@test XLSX._cf_sqref(XLSX.CellRange("A1:B2")) == "A1:B2"
xf = XLSX.newxlsx()
@@ -2243,49 +2160,6 @@
SAVE_FILES && save_outfile(xf)
end
- @testset "partition" begin
- xf = XLSX.newxlsx()
- s = xf[1]
- for i in 1:10
- s[i, 1] = i
- end
- s["A11"] = "text"
- s["A12"] = NaN
-
- p = XLSX.partition(s, "A1:A12", [3, 7])
- @test length(p) == 3
- @test first.(p) == [1, 2, 3]
- @test XLSX._cf_sqref(p[1][2]) == "A1:A2"
- @test XLSX._cf_sqref(p[2][2]) == "A3:A6"
- @test XLSX._cf_sqref(p[3][2]) == "A7:A10"
-
- all_sq = join((XLSX._cf_sqref(r) for (_, r) in p), " ")
- @test !occursin("A11", all_sq) # text excluded
- @test !occursin("A12", all_sq) # NaN excluded
-
- @test XLSX._cf_sqref(XLSX.partition(s, "A1:A12", [3, 7]; gte=false)[1][2]) == "A1:A3"
- @test XLSX._cf_sqref(XLSX.partition(s, "A1:A12", [3]; gte=[false])[1][2]) == "A1:A3"
-
- p = XLSX.partition(s, "A1:A10", [3, 7]; labels=[:lo, :mid, :hi])
- @test first.(p) == [:lo, :mid, :hi]
- @test eltype(p) <: Pair{Symbol}
-
- @test length(XLSX.partition(s, "A1:A10", [100, 200])) == 1 # empty bands dropped
-
- # key-function method: encounter order, not sorted
- p = XLSX.partition(s, "A1:A10", v -> v isa Real ? (iseven(v) ? :even : :odd) : nothing)
- @test first.(p) == [:odd, :even]
-
- p = XLSX.partition(s, "A1:A10", v -> v isa Real ? :all : nothing; compress=false)
- @test length(XLSX._cf_areas(p[1][2])) == 10
-
- @test_throws XLSX.XLSXError XLSX.partition(s, "A1:A10", [7, 3])
- @test_throws XLSX.XLSXError XLSX.partition(s, "A1:A10", [3, 7]; labels=[:a, :b])
- @test_throws XLSX.XLSXError XLSX.partition(s, "A1:A10", [3, 7]; labels=[:a, :b, :a])
- @test_throws XLSX.XLSXError XLSX.partition(s, "A1:A10", [3, 7]; gte=[true])
- SAVE_FILES && save_outfile(xf)
- end
-
@testset "setConditionalFormat on a non-contiguous range" begin
xf = XLSX.newxlsx()
s = xf[1]
@@ -2295,7 +2169,7 @@
@test XLSX.setConditionalFormat(s, "A1,A3", :dataBar) == 0
@test XLSX.setConditionalFormat(s, "A1,A3", :cellIs; operator="greaterThan", value="1") == 0
- @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "myNCRange", :cellIs; operator="greaterThan", value="1") # out of range
+ @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "myNCRange", :cellIs; operator="greaterThan", value="1") # no such defined name
cfs = XLSX.getConditionalFormats(s)
rng = first(first(cfs))
@@ -2317,133 +2191,42 @@
isfile(f) && rm(f)
end
- @testset "setColoredDataBars" begin
+ @testset "vector and step-range dispatch" begin
xf = XLSX.newxlsx()
s = xf[1]
- for i in 1:20
- s[i, 1] = (i * 7) % 20 + 1
+ for i in 1:6, j in 1:3
+ s[i, j] = i * j
end
- p = XLSX.setColoredDataBars(s, "A1:A20"; bands=4)
- @test length(p) == 4
- @test first.(p) == [1, 2, 3, 4]
-
- f = "cf_setcoloreddatabars.xlsx"
- XLSX.writexlsx(f, xf; overwrite=true)
- x = ZipArchives.zip_readentry(ZipArchives.ZipReader(read(f)),
- "xl/worksheets/sheet1.xml", String)
+ sq(ws) = XLSX._cf_sqref(first(first(XLSX.getConditionalFormats(ws))))
- @test count(" inside , not sit inside
- @test occursin(r"\s*\s*\s*(\{[^}]+\})", x)]
- @test length(ids) == 4
- @test all(i -> occursin("id=\"$i\"", x), ids)
+ # vector of columns, contiguous rows -> merges into per-column blocks
+ xf3 = XLSX.newxlsx()
+ s3 = xf3[1]
+ for i in 1:3, j in 1:3; s3[i, j] = i; end
+ @test XLSX.setConditionalFormat(s3, 1:3, [1, 3], :dataBar) == 0
+ @test sq(s3) == "A1:A3 C1:C3"
- # shared axis: exactly one min and one max cfvo value across all bands
- @test length(Set(m.match for m in eachmatch(r"", x))) == 2
-
- # sqrefs are space-separated
- @test !occursin(r"sqref=\"[^\"]*,", x)
- @test occursin(r"sqref=\"[^\"]* [^\"]*\"", x)
- SAVE_FILES && save_outfile(xf)
- isfile(f) && rm(f)
- end
-
- @testset "setColoredDataBars options" begin
- xf = XLSX.newxlsx()
- s = xf[1]
- for i in 1:10
- s[i, 1] = i
- end
-
- # bands=1 degenerates to one contiguous rule
- p = XLSX.setColoredDataBars(s, "A1:A10"; bands=1, colors="steelblue")
- @test length(p) == 1
- @test p[1][2] isa XLSX.CellRange
-
- # breaks overrides bands
- @test length(XLSX.setColoredDataBars(s, "A1:A10"; bands=9, breaks=[5], colors=["green", "red"])) == 2
-
- # explicit colours, one per band
- @test length(XLSX.setColoredDataBars(s, "A1:A10"; bands=3, colors=["green", "orange", "red"])) == 3
-
- # passthrough kwargs and explicit axis
- @test length(XLSX.setColoredDataBars(s, "A1:A10"; bands=2, min_val="0", max_val="100",
- showVal="false", direction="rightToLeft")) == 2
-
- # gte forwarding
- @test length(XLSX.setColoredDataBars(s, "A1:A10"; breaks=[5], gte=false, colors=["green", "red"])) == 2
-
- # alternative range argument forms
- @test length(XLSX.setColoredDataBars(s, XLSX.CellRange("A1:A10"); bands=2)) == 2
- @test length(XLSX.setColoredDataBars(s, XLSX.SheetCellRange("Sheet1!A1:A10"); bands=2)) == 2
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "setColoredDataBars errors" begin
- xf = XLSX.newxlsx()
- s = xf[1]
- for i in 1:10
- s[i, 1] = i
- end
- s["C1"] = 7
- s["C2"] = 7
- s["C3"] = 7
- s["D1"] = "text"
-
- @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "A1:A10"; bands=0)
- @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "A1:A10"; bands=3, min_type="percentile")
- @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "A1:A10"; bands=3, max_type="highest")
- @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "A1:A10"; bands=3, colors=["red", "green", "blue", "cyan"])
- @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "D1:D1") # no numeric values
- @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "C1:C3") # all values equal
- @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "A1:A1000") # outside dimension
- SAVE_FILES && save_outfile(xf)
- end
- @testset "vector and step-range dispatch" begin
- xf = XLSX.newxlsx()
- s = xf[1]
- for i in 1:6, j in 1:3
- s[i, j] = i * j
- end
-
- sq(ws) = XLSX._cf_sqref(first(first(XLSX.getConditionalFormats(ws))))
-
- # rows as a vector, single column
- xf1 = XLSX.newxlsx()
- s1 = xf1[1]
- for i in 1:6; s1[i, 1] = i; end
- @test XLSX.setConditionalFormat(s1, [1, 3, 5], 1, :dataBar) == 0
- @test sq(s1) == "A1 A3 A5"
-
- # contiguous StepRange collapses to a single area
- xf2 = XLSX.newxlsx()
- s2 = xf2[1]
- for i in 1:6; s2[i, 1] = i; end
- @test XLSX.setConditionalFormat(s2, 1:1:6, 1, :dataBar) == 0
- @test sq(s2) == "A1:A6"
-
- # vector of columns, contiguous rows -> merges into per-column blocks
- xf3 = XLSX.newxlsx()
- s3 = xf3[1]
- for i in 1:3, j in 1:3; s3[i, j] = i; end
- @test XLSX.setConditionalFormat(s3, 1:3, [1, 3], :dataBar) == 0
- @test sq(s3) == "A1:A3 C1:C3"
-
- # both vectors
- xf4 = XLSX.newxlsx()
- s4 = xf4[1]
- for i in 1:3, j in 1:3; s4[i, j] = i; end
- @test XLSX.setConditionalFormat(s4, [1, 3], [1, 3], :dataBar) == 0
- @test sq(s4) == "A1 A3 C1 C3"
+ # both vectors
+ xf4 = XLSX.newxlsx()
+ s4 = xf4[1]
+ for i in 1:3, j in 1:3; s4[i, j] = i; end
+ @test XLSX.setConditionalFormat(s4, [1, 3], [1, 3], :dataBar) == 0
+ @test sq(s4) == "A1 A3 C1 C3"
# colon forms
xf5 = XLSX.newxlsx()
@@ -2495,21 +2278,6 @@
end
- @testset "vector dispatch across all CF types" begin
- for (t, kw) in ((:dataBar, ()), (:cellIs, (operator="greaterThan", value="1")),
- (:colorScale, ()), (:iconSet, ()), (:top10, ()),
- (:aboveAverage, ()), (:expression, (formula="A1>1",)),
- (:containsErrors, ()), (:containsText, (value="a",)),
- (:timePeriod, ()))
- xf = XLSX.newxlsx()
- s = xf[1]
- for i in 1:4, j in 1:4; s[i, j] = i*j; end
- @test XLSX.setConditionalFormat(s, [1, 3], :, t; kw...) == 0
- @test XLSX.setConditionalFormat(s, :, [1, 3], t; kw...) == 0
- @test XLSX.setConditionalFormat(s, [1, 3], [1, 3], t; kw...) == 0
- SAVE_FILES && save_outfile(xf)
- end
- end
@testset "formula anchoring on a non-contiguous range" begin
xf = XLSX.newxlsx()
s = xf[1]
@@ -2524,7 +2292,7 @@
XLSX.writexlsx(f, xf; overwrite=true)
x = ZipArchives.zip_readentry(ZipArchives.ZipReader(read(f)),
"xl/worksheets/sheet1.xml", String)
- @test occursin("C5", x)
+ @test occursin(r"[^<]*,C5\)\)\)", x) # formula is relative to C5
@test occursin(r"sqref=\"C5:C9 A1:A3\"", x)
SAVE_FILES && save_outfile(xf)
isfile(f) && rm(f)
@@ -2579,263 +2347,626 @@
isfile(f) && rm(f)
end
- @testset "single-cell sqref round-trips" begin
+ @testset "cellIs default value averages the cells of a non-contiguous range" begin
xf = XLSX.newxlsx()
s = xf[1]
- s["A1"] = 1
- @test XLSX.setConditionalFormat(s, "A1", :dataBar) == 0
+ s["A1"] = 2
+ s["A2"] = 100
+ s["A3"] = 4
+ s["A4"] = "text"
+ XLSX.setConditionalFormat(s, "A1,A3", :cellIs) # the average of A1 and A3 only
+ XLSX.setConditionalFormat(s, "A1:A4", :cellIs) # contiguous, with a text cell
+ f = "cf_cellis_default_value_averages.xlsx"
+ XLSX.writexlsx(f, xf; overwrite=true)
+ r = XLSX.ZipArchives.ZipReader(read(f))
+ xml = XLSX.ZipArchives.zip_readentry(r, "xl/worksheets/sheet1.xml", String)
+ fmls = [m[1] for m in eachmatch(r"([^<]*)", xml)]
+ @test "3.0" in fmls # (2 + 4) / 2
+ @test any(x -> parse(Float64, x) ≈ 106 / 3, fmls) # (2 + 100 + 4) / 3; text ignored
+ @test !any(x -> occursin(r"\[|;;", x), fmls) # never a printed array
+ isfile(f) && rm(f)
+ SAVE_FILES && save_outfile(xf)
+ end
+ end
- f = "cf_single_cell_sqref_round_trips.xlsx"
+ @testset "colored data bars" begin
+ @testset "_compress" begin
+ C(r, c) = XLSX.CellRef(r, c)
+ expand(areas) = Set(vcat([a isa XLSX.CellRef ? [a] : collect(a) for a in areas]...))
+ a = XLSX._compress([C(1, 1)])
+ @test length(a) == 1 && a[1] isa XLSX.CellRef
+
+ @test XLSX._compress([C(1, 1), C(2, 1), C(3, 1)]) == [XLSX.CellRange("A1:A3")]
+ @test length(XLSX._compress([C(1, 1), C(2, 1), C(5, 1)])) == 2
+
+ block = vec([C(r, c) for r in 1:4, c in 1:3])
+ @test XLSX._compress(block) == [XLSX.CellRange("A1:C4")]
+
+ # ragged columns must not merge horizontally
+ @test length(XLSX._compress([C(1, 1), C(2, 1), C(1, 2)])) == 2
+
+ cb = [C(r, c) for r in 1:4 for c in 1:4 if iseven(r + c)]
+ @test length(XLSX._compress(cb)) == length(cb)
+
+ @test XLSX._compress([C(1, 1), C(1, 1), C(2, 1)]) == [XLSX.CellRange("A1:A2")]
+ @test isempty(XLSX._compress(XLSX.CellRef[]))
+
+ # compression preserves the covered cell set
+ scattered = [C(r, c) for r in 1:8 for c in 1:5 if (r * c) % 3 == 0]
+ @test expand(XLSX._compress(scattered)) == Set(scattered)
+ end
+
+ @testset "_band_colors" begin
+ @test XLSX._band_colors(["green", "orange", "red"], 3) == ["FF008000", "FFFFA500", "FFFF0000"]
+ @test length(XLSX._band_colors(["green", "red"], 5)) == 5
+ @test XLSX._band_colors(["green", "red"], 2) == ["FF008000", "FFFF0000"]
+ @test XLSX._band_colors("red", 1) == ["FFFF0000"]
+ @test XLSX._band_colors(["green", "red"], 1) == ["FF008000"]
+ @test XLSX._band_colors(:red, 1) == ["FFFF0000"]
+
+ interp = XLSX._band_colors(["FF008000", "FFFF0000"], 5)
+ @test interp[1] == "FF008000" && interp[end] == "FFFF0000"
+ @test all(c -> occursin(r"^FF[0-9A-F]{6}$", c), interp)
+
+ @test_throws XLSX.XLSXError XLSX._band_colors(["red", "green", "blue"], 5)
+ @test_throws XLSX.XLSXError XLSX._band_colors(["notacolour", "red"], 3)
+ end
+
+ @testset "partition" begin
+ xf = XLSX.newxlsx()
+ s = xf[1]
+ for i in 1:10
+ s[i, 1] = i
+ end
+ s["A11"] = "text"
+ s["A12"] = NaN
+
+ p = XLSX.partition(s, "A1:A12", [3, 7])
+ @test length(p) == 3
+ @test first.(p) == [1, 2, 3]
+ @test XLSX._cf_sqref(p[1][2]) == "A1:A2"
+ @test XLSX._cf_sqref(p[2][2]) == "A3:A6"
+ @test XLSX._cf_sqref(p[3][2]) == "A7:A10"
+
+ all_sq = join((XLSX._cf_sqref(r) for (_, r) in p), " ")
+ @test !occursin("A11", all_sq) # text excluded
+ @test !occursin("A12", all_sq) # NaN excluded
+
+ @test XLSX._cf_sqref(XLSX.partition(s, "A1:A12", [3, 7]; gte=false)[1][2]) == "A1:A3"
+ @test XLSX._cf_sqref(XLSX.partition(s, "A1:A12", [3]; gte=[false])[1][2]) == "A1:A3"
+
+ p = XLSX.partition(s, "A1:A10", [3, 7]; labels=[:lo, :mid, :hi])
+ @test first.(p) == [:lo, :mid, :hi]
+ @test eltype(p) <: Pair{Symbol}
+
+ @test length(XLSX.partition(s, "A1:A10", [100, 200])) == 1 # empty bands dropped
+
+ # key-function method: encounter order, not sorted
+ p = XLSX.partition(s, "A1:A10", v -> v isa Real ? (iseven(v) ? :even : :odd) : nothing)
+ @test first.(p) == [:odd, :even]
+
+ p = XLSX.partition(s, "A1:A10", v -> v isa Real ? :all : nothing; compress=false)
+ @test length(XLSX._cf_areas(p[1][2])) == 10
+
+ @test_throws XLSX.XLSXError XLSX.partition(s, "A1:A10", [7, 3])
+ @test_throws XLSX.XLSXError XLSX.partition(s, "A1:A10", [3, 7]; labels=[:a, :b])
+ @test_throws XLSX.XLSXError XLSX.partition(s, "A1:A10", [3, 7]; labels=[:a, :b, :a])
+ @test_throws XLSX.XLSXError XLSX.partition(s, "A1:A10", [3, 7]; gte=[true])
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "setColoredDataBars" begin
+ xf = XLSX.newxlsx()
+ s = xf[1]
+ for i in 1:20
+ s[i, 1] = (i * 7) % 20 + 1
+ end
+
+ p = XLSX.setColoredDataBars(s, "A1:A20"; bands=4)
+ @test length(p) == 4
+ @test first.(p) == [1, 2, 3, 4]
+
+ f = "cf_setcoloreddatabars.xlsx"
XLSX.writexlsx(f, xf; overwrite=true)
- xf2 = XLSX.opentemplate(f)
- rng = first(first(XLSX.getConditionalFormats(xf2[1])))
- @test XLSX._cf_sqref(rng) == "A1:A1"
- SAVE_FILES && save_outfile(xf2)
+ x = ZipArchives.zip_readentry(ZipArchives.ZipReader(read(f)),
+ "xl/worksheets/sheet1.xml", String)
+
+ @test count(" inside , not sit inside
+ @test occursin(r"\s*\s*\s*(\{[^}]+\})", x)]
+ @test length(ids) == 4
+ @test all(i -> occursin("id=\"$i\"", x), ids)
+
+ # shared axis: exactly one min and one max cfvo value across all bands
+ @test length(Set(m.match for m in eachmatch(r"", x))) == 2
+
+ # sqrefs are space-separated
+ @test !occursin(r"sqref=\"[^\"]*,", x)
+ @test occursin(r"sqref=\"[^\"]* [^\"]*\"", x)
SAVE_FILES && save_outfile(xf)
isfile(f) && rm(f)
end
- @testset "setColoredDataBars CellRef form" begin
+ @testset "setColoredDataBars options" begin
xf = XLSX.newxlsx()
s = xf[1]
- s["A1"] = 5
- p = XLSX.setColoredDataBars(s, XLSX.CellRef("A1"); bands=1)
- @test XLSX._band_colors(["green", "red"], 1) == ["FF008000"]
+ for i in 1:10
+ s[i, 1] = i
+ end
+
+ # bands=1 degenerates to one contiguous rule
+ p = XLSX.setColoredDataBars(s, "A1:A10"; bands=1, colors="steelblue")
@test length(p) == 1
+ @test p[1][2] isa XLSX.CellRange
+
+ # breaks overrides bands
+ @test length(XLSX.setColoredDataBars(s, "A1:A10"; bands=9, breaks=[5], colors=["green", "red"])) == 2
+
+ # explicit colours, one per band
+ @test length(XLSX.setColoredDataBars(s, "A1:A10"; bands=3, colors=["green", "orange", "red"])) == 3
+
+ # passthrough kwargs and explicit axis
+ @test length(XLSX.setColoredDataBars(s, "A1:A10"; bands=2, min_val="0", max_val="100",
+ showVal="false", direction="rightToLeft")) == 2
+
+ # gte forwarding
+ @test length(XLSX.setColoredDataBars(s, "A1:A10"; breaks=[5], gte=false, colors=["green", "red"])) == 2
+
+ # alternative range argument forms
+ @test length(XLSX.setColoredDataBars(s, XLSX.CellRange("A1:A10"); bands=2)) == 2
+ @test length(XLSX.setColoredDataBars(s, XLSX.SheetCellRange("Sheet1!A1:A10"); bands=2)) == 2
SAVE_FILES && save_outfile(xf)
end
- @testset "invalid keyword arguments" begin
+
+ @testset "setColoredDataBars errors" begin
xf = XLSX.newxlsx()
s = xf[1]
- for i in 1:5, j in 1:3
- s[i, j] = i * j
+ for i in 1:10
+ s[i, 1] = i
end
+ s["C1"] = 7
+ s["C2"] = 7
+ s["C3"] = 7
+ s["D1"] = "text"
- for (t, kw) in ((:cellIs, (operator="greaterThan", value="1")),
- (:containsText, (value="a",)),
- (:top10, ()),
- (:aboveAverage, ()),
- (:timePeriod, ()),
- (:containsErrors, ()),
- (:expression, (formula="A1>1",)),
- (:colorScale, ()),
- (:iconSet, ()),
- (:dataBar, ()))
- @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:C5", t; kw..., notAKeyword="x")
- end
+ @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "A1:A10"; bands=0)
+ @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "A1:A10"; bands=3, min_type="percentile")
+ @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "A1:A10"; bands=3, max_type="highest")
+ @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "A1:A10"; bands=3, colors=["red", "green", "blue", "cyan"])
+ @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "D1:D1") # no numeric values
+ @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "C1:C3") # all values equal
+ @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "A1:A1000") # outside dimension
SAVE_FILES && save_outfile(xf)
end
- @testset "colorScale numeric bounds and mid_col default" begin
+ @testset "setColoredDataBars CellRef form" begin
xf = XLSX.newxlsx()
s = xf[1]
- for i in 1:10, j in 1:3
- s[i, j] = i * j
- end
+ s["A1"] = 5
+ p = XLSX.setColoredDataBars(s, XLSX.CellRef("A1"); bands=1)
+ @test first.(p) == [1]
+ @test length(p) == 1
+ SAVE_FILES && save_outfile(xf)
+ end
- # explicit colours
- @test XLSX.setConditionalFormat(s, "A1:C10", :colorScale;
- min_type="num", min_val="2", min_col="tomato",
- mid_type="num", mid_val="6", mid_col="lawngreen",
- max_type="num", max_val="10", max_col="cadetblue") == 0
+ @testset "setColoredDataBars clear replaces previous bands" begin
+ xf = XLSX.newxlsx()
+ s = xf[1]
+ for i in 1:10; s[i, 1] = i; end
- # mid_col omitted: defaults to white rather than throwing
- @test XLSX.setConditionalFormat(s, "A1:C10", :colorScale;
- min_type="num", min_val="2",
- mid_type="num", mid_val="6",
- max_type="num", max_val="10") == 0
+ XLSX.setColoredDataBars(s, "A1:A10"; bands=1, colors="steelblue")
+ XLSX.setColoredDataBars(s, "A1:A10"; bands=3)
- f = "cf_colorscale_numeric_bounds_and.xlsx"
+ f = "cf_clear_replaces_previous_bands.xlsx"
XLSX.writexlsx(f, xf; overwrite=true)
x = ZipArchives.zip_readentry(ZipArchives.ZipReader(read(f)),
"xl/worksheets/sheet1.xml", String)
- # "num" types mean the values survive the `== "min"` / `== "max"` guards
- @test occursin("", x)
- @test occursin("", x)
- @test occursin("", x)
- @test occursin("rgb=\"FFFCFCFF\"", x)
+ @test count("`.
+ xf = XLSX.newxlsx()
+ xf[1]["A1:B3"] = 1
+ XLSX.setConditionalFormat(xf[1], "A1:A3", :dataBar)
+ XLSX.setConditionalFormat(xf[1], "B1:B3", :dataBar)
+ XLSX.clearConditionalFormats(xf[1], "A1:A3")
+ @test !isnothing(XLSX._x14_cf_block(XLSX.xmlroot(XLSX.get_workbook(xf), xf[1].relationship_id))) # B1:B3 remains
+ XLSX.clearConditionalFormats(xf[1], "B1:B3")
+ XLSX.writexlsx("cleared.xlsx", xf; overwrite=true)
+ SAVE_FILES && save_outfile("cleared.xlsx")
+ sheet = String(XLSX.ZipArchives.zip_readentry(XLSX.ZipArchives.ZipReader(read("cleared.xlsx")), "xl/worksheets/sheet1.xml"))
+ @test !occursin("conditionalFormattings", sheet)
+ @test isempty(XLSX.getConditionalFormats(XLSX.readxlsx("cleared.xlsx")[1]))
+ rm("cleared.xlsx")
+ end
+ end
+
+ @testset "reading back and round trips" begin
+ @testset "getConditionalFormats does not break subsequent cell reads (issue #425)" begin
+
+ function build_cf_workbook()
+ path = "cf_getconditionalformats_does_not.xlsx"
+ f = XLSX.newxlsx()
+ s = f[1]
+ for i in 1:5, j in 1:5
+ s[i, j] = i + j
+ end
+ XLSX.setConditionalFormat(s, "A1:E1", :dataBar)
+ XLSX.writexlsx(path, f; overwrite=true)
+ SAVE_FILES && save_outfile(f)
+ return path
+ end
+
+ @testset "getConditionalFormats then getdata (originally-failing order)" begin
+ path = build_cf_workbook()
+ XLSX.openxlsx(path) do xf
+ ws = xf[1]
+ cfs = XLSX.getConditionalFormats(ws)
+ @test length(cfs) == 1
+
+ data = XLSX.getdata(ws)
+ @test data[1, 1] == 2
+ @test size(data) == (5, 5)
+ end
+ isfile(path) && rm(path)
+ end
+
+ @testset "getdata then getConditionalFormats (originally-working order, must not regress)" begin
+ path = build_cf_workbook()
+ XLSX.openxlsx(path) do xf
+ ws = xf[1]
+ data = XLSX.getdata(ws)
+ @test data[1, 1] == 2
+
+ cfs = XLSX.getConditionalFormats(ws)
+ @test length(cfs) == 1
+ end
+ isfile(path) && rm(path)
+ end
+
+ @testset "getConditionalFormats then eachrow" begin
+ path = build_cf_workbook()
+ XLSX.openxlsx(path) do xf
+ ws = xf[1]
+ XLSX.getConditionalFormats(ws)
+
+ rows = collect(XLSX.eachrow(ws))
+ @test length(rows) == 5
+ @test XLSX.getdata(rows[1], 1) == 2
+ end
+ isfile(path) && rm(path)
+ end
+
+ @testset "getConditionalFormats then readtable" begin
+ path = build_cf_workbook()
+ XLSX.openxlsx(path) do xf
+ ws = xf[1]
+ XLSX.getConditionalFormats(ws)
+
+ tbl = XLSX.readtable(path, ws.name; header=false)
+ @test length(tbl.data) == 5 # 5 columns
+ @test length(tbl.data[1]) == 5 # 5 rows
+ end
+ isfile(path) && rm(path)
+ end
+
+ @testset "readxlsx: getConditionalFormats then getdata" begin
+ path = build_cf_workbook()
+ xf = XLSX.readxlsx(path)
+ ws = xf[1]
+ cfs = XLSX.getConditionalFormats(ws)
+ @test length(cfs) == 1
+ data = XLSX.getdata(ws)
+ @test data[1, 1] == 2
+ isfile(path) && rm(path)
+ end
+
+ @testset "x14 (ext) rules do not break subsequent reads" begin
+ path = "cf_getconditionalextformats.xlsx"
+ f = XLSX.newxlsx()
+ s = f[1]
+ for i in 1:5, j in 1:5
+ s[i, j] = i + j
+ end
+ # this icon set is written to the x14 extension block
+ XLSX.setConditionalFormat(s, "A1:E1", :iconSet; iconset="5Boxes")
+ XLSX.writexlsx(path, f; overwrite=true)
+
+ XLSX.openxlsx(path) do xf2
+ ws = xf2[1]
+ XLSX.getConditionalFormats(ws)
+ data = XLSX.getdata(ws)
+ @test data[1, 1] == 2
+ end
+ isfile(path) && rm(path)
+ SAVE_FILES && save_outfile(f)
+ end
+
+ end
+
+ @testset "single-cell sqref round-trips" begin
xf = XLSX.newxlsx()
s = xf[1]
- s["A1"] = 2
- s["A2"] = 100
- s["A3"] = 4
- s["A4"] = "text"
- XLSX.setConditionalFormat(s, "A1,A3", :cellIs) # the average of A1 and A3 only
- XLSX.setConditionalFormat(s, "A1:A4", :cellIs) # contiguous, with a text cell
- f = "cf_cellis_default_value_averages.xlsx"
+ s["A1"] = 1
+ @test XLSX.setConditionalFormat(s, "A1", :dataBar) == 0
+
+ f = "cf_single_cell_sqref_round_trips.xlsx"
XLSX.writexlsx(f, xf; overwrite=true)
- r = XLSX.ZipArchives.ZipReader(read(f))
- xml = XLSX.ZipArchives.zip_readentry(r, "xl/worksheets/sheet1.xml", String)
- fmls = [m[1] for m in eachmatch(r"([^<]*)", xml)]
- @test "3.0" in fmls # (2 + 4) / 2
- @test any(x -> parse(Float64, x) ≈ 106 / 3, fmls) # (2 + 100 + 4) / 3; text ignored
- @test !any(x -> occursin(r"\[|;;", x), fmls) # never a printed array
+ xf2 = XLSX.opentemplate(f)
+ rng = first(first(XLSX.getConditionalFormats(xf2[1])))
+ @test XLSX._cf_sqref(rng) == "A1:A1"
+ SAVE_FILES && save_outfile(xf2)
+ SAVE_FILES && save_outfile(xf)
isfile(f) && rm(f)
+ end
+
+ @testset "x14 block with whitespace after xm:sqref" begin
+ # Written files are indented, so a reopened x14 block has a text node after `xm:sqref`.
+ xf = XLSX.newxlsx()
+ xf[1]["A1:A3"] = 1
+ XLSX.setConditionalFormat(xf[1], "A1:A3", :dataBar)
+ XLSX.writexlsx("x14_ws.xlsx", xf; overwrite=true)
+ xf = XLSX.openxlsx("x14_ws.xlsx"; mode="rw")
+ XLSX.setConditionalFormat(xf[1], "A1:A3", :dataBar)
+ XLSX.setConditionalFormat(xf[1], "A1:A3", :dataBar)
+ @test length(XLSX.getConditionalFormats(xf[1])) == 3
+ XLSX.writexlsx("x14_ws.xlsx", xf; overwrite=true)
+ SAVE_FILES && save_outfile("x14_ws.xlsx")
+ sheet = String(XLSX.ZipArchives.zip_readentry(XLSX.ZipArchives.ZipReader(read("x14_ws.xlsx")), "xl/worksheets/sheet1.xml"))
+ @test count("A1:A3", sheet) == 1
+ @test count("]*id=\"(\{[^\"]+\})\"",
+ String(XLSX.ZipArchives.zip_readentry(XLSX.ZipArchives.ZipReader(read(f)), "xl/worksheets/sheet1.xml")))]
+ xf = XLSX.newxlsx()
+ xf[1]["A1:B3"] = 1
+ XLSX.setConditionalFormat(xf[1], "A1:A3", :dataBar)
+ XLSX.setConditionalFormat(xf[1], "A1:A3", :iconSet; iconset="3Stars")
+ XLSX.writexlsx("ids.xlsx", xf; overwrite=true)
+ for out in ("ids2.xlsx", "ids3.xlsx")
+ xf = XLSX.openxlsx("ids.xlsx"; mode="rw")
+ XLSX.setConditionalFormat(xf[1], "B1:B3", :dataBar)
+ XLSX.setConditionalFormat(xf[1], "B1:B3", :iconSet; iconset="3Stars")
+ XLSX.writexlsx(out, xf; overwrite=true)
+ end
+ SAVE_FILES && save_outfile("ids2.xlsx")
+ @test length(cfids("ids2.xlsx")) == 4 && allunique(cfids("ids2.xlsx"))
+ @test cfids("ids2.xlsx") == cfids("ids3.xlsx") # same steps, same ids
+ foreach(rm, ("ids.xlsx", "ids2.xlsx", "ids3.xlsx"))
+ end
+
+ @testset "first rule added to an existing range of a reopened file is kept" begin
+ # The sheet is still a raw string on reopening, so `allCfs` parses a throwaway copy;
+ # the new rule must go into the live tree.
+ xf = XLSX.newxlsx()
+ xf[1]["A1:A3"] = 1
+ XLSX.setConditionalFormat(xf[1], "A1:A3", :cellIs)
+ XLSX.writexlsx("reopen_cf.xlsx", xf; overwrite=true)
+ xf = XLSX.openxlsx("reopen_cf.xlsx"; mode="rw")
+ XLSX.setConditionalFormat(xf[1], "A1:A3", :cellIs)
+ @test length(XLSX.getConditionalFormats(xf[1])) == 2
+ XLSX.writexlsx("reopen_cf.xlsx", xf; overwrite=true)
+ SAVE_FILES && save_outfile("reopen_cf.xlsx")
+ @test length(XLSX.getConditionalFormats(XLSX.readxlsx("reopen_cf.xlsx")[1])) == 2
+ rm("reopen_cf.xlsx")
+ end
end
- @testset "clear replaces previous bands" begin
+
+ @testset "every color form in conditional formats" begin
xf = XLSX.newxlsx()
s = xf[1]
- for i in 1:10; s[i, 1] = i; end
-
- XLSX.setColoredDataBars(s, "A1:A10"; bands=1, colors="steelblue")
- XLSX.setColoredDataBars(s, "A1:A10"; bands=3)
-
- f = "cf_clear_replaces_previous_bands.xlsx"
- XLSX.writexlsx(f, xf; overwrite=true)
- x = ZipArchives.zip_readentry(ZipArchives.ZipReader(read(f)),
- "xl/worksheets/sheet1.xml", String)
-
- @test count(" :accent4, "bold" => "true"],
+ fill=["pattern" => "solid", "bgColor" => XLSX.SchemeColor(:accent5; lumMod=75)],
+ border=["style" => "thin", "color" => colorant"blue"])
+ XLSX.setConditionalFormat(s, "D1:D10", :cellIs; operator="lessThan", value="0",
+ font=["color" => "red"]) # all-String pairs still work
+ XLSX.setColoredDataBars(s, "E1:E10"; bands=2, colors=[:accent1, XLSX.SchemeColor(:accent6)])
+ XLSX.setColoredDataBars(s, "F1:F10"; bands=3, colors=[:green, colorant"red"])
+
+ io = IOBuffer()
+ XLSX.writexlsx(io, xf)
+ zr = ZipArchives.ZipReader(take!(io))
+ sheet = ZipArchives.zip_readentry(zr, "xl/worksheets/sheet1.xml", String)
+ styles = ZipArchives.zip_readentry(zr, "xl/styles.xml", String)
+
+ # color scale
+ @test occursin(r"\s*\s*", sheet)
+ # data bar, main and x14 parts
+ @test occursin("", sheet)
+ @test occursin("", sheet)
+ @test occursin("", sheet)
+ @test occursin("", sheet)
+ @test occursin("", sheet)
+ # dxf font, fill and border
+ @test occursin("", styles)
+ @test occursin("", styles)
+ @test occursin("", styles)
+ @test occursin("", styles)
+ # colored data bars: one theme color per band
+ @test occursin("", sheet) && occursin("", sheet)
+
+ # Errors
+ @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:A10", :colorScale; min_col="notacolor")
+ @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:A10", :colorScale;
+ min_col=XLSX.SchemeColor(:accent1; shade=50))
+ @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "C1:C10", :cellIs; operator="greaterThan", value="0",
+ font=["color" => 42])
+ # theme colors cannot be interpolated between
+ @test_throws XLSX.XLSXError XLSX.setColoredDataBars(s, "E1:E10"; bands=3, colors=[:accent1, :red])
+ @test XLSX._band_colors([:accent1, colorant"red"], 2) == [:accent1, "FFFF0000"]
SAVE_FILES && save_outfile(xf)
- isfile(f) && rm(f)
- end
- @testset "coverage: clearConditionalFormats with a string range" begin
- xf = XLSX.newxlsx()
- ws = xf[1]
- ws["A1:A5"] = reshape(collect(1:5), 5, 1)
- XLSX.setConditionalFormat(ws, "A1:A5", :colorScale)
- @test !isempty(XLSX.getConditionalFormats(ws))
- XLSX.clearConditionalFormats(ws, "A1:A5")
- @test isempty(XLSX.getConditionalFormats(ws))
- SAVE_FILES && save_outfile(xf)
- end
- @testset "x14 block with whitespace after xm:sqref" begin
- # Written files are indented, so a reopened x14 block has a text node after `xm:sqref`.
- xf = XLSX.newxlsx()
- xf[1]["A1:A3"] = 1
- XLSX.setConditionalFormat(xf[1], "A1:A3", :dataBar)
- XLSX.writexlsx("x14_ws.xlsx", xf; overwrite=true)
- xf = XLSX.openxlsx("x14_ws.xlsx"; mode="rw")
- XLSX.setConditionalFormat(xf[1], "A1:A3", :dataBar)
- XLSX.setConditionalFormat(xf[1], "A1:A3", :dataBar)
- @test length(XLSX.getConditionalFormats(xf[1])) == 3
- XLSX.writexlsx("x14_ws.xlsx", xf; overwrite=true)
- SAVE_FILES && save_outfile("x14_ws.xlsx")
- sheet = String(XLSX.ZipArchives.zip_readentry(XLSX.ZipArchives.ZipReader(read("x14_ws.xlsx")), "xl/worksheets/sheet1.xml"))
- @test count("A1:A3", sheet) == 1
- @test count(" XLSX.setConditionalFormat(s, "A1:A5", :iconSet; iconset="5Arrows", mid2_type="bogus")))
+ @test occursin("mid2_val", msg(() -> XLSX.setConditionalFormat(s, "A1:A5", :iconSet; iconset="5Arrows",
+ mid2_type="num", mid2_val="notanumber")))
+
+ # `reverse` reversed the caller's own `icon_list`
+ icons = [1, 2, 3]
+ XLSX.setConditionalFormat(s, "A1:A5", :iconSet; iconset="Custom", reverse="true",
+ min_type="num", min_val="1", max_type="num", max_val="5", icon_list=icons)
+ @test icons == [1, 2, 3]
end
- @testset "clearing every x14 rule leaves no empty conditionalFormattings" begin
- # Excel refuses to open a file with an empty ``.
+
+ # The rule-based setters read their keywords the same way.
+ @testset "rule keywords take strings or Symbols" begin
xf = XLSX.newxlsx()
- xf[1]["A1:B3"] = 1
- XLSX.setConditionalFormat(xf[1], "A1:A3", :dataBar)
- XLSX.setConditionalFormat(xf[1], "B1:B3", :dataBar)
- XLSX.clearConditionalFormats(xf[1], "A1:A3")
- @test !isnothing(XLSX._x14_cf_block(XLSX.xmlroot(XLSX.get_workbook(xf), xf[1].relationship_id))) # B1:B3 remains
- XLSX.clearConditionalFormats(xf[1], "B1:B3")
- XLSX.writexlsx("cleared.xlsx", xf; overwrite=true)
- SAVE_FILES && save_outfile("cleared.xlsx")
- sheet = String(XLSX.ZipArchives.zip_readentry(XLSX.ZipArchives.ZipReader(read("cleared.xlsx")), "xl/worksheets/sheet1.xml"))
- @test !occursin("conditionalFormattings", sheet)
- @test isempty(XLSX.getConditionalFormats(XLSX.readxlsx("cleared.xlsx")[1]))
- rm("cleared.xlsx")
+ s = xf[1]
+ s["A1:A5"] = collect(1:5)
+ XLSX.setConditionalFormat(s, "A1:A5", :cellIs; operator=:between, value="2", value2="4")
+ XLSX.setConditionalFormat(s, "A1:A5", :top10; operator=:bottomN, value="2")
+ @test length(XLSX.getConditionalFormats(s)) == 2
+ @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:A5", :cellIs; value=3)
+ @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:A5", :timePeriod; operator=7)
end
- @testset "x14 rule ids stay unique, and deterministic, after reopening" begin
- cfids(f) = [m[1] for m in eachmatch(r"]*id=\"(\{[^\"]+\})\"",
- String(XLSX.ZipArchives.zip_readentry(XLSX.ZipArchives.ZipReader(read(f)), "xl/worksheets/sheet1.xml")))]
- xf = XLSX.newxlsx()
- xf[1]["A1:B3"] = 1
- XLSX.setConditionalFormat(xf[1], "A1:A3", :dataBar)
- XLSX.setConditionalFormat(xf[1], "A1:A3", :iconSet; iconset="3Stars")
- XLSX.writexlsx("ids.xlsx", xf; overwrite=true)
- for out in ("ids2.xlsx", "ids3.xlsx")
- xf = XLSX.openxlsx("ids.xlsx"; mode="rw")
- XLSX.setConditionalFormat(xf[1], "B1:B3", :dataBar)
- XLSX.setConditionalFormat(xf[1], "B1:B3", :iconSet; iconset="3Stars")
- XLSX.writexlsx(out, xf; overwrite=true)
+
+ @testset "every setCf* setter accepts the same target forms" begin
+ targets(f) = Set(Base.tuple_type_tail(m.sig) for m in methods(f))
+ for f in (XLSX.setCfContainsText, XLSX.setCfTop10, XLSX.setCfAboveAverage, XLSX.setCfTimePeriod,
+ XLSX.setCfContainsBlankErrorUniqDup, XLSX.setCfFormula, XLSX.setCfColorScale,
+ XLSX.setCfIconSet, XLSX.setCfDataBar)
+ @test targets(f) == targets(XLSX.setCfCellIs)
end
- SAVE_FILES && save_outfile("ids2.xlsx")
- @test length(cfids("ids2.xlsx")) == 4 && allunique(cfids("ids2.xlsx"))
- @test cfids("ids2.xlsx") == cfids("ids3.xlsx") # same steps, same ids
- foreach(rm, ("ids.xlsx", "ids2.xlsx", "ids3.xlsx"))
- end
- @testset "first rule added to an existing range of a reopened file is kept" begin
- # The sheet is still a raw string on reopening, so `allCfs` parses a throwaway copy;
- # the new rule must go into the live tree.
- xf = XLSX.newxlsx()
- xf[1]["A1:A3"] = 1
- XLSX.setConditionalFormat(xf[1], "A1:A3", :cellIs)
- XLSX.writexlsx("reopen_cf.xlsx", xf; overwrite=true)
- xf = XLSX.openxlsx("reopen_cf.xlsx"; mode="rw")
- XLSX.setConditionalFormat(xf[1], "A1:A3", :cellIs)
- @test length(XLSX.getConditionalFormats(xf[1])) == 2
- XLSX.writexlsx("reopen_cf.xlsx", xf; overwrite=true)
- SAVE_FILES && save_outfile("reopen_cf.xlsx")
- @test length(XLSX.getConditionalFormats(XLSX.readxlsx("reopen_cf.xlsx")[1])) == 2
- rm("reopen_cf.xlsx")
end
end
+
+@testset "review fixes: dxf fonts and text rules" begin
+ f = XLSX.newxlsx(); s = f[1]; s["A1:A3"] = 1
+ XLSX.setConditionalFormat(s, "A1:A3", :cellIs; value="0", font=["bold"=>"true", "italic"=>"true", "under"=>"double"])
+ xroot = XLSX.styles_xmlroot(f.workbook)
+ i, j = XLSX.get_idces(xroot, "styleSheet", "dxfs")
+ font = XLSX.first_element_with_tag(last(XLSX.xml_elements(xroot[i][j])), "font")
+ @test Set((XLSX.localname(n), XLSX.get_attr(n, "val")) for n in XLSX.xml_elements(font)) == Set([("b", ""), ("i", ""), ("u", "double")])
+ @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:A3", :cellIs; value="0", font=["under"=>"wavy"])
+
+ s["B1:B3"] = "x"
+ XLSX.setConditionalFormat(s, "B1:B3", :containsText; value="say \"hi\"")
+ XLSX.setConditionalFormat(s, "B1:B3", :beginsWith; value="\$C\$1")
+ rules = [r for cf in XLSX.allCfs(s) for r in XLSX.xml_elements(cf) if cf["sqref"] == "B1:B3"]
+ formulas = [XLSX.child_text(r, "formula") for r in rules]
+ @test "NOT(ISERROR(SEARCH(\"say \"\"hi\"\"\",B1)))" in formulas
+ @test "LEFT(B1,LEN(\$C\$1))=\$C\$1" in formulas
+
+ # the type alone chooses the rule; a conflicting operator is refused
+ s["C1:C3"] = "x"
+ XLSX.setConditionalFormat(s, "C1:C3", :endsWith; value="z")
+ rule = only(r for cf in XLSX.allCfs(s) for r in XLSX.xml_elements(cf) if cf["sqref"] == "C1:C3")
+ @test rule["type"] == "endsWith"
+ @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "C1:C3", :endsWith; value="z", operator="beginsWith")
+ SAVE_FILES && save_outfile(f)
+end
diff --git a/test/test_files/Copy-Add_tests.jl b/test/test_files/Copy-Add_tests.jl
index 2f340e85..fc1fbab6 100644
--- a/test/test_files/Copy-Add_tests.jl
+++ b/test/test_files/Copy-Add_tests.jl
@@ -1,356 +1,655 @@
@testset "add/copy sheet!" begin
+ # Chart references to a deleted sheet, invalidated as Excel does it.
+
+ function _chart_elements(node, name)
+ out = []
+ for ch in XLSX.XML.eachelement(node)
+ XLSX.localname(ch) == name && push!(out, ch)
+ append!(out, _chart_elements(ch, name))
+ end
+ return out
+ end
+
+ _chart_f_texts(root) = [something(XLSX.XML.is_simple_value(f), "") for f in _chart_elements(root, "f")]
@testset "addsheet!" begin
+ @testset "adding named sheets" begin
- new_filename = "template_with_new_sheet.xlsx"
- f = XLSX.open_empty_template()
- s = XLSX.addsheet!(f, "new_sheet")
- s["A1"] = 10
- @test XLSX.sheetnames(f) == ["Sheet1", "new_sheet"]
- XLSX.writexlsx(new_filename, f, overwrite=true)
- SAVE_FILES && save_outfile(new_filename)
+ new_filename = "template_with_new_sheet.xlsx"
+ f = XLSX.open_empty_template()
+ s = XLSX.addsheet!(f, "new_sheet")
+ s["A1"] = 10
+ @test XLSX.sheetnames(f) == ["Sheet1", "new_sheet"]
+ XLSX.writexlsx(new_filename, f, overwrite=true)
+ SAVE_FILES && save_outfile(new_filename)
- big_sheetname = "aaaaaaaaaabbbbbbbbbbccccccccccd"
- s2 = XLSX.addsheet!(f, big_sheetname)
+ big_sheetname = "aaaaaaaaaabbbbbbbbbbccccccccccd"
+ s2 = XLSX.addsheet!(f, big_sheetname)
- XLSX.writexlsx(new_filename, f, overwrite=true)
- SAVE_FILES && save_outfile(new_filename)
- fx = XLSX.opentemplate(new_filename)
- @test XLSX.sheetnames(f) == ["Sheet1", "new_sheet", big_sheetname]
- SAVE_FILES && save_outfile(fx)
+ XLSX.writexlsx(new_filename, f, overwrite=true)
+ SAVE_FILES && save_outfile(new_filename)
+ fx = XLSX.opentemplate(new_filename)
+ @test XLSX.sheetnames(fx) == ["Sheet1", "new_sheet", big_sheetname]
+ SAVE_FILES && save_outfile(fx)
- end
+ end
+
+ @testset "invalid sheet names" begin
+
+ f = XLSX.open_empty_template()
+ s = XLSX.addsheet!(f, "new_sheet")
+ s["A1"] = 10
+ invalid_names = [
+ "aaaaaaaaaabbbbbbbbbbccccccccccd1",
+ "abc:def",
+ "abcdef/",
+ "\\aaaa",
+ "hey?you",
+ "[mysheet]",
+ "asteri*"
+ ]
+
+ for invalid_name in invalid_names
+ @test_throws XLSX.XLSXError XLSX.addsheet!(f, invalid_name)
+ end
- @testset "invalid sheet names" begin
-
- f = XLSX.open_empty_template()
- s = XLSX.addsheet!(f, "new_sheet")
- s["A1"] = 10
- invalid_names = [
- "aaaaaaaaaabbbbbbbbbbccccccccccd1",
- "abc:def",
- "abcdef/",
- "\\aaaa",
- "hey?you",
- "[mysheet]",
- "asteri*"
- ]
-
- for invalid_name in invalid_names
- @test_throws XLSX.XLSXError XLSX.addsheet!(f, invalid_name)
end
+ @testset "addsheet! after reopening gives a fresh xr:uid" begin
+ # `uuid_rng` has a fixed seed, so a reopened file replays the ids it was first written with.
+ uid(f, e) = match(r"xr:uid=\"(\{[^\"]+\})\"", String(XLSX.ZipArchives.zip_readentry(XLSX.ZipArchives.ZipReader(read(f)), e)))[1]
+ xf = XLSX.newxlsx()
+ XLSX.addsheet!(xf, "S2")
+ XLSX.writexlsx("uid.xlsx", xf; overwrite=true)
+ for out in ("uid2.xlsx", "uid3.xlsx")
+ xf = XLSX.openxlsx("uid.xlsx"; mode="rw")
+ XLSX.addsheet!(xf, "S3")
+ XLSX.writexlsx(out, xf; overwrite=true)
+ end
+ SAVE_FILES && save_outfile("uid2.xlsx")
+ uids = [uid("uid2.xlsx", "xl/worksheets/sheet$k.xml") for k in 1:3]
+ @test allunique(uids)
+ @test uids[3] == uid("uid3.xlsx", "xl/worksheets/sheet3.xml") # still deterministic
+ @test XLSX._unique_guid(XLSX.Random.Xoshiro(1), Set{String}()) != XLSX._unique_guid(XLSX.Random.Xoshiro(1), Set([XLSX._unique_guid(XLSX.Random.Xoshiro(1), Set{String}())]))
+ foreach(rm, ("uid.xlsx", "uid2.xlsx", "uid3.xlsx"))
+ end
end
@testset "copysheet!" begin
+ @testset "values, defined names, borders and conditional formats are copied" begin
+
+ f = XLSX.newxlsx()
+ XLSX.renamesheet!(f["Sheet1"], "new_name")
+ XLSX.addsheet!(f)
+ for x = 1:10, y = 1:10
+ f["Sheet1"][x, y] = x + y
+ f["new_name"][x, y] = x * y
+ end
+ XLSX.addDefinedName(f["new_name"], "new_name_range", "A1:B10")
+ XLSX.addDefinedName(f["Sheet1"], "Sheet1_range", "C1:D10")
+ XLSX.setBorder(f["new_name"], "A1:D10"; allsides=["style" => "thin", "color" => "red"])
+ XLSX.setBorder(f["Sheet1"], "A1:D10"; allsides=["style" => "thin", "color" => "red"])
+ XLSX.setConditionalFormat(f["new_name"], "A1:D10", :colorScale)
+
+ s3 = XLSX.copysheet!(f["new_name"], "copied_sheet")
+ @test s3.name == "copied_sheet"
+ @test s3["A1"] == 1
+ @test s3[5, 5] == 25
+ @test s3[10, 10] == 100
+ @test XLSX.get_workbook(s3).worksheet_names == XLSX.get_workbook(f["new_name"]).worksheet_names
+ @test XLSX.getConditionalFormats(s3) == XLSX.getConditionalFormats(f["new_name"])
+ @test XLSX.getBorder(s3, "C5").border == XLSX.getBorder(f["new_name"], "C5").border
+
+ # Check that the original sheet is unchanged
+ s2 = f["new_name"]
+ @test s2["A1"] == 1
+ @test s2[5, 5] == 25
+ @test s2[10, 10] == 100
+
+ s4 = XLSX.copysheet!(s3)
+ @test s4.name == "copied_sheet (copy)"
+ @test s4["A1"] == 1
+ @test s4[5, 5] == 25
+ @test s4[10, 10] == 100
+
+ @test XLSX.get_workbook(s4).worksheet_names == XLSX.get_workbook(f["new_name"]).worksheet_names
+ XLSX.setBorder(s4, "F1:H10"; allsides=["style" => "thin", "color" => "green"])
+ XLSX.setConditionalFormat(s4, "F1:H10", :colorScale; colorscale="redyellowgreen")
+
+ XLSX.writexlsx("copied_sheets.xlsx", f, overwrite=true)
+ SAVE_FILES && save_outfile("copied_sheets.xlsx")
+ f = XLSX.opentemplate("copied_sheets.xlsx")
+ @test XLSX.sheetnames(f) == ["new_name", "Sheet1", "copied_sheet", "copied_sheet (copy)"]
+ @test XLSX.get_workbook(f["copied_sheet"]).worksheet_names == XLSX.get_workbook(f["new_name"]).worksheet_names
+ @test XLSX.getConditionalFormats(f["copied_sheet (copy)"]) == XLSX.getConditionalFormats(s4)
+ @test XLSX.getBorder(f["copied_sheet (copy)"], "C5").border == XLSX.getBorder(f["new_name"], "C5").border
+ @test XLSX.getBorder(f["copied_sheet (copy)"], "G5").border == XLSX.getBorder(s4, "G5").border
+ SAVE_FILES && save_outfile(f)
- f = XLSX.newxlsx()
- XLSX.renamesheet!(f["Sheet1"], "new_name")
- XLSX.addsheet!(f)
- for x = 1:10, y = 1:10
- f["Sheet1"][x, y] = x + y
- f["new_name"][x, y] = x * y
end
- XLSX.addDefinedName(f["new_name"], "new_name_range", "A1:B10")
- XLSX.addDefinedName(f["Sheet1"], "Sheet1_range", "C1:D10")
- XLSX.setBorder(f["new_name"], "A1:D10"; allsides=["style" => "thin", "color" => "red"])
- XLSX.setBorder(f["Sheet1"], "A1:D10"; allsides=["style" => "thin", "color" => "red"])
- XLSX.setConditionalFormat(f["new_name"], "A1:D10", :colorScale)
-
- s3 = XLSX.copysheet!(f["new_name"], "copied_sheet")
- @test s3.name == "copied_sheet"
- @test s3["A1"] == 1
- @test s3[5, 5] == 25
- @test s3[10, 10] == 100
- @test XLSX.get_workbook(s3).worksheet_names == XLSX.get_workbook(f["new_name"]).worksheet_names
- @test XLSX.getConditionalFormats(s3) == XLSX.getConditionalFormats(f["new_name"])
- @test XLSX.getBorder(s3, "C5").border == XLSX.getBorder(f["new_name"], "C5").border
-
- # Check that the original sheet is unchanged
- s2 = f["new_name"]
- @test s2["A1"] == 1
- @test s2[5, 5] == 25
- @test s2[10, 10] == 100
-
- s4 = XLSX.copysheet!(s3)
- @test s4.name == "copied_sheet (copy)"
- @test s4["A1"] == 1
- @test s4[5, 5] == 25
- @test s4[10, 10] == 100
-
- @test XLSX.get_workbook(s4).worksheet_names == XLSX.get_workbook(f["new_name"]).worksheet_names
- XLSX.setBorder(s4, "F1:H10"; allsides=["style" => "thin", "color" => "green"])
- XLSX.setConditionalFormat(s4, "F1:H10", :colorScale; colorscale="redyellowgreen")
-
- XLSX.writexlsx("copied_sheets.xlsx", f, overwrite=true)
- SAVE_FILES && save_outfile("copied_sheets.xlsx")
- f = XLSX.opentemplate("copied_sheets.xlsx")
- @test XLSX.sheetnames(f) == ["new_name", "Sheet1", "copied_sheet", "copied_sheet (copy)"]
- @test XLSX.get_workbook(f["copied_sheet"]).worksheet_names == XLSX.get_workbook(f["new_name"]).worksheet_names
- @test XLSX.getConditionalFormats(f["copied_sheet (copy)"]) == XLSX.getConditionalFormats(s4)
- @test XLSX.getBorder(f["copied_sheet (copy)"], "C5").border == XLSX.getBorder(f["new_name"], "C5").border
- @test XLSX.getBorder(f["copied_sheet (copy)"], "G5").border == XLSX.getBorder(s4, "G5").border
- SAVE_FILES && save_outfile(f)
- end
- isfile("copied_sheets.xlsx") && rm("copied_sheets.xlsx")
+ @testset "copysheet! clones and repoints c: chart parts" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "chart_basic.xlsx"))
+ XLSX.copysheet!(f["Data"], "Copy")
+
+ charts = XLSX.Charts.getCharts(f)
+ @test length(charts) == 2
+ @test allunique(c.path for c in charts)
+ @test issetequal((c.sheet for c in charts), ["Data", "Copy"])
+
+ cp = only(filter(c -> c.sheet == "Copy", charts))
+ orig = only(filter(c -> c.sheet == "Data", charts))
+
+ # The copy plots the copied sheet. getChartRanges returns one named
+ # tuple per series for a `c:` chart, so the ranges are in its fields.
+ cp_ranges = XLSX.Charts.getChartRanges(cp)
+ @test [string(r.categories) for r in cp_ranges] == ["Copy!A2:A5", "Copy!A2:A5"]
+ @test [string(r.values) for r in cp_ranges] == ["Copy!B2:B5", "Copy!C2:C5"]
+
+ # Series name refs are repointed too, not just categories and values.
+ @test all(s -> startswith(s.name_ref.ref, "Copy!"), XLSX.Charts.getChartSeries(cp))
+
+ # The original is untouched — catches copynode aliasing the two trees.
+ orig_ranges = XLSX.Charts.getChartRanges(orig)
+ @test [string(r.categories) for r in orig_ranges] == ["Data!A2:A5", "Data!A2:A5"]
+ @test [string(r.values) for r in orig_ranges] == ["Data!B2:B5", "Data!C2:C5"]
+ @test all(s -> startswith(s.name_ref.ref, "Data!"), XLSX.Charts.getChartSeries(orig))
+
+ # The cache is deliberately left alone, so the copy still reads.
+ @test XLSX.Charts.getChartData(cp) isa XLSX.DataTable
+
+ # And it survives a round trip.
+ g = XLSX.writexlsx("mytest.xlsx", f; overwrite=true)
+ h = XLSX.readxlsx(g)
+ cp2 = only(filter(c -> c.sheet == "Copy", XLSX.Charts.getCharts(h)))
+ @test [string(r.values) for r in XLSX.Charts.getChartRanges(cp2)] ==
+ ["Copy!B2:B5", "Copy!C2:C5"]
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "deletesheet!" begin
+ @testset "copysheet! with a chartEx chart" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "chart_ex.xlsx"))
+ XLSX.copysheet!(f["Data"], "Copy")
+
+ charts = XLSX.Charts.getCharts(f)
+ @test length(charts) == 2
+ @test all(c -> c isa XLSX.Charts.ChartEx, charts)
+ @test issetequal((c.sheet for c in charts), ["Data", "Copy"])
+ @test allunique(c.path for c in charts)
+ @test all(c -> XLSX.Charts.getChartType(c) === :waterfall, charts)
+
+ cp = only(filter(c -> c.sheet == "Copy", charts))
+
+ # The copy gets its own hidden defined names, pointing at the copy.
+ @test XLSX.Charts._cx_refs(cp) == ["_xlchart.v2.0", "_xlchart.v2.1"]
+ @test all(r -> startswith(string(r), "Copy!"), XLSX.Charts.getChartRanges(cp))
+
+ # The original's names and ranges are untouched.
+ orig = only(filter(c -> c.sheet == "Data", charts))
+ @test XLSX.Charts._cx_refs(orig) == ["_xlchart.v1.0", "_xlchart.v1.1"]
+ @test all(r -> startswith(string(r), "Data!"), XLSX.Charts.getChartRanges(orig))
+
+ # Two names added, all still hidden and none user-visible.
+ all_names = XLSX.getAllDefinedNames(f; include_system=true)
+ @test length(all_names) == 11
+ @test all(dn -> dn.hidden, all_names)
+ @test isempty(XLSX.getDefinedNames(f))
+
+ g = XLSX.writexlsx("mytest.xlsx", f; overwrite=true)
+ h = XLSX.readxlsx(g)
+ @test length(XLSX.Charts.getCharts(h)) == 2
+ @test allunique(c.path for c in XLSX.Charts.getCharts(h))
+ SAVE_FILES && save_outfile(f)
+ end
- new_filename = "template_with_new_sheet.xlsx"
- big_sheetname = "aaaaaaaaaabbbbbbbbbbccccccccccd"
- fx = XLSX.opentemplate(new_filename)
- XLSX.deletesheet!(fx, big_sheetname)
- @test XLSX.sheetnames(fx) == ["Sheet1", "new_sheet"]
- XLSX.writexlsx(new_filename, fx, overwrite=true)
- SAVE_FILES && save_outfile(new_filename)
- f = XLSX.readxlsx(new_filename)
- @test XLSX.sheetnames(f) == ["Sheet1", "new_sheet"]
-
- f = XLSX.opentemplate(joinpath(data_directory, "general.xlsx"))
- sc = XLSX.sheetcount(f)
- XLSX.deletesheet!(f, "empty")
- @test XLSX.sheetcount(f) == sc - 1 # Check it's gone.
- @test XLSX.hassheet(f, "empty") == false # Check it's gone.
- @test_throws XLSX.XLSXError XLSX.deletesheet!(f, "empty") # Already deleted.
- @test_throws XLSX.XLSXError XLSX.deletesheet!(f, "nosuchsheet") # Never there.
- s2 = XLSX.addsheet!(f, "this_now")
- @test XLSX.sheetnames(f) == ["general", "table3", "table4", "table", "table2", "table5", "table6", "table7", "lookup", "header_error", "named_ranges_2", "named_ranges", "this_now"]
- XLSX.writexlsx(new_filename, f, overwrite=true)
- SAVE_FILES && save_outfile(new_filename)
-
- f = XLSX.opentemplate(new_filename)
- @test XLSX.sheetnames(f) == ["general", "table3", "table4", "table", "table2", "table5", "table6", "table7", "lookup", "header_error", "named_ranges_2", "named_ranges", "this_now"]
- XLSX.deletesheet!(f, "named_ranges")
- XLSX.deletesheet!(f["general"])
- @test XLSX.sheetnames(f) == ["table3", "table4", "table", "table2", "table5", "table6", "table7", "lookup", "header_error", "named_ranges_2", "this_now"]
- XLSX.writexlsx(new_filename, f, overwrite=true)
- SAVE_FILES && save_outfile(new_filename)
- dtable = XLSX.readtable(new_filename, "table4", "F:G")
- data, col_names = dtable.data, dtable.column_labels
- @test col_names == [:H2, :H3]
- test_data = Any[Any["C3", missing], Any[missing, "D4"]]
- check_test_data(data, test_data)
- @test XLSX.deletesheet!(f, 1) === f
- @test XLSX.sheetnames(f) == ["table4", "table", "table2", "table5", "table6", "table7", "lookup", "header_error", "named_ranges_2", "this_now"]
- XLSX.writexlsx(new_filename, f, overwrite=true)
- SAVE_FILES && save_outfile(new_filename)
- dtable = XLSX.readtable(new_filename, "table4", "F:G")
- data, col_names = dtable.data, dtable.column_labels
- @test col_names == [:H2, :H3]
- test_data = Any[Any["C3", missing], Any[missing, "D4"]]
- check_test_data(data, test_data)
-
- f = XLSX.opentemplate(joinpath(data_directory, "Book_1904.xlsx")) # Only one sheet - can't delete
- @test_throws XLSX.XLSXError XLSX.deletesheet!(f, 1)
- s = f[1]
- @test_throws XLSX.XLSXError XLSX.deletesheet!(s)
- @test_throws XLSX.XLSXError XLSX.deletesheet!(f, "Sheet1")
-
- SAVE_FILES && save_outfile(f)
- f = XLSX.openxlsx(joinpath(data_directory, "deletesheet.xlsx"), mode="rw")
- XLSX.deletesheet!(f[1])
- @test XLSX.getcell(f[1], "A1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A1"), "e", "", "#REF!", "", true)
- @test XLSX.getFormula(f[1], "A1") == "=#REF!+#REF!"
- SAVE_FILES && save_outfile(f)
-
- isfile("template_with_new_sheet.xlsx") && rm("template_with_new_sheet.xlsx")
-
- # XLSX.jl cannot currently create formula references that refer to other sheets, so we have to manually
- # insert them into the workbook's formula cache to test. Excel cannot read such a workbook and deletes these
- # formulas on opening. Tests are for completeness only
- f=XLSX.openxlsx("renamedelete.xlsx", mode="w")
- s=f[1]
- s[1:10, 1] = collect(1:10)
- XLSX.addsheet!(f)
- s2=f[2]
- s2["A1:B4"]=""
- XLSX.setFormula(s2, "A1", "=Sheet1!A1+10")
- XLSX.getcell(s2, "B1").formula=true
- XLSX.getcell(s2, "B2").formula=true
- XLSX.getcell(s2, "B3").formula=true
- XLSX.getcell(s2, "B4").formula=true
- w=XLSX.get_workbook(s2)
- w.formulas[XLSX.SheetCellRef("Sheet2!B1")] = XLSX.ReferencedFormula("=Sheet1!A1+10", 0, "B1:B4", nothing)
- w.formulas[XLSX.SheetCellRef("Sheet2!B2")] = XLSX.FormulaReference(0, nothing)
- w.formulas[XLSX.SheetCellRef("Sheet2!B3")] = XLSX.FormulaReference(0, nothing)
- w.formulas[XLSX.SheetCellRef("Sheet2!B4")] = XLSX.FormulaReference(0, nothing)
-
- XLSX.deletesheet!(s)
- @test XLSX.hassheet(f, "Sheet1") == false
- @test XLSX.getcell(s2, "A1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A1"), "e", "", "#REF!", "", true)
- @test XLSX.getcell(s2, "B1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("B1"), "e", "", "#REF!", "", true)
- @test XLSX.getcell(s2, "B2") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("B2"), "e", "", "#REF!", "", true)
- @test XLSX.getcell(s2, "B4") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("B4"), "e", "", "#REF!", "", true)
- @test XLSX.getFormula(s2, "A1") == "=#REF!+10"
- @test XLSX.getFormula(s2, "B1") == "=#REF!+10"
- @test XLSX.getFormula(s2, "B2") == "=#REF!+10"
- @test XLSX.getFormula(s2, "B4") == "=#REF!+10"
- @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("A1")) == XLSX.Formula("=#REF!+10", nothing, nothing, nothing)
- @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("B1")) == XLSX.ReferencedFormula("=#REF!+10", 0, "B1:B4", nothing)
- @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("B2")) == XLSX.FormulaReference(0, nothing)
- @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("B4")) == XLSX.FormulaReference(0, nothing)
- SAVE_FILES && save_outfile(f)
+ @testset "copysheet! keeps a shared chartEx name shared" begin
+ xf = XLSX.openxlsx(joinpath(data_directory, "chartex_layouts.xlsx"); mode="rw")
+ XLSX.copysheet!(xf["pareto"], "pareto2")
+ XLSX.writexlsx("mytest.xlsx", xf, overwrite=true)
+ SAVE_FILES && save_outfile("mytest.xlsx")
- end
- @testset "deletesheet! keeps the active tab on the same sheet" begin
- xf = XLSX.newxlsx("A")
- XLSX.addsheet!(xf, "B")
- XLSX.addsheet!(xf, "C")
- active(x) = parse(Int, XLSX.XML.attributes(first(XLSX.elements_with_tag(first(XLSX.elements_with_tag(
- XLSX.xml_root_element(XLSX.xmlroot(x, "xl/workbook.xml")), "bookViews")), "workbookView")))["activeTab"])
- XLSX.xmlroot(xf, "xl/workbook.xml") # ensure loaded
- wv = first(XLSX.elements_with_tag(first(XLSX.elements_with_tag(
- XLSX.xml_root_element(XLSX.xmlroot(xf, "xl/workbook.xml")), "bookViews")), "workbookView"))
- wv["activeTab"] = "2" # C is active
- XLSX.deletesheet!(xf, "A")
- @test active(xf) == 1 # still C
- XLSX.deletesheet!(xf, "C")
- @test active(xf) == 0 # C gone: clamped to B
- SAVE_FILES && save_outfile(xf)
- end
+ f = XLSX.readxlsx("mytest.xlsx")
+ cxs = filter(x -> x isa XLSX.Charts.ChartEx, XLSX.Charts.getCharts(f))
+ orig = only(filter(x -> x.sheet == "pareto", cxs))
+ copy = only(filter(x -> x.sheet == "pareto2", cxs))
- # Chart references to a deleted sheet, invalidated as Excel does it.
+ ob = XLSX.Charts.getChartDataBlocks(orig)
+ cb = XLSX.Charts.getChartDataBlocks(copy)
- function _chart_elements(node, name)
- out = []
- for ch in XLSX.XML.eachelement(node)
- XLSX.localname(ch) == name && push!(out, ch)
- append!(out, _chart_elements(ch, name))
+ # the original is untouched: its two blocks still share one name
+ @test ob[1].dimensions[1].formula == ob[2].dimensions[1].formula == "_xlchart.v1.18"
+
+ # the copy's two blocks share one name too, and it is a new one
+ shared = cb[1].dimensions[1].formula
+ @test cb[2].dimensions[1].formula == shared
+ @test shared != "_xlchart.v1.18"
+
+ # every data dimension and series name in the copy points at the copied sheet
+ for d in vcat(cb[1].dimensions, cb[2].dimensions)
+ @test d.range.sheet == "pareto2"
+ end
+ @test XLSX.Charts.getSeriesNameRange(copy, 1).sheet == "pareto2"
+ @test XLSX.Charts.getSeriesNameRange(copy, 2).sheet == "pareto2"
+
+ # data: cat (shared), val, val; series names: two. Five names, not six.
+ @test length(unique(XLSX.Charts._cx_refs(copy))) == 3 # data dimensions
+ @test length(XLSX.Charts._cx_refs(copy)) == 4 # cat appears twice
+ isfile("mytest.xlsx") && rm("mytest.xlsx")
end
- return out
- end
- _chart_f_texts(root) = [something(XLSX.XML.is_simple_value(f), "") for f in _chart_elements(root, "f")]
+ @testset "copysheet! clones chart parts for both schemas" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "chart_mixed.xlsx"))
+ XLSX.copysheet!(f["Data"], "Copy")
- @testset "deletesheet! invalidates chart references" begin
+ charts = XLSX.Charts.getCharts(f)
+ @test length(charts) == 4
+ @test allunique(c.path for c in charts)
+ @test count(c -> c.sheet == "Copy", charts) == 2
+ @test issetequal((XLSX.Charts.getChartType(c) for c in charts if c.sheet == "Copy"),
+ [:waterfall, :barChart])
- @testset "c: chart becomes #REF!, cache kept" begin
- xf = XLSX.openxlsx(joinpath(data_directory, "Chartsheet.xlsx"); mode="rw")
- XLSX.addsheet!(xf, "Keep")
- XLSX.deletesheet!(xf, "Sheet1")
+ # Stems are handled per family: chart1 -> chart2, chartEx1 -> chartEx2.
+ @test issetequal((c.name for c in charts),
+ ["chart1", "chart2", "chartEx1", "chartEx2"])
- c = only(XLSX.getCharts(xf))
- @test _chart_f_texts(XLSX.Charts.chart_root(c)) == ["#REF!", "#REF!"] # as Excel writes it
- @test all(r -> r.values === XLSX.XL_REF, XLSX.getChartRanges(c))
- @test XLSX.getChartData(xf, c.name) isa XLSX.DataTable # cache kept
+ # Anchors come across with the drawing.
+ @test issetequal((c.from for c in charts), ["F1", "F17"])
+
+ # Both the c: and the cx: chart are repointed at the copy.
+ bar = only(filter(c -> c.sheet == "Copy" && c isa XLSX.Charts.Chart, charts))
+ @test all(r -> startswith(string(r.values), "Copy!"), XLSX.Charts.getChartRanges(bar))
+
+ wf = only(filter(c -> c.sheet == "Copy" && c isa XLSX.Charts.ChartEx, charts))
+ @test all(r -> startswith(string(r), "Copy!"), XLSX.Charts.getChartRanges(wf))
+
+ g = XLSX.writexlsx("mytest.xlsx", f; overwrite=true)
+ h = XLSX.readxlsx(g)
+ @test length(XLSX.Charts.getCharts(h)) == 4
+ @test allunique(c.path for c in XLSX.Charts.getCharts(h))
+ SAVE_FILES && save_outfile(f)
+ end
- f = "deletesheet_chart_ref.xlsx"
- XLSX.writexlsx(f, xf; overwrite=true)
+ @testset "copysheet! clones chart defined names" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "chart_ex.xlsx"))
+ before = XLSX.getAllDefinedNames(f; include_system=true)
+ XLSX.copysheet!(f["Data"], "Copy")
+ after = XLSX.getAllDefinedNames(f; include_system=true)
+
+ # A chartEx chart's sources are hidden defined names, so the copy needs
+ # its own set pointing at the copied sheet.
+ @test length(before) == 9
+ @test length(after) == 11
+ @test all(dn -> isnothing(dn.scope), after) # all workbook-scoped
+ @test all(dn -> dn.hidden, after) # and all still hidden
+ @test isempty(XLSX.getDefinedNames(f)) # none user-visible
+
+ # The originals are unchanged; the new ones point at the copy.
+ added = setdiff(Set(dn.name for dn in after), Set(dn.name for dn in before))
+ @test added == Set(["_xlchart.v2.0", "_xlchart.v2.1"])
+ @test all(dn -> startswith(string(dn.value), "Copy!"),
+ filter(dn -> dn.name in added, after))
+ @test all(dn -> startswith(string(dn.value), "Data!"),
+ filter(dn -> dn.name ∉ added, after))
+ SAVE_FILES && save_outfile(f)
+ end
+
+ # The copy once shared the original's cache, so a change to either sheet
+ # showed up in both.
+ @testset "copysheet! gives the copy its own cells" begin
+ f = XLSX.newxlsx()
+ s = f[1]
+ s["A1"] = 1
+ s["A2"] = 2
+ c = XLSX.copysheet!(s)
+
+ c["A1"] = 100
+ c["A5"] = 5
+ XLSX.setFont(c, "A2"; bold=true)
+ XLSX.setRowHeight(c, 2; height=30)
+ XLSX.setFormula(c, "C1", "=A1+1")
+ @test c["A1"] == 100 && c["A5"] == 5
+ @test s["A1"] == 1
+ @test ismissing(s["A5"])
+ @test isnothing(XLSX.getFont(s, "A2"))
+ @test isnothing(XLSX.getRowHeight(s, "A2"))
+ @test isnothing(XLSX.getFormula(s, "C1"))
+
+ # and the other way round
+ s["B1"] = "original"
+ @test ismissing(c["B1"])
+
+ io = IOBuffer()
+ XLSX.writexlsx(io, f)
+ r = XLSX.readxlsx(seekstart(io))
+ @test r[1]["A1"] == 1 && ismissing(r[1]["A5"]) && r[1]["B1"] == "original"
+ @test r[2]["A1"] == 100 && r[2]["A5"] == 5 && ismissing(r[2]["B1"])
+ SAVE_FILES && save_outfile(f)
+ end
+ end
+
+ @testset "deletesheet!" begin
+ @testset "deleting sheets, and formulas that referred to them" begin
+
+ new_filename = "template_with_new_sheet.xlsx"
+ big_sheetname = "aaaaaaaaaabbbbbbbbbbccccccccccd"
+ f0 = XLSX.open_empty_template()
+ XLSX.addsheet!(f0, "new_sheet")
+ XLSX.addsheet!(f0, big_sheetname)
+ XLSX.writexlsx(new_filename, f0, overwrite=true)
+ fx = XLSX.opentemplate(new_filename)
+ XLSX.deletesheet!(fx, big_sheetname)
+ @test XLSX.sheetnames(fx) == ["Sheet1", "new_sheet"]
+ XLSX.writexlsx(new_filename, fx, overwrite=true)
+ SAVE_FILES && save_outfile(new_filename)
+ f = XLSX.readxlsx(new_filename)
+ @test XLSX.sheetnames(f) == ["Sheet1", "new_sheet"]
+
+ f = XLSX.opentemplate(joinpath(data_directory, "general.xlsx"))
+ sc = XLSX.sheetcount(f)
+ XLSX.deletesheet!(f, "empty")
+ @test XLSX.sheetcount(f) == sc - 1 # Check it's gone.
+ @test XLSX.hassheet(f, "empty") == false # Check it's gone.
+ @test_throws XLSX.XLSXError XLSX.deletesheet!(f, "empty") # Already deleted.
+ @test_throws XLSX.XLSXError XLSX.deletesheet!(f, "nosuchsheet") # Never there.
+ s2 = XLSX.addsheet!(f, "this_now")
+ @test XLSX.sheetnames(f) == ["general", "table3", "table4", "table", "table2", "table5", "table6", "table7", "lookup", "header_error", "named_ranges_2", "named_ranges", "this_now"]
+ XLSX.writexlsx(new_filename, f, overwrite=true)
+ SAVE_FILES && save_outfile(new_filename)
+
+ f = XLSX.opentemplate(new_filename)
+ @test XLSX.sheetnames(f) == ["general", "table3", "table4", "table", "table2", "table5", "table6", "table7", "lookup", "header_error", "named_ranges_2", "named_ranges", "this_now"]
+ XLSX.deletesheet!(f, "named_ranges")
+ XLSX.deletesheet!(f["general"])
+ @test XLSX.sheetnames(f) == ["table3", "table4", "table", "table2", "table5", "table6", "table7", "lookup", "header_error", "named_ranges_2", "this_now"]
+ XLSX.writexlsx(new_filename, f, overwrite=true)
+ SAVE_FILES && save_outfile(new_filename)
+ dtable = XLSX.readtable(new_filename, "table4", "F:G")
+ data, col_names = dtable.data, dtable.column_labels
+ @test col_names == [:H2, :H3]
+ test_data = Any[Any["C3", missing], Any[missing, "D4"]]
+ check_test_data(data, test_data)
+ @test XLSX.deletesheet!(f, 1) === f
+ @test XLSX.sheetnames(f) == ["table4", "table", "table2", "table5", "table6", "table7", "lookup", "header_error", "named_ranges_2", "this_now"]
+ XLSX.writexlsx(new_filename, f, overwrite=true)
+ SAVE_FILES && save_outfile(new_filename)
+ dtable = XLSX.readtable(new_filename, "table4", "F:G")
+ data, col_names = dtable.data, dtable.column_labels
+ @test col_names == [:H2, :H3]
+ test_data = Any[Any["C3", missing], Any[missing, "D4"]]
+ check_test_data(data, test_data)
+
+ f = XLSX.opentemplate(joinpath(data_directory, "Book_1904.xlsx")) # Only one sheet - can't delete
+ @test_throws XLSX.XLSXError XLSX.deletesheet!(f, 1)
+ s = f[1]
+ @test_throws XLSX.XLSXError XLSX.deletesheet!(s)
+ @test_throws XLSX.XLSXError XLSX.deletesheet!(f, "Sheet1")
+
+ SAVE_FILES && save_outfile(f)
+ f = XLSX.openxlsx(joinpath(data_directory, "deletesheet.xlsx"), mode="rw")
+ XLSX.deletesheet!(f[1])
+ @test XLSX.getcell(f[1], "A1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A1"), "e", "", "#REF!", "", true)
+ @test XLSX.getFormula(f[1], "A1") == "=#REF!+#REF!"
+ SAVE_FILES && save_outfile(f)
+
+ isfile("template_with_new_sheet.xlsx") && rm("template_with_new_sheet.xlsx")
+
+ # XLSX.jl cannot currently create formula references that refer to other sheets, so we have to manually
+ # insert them into the workbook's formula cache to test. Excel cannot read such a workbook and deletes these
+ # formulas on opening. Tests are for completeness only
+ f=XLSX.openxlsx("renamedelete.xlsx", mode="w")
+ s=f[1]
+ s[1:10, 1] = collect(1:10)
+ XLSX.addsheet!(f)
+ s2=f[2]
+ s2["A1:B4"]=""
+ XLSX.setFormula(s2, "A1", "=Sheet1!A1+10")
+ XLSX.getcell(s2, "B1").formula=true
+ XLSX.getcell(s2, "B2").formula=true
+ XLSX.getcell(s2, "B3").formula=true
+ XLSX.getcell(s2, "B4").formula=true
+ w=XLSX.get_workbook(s2)
+ w.formulas[XLSX.SheetCellRef("Sheet2!B1")] = XLSX.ReferencedFormula("=Sheet1!A1+10", 0, "B1:B4", nothing)
+ w.formulas[XLSX.SheetCellRef("Sheet2!B2")] = XLSX.FormulaReference(0, nothing)
+ w.formulas[XLSX.SheetCellRef("Sheet2!B3")] = XLSX.FormulaReference(0, nothing)
+ w.formulas[XLSX.SheetCellRef("Sheet2!B4")] = XLSX.FormulaReference(0, nothing)
+
+ XLSX.deletesheet!(s)
+ @test XLSX.hassheet(f, "Sheet1") == false
+ @test XLSX.getcell(s2, "A1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A1"), "e", "", "#REF!", "", true)
+ @test XLSX.getcell(s2, "B1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("B1"), "e", "", "#REF!", "", true)
+ @test XLSX.getcell(s2, "B2") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("B2"), "e", "", "#REF!", "", true)
+ @test XLSX.getcell(s2, "B4") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("B4"), "e", "", "#REF!", "", true)
+ @test XLSX.getFormula(s2, "A1") == "=#REF!+10"
+ @test XLSX.getFormula(s2, "B1") == "=#REF!+10"
+ @test XLSX.getFormula(s2, "B2") == "=#REF!+10"
+ @test XLSX.getFormula(s2, "B4") == "=#REF!+10"
+ @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("A1")) == XLSX.Formula("=#REF!+10", nothing, nothing, nothing)
+ @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("B1")) == XLSX.ReferencedFormula("=#REF!+10", 0, "B1:B4", nothing)
+ @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("B2")) == XLSX.FormulaReference(0, nothing)
+ @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("B4")) == XLSX.FormulaReference(0, nothing)
+ SAVE_FILES && save_outfile(f)
+
+ end
+
+ @testset "deletesheet! keeps the active tab on the same sheet" begin
+ xf = XLSX.newxlsx("A")
+ XLSX.addsheet!(xf, "B")
+ XLSX.addsheet!(xf, "C")
+ active(x) = parse(Int, XLSX.XML.attributes(first(XLSX.elements_with_tag(first(XLSX.elements_with_tag(
+ XLSX.xml_root_element(XLSX.xmlroot(x, "xl/workbook.xml")), "bookViews")), "workbookView")))["activeTab"])
+ XLSX.xmlroot(xf, "xl/workbook.xml") # ensure loaded
+ wv = first(XLSX.elements_with_tag(first(XLSX.elements_with_tag(
+ XLSX.xml_root_element(XLSX.xmlroot(xf, "xl/workbook.xml")), "bookViews")), "workbookView"))
+ wv["activeTab"] = "2" # C is active
+ XLSX.deletesheet!(xf, "A")
+ @test active(xf) == 1 # still C
+ XLSX.deletesheet!(xf, "C")
+ @test active(xf) == 0 # C gone: clamped to B
SAVE_FILES && save_outfile(xf)
- yf = XLSX.openxlsx(f; mode="rw")
- d = only(XLSX.getCharts(yf))
- @test all(r -> r.values === XLSX.XL_REF, XLSX.getChartRanges(d))
- a, b = XLSX.getChartData(yf, d.name), XLSX.getChartData(xf, c.name)
- @test a.column_labels == b.column_labels && a.data == b.data
- SAVE_FILES && save_outfile(yf)
- isfile(f) && rm(f)
end
- @testset "chartEx chart loses the reference" begin
- # chartex_kinds.xlsx with the treemap moved to a chartsheet in Excel,
- # so deleting its data sheet leaves the chart behind.
- xf = XLSX.openxlsx(joinpath(data_directory, "chartex_chartsheet.xlsx"); mode="rw")
- others = filter(c -> XLSX.Charts.getChartType(c) !== :treemap, XLSX.getCharts(xf))
- before = Dict(c.name => string.(XLSX.getChartRanges(c)) for c in others)
+ @testset "deletesheet! invalidates chart references" begin
+
+ @testset "c: chart becomes #REF!, cache kept" begin
+ xf = XLSX.openxlsx(joinpath(data_directory, "Chartsheet.xlsx"); mode="rw")
+ XLSX.addsheet!(xf, "Keep")
+ XLSX.deletesheet!(xf, "Sheet1")
- XLSX.deletesheet!(xf, "treemap")
+ c = only(XLSX.getCharts(xf))
+ @test _chart_f_texts(XLSX.Charts.chart_root(c)) == ["#REF!", "#REF!"] # as Excel writes it
+ @test all(r -> r.values === XLSX.XL_REF, XLSX.getChartRanges(c))
+ @test XLSX.getChartData(xf, c.name) isa XLSX.DataTable # cache kept
+
+ f = "deletesheet_chart_ref.xlsx"
+ XLSX.writexlsx(f, xf; overwrite=true)
+ SAVE_FILES && save_outfile(xf)
+ yf = XLSX.openxlsx(f; mode="rw")
+ d = only(XLSX.getCharts(yf))
+ @test all(r -> r.values === XLSX.XL_REF, XLSX.getChartRanges(d))
+ a, b = XLSX.getChartData(yf, d.name), XLSX.getChartData(xf, c.name)
+ @test a.column_labels == b.column_labels && a.data == b.data
+ SAVE_FILES && save_outfile(yf)
+ isfile(f) && rm(f)
+ end
+
+ @testset "chartEx chart loses the reference" begin
+ # chartex_kinds.xlsx with the treemap moved to a chartsheet in Excel,
+ # so deleting its data sheet leaves the chart behind.
+ xf = XLSX.openxlsx(joinpath(data_directory, "chartex_chartsheet.xlsx"); mode="rw")
+ others = filter(c -> XLSX.Charts.getChartType(c) !== :treemap, XLSX.getCharts(xf))
+ before = Dict(c.name => string.(XLSX.getChartRanges(c)) for c in others)
+
+ XLSX.deletesheet!(xf, "treemap")
+
+ c = only(filter(c -> XLSX.Charts.getChartType(c) === :treemap, XLSX.getCharts(xf)))
+ root = XLSX.Charts.chart_root(c)
+ @test isempty(_chart_f_texts(root))
+ strdim = only(_chart_elements(root, "strDim"))
+ numdim = only(_chart_elements(root, "numDim"))
+ @test length(_chart_elements(strdim, "lvl")) == 2 # one per hierarchy level
+ @test length(_chart_elements(numdim, "lvl")) == 1
+ @test all(l -> XLSX.get_attr(l, "ptCount") == "0",
+ vcat(_chart_elements(strdim, "lvl"), _chart_elements(numdim, "lvl")))
+ @test isnothing(XLSX.first_element_with_tag(only(_chart_elements(root, "series")), "tx"))
+ @test all(isnothing, XLSX.getChartRanges(c))
+
+ wb = XLSX.get_workbook(xf)
+ @test !any(v -> v.value isa XLSX.DefinedNameRangeTypes && v.value.sheet == "treemap",
+ values(wb.workbook_names))
+
+ # charts plotting other sheets are untouched
+ for d in filter(d -> haskey(before, d.name), XLSX.getCharts(xf))
+ @test string.(XLSX.getChartRanges(d)) == before[d.name]
+ end
- c = only(filter(c -> XLSX.Charts.getChartType(c) === :treemap, XLSX.getCharts(xf)))
+ f = "deletesheet_chartex_ref.xlsx"
+ XLSX.writexlsx(f, xf; overwrite=true)
+ SAVE_FILES && save_outfile(xf)
+ yf = XLSX.openxlsx(f; mode="rw")
+ d = only(filter(d -> XLSX.Charts.getChartType(d) === :treemap, XLSX.getCharts(yf)))
+ @test all(isnothing, XLSX.getChartRanges(d))
+ SAVE_FILES && save_outfile(yf)
+ isfile(f) && rm(f)
+ end
+ @testset "levels in a chartEx reference" begin
+ @test XLSX._ref_levels("Data!\$A\$2:\$B\$7", "") == 2 # columns, the default
+ @test XLSX._ref_levels("Data!\$A\$2:\$B\$7", "row") == 6 # rows, for dir="row"
+ @test XLSX._ref_levels("Data!\$C\$2", "") == 1 # a single cell
+ end
+ end
+
+ @testset "deletesheet! invalidates direct chartEx references (as addChartEx writes)" begin
+ xf = XLSX.newxlsx("Data")
+ ws = xf["Data"]
+ rows = [("Region", "Country", "Sales"),
+ ("Europe", "France", 30), ("Europe", "Germany", 45), ("Europe", "Spain", 20),
+ ("Asia", "Japan", 50), ("Asia", "India", 35),
+ ("Americas", "USA", 60), ("Americas", "Brazil", 25)]
+ for (i, r) in enumerate(rows), (j, v) in enumerate(r)
+ ws[i, j] = v
+ end
+ XLSX.Charts.addChartEx(xf, :treemap, "Data!C2:C8"; categories="Data!A2:B8", name_ref="Data!C1")
+ XLSX.addsheet!(xf, "Keep")
+ XLSX.deletesheet!(xf, "Data")
+
+ c = only(XLSX.getCharts(xf))
root = XLSX.Charts.chart_root(c)
@test isempty(_chart_f_texts(root))
- strdim = only(_chart_elements(root, "strDim"))
- numdim = only(_chart_elements(root, "numDim"))
- @test length(_chart_elements(strdim, "lvl")) == 2 # one per hierarchy level
- @test length(_chart_elements(numdim, "lvl")) == 1
- @test all(l -> XLSX.get_attr(l, "ptCount") == "0",
- vcat(_chart_elements(strdim, "lvl"), _chart_elements(numdim, "lvl")))
+ @test length(_chart_elements(only(_chart_elements(root, "strDim")), "lvl")) == 2
+ @test length(_chart_elements(only(_chart_elements(root, "numDim")), "lvl")) == 1
@test isnothing(XLSX.first_element_with_tag(only(_chart_elements(root, "series")), "tx"))
@test all(isnothing, XLSX.getChartRanges(c))
+ SAVE_FILES && save_outfile(xf)
+ end
- wb = XLSX.get_workbook(xf)
- @test !any(v -> v.value isa XLSX.DefinedNameRangeTypes && v.value.sheet == "treemap",
- values(wb.workbook_names))
+ @testset "deletesheet! removes chart parts (issue #427)" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "chart_basic.xlsx"))
+ XLSX.addsheet!(f, "Keep")
+ XLSX.deletesheet!(f, "Data")
+
+ @test isempty(XLSX.Charts.getCharts(f))
+ @test isempty(XLSX.Charts.chart_parts(f))
+ @test !haskey(f.data, "xl/charts/chart1.xml")
+ @test !haskey(f.data, "xl/drawings/drawing1.xml")
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "deletesheet! with a chartEx chart" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "chart_ex.xlsx"))
+ XLSX.addsheet!(f, "Keep")
+
+ XLSX.deletesheet!(f, "Data")
+ @test !XLSX.hassheet(f, "Data")
+ @test isempty(XLSX.Charts.getCharts(f))
- # charts plotting other sheets are untouched
- for d in filter(d -> haskey(before, d.name), XLSX.getCharts(xf))
- @test string.(XLSX.getChartRanges(d)) == before[d.name]
+ # The chartEx cluster and the drawing must all go.
+ for p in ("xl/charts/chartEx1.xml", "xl/charts/style1.xml",
+ "xl/charts/colors1.xml", "xl/drawings/drawing1.xml")
+ @test !haskey(f.data, p)
end
+ @test isempty(XLSX.Charts.chartex_parts(f))
- f = "deletesheet_chartex_ref.xlsx"
- XLSX.writexlsx(f, xf; overwrite=true)
- SAVE_FILES && save_outfile(xf)
- yf = XLSX.openxlsx(f; mode="rw")
- d = only(filter(d -> XLSX.Charts.getChartType(d) === :treemap, XLSX.getCharts(yf)))
- @test all(isnothing, XLSX.getChartRanges(d))
- SAVE_FILES && save_outfile(yf)
- isfile(f) && rm(f)
- end
- @testset "levels in a chartEx reference" begin
- @test XLSX._ref_levels("Data!\$A\$2:\$B\$7", "") == 2 # columns, the default
- @test XLSX._ref_levels("Data!\$A\$2:\$B\$7", "row") == 6 # rows, for dir="row"
- @test XLSX._ref_levels("Data!\$C\$2", "") == 1 # a single cell
- end
- end
- @testset "chartEx chart with direct references (as addChartEx writes)" begin
- xf = XLSX.newxlsx("Data")
- ws = xf["Data"]
- rows = [("Region", "Country", "Sales"),
- ("Europe", "France", 30), ("Europe", "Germany", 45), ("Europe", "Spain", 20),
- ("Asia", "Japan", 50), ("Asia", "India", 35),
- ("Americas", "USA", 60), ("Americas", "Brazil", 25)]
- for (i, r) in enumerate(rows), (j, v) in enumerate(r)
- ws[i, j] = v
+ # And the file must still be writable and readable.
+ g = XLSX.writexlsx("mytest.xlsx", f, overwrite=true)
+ h = XLSX.readxlsx(g)
+ @test isempty(XLSX.Charts.getCharts(h))
+ SAVE_FILES && save_outfile(f)
end
- XLSX.Charts.addChartEx(xf, :treemap, "Data!C2:C8"; categories="Data!A2:B8", name_ref="Data!C1")
- XLSX.addsheet!(xf, "Keep")
- XLSX.deletesheet!(xf, "Data")
-
- c = only(XLSX.getCharts(xf))
- root = XLSX.Charts.chart_root(c)
- @test isempty(_chart_f_texts(root))
- @test length(_chart_elements(only(_chart_elements(root, "strDim")), "lvl")) == 2
- @test length(_chart_elements(only(_chart_elements(root, "numDim")), "lvl")) == 1
- @test isnothing(XLSX.first_element_with_tag(only(_chart_elements(root, "series")), "tx"))
- @test all(isnothing, XLSX.getChartRanges(c))
- SAVE_FILES && save_outfile(xf)
end
+
@testset "renamesheet!" begin
+ @testset "renaming sheets" begin
+
+ f=XLSX.openxlsx("renamedelete.xlsx", mode="w")
+ s=f[1]
+ s[1:10, 1] = collect(1:10)
+ XLSX.addsheet!(f, "newsheet")
+ s2 = f["newsheet"]
+ XLSX.setFormula(s2, "A1", "=Sheet1!A1+10")
+ XLSX.setFormula(s2, "B1:B3", "=A1+10")
+
+ @test XLSX.getcell(s2, "A1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A1"), "", "", "", "", true)
+ @test XLSX.getFormula(s2, "A1") == "=Sheet1!A1+10"
+ @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("A1")) == XLSX.Formula("=Sheet1!A1+10", nothing, nothing, nothing)
+
+ w = XLSX.get_workbook(s2)
+ w.formulas[XLSX.SheetCellRef("newsheet!B1")] = XLSX.ReferencedFormula("=A1+10", 0, "B1:B3", nothing)
+ w.formulas[XLSX.SheetCellRef("newsheet!B2")] = XLSX.FormulaReference(0, nothing)
+ w.formulas[XLSX.SheetCellRef("newsheet!B3")] = XLSX.FormulaReference(0, nothing)
+
+ XLSX.renamesheet!(s2, "Sheet4")
+ @test s2.name == "Sheet4"
+ @test XLSX.getcell(s2, "A1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A1"), "", "", "", "", true)
+ @test XLSX.getFormula(s2, "A1") == "=Sheet1!A1+10"
+ @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("A1")) == XLSX.Formula("=Sheet1!A1+10", nothing, nothing, nothing)
+ @test XLSX.getFormula(s2, "B1") == "=A1+10"
+ @test XLSX.getFormula(s2, "B2") == "=A2+10"
+ @test XLSX.getFormula(s2, "B3") == "=A3+10"
+
+ XLSX.renamesheet!(s, "Sheet3")
+ @test s.name == "Sheet3"
+ @test XLSX.getcell(s2, "A1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A1"), "", "", "", "", true)
+ @test XLSX.getFormula(s2, "A1") == "=Sheet3!A1+10"
+ @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("A1")) == XLSX.Formula("=Sheet3!A1+10", nothing, nothing, nothing)
+ SAVE_FILES && save_outfile(f)
+
+ end
- f=XLSX.openxlsx("renamedelete.xlsx", mode="w")
- s=f[1]
- s[1:10, 1] = collect(1:10)
- XLSX.addsheet!(f, "newsheet")
- s2 = f["newsheet"]
- XLSX.setFormula(s2, "A1", "=Sheet1!A1+10")
- XLSX.setFormula(s2, "B1:B3", "=A1+10")
-
- @test XLSX.getcell(s2, "A1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A1"), "", "", "", "", true)
- @test XLSX.getFormula(s2, "A1") == "=Sheet1!A1+10"
- @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("A1")) == XLSX.Formula("=Sheet1!A1+10", nothing, nothing, nothing)
-
- w = XLSX.get_workbook(s2)
- w.formulas[XLSX.SheetCellRef("newsheet!B1")] = XLSX.ReferencedFormula("=A1+10", 0, "B1:B3", nothing)
- w.formulas[XLSX.SheetCellRef("newsheet!B2")] = XLSX.FormulaReference(0, nothing)
- w.formulas[XLSX.SheetCellRef("newsheet!B3")] = XLSX.FormulaReference(0, nothing)
-
- XLSX.renamesheet!(s2, "Sheet4")
- @test s2.name == "Sheet4"
- @test XLSX.getcell(s2, "A1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A1"), "", "", "", "", true)
- @test XLSX.getFormula(s2, "A1") == "=Sheet1!A1+10"
- @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("A1")) == XLSX.Formula("=Sheet1!A1+10", nothing, nothing, nothing)
- @test XLSX.getFormula(s2, "B1") == "=A1+10"
- @test XLSX.getFormula(s2, "B2") == "=A2+10"
- @test XLSX.getFormula(s2, "B3") == "=A3+10"
-
- XLSX.renamesheet!(s, "Sheet3")
- @test s.name == "Sheet3"
- @test XLSX.getcell(s2, "A1") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A1"), "", "", "", "", true)
- @test XLSX.getFormula(s2, "A1") == "=Sheet3!A1+10"
- @test XLSX.get_formula_from_cache(s2, XLSX.CellRef("A1")) == XLSX.Formula("=Sheet3!A1+10", nothing, nothing, nothing)
- SAVE_FILES && save_outfile(f)
+ @testset "renaming a sheet repoints chart references" begin
+ path = "chart_rename.xlsx"
+ cp(joinpath(data_directory, "chart_basic.xlsx"), path; force=true)
+ xf = XLSX.openxlsx(path; mode="rw")
+ XLSX.renamesheet!(xf["Data"], "Sales")
+ c = only(XLSX.Charts.getCharts(xf))
+ @test all(r -> startswith(string(r.values), "Sales!"), XLSX.Charts.getChartRanges(c))
+ SAVE_FILES && save_outfile(xf)
+ isfile(path) && rm(path)
+ end
+
+ @testset "chart reference repointing matches whole sheet names" begin
+ @test XLSX._repoint_ref_string("Data!\$A\$1:\$A\$5", "Data!", "Sales!") == "Sales!\$A\$1:\$A\$5"
+ @test XLSX._repoint_ref_string("MyData!\$A\$1", "Data!", "Sales!") == "MyData!\$A\$1"
+ @test XLSX._repoint_ref_string("(Data!\$A\$1,Data!\$C\$1)", "Data!", "Sales!") == "(Sales!\$A\$1,Sales!\$C\$1)"
+ @test XLSX._repoint_ref_string("'My Sheet'!\$B\$2", "'My Sheet'!", "Sales!") == "Sales!\$B\$2"
+ @test XLSX._repoint_ref_string("[1]Data!\$A\$1", "Data!", "Sales!") == "[1]Data!\$A\$1"
+ end
+ @testset "rename! alias, and the Workbook forms of the content-type helpers" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh[1:3, 1:4] = reshape(collect(1:12), 3, 4)
+
+ # the unexported alias and the Workbook forms of the content-type helpers
+ XLSX.rename!(sh, "Alias")
+ @test sh.name == "Alias"
+ wb = XLSX.get_workbook(xf)
+ XLSX.add_override!(wb, "/xl/dummy.xml", "application/xml")
+ @test occursin("/xl/dummy.xml", XLSX.XML.write(XLSX.xmlroot(xf, "[Content_Types].xml")))
+ XLSX.remove_override!(wb, "/xl/dummy.xml")
+ @test !occursin("/xl/dummy.xml", XLSX.XML.write(XLSX.xmlroot(xf, "[Content_Types].xml")))
+ SAVE_FILES && save_outfile(xf)
+ end
end
@testset "copysheet!/deletesheet! part cleanup (issue #427)" begin
@@ -493,216 +792,146 @@
end
end
- @testset "deletesheet! removes chart parts (issue #427)" begin
- f = XLSX.opentemplate(joinpath(data_directory, "chart_basic.xlsx"))
- XLSX.addsheet!(f, "Keep")
- XLSX.deletesheet!(f, "Data")
-
- @test isempty(XLSX.Charts.getCharts(f))
- @test isempty(XLSX.Charts.chart_parts(f))
- @test !haskey(f.data, "xl/charts/chart1.xml")
- @test !haskey(f.data, "xl/drawings/drawing1.xml")
- SAVE_FILES && save_outfile(f)
- end
- @testset "deletesheet! with a chartEx chart" begin
- f = XLSX.opentemplate(joinpath(data_directory, "chart_ex.xlsx"))
- XLSX.addsheet!(f, "Keep")
+ @testset "editing a template: renaming, editing cells and adding sheets" begin
+ f = XLSX.open_xlsx_template(joinpath(data_directory, "general.xlsx"))
+ s = f["general"]
+ @test_throws XLSX.XLSXError s["A1"] = :sym
+ XLSX.renamesheet!(s, "general") # no-op
+ @test_throws XLSX.XLSXError XLSX.renamesheet!(s, "table") # name is taken
+ XLSX.renamesheet!(s, "renamed_sheet") # retain old function to avoid breaking change
+ @test s.name == "renamed_sheet"
+ s["A1"] = "Hey You!"
+ s["B1"] = "Out there in the cold..."
+ s["A2"] = "Getting lonely getting old..."
+ s["B2"] = "Can you feel me?"
+ s["A3"] = 1000
+ s["B3"] = 99.99
+
+ # create a new sheet
+ s = XLSX.addsheet!(f, "my_new_sheet_1")
+ s = XLSX.addsheet!(f, "my_new_sheet_2")
+ s["B1"] = "This is a new sheet"
+ s["B2"] = "This is a new sheet"
+ s = XLSX.addsheet!(f)
+ s["B1"] = "unnamed sheet"
+
+ XLSX.writexlsx("general_copy_2.xlsx", f, overwrite=true)
+ SAVE_FILES && save_outfile("general_copy_2.xlsx")
+ @test isfile("general_copy_2.xlsx")
+
+ XLSX.openxlsx("general_copy_2.xlsx") do f
+ s = f["renamed_sheet"]
+ @test s["A1"] == "Hey You!"
+ @test s["B1"] == "Out there in the cold..."
+ @test s["A2"] == "Getting lonely getting old..."
+ @test s["B2"] == "Can you feel me?"
+ @test s["A3"] == 1000
+ @test s["B3"] == 99.99
+ f["my_new_sheet_1"]
+ @test f["my_new_sheet_2"]["B1"] == "This is a new sheet"
+ @test f["my_new_sheet_2"]["B2"] == "This is a new sheet"
+ @test f["Sheet1"]["B1"] == "unnamed sheet"
+ end
- XLSX.deletesheet!(f, "Data")
- @test !XLSX.hassheet(f, "Data")
- @test isempty(XLSX.Charts.getCharts(f))
+ isfile("general_copy_2.xlsx") && rm("general_copy_2.xlsx")
+ end
- # The chartEx cluster and the drawing must all go.
- for p in ("xl/charts/chartEx1.xml", "xl/charts/style1.xml",
- "xl/charts/colors1.xml", "xl/drawings/drawing1.xml")
- @test !haskey(f.data, p)
+ @testset "renaming and adding sheets updates docProps/app.xml (issue #428)" begin # issue #428
+
+ function parse_heading_pairs(app_xml::String)
+ m = match(r"(.*?)"s, app_xml)
+ m === nothing && return Tuple{String,Int}[]
+ inner = m.captures[1]
+ labels = [c.captures[1] for c in eachmatch(r"([^<]*)", inner)]
+ counts = [parse(Int, c.captures[1]) for c in eachmatch(r"([^<]*)", inner)]
+ return collect(zip(labels, counts))
end
- @test isempty(XLSX.Charts.chartex_parts(f))
- # And the file must still be writable and readable.
- g = XLSX.writexlsx("mytest.xlsx", f, overwrite=true) # your save_outfile helper
- h = XLSX.readxlsx(g)
- @test isempty(XLSX.Charts.getCharts(h))
- SAVE_FILES && save_outfile(f)
- end
+ function parse_titles_of_parts(app_xml::String)
+ m = match(r"(.*?)"s, app_xml)
+ m === nothing && return String[]
+ inner = m.captures[1]
+ return [c.captures[1] for c in eachmatch(r"([^<]*)", inner)]
+ end
- @testset "copysheet! clones and repoints c: chart parts" begin
- f = XLSX.opentemplate(joinpath(data_directory, "chart_basic.xlsx"))
- XLSX.copysheet!(f["Data"], "Copy")
-
- charts = XLSX.Charts.getCharts(f)
- @test length(charts) == 2
- @test allunique(c.path for c in charts)
- @test issetequal((c.sheet for c in charts), ["Data", "Copy"])
-
- cp = only(filter(c -> c.sheet == "Copy", charts))
- orig = only(filter(c -> c.sheet == "Data", charts))
-
- # The copy plots the copied sheet. getChartRanges returns one named
- # tuple per series for a `c:` chart, so the ranges are in its fields.
- cp_ranges = XLSX.Charts.getChartRanges(cp)
- @test [string(r.categories) for r in cp_ranges] == ["Copy!A2:A5", "Copy!A2:A5"]
- @test [string(r.values) for r in cp_ranges] == ["Copy!B2:B5", "Copy!C2:C5"]
-
- # Series name refs are repointed too, not just categories and values.
- @test all(s -> startswith(s.name_ref.ref, "Copy!"), XLSX.Charts.getChartSeries(cp))
-
- # The original is untouched — catches copynode aliasing the two trees.
- orig_ranges = XLSX.Charts.getChartRanges(orig)
- @test [string(r.categories) for r in orig_ranges] == ["Data!A2:A5", "Data!A2:A5"]
- @test [string(r.values) for r in orig_ranges] == ["Data!B2:B5", "Data!C2:C5"]
- @test all(s -> startswith(s.name_ref.ref, "Data!"), XLSX.Charts.getChartSeries(orig))
-
- # The cache is deliberately left alone, so the copy still reads.
- @test XLSX.Charts.getChartData(cp) isa XLSX.DataTable
-
- # And it survives a round trip.
- g = XLSX.writexlsx("mytest.xlsx", f; overwrite=true)
- h = XLSX.readxlsx(g)
- cp2 = only(filter(c -> c.sheet == "Copy", XLSX.Charts.getCharts(h)))
- @test [string(r.values) for r in XLSX.Charts.getChartRanges(cp2)] ==
- ["Copy!B2:B5", "Copy!C2:C5"]
- SAVE_FILES && save_outfile(f)
- end
- @testset "copysheet! with a chartEx chart" begin
- f = XLSX.opentemplate(joinpath(data_directory, "chart_ex.xlsx"))
- XLSX.copysheet!(f["Data"], "Copy")
-
- charts = XLSX.Charts.getCharts(f)
- @test length(charts) == 2
- @test all(c -> c isa XLSX.Charts.ChartEx, charts)
- @test issetequal((c.sheet for c in charts), ["Data", "Copy"])
- @test allunique(c.path for c in charts)
- @test all(c -> XLSX.Charts.getChartType(c) === :waterfall, charts)
-
- cp = only(filter(c -> c.sheet == "Copy", charts))
-
- # The copy gets its own hidden defined names, pointing at the copy.
- @test XLSX.Charts._cx_refs(cp) == ["_xlchart.v2.0", "_xlchart.v2.1"]
- @test all(r -> startswith(string(r), "Copy!"), XLSX.Charts.getChartRanges(cp))
-
- # The original's names and ranges are untouched.
- orig = only(filter(c -> c.sheet == "Data", charts))
- @test XLSX.Charts._cx_refs(orig) == ["_xlchart.v1.0", "_xlchart.v1.1"]
- @test all(r -> startswith(string(r), "Data!"), XLSX.Charts.getChartRanges(orig))
-
- # Two names added, all still hidden and none user-visible.
- all_names = XLSX.getAllDefinedNames(f; include_system=true)
- @test length(all_names) == 11
- @test all(dn -> dn.hidden, all_names)
- @test isempty(XLSX.getDefinedNames(f))
-
- g = XLSX.writexlsx("mytest.xlsx", f; overwrite=true)
- h = XLSX.readxlsx(g)
- @test length(XLSX.Charts.getCharts(h)) == 2
- @test allunique(c.path for c in XLSX.Charts.getCharts(h))
- SAVE_FILES && save_outfile(f)
- end
+ @testset "plain worksheets: rename + add updates HeadingPairs and TitlesOfParts" begin
+ src = joinpath(data_directory, "Book1.xlsx")
+ p = "testfiles_plain_worksheets_rename_add.xlsx"
+ cp(src, p; force=true)
- @testset "copysheet! keeps a shared chartEx name shared" begin
- xf = XLSX.openxlsx(joinpath(data_directory, "chartex_layouts.xlsx"); mode="rw")
- XLSX.copysheet!(xf["pareto"], "pareto2")
- XLSX.writexlsx("mytest.xlsx", xf, overwrite=true)
- SAVE_FILES && save_outfile("mytest.xlsx")
+ XLSX.openxlsx(p; mode="rw") do xf
+ XLSX.renamesheet!(xf["Sheet1"], "Renamed")
+ XLSX.addsheet!(xf, "Added")
+ end
+ SAVE_FILES && save_outfile(p)
- f = XLSX.readxlsx("mytest.xlsx")
- cxs = filter(x -> x isa XLSX.Charts.ChartEx, XLSX.Charts.getCharts(f))
- orig = only(filter(x -> x.sheet == "pareto", cxs))
- copy = only(filter(x -> x.sheet == "pareto2", cxs))
+ app = String(zip_readentry(ZipReader(read(p)), "docProps/app.xml"))
- ob = XLSX.Charts.getChartDataBlocks(orig)
- cb = XLSX.Charts.getChartDataBlocks(copy)
+ @test !occursin(">Sheet1<", app) # stale title gone
- # the original is untouched: its two blocks still share one name
- @test ob[1].dimensions[1].formula == ob[2].dimensions[1].formula == "_xlchart.v1.18"
+ titles = parse_titles_of_parts(app)
+ @test "Renamed" in titles
+ @test "Added" in titles
+ @test length(titles) == 3
- # the copy's two blocks share one name too, and it is a new one
- shared = cb[1].dimensions[1].formula
- @test cb[2].dimensions[1].formula == shared
- @test shared != "_xlchart.v1.18"
+ pairs = parse_heading_pairs(app)
+ ws_idx = findfirst(pr -> pr[1] == "Worksheets", pairs)
+ @test ws_idx !== nothing
+ @test pairs[ws_idx][2] == 3
- # every data dimension and series name in the copy points at the copied sheet
- for d in vcat(cb[1].dimensions, cb[2].dimensions)
- @test d.range.sheet == "pareto2"
+ isfile(p) && rm(p)
end
- @test XLSX.Charts.getSeriesNameRange(copy, 1).sheet == "pareto2"
- @test XLSX.Charts.getSeriesNameRange(copy, 2).sheet == "pareto2"
- # data: cat (shared), val, val; series names: two. Five names, not six.
- @test length(unique(XLSX.Charts._cx_refs(copy))) == 3 # data dimensions
- @test length(XLSX.Charts._cx_refs(copy)) == 4 # cat appears twice
- isfile("mytest.xlsx") && rm("mytest.xlsx")
- end
+ @testset "chartsheet workbook: Worksheets/Charts categories stay separate and correctly counted" begin
+ src = joinpath(data_directory, "Chartsheet.xlsx")
+ p = "testfiles_chartsheet_workbook_worksheets.xlsx"
+ cp(src, p; force=true)
- @testset "copysheet! clones chart parts for both schemas" begin
- f = XLSX.opentemplate(joinpath(data_directory, "chart_mixed.xlsx"))
- XLSX.copysheet!(f["Data"], "Copy")
+ XLSX.openxlsx(p; mode="rw") do xf
+ XLSX.renamesheet!(xf["Sheet1"], "RenamedData")
+ XLSX.addsheet!(xf, "ExtraData")
+ end
+ SAVE_FILES && save_outfile(p)
- charts = XLSX.Charts.getCharts(f)
- @test length(charts) == 4
- @test allunique(c.path for c in charts)
- @test count(c -> c.sheet == "Copy", charts) == 2
- @test issetequal((XLSX.Charts.getChartType(c) for c in charts if c.sheet == "Copy"),
- [:waterfall, :barChart])
+ app = String(zip_readentry(ZipReader(read(p)), "docProps/app.xml"))
+ pairs = parse_heading_pairs(app)
+ titles = parse_titles_of_parts(app)
- # Stems are handled per family: chart1 -> chart2, chartEx1 -> chartEx2.
- @test issetequal((c.name for c in charts),
- ["chart1", "chart2", "chartEx1", "chartEx2"])
+ ws_idx = findfirst(pr -> pr[1] == "Worksheets", pairs)
+ ch_idx = findfirst(pr -> pr[1] == "Charts", pairs)
+ @test ws_idx !== nothing
+ @test ch_idx !== nothing
+ @test pairs[ws_idx][2] == 2 # RenamedData, ExtraData
+ @test pairs[ch_idx][2] == 1 # Chart1 untouched by a worksheet-only operation
- # Anchors come across with the drawing.
- @test issetequal((c.from for c in charts), ["F1", "F17"])
+ @test "RenamedData" in titles
+ @test "ExtraData" in titles
+ @test "Chart1" in titles
+ @test !occursin(">Sheet1<", app)
+ @test length(titles) == 3
- # The c: chart is repointed at the copy; the cx: one is not yet.
- bar = only(filter(c -> c.sheet == "Copy" && c isa XLSX.Charts.Chart, charts))
- @test all(r -> startswith(string(r.values), "Copy!"), XLSX.Charts.getChartRanges(bar))
+ # Worksheets titles are grouped before Charts titles regardless of tab
+ # order — with only one chart, it must be the last title.
+ @test titles[end] == "Chart1"
- wf = only(filter(c -> c.sheet == "Copy" && c isa XLSX.Charts.ChartEx, charts))
- @test all(r -> startswith(string(r), "Copy!"), XLSX.Charts.getChartRanges(wf))
+ XLSX.openxlsx(p) do xf2
+ @test issetequal(XLSX.sheetnames(xf2), ["RenamedData", "ExtraData", "Chart1"])
+ end
- g = XLSX.writexlsx("mytest.xlsx", f; overwrite=true)
- h = XLSX.readxlsx(g)
- @test length(XLSX.Charts.getCharts(h)) == 4
- @test allunique(c.path for c in XLSX.Charts.getCharts(h))
- SAVE_FILES && save_outfile(f)
- end
+ isfile(p) && rm(p)
+ end
- isfile("mytest.xlsx") && rm("mytest.xlsx")
-
- @testset "renaming a sheet repoints chart references" begin
- path = "chart_rename.xlsx"
- cp(joinpath(data_directory, "chart_basic.xlsx"), path; force=true)
- xf = XLSX.openxlsx(path; mode="rw")
- XLSX.renamesheet!(xf["Data"], "Sales")
- c = only(XLSX.Charts.getCharts(xf))
- @test all(r -> startswith(string(r.values), "Sales!"), XLSX.Charts.getChartRanges(c))
- SAVE_FILES && save_outfile(xf)
- isfile(path) && rm(path)
end
+end
- @testset "chart reference repointing matches whole sheet names" begin
- @test XLSX._repoint_ref_string("Data!\$A\$1:\$A\$5", "Data!", "Sales!") == "Sales!\$A\$1:\$A\$5"
- @test XLSX._repoint_ref_string("MyData!\$A\$1", "Data!", "Sales!") == "MyData!\$A\$1"
- @test XLSX._repoint_ref_string("(Data!\$A\$1,Data!\$C\$1)", "Data!", "Sales!") == "(Sales!\$A\$1,Sales!\$C\$1)"
- @test XLSX._repoint_ref_string("'My Sheet'!\$B\$2", "'My Sheet'!", "Sales!") == "Sales!\$B\$2"
- @test XLSX._repoint_ref_string("[1]Data!\$A\$1", "Data!", "Sales!") == "[1]Data!\$A\$1"
- end
- @testset "addsheet! after reopening gives a fresh xr:uid" begin
- # `uuid_rng` has a fixed seed, so a reopened file replays the ids it was first written with.
- uid(f, e) = match(r"xr:uid=\"(\{[^\"]+\})\"", String(XLSX.ZipArchives.zip_readentry(XLSX.ZipArchives.ZipReader(read(f)), e)))[1]
- xf = XLSX.newxlsx()
- XLSX.addsheet!(xf, "S2")
- XLSX.writexlsx("uid.xlsx", xf; overwrite=true)
- for out in ("uid2.xlsx", "uid3.xlsx")
- xf = XLSX.openxlsx("uid.xlsx"; mode="rw")
- XLSX.addsheet!(xf, "S3")
- XLSX.writexlsx(out, xf; overwrite=true)
- end
- SAVE_FILES && save_outfile("uid2.xlsx")
- uids = [uid("uid2.xlsx", "xl/worksheets/sheet$k.xml") for k in 1:3]
- @test allunique(uids)
- @test uids[3] == uid("uid3.xlsx", "xl/worksheets/sheet3.xml") # still deterministic
- @test XLSX._unique_guid(XLSX.Random.Xoshiro(1), Set{String}()) != XLSX._unique_guid(XLSX.Random.Xoshiro(1), Set([XLSX._unique_guid(XLSX.Random.Xoshiro(1), Set{String}())]))
- foreach(rm, ("uid.xlsx", "uid2.xlsx", "uid3.xlsx"))
- end
+@testset "review fixes: copysheet! shared formula ids" begin
+ f = XLSX.newxlsx("S"); s = f[1]
+ s["A1:A4"] = 1
+ XLSX.setFormula(s, "B1:B4", "=A1+1")
+ c = XLSX.copysheet!(s, "C")
+ XLSX.setFormula(c, "D1:D4", "=A1*3")
+ ids = [f.id for (k, f) in f.workbook.formulas if k.sheet == "C" && f isa XLSX.ReferencedFormula]
+ @test allunique(ids)
+ SAVE_FILES && save_outfile(f)
end
diff --git a/test/test_files/Defined-names_tests.jl b/test/test_files/Defined-names_tests.jl
index 153a0f49..23941bfe 100644
--- a/test/test_files/Defined-names_tests.jl
+++ b/test/test_files/Defined-names_tests.jl
@@ -1,4 +1,4 @@
-@testset "Defined Names" begin # Issue #148
+@testset "Defined Names" begin # Issue #148
@test XLSX.is_defined_name_value_a_reference(XLSX.SheetCellRef("Sheet1!A1"))
@test XLSX.is_defined_name_value_a_reference(XLSX.SheetCellRange("Sheet1!A1:B2"))
@test !XLSX.is_defined_name_value_a_reference(1)
@@ -133,8 +133,8 @@
XLSX.writexlsx("mytest.xlsx", f, overwrite=true)
SAVE_FILES && save_outfile("mytest.xlsx")
f = XLSX.readxlsx("mytest.xlsx")
- @test s["MyCell"] == true
- @test s["YourCells"] == Any[false false; false false]
+ @test f[1]["MyCell"] == true
+ @test f[1]["YourCells"] == Any[false false; false false]
@test_throws XLSX.XLSXError XLSX.addDefinedName(f, "A1", "Sheet1!B1")
@test_throws XLSX.XLSXError XLSX.addDefinedName(f, "A1:A3", "Sheet1!B2:B3")
@@ -160,14 +160,15 @@
ff = XLSX.openxlsx("mytest.xlsx", mode="rw")
- @test XLSX.hassheet(f, "Tim's Sheet")
- @test XLSX.hassheet(f, "Ano'ther She'et")
- @test f[2]["A1"] == "tim"
- @test f[3]["A1"] == "another"
- @test f["mine"] == "tim"
- @test f["yours"] == "another"
+ @test XLSX.hassheet(ff, "Tim's Sheet")
+ @test XLSX.hassheet(ff, "Ano'ther She'et")
+ @test ff[2]["A1"] == "tim"
+ @test ff[3]["A1"] == "another"
+ @test ff["mine"] == "tim"
+ @test ff["yours"] == "another"
@test string(XLSX.get_defined_name_value(XLSX.get_workbook(ff), "mine")) == "'Tim''s Sheet'!A1"
@test string(XLSX.get_defined_name_value(XLSX.get_workbook(ff), "yours")) == "'Ano''ther She''et'!A1"
+ SAVE_FILES && save_outfile(ff)
isfile("mytest.xlsx") && rm("mytest.xlsx")
@@ -213,7 +214,6 @@
SAVE_FILES && save_outfile(f)
end
-
@testset "deleteDefinedName" begin
template = joinpath(data_directory, "general.xlsx")
@@ -400,8 +400,6 @@
end
end
-
-
@testset "Rename sheet updates defined names" begin
f = XLSX.opentemplate(joinpath(data_directory, "general.xlsx"))
wb = XLSX.get_workbook(f)
@@ -503,7 +501,109 @@
@test occursin("…", out)
SAVE_FILES && save_outfile(f)
end
- SAVE_FILES && save_outfile(ff)
+
+ @testset "parse_defined_name_value" begin
+ @testset "parse_defined_name_value - relative sheet cell reference" begin
+ v, isabs = XLSX.parse_defined_name_value("Sheet1!A1")
+ @test v == XLSX.SheetCellRef("Sheet1!A1")
+ @test isabs == false
+ end
+
+ @testset "parse_defined_name_value - relative sheet cell range" begin
+ v, isabs = XLSX.parse_defined_name_value("Sheet1!A1:B2")
+ @test v == XLSX.SheetCellRange("Sheet1!A1:B2")
+ @test isabs == false
+ end
+
+ @testset "parse_defined_name_value - absolute forms" begin
+ v, isabs = XLSX.parse_defined_name_value("Sheet1!\$A\$1")
+ @test v == XLSX.SheetCellRef("Sheet1!A1")
+ @test isabs == true
+
+ v2, isabs2 = XLSX.parse_defined_name_value("Sheet1!\$A\$1:\$B\$2")
+ @test v2 == XLSX.SheetCellRange("Sheet1!A1:B2")
+ @test isabs2 == true
+ end
+
+ @testset "parse_defined_name_value - empty string" begin
+ v, isabs = XLSX.parse_defined_name_value("")
+ @test ismissing(v)
+ @test isabs == false
+ end
+
+ @testset "parse_defined_name_value - quoted, numeric and fallback strings" begin
+ @test XLSX.parse_defined_name_value("\"hello\"") == ("hello", false)
+ @test ismissing(first(XLSX.parse_defined_name_value("\"\"")))
+ @test XLSX.parse_defined_name_value("42") == (42, false)
+ @test XLSX.parse_defined_name_value("3.5") == (3.5, false)
+ # any other expression is formula text, held with a leading `=`
+ @test XLSX.parse_defined_name_value("SomethingElse") == ("=SomethingElse", false)
+ end
+ end
+
+ @testset "invalid, duplicate and R1C1-style names are rejected" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ wb = XLSX.get_workbook(xf)
+ @test_throws XLSX.XLSXError XLSX.get_defined_name_value(wb, "missing_name")
+ @test_throws XLSX.XLSXError XLSX.get_defined_name_value(sh, "missing_name")
+ @test !XLSX.is_valid_defined_name("")
+ @test !XLSX.is_valid_defined_name("1abc")
+ @test !XLSX.is_valid_defined_name("a-b")
+ # Excel refuses a file with an R1C1-style defined name, or one that is just R or C,
+ # in any case. (A1-style names such as R5 were already rejected.)
+ for n in ("R1C1", "RC", "rc", "R12C", "RC3", "R", "C", "r", "c")
+ @test !XLSX.is_valid_defined_name(n)
+ end
+ for n in ("XFE1", "A0", "A1B", "RCX", "Rate", "Col")
+ @test XLSX.is_valid_defined_name(n)
+ end
+ err = try XLSX.addDefinedName(xf, "R1C1", 1); nothing catch e; e end
+ @test err isa XLSX.XLSXError && occursin("cell address", err.msg)
+ @test_throws XLSX.XLSXError XLSX.addDefinedName(sh, "rc", 1)
+ @test_throws XLSX.XLSXError XLSX.addDefName(xf, "", 1)
+
+ XLSX.addDefinedName(xf, "dup", 1)
+ @test_throws XLSX.XLSXError XLSX.addDefinedName(xf, "dup", 2)
+ @test_throws XLSX.XLSXError XLSX.addDefinedName(xf, "empty", "")
+ @test_throws XLSX.XLSXError XLSX.addDefinedName(sh, "empty", "")
+ @test_throws XLSX.XLSXError XLSX.addDefinedName(xf, "nosheet", "A1")
+ XLSX.addDefinedName(xf, "wb_nc", "Sheet1!A1,Sheet1!B2")
+ @test XLSX.get_defined_name_value(wb, "wb_nc") isa XLSX.NonContiguousRange
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "Integer values, hashing and show of defined names" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ wb = XLSX.get_workbook(xf)
+
+ # Integer values that aren't Int64 go through the Integer forwarding methods
+ XLSX.addDefName(xf, "wb_int", Int32(5))
+ XLSX.addDefName(sh, "ws_int", Int32(6))
+ @test XLSX.get_defined_name_value(wb, "wb_int") === Int64(5)
+ @test XLSX.get_defined_name_value(sh, "ws_int") === Int64(6)
+ @test !isnothing(XLSX.find_workbook_defined_name(xf, "wb_int"))
+ @test !isnothing(XLSX.find_worksheet_defined_name(sh, "ws_int"))
+ @test XLSX.is_worksheet_defined_name(wb, sh.name, "ws_int")
+ @test !XLSX.is_defined_name_value_a_reference(Int32(1))
+ @test XLSX.is_defined_name_value_a_constant(Int32(1))
+
+ dnv = XLSX.DefinedNameValue(Int64(1), false)
+ @test dnv.hidden == false && dnv.isabs == false
+
+ dns = XLSX.getDefinedNames(xf)
+ dn = only(filter(d -> d.name == "wb_int", dns))
+ @test hash(dn) == hash(only(filter(d -> d.name == "wb_int", XLSX.getDefinedNames(xf))))
+ s = sprint(show, MIME("text/plain"), dn)
+ @test occursin("DefinedName: wb_int", s) && occursin("scope: Workbook", s) && occursin("value: 5", s)
+
+ # a non-contiguous defined name follows its sheet when the sheet is renamed
+ XLSX.addDefName(sh, "nc", XLSX.NonContiguousRange("Sheet1!A1:A2,Sheet1!C1"))
+ XLSX.renamesheet!(sh, "Renamed")
+ @test XLSX.get_defined_name_value(sh, "nc").sheet == "Renamed"
+ SAVE_FILES && save_outfile(xf)
+ end
end
@testset "system defined names" begin
@@ -513,7 +613,7 @@ end
@test length(before) == 9
@test all(dn -> dn.hidden, before)
- g = XLSX.writexlsx("myfile.xlsx", f, overwrite=true) # your usual save_outfile helper
+ g = XLSX.writexlsx("myfile.xlsx", f, overwrite=true)
h = XLSX.readxlsx(g)
after = XLSX.getAllDefinedNames(h; include_system=true)
isfile("myfile.xlsx") && rm("myfile.xlsx")
@@ -642,27 +742,87 @@ end
isfile("mytest.xlsx") && rm("mytest.xlsx")
SAVE_FILES && save_outfile(f)
end
- @testset "copysheet! clones chart defined names" begin
- f = XLSX.opentemplate(joinpath(data_directory, "chart_ex.xlsx"))
- before = XLSX.getAllDefinedNames(f; include_system=true)
- XLSX.copysheet!(f["Data"], "Copy")
- after = XLSX.getAllDefinedNames(f; include_system=true)
+end
- # A chartEx chart's sources are hidden defined names, so the copy needs
- # its own set pointing at the copied sheet.
- @test length(before) == 9
- @test length(after) == 11
- @test all(dn -> isnothing(dn.scope), after) # all workbook-scoped
- @test all(dn -> dn.hidden, after) # and all still hidden
- @test isempty(XLSX.getDefinedNames(f)) # none user-visible
-
- # The originals are unchanged; the new ones point at the copy.
- added = setdiff(Set(dn.name for dn in after), Set(dn.name for dn in before))
- @test added == Set(["_xlchart.v2.0", "_xlchart.v2.1"])
- @test all(dn -> startswith(string(dn.value), "Copy!"),
- filter(dn -> dn.name in added, after))
- @test all(dn -> startswith(string(dn.value), "Data!"),
- filter(dn -> dn.name ∉ added, after))
+@testset "review fixes: defined names" begin
+ @testset "string constants and formulas" begin
+ f = XLSX.newxlsx("S")
+ XLSX.addDefinedName(f, "greeting", "Say \"hi\"")
+ XLSX.addDefinedName(f, "rows", "=COUNTA(S!\$A:\$A)")
+ XLSX.update_workbook_xml!(f)
+ texts = Dict(n["name"] => XLSX.text_value(n) for n in XLSX.elements_with_tag(XLSX.first_element_with_tag(XLSX.xml_root_element(XLSX.xmlroot(f, "xl/workbook.xml")), "definedNames"), "definedName"))
+ @test texts["greeting"] == "\"Say \"\"hi\"\"\""
+ @test texts["rows"] == "COUNTA(S!\$A:\$A)"
+ XLSX.writexlsx("dn_consts.xlsx", f; overwrite=true)
+ f2 = XLSX.readxlsx("dn_consts.xlsx")
+ @test f2["greeting"] == "Say \"hi\""
+ @test XLSX.get_defined_name_value(f2.workbook, "rows") == "=COUNTA(S!\$A:\$A)"
+ @test XLSX.parse_defined_name_value("\"a\"&\"b\"") == ("=\"a\"&\"b\"", false)
+ SAVE_FILES && save_outfile("dn_consts.xlsx")
+ end
+ @testset "names on sheets whose names need quoting" begin
+ f = XLSX.newxlsx("2024"); f[1]["A1"] = 42
+ a1 = XLSX.addsheet!(f, "A1"); a1["A1"] = 7
+ XLSX.addDefinedName(f, "fromDigit", "'2024'!A1")
+ XLSX.addDefinedName(f, "fromA1", "'A1'!A1")
+ XLSX.update_workbook_xml!(f)
+ texts = Dict(n["name"] => XLSX.text_value(n) for n in XLSX.elements_with_tag(XLSX.first_element_with_tag(XLSX.xml_root_element(XLSX.xmlroot(f, "xl/workbook.xml")), "definedNames"), "definedName"))
+ @test texts["fromDigit"] == "'2024'!\$A\$1"
+ @test texts["fromA1"] == "'A1'!\$A\$1"
SAVE_FILES && save_outfile(f)
end
+ @testset "names that are cell addresses in any case are refused" begin
+ @test !XLSX.is_valid_defined_name("a1")
+ @test !XLSX.is_valid_defined_name("xfd100")
+ end
+ @testset "a non-contiguous name recreated from getDefinedNames" begin
+ f = XLSX.newxlsx("S")
+ XLSX.addDefinedName(f, "nc", "S!A1,S!B2"; absolute=false)
+ dn = only(XLSX.getDefinedNames(f))
+ XLSX.deleteDefinedName(f, "nc")
+ XLSX.addDefinedName(f, dn.name, dn.value; absolute=dn.absolute)
+ @test only(XLSX.getDefinedNames(f)) == dn
+ @test_throws XLSX.XLSXError XLSX.addDefinedName(f, "nc2", dn.value; absolute=[true])
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "copysheet! repoints the copy's names" begin
+ f = XLSX.newxlsx("S")
+ XLSX.addDefinedName(f[1], "cell", "B2")
+ XLSX.addDefinedName(f[1], "nc", "A1,B2")
+ t = XLSX.copysheet!(f[1], "T")
+ @test Dict(dn.name => dn.value for dn in XLSX.getDefinedNames(t)) ==
+ Dict("cell" => XLSX.SheetCellRef("T!B2"), "nc" => XLSX.NonContiguousRange("T!A1,T!B2"))
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "deletesheet! sets names that refer to it to #REF!" begin
+ f = XLSX.newxlsx("S1"); s2 = XLSX.addsheet!(f, "S2")
+ XLSX.addDefinedName(s2, "pt", XLSX.SheetCellRef("S1!A1"))
+ XLSX.addDefinedName(f, "wb", "S1!A1:B2")
+ XLSX.addDefinedName(f, "keep", "S2!A1")
+ XLSX.addDefinedName(f[1], "own", "A1")
+ # addDefinedName refuses _xlchart names, so add one directly
+ wb = XLSX.get_workbook(f)
+ wb.workbook_names["_xlchart.v1.0"] = XLSX.DefinedNameValue(XLSX.SheetCellRange("S1!A1:A3"), true, true)
+ XLSX.deletesheet!(f, "S1")
+ @test only(XLSX.getDefinedNames(s2)).value == "=#REF!"
+ @test Dict(dn.name => dn.value for dn in XLSX.getDefinedNames(f)) ==
+ Dict("wb" => "=#REF!", "keep" => XLSX.SheetCellRef("S2!A1"))
+ @test !haskey(wb.workbook_names, "_xlchart.v1.0")
+ @test !any(k -> last(k) == "own", keys(wb.worksheet_names))
+
+ mktempdir() do dir
+ p = joinpath(dir, "deleted.xlsx")
+ XLSX.writexlsx(p, f)
+ g = XLSX.readxlsx(p)
+ @test only(XLSX.getDefinedNames(g[1])).value == "=#REF!"
+ @test Dict(dn.name => dn.value for dn in XLSX.getDefinedNames(g))["wb"] == "=#REF!"
+ end
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "quoteit" begin
+ @test XLSX.quoteit("2024") == "'2024'"
+ @test XLSX.quoteit("A1") == "'A1'"
+ @test XLSX.quoteit("rc") == "'rc'"
+ @test XLSX.quoteit("Sheet1") == "Sheet1"
+ end
end
diff --git a/test/test_files/Differential_tests.jl b/test/test_files/Differential_tests.jl
index 77b5a2f1..78c3d11a 100644
--- a/test/test_files/Differential_tests.jl
+++ b/test/test_files/Differential_tests.jl
@@ -1,5 +1,15 @@
#=
-Differential tests for `readtable` (#462).
+Differential tests (#462): new read paths checked against an oracle.
+
+ - `readtable` against `gettable` (described below);
+ - value rows against cache rows;
+ - the filtered cell kernel against the node decoder;
+ - uncached ranged reads (`getdata`/`getcellrange`/`getcell`) against the row decoders;
+ - the row stream against the node walk.
+
+Each differential is followed by the point tests for the same code path.
+
+## readtable
`readtable(source, …)` builds its own `XLSXFile` and discards it. The oracle is
`gettable` on a file opened with `readxlsx`, which stays on the worksheet-cache path,
@@ -25,41 +35,7 @@ covering array (slow: tens of minutes).
const _DIFF_FULL = get(ENV, "XLSX_FULL_DIFF", "0") == "1"
const _DIFF_CHECKS = Ref(0) # comparisons run in the current section
-# ── Minimal workbook builder ──────────────────────────────────────────────────
-# Compact XML, as Excel writes it. Style 0 = General, 1 = date (numFmt 14),
-# 2 = a fill only (for styled-but-empty cells).
-
-const _DIFF_CT = """
-"""
-const _DIFF_RELS = """
-"""
-const _DIFF_WB = """
-"""
-const _DIFF_WBRELS = """
-"""
-const _DIFF_STYLES = """
-"""
-
-function _diff_build_xlsx(rows_xml::AbstractString, sst::Vector{String})::Vector{UInt8}
- sst_xml = "\n" *
- "" *
- join(isempty(s) ? "" : "$s" for s in sst) * ""
- sheet_xml = "\n" *
- "" *
- "$rows_xml"
- io = IOBuffer()
- ZipArchives.ZipWriter(io) do w
- ZipArchives.zip_writefile(w, "[Content_Types].xml", codeunits(_DIFF_CT))
- ZipArchives.zip_writefile(w, "_rels/.rels", codeunits(_DIFF_RELS))
- ZipArchives.zip_writefile(w, "xl/workbook.xml", codeunits(_DIFF_WB))
- ZipArchives.zip_writefile(w, "xl/_rels/workbook.xml.rels", codeunits(_DIFF_WBRELS))
- ZipArchives.zip_writefile(w, "xl/styles.xml", codeunits(_DIFF_STYLES))
- ZipArchives.zip_writefile(w, "xl/sharedStrings.xml", codeunits(sst_xml))
- ZipArchives.zip_writefile(w, "xl/worksheets/sheet1.xml", codeunits(sheet_xml))
- end
- return take!(io)
-end
+# The workbook builder `_diff_build_xlsx` is in helpers.jl (Read-internals uses it too).
# ── Empty-row corpus ──────────────────────────────────────────────────────────
# The table sits in B:D with its header on `header_row`. Column F is always empty
@@ -139,39 +115,9 @@ function _diff_special_sheets()::Vector{Pair{String,Vector{UInt8}}}
return out
end
-# ── Pairwise covering array for the secondary keywords ────────────────────────
-
-# Greedy pairwise cover over `levels` (number of values per factor); deterministic.
-function _diff_pairwise(levels::Vector{Int})::Vector{Vector{Int}}
- nf = length(levels)
- uncovered = Set{NTuple{4,Int}}()
- for i in 1:nf, j in (i+1):nf, a in 1:levels[i], b in 1:levels[j]
- push!(uncovered, (i, a, j, b))
- end
- candidates = vec(collect(Iterators.product((1:l for l in levels)...)))
- rows = Vector{Vector{Int}}()
- while !isempty(uncovered)
- best, best_n = candidates[1], -1
- for c in candidates
- n = 0
- for i in 1:nf, j in (i+1):nf
- (i, c[i], j, c[j]) in uncovered && (n += 1)
- end
- n > best_n && ((best, best_n) = (c, n))
- end
- for i in 1:nf, j in (i+1):nf
- delete!(uncovered, (i, best[i], j, best[j]))
- end
- push!(rows, collect(best))
- end
- return rows
-end
-
-# Secondary factors: enable_cache, infer_eltypes, normalizenames, column_labels,
-# missing_strings, stop_in_row_function.
-const _DIFF_SECONDARY_LEVELS = [2, 2, 2, 3, 4, 5]
+# Columns and missing_strings values the readtable differentials cross.
+const _DIFF_COLUMNS = (nothing, "B:D", "B:C", "B:F", "C:C", "A:D", "H:I")
const _DIFF_MS_OPTIONS = (nothing, "NA", ["NA", ""], "a")
-const _DIFF_STOP_KINDS = (:none, :at2, :col1missing, :always, :never)
# ── Running one comparison ────────────────────────────────────────────────────
@@ -231,6 +177,39 @@ function _diff_compare_calls(a::Vector{_DiffCall}, b::Vector{_DiffCall})::Union{
return nothing
end
+# ── Pairwise covering array for the secondary keywords ────────────────────────
+
+# Greedy pairwise cover over `levels` (number of values per factor); deterministic.
+function _diff_pairwise(levels::Vector{Int})::Vector{Vector{Int}}
+ nf = length(levels)
+ uncovered = Set{NTuple{4,Int}}()
+ for i in 1:nf, j in (i+1):nf, a in 1:levels[i], b in 1:levels[j]
+ push!(uncovered, (i, a, j, b))
+ end
+ candidates = vec(collect(Iterators.product((1:l for l in levels)...)))
+ rows = Vector{Vector{Int}}()
+ while !isempty(uncovered)
+ best, best_n = candidates[1], -1
+ for c in candidates
+ n = 0
+ for i in 1:nf, j in (i+1):nf
+ (i, c[i], j, c[j]) in uncovered && (n += 1)
+ end
+ n > best_n && ((best, best_n) = (c, n))
+ end
+ for i in 1:nf, j in (i+1):nf
+ delete!(uncovered, (i, best[i], j, best[j]))
+ end
+ push!(rows, collect(best))
+ end
+ return rows
+end
+
+# Secondary factors: enable_cache, infer_eltypes, normalizenames, column_labels,
+# missing_strings, stop_in_row_function.
+const _DIFF_SECONDARY_LEVELS = [2, 2, 2, 3, 4, 5]
+const _DIFF_STOP_KINDS = (:none, :at2, :col1missing, :always, :never)
+
# `eachtablerow` gives untyped rows; its values must equal the oracle's (`isequal`).
function _diff_compare_rows(rows, oracle)::Union{Nothing,String}
rows isa Exception && return oracle isa Exception ? nothing : "eachtablerow threw: $(_diff_describe(rows))"
@@ -310,8 +289,6 @@ function _diff_check(bytes::Vector{UInt8}, xf, sheet, cols, kw::NamedTuple, sec:
return nothing
end
-const _DIFF_COLUMNS = (nothing, "B:D", "B:C", "B:F", "C:C", "A:D", "H:I")
-
"""
Run the row-deciding keyword product on one workbook sheet. Secondary keywords come
from the covering array, rotated by `offset` so different sheets see different
@@ -342,11 +319,11 @@ function _diff_product(label, bytes, sheet, first_rows, columns, cover, offset;
return mismatches
end
-# Prints the first mismatches; `XLSX_DIFF_LOG=` appends all of them to a file.
+# Prints a summary and the first mismatches, if any; `XLSX_DIFF_LOG=` appends all of them to a file.
function _diff_report(mismatches::Vector{String}, what::String, t0::Float64)
+ isempty(mismatches) && return
println("Differential $what: $(_DIFF_CHECKS[]) checks, $(length(mismatches)) mismatches, ",
round(time() - t0, digits = 1), " s")
- isempty(mismatches) && return
log = get(ENV, "XLSX_DIFF_LOG", "")
isempty(log) || open(io -> foreach(m -> println(io, what, " | ", m), mismatches), log, "a")
println("Differential mismatches ($what), first $(min(20, length(mismatches))) of $(length(mismatches)):")
@@ -618,8 +595,11 @@ end
# inside the read area: the same error on both paths
e1 = try XLSX.readtable(IOBuffer(inside), 1, "B:C") catch e; e end
XLSX._READTABLE_VALUE_ROWS[] = false
- e2 = try XLSX.readtable(IOBuffer(inside), 1, "B:C") catch e; e end
- XLSX._READTABLE_VALUE_ROWS[] = true
+ e2 = try
+ try XLSX.readtable(IOBuffer(inside), 1, "B:C") catch e; e end
+ finally
+ XLSX._READTABLE_VALUE_ROWS[] = true
+ end
@test e1 isa XLSX.XLSXError && e2 isa XLSX.XLSXError && e1.msg == e2.msg
end
@@ -1088,3 +1068,255 @@ end
nor = XLSX.openxlsx(IOBuffer(_diff_build_xlsx("1
", String[])); enable_cache = false)[1]
@test_throws XLSX.XLSXError collect(XLSX.SheetRowStreamIterator(nor, 2:3))
end
+
+#=
+#462 Stage 4: `iterate(::TableRowIterator)` and `gettable` now share one row-stepping
+function, and `gettable` writes values straight into columns. The table-row iterator
+and `gettable` as they were before are kept below as the reference: the new code must
+give exactly the same rows, values, types and `stop_in_row_function` calls.
+=#
+
+# The pre-#462 `iterate(::TableRowIterator)`, verbatim apart from qualified names.
+function _old_handle_gap(itr, table_row_index, col_count, expected_row, actual_row, sheet_row, sheet_row_iterator_state)
+ itr.stop_in_empty_row && return nothing
+ itr.keep_empty_rows || return :skip
+ table_row = XLSX.TableRow(table_row_index, itr.index, fill(missing, col_count))
+ XLSX._should_stop(itr, table_row) && return nothing
+ newstate = XLSX.TableRowIteratorState(table_row_index, expected_row, sheet_row_iterator_state,
+ actual_row - expected_row - 1, sheet_row)
+ return table_row, newstate
+end
+
+function _old_return_table_row(itr, table_row_index, actual_row, sheet_row, sheet_row_iterator_state)
+ table_row = XLSX.TableRow(table_row_index, itr.index, sheet_row, itr.missing_strings)
+ XLSX._should_stop(itr, table_row) && return nothing
+ newstate = XLSX.TableRowIteratorState(table_row_index, actual_row, sheet_row_iterator_state, 0, nothing)
+ return table_row, newstate
+end
+
+function _old_iterate(itr, state)
+ table_row_index = state.table_row_index + 1
+ col_count = length(XLSX.sheet_column_numbers(itr.index))
+ if state.missing_rows > 0
+ table_row = XLSX.TableRow(table_row_index, itr.index, fill(missing, col_count))
+ XLSX._should_stop(itr, table_row) && return nothing
+ newstate = XLSX.TableRowIteratorState(table_row_index, state.sheet_row_index + 1,
+ state.sheet_row_iterator_state, state.missing_rows - 1, state.row_pending)
+ return table_row, newstate
+ end
+ local sheet_row, sheet_row_iterator_state
+ if !isnothing(state.row_pending)
+ sheet_row = state.row_pending
+ sheet_row_iterator_state = state.sheet_row_iterator_state
+ else
+ next = iterate(itr.itr, state.sheet_row_iterator_state)
+ isnothing(next) && return nothing
+ sheet_row, sheet_row_iterator_state = next
+ end
+ actual_row = XLSX.row_number(sheet_row)
+ expected_row = state.sheet_row_index + 1
+ if actual_row > expected_row
+ result = _old_handle_gap(itr, table_row_index, col_count, expected_row, actual_row, sheet_row, sheet_row_iterator_state)
+ result === :skip || return result
+ end
+ result = XLSX._skip_empty_rows(itr, sheet_row, sheet_row_iterator_state)
+ isnothing(result) && return nothing
+ sheet_row, sheet_row_iterator_state = result
+ return _old_return_table_row(itr, table_row_index, XLSX.row_number(sheet_row), sheet_row, sheet_row_iterator_state)
+end
+
+# All rows the old iterator gives, as (table row, values).
+function _old_tablerows(itr)
+ out = Tuple{Int,Vector{Any}}[]
+ next = isnothing(itr.resume) ? iterate(itr.itr) : itr.resume
+ while !isnothing(next) && XLSX.row_number(next[1]) < itr.first_data_row
+ next = iterate(itr.itr, next[2])
+ end
+ isnothing(next) && return out
+ sheet_row, sheet_row_state = next
+ state = XLSX.TableRowIteratorState(0, itr.first_data_row - 1, sheet_row_state, 0, sheet_row)
+ while true
+ r = _old_iterate(itr, state)
+ isnothing(r) && break
+ row, state = r
+ push!(out, (row.row, Any[row.cell_values...]))
+ end
+ return out
+end
+
+# The pre-#462 `gettable(::TableRowIterator)`, over the old iterator's rows.
+function _old_gettable(itr; infer_eltypes::Bool = true)
+ n = XLSX.table_columns_count(itr)
+ data = Any[Any[] for _ in 1:n]
+ for (_, values) in _old_tablerows(itr), ci in 1:n
+ push!(data[ci], values[ci])
+ end
+ if infer_eltypes
+ for i in eachindex(data)
+ T = XLSX.infer_eltype(data[i])
+ T !== Any && (data[i] = convert(Vector{T}, data[i]))
+ end
+ end
+ return XLSX.DataTable(data, XLSX.get_column_labels(itr))
+end
+
+_same_values(a, b) = length(a) == length(b) &&
+ all(isequal(x, y) && typeof(x) == typeof(y) for (x, y) in zip(a, b))
+
+# Old vs new for one sheet and keyword set: iteration and `gettable`, with the
+# callback calls compared too. Returns a mismatch description or `nothing`.
+function _stage4_check(ws, cols, kw::NamedTuple, stop_kind::Symbol, infer_eltypes::Bool)
+ mk(log) = isnothing(cols) ?
+ XLSX.eachtablerow(ws; kw..., stop_in_row_function = _diff_stop_fn(stop_kind, log)) :
+ XLSX.eachtablerow(ws, cols; kw..., stop_in_row_function = _diff_stop_fn(stop_kind, log))
+ probe = _diff_run(() -> mk(_DiffCall[]))
+ probe isa Exception && return nothing # construction is unchanged code
+
+ # iteration
+ la, lb = _DiffCall[], _DiffCall[]
+ old_rows = _diff_run(() -> _old_tablerows(mk(la)))
+ new_rows = _diff_run(() -> [(r.row, Any[r.cell_values...]) for r in mk(lb)])
+ if old_rows isa Exception || new_rows isa Exception
+ (old_rows isa Exception && new_rows isa Exception && typeof(old_rows) == typeof(new_rows)) ||
+ return "iteration: $(_diff_describe(old_rows)) vs $(_diff_describe(new_rows))"
+ else
+ length(old_rows) == length(new_rows) || return "iteration: $(length(old_rows)) vs $(length(new_rows)) rows"
+ for (i, (a, b)) in enumerate(zip(old_rows, new_rows))
+ (a[1] == b[1] && _same_values(a[2], b[2])) || return "iteration row $i: $a vs $b"
+ end
+ end
+ m = _diff_compare_calls(la, lb)
+ m === nothing || return "iteration: " * m
+
+ # gettable
+ la, lb = _DiffCall[], _DiffCall[]
+ old_t = _diff_run(() -> _old_gettable(mk(la); infer_eltypes))
+ new_t = _diff_run(() -> XLSX.gettable(mk(lb); infer_eltypes))
+ m = _diff_compare(old_t, new_t)
+ m === nothing || return "gettable: " * m
+ m = _diff_compare_calls(la, lb)
+ m === nothing || return "gettable: " * m
+ return nothing
+end
+
+@testset "table rows and gettable unchanged (#462 Stage 4)" begin
+ stops = (:none, :at2, :col1missing, :always, :never)
+
+ @testset "empty-row corpus" begin
+ mismatches = String[]
+ n = 0
+ sheets = Pair{String,Vector{UInt8}}[]
+ for len in 0:3, states in Iterators.product(ntuple(_ -> _DIFF_ROW_STATES, len)...)
+ push!(sheets, "corpus $(collect(states))" => _diff_corpus_sheet(collect(states)))
+ end
+ append!(sheets, ["special \"$k\"" => v for (k, v) in _diff_special_sheets()])
+ for (label, bytes) in sheets
+ ws = XLSX.readxlsx(IOBuffer(bytes))[1]
+ for header in (true, false), stop_in_empty_row in (true, false), keep_empty_rows in (true, false),
+ first_row in (nothing, 1, 2, 3, 5), cols in _DIFF_COLUMNS
+ n += 1
+ kw = (; header, stop_in_empty_row, keep_empty_rows, first_row,
+ missing_strings = _DIFF_MS_OPTIONS[mod1(n, 4)])
+ m = _stage4_check(ws, cols, kw, stops[mod1(n, 5)], isodd(n ÷ 5))
+ m === nothing || push!(mismatches, "$label cols=$(repr(cols)) $kw: $m")
+ end
+ end
+ foreach(println, first(mismatches, 20))
+ @test isempty(mismatches)
+ @test n > 100_000
+ end
+
+ @testset "test/data workbooks" begin
+ mismatches = String[]
+ n = 0
+ for file in sort(readdir(data_directory))
+ any(ext -> endswith(lowercase(file), ext), (".xlsx", ".xlsm", ".xltx", ".xltm")) || continue
+ xf = try XLSX.readxlsx(joinpath(data_directory, file)) catch; continue end
+ wb = XLSX.get_workbook(xf)
+ for ws in wb.sheets
+ XLSX.is_chartsheet(wb, ws.name) && continue
+ dim = _diff_run(() -> XLSX.get_dimension(ws))
+ columns, first_rows = Any[nothing], Any[nothing, 1, 2]
+ if dim isa XLSX.CellRange
+ c1, c2 = XLSX.column_number(dim.start), XLSX.column_number(dim.stop)
+ col(i) = XLSX.encode_column_number(i)
+ append!(columns, ["$(col(c1)):$(col(c2))", "$(col(c1)):$(col(c1))", "$(col(c1)):$(col(c2 + 2))"])
+ push!(first_rows, XLSX.row_number(dim.stop))
+ end
+ for header in (true, false), stop_in_empty_row in (true, false), keep_empty_rows in (true, false),
+ first_row in first_rows, cols in columns
+ n += 1
+ kw = (; header, stop_in_empty_row, keep_empty_rows, first_row,
+ missing_strings = _DIFF_MS_OPTIONS[mod1(n, 4)])
+ m = _stage4_check(ws, cols, kw, stops[mod1(n, 5)], isodd(n ÷ 5))
+ m === nothing || push!(mismatches, "$file $(ws.name) cols=$(repr(cols)) $kw: $m")
+ end
+ end
+ end
+ foreach(println, first(mismatches, 20))
+ @test isempty(mismatches)
+ @test n > 10_000
+ end
+
+ fixtures = get(ENV, "XLSX_DIFF_FIXTURES", "")
+ if !isempty(fixtures)
+ @testset "bench fixtures" begin
+ for file in sort(readdir(fixtures))
+ startswith(file, "xl_") || continue
+ ws = XLSX.readxlsx(joinpath(fixtures, file))["Data"]
+ for stop_in_empty_row in (true, false), keep_empty_rows in (true, false)
+ kw = (; first_row = 5, stop_in_empty_row, keep_empty_rows)
+ m = _stage4_check(ws, "A:CF", kw, :none, true)
+ m === nothing || println("$file $kw: $m")
+ @test m === nothing
+ end
+ end
+ end
+ end
+
+ @testset "column types" begin
+ # cells for B2:B4, and the column eltype `gettable` should infer
+ cases = [
+ ("1", "2.5", "3") => Float64,
+ ("1", "2", "3") => Int64,
+ ("45000", "45000.5", "45001") => Dates.DateTime,
+ ("x", "2", "3") => Any,
+ ("1", "", "0") => Union{Missing, Bool},
+ ("", "", "1") => Union{Missing, Int64},
+ ]
+ for ((b2, b3, b4), T) in cases
+ rows = "hk
" *
+ "$(b2)1
$(b3)1
" *
+ "$(b4)1
"
+ ws = XLSX.readxlsx(IOBuffer(_diff_build_xlsx(rows, String[])))[1]
+ for infer_eltypes in (true, false)
+ @test _stage4_check(ws, "B:C", (;), :none, infer_eltypes) === nothing
+ end
+ @test eltype(XLSX.gettable(ws, "B:C").data[1]) == T
+ @test eltype(XLSX.gettable(ws, "B:C"; infer_eltypes = false).data[1]) == Any
+ end
+ # all missing, and no data rows at all
+ rows = "h
|
1
"
+ ws = XLSX.readxlsx(IOBuffer(_diff_build_xlsx(rows, String[])))[1]
+ kw = (; stop_in_empty_row = false, keep_empty_rows = true)
+ @test _stage4_check(ws, "B:B", kw, :none, true) === nothing
+ @test eltype(XLSX.gettable(ws, "B:B"; kw...).data[1]) == Any
+ rows = "h
"
+ ws = XLSX.readxlsx(IOBuffer(_diff_build_xlsx(rows, String[])))[1]
+ @test _stage4_check(ws, "B:B", (;), :none, true) === nothing
+ @test isempty(XLSX.gettable(ws, "B:B").data[1])
+ end
+
+ @testset "TableRow length and collect" begin
+ ws = XLSX.readxlsx(joinpath(data_directory, "general.xlsx"))["table"]
+ nrows = 0
+ for r in XLSX.eachtablerow(ws)
+ nrows += 1
+ n = length(XLSX.get_column_labels(r))
+ @test length(r) == n
+ @test isequal(collect(r), Any[r[i] for i in 1:n])
+ @test eltype(collect(r)) == XLSX.CellConcreteType
+ end
+ @test nrows > 0
+ end
+end
diff --git a/test/test_files/Dispatch-coverage_tests.jl b/test/test_files/Dispatch-coverage_tests.jl
deleted file mode 100644
index cd115515..00000000
--- a/test/test_files/Dispatch-coverage_tests.jl
+++ /dev/null
@@ -1,234 +0,0 @@
-# One call each for method variants the rest of the suite never reaches: alternative
-# argument forms, Base/Tables interface methods and small helpers. Each test checks
-# the variant agrees with the form it forwards to, rather than just running it.
-
-@testset "dispatch coverage" begin
-
- @testset "defined names" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
- wb = XLSX.get_workbook(xf)
-
- # Integer values that aren't Int64 go through the Integer forwarding methods
- XLSX.addDefName(xf, "wb_int", Int32(5))
- XLSX.addDefName(sh, "ws_int", Int32(6))
- @test XLSX.get_defined_name_value(wb, "wb_int") === Int64(5)
- @test XLSX.get_defined_name_value(sh, "ws_int") === Int64(6)
- @test !isnothing(XLSX.find_workbook_defined_name(xf, "wb_int"))
- @test !isnothing(XLSX.find_worksheet_defined_name(sh, "ws_int"))
- @test XLSX.is_worksheet_defined_name(wb, sh.name, "ws_int")
- @test !XLSX.is_defined_name_value_a_reference(Int32(1))
- @test XLSX.is_defined_name_value_a_constant(Int32(1))
-
- dnv = XLSX.DefinedNameValue(Int64(1), false)
- @test dnv.hidden == false && dnv.isabs == false
-
- dns = XLSX.getDefinedNames(xf)
- dn = only(filter(d -> d.name == "wb_int", dns))
- @test hash(dn) == hash(only(filter(d -> d.name == "wb_int", XLSX.getDefinedNames(xf))))
- s = sprint(show, MIME("text/plain"), dn)
- @test occursin("DefinedName: wb_int", s) && occursin("scope: Workbook", s) && occursin("value: 5", s)
-
- # a non-contiguous defined name follows its sheet when the sheet is renamed
- XLSX.addDefName(sh, "nc", XLSX.NonContiguousRange("Sheet1!A1:A2,Sheet1!C1"))
- XLSX.renamesheet!(sh, "Renamed")
- @test XLSX.get_defined_name_value(sh, "nc").sheet == "Renamed"
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "worksheet setters and ranges" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
- sh[1:3, 1:4] = reshape(collect(1:12), 3, 4)
-
- # setdata! forms
- XLSX.setdata!(sh, XLSX.CellRef("A5"), Int32(7))
- @test sh["A5"] === Int64(7)
- XLSX.setdata!(sh, 6, 1, XLSX.CellValue(sh, "six"))
- @test sh["A6"] == "six"
- XLSX.setdata!(sh, 7, 1:2, [1, 2])
- @test sh["A7"] == 1 && sh["B7"] == 2
- @test XLSX.CellValue(Int32(3), XLSX.EmptyCellDataFormat()).value === Int64(3)
-
- # formats over vector / step-range columns, and colon rows
- XLSX.setFormat(sh, 1, [1, 3]; format="0.00")
- @test XLSX.getFormat(sh, "A1").format["numFmt"]["formatCode"] == XLSX.getFormat(sh, "C1").format["numFmt"]["formatCode"] == "0.00"
- XLSX.setUniformFormat(sh, 2, 1:2:3; format="0.0")
- @test XLSX.getcell(sh, "A2").style == XLSX.getcell(sh, "C2").style
- @test XLSX.getFormat(sh, "C2").format["numFmt"]["formatCode"] == "0.0"
- XLSX.setUniformFill(sh, 3, :; pattern="solid", fgColor="yellow")
- @test XLSX.getcell(sh, "A3").style == XLSX.getcell(sh, "D3").style
- @test XLSX.getFill(sh, "D3").fill["patternFill"]["fgrgb"] == "FFFFFF00"
-
- # merged cells: each argument form names exactly the merged range, so unmerges it
- merged() = something(XLSX.getMergedCells(sh), XLSX.CellRange[])
- for (rng, unmerge) in (("A1:A7", () -> XLSX.removeMergedCells(sh, XLSX.ColumnRange("A:A"))),
- ("A1:A7", () -> XLSX.removeMergedCells(sh, XLSX.SheetColumnRange("Sheet1!A:A"))),
- ("A1:A7", () -> XLSX.removeMergedCells(sh, :, 1)),
- ("A1:D1", () -> XLSX.removeMergedCells(sh, XLSX.RowRange("1:1"))),
- ("A1:D1", () -> XLSX.removeMergedCells(sh, XLSX.SheetRowRange("Sheet1!1:1"))),
- ("A1:D1", () -> XLSX.removeMergedCells(sh, 1, :)),
- ("A1:D7", () -> XLSX.removeMergedCells(sh, :, :)))
- XLSX.mergeCells(sh, rng)
- @test length(merged()) == 1
- unmerge()
- @test isempty(merged())
- end
- XLSX.mergeCells(sh, XLSX.NonContiguousRange("Sheet1!A1:B2,Sheet1!C1:D2"))
- @test length(merged()) == 2
- XLSX.removeMergedCells(sh)
- @test isempty(merged())
-
- # cell ranges and Base methods on references
- @test XLSX.getcellrange(sh, :) == XLSX.getcellrange(sh, XLSX.get_dimension(sh))
- @test isless(XLSX.CellRef("A2"), XLSX.CellRef("B1"))
- @test 2 in XLSX.ColumnRange("A:C") && 2 in XLSX.RowRange("1:3")
- @test convert(XLSX.RowRange, "2:4") == XLSX.RowRange("2:4")
- @test convert(XLSX.RowRange, XLSX.RowRange("2:4")) == XLSX.RowRange("2:4")
- srr = XLSX.SheetRowRange("Sheet1!2:4")
- @test string(srr) == "Sheet1!2:4"
- @test hash(srr) == hash(XLSX.SheetRowRange("Sheet1!2:4"))
-
- # panes from an anchor cell, Base.eachrow, and the cache flag on every holder
- XLSX.splitFreeze(sh, "B3")
- doc = XLSX.get_worksheet_xml_document(sh)
- i, j = XLSX.get_idces(doc, "worksheet", "sheetViews")
- k, l = XLSX.get_idces(doc[i][j], "sheetView", "pane")
- pane = doc[i][j][k][l]
- @test pane["topLeftCell"] == "B3" && pane["xSplit"] == "1" && pane["ySplit"] == "2"
- @test length(collect(Base.eachrow(sh))) == length(collect(XLSX.eachrow(sh)))
- itr = XLSX.eachrow(sh)
- @test XLSX.is_cache_enabled(XLSX.get_workbook(xf)) == XLSX.is_cache_enabled(itr) == XLSX.is_cache_enabled(xf)
-
- # the unexported alias and the Workbook forms of the content-type helpers
- XLSX.rename!(sh, "Alias")
- @test sh.name == "Alias"
- wb = XLSX.get_workbook(xf)
- XLSX.add_override!(wb, "/xl/dummy.xml", "application/xml")
- @test occursin("/xl/dummy.xml", XLSX.XML.write(XLSX.xmlroot(xf, "[Content_Types].xml")))
- XLSX.remove_override!(wb, "/xl/dummy.xml")
- @test !occursin("/xl/dummy.xml", XLSX.XML.write(XLSX.xmlroot(xf, "[Content_Types].xml")))
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "styles, colors and formulas" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
- wb = XLSX.get_workbook(xf)
- sh["A1"] = 1.5
- XLSX.setFormat(sh, "A1"; format="0.00")
- sid = string(XLSX.getcell(sh, "A1").style)
- @test XLSX.styles_is_float(sh, sid) == XLSX.styles_is_float(wb, parse(Int, sid))
- @test XLSX.styles_is_datetime(sh, sid) == XLSX.styles_is_datetime(wb, parse(Int, sid))
- @test !isempty(XLSX.CellDataFormat(1)) && isempty(XLSX.EmptyCellDataFormat())
- @test XLSX.get_colorant(:red) == XLSX.get_colorant("red")
-
- @test XLSX.Formula() isa XLSX.EmptyFormula
- f = XLSX.Formula("SUM(A1:A2)")
- rf = XLSX.ReferencedFormula("A1+1", 0, "B1:B3", Dict("t" => "shared"))
- fr = XLSX.FormulaReference(0, nothing)
- @test !isempty(f) && isempty(XLSX.Formula("")) && !isempty(rf) && !isempty(fr)
- @test hash(f) == hash(XLSX.Formula("SUM(A1:A2)"))
- @test hash(rf) == hash(copy(rf)) && hash(fr) == hash(copy(fr))
- c = copy(rf)
- @test c.unhandled == rf.unhandled && c.unhandled !== rf.unhandled # Dict copied
- io = IOBuffer()
- XLSX.add_node_formula!(io, XLSX.CellFormula(f, XLSX.EmptyCellDataFormat()), "")
- @test occursin("SUM(A1:A2)", String(take!(io)))
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "cells and rich text" begin
- e = XLSX.EmptyCell(XLSX.CellRef("A1"))
- @test isempty(e) && !XLSX.iserror(e)
- @test !isempty(XLSX.Cell(XLSX.CellRef("A1"), UInt64(1), UInt32(0), UInt16(0), XLSX.CT_INT, false))
- @test hash(e) == hash(XLSX.EmptyCell(XLSX.CellRef("A1")))
- @test XLSX.get_error_string(nothing) == ""
-
- run = XLSX.RichTextRun("ab", nothing)
- @test length(run) == 2
- @test hash(run) == hash(XLSX.RichTextRun("ab"))
- rts = XLSX.RichTextString("ab", [run])
- @test String(rts) == "ab" && ncodeunits(rts) == 2
- @test codeunit(rts) == UInt8 && codeunit(rts, 1) == UInt8('a')
- @test isvalid(rts, 1) && rts[1] == XLSX.RichTextString("a", [XLSX.RichTextRun("a")])
- @test hash(rts) == hash(XLSX.RichTextString("ab", [XLSX.RichTextRun("ab")]))
-
- xf = XLSX.opentemplate(joinpath(data_directory, "is.xlsx"))
- sh = xf["Sheet1"]
- XLSX.setFont(sh, "A1"; name="Palatino")
- @test XLSX.getRichTextString(sh, 1, 1) == XLSX.getRichTextString(sh, "A1")
- @test XLSX.getRichTextString(xf, "Sheet1!A1") == XLSX.getRichTextString(sh, "A1")
- @test XLSX.get_sst(xf) === XLSX.get_sst(XLSX.get_workbook(xf))
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "tables" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
- XLSX.writetable!(sh, [[1, 2], [3, 4]], ["a", "b"])
- XLSX.addtable!(sh, "A1:B3"; name="T")
- t = XLSX.table(sh, "T")
- XLSX.appendtable!(sh, t.id, [[5, 6]])
- @test XLSX.gettable(sh).data[1] == [1, 2, 5]
- XLSX.settotals!(sh, t.id; a=:sum)
- XLSX.settotals!(XLSX.table(sh, "T"); b=:sum)
- @test XLSX.table(sh, "T").has_totals_row
-
- @test Tables.istable(XLSX.Table) && Tables.rowaccess(XLSX.Table) && Tables.columnaccess(XLSX.Table)
- rows = Tables.rows(XLSX.table(sh, "T"))
- @test Tables.istable(typeof(rows)) && Tables.rowaccess(typeof(rows))
- @test Tables.rowtable(rows) == Tables.rowtable(XLSX.table(sh, "T"))
-
- dt = XLSX.gettable(sh)
- @test Tables.istable(XLSX.DataTable) && Tables.columnaccess(XLSX.DataTable)
- itr = XLSX.eachtablerow(sh)
- @test Tables.istable(typeof(itr)) && Tables.rowaccess(typeof(itr))
- r = first(itr)
- # by position, with Int and with another integer type (was a MethodError for both)
- @test Tables.getcolumn(r, 1) == Tables.getcolumn(r, Int32(1)) == Tables.getcolumn(r, :a)
- @test XLSX._as_vector((1, 2)) == [1, 2]
- @test XLSX._colname_prefix_string(sh, XLSX.EmptyCell(XLSX.CellRef("A1"))) == "#Empty"
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "file array" begin
- p = joinpath(data_directory, "general.xlsx")
- fa = XLSX.FileArray(p)
- @test fa[1] == read(p)[1]
- end
-
- @testset "charts" begin
- f = XLSX.readxlsx(joinpath(data_directory, "chart_appearance.xlsx"))
- c = XLSX.Charts.getCharts(f)[1]
- @test XLSX.Charts.getChartSchema(c) === :c
- @test XLSX.Charts.chartname(c) == c.name && XLSX.Charts.chartpath(c) == c.path
- @test XLSX.Charts.sheetname(c) == c.sheet
- @test XLSX.Charts.getLegendShapeProps(c) isa Union{Nothing,XLSX.Charts.DrawingShapeProps}
- @test XLSX.Charts.getChartTitleShapeProps(c) isa Union{Nothing,XLSX.Charts.DrawingShapeProps}
- g = first(XLSX.Charts.getChartGroups(c))
- @test isnothing(XLSX.Charts.getGroupSeriesLines(c, g)) # a bar chart without c:serLines
-
- s1 = XLSX.Charts.getChartSeries(c)[1]
- r = s1.values
- @test XLSX.Charts.iserror(r, 1) == XLSX.Charts.iserror(r)[1]
- @test XLSX.Charts.no_cached_values(r) == false
-
- @test XLSX.Charts._show_color(XLSX.Charts.SchemeColor(:accent1)) == "accent1"
- pfx = Dict(XLSX.Charts.NS_A => "a")
- @test XLSX.localname(XLSX.Charts._color_node_from(XLSX.Charts.SchemeColor(:accent1), pfx)) == "schemeClr"
- @test XLSX.localname(XLSX.Charts._color_node_from("FF0000", pfx)) == "srgbClr"
- @test XLSX.Charts._check("bar", (:bar, :col), "direction") === :bar
-
- sh = XLSX.newxlsx()[1]
- @test occursin("\$A\$1:\$A\$2", XLSX.Charts._chart_f(sh, XLSX.NonContiguousRange("Sheet1!A1:A2,Sheet1!C1")))
- @test_throws XLSX.XLSXError XLSX.Charts._chart_f(sh, 42)
-
- xf = XLSX.opentemplate(joinpath(data_directory, "chart_kinds.xlsx"))
- lc = XLSX.Charts.getCharts(xf["linemarkers"])[1]
- XLSX.Charts.setMarker(lc, 1; symbol=:circle, size=7)
- m = XLSX.Charts.getSeriesMarker(lc, 1)
- @test XLSX.localname(XLSX.Charts._node(lc, m)) == "marker"
- SAVE_FILES && save_outfile(xf)
- end
-end
diff --git a/test/test_files/DrawingML_tests.jl b/test/test_files/DrawingML_tests.jl
index 60e58a29..1d6f1ee1 100644
--- a/test/test_files/DrawingML_tests.jl
+++ b/test/test_files/DrawingML_tests.jl
@@ -1,4 +1,4 @@
-@testset "DrawingML colours" begin
+@testset "DrawingML colors, fills and lines" begin
# A workbook is needed only for schemeClr resolution; any file with a
# standard theme will do.
@@ -184,7 +184,7 @@
@test XLSX.localname(fl.raw) == "gradFill"
end
- @testset "parent element is searched for its fill child" begin
+ @testset "parent element is searched for its fill child (c:spPr)" begin
fl = fill_of("""
@@ -192,13 +192,13 @@
@test fl.fgcolor.rgb == "ABCDEF"
end
- @testset "no fill child" begin
+ @testset "no fill child (c:spPr)" begin
doc = XML.parse("""""", XML.Node)
@test isnothing(XLSX.Charts.parse_drawing_fill(wb, XLSX.xml_root_element(doc)))
@test isnothing(XLSX.Charts.parse_drawing_fill(wb, nothing))
end
- @testset "parent element is searched for its fill child" begin
+ @testset "parent element is searched for its fill child (a:spPr)" begin
fl = fill_of("""
@@ -206,7 +206,7 @@
@test fl.fgcolor.rgb == "ABCDEF"
end
- @testset "no fill child" begin
+ @testset "no fill child (a:spPr)" begin
doc = XML.parse("""""", XML.Node)
@test isnothing(XLSX.Charts.parse_drawing_fill(wb, XLSX.xml_root_element(doc)))
@test isnothing(XLSX.Charts.parse_drawing_fill(wb, nothing))
@@ -279,19 +279,131 @@
@test isnothing(ln_bare.width)
end
SAVE_FILES && save_outfile(f)
-end
-# =============================================================================
-# DrawingML Text
-## =============================================================================
+ @testset "show: DrawingLine" begin
+ # Both types previously fell back to Julia's default struct printing, which
+ # dumps `raw`. These pin the summary and the multi-line forms.
+ plain(x) = sprint(show, MIME("text/plain"), x)
+
+ @testset "DrawingLine" begin
+ # Built in code, so no `raw` and nothing inherited from a file.
+ ln = XLSX.Charts.DrawingLine(; width=2.25, dash="sysDot", cap="rnd")
+ @test repr(ln) == "XLSX.Charts.DrawingLine(2.25pt, sysDot)"
+ s = plain(ln)
+ @test occursin("stroke: inherited", s)
+ @test occursin("width: 2.25 pt", s)
+ @test occursin("dash: sysDot", s)
+ @test occursin("cap: rnd", s)
+ @test !occursin("compound", s) # absent fields are not printed
+ @test !occursin("miter", s)
+
+ @test repr(XLSX.Charts.DrawingLine()) == "XLSX.Charts.DrawingLine(inherited)"
+ none = XLSX.Charts.DrawingLine(; fill=XLSX.Charts.DrawingFill(:none))
+ @test repr(none) == "XLSX.Charts.DrawingLine(no stroke)"
+ @test occursin("stroke: none", plain(none))
+
+ red = XLSX.Charts.DrawingLine(;
+ fill=XLSX.Charts.DrawingFill(:solid; fgcolor="FF0000"),
+ width=1.5)
+ @test occursin("1.5pt", repr(red))
+ @test occursin("stroke: ", plain(red))
+
+ # Read from a file: the line on chart_appearance's first series is an
+ # explicit , not an absent element.
+ xf = XLSX.readxlsx(joinpath(data_directory, "chart_appearance.xlsx"))
+ c = XLSX.Charts.getCharts(xf)[1]
+ fl = XLSX.Charts.getSeriesShapeProps(c, 1).line
+ @test repr(fl) == "XLSX.Charts.DrawingLine(no stroke)"
+ @test !occursin("Element", plain(fl)) # `raw` stays out of the output
+ end
+
+ end
+ @testset "theme colour transforms match Excel" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "chart_theme_colors.xlsx"))
+ local wb = XLSX.get_workbook(f)
+ ch = XLSX.xml_root_element(f.data["xl/charts/chart1.xml"])
+ cols = XLSX.Charts.DrawingColor[]
+ walk(n) = for c in XML.eachelement(n)
+ XLSX.localname(c) == "solidFill" ?
+ (col = XLSX.Charts.parse_drawing_color(wb, c); isnothing(col) || push!(cols, col)) :
+ walk(c)
+ end
+ walk(ch)
+
+ # Values confirmed against what Excel reports in More Fill Colors.
+ function byval(v, t)
+ hits = filter(c -> c.val == v && c.transforms == t, cols)
+ @test !isempty(hits)
+ @test allequal(c.rgb for c in hits) # same input, same output everywhere
+ return first(hits).rgb
+ end
+ @test byval("accent1", Pair{Symbol,Int}[]) == "156082"
+ @test byval("accent1", [:lumMod => 60000, :lumOff => 40000]) == "46B1E1"
+ @test byval("accent1", [:lumMod => 75000]) == "104862"
+ @test byval("tx1", [:lumMod => 65000, :lumOff => 35000]) == "595959"
+ @test byval("tx1", [:lumMod => 15000, :lumOff => 85000]) == "D9D9D9"
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "show: DrawingFill and DrawingColor" begin
+ local xf = XLSX.openxlsx(joinpath(data_directory, "chart_basic.xlsx"); mode="rw")
+ local c = XLSX.getChart(xf["Data"], "chart1")
+ XLSX.Charts.setSeriesFill(c, 1, "FF0000")
+ local fl = XLSX.Charts.getSeriesFill(c, 1).value
+ local s = sprint(show, MIME"text/plain"(), fl)
+ @test startswith(s, "XLSX.Charts.DrawingFill ")
+ @test occursin("foreground: #FF0000", s)
+ local cs = sprint(show, MIME"text/plain"(), fl.fgcolor)
+ @test startswith(cs, "XLSX.Charts.DrawingColor ")
+ @test occursin("resolves to: #FF0000", cs)
+ @test !isempty(sprint(show, fl.fgcolor))
+ SAVE_FILES && save_outfile(xf)
+ end
+ @testset "show: scheme colors and absent formatting" begin
+ local xf = XLSX.openxlsx(joinpath(data_directory, "chart_basic.xlsx"); mode="rw")
+ local c = XLSX.getChart(xf["Data"], "chart1")
+
+ XLSX.Charts.setSeriesFill(c, 1, XLSX.Charts.SchemeColor(:accent2))
+ local s = sprint(show, MIME"text/plain"(), XLSX.Charts.getSeriesFill(c, 1).value)
+ @test occursin("foreground: #E97132", s) # accent2, resolved through the theme
+
+ XLSX.Charts.setSeriesLine(c, 2, :none)
+ local sp = XLSX.Charts.getSeriesShapeProps(c, 2)
+ @test occursin("no line", sprint(show, sp))
+ XLSX.Charts.setSeriesFill(c, 2, :inherit)
+ XLSX.Charts.setSeriesLine(c, 2, :inherit)
+ sp = XLSX.Charts.getSeriesShapeProps(c, 2)
+ @test !isnothing(sp)
+ s = sprint(show, sp)
+ @test occursin("fill inherited", s)
+ @test occursin("line inherited", s)
+ SAVE_FILES && save_outfile(xf)
+ end
+ @testset "sysClr, prstClr and placeholder colors resolve" begin
+ wb = XLSX.get_workbook(XLSX.newxlsx())
+ el(tag; kw...) = XLSX.XML.Element("a:" * tag; kw...)
+ resolve(n) = XLSX.Charts.resolve_color_base(wb, n)
+ @test resolve(el("sysClr"; val="windowText", lastClr="112233")) == "112233"
+ @test resolve(el("sysClr"; val="window")) == "FFFFFF"
+ @test resolve(el("sysClr"; val="windowText")) == "000000"
+ @test resolve(el("prstClr"; val="dkBlue")) == "00008B" # darkblue
+ @test resolve(el("prstClr"; val="ltGray")) == "D3D3D3" # lightgray
+ @test resolve(el("prstClr"; val="medPurple")) == "9370DB" # mediumpurple
+ @test resolve(el("prstClr"; val="notAColour")) == "000000"
+ @test resolve(el("schemeClr"; val="phClr")) == "000000" # placeholder
+ @test resolve(el("unknownClr"; val="x")) == "000000"
+ end
+end
+# =============================================================================
+# DrawingML Text
+## =============================================================================
# Parse a fragment into its root element. XML.parse returns a document node;
# the element is its last child (a declaration may precede it).
@@ -618,21 +730,18 @@ const _NSDECL = "xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main
end
@testset "title rich text" begin
title = XLSX.first_element_with_tag(chart, "title")
- if title !== nothing
- tx = XLSX.first_element_with_tag(title, "tx")
- if tx !== nothing
- t = XLSX.Charts.parse_drawing_text(wb, tx; tag="rich")
- if t !== nothing
- # `getChartTitle` is parsed independently, so this
- # cross-checks the two paths agree.
- @test XLSX.Charts.text_content(t) == XLSX.Charts.getChartTitle(c)
- @test !isempty(XLSX.Charts.text_runs(t))
- end
- end
- end
+ @test title !== nothing
+ tx = XLSX.first_element_with_tag(title, "tx")
+ @test tx !== nothing
+ t = XLSX.Charts.parse_drawing_text(wb, tx; tag="rich")
+ @test t !== nothing
+ # `getChartTitle` is parsed independently, so this
+ # cross-checks the two paths agree.
+ @test XLSX.Charts.text_content(t) == XLSX.Charts.getChartTitle(c)
+ @test !isempty(XLSX.Charts.text_runs(t))
end
end
- @testset "text_content reads a raw body as it reads a parsed one" begin
+ @testset "text_content joins paragraphs and fields" begin
xml = """""" *
"""One""" *
"""Two!"""
@@ -688,512 +797,398 @@ const _NSDECL = "xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main
@test mp.size ≈ 18.0 # from the run
@test mp.bold === true # from defRPr
end
- @testset "setting a run property clears it from the runs" begin
- tmp = "runclear.xlsx"
- cp(joinpath(data_directory, "chartex_layouts.xlsx"), tmp; force=true)
- xf = XLSX.openxlsx(tmp; mode="rw")
- h = only(filter(x -> x isa XLSX.Charts.ChartEx && x.sheet == "histogram", XLSX.Charts.getCharts(xf)))
-
- # Excel wrote this title with sz on both a:defRPr and the run's a:rPr
- XLSX.Charts.setChartTitleTextProp(h, :size, 20)
- tx = XLSX.first_element_with_tag(
- XLSX.first_element_with_tag(XLSX.Charts._cx_chart(h), "title"), "txPr")
- p = XLSX.first_element_with_tag(tx, "p")
- def = XLSX.first_element_with_tag(XLSX.first_element_with_tag(p, "pPr"), "defRPr")
- rpr = XLSX.first_element_with_tag(XLSX.first_element_with_tag(p, "r"), "rPr")
-
- @test XLSX.get_attr(def, "sz") == "2000"
- @test isempty(XLSX.get_attr(rpr, "sz")) # cleared, so the default renders
- @test XLSX.get_attr(rpr, "lang") == "en-GB" # the run keeps everything else
-
- # what the reader resolves now matches what Excel will render
- @test XLSX.Charts.default_run_props(XLSX.Charts.getChartTitleTextProps(h)).size ≈ 20.0
-
- # a composite field: the run's own solidFill must go too
- XLSX.Charts.setChartTitleTextProp(h, :fill, "FFC00000")
- rpr = XLSX.first_element_with_tag(XLSX.first_element_with_tag(
- XLSX.first_element_with_tag(
- XLSX.first_element_with_tag(XLSX.Charts._cx_chart(h), "title"), "txPr"), "p"), "r")
- @test isnothing(XLSX.first_element_with_tag(
- XLSX.first_element_with_tag(rpr, "rPr"), "solidFill"))
- @test XLSX.Charts.default_run_props(XLSX.Charts.getChartTitleTextProps(h)).fill.fgcolor.rgb == "C00000"
- SAVE_FILES && save_outfile(xf)
- isfile(tmp) && rm(tmp)
- end
- @testset "formatting cascade" begin
- f = XLSX.readxlsx(joinpath(data_directory, "chartex_formatted.xlsx"))
- c = only(filter(x -> x isa XLSX.Charts.ChartEx, XLSX.Charts.getCharts(f)))
- # Series fill: theme colour with a transform.
- e = XLSX.Charts.getSeriesFill(c, 1)
- @test e.site.level == :series
- @test e.value.fgcolor.val == "accent2"
-
- # Point 5 (idx 4) has its own fill: explicit colour with transforms.
- e5 = XLSX.Charts.getSeriesFill(c, 1; point=5)
- @test e5.site.level == :point
- @test [s.level for s in e5.chain] == [:point, :series]
- @test e5.value.fgcolor.kind == :srgb
- @test e5.value.fgcolor.val == "4EA72E" # as written
- @test e5.value.fgcolor.transforms == [:lumMod => 60000, :lumOff => 40000]
- @test e5.value.fgcolor.rgb == "8ED973" # transforms applied
-
- # Point 1 has no dataPt: no point rung, resolves at the series.
- e1 = XLSX.Charts.getSeriesFill(c, 1; point=1)
- @test e1.site.level == :series
- @test [s.level for s in e1.chain] == [:series]
- @test_throws XLSX.XLSXError XLSX.Charts.getSeriesFill(c, 1; point=0)
-
- # Label text: set on dataLabels/txPr defRPr.
- s = XLSX.Charts.getLabelTextProp(c, 1, :size)
- @test s.value ≈ 11.0
- @test s.site.level == :series
- @test [x.level for x in s.chain] == [:series, :chartspace]
- @test XLSX.Charts.getLabelTextProp(c, 1, :bold).value === true
- @test XLSX.Charts.getLabelTextProp(c, 1, :fill).value.fgcolor.val == "7030A0"
- @test_throws XLSX.XLSXError XLSX.Charts.getLabelTextProp(c, 1, :nonsense)
-
- # Nothing written anywhere: no value, no site, but a full chain.
- g = XLSX.readxlsx(joinpath(data_directory, "chartex_layouts.xlsx"))
- w = only(filter(x -> x isa XLSX.Charts.ChartEx && x.sheet == "waterfall", XLSX.Charts.getCharts(g)))
- u = XLSX.Charts.getSeriesFill(w, 1)
- @test isnothing(u.value) && isnothing(u.site)
- @test length(u.chain) == 1
- @test isnothing(only(u.chain).props)
- @test isnothing(XLSX.Charts.getLabelTextProp(w, 1, :size).value)
+ @testset "text_content reads a raw body as it reads a parsed one" begin
+ xml = """""" *
+ """OneTwo""" *
+ """Three!""" *
+ """"""
+ tx = XLSX.xml_root_element(parse(xml, XLSX.XML.Node))
+ rich = XLSX.first_element_with_tag(tx, "rich")
+ wb = XLSX.get_workbook(XLSX.newxlsx())
+ @test XLSX.Charts.text_content(rich) == "One\nTwo\nThree!"
+ @test XLSX.Charts.text_content(rich) == XLSX.Charts.text_content(XLSX.Charts.parse_drawing_text(wb, tx; tag="rich"))
end
- @testset "DrawingML shape properties" begin
-
- XLSX.openxlsx(joinpath(data_directory, "chart_basic.xlsx")) do xf
- wb = XLSX.get_workbook(xf)
-
- @testset "solid fill and line" begin
- node = _frag("""
-
-
-
- """)
-
- sp = XLSX.Charts.parse_drawing_shape_props(wb, node)
- @test sp isa XLSX.Charts.DrawingShapeProps
- @test sp.fill.kind == :solid
- @test sp.fill.fgcolor.rgb == "4472C4"
- @test sp.line.width == 1.5 # points, converted at parse
- @test sp.line.dash == "dash"
- @test sp.line.fill.fgcolor.rgb == "203864"
- @test XLSX.Charts.hasFill(sp)
- @test XLSX.Charts.hasLine(sp)
- @test sp.effects === nothing
- @test sp.bwmode === nothing
- @test sp.raw !== nothing
- end
-
- @testset "noFill is not absence" begin
- # Chart-area shape: transparent background, no border. Both are
- # deliberate, and neither is the same as omitting the element.
- node = _frag("""
-
-
- """)
-
- sp = XLSX.Charts.parse_drawing_shape_props(wb, node)
- @test sp.fill !== nothing # present...
- @test sp.fill.kind == :none # ...and explicitly invisible
- @test !XLSX.Charts.hasFill(sp)
-
- @test sp.line !== nothing
- @test sp.line.fill.kind == :none
- @test !XLSX.Charts.hasLine(sp)
- end
-
- @testset "absence is inheritance" begin
- node = _frag("""""")
-
- sp = XLSX.Charts.parse_drawing_shape_props(wb, node)
- @test sp !== nothing # the element exists
- @test sp.fill === nothing # but sets nothing
- @test sp.line === nothing
- @test !XLSX.Charts.hasFill(sp)
- @test !XLSX.Charts.hasLine(sp)
- end
-
- @testset "parse from parent, and no spPr at all" begin
- ser = _frag("""
-
-
- """)
- sp = XLSX.Charts.parse_drawing_shape_props(wb, ser)
- @test sp !== nothing
- @test sp.fill.fgcolor.rgb == "156082" # accent1, as pinned elsewhere
-
- bare = _frag("""""")
- @test XLSX.Charts.parse_drawing_shape_props(wb, bare) === nothing
- # chains without a guard
- @test XLSX.Charts.parse_drawing_shape_props(wb, nothing) === nothing
- end
-
- @testset "effects preserved, not modelled" begin
- node = _frag("""
-
-
- """)
+end
- sp = XLSX.Charts.parse_drawing_shape_props(wb, node)
- @test sp.effects !== nothing
- @test XLSX.localname(sp.effects) == "effectLst"
- end
+@testset "DrawingML shape properties" begin
+
+ XLSX.openxlsx(joinpath(data_directory, "chart_basic.xlsx")) do xf
+ wb = XLSX.get_workbook(xf)
+
+ @testset "solid fill and line" begin
+ node = _frag("""
+
+
+
+ """)
+
+ sp = XLSX.Charts.parse_drawing_shape_props(wb, node)
+ @test sp isa XLSX.Charts.DrawingShapeProps
+ @test sp.fill.kind == :solid
+ @test sp.fill.fgcolor.rgb == "4472C4"
+ @test sp.line.width == 1.5 # points, converted at parse
+ @test sp.line.dash == "dash"
+ @test sp.line.fill.fgcolor.rgb == "203864"
+ @test XLSX.Charts.hasFill(sp)
+ @test XLSX.Charts.hasLine(sp)
+ @test sp.effects === nothing
+ @test sp.bwmode === nothing
+ @test sp.raw !== nothing
+ end
- @testset "pattern fill keeps DrawingML vocabulary" begin
- node = _frag("""
-
-
-
-
- """)
-
- sp = XLSX.Charts.parse_drawing_shape_props(wb, node)
- @test sp.fill.kind == :pattern
- @test sp.fill.preset == "ltUpDiag" # not "lightUp"
- @test XLSX.Charts.hasFill(sp)
- end
+ @testset "noFill is not absence" begin
+ # Chart-area shape: transparent background, no border. Both are
+ # deliberate, and neither is the same as omitting the element.
+ node = _frag("""
+
+
+ """)
+
+ sp = XLSX.Charts.parse_drawing_shape_props(wb, node)
+ @test sp.fill !== nothing # present...
+ @test sp.fill.kind == :none # ...and explicitly invisible
+ @test !XLSX.Charts.hasFill(sp)
+
+ @test sp.line !== nothing
+ @test sp.line.fill.kind == :none
+ @test !XLSX.Charts.hasLine(sp)
end
- @testset "fixture: series spPr" begin
- XLSX.openxlsx(joinpath(data_directory, "chart_basic.xlsx")) do xf
- wb = XLSX.get_workbook(xf)
- c = first(XLSX.Charts.getCharts(xf["Data"]))
+ @testset "absence is inheritance" begin
+ node = _frag("""""")
- root = XLSX.xml_root_element(XLSX.get_xml_data(xf, c.path))
- chart = XLSX.first_element_with_tag(root, "chart")
- plotarea = XLSX.first_element_with_tag(chart, "plotArea")
- barchart = XLSX.first_element_with_tag(plotarea, "barChart")
- @test barchart !== nothing
+ sp = XLSX.Charts.parse_drawing_shape_props(wb, node)
+ @test sp !== nothing # the element exists
+ @test sp.fill === nothing # but sets nothing
+ @test sp.line === nothing
+ @test !XLSX.Charts.hasFill(sp)
+ @test !XLSX.Charts.hasLine(sp)
+ end
- ser = XLSX.first_element_with_tag(barchart, "ser")
- @test ser !== nothing
+ @testset "parse from parent, and no spPr at all" begin
+ ser = _frag("""
+
+
+ """)
+ sp = XLSX.Charts.parse_drawing_shape_props(wb, ser)
+ @test sp !== nothing
+ @test sp.fill.fgcolor.rgb == "156082" # accent1, as pinned elsewhere
- sp = XLSX.Charts.parse_drawing_shape_props(wb, ser)
- @test sp !== nothing
- @test sp.raw !== nothing
+ bare = _frag("""""")
+ @test XLSX.Charts.parse_drawing_shape_props(wb, bare) === nothing
- @test XLSX.Charts.hasFill(sp)
- @test sp.fill.kind == :solid
- @test sp.fill.fgcolor.rgb == "156082" # accent1
-
- # Excel writes an explicit "no border", which is not the same as
- # omitting a:ln. hasLine must be false while sp.line is not nothing.
- @test sp.line !== nothing
- @test sp.line.fill.kind == :none
- @test !XLSX.Charts.hasLine(sp)
-
- @test sp.effects !== nothing
- @test XLSX.localname(sp.effects) == "effectLst"
- if sp !== nothing
- @test sp.raw !== nothing
- end
- end
+ # chains without a guard
+ @test XLSX.Charts.parse_drawing_shape_props(wb, nothing) === nothing
end
- end
- @testset "SchemeColor" begin
- @testset "construction" begin
- @test XLSX.Charts.SchemeColor(:accent1).token === :accent1
- @test isempty(XLSX.Charts.SchemeColor(:accent1).transforms)
- @test XLSX.Charts.SchemeColor(:accent1; lumMod=75).transforms == [:lumMod => 75.0]
- @test XLSX.Charts.SchemeColor(:tx1; lumMod=65, lumOff=35).transforms ==
- [:lumMod => 65.0, :lumOff => 35.0]
- # integers convert
- @test XLSX.Charts.SchemeColor(:accent1; alpha=80).transforms == [:alpha => 80.0]
- # vector form takes any order
- @test XLSX.Charts.SchemeColor(:accent2, [:shade => 50.0, :alpha => 80.0]).transforms ==
- [:shade => 50.0, :alpha => 80.0]
- end
+ @testset "effects preserved, not modelled" begin
+ node = _frag("""
+
+
+ """)
- @testset "validation" begin
- @test_throws XLSX.XLSXError XLSX.Charts.SchemeColor(:accent7)
- @test_throws XLSX.XLSXError XLSX.Charts.SchemeColor(:acccent1) # typo, not silently accepted
- @test_throws XLSX.XLSXError XLSX.Charts.SchemeColor(:accent1, [:lumMud => 75.0])
- @test_throws XLSX.XLSXError XLSX.Charts.SchemeColor(:theme1) # spreadsheet vocabulary, not DrawingML
+ sp = XLSX.Charts.parse_drawing_shape_props(wb, node)
+ @test sp.effects !== nothing
+ @test XLSX.localname(sp.effects) == "effectLst"
end
- @testset "aliases" begin
- # preserved as written
- @test XLSX.Charts.SchemeColor(:lt1).token === :lt1
- @test XLSX.Charts.SchemeColor(:dk2).token === :dk2
- # but equal and equally hashed
- @test XLSX.Charts.SchemeColor(:lt1) == XLSX.Charts.SchemeColor(:bg1)
- @test XLSX.Charts.SchemeColor(:dk1) == XLSX.Charts.SchemeColor(:tx1)
- @test XLSX.Charts.SchemeColor(:lt2) == XLSX.Charts.SchemeColor(:bg2)
- @test XLSX.Charts.SchemeColor(:dk2) == XLSX.Charts.SchemeColor(:tx2)
- @test hash(XLSX.Charts.SchemeColor(:lt1)) == hash(XLSX.Charts.SchemeColor(:bg1))
- @test isequal(XLSX.Charts.SchemeColor(:lt1), XLSX.Charts.SchemeColor(:bg1))
- # so one Dict slot, not two
- d = Dict(XLSX.Charts.SchemeColor(:lt1) => 1)
- d[XLSX.Charts.SchemeColor(:bg1)] = 2
- @test length(d) == 1
- # aliases with matching transforms are still equal
- @test XLSX.Charts.SchemeColor(:lt1; lumMod=50) == XLSX.Charts.SchemeColor(:bg1; lumMod=50)
- # different slots are not
- @test XLSX.Charts.SchemeColor(:accent1) != XLSX.Charts.SchemeColor(:accent2)
+ @testset "pattern fill keeps DrawingML vocabulary" begin
+ node = _frag("""
+
+
+
+
+ """)
+
+ sp = XLSX.Charts.parse_drawing_shape_props(wb, node)
+ @test sp.fill.kind == :pattern
+ @test sp.fill.preset == "ltUpDiag" # not "lightUp"
+ @test XLSX.Charts.hasFill(sp)
end
+ end
- @testset "transform order is significant" begin
- a = XLSX.Charts.SchemeColor(:accent1; lumMod=75, lumOff=25)
- b = XLSX.Charts.SchemeColor(:accent1, [:lumOff => 25.0, :lumMod => 75.0])
- @test a.transforms != b.transforms
- @test a != b
- @test XLSX.Charts.SchemeColor(:accent1; lumMod=75) != XLSX.Charts.SchemeColor(:accent1; lumMod=50)
- @test XLSX.Charts.SchemeColor(:accent1) != XLSX.Charts.SchemeColor(:accent1; lumMod=75)
- end
+ @testset "fixture: series spPr" begin
+ XLSX.openxlsx(joinpath(data_directory, "chart_basic.xlsx")) do xf
+ wb = XLSX.get_workbook(xf)
+ c = first(XLSX.Charts.getCharts(xf["Data"]))
- xf = XLSX.openxlsx(joinpath(data_directory, "chart_appearance.xlsx"))
- wb = XLSX.get_workbook(xf[1])
- @testset "SchemeColor round trip" begin
-
- # resolve_color_base does the theme lookup only; the transforms are applied
- # separately, so composing the two is what a reader effectively does.
- resolved(wb, n) = first(XLSX.apply_drawingml_transforms(
- XLSX.Charts.resolve_color_base(wb, n),
- XLSX.Charts.parse_drawing_color(wb, n).transforms))
-
- # accent1, untransformed
- n = XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1))
- @test XML.write(n) == """"""
- dc = XLSX.Charts.parse_drawing_color(wb, n)
- @test dc.kind === :scheme && dc.val == "accent1"
-
- # accent1 + lumMod 75% — 104862 against the Office theme, verified in stage 2
- n = XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1; lumMod=75))
- @test occursin("""val="75000\"""", XML.write(n))
- dc = XLSX.Charts.parse_drawing_color(wb, n)
- @test length(dc.transforms) == 1
- @test XLSX.Charts.resolve_color_base(wb, n) == "156082"
-
- # tx1 + lumMod 65 / lumOff 35 — 595959, and order is preserved through the XML
- n = XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:tx1; lumMod=65, lumOff=35))
- @test XLSX.localname.(collect(XML.eachelement(n))) == ["lumMod", "lumOff"]
- @test XLSX.Charts.resolve_color_base(wb, n) == "000000"
-
- # what we build parses back to what we built
- for sc in (XLSX.Charts.SchemeColor(:accent1), XLSX.Charts.SchemeColor(:accent1; lumMod=75),
- XLSX.Charts.SchemeColor(:tx1; lumMod=65, lumOff=35),
- XLSX.Charts.SchemeColor(:accent2, [:shade => 50.0, :alpha => 80.0]))
- dc = XLSX.Charts.parse_drawing_color(wb, XLSX.Charts._scheme_color_node(sc))
- @test dc.val == String(sc.token)
- @test length(dc.transforms) == length(sc.transforms)
+ root = XLSX.xml_root_element(XLSX.get_xml_data(xf, c.path))
+ chart = XLSX.first_element_with_tag(root, "chart")
+ plotarea = XLSX.first_element_with_tag(chart, "plotArea")
+ barchart = XLSX.first_element_with_tag(plotarea, "barChart")
+ @test barchart !== nothing
+
+ ser = XLSX.first_element_with_tag(barchart, "ser")
+ @test ser !== nothing
+
+ sp = XLSX.Charts.parse_drawing_shape_props(wb, ser)
+ @test sp !== nothing
+ @test sp.raw !== nothing
+
+ @test XLSX.Charts.hasFill(sp)
+ @test sp.fill.kind == :solid
+ @test sp.fill.fgcolor.rgb == "156082" # accent1
+
+ # Excel writes an explicit "no border", which is not the same as
+ # omitting a:ln. hasLine must be false while sp.line is not nothing.
+ @test sp.line !== nothing
+ @test sp.line.fill.kind == :none
+ @test !XLSX.Charts.hasLine(sp)
+
+ @test sp.effects !== nothing
+ @test XLSX.localname(sp.effects) == "effectLst"
+ if sp !== nothing
+ @test sp.raw !== nothing
end
+ end
+ end
+end
- @test XLSX.Charts.resolve_color_base(wb, XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1))) == "156082"
- @test resolved(wb, XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1; lumMod=75))) == "104862"
- @test resolved(wb, XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:tx1; lumMod=65, lumOff=35))) == "595959"
+@testset "SchemeColor and DrawingML writers" begin
+ @testset "construction" begin
+ @test XLSX.Charts.SchemeColor(:accent1).token === :accent1
+ @test isempty(XLSX.Charts.SchemeColor(:accent1).transforms)
+ @test XLSX.Charts.SchemeColor(:accent1; lumMod=75).transforms == [:lumMod => 75.0]
+ @test XLSX.Charts.SchemeColor(:tx1; lumMod=65, lumOff=35).transforms ==
+ [:lumMod => 65.0, :lumOff => 35.0]
+ # integers convert
+ @test XLSX.Charts.SchemeColor(:accent1; alpha=80).transforms == [:alpha => 80.0]
+ # vector form takes any order
+ @test XLSX.Charts.SchemeColor(:accent2, [:shade => 50.0, :alpha => 80.0]).transforms ==
+ [:shade => 50.0, :alpha => 80.0]
+ end
- # solidFill wrapper
- f = XLSX.Charts.parse_drawing_fill(wb, XLSX.Charts._solid_fill_node(XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1))))
- @test f.kind === :solid && f.fgcolor.val == "accent1"
+ @testset "validation" begin
+ @test_throws XLSX.XLSXError XLSX.Charts.SchemeColor(:accent7)
+ @test_throws XLSX.XLSXError XLSX.Charts.SchemeColor(:acccent1) # typo, not silently accepted
+ @test_throws XLSX.XLSXError XLSX.Charts.SchemeColor(:accent1, [:lumMud => 75.0])
+ @test_throws XLSX.XLSXError XLSX.Charts.SchemeColor(:theme1) # spreadsheet vocabulary, not DrawingML
+ end
- # alpha rides alongside the hex rather than altering it
- n = XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1; alpha=50))
- hex, alpha = XLSX.apply_drawingml_transforms(
- XLSX.Charts.resolve_color_base(wb, n), XLSX.Charts.parse_drawing_color(wb, n).transforms)
- @test hex == "156082"
- @test alpha ≈ 0.5
- end
+ @testset "aliases" begin
+ # preserved as written
+ @test XLSX.Charts.SchemeColor(:lt1).token === :lt1
+ @test XLSX.Charts.SchemeColor(:dk2).token === :dk2
+ # but equal and equally hashed
+ @test XLSX.Charts.SchemeColor(:lt1) == XLSX.Charts.SchemeColor(:bg1)
+ @test XLSX.Charts.SchemeColor(:dk1) == XLSX.Charts.SchemeColor(:tx1)
+ @test XLSX.Charts.SchemeColor(:lt2) == XLSX.Charts.SchemeColor(:bg2)
+ @test XLSX.Charts.SchemeColor(:dk2) == XLSX.Charts.SchemeColor(:tx2)
+ @test hash(XLSX.Charts.SchemeColor(:lt1)) == hash(XLSX.Charts.SchemeColor(:bg1))
+ @test isequal(XLSX.Charts.SchemeColor(:lt1), XLSX.Charts.SchemeColor(:bg1))
+ # so one Dict slot, not two
+ d = Dict(XLSX.Charts.SchemeColor(:lt1) => 1)
+ d[XLSX.Charts.SchemeColor(:bg1)] = 2
+ @test length(d) == 1
+ # aliases with matching transforms are still equal
+ @test XLSX.Charts.SchemeColor(:lt1; lumMod=50) == XLSX.Charts.SchemeColor(:bg1; lumMod=50)
+ # different slots are not
+ @test XLSX.Charts.SchemeColor(:accent1) != XLSX.Charts.SchemeColor(:accent2)
+ end
- @testset "srgb color nodes" begin
- # 8-digit hex passes through, split into six digits plus alpha
- @test XML.write(XLSX.Charts._srgb_color_node("FFFF0000")) == """"""
- @test XML.write(XLSX.Charts._srgb_color_node("ffff0000")) == """""" # case
-
- # Colors.jl names
- @test XML.write(XLSX.Charts._srgb_color_node("red")) == """"""
- @test XML.write(XLSX.Charts._srgb_color_node(:red)) == """"""
- @test XML.write(XLSX.Charts._srgb_color_node("grey")) == XML.write(XLSX.Charts._srgb_color_node("gray"))
-
- # opaque emits no alpha child
- @test isnothing(XLSX.Charts._srgb_color_node("FF0000FF").children) ||
- isempty(XLSX.Charts._srgb_color_node("FF0000FF").children)
-
- # partial alpha becomes a transform
- n = XLSX.Charts._srgb_color_node("800000FF")
- @test XLSX.localname.(collect(XML.eachelement(n))) == ["alpha"]
- @test XLSX.get_attr(first(XML.eachelement(n)), "val") == "50196" # 128/255
-
- # colorants, opaque and transparent
- @test XML.write(XLSX.Charts._srgb_color_node(Colors.RGB(1, 0, 0))) == """"""
- n = XLSX.Charts._srgb_color_node(Colors.ARGB(1, 0, 0, 0.5))
- @test XLSX.get_attr(n, "val") == "FF0000"
- @test !isempty(XML.children(n))
-
- # invalid names throw get_color's message
- @test_throws XLSX.XLSXError XLSX.Charts._srgb_color_node("notacolor")
-
- # round trip through the parser
- dc = XLSX.Charts.parse_drawing_color(wb, XLSX.Charts._srgb_color_node("red"))
- @test dc.kind === :srgb && dc.rgb == "FF0000"
- end
+ @testset "transform order is significant" begin
+ a = XLSX.Charts.SchemeColor(:accent1; lumMod=75, lumOff=25)
+ b = XLSX.Charts.SchemeColor(:accent1, [:lumOff => 25.0, :lumMod => 75.0])
+ @test a.transforms != b.transforms
+ @test a != b
+ @test XLSX.Charts.SchemeColor(:accent1; lumMod=75) != XLSX.Charts.SchemeColor(:accent1; lumMod=50)
+ @test XLSX.Charts.SchemeColor(:accent1) != XLSX.Charts.SchemeColor(:accent1; lumMod=75)
+ end
- @testset "_ln_with_*" begin
- pfx = Dict(XLSX.NS_A => "a")
- ln() = XML.Element("a:ln")
-
- @test XLSX.get_attr(XLSX.Charts._ln_with_width(ln(), 2), "w") == "25400"
- @test_throws XLSX.XLSXError XLSX.Charts._ln_with_width(ln(), 2000)
- @test_throws XLSX.XLSXError XLSX.Charts._ln_with_width(ln(), -1)
- @test XLSX.get_attr(XLSX.Charts._ln_with_width(XLSX.Charts._ln_with_width(ln(), 2), :inherit), "w") == ""
-
- # Excel names and DrawingML names both work, and resolve to the same thing
- @test XML.write(XLSX.Charts._ln_with_dash(ln(), :roundDot, pfx)) ==
- XML.write(XLSX.Charts._ln_with_dash(ln(), :sysDot, pfx))
- @test_throws XLSX.XLSXError XLSX.Charts._ln_with_dash(ln(), :dotted, pfx)
-
- @test XLSX.get_attr(XLSX.Charts._ln_with_cap(ln(), :round), "cap") == "rnd"
- @test XLSX.get_attr(XLSX.Charts._ln_with_cap(ln(), :rnd), "cap") == "rnd"
- @test XLSX.get_attr(XLSX.Charts._ln_with_compound(ln(), :double), "cmpd") == "dbl"
-
- # join, and the limit that depends on it
- l = XLSX.Charts._ln_with_join(ln(), :miter, pfx)
- @test XLSX.localname.(collect(XML.eachelement(l))) == ["miter"]
- @test XLSX.get_attr(first(XML.eachelement(XLSX.Charts._ln_with_miter_limit(l, 8))), "lim") == "800000"
- @test_throws XLSX.XLSXError XLSX.Charts._ln_with_miter_limit(ln(), 8)
- @test_throws XLSX.XLSXError XLSX.Charts._ln_with_miter_limit(XLSX.Charts._ln_with_join(ln(), :bevel, pfx), 8)
-
- # schema order: fill, dash, join
- full = XLSX.Charts._ln_with_join(XLSX.Charts._ln_with_dash(XLSX.Charts._ln_with_color(ln(), "red", pfx), :dash, pfx), :round, pfx)
- @test XLSX.localname.(collect(XML.eachelement(full))) == ["solidFill", "prstDash", "round"]
-
- # each choice group replaces rather than accumulates
- @test XLSX.localname.(collect(XML.eachelement(
- XLSX.Charts._ln_with_color(XLSX.Charts._ln_with_color(ln(), "red", pfx), :none, pfx)))) == ["noFill"]
+ xf = XLSX.openxlsx(joinpath(data_directory, "chart_appearance.xlsx"))
+ wb = XLSX.get_workbook(xf[1])
+ @testset "SchemeColor round trip" begin
+
+ # resolve_color_base does the theme lookup only; the transforms are applied
+ # separately, so composing the two is what a reader effectively does.
+ resolved(wb, n) = first(XLSX.apply_drawingml_transforms(
+ XLSX.Charts.resolve_color_base(wb, n),
+ XLSX.Charts.parse_drawing_color(wb, n).transforms))
+
+ # accent1, untransformed
+ n = XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1))
+ @test XML.write(n) == """"""
+ dc = XLSX.Charts.parse_drawing_color(wb, n)
+ @test dc.kind === :scheme && dc.val == "accent1"
+
+ # accent1 + lumMod 75% — 104862 against the Office theme, verified in stage 2
+ n = XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1; lumMod=75))
+ @test occursin("""val="75000\"""", XML.write(n))
+ dc = XLSX.Charts.parse_drawing_color(wb, n)
+ @test length(dc.transforms) == 1
+ @test XLSX.Charts.resolve_color_base(wb, n) == "156082"
+
+ # tx1 + lumMod 65 / lumOff 35 — 595959, and order is preserved through the XML
+ n = XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:tx1; lumMod=65, lumOff=35))
+ @test XLSX.localname.(collect(XML.eachelement(n))) == ["lumMod", "lumOff"]
+ @test XLSX.Charts.resolve_color_base(wb, n) == "000000"
+
+ # what we build parses back to what we built
+ for sc in (XLSX.Charts.SchemeColor(:accent1), XLSX.Charts.SchemeColor(:accent1; lumMod=75),
+ XLSX.Charts.SchemeColor(:tx1; lumMod=65, lumOff=35),
+ XLSX.Charts.SchemeColor(:accent2, [:shade => 50.0, :alpha => 80.0]))
+ dc = XLSX.Charts.parse_drawing_color(wb, XLSX.Charts._scheme_color_node(sc))
+ @test dc.val == String(sc.token)
+ @test length(dc.transforms) == length(sc.transforms)
end
- @testset "text body round trip" begin
- pfx = Dict(XLSX.NS_A => "a", XLSX.Charts.NS_C => "c")
-
- rt(t) = XLSX.Charts.parse_drawing_text(wb, XLSX.Charts._text_from(t, "txPr", pfx))
-
- # plain text
- t = XLSX.Charts.DrawingText("Revenue by Region")
- g = rt(t)
- @test length(g.paragraphs) == 1
- @test XLSX.Charts.text_content(g) == "Revenue by Region"
-
- # run properties survive, in both directions
- t = XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph(
- XLSX.Charts.DrawingRun("Revenue", props=XLSX.Charts.DrawingRunProps(size=14.0, bold=true,
- latin="Calibri"))))
- g = rt(t)
- rp = XLSX.Charts.first_run_props(g)
- @test rp.size ≈ 14.0 && rp.bold === true && rp.latin == "Calibri"
- @test isnothing(rp.italic) # absent stays absent
-
- # paragraph defaults
- t = XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph("x",
- props=XLSX.Charts.DrawingParaProps(align="ctr",
- defprops=XLSX.Charts.DrawingRunProps(size=10.5))))
- g = rt(t)
- @test g.paragraphs[1].props.align == "ctr"
- @test g.paragraphs[1].props.defprops.size ≈ 10.5
-
- # a solid fill on a run, scheme and srgb
- for color in (XLSX.Charts.DrawingColor(:srgb, "FF0000", Pair{Symbol,Int}[], "FF0000", 1.0),
- XLSX.Charts.DrawingColor(:scheme, "accent1", [:lumMod => 75000], "104862", 1.0))
- t = XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph(XLSX.Charts.DrawingRun("x",
- props=XLSX.Charts.DrawingRunProps(fill=XLSX.Charts.DrawingFill(:solid; fgcolor=color)))))
- f = XLSX.Charts.first_run_props(rt(t)).fill
- @test f.kind === :solid
- @test f.fgcolor.val == color.val
- @test f.fgcolor.transforms == color.transforms
- end
+ @test XLSX.Charts.resolve_color_base(wb, XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1))) == "156082"
+ @test resolved(wb, XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1; lumMod=75))) == "104862"
+ @test resolved(wb, XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:tx1; lumMod=65, lumOff=35))) == "595959"
- # a text outline
- t = XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph(XLSX.Charts.DrawingRun("x",
- props=XLSX.Charts.DrawingRunProps(line=XLSX.Charts.DrawingLine(width=1.5, dash="sysDot")))))
- l = XLSX.Charts.first_run_props(rt(t)).line
- @test l.width ≈ 1.5 && l.dash == "sysDot"
-
- # body properties, including the three-way autofit
- for (af, extra) in ((:none, ()), (:shape, ()),
- (:normal, (fontscale=0.9, linespacereduction=0.1)))
- t = XLSX.Charts.DrawingText("x"; body=XLSX.Charts.DrawingBodyProps(; rotation=-45.0, anchor="ctr",
- autofit=af, extra...))
- b = rt(t).body
- @test b.rotation ≈ -45.0 && b.anchor == "ctr" && b.autofit === af
- end
- b = rt(XLSX.Charts.DrawingText("x"; body=XLSX.Charts.DrawingBodyProps(autofit=:normal, fontscale=0.9))).body
- @test b.fontscale ≈ 0.9
-
- # several runs keep document order
- t = XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph("one", XLSX.Charts.DrawingRun("\n", kind=:br), "two"))
- g = rt(t)
- @test [r.kind for r in g.paragraphs[1].runs] == [:run, :br, :run]
- @test XLSX.Charts.text_content(g) == "one\ntwo"
-
- # schema order inside a:p and a:defRPr
- n = XLSX.Charts._text_from(XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph("x",
- props=XLSX.Charts.DrawingParaProps(defprops=XLSX.Charts.DrawingRunProps(size=10.0)))), "txPr", pfx)
- p = XLSX.first_element_with_tag(n, "p")
- @test XLSX.localname.(collect(XML.eachelement(p))) == ["pPr", "r"]
-
- # what cannot be written says so
- @test_throws XLSX.XLSXError XLSX.Charts._fill_node_from(XLSX.Charts.DrawingFill(:gradient), pfx)
- @test_throws XLSX.XLSXError XLSX.Charts._color_node_from(
- XLSX.Charts.DrawingColor(:scrgb, "", Pair{Symbol,Int}[], "000000", 1.0), pfx)
- end
+ # solidFill wrapper
+ f = XLSX.Charts.parse_drawing_fill(wb, XLSX.Charts._solid_fill_node(XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1))))
+ @test f.kind === :solid && f.fgcolor.val == "accent1"
+ # alpha rides alongside the hex rather than altering it
+ n = XLSX.Charts._scheme_color_node(XLSX.Charts.SchemeColor(:accent1; alpha=50))
+ hex, alpha = XLSX.apply_drawingml_transforms(
+ XLSX.Charts.resolve_color_base(wb, n), XLSX.Charts.parse_drawing_color(wb, n).transforms)
+ @test hex == "156082"
+ @test alpha ≈ 0.5
end
- @testset "text_content reads a raw body as it reads a parsed one" begin
- xml = """""" *
- """OneTwo""" *
- """Three!""" *
- """"""
- tx = XLSX.xml_root_element(parse(xml, XLSX.XML.Node))
- rich = XLSX.first_element_with_tag(tx, "rich")
- wb = XLSX.get_workbook(XLSX.newxlsx())
- @test XLSX.Charts.text_content(rich) == "One\nTwo\nThree!"
- @test XLSX.Charts.text_content(rich) == XLSX.Charts.text_content(XLSX.Charts.parse_drawing_text(wb, tx; tag="rich"))
+ @testset "srgb color nodes" begin
+ # 8-digit hex passes through, split into six digits plus alpha
+ @test XML.write(XLSX.Charts._srgb_color_node("FFFF0000")) == """"""
+ @test XML.write(XLSX.Charts._srgb_color_node("ffff0000")) == """""" # case
+
+ # Colors.jl names
+ @test XML.write(XLSX.Charts._srgb_color_node("red")) == """"""
+ @test XML.write(XLSX.Charts._srgb_color_node(:red)) == """"""
+ @test XML.write(XLSX.Charts._srgb_color_node("grey")) == XML.write(XLSX.Charts._srgb_color_node("gray"))
+
+ # opaque emits no alpha child
+ @test isnothing(XLSX.Charts._srgb_color_node("FF0000FF").children) ||
+ isempty(XLSX.Charts._srgb_color_node("FF0000FF").children)
+
+ # partial alpha becomes a transform
+ n = XLSX.Charts._srgb_color_node("800000FF")
+ @test XLSX.localname.(collect(XML.eachelement(n))) == ["alpha"]
+ @test XLSX.get_attr(first(XML.eachelement(n)), "val") == "50196" # 128/255
+
+ # colorants, opaque and transparent
+ @test XML.write(XLSX.Charts._srgb_color_node(Colors.RGB(1, 0, 0))) == """"""
+ n = XLSX.Charts._srgb_color_node(Colors.ARGB(1, 0, 0, 0.5))
+ @test XLSX.get_attr(n, "val") == "FF0000"
+ @test !isempty(XML.children(n))
+
+ # invalid names throw get_color's message
+ @test_throws XLSX.XLSXError XLSX.Charts._srgb_color_node("notacolor")
+
+ # round trip through the parser
+ dc = XLSX.Charts.parse_drawing_color(wb, XLSX.Charts._srgb_color_node("red"))
+ @test dc.kind === :srgb && dc.rgb == "FF0000"
end
- @testset "show methods: DrawingLine and ChartMarker" begin
- # Both types previously fell back to Julia's default struct printing, which
- # dumps `raw`. These pin the summary and the multi-line forms.
- plain(x) = sprint(show, MIME("text/plain"), x)
-
- @testset "DrawingLine" begin
- # Built in code, so no `raw` and nothing inherited from a file.
- ln = XLSX.Charts.DrawingLine(; width=2.25, dash="sysDot", cap="rnd")
- @test repr(ln) == "XLSX.Charts.DrawingLine(2.25pt, sysDot)"
- s = plain(ln)
- @test occursin("stroke: inherited", s)
- @test occursin("width: 2.25 pt", s)
- @test occursin("dash: sysDot", s)
- @test occursin("cap: rnd", s)
- @test !occursin("compound", s) # absent fields are not printed
- @test !occursin("miter", s)
-
- @test repr(XLSX.Charts.DrawingLine()) == "XLSX.Charts.DrawingLine(inherited)"
-
- none = XLSX.Charts.DrawingLine(; fill=XLSX.Charts.DrawingFill(:none))
- @test repr(none) == "XLSX.Charts.DrawingLine(no stroke)"
- @test occursin("stroke: none", plain(none))
-
- red = XLSX.Charts.DrawingLine(;
- fill=XLSX.Charts.DrawingFill(:solid; fgcolor="FF0000"),
- width=1.5)
- @test occursin("1.5pt", repr(red))
- @test occursin("stroke: ", plain(red))
+ @testset "_ln_with_*" begin
+ pfx = Dict(XLSX.NS_A => "a")
+ ln() = XML.Element("a:ln")
+
+ @test XLSX.get_attr(XLSX.Charts._ln_with_width(ln(), 2), "w") == "25400"
+ @test_throws XLSX.XLSXError XLSX.Charts._ln_with_width(ln(), 2000)
+ @test_throws XLSX.XLSXError XLSX.Charts._ln_with_width(ln(), -1)
+ @test XLSX.get_attr(XLSX.Charts._ln_with_width(XLSX.Charts._ln_with_width(ln(), 2), :inherit), "w") == ""
+
+ # Excel names and DrawingML names both work, and resolve to the same thing
+ @test XML.write(XLSX.Charts._ln_with_dash(ln(), :roundDot, pfx)) ==
+ XML.write(XLSX.Charts._ln_with_dash(ln(), :sysDot, pfx))
+ @test_throws XLSX.XLSXError XLSX.Charts._ln_with_dash(ln(), :dotted, pfx)
+
+ @test XLSX.get_attr(XLSX.Charts._ln_with_cap(ln(), :round), "cap") == "rnd"
+ @test XLSX.get_attr(XLSX.Charts._ln_with_cap(ln(), :rnd), "cap") == "rnd"
+ @test XLSX.get_attr(XLSX.Charts._ln_with_compound(ln(), :double), "cmpd") == "dbl"
+
+ # join, and the limit that depends on it
+ l = XLSX.Charts._ln_with_join(ln(), :miter, pfx)
+ @test XLSX.localname.(collect(XML.eachelement(l))) == ["miter"]
+ @test XLSX.get_attr(first(XML.eachelement(XLSX.Charts._ln_with_miter_limit(l, 8))), "lim") == "800000"
+ @test_throws XLSX.XLSXError XLSX.Charts._ln_with_miter_limit(ln(), 8)
+ @test_throws XLSX.XLSXError XLSX.Charts._ln_with_miter_limit(XLSX.Charts._ln_with_join(ln(), :bevel, pfx), 8)
+
+ # schema order: fill, dash, join
+ full = XLSX.Charts._ln_with_join(XLSX.Charts._ln_with_dash(XLSX.Charts._ln_with_color(ln(), "red", pfx), :dash, pfx), :round, pfx)
+ @test XLSX.localname.(collect(XML.eachelement(full))) == ["solidFill", "prstDash", "round"]
+
+ # each choice group replaces rather than accumulates
+ @test XLSX.localname.(collect(XML.eachelement(
+ XLSX.Charts._ln_with_color(XLSX.Charts._ln_with_color(ln(), "red", pfx), :none, pfx)))) == ["noFill"]
+ end
- # Read from a file: the line on chart_appearance's first series is an
- # explicit , not an absent element.
- xf = XLSX.readxlsx(joinpath(data_directory, "chart_appearance.xlsx"))
- c = XLSX.Charts.getCharts(xf)[1]
- fl = XLSX.Charts.getSeriesShapeProps(c, 1).line
- @test repr(fl) == "XLSX.Charts.DrawingLine(no stroke)"
- @test !occursin("Element", plain(fl)) # `raw` stays out of the output
+ @testset "text body round trip" begin
+ pfx = Dict(XLSX.NS_A => "a", XLSX.Charts.NS_C => "c")
+
+ rt(t) = XLSX.Charts.parse_drawing_text(wb, XLSX.Charts._text_from(t, "txPr", pfx))
+
+ # plain text
+ t = XLSX.Charts.DrawingText("Revenue by Region")
+ g = rt(t)
+ @test length(g.paragraphs) == 1
+ @test XLSX.Charts.text_content(g) == "Revenue by Region"
+
+ # run properties survive, in both directions
+ t = XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph(
+ XLSX.Charts.DrawingRun("Revenue", props=XLSX.Charts.DrawingRunProps(size=14.0, bold=true,
+ latin="Calibri"))))
+ g = rt(t)
+ rp = XLSX.Charts.first_run_props(g)
+ @test rp.size ≈ 14.0 && rp.bold === true && rp.latin == "Calibri"
+ @test isnothing(rp.italic) # absent stays absent
+
+ # paragraph defaults
+ t = XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph("x",
+ props=XLSX.Charts.DrawingParaProps(align="ctr",
+ defprops=XLSX.Charts.DrawingRunProps(size=10.5))))
+ g = rt(t)
+ @test g.paragraphs[1].props.align == "ctr"
+ @test g.paragraphs[1].props.defprops.size ≈ 10.5
+
+ # a solid fill on a run, scheme and srgb
+ for color in (XLSX.Charts.DrawingColor(:srgb, "FF0000", Pair{Symbol,Int}[], "FF0000", 1.0),
+ XLSX.Charts.DrawingColor(:scheme, "accent1", [:lumMod => 75000], "104862", 1.0))
+ t = XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph(XLSX.Charts.DrawingRun("x",
+ props=XLSX.Charts.DrawingRunProps(fill=XLSX.Charts.DrawingFill(:solid; fgcolor=color)))))
+ f = XLSX.Charts.first_run_props(rt(t)).fill
+ @test f.kind === :solid
+ @test f.fgcolor.val == color.val
+ @test f.fgcolor.transforms == color.transforms
end
-
+ # a text outline
+ t = XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph(XLSX.Charts.DrawingRun("x",
+ props=XLSX.Charts.DrawingRunProps(line=XLSX.Charts.DrawingLine(width=1.5, dash="sysDot")))))
+ l = XLSX.Charts.first_run_props(rt(t)).line
+ @test l.width ≈ 1.5 && l.dash == "sysDot"
+
+ # body properties, including the three-way autofit
+ for (af, extra) in ((:none, ()), (:shape, ()),
+ (:normal, (fontscale=0.9, linespacereduction=0.1)))
+ t = XLSX.Charts.DrawingText("x"; body=XLSX.Charts.DrawingBodyProps(; rotation=-45.0, anchor="ctr",
+ autofit=af, extra...))
+ b = rt(t).body
+ @test b.rotation ≈ -45.0 && b.anchor == "ctr" && b.autofit === af
+ end
+ b = rt(XLSX.Charts.DrawingText("x"; body=XLSX.Charts.DrawingBodyProps(autofit=:normal, fontscale=0.9))).body
+ @test b.fontscale ≈ 0.9
+
+ # several runs keep document order
+ t = XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph("one", XLSX.Charts.DrawingRun("\n", kind=:br), "two"))
+ g = rt(t)
+ @test [r.kind for r in g.paragraphs[1].runs] == [:run, :br, :run]
+ @test XLSX.Charts.text_content(g) == "one\ntwo"
+
+ # schema order inside a:p and a:defRPr
+ n = XLSX.Charts._text_from(XLSX.Charts.DrawingText(XLSX.Charts.DrawingParagraph("x",
+ props=XLSX.Charts.DrawingParaProps(defprops=XLSX.Charts.DrawingRunProps(size=10.0)))), "txPr", pfx)
+ p = XLSX.first_element_with_tag(n, "p")
+ @test XLSX.localname.(collect(XML.eachelement(p))) == ["pPr", "r"]
+
+ # what cannot be written says so
+ @test_throws XLSX.XLSXError XLSX.Charts._fill_node_from(XLSX.Charts.DrawingFill(:gradient), pfx)
+ @test_throws XLSX.XLSXError XLSX.Charts._color_node_from(
+ XLSX.Charts.DrawingColor(:scrgb, "", Pair{Symbol,Int}[], "000000", 1.0), pfx)
end
-end
\ No newline at end of file
+end
diff --git a/test/test_files/Edit_tests.jl b/test/test_files/Edit_tests.jl
deleted file mode 100644
index c62e09d2..00000000
--- a/test/test_files/Edit_tests.jl
+++ /dev/null
@@ -1,58 +0,0 @@
-@testset "Edit Template" begin
- new_filename = "new_file_from_empty_template.xlsx"
- f = XLSX.open_empty_template()
- f["Sheet1"]["A1"] = "Hello"
- f["Sheet1"]["A2"] = 10
- XLSX.writexlsx(new_filename, f, overwrite=true)
- SAVE_FILES && save_outfile(new_filename)
-
- f = XLSX.readxlsx(new_filename)
- @test f["Sheet1"]["A1"] == "Hello"
- @test f["Sheet1"]["A2"] == 10
-
- isfile(new_filename) && rm(new_filename)
-end
-
-@testset "Edit" begin
- f = XLSX.open_xlsx_template(joinpath(data_directory, "general.xlsx"))
- s = f["general"]
- @test_throws XLSX.XLSXError s["A1"] = :sym
- XLSX.renamesheet!(s, "general") # no-op
- @test_throws XLSX.XLSXError XLSX.renamesheet!(s, "table") # name is taken
- XLSX.renamesheet!(s, "renamed_sheet") # retain old function to avoid breaking change
- @test s.name == "renamed_sheet"
- s["A1"] = "Hey You!"
- s["B1"] = "Out there in the cold..."
- s["A2"] = "Getting lonely getting old..."
- s["B2"] = "Can you feel me?"
- s["A3"] = 1000
- s["B3"] = 99.99
-
- # create a new sheet
- s = XLSX.addsheet!(f, "my_new_sheet_1")
- s = XLSX.addsheet!(f, "my_new_sheet_2")
- s["B1"] = "This is a new sheet"
- s["B2"] = "This is a new sheet"
- s = XLSX.addsheet!(f)
- s["B1"] = "unnamed sheet"
-
- XLSX.writexlsx("general_copy_2.xlsx", f, overwrite=true)
- SAVE_FILES && save_outfile("general_copy_2.xlsx")
- @test isfile("general_copy_2.xlsx")
-
- XLSX.openxlsx("general_copy_2.xlsx") do f
- s = f["renamed_sheet"]
- @test s["A1"] == "Hey You!"
- @test s["B1"] == "Out there in the cold..."
- @test s["A2"] == "Getting lonely getting old..."
- @test s["B2"] == "Can you feel me?"
- @test s["A3"] == 1000
- @test s["B3"] == 99.99
- f["my_new_sheet_1"]
- @test f["my_new_sheet_2"]["B1"] == "This is a new sheet"
- @test f["my_new_sheet_2"]["B2"] == "This is a new sheet"
- @test f["Sheet1"]["B1"] == "unnamed sheet"
- end
-
- isfile("general_copy_2.xlsx") && rm("general_copy_2.xlsx")
-end
diff --git a/test/test_files/Error-branches_tests.jl b/test/test_files/Error-branches_tests.jl
deleted file mode 100644
index d85ef65e..00000000
--- a/test/test_files/Error-branches_tests.jl
+++ /dev/null
@@ -1,213 +0,0 @@
-# Error and rarely taken branches: bad input, read-only files, files opened without
-# the cache, and the style updates `setdata!` makes when a value replaces another
-# of a different kind.
-
-@testset "error branches" begin
-
- general = joinpath(data_directory, "general.xlsx")
-
- @testset "saving and writing" begin
- @test_throws XLSX.XLSXError XLSX.savexlsx(XLSX.newxlsx()) # blank: no file name
-
- outfile = "errbranch_exists.xlsx"
- XLSX.writetable(outfile, [[1, 2]], ["a"]; overwrite=true)
- @test_throws XLSX.XLSXError XLSX.writetable(outfile; Sheet1=([[1]], ["a"]))
- @test_throws XLSX.XLSXError XLSX.writetable(outfile, [("Sheet1", [[1]], ["a"])])
- SAVE_FILES && save_outfile(outfile)
- isfile(outfile) && rm(outfile)
- end
-
- @testset "setdata! - references" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
- sh[1:3, 1:3] = reshape(collect(1:9), 3, 3)
-
- XLSX.addDefName(sh, "ws_const", 5)
- XLSX.addDefName(xf, "wb_const", 6)
- @test_throws XLSX.XLSXError XLSX.setdata!(sh, "ws_const", 1)
- @test_throws XLSX.XLSXError XLSX.setdata!(sh, "wb_const", 1)
- @test_throws XLSX.XLSXError XLSX.setdata!(sh, "not a ref", 1)
-
- XLSX.setdata!(sh, "B:B", 0)
- @test all(==(0), sh["B1:B3"])
- XLSX.setdata!(sh, "3:3", 7)
- @test all(==(7), sh["A3:C3"])
- XLSX.setdata!(sh, "A1,C1", 9)
- @test sh["A1"] == 9 && sh["C1"] == 9
-
- @test_throws XLSX.XLSXError XLSX.setdata!(sh, "not a ref", [1 2; 3 4])
- @test_throws XLSX.XLSXError XLSX.setdata!(sh, "not a ref", [1, 2], 1) # was an UndefVarError
- @test_throws XLSX.XLSXError XLSX.target_cell_ref_from_offset(1, 1, 1, 3)
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "setdata! - a new value restyles an existing cell" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
- sh["A1"] = 1.5
- XLSX.setFormat(sh, "A1"; format="0.00")
- sh["A1"] = Dates.Time(12, 30)
- @test sh["A1"] == Dates.Time(12, 30)
- sh["A2"] = 1.5
- XLSX.setFormat(sh, "A2"; format="0.00")
- sh["A2"] = Dates.DateTime(2024, 1, 2, 3, 4)
- @test sh["A2"] == Dates.DateTime(2024, 1, 2, 3, 4)
- sh["A3"] = Dates.Date(2024, 1, 2)
- sh["A3"] = true
- @test sh["A3"] === true
-
- XLSX.setdata!(sh, XLSX.CellRef("B1"), XLSX.Formula("A2+1")) # new cell
- @test XLSX.getcell(sh, "B1").formula
- sh["B2"] = 1
- XLSX.setFormat(sh, "B2"; format="0.00")
- XLSX.setdata!(sh, XLSX.CellRef("B2"), XLSX.Formula("A2+2")) # styled cell
- @test XLSX.getcell(sh, "B2").formula
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "read-only and no-cache files" begin
- ro = XLSX.readxlsx(general)
- s = ro[1]
- @test_throws XLSX.XLSXError XLSX.setFormula(s, "A1", "=1+1")
- @test_throws XLSX.XLSXError XLSX.setFormula(s, "A1:A2", "=1+1")
- @test_throws XLSX.XLSXError XLSX.isMergedCell(s, "A1")
- @test_throws XLSX.XLSXError XLSX.getMergedBaseCell(s, "A1")
- @test_throws XLSX.XLSXError XLSX.mergeCells(s, "A1:B2")
-
- XLSX.openxlsx(general; enable_cache=false) do nc
- s = nc[1]
- @test_throws XLSX.XLSXError XLSX.getMergedCells(s)
- end
- end
-
- @testset "merged cells and row heights" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
- sh[1:2, 1:2] = [1 2; 3 4]
- @test_throws XLSX.XLSXError XLSX.isMergedCell(sh, "Z99")
- @test_throws XLSX.XLSXError XLSX.getMergedBaseCell(sh, "Z99")
- @test isnothing(XLSX.getMergedBaseCell(sh, "A1")) # no merged cells at all
-
- @test_throws XLSX.XLSXError XLSX.setRowHeight(sh, "A1"; height=-1)
- @test XLSX.setRowHeight(sh, "A1") == 0 # no height: no-op
- @test XLSX.setRowHeight(sh, "A10"; height=20) == -1 # row has no cells
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "cell references and ranges" begin
- # out-of-range column and row numbers are rejected
- @test_throws XLSX.XLSXError XLSX.encode_column_number(0)
- @test_throws XLSX.XLSXError XLSX.encode_column_number(XLSX.EXCEL_MAX_COLS + 1)
- @test XLSX.encode_column_number(XLSX.EXCEL_MAX_COLS) == "XFD"
- @test_throws XLSX.XLSXError XLSX.is_valid_row_range(string(XLSX.EXCEL_MAX_ROWS + 1))
- @test XLSX.is_valid_row_range(string(XLSX.EXCEL_MAX_ROWS))
- @test_throws XLSX.XLSXError XLSX.split_cellname("A")
-
- @test !XLSX.is_valid_row_name("0")
- @test !XLSX.is_valid_cellrange("A0:B2") && !XLSX.is_valid_cellrange("A1:B0")
- @test !XLSX.is_valid_column_range("A:ZZZZ")
- @test !XLSX.is_valid_row_range("0:2")
- @test !XLSX.is_valid_sheet_cellrange("Sheet1!A0:B2")
- @test !XLSX.is_valid_sheet_column_range("Sheet1!A:ZZZZ")
- @test !XLSX.is_valid_sheet_row_range("Sheet1!0:2")
-
- @test sprint(show, XLSX.SheetRowRange("Sheet1!2:4")) == "Sheet1!2:4"
- @test_throws XLSX.XLSXError XLSX.NonContiguousRange("Sheet1!A1,Sheet2!B2")
- @test_throws XLSX.XLSXError XLSX.nCR("Sheet1", ["not a ref"])
- nc = XLSX.nCR("Sheet1", ["\$A\$1", "\$B\$1:\$C\$2"])
- @test nc.rng == [XLSX.CellRef("A1"), XLSX.CellRange("B1:C2")]
- end
-
- @testset "defined names" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
- wb = XLSX.get_workbook(xf)
- @test_throws XLSX.XLSXError XLSX.get_defined_name_value(wb, "missing_name")
- @test_throws XLSX.XLSXError XLSX.get_defined_name_value(sh, "missing_name")
- @test !XLSX.is_valid_defined_name("")
- @test !XLSX.is_valid_defined_name("1abc")
- @test !XLSX.is_valid_defined_name("a-b")
- # Excel refuses a file with an R1C1-style defined name, or one that is just R or C,
- # in any case. (A1-style names such as R5 were already rejected.)
- for n in ("R1C1", "RC", "rc", "R12C", "RC3", "R", "C", "r", "c")
- @test !XLSX.is_valid_defined_name(n)
- end
- for n in ("XFE1", "A0", "A1B", "RCX", "Rate", "Col")
- @test XLSX.is_valid_defined_name(n)
- end
- err = try XLSX.addDefinedName(xf, "R1C1", 1); nothing catch e; e end
- @test err isa XLSX.XLSXError && occursin("cell address", err.msg)
- @test_throws XLSX.XLSXError XLSX.addDefinedName(sh, "rc", 1)
- @test_throws XLSX.XLSXError XLSX.addDefName(xf, "", 1)
-
- XLSX.addDefinedName(xf, "dup", 1)
- @test_throws XLSX.XLSXError XLSX.addDefinedName(xf, "dup", 2)
- @test_throws XLSX.XLSXError XLSX.addDefinedName(xf, "empty", "")
- @test_throws XLSX.XLSXError XLSX.addDefinedName(sh, "empty", "")
- @test_throws XLSX.XLSXError XLSX.addDefinedName(xf, "nosheet", "A1")
- XLSX.addDefinedName(xf, "wb_nc", "Sheet1!A1,Sheet1!B2")
- @test XLSX.get_defined_name_value(wb, "wb_nc") isa XLSX.NonContiguousRange
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "cells, tables and worksheets" begin
- @test_throws XLSX.XLSXError XLSX.get_error_type("#BOGUS!")
- @test_throws XLSX.XLSXError XLSX.get_error_string(UInt64(999))
-
- dt = XLSX.DataTable(Any[[1, 2]], [:a])
- @test_throws XLSX.XLSXError Tables.getcolumn(dt, :nope)
-
- sh = XLSX.newxlsx()[1] # nothing written: no dimension
- @test occursin("Sheet1", sprint(show, sh))
- # was an UndefVarError (`ws` in the message)
- @test_throws XLSX.XLSXError XLSX.ordinal_sheet_number(XLSX.get_workbook(sh), "nope")
- end
-
- @testset "DrawingML colour resolution" begin
- wb = XLSX.get_workbook(XLSX.newxlsx())
- el(tag; kw...) = XLSX.XML.Element("a:" * tag; kw...)
- resolve(n) = XLSX.Charts.resolve_color_base(wb, n)
- @test resolve(el("sysClr"; val="windowText", lastClr="112233")) == "112233"
- @test resolve(el("sysClr"; val="window")) == "FFFFFF"
- @test resolve(el("sysClr"; val="windowText")) == "000000"
- @test resolve(el("prstClr"; val="dkBlue")) == "00008B" # darkblue
- @test resolve(el("prstClr"; val="ltGray")) == "D3D3D3" # lightgray
- @test resolve(el("prstClr"; val="medPurple")) == "9370DB" # mediumpurple
- @test resolve(el("prstClr"; val="notAColour")) == "000000"
- @test resolve(el("schemeClr"; val="phClr")) == "000000" # placeholder
- @test resolve(el("unknownClr"; val="x")) == "000000"
- end
-
- @testset "formatting through defined names and odd references" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
- sh[1:3, 1:3] = reshape(collect(1:9), 3, 3)
- XLSX.addDefName(sh, "ws_const", 5)
- XLSX.addDefName(xf, "wb_const", 6)
-
- # a constant name is not a cell, whichever way it is reached
- @test_throws XLSX.XLSXError XLSX.setFont(xf, "wb_const"; bold=true)
- @test_throws XLSX.XLSXError XLSX.setFont(sh, "ws_const"; bold=true)
- @test_throws XLSX.XLSXError XLSX.setFont(sh, "wb_const"; bold=true)
- @test_throws XLSX.XLSXError XLSX.getFont(sh, "wb_const")
- @test_throws XLSX.XLSXError XLSX.getFont(sh, "A1:B2") # a getter takes one cell
- @test_throws XLSX.XLSXError XLSX.getFont(xf, "Sheet1!Z99") # outside the dimension
-
- # non-contiguous ranges, with and without the sheet name
- XLSX.setFont(sh, "A1,C1"; bold=true)
- @test XLSX.getFont(sh, "A1").font["b"] === nothing && XLSX.getFont(sh, "C1").font["b"] === nothing
- XLSX.setFont(sh, "Sheet1!A2,Sheet1!C2"; italic=true)
- @test haskey(XLSX.getFont(sh, "C2").font, "i")
- @test_throws XLSX.XLSXError XLSX.setFont(sh, XLSX.NonContiguousRange("Sheet1!A1,Sheet1!Z99"); bold=true)
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "borders through the XLSXFile" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
- sh[1:2, 1:2] = [1 2; 3 4]
- XLSX.setBorder(xf, "Sheet1!A1:B2"; outside=["style" => "thin", "color" => "black"])
- @test XLSX.getBorder(sh, "A1").border["top"]["style"] == "thin"
- SAVE_FILES && save_outfile(xf)
- end
-end
diff --git a/test/test_files/Errors_tests.jl b/test/test_files/Error-values_tests.jl
similarity index 88%
rename from test/test_files/Errors_tests.jl
rename to test/test_files/Error-values_tests.jl
index 1c73a55a..ce85d1fd 100644
--- a/test/test_files/Errors_tests.jl
+++ b/test/test_files/Error-values_tests.jl
@@ -14,7 +14,7 @@
SAVE_FILES && save_outfile("mytest.xlsx")
f2 = XLSX.openxlsx("mytest.xlsx", mode="rw")
- s=f[1]
+ s=f2[1]
@test Int64(XLSX.getcell(s, "A1").value) == 1
@test Int64(XLSX.getcell(s, "B1").value) == 2
@test Int64(XLSX.getcell(s, "C1").value) == 3
@@ -47,8 +47,8 @@
@test XLSX.geterror(sh, 1, 1) == "#NULL!"
@test XLSX.geterror(sh, "I1") == ""
@test XLSX.geterror(sh, 1, 9) == ""
- @test XLSX.geterror(s, "A1:I1") == ["#NULL!" "#DIV/0!" "#VALUE!" "#REF!" "#NAME?" "#NUM!" "#N/A" "#VALUE!" ""]
- @test XLSX.geterror(s, 1, 1:9) == ["#NULL!" "#DIV/0!" "#VALUE!" "#REF!" "#NAME?" "#NUM!" "#N/A" "#VALUE!" ""]
+ @test XLSX.geterror(sh, "A1:I1") == ["#NULL!" "#DIV/0!" "#VALUE!" "#REF!" "#NAME?" "#NUM!" "#N/A" "#VALUE!" ""]
+ @test XLSX.geterror(sh, 1, 1:9) == ["#NULL!" "#DIV/0!" "#VALUE!" "#REF!" "#NAME?" "#NUM!" "#N/A" "#VALUE!" ""]
@test XLSX.geterror(sh, "A1:B1,D1:E1") == [["#NULL!" "#DIV/0!"], ["#REF!" "#NAME?"]]
@test XLSX.geterror(sh, 1, [1, 2, 4, 5]) == ["#NULL!", "#DIV/0!", "#REF!", "#NAME?"]
@test XLSX.geterror(sh, :) == [ "#NULL!" "#DIV/0!" "#VALUE!" "#REF!" "#NAME?" "#NUM!" "#N/A" "#VALUE!"
@@ -59,4 +59,9 @@
]
SAVE_FILES && save_outfile(f2)
-end
\ No newline at end of file
+end
+
+@testset "unknown error codes are rejected" begin
+ @test_throws XLSX.XLSXError XLSX.get_error_type("#BOGUS!")
+ @test_throws XLSX.XLSXError XLSX.get_error_string(UInt64(999))
+end
diff --git a/test/test_files/Excel_tables_tests.jl b/test/test_files/Excel_tables_tests.jl
index 5389be39..d9b154c3 100644
--- a/test/test_files/Excel_tables_tests.jl
+++ b/test/test_files/Excel_tables_tests.jl
@@ -138,7 +138,6 @@ function make_readto_file(outfile)
return outfile
end
-
# Shared fixture: 3 sheets, one table each, plus extra non-table data
# on the middle sheet to confirm reads are scoped to the table's ref.
function make_multitable_file(outfile)
@@ -185,3683 +184,3528 @@ function _two_sheet_file()
return f, s1, s2, t1, t2
end
-
@testset "Excel Tables" begin
+ @testset "table lookup" begin
+ @testset "tables(sheet)" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet1"]
+ tbls = XLSX.tables(sh)
+
+ @test length(tbls) == 2
+ @test all(t -> t isa XLSX.Table, tbls)
+
+ names = [t.name for t in tbls]
+ @test "IO_Table" in names
+ @test "Age_height" in names
+ end
+ end
- @testset "tables(sheet)" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet1"]
- tbls = XLSX.tables(sh)
+ @testset "table(sheet, name) - IO_Table" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet1"]
+ t = XLSX.table(sh, "IO_Table")
+
+ @test t.id == 1
+ @test t.name == "IO_Table"
+ @test t.display_name == "IO_Table"
+ @test t.ref == XLSX.CellRange("A1:C8")
+ @test t.columns == ["id", "input", "output"]
+ @test t.has_totals_row == false
+
+ @test !isnothing(t.style)
+ @test t.style.name == "TableStyleMedium2"
+ @test t.style.show_row_stripes == true
+ @test t.style.show_column_stripes == false
+ @test t.style.show_first_column == false
+ @test t.style.show_last_column == false
+ end
+ end
- @test length(tbls) == 2
- @test all(t -> t isa XLSX.Table, tbls)
+ @testset "table(sheet, name) - Age_height" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet1"]
+ t = XLSX.table(sh, "Age_height")
- names = [t.name for t in tbls]
- @test "IO_Table" in names
- @test "Age_height" in names
+ @test t.id == 2
+ @test t.name == "Age_height"
+ @test t.display_name == "Age_height"
+ @test t.ref == XLSX.CellRange("E1:G5")
+ @test t.columns == ["name", "age", "height"]
+ @test t.has_totals_row == false
+ end
end
- end
- @testset "table(sheet, name) - IO_Table" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet1"]
- t = XLSX.table(sh, "IO_Table")
+ @testset "table(sheet, id)" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet1"]
- @test t.id == 1
- @test t.name == "IO_Table"
- @test t.display_name == "IO_Table"
- @test t.ref == XLSX.CellRange("A1:C8")
- @test t.columns == ["id", "input", "output"]
- @test t.has_totals_row == false
+ t1 = XLSX.table(sh, 1)
+ @test t1.name == "IO_Table"
- @test !isnothing(t.style)
- @test t.style.name == "TableStyleMedium2"
- @test t.style.show_row_stripes == true
- @test t.style.show_column_stripes == false
- @test t.style.show_first_column == false
- @test t.style.show_last_column == false
+ t2 = XLSX.table(sh, 2)
+ @test t2.name == "Age_height"
+ end
end
- end
- @testset "table(sheet, name) - Age_height" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet1"]
- t = XLSX.table(sh, "Age_height")
+ @testset "table(sheet, name/id) - not found" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet1"]
- @test t.id == 2
- @test t.name == "Age_height"
- @test t.display_name == "Age_height"
- @test t.ref == XLSX.CellRange("E1:G5")
- @test t.columns == ["name", "age", "height"]
- @test t.has_totals_row == false
+ @test_throws KeyError XLSX.table(sh, "NoSuchTable")
+ @test_throws KeyError XLSX.table(sh, 999)
+ end
end
- end
- @testset "table(sheet, id)" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet1"]
+ @testset "table(sheet, name) - totals row (Sheet2, with_total)" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet2"]
+ t = XLSX.table(sh, "with_total")
- t1 = XLSX.table(sh, 1)
- @test t1.name == "IO_Table"
+ @test t.id == 3
+ @test t.name == "with_total"
+ @test t.ref == XLSX.CellRange("A3:C11")
+ @test t.columns == ["start", "stop", "sin"]
- t2 = XLSX.table(sh, 2)
- @test t2.name == "Age_height"
+ # This table signals its totals row via `totalsRowCount="1"`,
+ # not `totalsRowShown` — regression check for that path.
+ @test t.has_totals_row == true
+ end
end
- end
- @testset "table(sheet, name/id) - not found" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet1"]
+ @testset "tables(sheet) - sheet with no tables" begin
+ # blank.xlsx (used internally by XLSX.newxlsx) has a single sheet with no tables
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ @test XLSX.tables(sh) == XLSX.Table[]
+ SAVE_FILES && save_outfile(xf)
+ end
- @test_throws KeyError XLSX.table(sh, "NoSuchTable")
- @test_throws KeyError XLSX.table(sh, 999)
+ @testset "consistent under enable_cache=false" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx"), enable_cache=false) do xf
+ sh = xf["Sheet1"]
+ tbls = XLSX.tables(sh)
+
+ @test length(tbls) == 2
+ @test XLSX.table(sh, "IO_Table").ref == XLSX.CellRange("A1:C8")
+ @test XLSX.table(sh, "Age_height").ref == XLSX.CellRange("E1:G5")
+ end
end
- end
- @testset "table(sheet, name) - totals row (Sheet2, with_total)" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet2"]
- t = XLSX.table(sh, "with_total")
+ @testset "consistent under mode=rw" begin
+ # `opentemplate` opens in rw mode without writing back to the fixture
+ xf = XLSX.opentemplate(joinpath(data_directory, "two_tables.xlsx"))
+ @test XLSX.is_writable(xf)
+ sh = xf["Sheet1"]
+ @test length(XLSX.tables(sh)) == 2
+ end
- @test t.id == 3
- @test t.name == "with_total"
- @test t.ref == XLSX.CellRange("A3:C11")
- @test t.columns == ["start", "stop", "sin"]
+ @testset "t.sheet identity is preserved" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
- # This table signals its totals row via `totalsRowCount="1"`,
- # not `totalsRowShown` — regression check for that path.
- @test t.has_totals_row == true
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ t = XLSX.table(sh, "T")
+ @test t.sheet === sh
+ SAVE_FILES && save_outfile(f)
end
- end
- @testset "tables(sheet) - sheet with no tables" begin
- # blank.xlsx (used internally by XLSX.newxlsx) has a single sheet with no tables
- xf = XLSX.newxlsx()
- sh = xf[1]
- @test XLSX.tables(sh) == XLSX.Table[]
- SAVE_FILES && save_outfile(xf)
- end
+ @testset "tables(xf) - every sheet, in order" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ all_t = XLSX.tables(xf)
- @testset "consistent under enable_cache=false" begin
- XLSX.openxlsx("data/two_tables.xlsx", enable_cache=false) do xf
- sh = xf["Sheet1"]
- tbls = XLSX.tables(sh)
+ @test length(all_t) == 3
+ @test [t.name for t in all_t] == ["IO_Table", "Age_height", "with_total"]
+ @test [t.sheet.name for t in all_t] == ["Sheet1", "Sheet1", "Sheet2"]
+ @test all(t -> t isa XLSX.Table, all_t)
+ end
+ end
- @test length(tbls) == 2
- @test XLSX.table(sh, "IO_Table").ref == XLSX.CellRange("A1:C8")
- @test XLSX.table(sh, "Age_height").ref == XLSX.CellRange("E1:G5")
+ @testset "tables(xf) - workbook with no tables" begin
+ xf = XLSX.newxlsx()
+ @test XLSX.tables(xf) == XLSX.Table[]
+ SAVE_FILES && save_outfile(xf)
end
- end
- @testset "consistent under mode=rw" begin
- XLSX.openxlsx("data/two_tables.xlsx", mode="r", enable_cache=true) do xf
- sh = xf["Sheet1"]
- @test length(XLSX.tables(sh)) == 2
+ @testset "table(xf, name) / table(xf, id)" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ t = XLSX.table(xf, "with_total")
+ @test t.sheet.name == "Sheet2"
+ @test t.ref == XLSX.CellRange("A3:C11")
+
+ @test XLSX.table(xf, "IO_Table").id == 1
+ @test XLSX.table(xf, 2).name == "Age_height"
+ @test XLSX.table(xf, 3).name == "with_total"
+
+ @test_throws KeyError XLSX.table(xf, "NoSuchTable")
+ @test_throws KeyError XLSX.table(xf, 999)
+ end
end
- end
+ @testset "table(wb, name) / table(wb, id)" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ wb = XLSX.get_workbook(xf)
- # NOTE: writing always requires enable_cache=true (rw mode's own contract:
- # "Cache must be enabled for files in `write` mode"). `opentemplate` already
- # opens in rw/cache=true, so every write below satisfies that automatically.
- # `enable_cache=false` is only ever used for the *reopen-to-verify* step,
- # never for the write itself.
+ @test XLSX.table(wb, "Age_height").ref == XLSX.CellRange("E1:G5")
+ @test XLSX.table(wb, 1).name == "IO_Table"
+
+ @test_throws KeyError XLSX.table(wb, "NoSuchTable")
+ @test_throws KeyError XLSX.table(wb, 999)
+ end
+ end
- @testset "no edits - tables survive save" begin
- original_tables = Dict{String,Vector{XLSX.Table}}()
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- for sh_name in ("Sheet1", "Sheet2")
- original_tables[sh_name] = XLSX.tables(xf[sh_name])
+ @testset "workbook-wide lookup agrees with sheet-scoped" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ @test tables_equal(XLSX.table(xf, "with_total"),
+ XLSX.table(xf["Sheet2"], "with_total"))
end
end
+ end
- f = XLSX.opentemplate("data/two_tables.xlsx")
- SAVE_FILES && save_outfile(f)
+ @testset "tables survive a save" begin
+ # NOTE: writing always requires enable_cache=true (rw mode's own contract:
+ # "Cache must be enabled for files in `write` mode"). `opentemplate` already
+ # opens in rw/cache=true, so every write below satisfies that automatically.
+ # `enable_cache=false` is only ever used for the *reopen-to-verify* step,
+ # never for the write itself.
- outfile = "two_tables_roundtrip_noedits.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ @testset "no edits - tables survive save" begin
+ original_tables = Dict{String,Vector{XLSX.Table}}()
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ for sh_name in ("Sheet1", "Sheet2")
+ original_tables[sh_name] = XLSX.tables(xf[sh_name])
+ end
+ end
- XLSX.openxlsx(outfile) do xf2
- for sh_name in ("Sheet1", "Sheet2")
- before = original_tables[sh_name]
- after = XLSX.tables(xf2[sh_name])
+ f = XLSX.opentemplate(joinpath(data_directory, "two_tables.xlsx"))
+ SAVE_FILES && save_outfile(f)
- @test length(after) == length(before)
- for t_before in before
- idx = findfirst(t -> t.name == t_before.name, after)
- @test idx !== nothing
- @test tables_equal(t_before, after[idx])
+ outfile = "two_tables_roundtrip_noedits.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf2
+ for sh_name in ("Sheet1", "Sheet2")
+ before = original_tables[sh_name]
+ after = XLSX.tables(xf2[sh_name])
+
+ @test length(after) == length(before)
+ for t_before in before
+ idx = findfirst(t -> t.name == t_before.name, after)
+ @test idx !== nothing
+ @test tables_equal(t_before, after[idx])
+ end
end
end
+
+ isfile(outfile) && rm(outfile)
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "unrelated cell edit elsewhere - tables still intact" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "two_tables.xlsx"))
- @testset "unrelated cell edit elsewhere - tables still intact" begin
- f = XLSX.opentemplate("data/two_tables.xlsx")
+ # Edit a cell well outside any table's range on Sheet1
+ # (IO_Table is A1:C8, Age_height is E1:G5).
+ f["Sheet1"]["Z100"] = "unrelated edit"
+ SAVE_FILES && save_outfile(f)
- # Edit a cell well outside any table's range on Sheet1
- # (IO_Table is A1:C8, Age_height is E1:G5).
- f["Sheet1"]["Z100"] = "unrelated edit"
- SAVE_FILES && save_outfile(f)
+ outfile = "two_tables_roundtrip_celledit.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
- outfile = "two_tables_roundtrip_celledit.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ XLSX.openxlsx(outfile) do xf2
+ @test xf2["Sheet1"]["Z100"] == "unrelated edit"
- XLSX.openxlsx(outfile) do xf2
- @test xf2["Sheet1"]["Z100"] == "unrelated edit"
+ sh1_tables = XLSX.tables(xf2["Sheet1"])
+ @test length(sh1_tables) == 2
+ io_table = XLSX.table(xf2["Sheet1"], "IO_Table")
+ @test io_table.ref == XLSX.CellRange("A1:C8")
+ @test io_table.columns == ["id", "input", "output"]
- sh1_tables = XLSX.tables(xf2["Sheet1"])
- @test length(sh1_tables) == 2
- io_table = XLSX.table(xf2["Sheet1"], "IO_Table")
- @test io_table.ref == XLSX.CellRange("A1:C8")
- @test io_table.columns == ["id", "input", "output"]
+ sh2_tables = XLSX.tables(xf2["Sheet2"])
+ @test length(sh2_tables) == 1
+ wt = XLSX.table(xf2["Sheet2"], "with_total")
+ @test wt.has_totals_row == true
+ @test wt.ref == XLSX.CellRange("A3:C11")
+ end
- sh2_tables = XLSX.tables(xf2["Sheet2"])
- @test length(sh2_tables) == 1
- wt = XLSX.table(xf2["Sheet2"], "with_total")
- @test wt.has_totals_row == true
- @test wt.ref == XLSX.CellRange("A3:C11")
+ isfile(outfile) && rm(outfile)
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "reopen after round trip under enable_cache=false" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "two_tables.xlsx"))
+ SAVE_FILES && save_outfile(f)
- @testset "reopen after round trip under enable_cache=false" begin
- f = XLSX.opentemplate("data/two_tables.xlsx")
- SAVE_FILES && save_outfile(f)
+ outfile = "two_tables_roundtrip_nocache.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
- outfile = "two_tables_roundtrip_nocache.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ # Only the *reopen* is enable_cache=false here — the write above
+ # still went through cache=true, as it must.
+ XLSX.openxlsx(outfile, enable_cache=false) do xf2
+ @test length(XLSX.tables(xf2["Sheet1"])) == 2
+ @test length(XLSX.tables(xf2["Sheet2"])) == 1
+ @test XLSX.table(xf2["Sheet2"], "with_total").has_totals_row == true
+ end
- # Only the *reopen* is enable_cache=false here — the write above
- # still went through cache=true, as it must.
- XLSX.openxlsx(outfile, enable_cache=false) do xf2
- @test length(XLSX.tables(xf2["Sheet1"])) == 2
- @test length(XLSX.tables(xf2["Sheet2"])) == 1
- @test XLSX.table(xf2["Sheet2"], "with_total").has_totals_row == true
+ isfile(outfile) && rm(outfile)
end
-
- isfile(outfile) && rm(outfile)
end
- @testset "addtable! - basic creation" begin
- f = XLSX.newxlsx()
- sh = f[1]
-
- sh["A1"] = "id"; sh["B1"] = "name"; sh["C1"] = "score"
- sh["A2"] = 1; sh["B2"] = "alice"; sh["C2"] = 10.5
- sh["A3"] = 2; sh["B3"] = "bob"; sh["C3"] = 20.0
-
- t = XLSX.addtable!(sh, "A1:C3"; name="MyTable")
-
- @test t isa XLSX.Table
- @test t.name == "MyTable"
- @test t.display_name == "MyTable"
- @test t.ref == XLSX.CellRange("A1:C3")
- @test t.columns == ["id", "name", "score"]
- @test t.has_totals_row == false
- @test isnothing(t.style)
-
- # confirm it's visible via the read API too
- @test length(XLSX.tables(sh)) == 1
- @test XLSX.table(sh, "MyTable").ref == XLSX.CellRange("A1:C3")
- @test XLSX.table(sh, 1).name == "MyTable"
- SAVE_FILES && save_outfile(f)
- end
+ @testset "addtable!" begin
+ @testset "addtable! - basic creation" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
- @testset "addtable! - auto-generated name" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "x"; sh["B1"] = "y"
- sh["A2"] = 1; sh["B2"] = 2
+ sh["A1"] = "id"; sh["B1"] = "name"; sh["C1"] = "score"
+ sh["A2"] = 1; sh["B2"] = "alice"; sh["C2"] = 10.5
+ sh["A3"] = 2; sh["B3"] = "bob"; sh["C3"] = 20.0
- t = XLSX.addtable!(sh, "A1:B2")
- @test t.name == "Table1"
+ t = XLSX.addtable!(sh, "A1:C3"; name="MyTable")
- sh["D1"] = "p"; sh["E1"] = "q"
- sh["D2"] = 1; sh["E2"] = 2
- t2 = XLSX.addtable!(sh, "D1:E2")
- @test t2.name == "Table2"
- SAVE_FILES && save_outfile(f)
- end
+ @test t isa XLSX.Table
+ @test t.name == "MyTable"
+ @test t.display_name == "MyTable"
+ @test t.ref == XLSX.CellRange("A1:C3")
+ @test t.columns == ["id", "name", "score"]
+ @test t.has_totals_row == false
+ @test isnothing(t.style)
- @testset "addtable! - with style and totals row (empty row, no warning)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "qty"; sh["B1"] = "price"
- sh["A2"] = 3; sh["B2"] = 9.99
- sh["A3"] = 5; sh["B3"] = 4.50
- sh["A4"] = missing; sh["B4"] = missing # totals row placeholder, genuinely empty
+ # confirm it's visible via the read API too
+ @test length(XLSX.tables(sh)) == 1
+ @test XLSX.table(sh, "MyTable").ref == XLSX.CellRange("A1:C3")
+ @test XLSX.table(sh, 1).name == "MyTable"
+ SAVE_FILES && save_outfile(f)
+ end
- t = @test_logs XLSX.addtable!(sh, "A1:B4"; name="Sales", style="TableStyleMedium9", has_totals_row=true)
+ @testset "addtable! - auto-generated name" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "x"; sh["B1"] = "y"
+ sh["A2"] = 1; sh["B2"] = 2
- @test t.has_totals_row == true
- @test !isnothing(t.style)
- @test t.style.name == "TableStyleMedium9"
- SAVE_FILES && save_outfile(f)
- end
+ t = XLSX.addtable!(sh, "A1:B2")
+ @test t.name == "Table1"
- @testset "addtable! - has_totals_row with nonempty last row errors" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "qty"; sh["B1"] = "price"
- sh["A2"] = 3; sh["B2"] = 9.99
- sh["A3"] = 5; sh["B3"] = 4.50
- # Excel refuses a file whose totals row holds a plain value such as 14.49.
- sh["A4"] = "Total"; sh["B4"] = 14.49
-
- err = try
- XLSX.addtable!(sh, "A1:B4"; name="Sales", has_totals_row=true); nothing
- catch e
- e
- end
- @test err isa XLSX.XLSXError
- @test occursin("already has content in column(s) qty, price", err.msg)
- @test isempty(XLSX.tables(sh))
-
- # cell contents must be completely untouched by addtable!
- @test sh["A4"] == "Total"
- @test sh["B4"] == 14.49
-
- # The usual fix: the content was data, so it stays out of the totals row.
- t = XLSX.addtable!(sh, "A1:B4"; name="Sales")
- @test !t.has_totals_row
- SAVE_FILES && save_outfile(f)
- end
+ sh["D1"] = "p"; sh["E1"] = "q"
+ sh["D2"] = 1; sh["E2"] = 2
+ t2 = XLSX.addtable!(sh, "D1:E2")
+ @test t2.name == "Table2"
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "addtable! - has_totals_row=false always treats last row as data, even if blank" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "qty"; sh["B1"] = "price"
- sh["A2"] = 3; sh["B2"] = 9.99
- sh["A3"] = missing; sh["B3"] = missing # blank last row, but has_totals_row=false
+ @testset "addtable! - with style and totals row (empty row, no warning)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "qty"; sh["B1"] = "price"
+ sh["A2"] = 3; sh["B2"] = 9.99
+ sh["A3"] = 5; sh["B3"] = 4.50
+ sh["A4"] = missing; sh["B4"] = missing # totals row placeholder, genuinely empty
- t = @test_logs XLSX.addtable!(sh, "A1:B3"; name="NoTotals", has_totals_row=false)
+ t = @test_logs XLSX.addtable!(sh, "A1:B4"; name="Sales", style="TableStyleMedium9", has_totals_row=true)
- @test t.has_totals_row == false
- @test t.ref == XLSX.CellRange("A1:B3")
- SAVE_FILES && save_outfile(f)
- end
+ @test t.has_totals_row == true
+ @test !isnothing(t.style)
+ @test t.style.name == "TableStyleMedium9"
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "addtable! - name collisions" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="Dup")
-
- sh["D1"] = "c"; sh["E1"] = "d"
- sh["D2"] = 1; sh["E2"] = 2
- @test_throws XLSX.XLSXError XLSX.addtable!(sh, "D1:E2"; name="Dup")
- SAVE_FILES && save_outfile(f)
- end
+ @testset "addtable! - has_totals_row with nonempty last row errors" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "qty"; sh["B1"] = "price"
+ sh["A2"] = 3; sh["B2"] = 9.99
+ sh["A3"] = 5; sh["B3"] = 4.50
+ # Excel refuses a file whose totals row holds a plain value such as 14.49.
+ sh["A4"] = "Total"; sh["B4"] = 14.49
- @testset "addtable! - invalid header (empty cell)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = missing
- sh["A2"] = 1; sh["B2"] = 2
- @test_throws XLSX.XLSXError XLSX.addtable!(sh, "A1:B2")
- SAVE_FILES && save_outfile(f)
- end
+ err = try
+ XLSX.addtable!(sh, "A1:B4"; name="Sales", has_totals_row=true); nothing
+ catch e
+ e
+ end
+ @test err isa XLSX.XLSXError
+ @test occursin("already has content in column(s) qty, price", err.msg)
+ @test isempty(XLSX.tables(sh))
- @testset "addtable! - invalid header (duplicate names)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "same"; sh["B1"] = "same"
- sh["A2"] = 1; sh["B2"] = 2
- @test_throws XLSX.XLSXError XLSX.addtable!(sh, "A1:B2")
- SAVE_FILES && save_outfile(f)
- end
+ # cell contents must be completely untouched by addtable!
+ @test sh["A4"] == "Total"
+ @test sh["B4"] == 14.49
- @testset "addtable! - displayName validation" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- @test_throws XLSX.XLSXError XLSX.addtable!(sh, "A1:B2"; name="has space")
- SAVE_FILES && save_outfile(f)
- end
+ # The usual fix: the content was data, so it stays out of the totals row.
+ t = XLSX.addtable!(sh, "A1:B4"; name="Sales")
+ @test !t.has_totals_row
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "addtable! - a cell address is not a table name" begin
- # Excel refuses a file with any of the first group, and opens one with the second.
- for n in ("T1", "t1", "XFD1", "R5", "R1C1", "rc", "R", "c")
- @test XLSX._is_cell_address_like(n)
- end
- for n in ("XFE1", "A0", "A1B", "Tbl_1", "Table1", "RCX", "R1C1X")
- @test !XLSX._is_cell_address_like(n)
- end
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- for n in ("T1", "t1", "R1C1", "R")
- err = try XLSX.addtable!(sh, "A1:B2"; name=n); nothing catch e; e end
- @test err isa XLSX.XLSXError && occursin("cell address", err.msg)
- end
- @test isempty(XLSX.tables(sh))
- @test XLSX.addtable!(sh, "A1:B2"; name="XFE1").name == "XFE1"
- SAVE_FILES && save_outfile(f)
- end
+ @testset "addtable! - has_totals_row=false always treats last row as data, even if blank" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "qty"; sh["B1"] = "price"
+ sh["A2"] = 3; sh["B2"] = 9.99
+ sh["A3"] = missing; sh["B3"] = missing # blank last row, but has_totals_row=false
- @testset "addtable! - may not overlap a merged range or another table" begin
- # Excel drops ("repairs") a table that overlaps either.
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1:E8"] = "x"
- sh["B3:E3"] = ["Region" "N1" "N2" "N3"]
- XLSX.mergeCells(sh, "A3:C3") # leaves B3 and C3 missing
- sh["B3"] = "Region"; sh["C3"] = "N1"
- err = try XLSX.addtable!(sh, "B3:E7"; name="Overlaps"); nothing catch e; e end
- @test err isa XLSX.XLSXError && occursin("merged range `A3:C3`", err.msg)
- @test isempty(XLSX.tables(sh))
-
- sh["D4:G4"] = ["h1" "h2" "h3" "h4"]
- t = XLSX.addtable!(sh, "D4:G8"; name="Clear") # below and beside the merge
- @test t.ref == XLSX.CellRange("D4:G8")
-
- sh["H4:I4"] = ["h5" "h6"]
- err = try XLSX.addtable!(sh, "G4:I8"; name="Clash"); nothing catch e; e end
- @test err isa XLSX.XLSXError && occursin("table `Clear`", err.msg)
- @test XLSX.addtable!(sh, "H4:I8"; name="Beside").ref == XLSX.CellRange("H4:I8")
- @test length(XLSX.tables(sh)) == 2
- SAVE_FILES && save_outfile(f)
- end
+ t = @test_logs XLSX.addtable!(sh, "A1:B3"; name="NoTotals", has_totals_row=false)
- @testset "writetable!(as_table=true) - refuses to overlap a merged range, writing nothing" begin
- # The OW27 layout: a legend merged in A1:C3, then a table anchored at B3.
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1:C3"] = "legend"
- XLSX.mergeCells(sh, "A1:C1"); XLSX.mergeCells(sh, "A2:C2"); XLSX.mergeCells(sh, "A3:C3")
- data = Any[["Wales", "Scotland"], [1, 2], [3, 4], [5, 6]]
- cols = ["Region", "No. Offers", "No. Submitted", "No. Enabled"]
- err = try XLSX.writetable!(sh, data, cols; anchor_cell=XLSX.CellRef("B3"), as_table=true, table_name="Unk_Regions"); nothing catch e; e end
- @test err isa XLSX.XLSXError && occursin("merged range `A3:C3`", err.msg)
- @test isempty(XLSX.tables(sh))
- @test XLSX.get_dimension(sh) == XLSX.CellRange("A1:C3") # nothing was written
-
- XLSX.writetable!(sh, data, cols; anchor_cell=XLSX.CellRef("B4"), as_table=true, table_name="Unk_Regions")
- @test only(XLSX.tables(sh)).ref == XLSX.CellRange("B4:E6")
- SAVE_FILES && save_outfile(f)
- end
+ @test t.has_totals_row == false
+ @test t.ref == XLSX.CellRange("A1:B3")
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "mergeCells - may not overlap a table" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1:D6"] = "x"
- sh["B2:C2"] = ["a" "b"]
- XLSX.addtable!(sh, "B2:C4"; name="T_merge")
- err = try XLSX.mergeCells(sh, "A1:B2"); nothing catch e; e end
- @test err isa XLSX.XLSXError && occursin("table `T_merge`", err.msg)
- @test isnothing(XLSX.getMergedCells(sh))
- @test XLSX.mergeCells(sh, "A5:D6") == 0 # below the table
- @test XLSX.getMergedCells(sh) == [XLSX.CellRange("A5:D6")]
- SAVE_FILES && save_outfile(f)
- end
+ @testset "addtable! - name collisions" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="Dup")
- @testset "writetable - a sheet name that is a cell address falls back to an auto-generated table name" begin
- outfile = "writetable_astable_celladdress.xlsx"
- @test_logs (:warn, r"is a cell address") match_mode=:any begin
- XLSX.writetable(outfile, [("Q1", [[1, 2], [3, 4]], ["x", "y"])]; as_table=true, overwrite=true)
- end
- SAVE_FILES && save_outfile(outfile)
- XLSX.openxlsx(outfile) do xf
- @test startswith(only(XLSX.tables(xf["Q1"])).name, "Table")
+ sh["D1"] = "c"; sh["E1"] = "d"
+ sh["D2"] = 1; sh["E2"] = 2
+ @test_throws XLSX.XLSXError XLSX.addtable!(sh, "D1:E2"; name="Dup")
+ SAVE_FILES && save_outfile(f)
end
- rm(outfile)
- end
-
- @testset "addtable! - round trip (create -> save -> reopen)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "value"
- sh["A2"] = 1; sh["B2"] = 100
- sh["A3"] = 2; sh["B3"] = 200
- XLSX.addtable!(sh, "A1:B3"; name="RoundTripTable", style="TableStyleLight1")
- SAVE_FILES && save_outfile(f)
+ @testset "addtable! - invalid header (empty cell)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = missing
+ sh["A2"] = 1; sh["B2"] = 2
+ @test_throws XLSX.XLSXError XLSX.addtable!(sh, "A1:B2")
+ SAVE_FILES && save_outfile(f)
+ end
- outfile = "newtable_roundtrip.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ @testset "addtable! - invalid header (duplicate names)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "same"; sh["B1"] = "same"
+ sh["A2"] = 1; sh["B2"] = 2
+ @test_throws XLSX.XLSXError XLSX.addtable!(sh, "A1:B2")
+ SAVE_FILES && save_outfile(f)
+ end
- XLSX.openxlsx(outfile) do xf2
- sh2 = xf2[1]
- @test length(XLSX.tables(sh2)) == 1
- t = XLSX.table(sh2, "RoundTripTable")
- @test t.ref == XLSX.CellRange("A1:B3")
- @test t.columns == ["id", "value"]
- @test t.style.name == "TableStyleLight1"
+ @testset "addtable! - displayName validation" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ @test_throws XLSX.XLSXError XLSX.addtable!(sh, "A1:B2"; name="has space")
+ SAVE_FILES && save_outfile(f)
end
- XLSX.openxlsx(outfile, enable_cache=false) do xf2
- @test length(XLSX.tables(xf2[1])) == 1
- @test XLSX.table(xf2[1], "RoundTripTable").columns == ["id", "value"]
+ @testset "addtable! - a cell address is not a table name" begin
+ # Excel refuses a file with any of the first group, and opens one with the second.
+ for n in ("T1", "t1", "XFD1", "R5", "R1C1", "rc", "R", "c")
+ @test XLSX._is_cell_address_like(n)
+ end
+ for n in ("XFE1", "A0", "A1B", "Tbl_1", "Table1", "RCX", "R1C1X")
+ @test !XLSX._is_cell_address_like(n)
+ end
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ for n in ("T1", "t1", "R1C1", "R")
+ err = try XLSX.addtable!(sh, "A1:B2"; name=n); nothing catch e; e end
+ @test err isa XLSX.XLSXError && occursin("cell address", err.msg)
+ end
+ @test isempty(XLSX.tables(sh))
+ @test XLSX.addtable!(sh, "A1:B2"; name="XFE1").name == "XFE1"
+ SAVE_FILES && save_outfile(f)
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "_is_valid_table_display_name" begin
+ @test XLSX._is_valid_table_display_name("Table1")
+ @test XLSX._is_valid_table_display_name("_leading")
+ @test XLSX._is_valid_table_display_name("with.periods")
+ @test XLSX._is_valid_table_display_name("Ünicode")
+
+ @test !XLSX._is_valid_table_display_name("")
+ @test !XLSX._is_valid_table_display_name("1leading")
+ @test !XLSX._is_valid_table_display_name("has space")
+ @test !XLSX._is_valid_table_display_name("has-hyphen")
+ end
+
+ @testset "addtable! - may not overlap a merged range or another table" begin
+ # Excel drops ("repairs") a table that overlaps either.
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1:E8"] = "x"
+ sh["B3:E3"] = ["Region" "N1" "N2" "N3"]
+ XLSX.mergeCells(sh, "A3:C3") # leaves B3 and C3 missing
+ sh["B3"] = "Region"; sh["C3"] = "N1"
+ err = try XLSX.addtable!(sh, "B3:E7"; name="Overlaps"); nothing catch e; e end
+ @test err isa XLSX.XLSXError && occursin("merged range `A3:C3`", err.msg)
+ @test isempty(XLSX.tables(sh))
+
+ sh["D4:G4"] = ["h1" "h2" "h3" "h4"]
+ t = XLSX.addtable!(sh, "D4:G8"; name="Clear") # below and beside the merge
+ @test t.ref == XLSX.CellRange("D4:G8")
+
+ sh["H4:I4"] = ["h5" "h6"]
+ err = try XLSX.addtable!(sh, "G4:I8"; name="Clash"); nothing catch e; e end
+ @test err isa XLSX.XLSXError && occursin("table `Clear`", err.msg)
+ @test XLSX.addtable!(sh, "H4:I8"; name="Beside").ref == XLSX.CellRange("H4:I8")
+ @test length(XLSX.tables(sh)) == 2
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "deletetable! - by name, single table" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="ToDelete")
-
- @test length(XLSX.tables(sh)) == 1
- XLSX.deletetable!(sh, "ToDelete")
- @test length(XLSX.tables(sh)) == 0
- @test_throws KeyError XLSX.table(sh, "ToDelete")
- SAVE_FILES && save_outfile(f)
- end
+ @testset "mergeCells - may not overlap a table" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1:D6"] = "x"
+ sh["B2:C2"] = ["a" "b"]
+ XLSX.addtable!(sh, "B2:C4"; name="T_merge")
+ err = try XLSX.mergeCells(sh, "A1:B2"); nothing catch e; e end
+ @test err isa XLSX.XLSXError && occursin("table `T_merge`", err.msg)
+ @test isnothing(XLSX.getMergedCells(sh))
+ @test XLSX.mergeCells(sh, "A5:D6") == 0 # below the table
+ @test XLSX.getMergedCells(sh) == [XLSX.CellRange("A5:D6")]
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "deletetable! - by id" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- t = XLSX.addtable!(sh, "A1:B2"; name="ById")
+ @testset "addtable! - round trip (create -> save -> reopen)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "value"
+ sh["A2"] = 1; sh["B2"] = 100
+ sh["A3"] = 2; sh["B3"] = 200
+ XLSX.addtable!(sh, "A1:B3"; name="RoundTripTable", style="TableStyleLight1")
- XLSX.deletetable!(sh, t.id)
- @test length(XLSX.tables(sh)) == 0
- SAVE_FILES && save_outfile(f)
- end
+ SAVE_FILES && save_outfile(f)
- @testset "deletetable! - one of several tables survives" begin
- f = XLSX.newxlsx()
- sh = f[1]
+ outfile = "newtable_roundtrip.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf2
+ sh2 = xf2[1]
+ @test length(XLSX.tables(sh2)) == 1
+ t = XLSX.table(sh2, "RoundTripTable")
+ @test t.ref == XLSX.CellRange("A1:B3")
+ @test t.columns == ["id", "value"]
+ @test t.style.name == "TableStyleLight1"
+ end
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- t1 = XLSX.addtable!(sh, "A1:B2"; name="Keep")
+ XLSX.openxlsx(outfile, enable_cache=false) do xf2
+ @test length(XLSX.tables(xf2[1])) == 1
+ @test XLSX.table(xf2[1], "RoundTripTable").columns == ["id", "value"]
+ end
- sh["D1"] = "c"; sh["E1"] = "d"
- sh["D2"] = 3; sh["E2"] = 4
- t2 = XLSX.addtable!(sh, "D1:E2"; name="Remove")
+ isfile(outfile) && rm(outfile)
+ end
+ end
- @test length(XLSX.tables(sh)) == 2
+ @testset "deletetable!" begin
+ @testset "deletetable! - by name, single table" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="ToDelete")
- XLSX.deletetable!(sh, "Remove")
+ @test length(XLSX.tables(sh)) == 1
+ XLSX.deletetable!(sh, "ToDelete")
+ @test length(XLSX.tables(sh)) == 0
+ @test_throws KeyError XLSX.table(sh, "ToDelete")
+ SAVE_FILES && save_outfile(f)
+ end
- remaining = XLSX.tables(sh)
- @test length(remaining) == 1
- @test remaining[1].name == "Keep"
- @test XLSX.table(sh, "Keep").ref == XLSX.CellRange("A1:B2")
- @test_throws KeyError XLSX.table(sh, "Remove")
- SAVE_FILES && save_outfile(f)
- end
+ @testset "deletetable! - by id" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ t = XLSX.addtable!(sh, "A1:B2"; name="ById")
- @testset "deletetable! - survives round trip, remaining table intact" begin
- f = XLSX.newxlsx()
- sh = f[1]
+ XLSX.deletetable!(sh, t.id)
+ @test length(XLSX.tables(sh)) == 0
+ SAVE_FILES && save_outfile(f)
+ end
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="Keep")
+ @testset "deletetable! - one of several tables survives" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
- sh["D1"] = "c"; sh["E1"] = "d"
- sh["D2"] = 3; sh["E2"] = 4
- XLSX.addtable!(sh, "D1:E2"; name="Remove")
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ t1 = XLSX.addtable!(sh, "A1:B2"; name="Keep")
- XLSX.deletetable!(sh, "Remove")
+ sh["D1"] = "c"; sh["E1"] = "d"
+ sh["D2"] = 3; sh["E2"] = 4
+ t2 = XLSX.addtable!(sh, "D1:E2"; name="Remove")
- SAVE_FILES && save_outfile(f)
+ @test length(XLSX.tables(sh)) == 2
- outfile = "deletetable_roundtrip.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ XLSX.deletetable!(sh, "Remove")
- XLSX.openxlsx(outfile) do xf2
- sh2 = xf2[1]
- @test length(XLSX.tables(sh2)) == 1
- @test XLSX.table(sh2, "Keep").ref == XLSX.CellRange("A1:B2")
- @test_throws KeyError XLSX.table(sh2, "Remove")
+ remaining = XLSX.tables(sh)
+ @test length(remaining) == 1
+ @test remaining[1].name == "Keep"
+ @test XLSX.table(sh, "Keep").ref == XLSX.CellRange("A1:B2")
+ @test_throws KeyError XLSX.table(sh, "Remove")
+ SAVE_FILES && save_outfile(f)
end
- isfile(outfile) && rm(outfile)
- end
-
- @testset "deletetable! - on existing fixture, other sheet's tables untouched" begin
- f = XLSX.opentemplate("data/two_tables.xlsx")
- sh1 = f["Sheet1"]
- sh2 = f["Sheet2"]
+ @testset "deletetable! - survives round trip, remaining table intact" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
- @test length(XLSX.tables(sh1)) == 2
- @test length(XLSX.tables(sh2)) == 1
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="Keep")
- XLSX.deletetable!(sh1, "IO_Table")
+ sh["D1"] = "c"; sh["E1"] = "d"
+ sh["D2"] = 3; sh["E2"] = 4
+ XLSX.addtable!(sh, "D1:E2"; name="Remove")
- @test length(XLSX.tables(sh1)) == 1
- @test XLSX.table(sh1, "Age_height").ref == XLSX.CellRange("E1:G5")
- @test_throws KeyError XLSX.table(sh1, "IO_Table")
+ XLSX.deletetable!(sh, "Remove")
- # Sheet2's table must be completely unaffected
- @test length(XLSX.tables(sh2)) == 1
- @test XLSX.table(sh2, "with_total").has_totals_row == true
-
- SAVE_FILES && save_outfile(f)
+ SAVE_FILES && save_outfile(f)
- outfile = "deletetable_fixture_roundtrip.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ outfile = "deletetable_roundtrip.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
- XLSX.openxlsx(outfile) do xf2
- @test length(XLSX.tables(xf2["Sheet1"])) == 1
- @test XLSX.table(xf2["Sheet1"], "Age_height").columns == ["name", "age", "height"]
- @test_throws KeyError XLSX.table(xf2["Sheet1"], "IO_Table")
+ XLSX.openxlsx(outfile) do xf2
+ sh2 = xf2[1]
+ @test length(XLSX.tables(sh2)) == 1
+ @test XLSX.table(sh2, "Keep").ref == XLSX.CellRange("A1:B2")
+ @test_throws KeyError XLSX.table(sh2, "Remove")
+ end
- @test length(XLSX.tables(xf2["Sheet2"])) == 1
- @test XLSX.table(xf2["Sheet2"], "with_total").ref == XLSX.CellRange("A3:C11")
+ isfile(outfile) && rm(outfile)
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "deletetable! - on existing fixture, other sheet's tables untouched" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "two_tables.xlsx"))
+ sh1 = f["Sheet1"]
+ sh2 = f["Sheet2"]
- @testset "settotals! - adds a totals row where none existed" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "qty"; sh["B1"] = "price"
- sh["A2"] = 3; sh["B2"] = 9.99
- sh["A3"] = 5; sh["B3"] = 4.50
+ @test length(XLSX.tables(sh1)) == 2
+ @test length(XLSX.tables(sh2)) == 1
- XLSX.addtable!(sh, "A1:B3"; name="Sales")
- @test XLSX.table(sh, "Sales").has_totals_row == false
- @test XLSX.table(sh, "Sales").ref == XLSX.CellRange("A1:B3")
+ XLSX.deletetable!(sh1, "IO_Table")
- t = XLSX.settotals!(sh, "Sales", "price" => :sum)
+ @test length(XLSX.tables(sh1)) == 1
+ @test XLSX.table(sh1, "Age_height").ref == XLSX.CellRange("E1:G5")
+ @test_throws KeyError XLSX.table(sh1, "IO_Table")
- @test t.has_totals_row == true
- @test t.ref == XLSX.CellRange("A1:B4") # extended by one row
+ # Sheet2's table must be completely unaffected
+ @test length(XLSX.tables(sh2)) == 1
+ @test XLSX.table(sh2, "with_total").has_totals_row == true
- func, label = _totals_col_attrs(sh, "Sales", "price")
- @test func == "sum"
- @test isnothing(label)
+ SAVE_FILES && save_outfile(f)
- # formula cell has no cached value (per the no-calc-engine convention)
- @test ismissing(sh["B4"])
- SAVE_FILES && save_outfile(f)
- end
+ outfile = "deletetable_fixture_roundtrip.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
- @testset "settotals! - errors if extension row already has content" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "qty"; sh["B1"] = "price"
- sh["A2"] = 3; sh["B2"] = 9.99
- sh["A3"] = "oops"; sh["B3"] = 1 # occupies what would become the totals row
+ XLSX.openxlsx(outfile) do xf2
+ @test length(XLSX.tables(xf2["Sheet1"])) == 1
+ @test XLSX.table(xf2["Sheet1"], "Age_height").columns == ["name", "age", "height"]
+ @test_throws KeyError XLSX.table(xf2["Sheet1"], "IO_Table")
- XLSX.addtable!(sh, "A1:B2"; name="Sales") # table itself only spans A1:B2
- @test_throws XLSX.XLSXError XLSX.settotals!(sh, "Sales", "price" => :sum)
- SAVE_FILES && save_outfile(f)
- end
+ @test length(XLSX.tables(xf2["Sheet2"])) == 1
+ @test XLSX.table(xf2["Sheet2"], "with_total").ref == XLSX.CellRange("A3:C11")
+ end
- @testset "settotals! - label column" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "name"; sh["B1"] = "score"
- sh["A2"] = "alice"; sh["B2"] = 10
- sh["A3"] = "bob"; sh["B3"] = 20
+ isfile(outfile) && rm(outfile)
+ end
+
+ @testset "deletetable! - totals formulas rewritten to static ranges" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "revenue"; sh["B1"] = "cost"; sh["C1"] = "margin"
+ sh["A2"] = 100; sh["B2"] = 60; sh["C2"] = 40
+ sh["A3"] = 200; sh["B3"] = 90; sh["C3"] = 110
+
+ XLSX.addtable!(sh, "A1:C3"; name="PnL")
+ XLSX.settotals!(sh, "PnL",
+ "revenue" => :sum,
+ "cost" => "Total", # label only: no formula -> exercises the skip path
+ "margin" => (:custom, "SUBTOTAL(109,PnL[revenue])-SUBTOTAL(109,PnL[cost])"),
+ )
+
+ @test XLSX.table(sh, "PnL").ref == XLSX.CellRange("A1:C4")
+
+ XLSX.deletetable!(sh, "PnL")
+ @test isempty(XLSX.tables(sh))
+
+ # Single-column reference: PnL[revenue] -> Sheet1!$A$2:$A$3
+ fa = XLSX.getFormula(sh, "A4")
+ @test !occursin("PnL[", fa)
+ @test occursin("\$A\$2:\$A\$3", fa)
+ @test occursin(sh.name, fa)
+
+ # Custom formula referencing *two* columns: both must be substituted,
+ # and the surrounding formula left intact.
+ fc = XLSX.getFormula(sh, "C4")
+ @test !occursin("PnL[", fc)
+ @test occursin("\$A\$2:\$A\$3", fc)
+ @test occursin("\$B\$2:\$B\$3", fc)
+ @test occursin("SUBTOTAL(109,", fc)
+ @test count("SUBTOTAL(109,", fc) == 2
+
+ # Label column had no formula: value untouched, nothing rewritten.
+ @test sh["B4"] == "Total"
+
+ # Cell data survives the delete, as documented.
+ @test sh["A2"] == 100
+ @test sh["C3"] == 110
+ SAVE_FILES && save_outfile(f)
+ end
- XLSX.addtable!(sh, "A1:B3"; name="Scores")
- t = XLSX.settotals!(sh, "Scores", "name" => "Grand Total", "score" => :sum)
+ @testset "deletetable! - totals row with a column that has no totals at all" begin
+ # Covers the `c isa EmptyCell` half of the skip condition: column "b"
+ # has a genuinely empty totals cell.
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
- @test t.has_totals_row == true
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.settotals!(sh, "T", "a" => :sum) # b left with no totals content
- func, label = _totals_col_attrs(sh, "Scores", "name")
- @test isnothing(func)
- @test label == "Grand Total"
- @test sh["A4"] == "Grand Total" # literal value written, not just the attribute
+ XLSX.deletetable!(sh, "T")
- func2, label2 = _totals_col_attrs(sh, "Scores", "score")
- @test func2 == "sum"
- @test isnothing(label2)
- SAVE_FILES && save_outfile(f)
- end
+ @test !occursin("T[", XLSX.getFormula(sh, "A3"))
+ @test ismissing(sh["B3"])
+ @test isempty(XLSX.tables(sh))
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "settotals! - custom formula (:custom tuple)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "revenue"; sh["B1"] = "cost"; sh["C1"] = "margin"
- sh["A2"] = 100; sh["B2"] = 60; sh["C2"] = 40
- sh["A3"] = 200; sh["B3"] = 90; sh["C3"] = 110
-
- XLSX.addtable!(sh, "A1:C3"; name="PnL")
- t = XLSX.settotals!(sh, "PnL",
- "revenue" => :sum,
- "cost" => :sum,
- "margin" => (:custom, "PnL[revenue]-PnL[cost]"),
- )
-
- @test t.has_totals_row == true
-
- func, label = _totals_col_attrs(sh, "PnL", "margin")
- @test func == "custom"
- @test isnothing(label)
- @test ismissing(sh["C4"]) # formula cell, no cached value
- SAVE_FILES && save_outfile(f)
- end
+ @testset "deletetable! - sheet name needing quoting" begin
+ # Exercises quoteit's quoting branch in the static-range substitution.
+ f = XLSX.newxlsx()
+ sh = XLSX.addsheet!(f, "My Data")
+ sh["A1"] = "amount"
+ sh["A2"] = 10
+ sh["A3"] = 20
- @testset "settotals! - rejects malformed custom tuple" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.addtable!(sh, "A1:A3"; name="Amounts")
+ XLSX.settotals!(sh, "Amounts", "amount" => :sum)
+ XLSX.deletetable!(sh, "Amounts")
- @test_throws XLSX.XLSXError XLSX.settotals!(sh, "T", "b" => (:notcustom, "SUM(1,2)"))
- SAVE_FILES && save_outfile(f)
- end
+ fa = XLSX.getFormula(sh, "A4")
+ @test !occursin("Amounts[", fa)
+ @test occursin("My Data", fa)
+ @test occursin("\$A\$2:\$A\$3", fa)
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "settotals! - unknown column name errors" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
+ @testset "deletetable! - round trip after totals rewrite" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "n"
+ sh["A2"] = 1
+ sh["A3"] = 2
+ XLSX.addtable!(sh, "A1:A3"; name="T")
+ XLSX.settotals!(sh, "T", "n" => :sum)
+ XLSX.deletetable!(sh, "T")
+ SAVE_FILES && save_outfile(f)
- @test_throws XLSX.XLSXError XLSX.settotals!(sh, "T", "nope" => :sum)
- SAVE_FILES && save_outfile(f)
- end
+ outfile = "deletetable_totals_roundtrip.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
- @testset "settotals! - unknown built-in function symbol errors" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.openxlsx(outfile) do xf2
+ sh2 = xf2[1]
+ @test isempty(XLSX.tables(sh2))
+ @test !occursin("T[", XLSX.getFormula(sh2, "A4"))
+ @test sh2["A2"] == 1
+ end
- @test_throws XLSX.XLSXError XLSX.settotals!(sh, "T", "b" => :median)
- SAVE_FILES && save_outfile(f)
+ isfile(outfile) && rm(outfile)
+ end
end
- @testset "settotals! - invalid value type errors" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
+ @testset "settotals!" begin
+ @testset "settotals! - adds a totals row where none existed" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "qty"; sh["B1"] = "price"
+ sh["A2"] = 3; sh["B2"] = 9.99
+ sh["A3"] = 5; sh["B3"] = 4.50
- @test_throws XLSX.XLSXError XLSX.settotals!(sh, "T", "b" => 3.14)
- SAVE_FILES && save_outfile(f)
- end
+ XLSX.addtable!(sh, "A1:B3"; name="Sales")
+ @test XLSX.table(sh, "Sales").has_totals_row == false
+ @test XLSX.table(sh, "Sales").ref == XLSX.CellRange("A1:B3")
- @testset "settotals! - only mentioned columns change; others untouched" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"; sh["C1"] = "c"
- sh["A2"] = 1; sh["B2"] = 2; sh["C2"] = 3
-
- XLSX.addtable!(sh, "A1:C2"; name="T")
- XLSX.settotals!(sh, "T", "a" => :sum, "b" => :average)
-
- func_a, _ = _totals_col_attrs(sh, "T", "a")
- func_b, _ = _totals_col_attrs(sh, "T", "b")
- func_c, label_c = _totals_col_attrs(sh, "T", "c")
-
- @test func_a == "sum"
- @test func_b == "average"
- @test isnothing(func_c)
- @test isnothing(label_c)
-
- # calling again on only column "a" must not disturb "b"
- XLSX.settotals!(sh, "T", "a" => :max)
- func_a2, _ = _totals_col_attrs(sh, "T", "a")
- func_b2, _ = _totals_col_attrs(sh, "T", "b")
- @test func_a2 == "max"
- @test func_b2 == "average" # unchanged
- SAVE_FILES && save_outfile(f)
- end
+ t = XLSX.settotals!(sh, "Sales", "price" => :sum)
- @testset "settotals! - overwriting a column's totals replaces stale content" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
-
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.settotals!(sh, "T", "b" => "Label first")
- @test sh["B3"] == "Label first"
- func1, label1 = _totals_col_attrs(sh, "T", "b")
- @test isnothing(func1)
- @test label1 == "Label first"
-
- # now switch that same column to a function — old label attribute
- # and cell value must be fully replaced, not left lingering
- XLSX.settotals!(sh, "T", "b" => :sum)
- func2, label2 = _totals_col_attrs(sh, "T", "b")
- @test func2 == "sum"
- @test isnothing(label2) # old totalsRowLabel attribute must be gone
- @test ismissing(sh["B3"]) # old literal "Label first" value must be gone
- SAVE_FILES && save_outfile(f)
- end
+ @test t.has_totals_row == true
+ @test t.ref == XLSX.CellRange("A1:B4") # extended by one row
- @testset "settotals! - kwarg form" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "revenue"; sh["B1"] = "margin"
- sh["A2"] = 100; sh["B2"] = 40
-
- XLSX.addtable!(sh, "A1:B2"; name="Rpt")
- t = XLSX.settotals!(sh, "Rpt"; revenue=:sum, margin=:average)
-
- @test t.has_totals_row == true
- func_r, _ = _totals_col_attrs(sh, "Rpt", "revenue")
- func_m, _ = _totals_col_attrs(sh, "Rpt", "margin")
- @test func_r == "sum"
- @test func_m == "average"
- SAVE_FILES && save_outfile(f)
- end
+ func, label = _totals_col_attrs(sh, "Sales", "price")
+ @test func == "sum"
+ @test isnothing(label)
- @testset "settotals! - by id" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- tbl = XLSX.addtable!(sh, "A1:B2"; name="ById")
-
- t = XLSX.settotals!(sh, tbl.id, "b" => :sum)
- @test t.has_totals_row == true
- func, _ = _totals_col_attrs(sh, "ById", "b")
- @test func == "sum"
- SAVE_FILES && save_outfile(f)
- end
+ # formula cell has no cached value (per the no-calc-engine convention)
+ @test ismissing(sh["B4"])
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "settotals! - updating a table that already has a totals row (real fixture)" begin
- f = XLSX.opentemplate("data/two_tables.xlsx")
- sh2 = f["Sheet2"]
+ @testset "settotals! - errors if extension row already has content" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "qty"; sh["B1"] = "price"
+ sh["A2"] = 3; sh["B2"] = 9.99
+ sh["A3"] = "oops"; sh["B3"] = 1 # occupies what would become the totals row
- t_before = XLSX.table(sh2, "with_total")
- @test t_before.has_totals_row == true
- ref_before = t_before.ref
+ XLSX.addtable!(sh, "A1:B2"; name="Sales") # table itself only spans A1:B2
+ @test_throws XLSX.XLSXError XLSX.settotals!(sh, "Sales", "price" => :sum)
+ SAVE_FILES && save_outfile(f)
+ end
- func_before, _ = _totals_col_attrs(sh2, "with_total", "sin")
- @test func_before == "sum"
+ @testset "settotals! - label column" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "name"; sh["B1"] = "score"
+ sh["A2"] = "alice"; sh["B2"] = 10
+ sh["A3"] = "bob"; sh["B3"] = 20
- # change the existing function without adding a new row
- t_after = XLSX.settotals!(sh2, "with_total", "sin" => :average)
+ XLSX.addtable!(sh, "A1:B3"; name="Scores")
+ t = XLSX.settotals!(sh, "Scores", "name" => "Grand Total", "score" => :sum)
- @test t_after.ref == ref_before # unchanged — table already had a totals row
- func_after, _ = _totals_col_attrs(sh2, "with_total", "sin")
- @test func_after == "average"
- SAVE_FILES && save_outfile(f)
- end
+ @test t.has_totals_row == true
- @testset "settotals! - round trip (create -> save -> reopen)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "amount"; sh["C1"] = "note"
- sh["A2"] = 1; sh["B2"] = 50; sh["C2"] = "x"
- sh["A3"] = 2; sh["B3"] = 75; sh["C3"] = "y"
+ func, label = _totals_col_attrs(sh, "Scores", "name")
+ @test isnothing(func)
+ @test label == "Grand Total"
+ @test sh["A4"] == "Grand Total" # literal value written, not just the attribute
- XLSX.addtable!(sh, "A1:C3"; name="RT")
- XLSX.settotals!(sh, "RT", "amount" => :sum, "note" => "Total")
+ func2, label2 = _totals_col_attrs(sh, "Scores", "score")
+ @test func2 == "sum"
+ @test isnothing(label2)
+ SAVE_FILES && save_outfile(f)
+ end
- SAVE_FILES && save_outfile(f)
+ @testset "settotals! - custom formula (:custom tuple)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "revenue"; sh["B1"] = "cost"; sh["C1"] = "margin"
+ sh["A2"] = 100; sh["B2"] = 60; sh["C2"] = 40
+ sh["A3"] = 200; sh["B3"] = 90; sh["C3"] = 110
- outfile = "settotals_roundtrip.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ XLSX.addtable!(sh, "A1:C3"; name="PnL")
+ t = XLSX.settotals!(sh, "PnL",
+ "revenue" => :sum,
+ "cost" => :sum,
+ "margin" => (:custom, "PnL[revenue]-PnL[cost]"),
+ )
- XLSX.openxlsx(outfile) do xf2
- sh2 = xf2[1]
- t = XLSX.table(sh2, "RT")
@test t.has_totals_row == true
- @test t.ref == XLSX.CellRange("A1:C4")
- func, _ = _totals_col_attrs(sh2, "RT", "amount")
- @test func == "sum"
- _, label = _totals_col_attrs(sh2, "RT", "note")
- @test label == "Total"
- @test sh2["C4"] == "Total"
+ func, label = _totals_col_attrs(sh, "PnL", "margin")
+ @test func == "custom"
+ @test isnothing(label)
+ @test ismissing(sh["C4"]) # formula cell, no cached value
+ SAVE_FILES && save_outfile(f)
end
- XLSX.openxlsx(outfile, enable_cache=false) do xf2
- t = XLSX.table(xf2[1], "RT")
- @test t.has_totals_row == true
- @test t.ref == XLSX.CellRange("A1:C4")
- end
+ @testset "settotals! - rejects malformed custom tuple" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
- isfile(outfile) && rm(outfile)
- end
+ @test_throws XLSX.XLSXError XLSX.settotals!(sh, "T", "b" => (:notcustom, "SUM(1,2)"))
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "show(Table) - compact form, no style, no totals" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "name"; sh["C1"] = "score"
- sh["A2"] = 1; sh["B2"] = "alice"; sh["C2"] = 10.5
-
- t = XLSX.addtable!(sh, "A1:C2"; name="Plain")
- s = sprint(show, t)
-
- @test occursin("Plain", s)
- @test occursin(string(t.ref), s)
- @test occursin("id=$(t.id)", s)
- @test occursin("1x3", s) # 1 data row x 3 columns
- @test !occursin("totals", s) # no totals row -> no "+totals" marker
- SAVE_FILES && save_outfile(f)
- end
+ @testset "settotals! - unknown column name errors" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
- @testset "show(Table) - compact form, with totals" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "qty"; sh["B1"] = "price"
- sh["A2"] = 3; sh["B2"] = 9.99
-
- XLSX.addtable!(sh, "A1:B2"; name="Sales")
- t = XLSX.settotals!(sh, "Sales", "price" => :sum)
- s = sprint(show, t)
-
- @test occursin("Sales", s)
- @test occursin("id=$(t.id)", s)
- @test occursin("1x2", s) # data rows exclude header and totals row
- @test occursin("totals", s)
- SAVE_FILES && save_outfile(f)
- end
+ @test_throws XLSX.XLSXError XLSX.settotals!(sh, "T", "nope" => :sum)
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "show(Table) - compact form used in Vector display" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="TblA")
-
- sh["D1"] = "c"; sh["E1"] = "d"
- sh["D2"] = 3; sh["E2"] = 4
- XLSX.addtable!(sh, "D1:E2"; name="TblB")
-
- tbls = XLSX.tables(sh)
-
- # plain `show` — no array summary header, just each element's compact form
- s = sprint(show, tbls)
- @test occursin("TblA", s)
- @test occursin("TblB", s)
-
- # MIME"text/plain" — this is the form that adds the "2-element" summary
- s_repl = sprint(show, MIME("text/plain"), tbls)
- @test occursin("TblA", s_repl)
- @test occursin("TblB", s_repl)
- @test occursin("2-element", s_repl)
- SAVE_FILES && save_outfile(f)
- end
+ @testset "settotals! - unknown built-in function symbol errors" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
- @testset "show(text/plain, Table) - no style, no totals" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "name"
- sh["A2"] = 1; sh["B2"] = "alice"
-
- t = XLSX.addtable!(sh, "A1:B2"; name="Basic")
- s = sprint(show, MIME("text/plain"), t)
-
- @test occursin("Basic", s)
- @test occursin(string(t.id), s)
- @test occursin(string(t.ref), s)
- @test occursin("id, name", s)
- @test occursin("style : none", s)
- @test occursin("totals : no", s)
- @test !occursin("displayName", s) # name == display_name, so no extra note
- SAVE_FILES && save_outfile(f)
- end
+ @test_throws XLSX.XLSXError XLSX.settotals!(sh, "T", "b" => :median)
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "show(text/plain, Table) - with style and totals row" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "region"; sh["B1"] = "revenue"
- sh["A2"] = "North"; sh["B2"] = 1000
-
- XLSX.addtable!(sh, "A1:B2"; name="Sales", style="TableStyleMedium9")
- t = XLSX.settotals!(sh, "Sales", "region" => "Total", "revenue" => :sum)
- s = sprint(show, MIME("text/plain"), t)
-
- @test occursin("Sales", s)
- @test occursin("region, revenue", s)
- @test occursin("TableStyleMedium9", s)
- @test occursin("row stripes", s) # default style_info sets show_row_stripes=true
- @test occursin("totals : yes", s)
- SAVE_FILES && save_outfile(f)
- end
+ @testset "settotals! - invalid value type errors" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
- @testset "show(TableStyleInfo) - compact form" begin
- style = XLSX.TableStyleInfo("TableStyleLight1", false, false, true, false)
- s = sprint(show, style)
+ @test_throws XLSX.XLSXError XLSX.settotals!(sh, "T", "b" => 3.14)
+ SAVE_FILES && save_outfile(f)
+ end
- @test occursin("TableStyleLight1", s)
- @test occursin("rows", s)
- @test !occursin("first", s)
- @test !occursin("last", s)
- @test !occursin("cols", s)
- end
+ @testset "settotals! - only mentioned columns change; others untouched" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"; sh["C1"] = "c"
+ sh["A2"] = 1; sh["B2"] = 2; sh["C2"] = 3
- @testset "show(TableStyleInfo) - no name, no flags" begin
- style = XLSX.TableStyleInfo(nothing, false, false, false, false)
- s = sprint(show, style)
+ XLSX.addtable!(sh, "A1:C2"; name="T")
+ XLSX.settotals!(sh, "T", "a" => :sum, "b" => :average)
- @test occursin("none", s)
- @test !occursin(",", s) # no flags appended
- end
+ func_a, _ = _totals_col_attrs(sh, "T", "a")
+ func_b, _ = _totals_col_attrs(sh, "T", "b")
+ func_c, label_c = _totals_col_attrs(sh, "T", "c")
- @testset "show(TableStyleInfo) - all flags set" begin
- style = XLSX.TableStyleInfo("Custom", true, true, true, true)
- s = sprint(show, style)
+ @test func_a == "sum"
+ @test func_b == "average"
+ @test isnothing(func_c)
+ @test isnothing(label_c)
- @test occursin("Custom", s)
- @test occursin("first", s)
- @test occursin("last", s)
- @test occursin("rows", s)
- @test occursin("cols", s)
- end
+ # calling again on only column "a" must not disturb "b"
+ XLSX.settotals!(sh, "T", "a" => :max)
+ func_a2, _ = _totals_col_attrs(sh, "T", "a")
+ func_b2, _ = _totals_col_attrs(sh, "T", "b")
+ @test func_a2 == "max"
+ @test func_b2 == "average" # unchanged
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "show output does not error on real fixture tables" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- for t in XLSX.tables(xf["Sheet1"])
- @test !isempty(sprint(show, t))
- @test !isempty(sprint(show, MIME("text/plain"), t))
- end
- for t in XLSX.tables(xf["Sheet2"])
- @test !isempty(sprint(show, t))
- @test !isempty(sprint(show, MIME("text/plain"), t))
- end
+ @testset "settotals! - overwriting a column's totals replaces stale content" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.settotals!(sh, "T", "b" => "Label first")
+ @test sh["B3"] == "Label first"
+ func1, label1 = _totals_col_attrs(sh, "T", "b")
+ @test isnothing(func1)
+ @test label1 == "Label first"
+
+ # now switch that same column to a function — old label attribute
+ # and cell value must be fully replaced, not left lingering
+ XLSX.settotals!(sh, "T", "b" => :sum)
+ func2, label2 = _totals_col_attrs(sh, "T", "b")
+ @test func2 == "sum"
+ @test isnothing(label2) # old totalsRowLabel attribute must be gone
+ @test ismissing(sh["B3"]) # old literal "Label first" value must be gone
+ SAVE_FILES && save_outfile(f)
end
- end
- @testset "writetable! - as_table=true basic" begin
- f = XLSX.newxlsx()
- sh = f[1]
- data = [[1, 2, 3], ["alice", "bob", "carol"]]
- cols = ["id", "name"]
+ @testset "settotals! - kwarg form" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "revenue"; sh["B1"] = "margin"
+ sh["A2"] = 100; sh["B2"] = 40
- XLSX.writetable!(sh, data, cols; as_table=true, table_name="People")
+ XLSX.addtable!(sh, "A1:B2"; name="Rpt")
+ t = XLSX.settotals!(sh, "Rpt"; revenue=:sum, margin=:average)
- t = XLSX.table(sh, "People")
- @test t.ref == XLSX.CellRange("A1:B4")
- @test t.columns == ["id", "name"]
- @test t.has_totals_row == false
- SAVE_FILES && save_outfile(f)
- end
+ @test t.has_totals_row == true
+ func_r, _ = _totals_col_attrs(sh, "Rpt", "revenue")
+ func_m, _ = _totals_col_attrs(sh, "Rpt", "margin")
+ @test func_r == "sum"
+ @test func_m == "average"
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "writetable! - as_table=false writes no table (default)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- data = [[1, 2], [3, 4]]
- cols = ["a", "b"]
+ @testset "settotals! - by id" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ tbl = XLSX.addtable!(sh, "A1:B2"; name="ById")
- XLSX.writetable!(sh, data, cols) # as_table defaults to false
+ t = XLSX.settotals!(sh, tbl.id, "b" => :sum)
+ @test t.has_totals_row == true
+ func, _ = _totals_col_attrs(sh, "ById", "b")
+ @test func == "sum"
+ SAVE_FILES && save_outfile(f)
+ end
- @test isempty(XLSX.tables(sh))
- SAVE_FILES && save_outfile(f)
- end
+ @testset "settotals! - updating a table that already has a totals row (real fixture)" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "two_tables.xlsx"))
+ sh2 = f["Sheet2"]
- @testset "writetable! - as_table=true requires write_columnnames=true" begin
- f = XLSX.newxlsx()
- sh = f[1]
- data = [[1, 2], [3, 4]]
- cols = ["a", "b"]
+ t_before = XLSX.table(sh2, "with_total")
+ @test t_before.has_totals_row == true
+ ref_before = t_before.ref
- @test_throws XLSX.XLSXError XLSX.writetable!(sh, data, cols;
- as_table=true, write_columnnames=false)
- SAVE_FILES && save_outfile(f)
- end
+ func_before, _ = _totals_col_attrs(sh2, "with_total", "sin")
+ @test func_before == "sum"
- @testset "writetable! - as_table=true requires at least one data row" begin
- f = XLSX.newxlsx()
- sh = f[1]
- data = [Int[], String[]]
- cols = ["a", "b"]
+ # change the existing function without adding a new row
+ t_after = XLSX.settotals!(sh2, "with_total", "sin" => :average)
- @test_throws XLSX.XLSXError XLSX.writetable!(sh, data, cols; as_table=true)
- SAVE_FILES && save_outfile(f)
- end
+ @test t_after.ref == ref_before # unchanged — table already had a totals row
+ func_after, _ = _totals_col_attrs(sh2, "with_total", "sin")
+ @test func_after == "average"
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "writetable! - as_table=true with style and anchor offset" begin
- f = XLSX.newxlsx()
- sh = f[1]
- data = [[10, 20], [1.5, 2.5]]
- cols = ["qty", "price"]
+ @testset "settotals! - round trip (create -> save -> reopen)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "amount"; sh["C1"] = "note"
+ sh["A2"] = 1; sh["B2"] = 50; sh["C2"] = "x"
+ sh["A3"] = 2; sh["B3"] = 75; sh["C3"] = "y"
- XLSX.writetable!(sh, data, cols;
- anchor_cell=XLSX.CellRef("C3"), as_table=true,
- table_name="Prices", table_style="TableStyleMedium9")
+ XLSX.addtable!(sh, "A1:C3"; name="RT")
+ XLSX.settotals!(sh, "RT", "amount" => :sum, "note" => "Total")
- t = XLSX.table(sh, "Prices")
- @test t.ref == XLSX.CellRange("C3:D5")
- @test t.style.name == "TableStyleMedium9"
- SAVE_FILES && save_outfile(f)
- end
+ SAVE_FILES && save_outfile(f)
- @testset "writetable (single-sheet, new file) - as_table=true" begin
- outfile = "writetable_astable_single.xlsx"
+ outfile = "settotals_roundtrip.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf2
+ sh2 = xf2[1]
+ t = XLSX.table(sh2, "RT")
+ @test t.has_totals_row == true
+ @test t.ref == XLSX.CellRange("A1:C4")
+
+ func, _ = _totals_col_attrs(sh2, "RT", "amount")
+ @test func == "sum"
+ _, label = _totals_col_attrs(sh2, "RT", "note")
+ @test label == "Total"
+ @test sh2["C4"] == "Total"
+ end
- columns = [[1, 2, 3], ["x", "y", "z"]]
- colnames = ["num", "letter"]
- XLSX.writetable(outfile, columns, colnames; as_table=true, table_name="Data", overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ XLSX.openxlsx(outfile, enable_cache=false) do xf2
+ t = XLSX.table(xf2[1], "RT")
+ @test t.has_totals_row == true
+ @test t.ref == XLSX.CellRange("A1:C4")
+ end
- XLSX.openxlsx(outfile) do xf
- t = XLSX.table(xf[1], "Data")
- @test t.ref == XLSX.CellRange("A1:B4")
- @test t.columns == ["num", "letter"]
+ isfile(outfile) && rm(outfile)
end
- isfile(outfile) && rm(outfile)
- end
-
- @testset "writetable (kwarg multi-sheet) - as_table=true, table names from sheet names" begin
- outfile = "writetable_astable_multi_kw.xlsx"
+ @testset "settotals! - :none clears a column's totals, row survives" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
- colsA = [[1, 2], [3, 4]]
- namesA = ["a", "b"]
- colsB = [[5, 6], [7, 8]]
- namesB = ["c", "d"]
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.settotals!(sh, "T", "a" => :sum, "b" => "Label")
+ @test sh["B3"] == "Label"
- XLSX.writetable(outfile, as_table=true, table_style="TableStyleLight1",
- REPORT_A=(colsA, namesA), REPORT_B=(colsB, namesB), overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ t = XLSX.settotals!(sh, "T", "a" => :none, "b" => :none)
- XLSX.openxlsx(outfile) do xf
- tA = XLSX.table(xf["REPORT_A"], "REPORT_A")
- @test tA.ref == XLSX.CellRange("A1:B3")
- @test tA.style.name == "TableStyleLight1"
+ func_a, label_a = _totals_col_attrs(sh, "T", "a")
+ func_b, label_b = _totals_col_attrs(sh, "T", "b")
+ @test isnothing(func_a); @test isnothing(label_a)
+ @test isnothing(func_b); @test isnothing(label_b)
+ @test ismissing(sh["A3"])
+ @test ismissing(sh["B3"])
- tB = XLSX.table(xf["REPORT_B"], "REPORT_B")
- @test tB.ref == XLSX.CellRange("A1:B3")
- @test tB.style.name == "TableStyleLight1"
+ # an entirely empty totals row is still a totals row
+ @test t.has_totals_row == true
+ @test t.ref == XLSX.CellRange("A1:B3")
+ SAVE_FILES && save_outfile(f)
end
- isfile(outfile) && rm(outfile)
+ @testset "settotals! - :none on a table with no totals row still adds one" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+
+ t = XLSX.settotals!(sh, "T", "a" => :none)
+ @test t.has_totals_row == true
+ @test t.ref == XLSX.CellRange("A1:B3")
+ @test ismissing(sh["A3"])
+ SAVE_FILES && save_outfile(f)
+ end
end
- @testset "writetable (Vector{Tuple} multi-sheet) - as_table=true, table names from sheet names" begin
- outfile = "writetable_astable_multi_vec.xlsx"
+ @testset "removetotals!" begin
+ @testset "removes the totals row and shrinks ref" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ sh["A3"] = "Pears"; sh["B3"] = 8
+ XLSX.addtable!(sh, "A1:B3"; name="Bulk")
+ XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
+
+ @test XLSX.table(sh, "Bulk").ref == XLSX.CellRange("A1:B4")
+ @test XLSX.table(sh, "Bulk").has_totals_row == true
- colsA = [[1, 2], [3, 4]]
- namesA = ["a", "b"]
- colsB = [[5, 6], [7, 8]]
- namesB = ["c", "d"]
+ t = XLSX.removetotals!(sh, "Bulk")
- XLSX.writetable(outfile, [
- ("First", colsA, namesA),
- ("Second", colsB, namesB),
- ]; as_table=true, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ @test t.ref == XLSX.CellRange("A1:B3")
+ @test t.has_totals_row == false
- XLSX.openxlsx(outfile) do xf
- @test XLSX.table(xf["First"], "First").ref == XLSX.CellRange("A1:B3")
- @test XLSX.table(xf["Second"], "Second").ref == XLSX.CellRange("A1:B3")
+ # the old totals row cells are cleared
+ @test ismissing(sh["A4"])
+ @test ismissing(sh["B4"])
+
+ # data rows untouched
+ cols = Tables.columns(t)
+ @test cols.item == ["Apples", "Pears"]
+ @test cols.amount == [12, 8]
+ SAVE_FILES && save_outfile(f)
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "drops totalsRowShown / totalsRowCount from the table part" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.settotals!(sh, "T", "b" => :sum)
- @testset "writetable - table name normalized from an invalid sheet name (spaces)" begin
- outfile = "writetable_astable_normalize.xlsx"
+ table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "T"))
+ root = XLSX.xml_root_element(table_doc)
+ @test XLSX.get_attr(root, "totalsRowShown", "") != ""
+ @test XLSX.get_attr(root, "totalsRowCount", "") != ""
- cols = [[1, 2], [3, 4]]
- names = ["x", "y"]
+ XLSX.removetotals!(sh, "T")
- # sheet names may contain spaces; table names may not — expect a
- # warning and a normalized fallback name ("Report A" -> "Report_A")
- @test_logs (:warn, r"valid Excel Table name"i) match_mode=:any begin
- XLSX.writetable(outfile, [("Report A", cols, names)]; as_table=true, overwrite=true)
+ @test XLSX.get_attr(root, "totalsRowShown", "") == ""
+ @test XLSX.get_attr(root, "totalsRowCount", "") == ""
+ @test XLSX.get_attr(root, "ref") == "A1:B2"
+ SAVE_FILES && save_outfile(f)
end
- SAVE_FILES && save_outfile(outfile)
- XLSX.openxlsx(outfile) do xf
- sh = xf["Report A"]
- tbls = XLSX.tables(sh)
- @test length(tbls) == 1
- @test tbls[1].name == "Report_A"
+ @testset "drops per-column totalsRowFunction / totalsRowLabel" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "label"; sh["B1"] = "num"; sh["C1"] = "other"
+ sh["A2"] = "x"; sh["B2"] = 10; sh["C2"] = 20
+ XLSX.addtable!(sh, "A1:C2"; name="T")
+ XLSX.settotals!(sh, "T", "label" => "Total", "num" => :sum)
+
+ XLSX.removetotals!(sh, "T")
+
+ table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "T"))
+ i, j = XLSX.get_idces(table_doc, "table", "tableColumns")
+ for node in XLSX.xml_elements(table_doc[i][j])
+ XLSX.localname(node) == "tableColumn" || continue
+ @test XLSX.get_attr(node, "totalsRowFunction", "") == ""
+ @test XLSX.get_attr(node, "totalsRowLabel", "") == ""
+ end
+ SAVE_FILES && save_outfile(f)
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "clears a custom totals formula" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "revenue"; sh["B1"] = "cost"; sh["C1"] = "margin"
+ sh["A2"] = 100; sh["B2"] = 60; sh["C2"] = 40
+ XLSX.addtable!(sh, "A1:C2"; name="PnL")
+ XLSX.settotals!(sh, "PnL",
+ "revenue" => :sum,
+ "margin" => (:custom, "SUBTOTAL(109,PnL[revenue])-SUBTOTAL(109,PnL[cost])"),
+ )
- @testset "writetable - normalized name falls back to auto-generated on collision" begin
- outfile = "writetable_astable_normalize_collision.xlsx"
+ @test !isnothing(XLSX.getFormula(sh, "C3"))
- cols = [[1, 2], [3, 4]]
- names = ["x", "y"]
+ XLSX.removetotals!(sh, "PnL")
- # "Report A" normalizes to "Report_A"; second sheet is literally
- # named "Report_A" already — its table name candidate collides with
- # the first table, so it must fall back to an auto-generated name.
- # Two warnings are expected here (normalization + collision) but
- # aren't the focus of this test, so they're suppressed rather than
- # left to print to the console.
- with_logger(NullLogger()) do
- XLSX.writetable(outfile, [
- ("Report A", cols, names),
- ("Report_A", cols, names),
- ]; as_table=true, overwrite=true)
+ @test XLSX.table(sh, "PnL").ref == XLSX.CellRange("A1:C2")
+ @test ismissing(sh["A3"])
+ @test ismissing(sh["C3"])
+ SAVE_FILES && save_outfile(f)
end
- SAVE_FILES && save_outfile(outfile)
- XLSX.openxlsx(outfile) do xf
- t1 = only(XLSX.tables(xf["Report A"]))
- t2 = only(XLSX.tables(xf["Report_A"]))
+ @testset "no-op when the table has no totals row" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ t_before = XLSX.addtable!(sh, "A1:B2"; name="T")
- @test t1.name == "Report_A"
- @test t2.name != "Report_A" # forced to fall back
- @test t2.name != t1.name
- @test startswith(t2.name, "Table") # addtable!'s auto-naming pattern
+ t_after = XLSX.removetotals!(sh, "T")
+
+ @test t_after.ref == t_before.ref
+ @test t_after.has_totals_row == false
+ @test sh["A2"] == 1 # nothing cleared
+ @test sh["B2"] == 2
+ SAVE_FILES && save_outfile(f)
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "by id" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ tbl = XLSX.addtable!(sh, "A1:B2"; name="ById")
+ XLSX.settotals!(sh, "ById", "b" => :sum)
- @testset "writetable - reserved Julia keyword as sheet name still normalizes" begin
- outfile = "writetable_astable_reserved.xlsx"
+ t = XLSX.removetotals!(sh, tbl.id)
+ @test t.has_totals_row == false
+ @test t.ref == XLSX.CellRange("A1:B2")
+ SAVE_FILES && save_outfile(f)
+ end
- cols = [[1, 2], [3, 4]]
- names = ["x", "y"]
+ @testset "table not found errors" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
- # "for" is a valid Excel sheet name (no restricted characters) but a
- # reserved Julia keyword; normalizename prepends "_" for these. The
- # resulting warning isn't the focus of this test, so it's suppressed.
- with_logger(NullLogger()) do
- XLSX.writetable(outfile, [("for", cols, names)]; as_table=true, overwrite=true)
+ @test_throws KeyError XLSX.removetotals!(sh, "NoSuchTable")
+ SAVE_FILES && save_outfile(f)
end
- SAVE_FILES && save_outfile(outfile)
- XLSX.openxlsx(outfile) do xf
- t = only(XLSX.tables(xf["for"]))
- @test t.name == "_for"
+ @testset "not writable errors" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf # read-only
+ sh2 = xf["Sheet2"]
+ @test_throws XLSX.XLSXError XLSX.removetotals!(sh2, "with_total")
+ end
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "settotals! can add a totals row back afterwards" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ sh["A3"] = "Pears"; sh["B3"] = 8
+ XLSX.addtable!(sh, "A1:B3"; name="Bulk")
- @testset "writetable - as_table=false leaves no tables in multi-sheet write" begin
- outfile = "writetable_no_table_multi.xlsx"
+ XLSX.settotals!(sh, "Bulk", "amount" => :sum)
+ @test XLSX.table(sh, "Bulk").ref == XLSX.CellRange("A1:B4")
- cols = [[1, 2], [3, 4]]
- names = ["x", "y"]
- XLSX.writetable(outfile, [("Sheet1", cols, names), ("Sheet2", cols, names)]; overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ XLSX.removetotals!(sh, "Bulk")
+ @test XLSX.table(sh, "Bulk").ref == XLSX.CellRange("A1:B3")
- XLSX.openxlsx(outfile) do xf
- @test isempty(XLSX.tables(xf["Sheet1"]))
- @test isempty(XLSX.tables(xf["Sheet2"]))
+ # row 4 must be empty again, so settotals! can re-extend into it
+ t = XLSX.settotals!(sh, "Bulk", "amount" => :average)
+ @test t.ref == XLSX.CellRange("A1:B4")
+ @test t.has_totals_row == true
+ @test occursin("SUBTOTAL(101", XLSX.getFormula(sh, "B4"))
+ SAVE_FILES && save_outfile(f)
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "appendtable! works after removing totals" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 10
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.settotals!(sh, "T", "b" => :sum)
+ XLSX.removetotals!(sh, "T")
- @testset "Tables.istable / rowaccess / columnaccess" begin
- @test Tables.istable(XLSX.Table)
- @test Tables.istable(XLSX.XLSXTableRowIterator)
- @test Tables.rowaccess(XLSX.Table)
- @test Tables.rowaccess(XLSX.XLSXTableRowIterator)
- @test Tables.columnaccess(XLSX.Table)
- end
+ t = XLSX.appendtable!(sh, "T", [(2, 20), (3, 30)])
+ @test t.ref == XLSX.CellRange("A1:B4")
+ @test t.has_totals_row == false
+ @test Tables.columns(t).a == [1, 2, 3]
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "Tables.schema - column names, types unknown (nothing)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "name"; sh["C1"] = "score"
- sh["A2"] = 1; sh["B2"] = "alice"; sh["C2"] = 10.5
-
- t = XLSX.addtable!(sh, "A1:C2"; name="T")
- sch = Tables.schema(t)
-
- # `nothing` (not a declared `fill(Any, n)`) is deliberate: declaring
- # Any explicitly would make DataFrames (and other Tables.jl sinks)
- # trust that declaration literally and permanently box every column
- # as Any, rather than inferring real types from the data — exactly
- # the issue #225 regression.
- @test sch.names == (:id, :name, :score)
- @test isnothing(sch.types)
- SAVE_FILES && save_outfile(f)
- end
+ @testset "round trip: remove totals, save and reopen" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ sh["A3"] = "Pears"; sh["B3"] = 8
+ XLSX.addtable!(sh, "A1:B3"; name="Bulk", style="TableStyleMedium2")
+ XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
+ XLSX.removetotals!(sh, "Bulk")
+ SAVE_FILES && save_outfile(f)
- @testset "Tables.columns / DataFrame infer concrete column types (issue #225 regression)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "score"; sh["B1"] = "label"
- sh["A2"] = 10.5; sh["B2"] = "alice"
- sh["A3"] = 20.0; sh["B3"] = "bob"
- sh["A4"] = 15.25; sh["B4"] = "carol"
-
- t = XLSX.addtable!(sh, "A1:B4"; name="T")
-
- # Direct Tables.columns(t) — the underlying mechanism
- cols = Tables.columns(t)
- @test eltype(cols.score) != Any
- @test eltype(cols.score) <: Union{Missing,Float64}
- @test eltype(cols.label) <: Union{Missing,String}
-
- # Same, via the row iterator returned by eachtablerow — this is the
- # exact path that previously regressed to Any/Any (issue #225)
- it = XLSX.eachtablerow(t)
- cols_via_iter = Tables.columns(it)
- @test eltype(cols_via_iter.score) != Any
- @test eltype(cols_via_iter.score) <: Union{Missing,Float64}
-
- # And the full DataFrame(...) round trip
- df = DataFrames.DataFrame(it)
- @test eltype(df.score) != Any
- @test eltype(df.score) <: Union{Missing,Float64}
- @test eltype(df.label) <: Union{Missing,String}
- SAVE_FILES && save_outfile(f)
- end
+ outfile = "removetotals_roundtrip.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf2
+ t = XLSX.table(xf2[1], "Bulk")
+ @test t.ref == XLSX.CellRange("A1:B3")
+ @test t.has_totals_row == false
+ @test t.style.name == "TableStyleMedium2" # style survives
+ cols = Tables.columns(t)
+ @test cols.item == ["Apples", "Pears"]
+ @test cols.amount == [12, 8]
+ end
- @testset "Tables.schema - matches between Table and its row iterator" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
+ XLSX.openxlsx(outfile, enable_cache=false) do xf2
+ t = XLSX.table(xf2[1], "Bulk")
+ @test t.ref == XLSX.CellRange("A1:B3")
+ @test t.has_totals_row == false
+ end
- t = XLSX.addtable!(sh, "A1:B2"; name="T")
- it = Tables.rows(t)
+ isfile(outfile) && rm(outfile)
+ end
- @test it isa XLSX.XLSXTableRowIterator
- @test Tables.rows(it) === it # identity, matching TableRowIterator convention
- @test Tables.schema(it).names == Tables.schema(t).names
- @test Tables.schema(it).types == Tables.schema(t).types
- SAVE_FILES && save_outfile(f)
- end
+ @testset "real fixture (two_tables.xlsx) - with_total" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "two_tables.xlsx"))
+ sh2 = f["Sheet2"]
- @testset "Tables.columnnames matches table columns" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "x"; sh["B1"] = "y"
- sh["A2"] = 1; sh["B2"] = 2
-
- t = XLSX.addtable!(sh, "A1:B2"; name="T")
- row = first(XLSX.eachtablerow(t))
- @test Tables.columnnames(row) == [:x, :y]
- @test Tables.columnnames(t) == [:x, :y]
- SAVE_FILES && save_outfile(f)
- end
+ t_before = XLSX.table(sh2, "with_total")
+ @test t_before.has_totals_row == true
+ old_stop = t_before.ref.stop.row_number
+
+ t = XLSX.removetotals!(sh2, "with_total")
+
+ @test t.has_totals_row == false
+ @test t.ref.stop.row_number == old_stop - 1
+ @test t.columns == ["start", "stop", "sin"]
- @testset "Tables.columns - values match what was written" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "name"; sh["C1"] = "score"
- sh["A2"] = 1; sh["B2"] = "alice"; sh["C2"] = 10.5
- sh["A3"] = 2; sh["B3"] = "bob"; sh["C3"] = 20.0
- sh["A4"] = 3; sh["B4"] = "carol"; sh["C4"] = 15.0
-
- t = XLSX.addtable!(sh, "A1:C4"; name="People")
- cols = Tables.columns(t)
-
- @test cols.id == [1, 2, 3]
- @test cols.name == ["alice", "bob", "carol"]
- @test cols.score == [10.5, 20.0, 15.0]
- SAVE_FILES && save_outfile(f)
+ # other sheet's tables unaffected
+ @test length(XLSX.tables(f["Sheet1"])) == 2
+ SAVE_FILES && save_outfile(f)
+ end
end
- @testset "eachtablerow - length excludes header row" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- sh["A3"] = 3; sh["B3"] = 4
- sh["A4"] = 5; sh["B4"] = 6
+ @testset "gettotals" begin
+ @testset "empty NamedTuple when there is no totals row" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ t = XLSX.addtable!(sh, "A1:B2"; name="T")
- t = XLSX.addtable!(sh, "A1:B4"; name="T")
- rows = collect(XLSX.eachtablerow(t))
+ tot = XLSX.gettotals(t)
+ @test tot isa NamedTuple
+ @test isempty(tot)
+ @test length(tot) == 0
+ SAVE_FILES && save_outfile(f)
+ end
- @test length(rows) == 3
- @test length(XLSX.eachtablerow(t)) == 3 # Base.length via iterator, not just collect
- SAVE_FILES && save_outfile(f)
- end
+ @testset "keys match the table's columns, in order" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "region"; sh["B1"] = "revenue"; sh["C1"] = "margin"
+ sh["A2"] = "North"; sh["B2"] = 1000; sh["C2"] = 200
+ XLSX.addtable!(sh, "A1:C2"; name="Sales")
+ t = XLSX.settotals!(sh, "Sales", "region" => "Total", "revenue" => :sum)
- @testset "eachtablerow - minimum valid table (one data row)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
-
- t = XLSX.addtable!(sh, "A1:B2"; name="T")
- rows = collect(XLSX.eachtablerow(t))
-
- @test length(rows) == 1
- @test Tables.getcolumn(rows[1], :a) == 1
- @test Tables.getcolumn(rows[1], :b) == 2
- @test Tables.getcolumn(rows[1], 1) == 1
- @test Tables.getcolumn(rows[1], 2) == 2
- SAVE_FILES && save_outfile(f)
- end
+ tot = XLSX.gettotals(t)
+ @test collect(keys(tot)) == [:region, :revenue, :margin]
+ @test length(tot) == 3
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "eachtablerow - values match by name and by index" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "name"
- sh["A2"] = 1; sh["B2"] = "alice"
- sh["A3"] = 2; sh["B3"] = "bob"
-
- t = XLSX.addtable!(sh, "A1:B3"; name="People")
- rows = collect(XLSX.eachtablerow(t))
-
- @test Tables.getcolumn(rows[1], :id) == 1
- @test Tables.getcolumn(rows[1], :name) == "alice"
- @test Tables.getcolumn(rows[2], 1) == 2
- @test Tables.getcolumn(rows[2], 2) == "bob"
- SAVE_FILES && save_outfile(f)
- end
+ @testset "built-in function columns" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "s"; sh["B1"] = "av"
+ sh["A2"] = 10; sh["B2"] = 20
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ t = XLSX.settotals!(sh, "T", "s" => :sum, "av" => :average)
- @testset "eachtablerow - totals row excluded (created via settotals!)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- sh["A3"] = "Pears"; sh["B3"] = 8
-
- XLSX.addtable!(sh, "A1:B3"; name="Bulk")
- t = XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
-
- rows = collect(XLSX.eachtablerow(t))
- @test length(rows) == 2 # totals row (row 4) must NOT be included
- @test Tables.getcolumn(rows[1], :item) == "Apples"
- @test Tables.getcolumn(rows[2], :item) == "Pears"
-
- cols = Tables.columns(t)
- @test cols.item == ["Apples", "Pears"]
- @test cols.amount == [12, 8]
- SAVE_FILES && save_outfile(f)
- end
+ tot = XLSX.gettotals(t)
- @testset "eachtablerow - missing values pass through" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = missing
- sh["A3"] = missing; sh["B3"] = 4
+ @test tot.s.setting == :sum
+ @test !isnothing(tot.s.formula)
+ @test occursin("SUBTOTAL(109", tot.s.formula)
+ @test occursin("T[s]", tot.s.formula)
+ # XLSX.jl writes no cached value alongside a formula
+ @test ismissing(tot.s.value)
- t = XLSX.addtable!(sh, "A1:B3"; name="T")
- rows = collect(XLSX.eachtablerow(t))
+ @test tot.av.setting == :average
+ @test occursin("SUBTOTAL(101", tot.av.formula)
+ @test ismissing(tot.av.value)
+ SAVE_FILES && save_outfile(f)
+ end
- @test ismissing(Tables.getcolumn(rows[1], :b))
- @test ismissing(Tables.getcolumn(rows[2], :a))
- SAVE_FILES && save_outfile(f)
- end
+ @testset "label column" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "name"; sh["B1"] = "score"
+ sh["A2"] = "x"; sh["B2"] = 10
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ t = XLSX.settotals!(sh, "T", "name" => "Grand Total", "score" => :sum)
- @testset "eachtablerow - a fully blank row within ref is preserved, not skipped" begin
- # Unlike gettable/TableRowIterator (which infers table bounds from
- # content and offers stop_in_empty_row/keep_empty_rows/
- # stop_in_row_function to resolve that ambiguity), a Table's `ref` is
- # already authoritative — every row between header and totals (if
- # any) is unconditionally part of the table, whether blank or not.
- # eachtablerow must never drop a row just because it happens to be
- # entirely empty.
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 10
- # row 3 deliberately left entirely blank
- sh["A4"] = 3; sh["B4"] = 30
-
- t = XLSX.addtable!(sh, "A1:B4"; name="T")
- rows = collect(XLSX.eachtablerow(t))
-
- @test length(rows) == 3 # rows 2, 3, 4 — the blank row 3 must still be present
- @test Tables.getcolumn(rows[1], :a) == 1
- @test ismissing(Tables.getcolumn(rows[2], :a))
- @test ismissing(Tables.getcolumn(rows[2], :b))
- @test Tables.getcolumn(rows[3], :a) == 3
-
- cols = Tables.columns(t)
- @test length(cols.a) == 3
- @test ismissing(cols.a[2])
- SAVE_FILES && save_outfile(f)
- end
+ tot = XLSX.gettotals(t)
- @testset "Tables.rowtable - generic Tables.jl round trip" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "score"
- sh["A2"] = 1; sh["B2"] = 10.5
- sh["A3"] = 2; sh["B3"] = 20.0
-
- t = XLSX.addtable!(sh, "A1:B3"; name="T")
-
- nt_rows = Tables.rowtable(t)
- @test length(nt_rows) == 2
- @test nt_rows[1].id == 1
- @test nt_rows[1].score == 10.5
- @test nt_rows[2].id == 2
- @test nt_rows[2].score == 20.0
- SAVE_FILES && save_outfile(f)
- end
+ @test tot.name.setting == :label
+ @test isnothing(tot.name.formula) # a label is a plain value
+ @test tot.name.value == "Grand Total" # and the value is the label text
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "real fixture (two_tables.xlsx) - with_total (Sheet2), has a totals row" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh2 = xf["Sheet2"]
- t = XLSX.table(sh2, "with_total")
- @test t.has_totals_row == true
- @test t.columns == ["start", "stop", "sin"]
+ @testset "custom formula column" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "revenue"; sh["B1"] = "cost"; sh["C1"] = "margin"
+ sh["A2"] = 100; sh["B2"] = 60; sh["C2"] = 40
+ XLSX.addtable!(sh, "A1:C2"; name="PnL")
+ t = XLSX.settotals!(sh, "PnL",
+ "revenue" => :sum,
+ "margin" => (:custom, "SUBTOTAL(109,PnL[revenue])-SUBTOTAL(109,PnL[cost])"),
+ )
- rows = collect(XLSX.eachtablerow(t))
- expected_data_rows = (t.ref.stop.row_number - 1) - (t.ref.start.row_number + 1) + 1
- @test length(rows) == expected_data_rows
+ tot = XLSX.gettotals(t)
- # the last row iterated must be genuine data — not the totals
- # row. The totals row's "sin" column holds a formula (no cached
- # value), so getdata would return `missing`; confirm the last
- # data row's "sin" value is real, present data instead.
- last_row = rows[end]
- @test !ismissing(Tables.getcolumn(last_row, :sin))
+ @test tot.margin.setting == :custom
+ @test !isnothing(tot.margin.formula)
+ @test occursin("PnL[revenue]", tot.margin.formula)
+ @test occursin("PnL[cost]", tot.margin.formula)
+ @test ismissing(tot.margin.value)
+ SAVE_FILES && save_outfile(f)
end
- end
- @testset "real fixture (two_tables.xlsx) - Age_height (Sheet1), no totals row" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet1"]
- t = XLSX.table(sh, "Age_height")
- @test t.has_totals_row == false
+ @testset "columns with no totals setting" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"; sh["C1"] = "c"
+ sh["A2"] = 1; sh["B2"] = 2; sh["C2"] = 3
+ XLSX.addtable!(sh, "A1:C2"; name="T")
+ t = XLSX.settotals!(sh, "T", "b" => :sum) # only b
- rows = collect(XLSX.eachtablerow(t))
- @test length(rows) == t.ref.stop.row_number - t.ref.start.row_number # all rows below header
+ tot = XLSX.gettotals(t)
+
+ @test tot.a.setting == :none
+ @test isnothing(tot.a.formula)
+ @test ismissing(tot.a.value)
+
+ @test tot.b.setting == :sum
+
+ @test tot.c.setting == :none
+ @test isnothing(tot.c.formula)
+ @test ismissing(tot.c.value)
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "all built-in functions round trip through gettotals" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ cols = ["s", "av", "cnt", "cntnum", "mx", "mn", "sd", "vr"]
+ for (i, c) in enumerate(cols)
+ sh[1, i] = c
+ end
+ for i in 1:length(cols)
+ sh[2, i] = 10 * i
+ end
+ XLSX.addtable!(sh, "A1:H2"; name="Funcs")
+ t = XLSX.settotals!(sh, "Funcs";
+ s=:sum, av=:average, cnt=:count, cntnum=:countnums,
+ mx=:max, mn=:min, sd=:stddev, vr=:var)
+
+ tot = XLSX.gettotals(t)
+
+ @test tot.s.setting == :sum
+ @test tot.av.setting == :average
+ @test tot.cnt.setting == :count
+ @test tot.cntnum.setting == :countnums
+ @test tot.mx.setting == :max
+ @test tot.mn.setting == :min
+ @test tot.sd.setting == :stddev
+ @test tot.vr.setting == :var
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "reflects a changed setting" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+
+ t = XLSX.settotals!(sh, "T", "b" => :sum)
+ @test XLSX.gettotals(t).b.setting == :sum
+
+ t = XLSX.settotals!(sh, "T", "b" => :max)
+ @test XLSX.gettotals(t).b.setting == :max
+
+ t = XLSX.settotals!(sh, "T", "b" => "Total")
+ tot = XLSX.gettotals(t)
+ @test tot.b.setting == :label
+ @test tot.b.value == "Total"
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset ":none clears a column back to no setting" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+
+ t = XLSX.settotals!(sh, "T", "a" => "Total", "b" => :sum)
+ @test XLSX.gettotals(t).b.setting == :sum
+
+ t = XLSX.settotals!(sh, "T", "b" => :none)
+ tot = XLSX.gettotals(t)
+ @test tot.b.setting == :none
+ @test isnothing(tot.b.formula)
+ @test ismissing(tot.b.value)
+ @test tot.a.setting == :label # other column untouched
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "empty again after removetotals!" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ t = XLSX.settotals!(sh, "T", "b" => :sum)
+ @test !isempty(XLSX.gettotals(t))
+
+ t = XLSX.removetotals!(sh, "T")
+ @test isempty(XLSX.gettotals(t))
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "survives appendtable! (totals row moved)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ XLSX.addtable!(sh, "A1:B2"; name="Bulk")
+ XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
+
+ t = XLSX.appendtable!(sh, "Bulk", [("Pears", 8), ("Cherries", 25)])
+ tot = XLSX.gettotals(t)
+
+ @test tot.item.setting == :label
+ @test tot.item.value == "Total"
+ @test tot.amount.setting == :sum
+ @test occursin("SUBTOTAL(109", tot.amount.formula)
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "round trip: save and reopen" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ sh["A3"] = "Pears"; sh["B3"] = 8
+ XLSX.addtable!(sh, "A1:B3"; name="Bulk")
+ XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
+ SAVE_FILES && save_outfile(f)
+
+ outfile = "gettotals_roundtrip.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf2
+ t = XLSX.table(xf2[1], "Bulk")
+ tot = XLSX.gettotals(t)
+
+ @test tot.item.setting == :label
+ @test tot.item.value == "Total"
+ @test tot.amount.setting == :sum
+ @test occursin("SUBTOTAL(109", tot.amount.formula)
+ end
+
+ isfile(outfile) && rm(outfile)
+ end
+
+ @testset "real fixture (two_tables.xlsx) - with_total" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ t = XLSX.table(xf["Sheet2"], "with_total")
+ @test t.has_totals_row == true
+
+ tot = XLSX.gettotals(t)
+ @test collect(keys(tot)) == [:start, :stop, :sin]
+ @test tot.sin.setting == :sum
+
+ # Excel-authored file: the totals cell carries a cached value
+ # alongside its formula, unlike one XLSX.jl wrote.
+ @test !isnothing(tot.sin.formula)
+ @test !ismissing(tot.sin.value)
+ end
+ end
+
+ @testset "table with no totals on a sheet that has one elsewhere" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ t = XLSX.table(xf["Sheet1"], "IO_Table")
+ @test t.has_totals_row == false
+ @test isempty(XLSX.gettotals(t))
+ end
+ end
+
+ @testset "enable_cache=false throws" begin
+ # gettotals reads each totals cell's formula, and getFormula requires
+ # the cache, so the whole function is unavailable without it.
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx"), enable_cache=false) do xf
+ t = XLSX.table(xf["Sheet2"], "with_total")
+ @test_throws XLSX.XLSXError XLSX.gettotals(t)
+ end
+ end
+
+ @testset "enable_cache=false, no totals row: returns empty without throwing" begin
+ # The early return happens before any cell is touched, so a table
+ # with no totals row is safe even without the cache.
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx"), enable_cache=false) do xf
+ t = XLSX.table(xf["Sheet1"], "IO_Table")
+ @test isempty(XLSX.gettotals(t))
+ end
end
end
- @testset "real fixture (two_tables.xlsx) - IO_Table, no totals row" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet1"]
- t = XLSX.table(sh, "IO_Table")
+ @testset "appendtable!" begin
+ @testset "appends rows to a table with no totals row" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "name"
+ sh["A2"] = 1; sh["B2"] = "alice"
+ sh["A3"] = 2; sh["B3"] = "bob"
+ XLSX.addtable!(sh, "A1:B3"; name="People")
+
+ t = XLSX.appendtable!(sh, "People", [(3, "carol"), (4, "dave")])
+
+ @test t.ref == XLSX.CellRange("A1:B5")
@test t.has_totals_row == false
+ @test sh["A4"] == 3; @test sh["B4"] == "carol"
+ @test sh["A5"] == 4; @test sh["B5"] == "dave"
- rows = collect(XLSX.eachtablerow(t))
- @test length(rows) == t.ref.stop.row_number - t.ref.start.row_number # all rows below header
- @test Tables.columnnames(rows[1]) == Symbol.(t.columns)
+ cols = Tables.columns(t)
+ @test cols.id == [1, 2, 3, 4]
+ @test cols.name == ["alice", "bob", "carol", "dave"]
+ SAVE_FILES && save_outfile(f)
end
- end
- @testset "t.sheet identity is preserved" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
+ @testset "appending zero rows is a no-op" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.addtable!(sh, "A1:B2"; name="T")
- t = XLSX.table(sh, "T")
- @test t.sheet === sh
- SAVE_FILES && save_outfile(f)
- end
+ t = XLSX.appendtable!(sh, "T", Tuple{Int,Int}[])
+ @test t.ref == XLSX.CellRange("A1:B2")
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "row-access path (Tables.rows) resolves schema/columnnames without erroring" begin
- # Regression check: PrettyTables (and other row-access consumers)
- # call Tables.schema/Tables.columnnames on Tables.rows(t) — the
- # XLSXTableRowIterator — not on `t` directly.
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "name"
- sh["A2"] = 1; sh["B2"] = "alice"
- sh["A3"] = 2; sh["B3"] = "bob"
-
- t = XLSX.addtable!(sh, "A1:B3"; name="T")
- it = Tables.rows(t)
-
- @test !isnothing(Tables.schema(it))
- @test Tables.schema(it).names == (:id, :name)
- @test !isnothing(Tables.columnnames(first(it)))
- @test collect(it) isa Vector{XLSX.XLSXTableRow}
- SAVE_FILES && save_outfile(f)
- end
+ @testset "autoFilter extends with ref (no totals row)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.appendtable!(sh, "T", [(3, 4)])
- @testset "basic matrix shape and values" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "region"; sh["B1"] = "revenue"
- sh["A2"] = "North"; sh["B2"] = 1000
- sh["A3"] = "South"; sh["B3"] = 1500
- sh["A4"] = "East"; sh["B4"] = 900
-
- t = XLSX.addtable!(sh, "A1:B4"; name="Sales")
- m = XLSX.getdata(t)
-
- @test m isa Matrix{Any}
- @test size(m) == (3, 2)
- @test m[1, 1] == "North"; @test m[1, 2] == 1000
- @test m[2, 1] == "South"; @test m[2, 2] == 1500
- @test m[3, 1] == "East"; @test m[3, 2] == 900
- SAVE_FILES && save_outfile(f)
- end
+ table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "T"))
+ root = XLSX.xml_root_element(table_doc)
+ af = XLSX.elements_with_tag(root, "autoFilter")
+ @test !isempty(af)
+ @test XLSX.get_attr(af[1], "ref") == "A1:B3" # no totals row: same as ref
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "excludes totals row" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- sh["A3"] = "Pears"; sh["B3"] = 8
-
- XLSX.addtable!(sh, "A1:B3"; name="Bulk")
- t = XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
-
- m = XLSX.getdata(t)
- @test size(m) == (2, 2) # totals row (row 4) must not appear
- @test m[end, 1] == "Pears"
- @test m[end, 2] == 8
- SAVE_FILES && save_outfile(f)
- end
+ @testset "totals row moves down and is regenerated (sum + label)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ sh["A3"] = "Pears"; sh["B3"] = 8
+ XLSX.addtable!(sh, "A1:B3"; name="Bulk")
+ XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
- @testset "blank row within ref is preserved, not skipped" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 10
- # row 3 deliberately left entirely blank
- sh["A4"] = 3; sh["B4"] = 30
-
- t = XLSX.addtable!(sh, "A1:B4"; name="T")
- m = XLSX.getdata(t)
-
- @test size(m) == (3, 2)
- @test m[1, 1] == 1
- @test ismissing(m[2, 1])
- @test ismissing(m[2, 2])
- @test m[3, 1] == 3
- SAVE_FILES && save_outfile(f)
- end
+ t_before = XLSX.table(sh, "Bulk")
+ @test t_before.ref == XLSX.CellRange("A1:B4")
+ @test sh["A4"] == "Total"
- @testset "minimum valid table (one data row)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
+ t = XLSX.appendtable!(sh, "Bulk", [("Cherries", 25), ("Damsons", 5)])
- t = XLSX.addtable!(sh, "A1:B2"; name="T")
- m = XLSX.getdata(t)
+ # ref grew by 2; totals row is still last
+ @test t.ref == XLSX.CellRange("A1:B6")
+ @test t.has_totals_row == true
- @test size(m) == (1, 2)
- @test m[1, 1] == 1
- @test m[1, 2] == 2
- SAVE_FILES && save_outfile(f)
- end
+ # old totals position (row 4) now holds appended data
+ @test sh["A4"] == "Cherries"; @test sh["B4"] == 25
+ @test sh["A5"] == "Damsons"; @test sh["B5"] == 5
- @testset "real fixture (two_tables.xlsx) - IO_Table" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet1"]
- t = XLSX.table(sh, "IO_Table")
- m = XLSX.getdata(t)
+ # new totals row regenerated at row 6
+ @test sh["A6"] == "Total"
+ @test occursin("SUBTOTAL(109", XLSX.getFormula(sh, "B6"))
+ @test occursin("Bulk[amount]", XLSX.getFormula(sh, "B6"))
- @test size(m) == (t.ref.stop.row_number - t.ref.start.row_number, length(t.columns))
+ # data body excludes the totals row
+ cols = Tables.columns(t)
+ @test cols.item == ["Apples", "Pears", "Cherries", "Damsons"]
+ @test cols.amount == [12, 8, 25, 5]
+ SAVE_FILES && save_outfile(f)
end
- end
- @testset "sheet-scoped: readtable(source, sheet; table_name=...)" begin
- outfile = make_multitable_file("readtable_tablename_scoped.xlsx")
- SAVE_FILES && save_outfile(outfile)
+ @testset "totals row: autoFilter excludes the totals row after append" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 10
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.settotals!(sh, "T", "b" => :sum) # ref becomes A1:B3
- dt = XLSX.readtable(outfile, "Second"; table_name="TableTwo")
+ XLSX.appendtable!(sh, "T", [(2, 20), (3, 30)]) # ref becomes A1:B5
- @test dt isa XLSX.DataTable
- @test dt.column_labels == [:id, :name, :score]
- @test length(dt.data) == 3
- @test dt.data[1] == [1, 2, 3]
- @test dt.data[2] == ["alice", "bob", "carol"]
- @test dt.data[3] == [10.5, 20.0, 15.25]
+ table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "T"))
+ root = XLSX.xml_root_element(table_doc)
+ @test XLSX.get_attr(root, "ref") == "A1:B5"
+ af = XLSX.elements_with_tag(root, "autoFilter")
+ @test XLSX.get_attr(af[1], "ref") == "A1:B4" # one row short of ref
+ SAVE_FILES && save_outfile(f)
+ end
- isfile(outfile) && rm(outfile)
- end
+ @testset "totals row: preserves a custom formula" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "revenue"; sh["B1"] = "cost"; sh["C1"] = "margin"
+ sh["A2"] = 100; sh["B2"] = 60; sh["C2"] = 40
+ XLSX.addtable!(sh, "A1:C2"; name="PnL")
+ XLSX.settotals!(sh, "PnL",
+ "revenue" => :sum,
+ "margin" => (:custom, "SUBTOTAL(109,PnL[revenue])-SUBTOTAL(109,PnL[cost])"),
+ )
- @testset "sheet-scoped: by sheet index rather than name" begin
- outfile = make_multitable_file("readtable_tablename_scoped_idx.xlsx")
+ f_before = XLSX.getFormula(sh, "C3")
- dt = XLSX.readtable(outfile, 2; table_name="TableTwo")
- @test dt.column_labels == [:id, :name, :score]
- @test dt.data[1] == [1, 2, 3]
+ XLSX.appendtable!(sh, "PnL", [(200, 90, 110)])
- isfile(outfile) && rm(outfile)
- end
+ t = XLSX.table(sh, "PnL")
+ @test t.ref == XLSX.CellRange("A1:C4")
- @testset "sheet-scoped: excludes data outside the table's ref" begin
- outfile = make_multitable_file("readtable_tablename_bounds.xlsx")
+ # custom formula survived the move, unchanged
+ f_after = XLSX.getFormula(sh, "C4")
+ @test f_after == f_before
+ @test occursin("PnL[revenue]", f_after)
+ @test occursin("PnL[cost]", f_after)
+
+ # and the table part still records it as custom
+ table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "PnL"))
+ i, j = XLSX.get_idces(table_doc, "table", "tableColumns")
+ margin_node = collect(XLSX.xml_elements(table_doc[i][j]))[3]
+ @test XLSX.get_attr(margin_node, "totalsRowFunction") == "custom"
+ SAVE_FILES && save_outfile(f)
+ end
- dt = XLSX.readtable(outfile, "Second"; table_name="TableTwo")
+ @testset "totals row: preserves every built-in function kind" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ cols = ["s", "av", "cnt", "cnta", "mx", "mn"]
+ for (i, c) in enumerate(cols)
+ sh[1, i] = c
+ end
+ for r in 2:4, i in 1:length(cols)
+ sh[r, i] = r * 10 + i
+ end
+ XLSX.addtable!(sh, "A1:F4"; name="Funcs")
+ XLSX.settotals!(sh, "Funcs"; s=:sum, av=:average, cnt=:countnums, cnta=:count, mx=:max, mn=:min)
+
+ XLSX.appendtable!(sh, "Funcs", [(99, 99, 99, 99, 99, 99)])
+
+ t = XLSX.table(sh, "Funcs")
+ @test t.ref == XLSX.CellRange("A1:F6")
+
+ table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "Funcs"))
+ i, j = XLSX.get_idces(table_doc, "table", "tableColumns")
+ nodes = collect(XLSX.xml_elements(table_doc[i][j]))
+
+ @test XLSX.get_attr(nodes[1], "totalsRowFunction") == "sum"
+ @test XLSX.get_attr(nodes[2], "totalsRowFunction") == "average"
+ # NB: OOXML's attribute names for the two count variants are the
+ # reverse of Excel's function names — :countnums (Excel COUNT, numeric
+ # only) is OOXML "countNums", and :count (Excel COUNTA, non-blank)
+ # is OOXML "count".
+ @test XLSX.get_attr(nodes[3], "totalsRowFunction") == "countNums"
+ @test XLSX.get_attr(nodes[4], "totalsRowFunction") == "count"
+ @test XLSX.get_attr(nodes[5], "totalsRowFunction") == "max"
+ @test XLSX.get_attr(nodes[6], "totalsRowFunction") == "min"
+ SAVE_FILES && save_outfile(f)
+ end
- # only the table's 3 columns / 3 data rows — nothing from E1:E2 or row 6
- @test length(dt.column_labels) == 3
- @test :outside ∉ dt.column_labels
- @test all(length(col) == 3 for col in dt.data)
- @test "not in table" ∉ dt.data[1]
- @test "also not in table" ∉ dt.data[2]
+ @testset "totals row: columns with no totals setting stay empty" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"; sh["C1"] = "c"
+ sh["A2"] = 1; sh["B2"] = 2; sh["C2"] = 3
+ XLSX.addtable!(sh, "A1:C2"; name="T")
+ XLSX.settotals!(sh, "T", "b" => :sum) # only column b has totals
- isfile(outfile) && rm(outfile)
- end
+ XLSX.appendtable!(sh, "T", [(4, 5, 6)])
- @testset "workbook-wide: readtable(source; table_name=...)" begin
- outfile = make_multitable_file("readtable_tablename_wide.xlsx")
+ t = XLSX.table(sh, "T")
+ totals_row = t.ref.stop.row_number
+ @test ismissing(sh[XLSX.CellRef(totals_row, 1)]) # a: no totals
+ @test !ismissing(XLSX.getFormula(sh, XLSX.CellRef(totals_row, 2))) # b: sum
+ @test ismissing(sh[XLSX.CellRef(totals_row, 3)]) # c: no totals
+ SAVE_FILES && save_outfile(f)
+ end
- # table on the *second* sheet, found without naming the sheet
- dt = XLSX.readtable(outfile; table_name="TableTwo")
- @test dt.column_labels == [:id, :name, :score]
- @test dt.data[1] == [1, 2, 3]
+ @testset "appendtable! - :none columns stay clear after the totals row moves" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.settotals!(sh, "T", "a" => :sum, "b" => :none)
- # table on the first sheet
- dt1 = XLSX.readtable(outfile; table_name="TableOne")
- @test dt1.column_labels == [:a, :b]
- @test dt1.data[1] == [1, 2]
+ XLSX.appendtable!(sh, "T", [(3, 4)])
- # table on the last sheet
- dt3 = XLSX.readtable(outfile; table_name="TableThree")
- @test dt3.column_labels == [:e, :f]
- @test dt3.data[1] == [5]
+ t = XLSX.table(sh, "T")
+ totals_row = t.ref.stop.row_number
+ @test occursin("SUBTOTAL(109", XLSX.getFormula(sh, XLSX.CellRef(totals_row, 1)))
+ @test ismissing(sh[XLSX.CellRef(totals_row, 2)])
+ SAVE_FILES && save_outfile(f)
+ end
- isfile(outfile) && rm(outfile)
- end
+ @testset "input shapes: vector of tuples, vector of vectors, matrix, Tables.jl source" begin
- @testset "sheet-scoped and workbook-wide agree" begin
- outfile = make_multitable_file("readtable_tablename_agree.xlsx")
+ sh = fresh()
+ t = XLSX.appendtable!(sh, "T", [(2, 20), (3, 30)])
+ @test Tables.columns(t).x == [1, 2, 3]
- dt_scoped = XLSX.readtable(outfile, "Second"; table_name="TableTwo")
- dt_wide = XLSX.readtable(outfile; table_name="TableTwo")
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ sh = fresh()
+ t = XLSX.appendtable!(sh, "T", [[2, 20], [3, 30]])
+ @test Tables.columns(t).x == [1, 2, 3]
- @test dt_scoped.column_labels == dt_wide.column_labels
- @test dt_scoped.data == dt_wide.data
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ sh = fresh()
+ t = XLSX.appendtable!(sh, "T", [2 20; 3 30])
+ @test Tables.columns(t).x == [1, 2, 3]
- isfile(outfile) && rm(outfile)
- end
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ sh = fresh()
+ t = XLSX.appendtable!(sh, "T", DataFrames.DataFrame(x=[2, 3], y=[20, 30]))
+ @test Tables.columns(t).x == [1, 2, 3]
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ end
- @testset "enable_cache=false: sheet-scoped" begin
- outfile = make_multitable_file("readtable_tablename_nocache_scoped.xlsx")
+ @testset "column count mismatch errors" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
- dt = XLSX.readtable(outfile, "Second"; table_name="TableTwo", enable_cache=false)
- @test dt.column_labels == [:id, :name, :score]
- @test dt.data[1] == [1, 2, 3]
- @test dt.data[2] == ["alice", "bob", "carol"]
- @test dt.data[3] == [10.5, 20.0, 15.25]
+ @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", [(1, 2, 3)])
+ @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", [(1,)])
+ SAVE_FILES && save_outfile(f)
+ end
- isfile(outfile) && rm(outfile)
- end
+ @testset "refuses non-empty rows below the table; check_empty=false overrides" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ sh["A3"] = "in the way"
- @testset "enable_cache=false: workbook-wide (scans every sheet's tableParts)" begin
- outfile = make_multitable_file("readtable_tablename_nocache_wide.xlsx")
+ @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", [(9, 9)])
- # This is the least-exercised corner: no target_sheet, no cache, so
- # every sheet's is cursor-scanned via
- # open_internal_file_stream rather than read from xf.data.
- dt = XLSX.readtable(outfile; table_name="TableTwo", enable_cache=false)
- @test dt.column_labels == [:id, :name, :score]
- @test dt.data[1] == [1, 2, 3]
+ # table unchanged after the failed attempt
+ @test XLSX.table(sh, "T").ref == XLSX.CellRange("A1:B2")
- # also reach the first and last sheets under the same conditions
- dt1 = XLSX.readtable(outfile; table_name="TableOne", enable_cache=false)
- @test dt1.data[1] == [1, 2]
+ t = XLSX.appendtable!(sh, "T", [(9, 9)]; check_empty=false)
+ @test t.ref == XLSX.CellRange("A1:B3")
+ @test sh["A3"] == 9
+ SAVE_FILES && save_outfile(f)
+ end
- dt3 = XLSX.readtable(outfile; table_name="TableThree", enable_cache=false)
- @test dt3.data[1] == [5]
+ @testset "table not found errors" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
- isfile(outfile) && rm(outfile)
- end
+ @test_throws KeyError XLSX.appendtable!(sh, "NoSuchTable", [(1, 2)])
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "enable_cache=false agrees with enable_cache=true" begin
- outfile = make_multitable_file("readtable_tablename_cachemodes.xlsx")
+ @testset "appending missing values" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
- dt_cached = XLSX.readtable(outfile; table_name="TableTwo", enable_cache=true)
- dt_nocache = XLSX.readtable(outfile; table_name="TableTwo", enable_cache=false)
+ t = XLSX.appendtable!(sh, "T", [(3, missing), (missing, 6)])
+ @test t.ref == XLSX.CellRange("A1:B4")
+ @test ismissing(sh["B3"])
+ @test ismissing(sh["A4"])
+ SAVE_FILES && save_outfile(f)
+ end
- @test dt_cached.column_labels == dt_nocache.column_labels
- @test dt_cached.data == dt_nocache.data
+ @testset "two tables on one sheet: appending to one leaves the other alone" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="Left")
- isfile(outfile) && rm(outfile)
- end
+ sh["D1"] = "c"; sh["E1"] = "d"
+ sh["D2"] = 3; sh["E2"] = 4
+ XLSX.addtable!(sh, "D1:E2"; name="Right")
- @testset "infer_eltypes / normalizenames / missing_strings still apply" begin
- outfile = "readtable_tablename_kwargs.xlsx"
+ XLSX.appendtable!(sh, "Left", [(5, 6)])
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "col one"; sh["B1"] = "value"
- sh["A2"] = "x"; sh["B2"] = 1.5
- sh["A3"] = "N/A"; sh["B3"] = 2.5
- XLSX.addtable!(sh, "A1:B3"; name="T")
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ @test XLSX.table(sh, "Left").ref == XLSX.CellRange("A1:B3")
+ @test XLSX.table(sh, "Right").ref == XLSX.CellRange("D1:E2") # untouched
+ @test length(XLSX.tables(sh)) == 2
+ SAVE_FILES && save_outfile(f)
+ end
- # infer_eltypes=true (default) narrows columns
- dt = XLSX.readtable(outfile; table_name="T")
- @test eltype(dt.data[2]) != Any
- @test eltype(dt.data[2]) <: Union{Missing,Float64}
+ @testset "round trip: append then save and reopen" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ XLSX.addtable!(sh, "A1:B2"; name="Bulk")
+ XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
+ XLSX.appendtable!(sh, "Bulk", [("Pears", 8), ("Cherries", 25)])
+ SAVE_FILES && save_outfile(f)
- # infer_eltypes=false leaves them as Any
- dt_any = XLSX.readtable(outfile; table_name="T", infer_eltypes=false)
- @test eltype(dt_any.data[2]) == Any
+ outfile = "appendtable_roundtrip.xlsx"
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf2
+ t = XLSX.table(xf2[1], "Bulk")
+ @test t.ref == XLSX.CellRange("A1:B5")
+ @test t.has_totals_row == true
+ cols = Tables.columns(t)
+ @test cols.item == ["Apples", "Pears", "Cherries"]
+ @test cols.amount == [12, 8, 25]
+ end
- # normalizenames turns "col one" into a valid identifier
- dt_norm = XLSX.readtable(outfile; table_name="T", normalizenames=true)
- @test dt_norm.column_labels[1] == :col_one
+ XLSX.openxlsx(outfile, enable_cache=false) do xf2
+ t = XLSX.table(xf2[1], "Bulk")
+ @test t.ref == XLSX.CellRange("A1:B5")
+ @test t.has_totals_row == true
+ end
- # without normalizenames, the label keeps its space
- @test dt.column_labels[1] == Symbol("col one")
+ isfile(outfile) && rm(outfile)
+ end
- # missing_strings converts "N/A" to missing
- dt_miss = XLSX.readtable(outfile; table_name="T", missing_strings="N/A")
- @test ismissing(dt_miss.data[1][2])
- @test dt_miss.data[1][1] == "x"
+ @testset "repeated appends accumulate correctly" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "n"
+ sh["A2"] = 1
+ XLSX.addtable!(sh, "A1:A2"; name="T")
+ XLSX.settotals!(sh, "T", "n" => :sum)
- isfile(outfile) && rm(outfile)
- end
+ for i in 2:5
+ XLSX.appendtable!(sh, "T", [(i,)])
+ end
- @testset "DataFrame round trip via readtable(; table_name)" begin
- outfile = make_multitable_file("readtable_tablename_df.xlsx")
+ t = XLSX.table(sh, "T")
+ @test t.ref == XLSX.CellRange("A1:A7") # header + 5 data + totals
+ @test Tables.columns(t).n == [1, 2, 3, 4, 5]
+ @test occursin("SUBTOTAL(109", XLSX.getFormula(sh, "A7"))
+ SAVE_FILES && save_outfile(f)
+ end
- df = DataFrames.DataFrame(XLSX.readtable(outfile, "Second"; table_name="TableTwo"))
- @test size(df) == (3, 3)
- @test names(df) == ["id", "name", "score"]
- @test df.score == [10.5, 20.0, 15.25]
- @test eltype(df.score) != Any
+ @testset "not writable errors" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf # read-only
+ sh = xf["Sheet1"]
+ @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "IO_Table", [(1, 2, 3)])
+ end
+ end
- isfile(outfile) && rm(outfile)
- end
+ @testset "DataFrame columns matched by name, not position" begin
+ sh = fresh_abc()
- @testset "errors: table not found" begin
- outfile = make_multitable_file("readtable_tablename_notfound.xlsx")
+ # deliberately scrambled column order
+ df = DataFrames.DataFrame(c=[30, 60], a=[10, 40], b=[20, 50])
+ t = XLSX.appendtable!(sh, "T", df)
- # workbook-wide: no such table anywhere
- @test_throws XLSX.XLSXError XLSX.readtable(outfile; table_name="NoSuchTable")
+ @test t.ref == XLSX.CellRange("A1:C4")
+ cols = Tables.columns(t)
+ @test cols.a == [1, 10, 40]
+ @test cols.b == [2, 20, 50]
+ @test cols.c == [3, 30, 60]
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ end
- # sheet-scoped: table exists, but not on the named sheet
- @test_throws KeyError XLSX.readtable(outfile, "First"; table_name="TableTwo")
+ @testset "DataTable source works and is name-matched" begin
+ sh = fresh_abc()
- isfile(outfile) && rm(outfile)
- end
+ # build a DataTable with scrambled column order
+ dt = XLSX.DataTable(Any[[30, 60], [10, 40], [20, 50]], [:c, :a, :b])
+ t = XLSX.appendtable!(sh, "T", dt)
- @testset "errors: table_name cannot combine with a columns range" begin
- outfile = make_multitable_file("readtable_tablename_conflict.xlsx")
+ @test t.ref == XLSX.CellRange("A1:C4")
+ cols = Tables.columns(t)
+ @test cols.a == [1, 10, 40]
+ @test cols.b == [2, 20, 50]
+ @test cols.c == [3, 30, 60]
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ end
+
+ @testset "DataTable read from one table appended to another" begin
+ # read a table out, then append it to a second table with the same
+ # columns in a different order
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "x"; sh["B1"] = "y"
+ sh["A2"] = 1; sh["B2"] = 10
+ sh["A3"] = 2; sh["B3"] = 20
+ src = XLSX.addtable!(sh, "A1:B3"; name="Source")
+
+ sh["E1"] = "y"; sh["F1"] = "x" # reversed relative to Source
+ sh["E2"] = 99; sh["F2"] = 9
+ XLSX.addtable!(sh, "E1:F2"; name="Dest")
+
+ dt = XLSX.gettable(src)
+ t = XLSX.appendtable!(sh, "Dest", dt)
+
+ @test t.ref == XLSX.CellRange("E1:F4")
+ cols = Tables.columns(t)
+ @test cols.y == [99, 10, 20] # matched by name despite reversal
+ @test cols.x == [9, 1, 2]
+ SAVE_FILES && save_outfile(f)
+ end
- @test_throws XLSX.XLSXError XLSX.readtable(outfile, "Second", "A:C"; table_name="TableTwo")
- @test_throws XLSX.XLSXError XLSX.readtable(outfile, "Second", XLSX.ColumnRange("A:C"); table_name="TableTwo")
+ @testset "missing column errors" begin
+ sh = fresh_abc()
+ df = DataFrames.DataFrame(a=[10], b=[20]) # no "c"
+ @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", df)
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ end
- isfile(outfile) && rm(outfile)
- end
+ @testset "extra column errors" begin
+ sh = fresh_abc()
+ df = DataFrames.DataFrame(a=[10], b=[20], c=[30], d=[40]) # extra "d"
+ @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", df)
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ end
- @testset "table with a totals row: totals excluded" begin
- outfile = "readtable_tablename_totals.xlsx"
+ @testset "both missing and extra columns errors" begin
+ sh = fresh_abc()
+ df = DataFrames.DataFrame(a=[10], b=[20], z=[99]) # missing c, extra z
+ @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", df)
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ end
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- sh["A3"] = "Pears"; sh["B3"] = 8
- XLSX.addtable!(sh, "A1:B3"; name="Bulk")
- XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ @testset "unnamed sources remain positional" begin
+ # matrix
+ sh = fresh_abc()
+ t = XLSX.appendtable!(sh, "T", [10 20 30])
+ cols = Tables.columns(t)
+ @test cols.a == [1, 10]
+ @test cols.b == [2, 20]
+ @test cols.c == [3, 30]
+
+ # vector of tuples
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ sh = fresh_abc()
+ t = XLSX.appendtable!(sh, "T", [(10, 20, 30)])
+ cols = Tables.columns(t)
+ @test cols.a == [1, 10]
+ @test cols.c == [3, 30]
- for cache in (true, false)
- dt = XLSX.readtable(outfile; table_name="Bulk", enable_cache=cache)
- @test length(dt.data[1]) == 2 # totals row excluded
- @test dt.data[1] == ["Apples", "Pears"]
- @test dt.data[2] == [12, 8]
+ # vector of vectors
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ sh = fresh_abc()
+ t = XLSX.appendtable!(sh, "T", [[10, 20, 30]])
+ cols = Tables.columns(t)
+ @test cols.a == [1, 10]
+ @test cols.c == [3, 30]
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "name matching still works when a totals row is present" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ XLSX.addtable!(sh, "A1:B2"; name="Bulk")
+ XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
+
+ # reversed column order in the source
+ df = DataFrames.DataFrame(amount=[8, 25], item=["Pears", "Cherries"])
+ t = XLSX.appendtable!(sh, "Bulk", df)
+
+ @test t.ref == XLSX.CellRange("A1:B5")
+ @test t.has_totals_row == true
+ cols = Tables.columns(t)
+ @test cols.item == ["Apples", "Pears", "Cherries"]
+ @test cols.amount == [12, 8, 25]
+ @test occursin("SUBTOTAL(109", XLSX.getFormula(sh, "B5"))
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "table with a blank row: row preserved" begin
- outfile = "readtable_tablename_blank.xlsx"
+ @testset "appendtable! - source without a schema, matched by columnnames" begin
+ sh = fresh_abc() # table "T" over A1:C2, columns a, b, c
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 10
- # row 3 blank
- sh["A4"] = 3; sh["B4"] = 30
- XLSX.addtable!(sh, "A1:B4"; name="T")
- XLSX.writexlsx(outfile, f, overwrite=true)
+ # scrambled order, and no Tables.schema — forces the columnnames fallback
+ src = NoSchemaCols((c=[30, 60], a=[10, 40], b=[20, 50]))
+ t = XLSX.appendtable!(sh, "T", src)
- for cache in (true, false)
- dt = XLSX.readtable(outfile; table_name="T", enable_cache=cache)
- @test length(dt.data[1]) == 3 # blank row still present
- @test ismissing(dt.data[1][2])
- @test ismissing(dt.data[2][2])
+ @test t.ref == XLSX.CellRange("A1:C4")
+ cols = Tables.columns(t)
+ @test cols.a == [1, 10, 40]
+ @test cols.b == [2, 20, 50]
+ @test cols.c == [3, 30, 60]
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ end
+
+ @testset "appendtable! - schemaless source with wrong columns still errors" begin
+ sh = fresh_abc()
+ @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", NoSchemaCols((a=[1], b=[2])))
+ @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T",
+ NoSchemaCols((a=[1], b=[2], c=[3], d=[4])))
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
end
- isfile(outfile) && rm(outfile)
- SAVE_FILES && save_outfile(f)
+ @testset "appendtable! - nameless Tables.jl source falls back to positional" begin
+ sh = fresh_abc()
+
+ src = NoNamesSource([Any[10, 20, 30], Any[40, 50, 60]])
+ t = XLSX.appendtable!(sh, "T", src)
+
+ @test t.ref == XLSX.CellRange("A1:C4")
+ cols = Tables.columns(t)
+ @test cols.a == [1, 10, 40]
+ @test cols.b == [2, 20, 50]
+ @test cols.c == [3, 30, 60]
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
+ end
end
- @testset "existing readtable behaviour unaffected when table_name omitted" begin
- outfile = make_multitable_file("readtable_tablename_regression.xlsx")
+ @testset "read-only files" begin
+ @testset "read-only file rejects every mutating table call" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet1"]
- # plain range-inference read of the same sheet still works and, on
- # this fixture, picks up more than just the table (row 6 data too)
- dt_plain = XLSX.readtable(outfile, "Second")
- @test dt_plain isa XLSX.DataTable
- @test dt_plain.column_labels == [:id, :name, :score]
+ @test_throws XLSX.XLSXError XLSX.addtable!(sh, "J1:K2"; name="Nope")
+ @test_throws XLSX.XLSXError XLSX.deletetable!(sh, "IO_Table")
+ @test_throws XLSX.XLSXError XLSX.deletetable!(sh, 1)
+ @test_throws XLSX.XLSXError XLSX.settotals!(sh, "IO_Table", "id" => :sum)
+ @test_throws XLSX.XLSXError XLSX.settotals!(sh, 1, "id" => :sum)
- isfile(outfile) && rm(outfile)
+ # nothing was mutated by the failed attempts
+ @test length(XLSX.tables(sh)) == 2
+ end
+ end
end
- @testset "multiple spaces collapse to a single underscore" begin
- outfile = "readtable_normalize_multispace.xlsx"
+ @testset "show" begin
+ @testset "show(Table) - compact form, no style, no totals" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "name"; sh["C1"] = "score"
+ sh["A2"] = 1; sh["B2"] = "alice"; sh["C2"] = 10.5
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "col one"; sh["B1"] = "col two" # 2 and 3 spaces
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
+ t = XLSX.addtable!(sh, "A1:C2"; name="Plain")
+ s = sprint(show, t)
- dt = XLSX.readtable(outfile; table_name="T", normalizenames=true)
- @test dt.column_labels == [:col_one, :col_two]
+ @test occursin("Plain", s)
+ @test occursin(string(t.ref), s)
+ @test occursin("id=$(t.id)", s)
+ @test occursin("1x3", s) # 1 data row x 3 columns
+ @test !occursin("totals", s) # no totals row -> no "+totals" marker
+ SAVE_FILES && save_outfile(f)
+ end
- # without normalizenames, the original spacing is preserved
- dt_raw = XLSX.readtable(outfile; table_name="T")
- @test dt_raw.column_labels == [Symbol("col one"), Symbol("col two")]
+ @testset "show(Table) - compact form, with totals" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "qty"; sh["B1"] = "price"
+ sh["A2"] = 3; sh["B2"] = 9.99
- isfile(outfile) && rm(outfile)
- end
+ XLSX.addtable!(sh, "A1:B2"; name="Sales")
+ t = XLSX.settotals!(sh, "Sales", "price" => :sum)
+ s = sprint(show, t)
- @testset "header starting with a digit gets a leading underscore" begin
- outfile = "readtable_normalize_digit.xlsx"
+ @test occursin("Sales", s)
+ @test occursin("id=$(t.id)", s)
+ @test occursin("1x2", s) # data rows exclude header and totals row
+ @test occursin("totals", s)
+ SAVE_FILES && save_outfile(f)
+ end
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "2024"; sh["B1"] = "2025 total"
- sh["A2"] = 10; sh["B2"] = 20
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.writexlsx(outfile, f, overwrite=true)
+ @testset "show(Table) - compact form used in Vector display" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="TblA")
- dt = XLSX.readtable(outfile; table_name="T", normalizenames=true)
- @test dt.column_labels == [:_2024, :_2025_total]
+ sh["D1"] = "c"; sh["E1"] = "d"
+ sh["D2"] = 3; sh["E2"] = 4
+ XLSX.addtable!(sh, "D1:E2"; name="TblB")
- isfile(outfile) && rm(outfile)
- SAVE_FILES && save_outfile(f)
- end
+ tbls = XLSX.tables(sh)
- @testset "reserved Julia keyword header gets a leading underscore" begin
- outfile = "readtable_normalize_reserved.xlsx"
+ # plain `show` — no array summary header, just each element's compact form
+ s = sprint(show, tbls)
+ @test occursin("TblA", s)
+ @test occursin("TblB", s)
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "end"; sh["B1"] = "function"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.writexlsx(outfile, f, overwrite=true)
+ # MIME"text/plain" — this is the form that adds the "2-element" summary
+ s_repl = sprint(show, MIME("text/plain"), tbls)
+ @test occursin("TblA", s_repl)
+ @test occursin("TblB", s_repl)
+ @test occursin("2-element", s_repl)
+ SAVE_FILES && save_outfile(f)
+ end
- dt = XLSX.readtable(outfile; table_name="T", normalizenames=true)
- @test dt.column_labels == [:_end, :_function]
+ @testset "show(text/plain, Table) - no style, no totals" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "name"
+ sh["A2"] = 1; sh["B2"] = "alice"
- isfile(outfile) && rm(outfile)
- SAVE_FILES && save_outfile(f)
- end
+ t = XLSX.addtable!(sh, "A1:B2"; name="Basic")
+ s = sprint(show, MIME("text/plain"), t)
- @testset "gettable(t) directly honours normalizenames" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "col one"; sh["B1"] = "already_ok"
- sh["A2"] = 1; sh["B2"] = 2
- t = XLSX.addtable!(sh, "A1:B2"; name="T")
+ @test occursin("Basic", s)
+ @test occursin(string(t.id), s)
+ @test occursin(string(t.ref), s)
+ @test occursin("id, name", s)
+ @test occursin("style : none", s)
+ @test occursin("totals : no", s)
+ @test !occursin("displayName", s) # name == display_name, so no extra note
+ SAVE_FILES && save_outfile(f)
+ end
- dt = XLSX.gettable(t; normalizenames=true)
- @test dt.column_labels == [:col_one, :already_ok]
+ @testset "show(text/plain, Table) - with style and totals row" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "region"; sh["B1"] = "revenue"
+ sh["A2"] = "North"; sh["B2"] = 1000
- dt_raw = XLSX.gettable(t)
- @test dt_raw.column_labels == [Symbol("col one"), :already_ok]
- SAVE_FILES && save_outfile(f)
- end
+ XLSX.addtable!(sh, "A1:B2"; name="Sales", style="TableStyleMedium9")
+ t = XLSX.settotals!(sh, "Sales", "region" => "Total", "revenue" => :sum)
+ s = sprint(show, MIME("text/plain"), t)
- @testset "appends rows to a table with no totals row" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "name"
- sh["A2"] = 1; sh["B2"] = "alice"
- sh["A3"] = 2; sh["B3"] = "bob"
- XLSX.addtable!(sh, "A1:B3"; name="People")
-
- t = XLSX.appendtable!(sh, "People", [(3, "carol"), (4, "dave")])
-
- @test t.ref == XLSX.CellRange("A1:B5")
- @test t.has_totals_row == false
- @test sh["A4"] == 3; @test sh["B4"] == "carol"
- @test sh["A5"] == 4; @test sh["B5"] == "dave"
-
- cols = Tables.columns(t)
- @test cols.id == [1, 2, 3, 4]
- @test cols.name == ["alice", "bob", "carol", "dave"]
- SAVE_FILES && save_outfile(f)
- end
+ @test occursin("Sales", s)
+ @test occursin("region, revenue", s)
+ @test occursin("TableStyleMedium9", s)
+ @test occursin("row stripes", s) # default style_info sets show_row_stripes=true
+ @test occursin("totals : yes", s)
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "appending zero rows is a no-op" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
+ @testset "show(TableStyleInfo) - compact form" begin
+ style = XLSX.TableStyleInfo("TableStyleLight1", false, false, true, false)
+ s = sprint(show, style)
- t = XLSX.appendtable!(sh, "T", Tuple{Int,Int}[])
- @test t.ref == XLSX.CellRange("A1:B2")
- SAVE_FILES && save_outfile(f)
- end
+ @test occursin("TableStyleLight1", s)
+ @test occursin("rows", s)
+ @test !occursin("first", s)
+ @test !occursin("last", s)
+ @test !occursin("cols", s)
+ end
- @testset "autoFilter extends with ref (no totals row)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.appendtable!(sh, "T", [(3, 4)])
-
- table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "T"))
- root = XLSX.xml_root_element(table_doc)
- af = XLSX.elements_with_tag(root, "autoFilter")
- @test !isempty(af)
- @test XLSX.get_attr(af[1], "ref") == "A1:B3" # no totals row: same as ref
- SAVE_FILES && save_outfile(f)
- end
+ @testset "show(TableStyleInfo) - no name, no flags" begin
+ style = XLSX.TableStyleInfo(nothing, false, false, false, false)
+ s = sprint(show, style)
- @testset "totals row moves down and is regenerated (sum + label)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- sh["A3"] = "Pears"; sh["B3"] = 8
- XLSX.addtable!(sh, "A1:B3"; name="Bulk")
- XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
-
- t_before = XLSX.table(sh, "Bulk")
- @test t_before.ref == XLSX.CellRange("A1:B4")
- @test sh["A4"] == "Total"
-
- t = XLSX.appendtable!(sh, "Bulk", [("Cherries", 25), ("Damsons", 5)])
-
- # ref grew by 2; totals row is still last
- @test t.ref == XLSX.CellRange("A1:B6")
- @test t.has_totals_row == true
-
- # old totals position (row 4) now holds appended data
- @test sh["A4"] == "Cherries"; @test sh["B4"] == 25
- @test sh["A5"] == "Damsons"; @test sh["B5"] == 5
-
- # new totals row regenerated at row 6
- @test sh["A6"] == "Total"
- @test occursin("SUBTOTAL(109", XLSX.getFormula(sh, "B6"))
- @test occursin("Bulk[amount]", XLSX.getFormula(sh, "B6"))
-
- # data body excludes the totals row
- cols = Tables.columns(t)
- @test cols.item == ["Apples", "Pears", "Cherries", "Damsons"]
- @test cols.amount == [12, 8, 25, 5]
- SAVE_FILES && save_outfile(f)
- end
+ @test occursin("none", s)
+ @test !occursin(",", s) # no flags appended
+ end
- @testset "totals row: autoFilter excludes the totals row after append" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 10
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.settotals!(sh, "T", "b" => :sum) # ref becomes A1:B3
-
- XLSX.appendtable!(sh, "T", [(2, 20), (3, 30)]) # ref becomes A1:B5
-
- table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "T"))
- root = XLSX.xml_root_element(table_doc)
- @test XLSX.get_attr(root, "ref") == "A1:B5"
- af = XLSX.elements_with_tag(root, "autoFilter")
- @test XLSX.get_attr(af[1], "ref") == "A1:B4" # one row short of ref
- SAVE_FILES && save_outfile(f)
- end
+ @testset "show(TableStyleInfo) - all flags set" begin
+ style = XLSX.TableStyleInfo("Custom", true, true, true, true)
+ s = sprint(show, style)
- @testset "totals row: preserves a custom formula" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "revenue"; sh["B1"] = "cost"; sh["C1"] = "margin"
- sh["A2"] = 100; sh["B2"] = 60; sh["C2"] = 40
- XLSX.addtable!(sh, "A1:C2"; name="PnL")
- XLSX.settotals!(sh, "PnL",
- "revenue" => :sum,
- "margin" => (:custom, "SUBTOTAL(109,PnL[revenue])-SUBTOTAL(109,PnL[cost])"),
- )
-
- f_before = XLSX.getFormula(sh, "C3")
-
- XLSX.appendtable!(sh, "PnL", [(200, 90, 110)])
-
- t = XLSX.table(sh, "PnL")
- @test t.ref == XLSX.CellRange("A1:C4")
-
- # custom formula survived the move, unchanged
- f_after = XLSX.getFormula(sh, "C4")
- @test f_after == f_before
- @test occursin("PnL[revenue]", f_after)
- @test occursin("PnL[cost]", f_after)
-
- # and the table part still records it as custom
- table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "PnL"))
- i, j = XLSX.get_idces(table_doc, "table", "tableColumns")
- margin_node = collect(XLSX.xml_elements(table_doc[i][j]))[3]
- @test XLSX.get_attr(margin_node, "totalsRowFunction") == "custom"
- SAVE_FILES && save_outfile(f)
- end
+ @test occursin("Custom", s)
+ @test occursin("first", s)
+ @test occursin("last", s)
+ @test occursin("rows", s)
+ @test occursin("cols", s)
+ end
- @testset "totals row: preserves every built-in function kind" begin
- f = XLSX.newxlsx()
- sh = f[1]
- cols = ["s", "av", "cnt", "cnta", "mx", "mn"]
- for (i, c) in enumerate(cols)
- sh[1, i] = c
- end
- for r in 2:4, i in 1:length(cols)
- sh[r, i] = r * 10 + i
- end
- XLSX.addtable!(sh, "A1:F4"; name="Funcs")
- XLSX.settotals!(sh, "Funcs"; s=:sum, av=:average, cnt=:countnums, cnta=:count, mx=:max, mn=:min)
-
- XLSX.appendtable!(sh, "Funcs", [(99, 99, 99, 99, 99, 99)])
-
- t = XLSX.table(sh, "Funcs")
- @test t.ref == XLSX.CellRange("A1:F6")
-
- table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "Funcs"))
- i, j = XLSX.get_idces(table_doc, "table", "tableColumns")
- nodes = collect(XLSX.xml_elements(table_doc[i][j]))
-
- @test XLSX.get_attr(nodes[1], "totalsRowFunction") == "sum"
- @test XLSX.get_attr(nodes[2], "totalsRowFunction") == "average"
- # NB: OOXML's attribute names for the two count variants are the
- # reverse of Excel's function names — :countnums (Excel COUNT, numeric
- # only) is OOXML "countNums", and :count (Excel COUNTA, non-blank)
- # is OOXML "count".
- @test XLSX.get_attr(nodes[3], "totalsRowFunction") == "countNums"
- @test XLSX.get_attr(nodes[4], "totalsRowFunction") == "count"
- @test XLSX.get_attr(nodes[5], "totalsRowFunction") == "max"
- @test XLSX.get_attr(nodes[6], "totalsRowFunction") == "min"
- SAVE_FILES && save_outfile(f)
+ @testset "show output does not error on real fixture tables" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ for t in XLSX.tables(xf["Sheet1"])
+ @test !isempty(sprint(show, t))
+ @test !isempty(sprint(show, MIME("text/plain"), t))
+ end
+ for t in XLSX.tables(xf["Sheet2"])
+ @test !isempty(sprint(show, t))
+ @test !isempty(sprint(show, MIME("text/plain"), t))
+ end
+ end
+ end
end
- @testset "totals row: columns with no totals setting stay empty" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"; sh["C1"] = "c"
- sh["A2"] = 1; sh["B2"] = 2; sh["C2"] = 3
- XLSX.addtable!(sh, "A1:C2"; name="T")
- XLSX.settotals!(sh, "T", "b" => :sum) # only column b has totals
-
- XLSX.appendtable!(sh, "T", [(4, 5, 6)])
-
- t = XLSX.table(sh, "T")
- totals_row = t.ref.stop.row_number
- @test ismissing(sh[XLSX.CellRef(totals_row, 1)]) # a: no totals
- @test !ismissing(XLSX.getFormula(sh, XLSX.CellRef(totals_row, 2))) # b: sum
- @test ismissing(sh[XLSX.CellRef(totals_row, 3)]) # c: no totals
- SAVE_FILES && save_outfile(f)
- end
+ @testset "writetable with as_table" begin
+ @testset "writetable! - as_table=true basic" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ data = [[1, 2, 3], ["alice", "bob", "carol"]]
+ cols = ["id", "name"]
- @testset "input shapes: vector of tuples, vector of vectors, matrix, Tables.jl source" begin
+ XLSX.writetable!(sh, data, cols; as_table=true, table_name="People")
- sh = fresh()
- t = XLSX.appendtable!(sh, "T", [(2, 20), (3, 30)])
- @test Tables.columns(t).x == [1, 2, 3]
+ t = XLSX.table(sh, "People")
+ @test t.ref == XLSX.CellRange("A1:B4")
+ @test t.columns == ["id", "name"]
+ @test t.has_totals_row == false
+ SAVE_FILES && save_outfile(f)
+ end
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- sh = fresh()
- t = XLSX.appendtable!(sh, "T", [[2, 20], [3, 30]])
- @test Tables.columns(t).x == [1, 2, 3]
+ @testset "writetable! - as_table=false writes no table (default)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ data = [[1, 2], [3, 4]]
+ cols = ["a", "b"]
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- sh = fresh()
- t = XLSX.appendtable!(sh, "T", [2 20; 3 30])
- @test Tables.columns(t).x == [1, 2, 3]
+ XLSX.writetable!(sh, data, cols) # as_table defaults to false
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- sh = fresh()
- t = XLSX.appendtable!(sh, "T", DataFrames.DataFrame(x=[2, 3], y=[20, 30]))
- @test Tables.columns(t).x == [1, 2, 3]
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- end
+ @test isempty(XLSX.tables(sh))
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "column count mismatch errors" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
+ @testset "writetable! - as_table=true requires write_columnnames=true" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ data = [[1, 2], [3, 4]]
+ cols = ["a", "b"]
- @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", [(1, 2, 3)])
- @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", [(1,)])
- SAVE_FILES && save_outfile(f)
- end
+ @test_throws XLSX.XLSXError XLSX.writetable!(sh, data, cols;
+ as_table=true, write_columnnames=false)
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "refuses non-empty rows below the table; check_empty=false overrides" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
- sh["A3"] = "in the way"
+ @testset "writetable! - as_table=true requires at least one data row" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ data = [Int[], String[]]
+ cols = ["a", "b"]
- @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", [(9, 9)])
+ @test_throws XLSX.XLSXError XLSX.writetable!(sh, data, cols; as_table=true)
+ SAVE_FILES && save_outfile(f)
+ end
- # table unchanged after the failed attempt
- @test XLSX.table(sh, "T").ref == XLSX.CellRange("A1:B2")
+ @testset "writetable! - as_table=true with style and anchor offset" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ data = [[10, 20], [1.5, 2.5]]
+ cols = ["qty", "price"]
- t = XLSX.appendtable!(sh, "T", [(9, 9)]; check_empty=false)
- @test t.ref == XLSX.CellRange("A1:B3")
- @test sh["A3"] == 9
- SAVE_FILES && save_outfile(f)
- end
+ XLSX.writetable!(sh, data, cols;
+ anchor_cell=XLSX.CellRef("C3"), as_table=true,
+ table_name="Prices", table_style="TableStyleMedium9")
- @testset "table not found errors" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
+ t = XLSX.table(sh, "Prices")
+ @test t.ref == XLSX.CellRange("C3:D5")
+ @test t.style.name == "TableStyleMedium9"
+ SAVE_FILES && save_outfile(f)
+ end
- @test_throws KeyError XLSX.appendtable!(sh, "NoSuchTable", [(1, 2)])
- SAVE_FILES && save_outfile(f)
- end
+ @testset "writetable!(as_table=true) - refuses to overlap a merged range, writing nothing" begin
+ # The OW27 layout: a legend merged in A1:C3, then a table anchored at B3.
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1:C3"] = "legend"
+ XLSX.mergeCells(sh, "A1:C1"); XLSX.mergeCells(sh, "A2:C2"); XLSX.mergeCells(sh, "A3:C3")
+ data = Any[["Wales", "Scotland"], [1, 2], [3, 4], [5, 6]]
+ cols = ["Region", "No. Offers", "No. Submitted", "No. Enabled"]
+ err = try XLSX.writetable!(sh, data, cols; anchor_cell=XLSX.CellRef("B3"), as_table=true, table_name="Unk_Regions"); nothing catch e; e end
+ @test err isa XLSX.XLSXError && occursin("merged range `A3:C3`", err.msg)
+ @test isempty(XLSX.tables(sh))
+ @test XLSX.get_dimension(sh) == XLSX.CellRange("A1:C3") # nothing was written
+
+ XLSX.writetable!(sh, data, cols; anchor_cell=XLSX.CellRef("B4"), as_table=true, table_name="Unk_Regions")
+ @test only(XLSX.tables(sh)).ref == XLSX.CellRange("B4:E6")
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "appending missing values" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
-
- t = XLSX.appendtable!(sh, "T", [(3, missing), (missing, 6)])
- @test t.ref == XLSX.CellRange("A1:B4")
- @test ismissing(sh["B3"])
- @test ismissing(sh["A4"])
- SAVE_FILES && save_outfile(f)
- end
+ @testset "writetable (single-sheet, new file) - as_table=true" begin
+ outfile = "writetable_astable_single.xlsx"
- @testset "two tables on one sheet: appending to one leaves the other alone" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="Left")
+ columns = [[1, 2, 3], ["x", "y", "z"]]
+ colnames = ["num", "letter"]
+ XLSX.writetable(outfile, columns, colnames; as_table=true, table_name="Data", overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
- sh["D1"] = "c"; sh["E1"] = "d"
- sh["D2"] = 3; sh["E2"] = 4
- XLSX.addtable!(sh, "D1:E2"; name="Right")
+ XLSX.openxlsx(outfile) do xf
+ t = XLSX.table(xf[1], "Data")
+ @test t.ref == XLSX.CellRange("A1:B4")
+ @test t.columns == ["num", "letter"]
+ end
- XLSX.appendtable!(sh, "Left", [(5, 6)])
+ isfile(outfile) && rm(outfile)
+ end
- @test XLSX.table(sh, "Left").ref == XLSX.CellRange("A1:B3")
- @test XLSX.table(sh, "Right").ref == XLSX.CellRange("D1:E2") # untouched
- @test length(XLSX.tables(sh)) == 2
- SAVE_FILES && save_outfile(f)
- end
+ @testset "writetable (kwarg multi-sheet) - as_table=true, table names from sheet names" begin
+ outfile = "writetable_astable_multi_kw.xlsx"
- @testset "round trip: append then save and reopen" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- XLSX.addtable!(sh, "A1:B2"; name="Bulk")
- XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
- XLSX.appendtable!(sh, "Bulk", [("Pears", 8), ("Cherries", 25)])
- SAVE_FILES && save_outfile(f)
-
- outfile = "appendtable_roundtrip.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
-
- XLSX.openxlsx(outfile) do xf2
- t = XLSX.table(xf2[1], "Bulk")
- @test t.ref == XLSX.CellRange("A1:B5")
- @test t.has_totals_row == true
- cols = Tables.columns(t)
- @test cols.item == ["Apples", "Pears", "Cherries"]
- @test cols.amount == [12, 8, 25]
+ colsA = [[1, 2], [3, 4]]
+ namesA = ["a", "b"]
+ colsB = [[5, 6], [7, 8]]
+ namesB = ["c", "d"]
+
+ XLSX.writetable(outfile, as_table=true, table_style="TableStyleLight1",
+ REPORT_A=(colsA, namesA), REPORT_B=(colsB, namesB), overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf
+ tA = XLSX.table(xf["REPORT_A"], "REPORT_A")
+ @test tA.ref == XLSX.CellRange("A1:B3")
+ @test tA.style.name == "TableStyleLight1"
+
+ tB = XLSX.table(xf["REPORT_B"], "REPORT_B")
+ @test tB.ref == XLSX.CellRange("A1:B3")
+ @test tB.style.name == "TableStyleLight1"
+ end
+
+ isfile(outfile) && rm(outfile)
end
- XLSX.openxlsx(outfile, enable_cache=false) do xf2
- t = XLSX.table(xf2[1], "Bulk")
- @test t.ref == XLSX.CellRange("A1:B5")
- @test t.has_totals_row == true
+ @testset "writetable (Vector{Tuple} multi-sheet) - as_table=true, table names from sheet names" begin
+ outfile = "writetable_astable_multi_vec.xlsx"
+
+ colsA = [[1, 2], [3, 4]]
+ namesA = ["a", "b"]
+ colsB = [[5, 6], [7, 8]]
+ namesB = ["c", "d"]
+
+ XLSX.writetable(outfile, [
+ ("First", colsA, namesA),
+ ("Second", colsB, namesB),
+ ]; as_table=true, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf
+ @test XLSX.table(xf["First"], "First").ref == XLSX.CellRange("A1:B3")
+ @test XLSX.table(xf["Second"], "Second").ref == XLSX.CellRange("A1:B3")
+ end
+
+ isfile(outfile) && rm(outfile)
end
- isfile(outfile) && rm(outfile)
- end
+ @testset "writetable - table name normalized from an invalid sheet name (spaces)" begin
+ outfile = "writetable_astable_normalize.xlsx"
- @testset "repeated appends accumulate correctly" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "n"
- sh["A2"] = 1
- XLSX.addtable!(sh, "A1:A2"; name="T")
- XLSX.settotals!(sh, "T", "n" => :sum)
+ cols = [[1, 2], [3, 4]]
+ names = ["x", "y"]
- for i in 2:5
- XLSX.appendtable!(sh, "T", [(i,)])
+ # sheet names may contain spaces; table names may not — expect a
+ # warning and a normalized fallback name ("Report A" -> "Report_A")
+ @test_logs (:warn, r"valid Excel Table name"i) match_mode=:any begin
+ XLSX.writetable(outfile, [("Report A", cols, names)]; as_table=true, overwrite=true)
+ end
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf
+ sh = xf["Report A"]
+ tbls = XLSX.tables(sh)
+ @test length(tbls) == 1
+ @test tbls[1].name == "Report_A"
+ end
+
+ isfile(outfile) && rm(outfile)
+ end
+
+ @testset "writetable - normalized name falls back to auto-generated on collision" begin
+ outfile = "writetable_astable_normalize_collision.xlsx"
+
+ cols = [[1, 2], [3, 4]]
+ names = ["x", "y"]
+
+ # "Report A" normalizes to "Report_A"; second sheet is literally
+ # named "Report_A" already — its table name candidate collides with
+ # the first table, so it must fall back to an auto-generated name.
+ # Two warnings are expected here (normalization + collision) but
+ # aren't the focus of this test, so they're suppressed rather than
+ # left to print to the console.
+ with_logger(NullLogger()) do
+ XLSX.writetable(outfile, [
+ ("Report A", cols, names),
+ ("Report_A", cols, names),
+ ]; as_table=true, overwrite=true)
+ end
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf
+ t1 = only(XLSX.tables(xf["Report A"]))
+ t2 = only(XLSX.tables(xf["Report_A"]))
+
+ @test t1.name == "Report_A"
+ @test t2.name != "Report_A" # forced to fall back
+ @test t2.name != t1.name
+ @test startswith(t2.name, "Table") # addtable!'s auto-naming pattern
+ end
+
+ isfile(outfile) && rm(outfile)
+ end
+
+ @testset "writetable - reserved Julia keyword as sheet name still normalizes" begin
+ outfile = "writetable_astable_reserved.xlsx"
+
+ cols = [[1, 2], [3, 4]]
+ names = ["x", "y"]
+
+ # "for" is a valid Excel sheet name (no restricted characters) but a
+ # reserved Julia keyword; normalizename prepends "_" for these. The
+ # resulting warning isn't the focus of this test, so it's suppressed.
+ with_logger(NullLogger()) do
+ XLSX.writetable(outfile, [("for", cols, names)]; as_table=true, overwrite=true)
+ end
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf
+ t = only(XLSX.tables(xf["for"]))
+ @test t.name == "_for"
+ end
+
+ isfile(outfile) && rm(outfile)
+ end
+
+ @testset "writetable - a sheet name that is a cell address falls back to an auto-generated table name" begin
+ outfile = "writetable_astable_celladdress.xlsx"
+ @test_logs (:warn, r"is a cell address") match_mode=:any begin
+ XLSX.writetable(outfile, [("Q1", [[1, 2], [3, 4]], ["x", "y"])]; as_table=true, overwrite=true)
+ end
+ SAVE_FILES && save_outfile(outfile)
+ XLSX.openxlsx(outfile) do xf
+ @test startswith(only(XLSX.tables(xf["Q1"])).name, "Table")
+ end
+ rm(outfile)
end
- t = XLSX.table(sh, "T")
- @test t.ref == XLSX.CellRange("A1:A7") # header + 5 data + totals
- @test Tables.columns(t).n == [1, 2, 3, 4, 5]
- @test occursin("SUBTOTAL(109", XLSX.getFormula(sh, "A7"))
- SAVE_FILES && save_outfile(f)
+ @testset "writetable - as_table=false leaves no tables in multi-sheet write" begin
+ outfile = "writetable_no_table_multi.xlsx"
+
+ cols = [[1, 2], [3, 4]]
+ names = ["x", "y"]
+ XLSX.writetable(outfile, [("Sheet1", cols, names), ("Sheet2", cols, names)]; overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
+
+ XLSX.openxlsx(outfile) do xf
+ @test isempty(XLSX.tables(xf["Sheet1"]))
+ @test isempty(XLSX.tables(xf["Sheet2"]))
+ end
+
+ isfile(outfile) && rm(outfile)
+ end
end
- @testset "not writable errors" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf # read-only
- sh = xf["Sheet1"]
- @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "IO_Table", [(1, 2, 3)])
+ @testset "Tables.jl interface" begin
+ @testset "Tables.istable / rowaccess / columnaccess" begin
+ @test Tables.istable(XLSX.Table)
+ @test Tables.istable(XLSX.XLSXTableRowIterator)
+ @test Tables.rowaccess(XLSX.Table)
+ @test Tables.rowaccess(XLSX.XLSXTableRowIterator)
+ @test Tables.columnaccess(XLSX.Table)
+ end
+
+ @testset "Tables.schema - column names, types unknown (nothing)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "name"; sh["C1"] = "score"
+ sh["A2"] = 1; sh["B2"] = "alice"; sh["C2"] = 10.5
+
+ t = XLSX.addtable!(sh, "A1:C2"; name="T")
+ sch = Tables.schema(t)
+
+ # `nothing` (not a declared `fill(Any, n)`) is deliberate: declaring
+ # Any explicitly would make DataFrames (and other Tables.jl sinks)
+ # trust that declaration literally and permanently box every column
+ # as Any, rather than inferring real types from the data — exactly
+ # the issue #225 regression.
+ @test sch.names == (:id, :name, :score)
+ @test isnothing(sch.types)
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "Tables.columns / DataFrame infer concrete column types (issue #225 regression)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "score"; sh["B1"] = "label"
+ sh["A2"] = 10.5; sh["B2"] = "alice"
+ sh["A3"] = 20.0; sh["B3"] = "bob"
+ sh["A4"] = 15.25; sh["B4"] = "carol"
+
+ t = XLSX.addtable!(sh, "A1:B4"; name="T")
+
+ # Direct Tables.columns(t) — the underlying mechanism
+ cols = Tables.columns(t)
+ @test eltype(cols.score) != Any
+ @test eltype(cols.score) <: Union{Missing,Float64}
+ @test eltype(cols.label) <: Union{Missing,String}
+
+ # Same, via the row iterator returned by eachtablerow — this is the
+ # exact path that previously regressed to Any/Any (issue #225)
+ it = XLSX.eachtablerow(t)
+ cols_via_iter = Tables.columns(it)
+ @test eltype(cols_via_iter.score) != Any
+ @test eltype(cols_via_iter.score) <: Union{Missing,Float64}
+
+ # And the full DataFrame(...) round trip
+ df = DataFrames.DataFrame(it)
+ @test eltype(df.score) != Any
+ @test eltype(df.score) <: Union{Missing,Float64}
+ @test eltype(df.label) <: Union{Missing,String}
+ SAVE_FILES && save_outfile(f)
end
- end
- @testset "DataFrame columns matched by name, not position" begin
- sh = fresh_abc()
+ @testset "Tables.schema - matches between Table and its row iterator" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
- # deliberately scrambled column order
- df = DataFrames.DataFrame(c=[30, 60], a=[10, 40], b=[20, 50])
- t = XLSX.appendtable!(sh, "T", df)
+ t = XLSX.addtable!(sh, "A1:B2"; name="T")
+ it = Tables.rows(t)
- @test t.ref == XLSX.CellRange("A1:C4")
- cols = Tables.columns(t)
- @test cols.a == [1, 10, 40]
- @test cols.b == [2, 20, 50]
- @test cols.c == [3, 30, 60]
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- end
+ @test it isa XLSX.XLSXTableRowIterator
+ @test Tables.rows(it) === it # identity, matching TableRowIterator convention
+ @test Tables.schema(it).names == Tables.schema(t).names
+ @test Tables.schema(it).types == Tables.schema(t).types
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "DataTable source works and is name-matched" begin
- sh = fresh_abc()
+ @testset "row-access path (Tables.rows) resolves schema/columnnames without erroring" begin
+ # Regression check: PrettyTables (and other row-access consumers)
+ # call Tables.schema/Tables.columnnames on Tables.rows(t) — the
+ # XLSXTableRowIterator — not on `t` directly.
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "name"
+ sh["A2"] = 1; sh["B2"] = "alice"
+ sh["A3"] = 2; sh["B3"] = "bob"
- # build a DataTable with scrambled column order
- dt = XLSX.DataTable(Any[[30, 60], [10, 40], [20, 50]], [:c, :a, :b])
- t = XLSX.appendtable!(sh, "T", dt)
+ t = XLSX.addtable!(sh, "A1:B3"; name="T")
+ it = Tables.rows(t)
- @test t.ref == XLSX.CellRange("A1:C4")
- cols = Tables.columns(t)
- @test cols.a == [1, 10, 40]
- @test cols.b == [2, 20, 50]
- @test cols.c == [3, 30, 60]
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- end
+ @test !isnothing(Tables.schema(it))
+ @test Tables.schema(it).names == (:id, :name)
+ @test !isnothing(Tables.columnnames(first(it)))
+ @test collect(it) isa Vector{XLSX.XLSXTableRow}
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "DataTable read from one table appended to another" begin
- # read a table out, then append it to a second table with the same
- # columns in a different order
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "x"; sh["B1"] = "y"
- sh["A2"] = 1; sh["B2"] = 10
- sh["A3"] = 2; sh["B3"] = 20
- src = XLSX.addtable!(sh, "A1:B3"; name="Source")
-
- sh["E1"] = "y"; sh["F1"] = "x" # reversed relative to Source
- sh["E2"] = 99; sh["F2"] = 9
- XLSX.addtable!(sh, "E1:F2"; name="Dest")
-
- dt = XLSX.gettable(src)
- t = XLSX.appendtable!(sh, "Dest", dt)
-
- @test t.ref == XLSX.CellRange("E1:F4")
- cols = Tables.columns(t)
- @test cols.y == [99, 10, 20] # matched by name despite reversal
- @test cols.x == [9, 1, 2]
- SAVE_FILES && save_outfile(f)
- end
+ @testset "Tables.columnnames matches table columns" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "x"; sh["B1"] = "y"
+ sh["A2"] = 1; sh["B2"] = 2
- @testset "missing column errors" begin
- sh = fresh_abc()
- df = DataFrames.DataFrame(a=[10], b=[20]) # no "c"
- @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", df)
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- end
+ t = XLSX.addtable!(sh, "A1:B2"; name="T")
+ row = first(XLSX.eachtablerow(t))
+ @test Tables.columnnames(row) == [:x, :y]
+ @test Tables.columnnames(t) == [:x, :y]
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "extra column errors" begin
- sh = fresh_abc()
- df = DataFrames.DataFrame(a=[10], b=[20], c=[30], d=[40]) # extra "d"
- @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", df)
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- end
+ @testset "Tables.columns - values match what was written" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "name"; sh["C1"] = "score"
+ sh["A2"] = 1; sh["B2"] = "alice"; sh["C2"] = 10.5
+ sh["A3"] = 2; sh["B3"] = "bob"; sh["C3"] = 20.0
+ sh["A4"] = 3; sh["B4"] = "carol"; sh["C4"] = 15.0
- @testset "both missing and extra columns errors" begin
- sh = fresh_abc()
- df = DataFrames.DataFrame(a=[10], b=[20], z=[99]) # missing c, extra z
- @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", df)
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- end
+ t = XLSX.addtable!(sh, "A1:C4"; name="People")
+ cols = Tables.columns(t)
- @testset "unnamed sources remain positional" begin
- # matrix
- sh = fresh_abc()
- t = XLSX.appendtable!(sh, "T", [10 20 30])
- cols = Tables.columns(t)
- @test cols.a == [1, 10]
- @test cols.b == [2, 20]
- @test cols.c == [3, 30]
-
- # vector of tuples
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- sh = fresh_abc()
- t = XLSX.appendtable!(sh, "T", [(10, 20, 30)])
- cols = Tables.columns(t)
- @test cols.a == [1, 10]
- @test cols.c == [3, 30]
-
- # vector of vectors
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- sh = fresh_abc()
- t = XLSX.appendtable!(sh, "T", [[10, 20, 30]])
- cols = Tables.columns(t)
- @test cols.a == [1, 10]
- @test cols.c == [3, 30]
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- end
+ @test cols.id == [1, 2, 3]
+ @test cols.name == ["alice", "bob", "carol"]
+ @test cols.score == [10.5, 20.0, 15.0]
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "name matching still works when a totals row is present" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- XLSX.addtable!(sh, "A1:B2"; name="Bulk")
- XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
-
- # reversed column order in the source
- df = DataFrames.DataFrame(amount=[8, 25], item=["Pears", "Cherries"])
- t = XLSX.appendtable!(sh, "Bulk", df)
-
- @test t.ref == XLSX.CellRange("A1:B5")
- @test t.has_totals_row == true
- cols = Tables.columns(t)
- @test cols.item == ["Apples", "Pears", "Cherries"]
- @test cols.amount == [12, 8, 25]
- @test occursin("SUBTOTAL(109", XLSX.getFormula(sh, "B5"))
- SAVE_FILES && save_outfile(f)
- end
+ @testset "Tables.rowtable - generic Tables.jl round trip" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "score"
+ sh["A2"] = 1; sh["B2"] = 10.5
+ sh["A3"] = 2; sh["B3"] = 20.0
- @testset "readto(source, sheet, sink; table_name=...)" begin
- outfile = make_readto_file("readto_tablename_scoped.xlsx")
- SAVE_FILES && save_outfile(outfile)
+ t = XLSX.addtable!(sh, "A1:B3"; name="T")
- df = XLSX.readto(outfile, "Second", DataFrames.DataFrame; table_name="TableTwo")
+ nt_rows = Tables.rowtable(t)
+ @test length(nt_rows) == 2
+ @test nt_rows[1].id == 1
+ @test nt_rows[1].score == 10.5
+ @test nt_rows[2].id == 2
+ @test nt_rows[2].score == 20.0
+ SAVE_FILES && save_outfile(f)
+ end
- @test df isa DataFrames.DataFrame
- @test size(df) == (3, 3)
- @test DataFrames.names(df) == ["id", "name", "score"]
- @test df.id == [1, 2, 3]
- @test df.name == ["alice", "bob", "carol"]
- @test df.score == [10.5, 20.0, 15.25]
- @test eltype(df.score) != Any
+ @testset "argument forms of the table functions" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ XLSX.writetable!(sh, [[1, 2], [3, 4]], ["a", "b"])
+ XLSX.addtable!(sh, "A1:B3"; name="T")
+ t = XLSX.table(sh, "T")
+ XLSX.appendtable!(sh, t.id, [[5, 6]])
+ @test XLSX.gettable(sh).data[1] == [1, 2, 5]
+ XLSX.settotals!(sh, t.id; a=:sum)
+ XLSX.settotals!(XLSX.table(sh, "T"); b=:sum)
+ @test XLSX.table(sh, "T").has_totals_row
+
+ @test Tables.istable(XLSX.Table) && Tables.rowaccess(XLSX.Table) && Tables.columnaccess(XLSX.Table)
+ rows = Tables.rows(XLSX.table(sh, "T"))
+ @test Tables.istable(typeof(rows)) && Tables.rowaccess(typeof(rows))
+ @test Tables.rowtable(rows) == Tables.rowtable(XLSX.table(sh, "T"))
+
+ dt = XLSX.gettable(sh)
+ @test Tables.istable(XLSX.DataTable) && Tables.columnaccess(XLSX.DataTable)
+ itr = XLSX.eachtablerow(sh)
+ @test Tables.istable(typeof(itr)) && Tables.rowaccess(typeof(itr))
+ r = first(itr)
+ # by position, with Int and with another integer type (was a MethodError for both)
+ @test Tables.getcolumn(r, 1) == Tables.getcolumn(r, Int32(1)) == Tables.getcolumn(r, :a)
+ @test XLSX._as_vector((1, 2)) == [1, 2]
+ @test XLSX._colname_prefix_string(sh, XLSX.EmptyCell(XLSX.CellRef("A1"))) == "#Empty"
+ SAVE_FILES && save_outfile(xf)
+ end
+ end
+
+ @testset "eachtablerow(::Table)" begin
+ @testset "eachtablerow - length excludes header row" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ sh["A3"] = 3; sh["B3"] = 4
+ sh["A4"] = 5; sh["B4"] = 6
+
+ t = XLSX.addtable!(sh, "A1:B4"; name="T")
+ rows = collect(XLSX.eachtablerow(t))
- isfile(outfile) && rm(outfile)
- end
+ @test length(rows) == 3
+ @test length(XLSX.eachtablerow(t)) == 3 # Base.length via iterator, not just collect
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "readto(source, sink; table_name=...) - workbook-wide" begin
- outfile = make_readto_file("readto_tablename_wide.xlsx")
+ @testset "eachtablerow - minimum valid table (one data row)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
- # table on the second sheet, found without naming the sheet
- df = XLSX.readto(outfile, DataFrames.DataFrame; table_name="TableTwo")
- @test size(df) == (3, 3)
- @test df.id == [1, 2, 3]
+ t = XLSX.addtable!(sh, "A1:B2"; name="T")
+ rows = collect(XLSX.eachtablerow(t))
- # table on the first sheet
- df1 = XLSX.readto(outfile, DataFrames.DataFrame; table_name="TableOne")
- @test size(df1) == (2, 2)
- @test df1.a == [1, 2]
+ @test length(rows) == 1
+ @test Tables.getcolumn(rows[1], :a) == 1
+ @test Tables.getcolumn(rows[1], :b) == 2
+ @test Tables.getcolumn(rows[1], 1) == 1
+ @test Tables.getcolumn(rows[1], 2) == 2
+ SAVE_FILES && save_outfile(f)
+ end
- isfile(outfile) && rm(outfile)
- end
+ @testset "eachtablerow - values match by name and by index" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "name"
+ sh["A2"] = 1; sh["B2"] = "alice"
+ sh["A3"] = 2; sh["B3"] = "bob"
- @testset "readto by sheet index" begin
- outfile = make_readto_file("readto_tablename_idx.xlsx")
+ t = XLSX.addtable!(sh, "A1:B3"; name="People")
+ rows = collect(XLSX.eachtablerow(t))
- df = XLSX.readto(outfile, 2, DataFrames.DataFrame; table_name="TableTwo")
- @test DataFrames.names(df) == ["id", "name", "score"]
- @test df.id == [1, 2, 3]
+ @test Tables.getcolumn(rows[1], :id) == 1
+ @test Tables.getcolumn(rows[1], :name) == "alice"
+ @test Tables.getcolumn(rows[2], 1) == 2
+ @test Tables.getcolumn(rows[2], 2) == "bob"
+ SAVE_FILES && save_outfile(f)
+ end
- isfile(outfile) && rm(outfile)
- end
+ @testset "eachtablerow - totals row excluded (created via settotals!)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ sh["A3"] = "Pears"; sh["B3"] = 8
- @testset "readto excludes data outside the table's ref" begin
- outfile = make_readto_file("readto_tablename_bounds.xlsx")
+ XLSX.addtable!(sh, "A1:B3"; name="Bulk")
+ t = XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
- df = XLSX.readto(outfile, "Second", DataFrames.DataFrame; table_name="TableTwo")
- @test DataFrames.ncol(df) == 3
- @test "outside" ∉ DataFrames.names(df)
- @test DataFrames.nrow(df) == 3
+ rows = collect(XLSX.eachtablerow(t))
+ @test length(rows) == 2 # totals row (row 4) must NOT be included
+ @test Tables.getcolumn(rows[1], :item) == "Apples"
+ @test Tables.getcolumn(rows[2], :item) == "Pears"
- isfile(outfile) && rm(outfile)
- end
+ cols = Tables.columns(t)
+ @test cols.item == ["Apples", "Pears"]
+ @test cols.amount == [12, 8]
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "readto scoped and workbook-wide agree" begin
- outfile = make_readto_file("readto_tablename_agree.xlsx")
+ @testset "eachtablerow - missing values pass through" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = missing
+ sh["A3"] = missing; sh["B3"] = 4
- df_scoped = XLSX.readto(outfile, "Second", DataFrames.DataFrame; table_name="TableTwo")
- df_wide = XLSX.readto(outfile, DataFrames.DataFrame; table_name="TableTwo")
- @test isequal(df_scoped, df_wide)
+ t = XLSX.addtable!(sh, "A1:B3"; name="T")
+ rows = collect(XLSX.eachtablerow(t))
- isfile(outfile) && rm(outfile)
- end
+ @test ismissing(Tables.getcolumn(rows[1], :b))
+ @test ismissing(Tables.getcolumn(rows[2], :a))
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "readto with enable_cache=false" begin
- outfile = make_readto_file("readto_tablename_nocache.xlsx")
+ @testset "eachtablerow - a fully blank row within ref is preserved, not skipped" begin
+ # Unlike gettable/TableRowIterator (which infers table bounds from
+ # content and offers stop_in_empty_row/keep_empty_rows/
+ # stop_in_row_function to resolve that ambiguity), a Table's `ref` is
+ # already authoritative — every row between header and totals (if
+ # any) is unconditionally part of the table, whether blank or not.
+ # eachtablerow must never drop a row just because it happens to be
+ # entirely empty.
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 10
+ # row 3 deliberately left entirely blank
+ sh["A4"] = 3; sh["B4"] = 30
+
+ t = XLSX.addtable!(sh, "A1:B4"; name="T")
+ rows = collect(XLSX.eachtablerow(t))
- df_scoped = XLSX.readto(outfile, "Second", DataFrames.DataFrame;
- table_name="TableTwo", enable_cache=false)
- @test size(df_scoped) == (3, 3)
- @test df_scoped.name == ["alice", "bob", "carol"]
+ @test length(rows) == 3 # rows 2, 3, 4 — the blank row 3 must still be present
+ @test Tables.getcolumn(rows[1], :a) == 1
+ @test ismissing(Tables.getcolumn(rows[2], :a))
+ @test ismissing(Tables.getcolumn(rows[2], :b))
+ @test Tables.getcolumn(rows[3], :a) == 3
- df_wide = XLSX.readto(outfile, DataFrames.DataFrame;
- table_name="TableTwo", enable_cache=false)
- @test isequal(df_scoped, df_wide)
+ cols = Tables.columns(t)
+ @test length(cols.a) == 3
+ @test ismissing(cols.a[2])
+ SAVE_FILES && save_outfile(f)
+ end
- # and agrees with the cached read
- df_cached = XLSX.readto(outfile, DataFrames.DataFrame; table_name="TableTwo")
- @test isequal(df_cached, df_wide)
+ @testset "real fixture (two_tables.xlsx) - with_total (Sheet2), has a totals row" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh2 = xf["Sheet2"]
+ t = XLSX.table(sh2, "with_total")
+ @test t.has_totals_row == true
+ @test t.columns == ["start", "stop", "sin"]
- isfile(outfile) && rm(outfile)
- end
+ rows = collect(XLSX.eachtablerow(t))
+ expected_data_rows = (t.ref.stop.row_number - 1) - (t.ref.start.row_number + 1) + 1
+ @test length(rows) == expected_data_rows
- @testset "readto passes normalizenames / missing_strings through" begin
- outfile = "readto_tablename_kwargs.xlsx"
+ # the last row iterated must be genuine data — not the totals
+ # row. The totals row's "sin" column holds a formula (no cached
+ # value), so getdata would return `missing`; confirm the last
+ # data row's "sin" value is real, present data instead.
+ last_row = rows[end]
+ @test !ismissing(Tables.getcolumn(last_row, :sin))
+ end
+ end
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "col one"; sh["B1"] = "value"
- sh["A2"] = "x"; sh["B2"] = 1.5
- sh["A3"] = "N/A"; sh["B3"] = 2.5
- XLSX.addtable!(sh, "A1:B3"; name="T")
- XLSX.writexlsx(outfile, f, overwrite=true)
+ @testset "real fixture (two_tables.xlsx) - Age_height (Sheet1), no totals row" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet1"]
+ t = XLSX.table(sh, "Age_height")
+ @test t.has_totals_row == false
- df_norm = XLSX.readto(outfile, DataFrames.DataFrame; table_name="T", normalizenames=true)
- @test DataFrames.names(df_norm)[1] == "col_one"
+ rows = collect(XLSX.eachtablerow(t))
+ @test length(rows) == t.ref.stop.row_number - t.ref.start.row_number # all rows below header
+ end
+ end
- df_miss = XLSX.readto(outfile, DataFrames.DataFrame; table_name="T", missing_strings="N/A")
- @test ismissing(df_miss[2, 1])
+ @testset "real fixture (two_tables.xlsx) - IO_Table, no totals row" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet1"]
+ t = XLSX.table(sh, "IO_Table")
+ @test t.has_totals_row == false
- isfile(outfile) && rm(outfile)
- SAVE_FILES && save_outfile(f)
+ rows = collect(XLSX.eachtablerow(t))
+ @test length(rows) == t.ref.stop.row_number - t.ref.start.row_number # all rows below header
+ @test Tables.columnnames(rows[1]) == Symbol.(t.columns)
+ end
+ end
end
- @testset "readto with a totals row: totals excluded" begin
- outfile = "readto_tablename_totals.xlsx"
-
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- sh["A3"] = "Pears"; sh["B3"] = 8
- XLSX.addtable!(sh, "A1:B3"; name="Bulk")
- XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
- XLSX.writexlsx(outfile, f, overwrite=true)
-
- df = XLSX.readto(outfile, DataFrames.DataFrame; table_name="Bulk")
- @test DataFrames.nrow(df) == 2
- @test df.item == ["Apples", "Pears"]
- @test df.amount == [12, 8]
-
- isfile(outfile) && rm(outfile)
- SAVE_FILES && save_outfile(f)
- end
+ @testset "getdata(::Table)" begin
+ @testset "basic matrix shape and values" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "region"; sh["B1"] = "revenue"
+ sh["A2"] = "North"; sh["B2"] = 1000
+ sh["A3"] = "South"; sh["B3"] = 1500
+ sh["A4"] = "East"; sh["B4"] = 900
+
+ t = XLSX.addtable!(sh, "A1:B4"; name="Sales")
+ m = XLSX.getdata(t)
- @testset "readto errors: table_name with a columns range" begin
- outfile = make_readto_file("readto_tablename_conflict.xlsx")
+ @test m isa Matrix{Any}
+ @test size(m) == (3, 2)
+ @test m[1, 1] == "North"; @test m[1, 2] == 1000
+ @test m[2, 1] == "South"; @test m[2, 2] == 1500
+ @test m[3, 1] == "East"; @test m[3, 2] == 900
+ SAVE_FILES && save_outfile(f)
+ end
- @test_throws XLSX.XLSXError XLSX.readto(outfile, "Second", "A:C",
- DataFrames.DataFrame; table_name="TableTwo")
+ @testset "excludes totals row" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ sh["A3"] = "Pears"; sh["B3"] = 8
- isfile(outfile) && rm(outfile)
- end
+ XLSX.addtable!(sh, "A1:B3"; name="Bulk")
+ t = XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
- @testset "readto errors: table not found" begin
- outfile = make_readto_file("readto_tablename_notfound.xlsx")
+ m = XLSX.getdata(t)
+ @test size(m) == (2, 2) # totals row (row 4) must not appear
+ @test m[end, 1] == "Pears"
+ @test m[end, 2] == 8
+ SAVE_FILES && save_outfile(f)
+ end
- @test_throws XLSX.XLSXError XLSX.readto(outfile, DataFrames.DataFrame; table_name="NoSuchTable")
- @test_throws KeyError XLSX.readto(outfile, "First", DataFrames.DataFrame; table_name="TableTwo")
+ @testset "blank row within ref is preserved, not skipped" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 10
+ # row 3 deliberately left entirely blank
+ sh["A4"] = 3; sh["B4"] = 30
- isfile(outfile) && rm(outfile)
- end
+ t = XLSX.addtable!(sh, "A1:B4"; name="T")
+ m = XLSX.getdata(t)
- @testset "readto errors: missing sink" begin
- outfile = make_readto_file("readto_tablename_nosink.xlsx")
+ @test size(m) == (3, 2)
+ @test m[1, 1] == 1
+ @test ismissing(m[2, 1])
+ @test ismissing(m[2, 2])
+ @test m[3, 1] == 3
+ SAVE_FILES && save_outfile(f)
+ end
- @test_throws XLSX.XLSXError XLSX.readto(outfile; table_name="TableTwo")
- @test_throws XLSX.XLSXError XLSX.readto(outfile, "Second"; table_name="TableTwo")
+ @testset "minimum valid table (one data row)" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
- isfile(outfile) && rm(outfile)
- end
+ t = XLSX.addtable!(sh, "A1:B2"; name="T")
+ m = XLSX.getdata(t)
- @testset "readto agrees with readtable for the same table" begin
- outfile = make_readto_file("readto_tablename_vs_readtable.xlsx")
+ @test size(m) == (1, 2)
+ @test m[1, 1] == 1
+ @test m[1, 2] == 2
+ SAVE_FILES && save_outfile(f)
+ end
- df_readto = XLSX.readto(outfile, "Second", DataFrames.DataFrame; table_name="TableTwo")
- df_readtable = DataFrames.DataFrame(XLSX.readtable(outfile, "Second"; table_name="TableTwo"))
- @test isequal(df_readto, df_readtable)
+ @testset "real fixture (two_tables.xlsx) - IO_Table" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet1"]
+ t = XLSX.table(sh, "IO_Table")
+ m = XLSX.getdata(t)
- isfile(outfile) && rm(outfile)
+ @test size(m) == (t.ref.stop.row_number - t.ref.start.row_number, length(t.columns))
+ end
+ end
end
- @testset "existing readto behaviour unaffected when table_name omitted" begin
- outfile = make_readto_file("readto_tablename_regression.xlsx")
+ @testset "readtable(; table_name)" begin
+ @testset "sheet-scoped: readtable(source, sheet; table_name=...)" begin
+ outfile = make_multitable_file("readtable_tablename_scoped.xlsx")
+ SAVE_FILES && save_outfile(outfile)
- df = XLSX.readto(outfile, "First", DataFrames.DataFrame)
- @test df isa DataFrames.DataFrame
- @test DataFrames.names(df) == ["a", "b"]
+ dt = XLSX.readtable(outfile, "Second"; table_name="TableTwo")
- df_cols = XLSX.readto(outfile, "Second", "A:B", DataFrames.DataFrame)
- @test DataFrames.names(df_cols) == ["id", "name"]
+ @test dt isa XLSX.DataTable
+ @test dt.column_labels == [:id, :name, :score]
+ @test length(dt.data) == 3
+ @test dt.data[1] == [1, 2, 3]
+ @test dt.data[2] == ["alice", "bob", "carol"]
+ @test dt.data[3] == [10.5, 20.0, 15.25]
- isfile(outfile) && rm(outfile)
- end
+ isfile(outfile) && rm(outfile)
+ end
- @testset "index by integer, symbol and string" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "id"; sh["B1"] = "name"; sh["C1"] = "score"
- sh["A2"] = 1; sh["B2"] = "alice"; sh["C2"] = 10.5
- sh["A3"] = 2; sh["B3"] = "bob"; sh["C3"] = 20.0
-
- t = XLSX.addtable!(sh, "A1:C3"; name="People")
- rows = collect(XLSX.eachtablerow(t))
-
- # by integer position
- @test rows[1][1] == 1
- @test rows[1][2] == "alice"
- @test rows[1][3] == 10.5
- @test rows[2][1] == 2
-
- # by symbol
- @test rows[1][:id] == 1
- @test rows[1][:name] == "alice"
- @test rows[1][:score] == 10.5
- @test rows[2][:name] == "bob"
-
- # by string
- @test rows[1]["id"] == 1
- @test rows[1]["name"] == "alice"
- @test rows[2]["score"] == 20.0
- SAVE_FILES && save_outfile(f)
- end
+ @testset "sheet-scoped: by sheet index rather than name" begin
+ outfile = make_multitable_file("readtable_tablename_scoped_idx.xlsx")
- @testset "all three index forms agree" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 10; sh["B2"] = 20
+ dt = XLSX.readtable(outfile, 2; table_name="TableTwo")
+ @test dt.column_labels == [:id, :name, :score]
+ @test dt.data[1] == [1, 2, 3]
- t = XLSX.addtable!(sh, "A1:B2"; name="T")
- r = first(XLSX.eachtablerow(t))
+ isfile(outfile) && rm(outfile)
+ end
- @test r[1] == r[:a] == r["a"]
- @test r[2] == r[:b] == r["b"]
- SAVE_FILES && save_outfile(f)
- end
+ @testset "sheet-scoped: excludes data outside the table's ref" begin
+ outfile = make_multitable_file("readtable_tablename_bounds.xlsx")
- @testset "getindex agrees with Tables.getcolumn" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "x"; sh["B1"] = "y"
- sh["A2"] = 1; sh["B2"] = "one"
- sh["A3"] = 2; sh["B3"] = "two"
+ dt = XLSX.readtable(outfile, "Second"; table_name="TableTwo")
- t = XLSX.addtable!(sh, "A1:B3"; name="T")
+ # only the table's 3 columns / 3 data rows — nothing from E1:E2 or row 6
+ @test length(dt.column_labels) == 3
+ @test :outside ∉ dt.column_labels
+ @test all(length(col) == 3 for col in dt.data)
+ @test "not in table" ∉ dt.data[1]
+ @test "also not in table" ∉ dt.data[2]
- for r in XLSX.eachtablerow(t)
- @test r[1] == Tables.getcolumn(r, 1)
- @test r[:x] == Tables.getcolumn(r, :x)
- @test r["y"] == Tables.getcolumn(r, :y)
+ isfile(outfile) && rm(outfile)
end
- SAVE_FILES && save_outfile(f)
- end
- @testset "column names with spaces are reachable by string" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "col one"; sh["B1"] = "col two"
- sh["A2"] = 1; sh["B2"] = 2
+ @testset "workbook-wide: readtable(source; table_name=...)" begin
+ outfile = make_multitable_file("readtable_tablename_wide.xlsx")
- t = XLSX.addtable!(sh, "A1:B2"; name="T")
- r = first(XLSX.eachtablerow(t))
+ # table on the *second* sheet, found without naming the sheet
+ dt = XLSX.readtable(outfile; table_name="TableTwo")
+ @test dt.column_labels == [:id, :name, :score]
+ @test dt.data[1] == [1, 2, 3]
- @test r["col one"] == 1
- @test r["col two"] == 2
- @test r[Symbol("col one")] == 1 # symbol form still works, just awkward
- SAVE_FILES && save_outfile(f)
- end
+ # table on the first sheet
+ dt1 = XLSX.readtable(outfile; table_name="TableOne")
+ @test dt1.column_labels == [:a, :b]
+ @test dt1.data[1] == [1, 2]
- @testset "missing values via getindex" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = missing
- sh["A3"] = missing; sh["B3"] = 4
+ # table on the last sheet
+ dt3 = XLSX.readtable(outfile; table_name="TableThree")
+ @test dt3.column_labels == [:e, :f]
+ @test dt3.data[1] == [5]
- t = XLSX.addtable!(sh, "A1:B3"; name="T")
- rows = collect(XLSX.eachtablerow(t))
+ isfile(outfile) && rm(outfile)
+ end
- @test ismissing(rows[1][:b])
- @test ismissing(rows[1][2])
- @test ismissing(rows[2]["a"])
- SAVE_FILES && save_outfile(f)
- end
+ @testset "sheet-scoped and workbook-wide agree" begin
+ outfile = make_multitable_file("readtable_tablename_agree.xlsx")
- @testset "totals row excluded when indexing rows" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- sh["A3"] = "Pears"; sh["B3"] = 8
- XLSX.addtable!(sh, "A1:B3"; name="Bulk")
- t = XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
-
- rows = collect(XLSX.eachtablerow(t))
- @test length(rows) == 2
- @test rows[end][:item] == "Pears" # not "Total"
- @test rows[end][:amount] == 8
- SAVE_FILES && save_outfile(f)
- end
+ dt_scoped = XLSX.readtable(outfile, "Second"; table_name="TableTwo")
+ dt_wide = XLSX.readtable(outfile; table_name="TableTwo")
- @testset "real fixture (two_tables.xlsx)" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet1"]
- t = XLSX.table(sh, "IO_Table")
- r = first(XLSX.eachtablerow(t))
+ @test dt_scoped.column_labels == dt_wide.column_labels
+ @test dt_scoped.data == dt_wide.data
- for (i, colname) in enumerate(t.columns)
- @test isequal(r[i], r[Symbol(colname)])
- @test isequal(r[i], r[colname])
- end
+ isfile(outfile) && rm(outfile)
end
- end
-
-# ---------------------------------------------------------------------------
-# Test doubles for `_normalize_append_rows`' non-schema paths.
-# ---------------------------------------------------------------------------
-
-
-# ===========================================================================
-# deletetable! — totals-row formula rewriting (largest uncovered block)
-# ===========================================================================
-
- @testset "deletetable! - totals formulas rewritten to static ranges" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "revenue"; sh["B1"] = "cost"; sh["C1"] = "margin"
- sh["A2"] = 100; sh["B2"] = 60; sh["C2"] = 40
- sh["A3"] = 200; sh["B3"] = 90; sh["C3"] = 110
-
- XLSX.addtable!(sh, "A1:C3"; name="PnL")
- XLSX.settotals!(sh, "PnL",
- "revenue" => :sum,
- "cost" => "Total", # label only: no formula -> exercises the skip path
- "margin" => (:custom, "SUBTOTAL(109,PnL[revenue])-SUBTOTAL(109,PnL[cost])"),
- )
-
- @test XLSX.table(sh, "PnL").ref == XLSX.CellRange("A1:C4")
-
- XLSX.deletetable!(sh, "PnL")
- @test isempty(XLSX.tables(sh))
-
- # Single-column reference: PnL[revenue] -> Sheet1!$A$2:$A$3
- fa = XLSX.getFormula(sh, "A4")
- @test !occursin("PnL[", fa)
- @test occursin("\$A\$2:\$A\$3", fa)
- @test occursin(sh.name, fa)
-
- # Custom formula referencing *two* columns: both must be substituted,
- # and the surrounding formula left intact.
- fc = XLSX.getFormula(sh, "C4")
- @test !occursin("PnL[", fc)
- @test occursin("\$A\$2:\$A\$3", fc)
- @test occursin("\$B\$2:\$B\$3", fc)
- @test occursin("SUBTOTAL(109,", fc)
- @test count("SUBTOTAL(109,", fc) == 2
-
- # Label column had no formula: value untouched, nothing rewritten.
- @test sh["B4"] == "Total"
-
- # Cell data survives the delete, as documented.
- @test sh["A2"] == 100
- @test sh["C3"] == 110
- SAVE_FILES && save_outfile(f)
- end
+ @testset "enable_cache=false: sheet-scoped" begin
+ outfile = make_multitable_file("readtable_tablename_nocache_scoped.xlsx")
- @testset "deletetable! - totals row with a column that has no totals at all" begin
- # Covers the `c isa EmptyCell` half of the skip condition: column "b"
- # has a genuinely empty totals cell.
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
+ dt = XLSX.readtable(outfile, "Second"; table_name="TableTwo", enable_cache=false)
+ @test dt.column_labels == [:id, :name, :score]
+ @test dt.data[1] == [1, 2, 3]
+ @test dt.data[2] == ["alice", "bob", "carol"]
+ @test dt.data[3] == [10.5, 20.0, 15.25]
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.settotals!(sh, "T", "a" => :sum) # b left with no totals content
+ isfile(outfile) && rm(outfile)
+ end
- XLSX.deletetable!(sh, "T")
+ @testset "enable_cache=false: workbook-wide (scans every sheet's tableParts)" begin
+ outfile = make_multitable_file("readtable_tablename_nocache_wide.xlsx")
- @test !occursin("T[", XLSX.getFormula(sh, "A3"))
- @test ismissing(sh["B3"])
- @test isempty(XLSX.tables(sh))
- SAVE_FILES && save_outfile(f)
- end
+ # This is the least-exercised corner: no target_sheet, no cache, so
+ # every sheet's is cursor-scanned via
+ # open_internal_file_stream rather than read from xf.data.
+ dt = XLSX.readtable(outfile; table_name="TableTwo", enable_cache=false)
+ @test dt.column_labels == [:id, :name, :score]
+ @test dt.data[1] == [1, 2, 3]
- @testset "deletetable! - sheet name needing quoting" begin
- # Exercises quoteit's quoting branch in the static-range substitution.
- f = XLSX.newxlsx()
- sh = XLSX.addsheet!(f, "My Data")
- sh["A1"] = "amount"
- sh["A2"] = 10
- sh["A3"] = 20
-
- XLSX.addtable!(sh, "A1:A3"; name="Amounts")
- XLSX.settotals!(sh, "Amounts", "amount" => :sum)
- XLSX.deletetable!(sh, "Amounts")
-
- fa = XLSX.getFormula(sh, "A4")
- @test !occursin("Amounts[", fa)
- @test occursin("My Data", fa)
- @test occursin("\$A\$2:\$A\$3", fa)
- SAVE_FILES && save_outfile(f)
- end
+ # also reach the first and last sheets under the same conditions
+ dt1 = XLSX.readtable(outfile; table_name="TableOne", enable_cache=false)
+ @test dt1.data[1] == [1, 2]
- @testset "deletetable! - round trip after totals rewrite" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "n"
- sh["A2"] = 1
- sh["A3"] = 2
- XLSX.addtable!(sh, "A1:A3"; name="T")
- XLSX.settotals!(sh, "T", "n" => :sum)
- XLSX.deletetable!(sh, "T")
- SAVE_FILES && save_outfile(f)
-
- outfile = "deletetable_totals_roundtrip.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
-
- XLSX.openxlsx(outfile) do xf2
- sh2 = xf2[1]
- @test isempty(XLSX.tables(sh2))
- @test !occursin("T[", XLSX.getFormula(sh2, "A4"))
- @test sh2["A2"] == 1
- end
-
- isfile(outfile) && rm(outfile)
- end
+ dt3 = XLSX.readtable(outfile; table_name="TableThree", enable_cache=false)
+ @test dt3.data[1] == [5]
+ isfile(outfile) && rm(outfile)
+ end
- # ===========================================================================
- # settotals! — :none
- # ===========================================================================
+ @testset "enable_cache=false agrees with enable_cache=true" begin
+ outfile = make_multitable_file("readtable_tablename_cachemodes.xlsx")
- @testset "settotals! - :none clears a column's totals, row survives" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
+ dt_cached = XLSX.readtable(outfile; table_name="TableTwo", enable_cache=true)
+ dt_nocache = XLSX.readtable(outfile; table_name="TableTwo", enable_cache=false)
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.settotals!(sh, "T", "a" => :sum, "b" => "Label")
- @test sh["B3"] == "Label"
+ @test dt_cached.column_labels == dt_nocache.column_labels
+ @test dt_cached.data == dt_nocache.data
- t = XLSX.settotals!(sh, "T", "a" => :none, "b" => :none)
+ isfile(outfile) && rm(outfile)
+ end
- func_a, label_a = _totals_col_attrs(sh, "T", "a")
- func_b, label_b = _totals_col_attrs(sh, "T", "b")
- @test isnothing(func_a); @test isnothing(label_a)
- @test isnothing(func_b); @test isnothing(label_b)
- @test ismissing(sh["A3"])
- @test ismissing(sh["B3"])
+ @testset "infer_eltypes / normalizenames / missing_strings still apply" begin
+ outfile = "readtable_tablename_kwargs.xlsx"
- # an entirely empty totals row is still a totals row
- @test t.has_totals_row == true
- @test t.ref == XLSX.CellRange("A1:B3")
- SAVE_FILES && save_outfile(f)
- end
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "col one"; sh["B1"] = "value"
+ sh["A2"] = "x"; sh["B2"] = 1.5
+ sh["A3"] = "N/A"; sh["B3"] = 2.5
+ XLSX.addtable!(sh, "A1:B3"; name="T")
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
- @testset "settotals! - :none on a table with no totals row still adds one" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
-
- t = XLSX.settotals!(sh, "T", "a" => :none)
- @test t.has_totals_row == true
- @test t.ref == XLSX.CellRange("A1:B3")
- @test ismissing(sh["A3"])
- SAVE_FILES && save_outfile(f)
- end
+ # infer_eltypes=true (default) narrows columns
+ dt = XLSX.readtable(outfile; table_name="T")
+ @test eltype(dt.data[2]) != Any
+ @test eltype(dt.data[2]) <: Union{Missing,Float64}
- @testset "appendtable! - :none columns stay clear after the totals row moves" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.settotals!(sh, "T", "a" => :sum, "b" => :none)
-
- XLSX.appendtable!(sh, "T", [(3, 4)])
-
- t = XLSX.table(sh, "T")
- totals_row = t.ref.stop.row_number
- @test occursin("SUBTOTAL(109", XLSX.getFormula(sh, XLSX.CellRef(totals_row, 1)))
- @test ismissing(sh[XLSX.CellRef(totals_row, 2)])
- SAVE_FILES && save_outfile(f)
- end
+ # infer_eltypes=false leaves them as Any
+ dt_any = XLSX.readtable(outfile; table_name="T", infer_eltypes=false)
+ @test eltype(dt_any.data[2]) == Any
+ # normalizenames turns "col one" into a valid identifier
+ dt_norm = XLSX.readtable(outfile; table_name="T", normalizenames=true)
+ @test dt_norm.column_labels[1] == :col_one
- # ===========================================================================
- # Workbook-scoped lookups (never compiled -> absent from lcov entirely)
- # ===========================================================================
+ # without normalizenames, the label keeps its space
+ @test dt.column_labels[1] == Symbol("col one")
- @testset "tables(xf) - every sheet, in order" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- all_t = XLSX.tables(xf)
+ # missing_strings converts "N/A" to missing
+ dt_miss = XLSX.readtable(outfile; table_name="T", missing_strings="N/A")
+ @test ismissing(dt_miss.data[1][2])
+ @test dt_miss.data[1][1] == "x"
- @test length(all_t) == 3
- @test [t.name for t in all_t] == ["IO_Table", "Age_height", "with_total"]
- @test [t.sheet.name for t in all_t] == ["Sheet1", "Sheet1", "Sheet2"]
- @test all(t -> t isa XLSX.Table, all_t)
+ isfile(outfile) && rm(outfile)
end
- end
-
- @testset "tables(xf) - workbook with no tables" begin
- xf = XLSX.newxlsx()
- @test XLSX.tables(xf) == XLSX.Table[]
- SAVE_FILES && save_outfile(xf)
- end
- @testset "table(xf, name) / table(xf, id)" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- t = XLSX.table(xf, "with_total")
- @test t.sheet.name == "Sheet2"
- @test t.ref == XLSX.CellRange("A3:C11")
+ @testset "DataFrame round trip via readtable(; table_name)" begin
+ outfile = make_multitable_file("readtable_tablename_df.xlsx")
- @test XLSX.table(xf, "IO_Table").id == 1
- @test XLSX.table(xf, 2).name == "Age_height"
- @test XLSX.table(xf, 3).name == "with_total"
+ df = DataFrames.DataFrame(XLSX.readtable(outfile, "Second"; table_name="TableTwo"))
+ @test size(df) == (3, 3)
+ @test names(df) == ["id", "name", "score"]
+ @test df.score == [10.5, 20.0, 15.25]
+ @test eltype(df.score) != Any
- @test_throws KeyError XLSX.table(xf, "NoSuchTable")
- @test_throws KeyError XLSX.table(xf, 999)
+ isfile(outfile) && rm(outfile)
end
- end
- @testset "table(wb, name) / table(wb, id)" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- wb = XLSX.get_workbook(xf)
+ @testset "errors: table not found" begin
+ outfile = make_multitable_file("readtable_tablename_notfound.xlsx")
- @test XLSX.table(wb, "Age_height").ref == XLSX.CellRange("E1:G5")
- @test XLSX.table(wb, 1).name == "IO_Table"
+ # workbook-wide: no such table anywhere
+ @test_throws XLSX.XLSXError XLSX.readtable(outfile; table_name="NoSuchTable")
- @test_throws KeyError XLSX.table(wb, "NoSuchTable")
- @test_throws KeyError XLSX.table(wb, 999)
- end
- end
+ # sheet-scoped: table exists, but not on the named sheet
+ @test_throws KeyError XLSX.readtable(outfile, "First"; table_name="TableTwo")
- @testset "workbook-wide lookup agrees with sheet-scoped" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- @test tables_equal(XLSX.table(xf, "with_total"),
- XLSX.table(xf["Sheet2"], "with_total"))
+ isfile(outfile) && rm(outfile)
end
- end
+ @testset "errors: table_name cannot combine with a columns range" begin
+ outfile = make_multitable_file("readtable_tablename_conflict.xlsx")
- # ===========================================================================
- # _normalize_append_rows — non-schema Tables.jl sources
- # ===========================================================================
+ @test_throws XLSX.XLSXError XLSX.readtable(outfile, "Second", "A:C"; table_name="TableTwo")
+ @test_throws XLSX.XLSXError XLSX.readtable(outfile, "Second", XLSX.ColumnRange("A:C"); table_name="TableTwo")
- @testset "appendtable! - source without a schema, matched by columnnames" begin
- sh = fresh_abc() # table "T" over A1:C2, columns a, b, c
+ isfile(outfile) && rm(outfile)
+ end
- # scrambled order, and no Tables.schema — forces the columnnames fallback
- src = NoSchemaCols((c=[30, 60], a=[10, 40], b=[20, 50]))
- t = XLSX.appendtable!(sh, "T", src)
+ @testset "table with a totals row: totals excluded" begin
+ outfile = "readtable_tablename_totals.xlsx"
- @test t.ref == XLSX.CellRange("A1:C4")
- cols = Tables.columns(t)
- @test cols.a == [1, 10, 40]
- @test cols.b == [2, 20, 50]
- @test cols.c == [3, 30, 60]
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- end
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ sh["A3"] = "Pears"; sh["B3"] = 8
+ XLSX.addtable!(sh, "A1:B3"; name="Bulk")
+ XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
- @testset "appendtable! - schemaless source with wrong columns still errors" begin
- sh = fresh_abc()
- @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T", NoSchemaCols((a=[1], b=[2])))
- @test_throws XLSX.XLSXError XLSX.appendtable!(sh, "T",
- NoSchemaCols((a=[1], b=[2], c=[3], d=[4])))
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- end
+ for cache in (true, false)
+ dt = XLSX.readtable(outfile; table_name="Bulk", enable_cache=cache)
+ @test length(dt.data[1]) == 2 # totals row excluded
+ @test dt.data[1] == ["Apples", "Pears"]
+ @test dt.data[2] == [12, 8]
+ end
- @testset "appendtable! - nameless Tables.jl source falls back to positional" begin
- sh = fresh_abc()
+ isfile(outfile) && rm(outfile)
+ end
- src = NoNamesSource([Any[10, 20, 30], Any[40, 50, 60]])
- t = XLSX.appendtable!(sh, "T", src)
+ @testset "table with a blank row: row preserved" begin
+ outfile = "readtable_tablename_blank.xlsx"
- @test t.ref == XLSX.CellRange("A1:C4")
- cols = Tables.columns(t)
- @test cols.a == [1, 10, 40]
- @test cols.b == [2, 20, 50]
- @test cols.c == [3, 30, 60]
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(sh))
- end
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 10
+ # row 3 blank
+ sh["A4"] = 3; sh["B4"] = 30
+ XLSX.addtable!(sh, "A1:B4"; name="T")
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ for cache in (true, false)
+ dt = XLSX.readtable(outfile; table_name="T", enable_cache=cache)
+ @test length(dt.data[1]) == 3 # blank row still present
+ @test ismissing(dt.data[1][2])
+ @test ismissing(dt.data[2][2])
+ end
- # ===========================================================================
- # Writability guards
- # ===========================================================================
+ isfile(outfile) && rm(outfile)
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "read-only file rejects every mutating table call" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- sh = xf["Sheet1"]
+ @testset "existing readtable behaviour unaffected when table_name omitted" begin
+ outfile = make_multitable_file("readtable_tablename_regression.xlsx")
- @test_throws XLSX.XLSXError XLSX.addtable!(sh, "J1:K2"; name="Nope")
- @test_throws XLSX.XLSXError XLSX.deletetable!(sh, "IO_Table")
- @test_throws XLSX.XLSXError XLSX.deletetable!(sh, 1)
- @test_throws XLSX.XLSXError XLSX.settotals!(sh, "IO_Table", "id" => :sum)
- @test_throws XLSX.XLSXError XLSX.settotals!(sh, 1, "id" => :sum)
+ # plain range-inference read of the same sheet still works; it stops at
+ # the empty row 5, so the "below" data in row 6 is not read
+ dt_plain = XLSX.readtable(outfile, "Second")
+ @test dt_plain isa XLSX.DataTable
+ @test dt_plain.column_labels == [:id, :name, :score]
+ @test dt_plain.data[1] == [1, 2, 3]
- # nothing was mutated by the failed attempts
- @test length(XLSX.tables(sh)) == 2
+ isfile(outfile) && rm(outfile)
end
- end
+ @testset "multiple spaces collapse to a single underscore" begin
+ outfile = "readtable_normalize_multispace.xlsx"
- # ===========================================================================
- # Parser error paths (branch coverage on `cond && throw(...)` one-liners)
- # ===========================================================================
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "col one"; sh["B1"] = "col two" # 2 and 3 spaces
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.writexlsx(outfile, f, overwrite=true)
+ SAVE_FILES && save_outfile(outfile)
- @testset "parse_table_xml - malformed parts" begin
- f = XLSX.newxlsx()
- sh = f[1]
+ dt = XLSX.readtable(outfile; table_name="T", normalizenames=true)
+ @test dt.column_labels == [:col_one, :col_two]
- # root element is not
- @test_throws XLSX.XLSXError XLSX.parse_table_xml(
- _tbl_doc(""), "bad.xml", sh)
+ # without normalizenames, the original spacing is preserved
+ dt_raw = XLSX.readtable(outfile; table_name="T")
+ @test dt_raw.column_labels == [Symbol("col one"), Symbol("col two")]
- # with no attributes at all
- @test_throws XLSX.XLSXError XLSX.parse_table_xml(
- _tbl_doc(""""""), "bad.xml", sh)
+ isfile(outfile) && rm(outfile)
+ end
- # missing each required attribute in turn
- @test_throws XLSX.XLSXError XLSX.parse_table_xml(
- _tbl_doc(""""""), "bad.xml", sh)
- @test_throws XLSX.XLSXError XLSX.parse_table_xml(
- _tbl_doc(""""""), "bad.xml", sh)
- @test_throws XLSX.XLSXError XLSX.parse_table_xml(
- _tbl_doc(""""""), "bad.xml", sh)
+ @testset "header starting with a digit gets a leading underscore" begin
+ outfile = "readtable_normalize_digit.xlsx"
- # no element
- @test_throws XLSX.XLSXError XLSX.parse_table_xml(
- _tbl_doc(""""""),
- "bad.xml", sh)
- SAVE_FILES && save_outfile(f)
- end
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "2024"; sh["B1"] = "2025 total"
+ sh["A2"] = 10; sh["B2"] = 20
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.writexlsx(outfile, f, overwrite=true)
- @testset "parse_table_columns - without a name" begin
- doc = _tbl_doc("""""")
- @test_throws XLSX.XLSXError XLSX.parse_table_columns(doc)
- end
+ dt = XLSX.readtable(outfile; table_name="T", normalizenames=true)
+ @test dt.column_labels == [:_2024, :_2025_total]
- @testset "parse_table_columns - non-tableColumn children are skipped" begin
- doc = _tbl_doc("""""")
- @test XLSX.parse_table_columns(doc) == ["a", "b"]
- end
+ isfile(outfile) && rm(outfile)
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "parse_table_xml - totals and header rows follow the count attributes" begin
- # totalsRowCount is the number of totals rows in ref now; totalsRowShown is
- # history (has one ever been shown) and says nothing about the current range.
- # Checked against Excel: a table whose totals row was switched on then off
- # carries neither attribute, and one that never had totals has
- # totalsRowShown="0".
- f = XLSX.newxlsx()
- sh = f[1]
- base = """"""
- tbl(attrs) = XLSX.parse_table_xml(_tbl_doc(
- """"""),
- "t.xml", sh)
-
- @test tbl("""totalsRowCount="1\"""").has_totals_row == true
- @test tbl("""totalsRowShown="1" totalsRowCount="1\"""").has_totals_row == true # as XLSX.jl writes it
- @test tbl("""totalsRowShown="1\"""").has_totals_row == false # shown once, not now
- @test tbl("""totalsRowShown="0\"""").has_totals_row == false # never had one
- @test tbl("").has_totals_row == false # on, then off, in Excel
-
- @test tbl("").has_header_row == true # headerRowCount defaults to 1
- @test tbl("""headerRowCount="1\"""").has_header_row == true
- @test tbl("""headerRowCount="0\"""").has_header_row == false # header row hidden
-
- # displayName defaults to name when absent
- @test tbl("").display_name == "T"
- SAVE_FILES && save_outfile(f)
- end
+ @testset "reserved Julia keyword header gets a leading underscore" begin
+ outfile = "readtable_normalize_reserved.xlsx"
- @testset "parse_table_style_info - element present but bare" begin
- doc = _tbl_doc("""""")
- s = XLSX.parse_table_style_info(doc)
-
- @test !isnothing(s)
- @test isnothing(s.name)
- @test s.show_first_column == false
- @test s.show_last_column == false
- @test s.show_row_stripes == false
- @test s.show_column_stripes == false
- end
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "end"; sh["B1"] = "function"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.writexlsx(outfile, f, overwrite=true)
- @testset "parse_table_style_info - absent returns nothing" begin
- doc = _tbl_doc("""""")
- @test isnothing(XLSX.parse_table_style_info(doc))
- end
+ dt = XLSX.readtable(outfile; table_name="T", normalizenames=true)
+ @test dt.column_labels == [:_end, :_function]
- @testset "parse_totals_settings - unrecognized totalsRowFunction errors" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.settotals!(sh, "T", "b" => :sum)
-
- table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "T"))
- i, j = XLSX.get_idces(table_doc, "table", "tableColumns")
- node = collect(XLSX.xml_elements(table_doc[i][j]))[2]
- node["totalsRowFunction"] = "notAFunction"
- sh.tables_cache = nothing
-
- @test_throws XLSX.XLSXError XLSX.parse_totals_settings(sh, XLSX.table(sh, "T"))
- end
+ isfile(outfile) && rm(outfile)
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "parse_totals_settings - custom function with no formula errors" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.settotals!(sh, "T", "b" => (:custom, "SUBTOTAL(109,T[a])"))
+ @testset "gettable(t) directly honours normalizenames" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "col one"; sh["B1"] = "already_ok"
+ sh["A2"] = 1; sh["B2"] = 2
+ t = XLSX.addtable!(sh, "A1:B2"; name="T")
- # blank the formula cell while leaving totalsRowFunction="custom" in place
- sh["B3"] = missing
- sh.tables_cache = nothing
+ dt = XLSX.gettable(t; normalizenames=true)
+ @test dt.column_labels == [:col_one, :already_ok]
- @test_throws XLSX.XLSXError XLSX.parse_totals_settings(sh, XLSX.table(sh, "T"))
- SAVE_FILES && save_outfile(f)
+ dt_raw = XLSX.gettable(t)
+ @test dt_raw.column_labels == [Symbol("col one"), :already_ok]
+ SAVE_FILES && save_outfile(f)
+ end
end
- @testset "parse_totals_settings - label-only column is returned as a String" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.settotals!(sh, "T", "a" => "Total", "b" => :sum)
-
- settings = Dict(XLSX.parse_totals_settings(sh, XLSX.table(sh, "T")))
- @test settings["a"] == "Total"
- @test settings["b"] === :sum
- SAVE_FILES && save_outfile(f)
- end
+ @testset "readto(; table_name)" begin
+ @testset "readto(source, sheet, sink; table_name=...)" begin
+ outfile = make_readto_file("readto_tablename_scoped.xlsx")
+ SAVE_FILES && save_outfile(outfile)
+ df = XLSX.readto(outfile, "Second", DataFrames.DataFrame; table_name="TableTwo")
- # ===========================================================================
- # Small helpers
- # ===========================================================================
+ @test df isa DataFrames.DataFrame
+ @test size(df) == (3, 3)
+ @test DataFrames.names(df) == ["id", "name", "score"]
+ @test df.id == [1, 2, 3]
+ @test df.name == ["alice", "bob", "carol"]
+ @test df.score == [10.5, 20.0, 15.25]
+ @test eltype(df.score) != Any
- @testset "_is_valid_table_display_name" begin
- @test XLSX._is_valid_table_display_name("Table1")
- @test XLSX._is_valid_table_display_name("_leading")
- @test XLSX._is_valid_table_display_name("with.periods")
- @test XLSX._is_valid_table_display_name("Ünicode")
+ isfile(outfile) && rm(outfile)
+ end
- @test !XLSX._is_valid_table_display_name("")
- @test !XLSX._is_valid_table_display_name("1leading")
- @test !XLSX._is_valid_table_display_name("has space")
- @test !XLSX._is_valid_table_display_name("has-hyphen")
- end
+ @testset "readto(source, sink; table_name=...) - workbook-wide" begin
+ outfile = make_readto_file("readto_tablename_wide.xlsx")
- @testset "remove_attr! - node with no attributes, and absent key" begin
- bare = XLSX.XML.Element("x")
- @test isnothing(XLSX.remove_attr!(bare, "anything"))
-
- node = XLSX.XML.Element("x"; a="1", b="2")
- @test isnothing(XLSX.remove_attr!(node, "notThere"))
- @test XLSX.get_attr(node, "a") == "1"
- @test XLSX.get_attr(node, "b") == "2"
-
- XLSX.remove_attr!(node, "a")
- @test XLSX.get_attr(node, "a", "") == ""
- @test !haskey(XML.attributes(node), "a")
- @test XLSX.get_attr(node, "b") == "2"end
-
- @testset "TableRow getindex with a vector or range of columns" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"; sh["C1"] = "c"
- sh["A2"] = 1; sh["B2"] = 2; sh["C2"] = 3
-
- r = first(XLSX.eachtablerow(sh))
- @test r[1:2] == [1, 2]
- @test r[[1, 3]] == [1, 3]
- @test r[1:3] == [1, 2, 3]
- SAVE_FILES && save_outfile(f)
- end
+ # table on the second sheet, found without naming the sheet
+ df = XLSX.readto(outfile, DataFrames.DataFrame; table_name="TableTwo")
+ @test size(df) == (3, 3)
+ @test df.id == [1, 2, 3]
- @testset "eachtablerow(sheet) - leading row whose cells all read missing" begin
- # Covers the `isnothing(ci)` skip: row 1 has cells present in the XML but
- # every value reads back as `missing`, so it can't anchor the table.
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = 1; sh["B1"] = 2
- sh["A1"] = missing; sh["B1"] = missing
- sh["A2"] = "a"; sh["B2"] = "b"
- sh["A3"] = 1; sh["B3"] = 2
-
- dt = XLSX.gettable(sh)
- @test dt.column_labels == [:a, :b]
- @test dt.data[1] == [1]
- SAVE_FILES && save_outfile(f)
- end
+ # table on the first sheet
+ df1 = XLSX.readto(outfile, DataFrames.DataFrame; table_name="TableOne")
+ @test size(df1) == (2, 2)
+ @test df1.a == [1, 2]
- @testset "removes the totals row and shrinks ref" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- sh["A3"] = "Pears"; sh["B3"] = 8
- XLSX.addtable!(sh, "A1:B3"; name="Bulk")
- XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
+ isfile(outfile) && rm(outfile)
+ end
- @test XLSX.table(sh, "Bulk").ref == XLSX.CellRange("A1:B4")
- @test XLSX.table(sh, "Bulk").has_totals_row == true
+ @testset "readto by sheet index" begin
+ outfile = make_readto_file("readto_tablename_idx.xlsx")
- t = XLSX.removetotals!(sh, "Bulk")
+ df = XLSX.readto(outfile, 2, DataFrames.DataFrame; table_name="TableTwo")
+ @test DataFrames.names(df) == ["id", "name", "score"]
+ @test df.id == [1, 2, 3]
- @test t.ref == XLSX.CellRange("A1:B3")
- @test t.has_totals_row == false
+ isfile(outfile) && rm(outfile)
+ end
- # the old totals row cells are cleared
- @test ismissing(sh["A4"])
- @test ismissing(sh["B4"])
+ @testset "readto excludes data outside the table's ref" begin
+ outfile = make_readto_file("readto_tablename_bounds.xlsx")
- # data rows untouched
- cols = Tables.columns(t)
- @test cols.item == ["Apples", "Pears"]
- @test cols.amount == [12, 8]
- SAVE_FILES && save_outfile(f)
- end
+ df = XLSX.readto(outfile, "Second", DataFrames.DataFrame; table_name="TableTwo")
+ @test DataFrames.ncol(df) == 3
+ @test "outside" ∉ DataFrames.names(df)
+ @test DataFrames.nrow(df) == 3
- @testset "drops totalsRowShown / totalsRowCount from the table part" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.settotals!(sh, "T", "b" => :sum)
-
- table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "T"))
- root = XLSX.xml_root_element(table_doc)
- @test XLSX.get_attr(root, "totalsRowShown", "") != ""
- @test XLSX.get_attr(root, "totalsRowCount", "") != ""
-
- XLSX.removetotals!(sh, "T")
-
- @test XLSX.get_attr(root, "totalsRowShown", "") == ""
- @test XLSX.get_attr(root, "totalsRowCount", "") == ""
- @test XLSX.get_attr(root, "ref") == "A1:B2"
- SAVE_FILES && save_outfile(f)
- end
+ isfile(outfile) && rm(outfile)
+ end
- @testset "drops per-column totalsRowFunction / totalsRowLabel" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "label"; sh["B1"] = "num"; sh["C1"] = "other"
- sh["A2"] = "x"; sh["B2"] = 10; sh["C2"] = 20
- XLSX.addtable!(sh, "A1:C2"; name="T")
- XLSX.settotals!(sh, "T", "label" => "Total", "num" => :sum)
+ @testset "readto scoped and workbook-wide agree" begin
+ outfile = make_readto_file("readto_tablename_agree.xlsx")
- XLSX.removetotals!(sh, "T")
+ df_scoped = XLSX.readto(outfile, "Second", DataFrames.DataFrame; table_name="TableTwo")
+ df_wide = XLSX.readto(outfile, DataFrames.DataFrame; table_name="TableTwo")
+ @test isequal(df_scoped, df_wide)
- table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "T"))
- i, j = XLSX.get_idces(table_doc, "table", "tableColumns")
- for node in XLSX.xml_elements(table_doc[i][j])
- XLSX.localname(node) == "tableColumn" || continue
- @test XLSX.get_attr(node, "totalsRowFunction", "") == ""
- @test XLSX.get_attr(node, "totalsRowLabel", "") == ""
+ isfile(outfile) && rm(outfile)
end
- SAVE_FILES && save_outfile(f)
- end
- @testset "clears a custom totals formula" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "revenue"; sh["B1"] = "cost"; sh["C1"] = "margin"
- sh["A2"] = 100; sh["B2"] = 60; sh["C2"] = 40
- XLSX.addtable!(sh, "A1:C2"; name="PnL")
- XLSX.settotals!(sh, "PnL",
- "revenue" => :sum,
- "margin" => (:custom, "SUBTOTAL(109,PnL[revenue])-SUBTOTAL(109,PnL[cost])"),
- )
-
- @test !isnothing(XLSX.getFormula(sh, "C3"))
-
- XLSX.removetotals!(sh, "PnL")
-
- @test XLSX.table(sh, "PnL").ref == XLSX.CellRange("A1:C2")
- @test ismissing(sh["A3"])
- @test ismissing(sh["C3"])
- SAVE_FILES && save_outfile(f)
- end
-
- @testset "no-op when the table has no totals row" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- t_before = XLSX.addtable!(sh, "A1:B2"; name="T")
+ @testset "readto with enable_cache=false" begin
+ outfile = make_readto_file("readto_tablename_nocache.xlsx")
- t_after = XLSX.removetotals!(sh, "T")
+ df_scoped = XLSX.readto(outfile, "Second", DataFrames.DataFrame;
+ table_name="TableTwo", enable_cache=false)
+ @test size(df_scoped) == (3, 3)
+ @test df_scoped.name == ["alice", "bob", "carol"]
- @test t_after.ref == t_before.ref
- @test t_after.has_totals_row == false
- @test sh["A2"] == 1 # nothing cleared
- @test sh["B2"] == 2
- SAVE_FILES && save_outfile(f)
- end
+ df_wide = XLSX.readto(outfile, DataFrames.DataFrame;
+ table_name="TableTwo", enable_cache=false)
+ @test isequal(df_scoped, df_wide)
- @testset "by id" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- tbl = XLSX.addtable!(sh, "A1:B2"; name="ById")
- XLSX.settotals!(sh, "ById", "b" => :sum)
-
- t = XLSX.removetotals!(sh, tbl.id)
- @test t.has_totals_row == false
- @test t.ref == XLSX.CellRange("A1:B2")
- SAVE_FILES && save_outfile(f)
- end
+ # and agrees with the cached read
+ df_cached = XLSX.readto(outfile, DataFrames.DataFrame; table_name="TableTwo")
+ @test isequal(df_cached, df_wide)
- @testset "table not found errors" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
+ isfile(outfile) && rm(outfile)
+ end
- @test_throws KeyError XLSX.removetotals!(sh, "NoSuchTable")
- SAVE_FILES && save_outfile(f)
- end
+ @testset "readto passes normalizenames / missing_strings through" begin
+ outfile = "readto_tablename_kwargs.xlsx"
- @testset "not writable errors" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf # read-only
- sh2 = xf["Sheet2"]
- @test_throws XLSX.XLSXError XLSX.removetotals!(sh2, "with_total")
- end
- end
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "col one"; sh["B1"] = "value"
+ sh["A2"] = "x"; sh["B2"] = 1.5
+ sh["A3"] = "N/A"; sh["B3"] = 2.5
+ XLSX.addtable!(sh, "A1:B3"; name="T")
+ XLSX.writexlsx(outfile, f, overwrite=true)
- @testset "settotals! can add a totals row back afterwards" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- sh["A3"] = "Pears"; sh["B3"] = 8
- XLSX.addtable!(sh, "A1:B3"; name="Bulk")
-
- XLSX.settotals!(sh, "Bulk", "amount" => :sum)
- @test XLSX.table(sh, "Bulk").ref == XLSX.CellRange("A1:B4")
-
- XLSX.removetotals!(sh, "Bulk")
- @test XLSX.table(sh, "Bulk").ref == XLSX.CellRange("A1:B3")
-
- # row 4 must be empty again, so settotals! can re-extend into it
- t = XLSX.settotals!(sh, "Bulk", "amount" => :average)
- @test t.ref == XLSX.CellRange("A1:B4")
- @test t.has_totals_row == true
- @test occursin("SUBTOTAL(101", XLSX.getFormula(sh, "B4"))
- SAVE_FILES && save_outfile(f)
- end
+ df_norm = XLSX.readto(outfile, DataFrames.DataFrame; table_name="T", normalizenames=true)
+ @test DataFrames.names(df_norm)[1] == "col_one"
- @testset "appendtable! works after removing totals" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 10
- XLSX.addtable!(sh, "A1:B2"; name="T")
- XLSX.settotals!(sh, "T", "b" => :sum)
- XLSX.removetotals!(sh, "T")
-
- t = XLSX.appendtable!(sh, "T", [(2, 20), (3, 30)])
- @test t.ref == XLSX.CellRange("A1:B4")
- @test t.has_totals_row == false
- @test Tables.columns(t).a == [1, 2, 3]
- SAVE_FILES && save_outfile(f)
- end
+ df_miss = XLSX.readto(outfile, DataFrames.DataFrame; table_name="T", missing_strings="N/A")
+ @test ismissing(df_miss[2, 1])
- @testset "round trip: remove totals, save and reopen" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- sh["A3"] = "Pears"; sh["B3"] = 8
- XLSX.addtable!(sh, "A1:B3"; name="Bulk", style="TableStyleMedium2")
- XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
- XLSX.removetotals!(sh, "Bulk")
- SAVE_FILES && save_outfile(f)
-
- outfile = "removetotals_roundtrip.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
-
- XLSX.openxlsx(outfile) do xf2
- t = XLSX.table(xf2[1], "Bulk")
- @test t.ref == XLSX.CellRange("A1:B3")
- @test t.has_totals_row == false
- @test t.style.name == "TableStyleMedium2" # style survives
- cols = Tables.columns(t)
- @test cols.item == ["Apples", "Pears"]
- @test cols.amount == [12, 8]
+ isfile(outfile) && rm(outfile)
+ SAVE_FILES && save_outfile(f)
end
- XLSX.openxlsx(outfile, enable_cache=false) do xf2
- t = XLSX.table(xf2[1], "Bulk")
- @test t.ref == XLSX.CellRange("A1:B3")
- @test t.has_totals_row == false
- end
+ @testset "readto with a totals row: totals excluded" begin
+ outfile = "readto_tablename_totals.xlsx"
- isfile(outfile) && rm(outfile)
- end
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ sh["A3"] = "Pears"; sh["B3"] = 8
+ XLSX.addtable!(sh, "A1:B3"; name="Bulk")
+ XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
+ XLSX.writexlsx(outfile, f, overwrite=true)
- @testset "real fixture (two_tables.xlsx) - with_total" begin
- f = XLSX.opentemplate("data/two_tables.xlsx")
- sh2 = f["Sheet2"]
+ df = XLSX.readto(outfile, DataFrames.DataFrame; table_name="Bulk")
+ @test DataFrames.nrow(df) == 2
+ @test df.item == ["Apples", "Pears"]
+ @test df.amount == [12, 8]
- t_before = XLSX.table(sh2, "with_total")
- @test t_before.has_totals_row == true
- old_stop = t_before.ref.stop.row_number
+ isfile(outfile) && rm(outfile)
+ SAVE_FILES && save_outfile(f)
+ end
- t = XLSX.removetotals!(sh2, "with_total")
+ @testset "readto errors: table_name with a columns range" begin
+ outfile = make_readto_file("readto_tablename_conflict.xlsx")
- @test t.has_totals_row == false
- @test t.ref.stop.row_number == old_stop - 1
- @test t.columns == ["start", "stop", "sin"]
+ @test_throws XLSX.XLSXError XLSX.readto(outfile, "Second", "A:C",
+ DataFrames.DataFrame; table_name="TableTwo")
- # other sheet's tables unaffected
- @test length(XLSX.tables(f["Sheet1"])) == 2
- SAVE_FILES && save_outfile(f)
- end
+ isfile(outfile) && rm(outfile)
+ end
- @testset "empty NamedTuple when there is no totals row" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- t = XLSX.addtable!(sh, "A1:B2"; name="T")
-
- tot = XLSX.gettotals(t)
- @test tot isa NamedTuple
- @test isempty(tot)
- @test length(tot) == 0
- SAVE_FILES && save_outfile(f)
- end
+ @testset "readto errors: table not found" begin
+ outfile = make_readto_file("readto_tablename_notfound.xlsx")
- @testset "keys match the table's columns, in order" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "region"; sh["B1"] = "revenue"; sh["C1"] = "margin"
- sh["A2"] = "North"; sh["B2"] = 1000; sh["C2"] = 200
- XLSX.addtable!(sh, "A1:C2"; name="Sales")
- t = XLSX.settotals!(sh, "Sales", "region" => "Total", "revenue" => :sum)
-
- tot = XLSX.gettotals(t)
- @test collect(keys(tot)) == [:region, :revenue, :margin]
- @test length(tot) == 3
- SAVE_FILES && save_outfile(f)
- end
+ @test_throws XLSX.XLSXError XLSX.readto(outfile, DataFrames.DataFrame; table_name="NoSuchTable")
+ @test_throws KeyError XLSX.readto(outfile, "First", DataFrames.DataFrame; table_name="TableTwo")
- @testset "built-in function columns" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "s"; sh["B1"] = "av"
- sh["A2"] = 10; sh["B2"] = 20
- XLSX.addtable!(sh, "A1:B2"; name="T")
- t = XLSX.settotals!(sh, "T", "s" => :sum, "av" => :average)
-
- tot = XLSX.gettotals(t)
-
- @test tot.s.setting == :sum
- @test !isnothing(tot.s.formula)
- @test occursin("SUBTOTAL(109", tot.s.formula)
- @test occursin("T[s]", tot.s.formula)
- # XLSX.jl writes no cached value alongside a formula
- @test ismissing(tot.s.value)
-
- @test tot.av.setting == :average
- @test occursin("SUBTOTAL(101", tot.av.formula)
- @test ismissing(tot.av.value)
- SAVE_FILES && save_outfile(f)
- end
+ isfile(outfile) && rm(outfile)
+ end
- @testset "label column" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "name"; sh["B1"] = "score"
- sh["A2"] = "x"; sh["B2"] = 10
- XLSX.addtable!(sh, "A1:B2"; name="T")
- t = XLSX.settotals!(sh, "T", "name" => "Grand Total", "score" => :sum)
+ @testset "readto errors: missing sink" begin
+ outfile = make_readto_file("readto_tablename_nosink.xlsx")
- tot = XLSX.gettotals(t)
+ @test_throws XLSX.XLSXError XLSX.readto(outfile; table_name="TableTwo")
+ @test_throws XLSX.XLSXError XLSX.readto(outfile, "Second"; table_name="TableTwo")
- @test tot.name.setting == :label
- @test isnothing(tot.name.formula) # a label is a plain value
- @test tot.name.value == "Grand Total" # and the value is the label text
- SAVE_FILES && save_outfile(f)
- end
+ isfile(outfile) && rm(outfile)
+ end
- @testset "custom formula column" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "revenue"; sh["B1"] = "cost"; sh["C1"] = "margin"
- sh["A2"] = 100; sh["B2"] = 60; sh["C2"] = 40
- XLSX.addtable!(sh, "A1:C2"; name="PnL")
- t = XLSX.settotals!(sh, "PnL",
- "revenue" => :sum,
- "margin" => (:custom, "SUBTOTAL(109,PnL[revenue])-SUBTOTAL(109,PnL[cost])"),
- )
-
- tot = XLSX.gettotals(t)
-
- @test tot.margin.setting == :custom
- @test !isnothing(tot.margin.formula)
- @test occursin("PnL[revenue]", tot.margin.formula)
- @test occursin("PnL[cost]", tot.margin.formula)
- @test ismissing(tot.margin.value)
- SAVE_FILES && save_outfile(f)
- end
+ @testset "readto agrees with readtable for the same table" begin
+ outfile = make_readto_file("readto_tablename_vs_readtable.xlsx")
- @testset "columns with no totals setting" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"; sh["C1"] = "c"
- sh["A2"] = 1; sh["B2"] = 2; sh["C2"] = 3
- XLSX.addtable!(sh, "A1:C2"; name="T")
- t = XLSX.settotals!(sh, "T", "b" => :sum) # only b
+ df_readto = XLSX.readto(outfile, "Second", DataFrames.DataFrame; table_name="TableTwo")
+ df_readtable = DataFrames.DataFrame(XLSX.readtable(outfile, "Second"; table_name="TableTwo"))
+ @test isequal(df_readto, df_readtable)
- tot = XLSX.gettotals(t)
+ isfile(outfile) && rm(outfile)
+ end
- @test tot.a.setting == :none
- @test isnothing(tot.a.formula)
- @test ismissing(tot.a.value)
+ @testset "existing readto behaviour unaffected when table_name omitted" begin
+ outfile = make_readto_file("readto_tablename_regression.xlsx")
- @test tot.b.setting == :sum
+ df = XLSX.readto(outfile, "First", DataFrames.DataFrame)
+ @test df isa DataFrames.DataFrame
+ @test DataFrames.names(df) == ["a", "b"]
- @test tot.c.setting == :none
- @test isnothing(tot.c.formula)
- @test ismissing(tot.c.value)
- SAVE_FILES && save_outfile(f)
- end
+ df_cols = XLSX.readto(outfile, "Second", "A:B", DataFrames.DataFrame)
+ @test DataFrames.names(df_cols) == ["id", "name"]
- @testset "all built-in functions round trip through gettotals" begin
- f = XLSX.newxlsx()
- sh = f[1]
- cols = ["s", "av", "cnt", "cntnum", "mx", "mn", "sd", "vr"]
- for (i, c) in enumerate(cols)
- sh[1, i] = c
- end
- for i in 1:length(cols)
- sh[2, i] = 10 * i
- end
- XLSX.addtable!(sh, "A1:H2"; name="Funcs")
- t = XLSX.settotals!(sh, "Funcs";
- s=:sum, av=:average, cnt=:count, cntnum=:countnums,
- mx=:max, mn=:min, sd=:stddev, vr=:var)
-
- tot = XLSX.gettotals(t)
-
- @test tot.s.setting == :sum
- @test tot.av.setting == :average
- @test tot.cnt.setting == :count
- @test tot.cntnum.setting == :countnums
- @test tot.mx.setting == :max
- @test tot.mn.setting == :min
- @test tot.sd.setting == :stddev
- @test tot.vr.setting == :var
- SAVE_FILES && save_outfile(f)
+ isfile(outfile) && rm(outfile)
+ end
end
- @testset "reflects a changed setting" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
+ @testset "XLSXTableRow getindex" begin
+ @testset "index by integer, symbol and string" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "id"; sh["B1"] = "name"; sh["C1"] = "score"
+ sh["A2"] = 1; sh["B2"] = "alice"; sh["C2"] = 10.5
+ sh["A3"] = 2; sh["B3"] = "bob"; sh["C3"] = 20.0
- t = XLSX.settotals!(sh, "T", "b" => :sum)
- @test XLSX.gettotals(t).b.setting == :sum
+ t = XLSX.addtable!(sh, "A1:C3"; name="People")
+ rows = collect(XLSX.eachtablerow(t))
- t = XLSX.settotals!(sh, "T", "b" => :max)
- @test XLSX.gettotals(t).b.setting == :max
+ # by integer position
+ @test rows[1][1] == 1
+ @test rows[1][2] == "alice"
+ @test rows[1][3] == 10.5
+ @test rows[2][1] == 2
+
+ # by symbol
+ @test rows[1][:id] == 1
+ @test rows[1][:name] == "alice"
+ @test rows[1][:score] == 10.5
+ @test rows[2][:name] == "bob"
+
+ # by string
+ @test rows[1]["id"] == 1
+ @test rows[1]["name"] == "alice"
+ @test rows[2]["score"] == 20.0
+ SAVE_FILES && save_outfile(f)
+ end
- t = XLSX.settotals!(sh, "T", "b" => "Total")
- tot = XLSX.gettotals(t)
- @test tot.b.setting == :label
- @test tot.b.value == "Total"
- SAVE_FILES && save_outfile(f)
- end
+ @testset "all three index forms agree" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 10; sh["B2"] = 20
- @testset ":none clears a column back to no setting" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
-
- t = XLSX.settotals!(sh, "T", "a" => "Total", "b" => :sum)
- @test XLSX.gettotals(t).b.setting == :sum
-
- t = XLSX.settotals!(sh, "T", "b" => :none)
- tot = XLSX.gettotals(t)
- @test tot.b.setting == :none
- @test isnothing(tot.b.formula)
- @test ismissing(tot.b.value)
- @test tot.a.setting == :label # other column untouched
- SAVE_FILES && save_outfile(f)
- end
+ t = XLSX.addtable!(sh, "A1:B2"; name="T")
+ r = first(XLSX.eachtablerow(t))
- @testset "empty again after removetotals!" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "a"; sh["B1"] = "b"
- sh["A2"] = 1; sh["B2"] = 2
- XLSX.addtable!(sh, "A1:B2"; name="T")
- t = XLSX.settotals!(sh, "T", "b" => :sum)
- @test !isempty(XLSX.gettotals(t))
-
- t = XLSX.removetotals!(sh, "T")
- @test isempty(XLSX.gettotals(t))
- SAVE_FILES && save_outfile(f)
- end
+ @test r[1] == r[:a] == r["a"]
+ @test r[2] == r[:b] == r["b"]
+ SAVE_FILES && save_outfile(f)
+ end
- @testset "survives appendtable! (totals row moved)" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- XLSX.addtable!(sh, "A1:B2"; name="Bulk")
- XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
-
- t = XLSX.appendtable!(sh, "Bulk", [("Pears", 8), ("Cherries", 25)])
- tot = XLSX.gettotals(t)
-
- @test tot.item.setting == :label
- @test tot.item.value == "Total"
- @test tot.amount.setting == :sum
- @test occursin("SUBTOTAL(109", tot.amount.formula)
- SAVE_FILES && save_outfile(f)
- end
+ @testset "getindex agrees with Tables.getcolumn" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "x"; sh["B1"] = "y"
+ sh["A2"] = 1; sh["B2"] = "one"
+ sh["A3"] = 2; sh["B3"] = "two"
- @testset "round trip: save and reopen" begin
- f = XLSX.newxlsx()
- sh = f[1]
- sh["A1"] = "item"; sh["B1"] = "amount"
- sh["A2"] = "Apples"; sh["B2"] = 12
- sh["A3"] = "Pears"; sh["B3"] = 8
- XLSX.addtable!(sh, "A1:B3"; name="Bulk")
- XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
- SAVE_FILES && save_outfile(f)
-
- outfile = "gettotals_roundtrip.xlsx"
- XLSX.writexlsx(outfile, f, overwrite=true)
- SAVE_FILES && save_outfile(outfile)
-
- XLSX.openxlsx(outfile) do xf2
- t = XLSX.table(xf2[1], "Bulk")
- tot = XLSX.gettotals(t)
+ t = XLSX.addtable!(sh, "A1:B3"; name="T")
- @test tot.item.setting == :label
- @test tot.item.value == "Total"
- @test tot.amount.setting == :sum
- @test occursin("SUBTOTAL(109", tot.amount.formula)
+ for r in XLSX.eachtablerow(t)
+ @test r[1] == Tables.getcolumn(r, 1)
+ @test r[:x] == Tables.getcolumn(r, :x)
+ @test r["y"] == Tables.getcolumn(r, :y)
+ end
+ SAVE_FILES && save_outfile(f)
end
- isfile(outfile) && rm(outfile)
- end
-
- @testset "real fixture (two_tables.xlsx) - with_total" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- t = XLSX.table(xf["Sheet2"], "with_total")
- @test t.has_totals_row == true
+ @testset "column names with spaces are reachable by string" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "col one"; sh["B1"] = "col two"
+ sh["A2"] = 1; sh["B2"] = 2
- tot = XLSX.gettotals(t)
- @test collect(keys(tot)) == [:start, :stop, :sin]
- @test tot.sin.setting == :sum
+ t = XLSX.addtable!(sh, "A1:B2"; name="T")
+ r = first(XLSX.eachtablerow(t))
- # Excel-authored file: the totals cell carries a cached value
- # alongside its formula, unlike one XLSX.jl wrote.
- @test !isnothing(tot.sin.formula)
- @test !ismissing(tot.sin.value)
+ @test r["col one"] == 1
+ @test r["col two"] == 2
+ @test r[Symbol("col one")] == 1 # symbol form still works, just awkward
+ SAVE_FILES && save_outfile(f)
end
- end
- @testset "table with no totals on a sheet that has one elsewhere" begin
- XLSX.openxlsx("data/two_tables.xlsx") do xf
- t = XLSX.table(xf["Sheet1"], "IO_Table")
- @test t.has_totals_row == false
- @test isempty(XLSX.gettotals(t))
+ @testset "missing values via getindex" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = missing
+ sh["A3"] = missing; sh["B3"] = 4
+
+ t = XLSX.addtable!(sh, "A1:B3"; name="T")
+ rows = collect(XLSX.eachtablerow(t))
+
+ @test ismissing(rows[1][:b])
+ @test ismissing(rows[1][2])
+ @test ismissing(rows[2]["a"])
+ SAVE_FILES && save_outfile(f)
end
- end
- @testset "enable_cache=false throws" begin
- # gettotals reads each totals cell's formula, and getFormula requires
- # the cache, so the whole function is unavailable without it.
- XLSX.openxlsx("data/two_tables.xlsx", enable_cache=false) do xf
- t = XLSX.table(xf["Sheet2"], "with_total")
- @test_throws XLSX.XLSXError XLSX.gettotals(t)
+ @testset "totals row excluded when indexing rows" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "item"; sh["B1"] = "amount"
+ sh["A2"] = "Apples"; sh["B2"] = 12
+ sh["A3"] = "Pears"; sh["B3"] = 8
+ XLSX.addtable!(sh, "A1:B3"; name="Bulk")
+ t = XLSX.settotals!(sh, "Bulk", "item" => "Total", "amount" => :sum)
+
+ rows = collect(XLSX.eachtablerow(t))
+ @test length(rows) == 2
+ @test rows[end][:item] == "Pears" # not "Total"
+ @test rows[end][:amount] == 8
+ SAVE_FILES && save_outfile(f)
end
- end
- @testset "enable_cache=false, no totals row: returns empty without throwing" begin
- # The early return happens before any cell is touched, so a table
- # with no totals row is safe even without the cache.
- XLSX.openxlsx("data/two_tables.xlsx", enable_cache=false) do xf
- t = XLSX.table(xf["Sheet1"], "IO_Table")
- @test isempty(XLSX.gettotals(t))
+ @testset "real fixture (two_tables.xlsx)" begin
+ XLSX.openxlsx(joinpath(data_directory, "two_tables.xlsx")) do xf
+ sh = xf["Sheet1"]
+ t = XLSX.table(sh, "IO_Table")
+ r = first(XLSX.eachtablerow(t))
+
+ for (i, colname) in enumerate(t.columns)
+ @test isequal(r[i], r[Symbol(colname)])
+ @test isequal(r[i], r[colname])
+ end
+ end
end
end
+
@testset "Table-first dispatch" begin
# Two sheets, each with its own table, so cross-sheet delegation is
@@ -3951,49 +3795,205 @@ end
SAVE_FILES && save_outfile(f)
end
end
-
+
@testset "gettablerange" begin
- xf = XLSX.newxlsx("data")
- ws = xf["data"]
- ws["A1"] = "Region"; ws["B1"] = "Item"; ws["C1"] = "Amount"
- for (i, row) in enumerate((("Europe", "France", 30), ("Europe", "Spain", 20),
- ("Asia", "Japan", 50), ("Asia", "India", 35),
- ("Americas", "USA", 60)))
- for (j, v) in enumerate(row); ws[i + 1, j] = v; end
- end
- t = XLSX.addtable!(ws, XLSX.CellRange("A1:C6"); name = "Sales")
-
- @test string(XLSX.gettablerange(t)) == "data!A2:C6"
- @test string(XLSX.gettablerange(t, "Amount")) == "data!C2:C6"
- @test string(XLSX.gettablerange(t, 3)) == "data!C2:C6"
- @test string(XLSX.gettablerange(t, "Amount"; header = true)) == "data!C1"
- @test string(XLSX.gettablerange(t, "Region", "Item")) == "data!A2:B6"
- @test_throws XLSX.XLSXError XLSX.gettablerange(t, "Nope")
- @test_throws XLSX.XLSXError XLSX.gettablerange(t, 4)
-
- # A chart from table columns.
- c = XLSX.Charts.addChart(ws, :column; anchor = "E2:L18")
- XLSX.Charts.addSeries(c, XLSX.gettablerange(t, "Amount");
- categories = XLSX.gettablerange(t, "Item"),
- name_ref = XLSX.gettablerange(t, "Amount"; header = true))
- s = only(XLSX.Charts.getChartSeries(c))
- @test s.name == "Amount"
- @test s.values.data == [30, 20, 50, 35, 60]
- SAVE_FILES && save_outfile(xf)
+ @testset "gettablerange" begin
+ xf = XLSX.newxlsx("data")
+ ws = xf["data"]
+ ws["A1"] = "Region"; ws["B1"] = "Item"; ws["C1"] = "Amount"
+ for (i, row) in enumerate((("Europe", "France", 30), ("Europe", "Spain", 20),
+ ("Asia", "Japan", 50), ("Asia", "India", 35),
+ ("Americas", "USA", 60)))
+ for (j, v) in enumerate(row); ws[i + 1, j] = v; end
+ end
+ t = XLSX.addtable!(ws, XLSX.CellRange("A1:C6"); name = "Sales")
+
+ @test string(XLSX.gettablerange(t)) == "data!A2:C6"
+ @test string(XLSX.gettablerange(t, "Amount")) == "data!C2:C6"
+ @test string(XLSX.gettablerange(t, 3)) == "data!C2:C6"
+ @test string(XLSX.gettablerange(t, "Amount"; header = true)) == "data!C1"
+ @test string(XLSX.gettablerange(t, "Region", "Item")) == "data!A2:B6"
+ @test_throws XLSX.XLSXError XLSX.gettablerange(t, "Nope")
+ @test_throws XLSX.XLSXError XLSX.gettablerange(t, 4)
+
+ # A chart from table columns.
+ c = XLSX.Charts.addChart(ws, :column; anchor = "E2:L18")
+ XLSX.Charts.addSeries(c, XLSX.gettablerange(t, "Amount");
+ categories = XLSX.gettablerange(t, "Item"),
+ name_ref = XLSX.gettablerange(t, "Amount"; header = true))
+ s = only(XLSX.Charts.getChartSeries(c))
+ @test s.name == "Amount"
+ @test s.values.data == [30, 20, 50, 35, 60]
+ SAVE_FILES && save_outfile(xf)
+ end
end
@testset "a table with its header row hidden" begin
- f = XLSX.readxlsx(joinpath(data_directory, "table_hidden_header.xlsx"))
- t = only(XLSX.tables(f))
- @test !t.has_header_row
- dt = XLSX.gettable(t)
- @test length(dt.data[1]) == 3 # A2:B4, all three rows
- @test string.(dt.column_labels) == t.columns # names from the table definition
- @test length(collect(XLSX.eachtablerow(t))) == 3
- @test string(XLSX.gettablerange(t)) == "$(t.sheet.name)!A2:B4"
- cols = Tables.columns(t)
- @test length(Tables.getcolumn(cols, 1)) == 3 # three data rows, not two
- @test length(collect(Tables.rows(t))) == 3
+ @testset "a table with its header row hidden" begin
+ f = XLSX.readxlsx(joinpath(data_directory, "table_hidden_header.xlsx"))
+ t = only(XLSX.tables(f))
+ @test !t.has_header_row
+ dt = XLSX.gettable(t)
+ @test length(dt.data[1]) == 3 # A2:B4, all three rows
+ @test string.(dt.column_labels) == t.columns # names from the table definition
+ @test length(collect(XLSX.eachtablerow(t))) == 3
+ @test string(XLSX.gettablerange(t)) == "$(t.sheet.name)!A2:B4"
+ cols = Tables.columns(t)
+ @test length(Tables.getcolumn(cols, 1)) == 3 # three data rows, not two
+ @test length(collect(Tables.rows(t))) == 3
+ end
+ end
+
+ @testset "table part parsing" begin
+ @testset "parse_table_xml - malformed parts" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+
+ # root element is not
+ @test_throws XLSX.XLSXError XLSX.parse_table_xml(
+ _tbl_doc(""), "bad.xml", sh)
+
+ # with no attributes at all
+ @test_throws XLSX.XLSXError XLSX.parse_table_xml(
+ _tbl_doc(""""""), "bad.xml", sh)
+
+ # missing each required attribute in turn
+ @test_throws XLSX.XLSXError XLSX.parse_table_xml(
+ _tbl_doc(""""""), "bad.xml", sh)
+ @test_throws XLSX.XLSXError XLSX.parse_table_xml(
+ _tbl_doc(""""""), "bad.xml", sh)
+ @test_throws XLSX.XLSXError XLSX.parse_table_xml(
+ _tbl_doc(""""""), "bad.xml", sh)
+
+ # no element
+ @test_throws XLSX.XLSXError XLSX.parse_table_xml(
+ _tbl_doc(""""""),
+ "bad.xml", sh)
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "parse_table_columns - without a name" begin
+ doc = _tbl_doc("""""")
+ @test_throws XLSX.XLSXError XLSX.parse_table_columns(doc)
+ end
+
+ @testset "parse_table_columns - non-tableColumn children are skipped" begin
+ doc = _tbl_doc("""""")
+ @test XLSX.parse_table_columns(doc) == ["a", "b"]
+ end
+
+ @testset "parse_table_xml - totals and header rows follow the count attributes" begin
+ # totalsRowCount is the number of totals rows in ref now; totalsRowShown is
+ # history (has one ever been shown) and says nothing about the current range.
+ # Checked against Excel: a table whose totals row was switched on then off
+ # carries neither attribute, and one that never had totals has
+ # totalsRowShown="0".
+ f = XLSX.newxlsx()
+ sh = f[1]
+ base = """"""
+ tbl(attrs) = XLSX.parse_table_xml(_tbl_doc(
+ """"""),
+ "t.xml", sh)
+
+ @test tbl("""totalsRowCount="1\"""").has_totals_row == true
+ @test tbl("""totalsRowShown="1" totalsRowCount="1\"""").has_totals_row == true # as XLSX.jl writes it
+ @test tbl("""totalsRowShown="1\"""").has_totals_row == false # shown once, not now
+ @test tbl("""totalsRowShown="0\"""").has_totals_row == false # never had one
+ @test tbl("").has_totals_row == false # on, then off, in Excel
+
+ @test tbl("").has_header_row == true # headerRowCount defaults to 1
+ @test tbl("""headerRowCount="1\"""").has_header_row == true
+ @test tbl("""headerRowCount="0\"""").has_header_row == false # header row hidden
+
+ # displayName defaults to name when absent
+ @test tbl("").display_name == "T"
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "parse_table_style_info - element present but bare" begin
+ doc = _tbl_doc("""""")
+ s = XLSX.parse_table_style_info(doc)
+
+ @test !isnothing(s)
+ @test isnothing(s.name)
+ @test s.show_first_column == false
+ @test s.show_last_column == false
+ @test s.show_row_stripes == false
+ @test s.show_column_stripes == false
+ end
+
+ @testset "parse_table_style_info - absent returns nothing" begin
+ doc = _tbl_doc("""""")
+ @test isnothing(XLSX.parse_table_style_info(doc))
+ end
+
+ @testset "parse_totals_settings - unrecognized totalsRowFunction errors" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.settotals!(sh, "T", "b" => :sum)
+
+ table_doc = XLSX.get_xml_data(XLSX.get_xlsxfile(sh), XLSX._table_part_path(sh, "T"))
+ i, j = XLSX.get_idces(table_doc, "table", "tableColumns")
+ node = collect(XLSX.xml_elements(table_doc[i][j]))[2]
+ node["totalsRowFunction"] = "notAFunction"
+ sh.tables_cache = nothing
+
+ @test_throws XLSX.XLSXError XLSX.parse_totals_settings(sh, XLSX.table(sh, "T"))
+ end
+
+ @testset "parse_totals_settings - custom function with no formula errors" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.settotals!(sh, "T", "b" => (:custom, "SUBTOTAL(109,T[a])"))
+
+ # blank the formula cell while leaving totalsRowFunction="custom" in place
+ sh["B3"] = missing
+ sh.tables_cache = nothing
+
+ @test_throws XLSX.XLSXError XLSX.parse_totals_settings(sh, XLSX.table(sh, "T"))
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "parse_totals_settings - label-only column is returned as a String" begin
+ f = XLSX.newxlsx()
+ sh = f[1]
+ sh["A1"] = "a"; sh["B1"] = "b"
+ sh["A2"] = 1; sh["B2"] = 2
+ XLSX.addtable!(sh, "A1:B2"; name="T")
+ XLSX.settotals!(sh, "T", "a" => "Total", "b" => :sum)
+
+ settings = Dict(XLSX.parse_totals_settings(sh, XLSX.table(sh, "T")))
+ @test settings["a"] == "Total"
+ @test settings["b"] === :sum
+ SAVE_FILES && save_outfile(f)
+ end
end
+end
+@testset "review fixes: tables" begin
+ f = XLSX.newxlsx(); s = f[1]
+ s["A1:B1"] = ["Item" "Price [USD]"]; s["A2:A3"] = "x"; s["B2:B3"] = 1.0
+ XLSX.addtable!(s, "A1:B3"; name="Sales")
+ s["D1:E1"] = ["x" "y"]; s["D2:E3"] = 1
+ @test_throws XLSX.XLSXError XLSX.addtable!(s, "D1:E3"; name="sales")
+ s["G1:H1"] = ["Col" "col"]; s["G2:H3"] = 1
+ @test_throws XLSX.XLSXError XLSX.addtable!(s, "G1:H3")
+ XLSX.settotals!(s, "Sales", "Price [USD]" => :sum)
+ @test XLSX.getFormula(s, "B4") == "=SUBTOTAL(109,Sales[Price '[USD']])"
+ SAVE_FILES && save_outfile(f) # the escaped totals formula, as Excel reads it
+ XLSX.deletetable!(s, "Sales")
+ @test XLSX.getFormula(s, "B4") == "=SUBTOTAL(109,Sheet1!\$B\$2:\$B\$3)"
+ SAVE_FILES && save_outfile(f)
end
diff --git a/test/test_files/Filemodes_tests.jl b/test/test_files/Filemodes_tests.jl
index fa05256d..c2ae284a 100644
--- a/test/test_files/Filemodes_tests.jl
+++ b/test/test_files/Filemodes_tests.jl
@@ -83,121 +83,6 @@
@test_throws XLSX.XLSXError f = XLSX.openxlsx(filename; mode="rw", enable_cache=false) # Cache must be enabled to open in `write` mode.
- @testset "write column" begin
- col_data = collect(1:50)
-
- XLSX.openxlsx(filename, mode="w") do xf
- sheet = xf[1]
- sheet[:, 2] = col_data
- sheet[51:100, 3] = col_data
- sheet[2, 4, dim=1] = col_data
- end
- SAVE_FILES && save_outfile(filename)
-
- XLSX.openxlsx(filename) do xf
- sheet = xf[1]
-
- for (row, val) in enumerate(col_data)
- @test sheet[row, 2] == val
- @test sheet[50+row, 3] == val
- @test sheet[row+1, 4] == val
- end
- end
- end
-
- @testset "write matrix with anchor cell" begin
- test_data = [1 2 3; 4 5 6; 7 8 9]
- XLSX.openxlsx(filename, mode="w") do xf
- sheet = xf[1]
- sheet["A7"] = test_data
- end
- SAVE_FILES && save_outfile(filename)
-
- XLSX.openxlsx(filename) do xf
- sheet = xf[1]
- rows, cols = size(test_data)
- for c in 1:cols, r in 1:rows
- @test sheet[r+6, c] == test_data[r, c]
- end
- end
-
- xf = XLSX.newxlsx()
- sheet = xf[1]
- sheet[7, 1] = test_data
- XLSX.writexlsx(filename, xf, overwrite=true)
- SAVE_FILES && save_outfile(filename)
-
- XLSX.openxlsx(filename) do xf
- sheet = xf[1]
- rows, cols = size(test_data)
- for c in 1:cols, r in 1:rows
- @test sheet[r+6, c] == test_data[r, c]
- end
- end
- end
-
- @testset "write matrix with range" begin
- test_data = [1 2 3; 4 5 6; 7 8 9]
- XLSX.openxlsx(filename, mode="w") do xf
- sheet = xf[1]
- sheet["A7:C9"] = test_data
- end
- SAVE_FILES && save_outfile(filename)
-
- XLSX.openxlsx(filename) do xf
- sheet = xf[1]
- rows, cols = size(test_data)
- for c in 1:cols, r in 1:rows
- @test sheet[r+6, c] == test_data[r, c]
- end
- end
-
- xf = XLSX.newxlsx()
- sheet = xf[1]
- sheet[7:9, 1:3] = test_data
- XLSX.writexlsx(filename, xf, overwrite=true)
- SAVE_FILES && save_outfile(filename)
-
- XLSX.openxlsx(filename) do xf
- sheet = xf[1]
- rows, cols = size(test_data)
- for c in 1:cols, r in 1:rows
- @test sheet[r+6, c] == test_data[r, c]
- end
- end
- end
-
- @testset "write matrix with range mismatch" begin
- test_data = [1 2 3; 4 5 6; 7 8 9]
- XLSX.openxlsx(filename, mode="w") do xf
- sheet = xf[1]
- @test_throws XLSX.XLSXError sheet["A7:C10"] = test_data
- end
- SAVE_FILES && save_outfile(filename)
- end
-
- @testset "write matrix with heterogeneous data types" begin
- # issue #97
- test_data = ["A" "B"; 1 2; "a" "b"; 0 "x"]
- XLSX.openxlsx(filename, mode="w") do xf
- sheet = xf[1]
- sheet["B2"] = test_data
- end
- SAVE_FILES && save_outfile(filename)
-
- XLSX.openxlsx(filename, mode="r") do xf
- sheet = xf[1]
- @test sheet["B2"] == "A"
- @test sheet["C2"] == "B"
- @test sheet["B3"] == 1
- @test sheet["C3"] == 2
- @test sheet["B4"] == "a"
- @test sheet["C4"] == "b"
- @test sheet["B5"] == 0
- @test sheet["C5"] == "x"
- end
- end
-
@testset "doctest for writetable!" begin
columns = Vector()
push!(columns, [1, 2, 3])
@@ -248,4 +133,63 @@
end
isfile(filename) && rm(filename)
+
+ @testset "openxlsx / parse_file_mode - argument errors" begin
+ missing_file = "definitely_not_a_file_12345.xlsx"
+
+ @test_throws XLSX.XLSXError XLSX.openxlsx(missing_file)
+ @test_throws XLSX.XLSXError XLSX.openxlsx(identity, missing_file)
+ @test_throws XLSX.XLSXError XLSX.openxlsx(missing_file; mode="q")
+ @test XLSX.parse_file_mode("wr") == (true, true)
+ @test XLSX.parse_file_mode("RW") == (true, true)
+ end
+
+ @testset "missing file or bad `mode`" begin
+ @test_throws XLSX.XLSXError XLSX.openxlsx("noSuchFile.xlsx")
+ @test_throws XLSX.XLSXError XLSX.openxlsx(joinpath(data_directory, "Book1.xlsx"); mode="tg")
+ end
+
+ @testset "write-only mode" begin
+ XLSX.openxlsx("mytest.xlsx", mode="w") do f
+ f[1]["A1"] = 1
+ @test f.source == "mytest.xlsx"
+ end
+ SAVE_FILES && save_outfile("mytest.xlsx")
+ ef = XLSX.readxlsx("mytest.xlsx")
+ @test ef["Sheet1"]["A1"] == 1
+ f = XLSX.openxlsx("mytest2.xlsx", mode="w")
+ @test f.source == "mytest2.xlsx"
+ f[1]["A1"] = 1
+ XLSX.writexlsx("mytest3.xlsx", f, overwrite=true)
+ SAVE_FILES && save_outfile("mytest3.xlsx")
+ ef = XLSX.readxlsx("mytest3.xlsx")
+ @test ef["Sheet1"]["A1"] == 1
+ f = XLSX.newxlsx()
+ @test f.source == "blank.xlsx"
+ f[1]["A1"] = 1
+ XLSX.writexlsx("mytest.xlsx", f, overwrite=true)
+ SAVE_FILES && save_outfile("mytest.xlsx")
+ ef = XLSX.readxlsx("mytest.xlsx")
+ @test ef["Sheet1"]["A1"] == 1
+ for f in ["mytest.xlsx", "mytest2.xlsx", "mytest3.xlsx"]
+ isfile(f) && rm(f)
+ end
+ end
+
+ @testset "read-only and no-cache files reject formula and merge calls" begin
+ general = joinpath(data_directory, "general.xlsx")
+
+ ro = XLSX.readxlsx(general)
+ s = ro[1]
+ @test_throws XLSX.XLSXError XLSX.setFormula(s, "A1", "=1+1")
+ @test_throws XLSX.XLSXError XLSX.setFormula(s, "A1:A2", "=1+1")
+ @test_throws XLSX.XLSXError XLSX.isMergedCell(s, "A1")
+ @test_throws XLSX.XLSXError XLSX.getMergedBaseCell(s, "A1")
+ @test_throws XLSX.XLSXError XLSX.mergeCells(s, "A1:B2")
+
+ XLSX.openxlsx(general; enable_cache=false) do nc
+ s = nc[1]
+ @test_throws XLSX.XLSXError XLSX.getMergedCells(s)
+ end
+ end
end
diff --git a/test/test_files/Test-files_tests.jl b/test/test_files/Fixture-files_tests.jl
similarity index 55%
rename from test/test_files/Test-files_tests.jl
rename to test/test_files/Fixture-files_tests.jl
index 84f7a740..324b7622 100644
--- a/test/test_files/Test-files_tests.jl
+++ b/test/test_files/Fixture-files_tests.jl
@@ -6,7 +6,7 @@
ef_book_sparse = XLSX.readxlsx(joinpath(data_directory, "book_sparse.xlsx"))
ef_book_sparse_2 = XLSX.readxlsx(joinpath(data_directory, "book_sparse_2.xlsx"))
- XLSX.readxlsx(joinpath(data_directory, "missing_numFmtId.xlsx"))["Koldioxid (CO2)"][7, 5]
+ @test XLSX.readxlsx(joinpath(data_directory, "missing_numFmtId.xlsx"))["Koldioxid (CO2)"][7, 5] ≈ 208634.871365389
@test open(joinpath(data_directory, "blank_ptbr_1904.xlsx")) do io
XLSX.readxlsx(io)
@@ -68,31 +68,33 @@
# Issue #293
@testset "read .xltx file" begin
- xf = XLSX.openxlsx(joinpath(data_directory, "Template File.xltx"); mode="RW")
+ # saving a template writes the .xlsx beside it, so work on a copy
+ cp(joinpath(data_directory, "Template File.xltx"), "Template File.xltx"; force=true)
+ xf = XLSX.openxlsx("Template File.xltx"; mode="RW")
s=xf[1]
@test s["P5"] == 5
@test XLSX.getFormula(s, "B5") == "=RANDBETWEEN(0,100)"
@test xf.template_type == XLSX.XLTXTemplate
XLSX.savexlsx(xf)
- SAVE_FILES && save_outfile(joinpath(data_directory, "Template File.xlsx"))
- @test isfile(joinpath(data_directory, "Template File.xlsx"))
- xf = XLSX.readxlsx(joinpath(data_directory, "Template File.xlsx"))
+ SAVE_FILES && save_outfile("Template File.xlsx")
+ @test isfile("Template File.xlsx")
+ xf = XLSX.readxlsx("Template File.xlsx")
s=xf[1]
@test s["P5"] == 5
@test XLSX.getFormula(s, "B5") == "=RANDBETWEEN(0,100)"
@test xf.template_type == XLSX.NotATemplate
- isfile(joinpath(data_directory, "Template File.xlsx")) && rm(joinpath(data_directory, "Template File.xlsx"))
+ isfile("Template File.xlsx") && rm("Template File.xlsx")
- XLSX.openxlsx(joinpath(data_directory, "Template File.xltx"); mode="RW") do xf
+ XLSX.openxlsx("Template File.xltx"; mode="RW") do xf
s=xf[1]
@test s["P5"] == 5
@test XLSX.getFormula(s, "B5") == "=RANDBETWEEN(0,100)"
@test xf.template_type == XLSX.XLTXTemplate
end
- @test isfile(joinpath(data_directory, "Template File.xlsx"))
- SAVE_FILES && save_outfile(joinpath(data_directory, "Template File.xlsx"))
- isfile(joinpath(data_directory, "Template File.xlsx")) && rm(joinpath(data_directory, "Template File.xlsx"))
+ @test isfile("Template File.xlsx")
+ SAVE_FILES && save_outfile("Template File.xlsx")
+ isfile("Template File.xlsx") && rm("Template File.xlsx")
end
# Issue #401
@@ -106,45 +108,41 @@
isfile("mytest.xlsm") && rm("mytest.xlsm")
SAVE_FILES && save_outfile(mf)
- mf = XLSX.openxlsx(joinpath(data_directory, "macro-enabled2.xltm"); mode="rW")
+ cp(joinpath(data_directory, "macro-enabled2.xltm"), "macro-enabled2.xltm"; force=true)
+ mf = XLSX.openxlsx("macro-enabled2.xltm"; mode="rW")
@test mf[1]["A1"] == "hello"
@test mf.template_type == XLSX.XLTMTemplate
XLSX.savexlsx(mf)
- SAVE_FILES && save_outfile(joinpath(data_directory, "macro-enabled2.xlsm"))
- @test isfile(joinpath(data_directory, "macro-enabled2.xlsm"))
- mf = XLSX.readxlsx(joinpath(data_directory, "macro-enabled2.xlsm"))
+ SAVE_FILES && save_outfile("macro-enabled2.xlsm")
+ @test isfile("macro-enabled2.xlsm")
+ mf = XLSX.readxlsx("macro-enabled2.xlsm")
s=mf[1]
@test mf[1]["A1"] == "hello"
@test mf.template_type == XLSX.NotATemplate
- isfile(joinpath(data_directory, "macro-enabled2.xlsm")) && rm(joinpath(data_directory, "macro-enabled2.xlsm"))
+ isfile("macro-enabled2.xlsm") && rm("macro-enabled2.xlsm")
- XLSX.openxlsx(joinpath(data_directory, "macro-enabled2.xltm"); mode="wr") do mf
+ XLSX.openxlsx("macro-enabled2.xltm"; mode="wr") do mf
@test mf[1]["A1"] == "hello"
@test mf.template_type == XLSX.XLTMTemplate
end
- SAVE_FILES && save_outfile(joinpath(data_directory, "macro-enabled2.xltm"))
- @test isfile(joinpath(data_directory, "macro-enabled2.xlsm"))
- SAVE_FILES && save_outfile(joinpath(data_directory, "macro-enabled2.xlsm"))
- mf = XLSX.openxlsx(joinpath(data_directory, "macro-enabled2.xlsm"); mode="WR")
+ @test isfile("macro-enabled2.xlsm")
+ SAVE_FILES && save_outfile("macro-enabled2.xlsm")
+ mf = XLSX.openxlsx("macro-enabled2.xlsm"; mode="WR")
@test mf[1]["A1"] == "hello"
@test mf.template_type == XLSX.NotATemplate
- isfile(joinpath(data_directory, "macro-enabled2.xlsm")) && rm(joinpath(data_directory, "macro-enabled2.xlsm"))
+ isfile("macro-enabled2.xlsm") && rm("macro-enabled2.xlsm")
end
# Issue #403
@testset "UTF-16 customXml" begin
- try
- xf1 = XLSX.openxlsx(joinpath(data_directory, "UTF-16.xlsx"); mode="rw")
- XLSX.writexlsx("UTF-16_test.xlsx", xf1; overwrite=true)
- SAVE_FILES && save_outfile("UTF-16_test.xlsx")
- xf2 = XLSX.openxlsx("UTF-16_test.xlsx"; mode="rw")
- @test xf1[1]["E3"] == xf2[1]["E3"]
- @test xf1[1]["R99"] == xf2[1]["R99"]
- SAVE_FILES && save_outfile(xf2)
- catch e
- @test false
- end
+ xf1 = XLSX.openxlsx(joinpath(data_directory, "UTF-16.xlsx"); mode="rw")
+ XLSX.writexlsx("UTF-16_test.xlsx", xf1; overwrite=true)
+ SAVE_FILES && save_outfile("UTF-16_test.xlsx")
+ xf2 = XLSX.openxlsx("UTF-16_test.xlsx"; mode="rw")
+ @test xf1[1]["E3"] == xf2[1]["E3"]
+ @test xf1[1]["R99"] == xf2[1]["R99"]
+ SAVE_FILES && save_outfile(xf2)
isfile("UTF-16_test.xlsx") && rm("UTF-16_test.xlsx")
@@ -170,57 +168,6 @@
end
end
- @testset "missing file or bad `mode`" begin
- @test_throws XLSX.XLSXError XLSX.openxlsx("noSuchFile.xlsx")
- @test_throws XLSX.XLSXError XLSX.openxlsx(joinpath(data_directory, "Book1.xlsx"); mode="tg")
- end
-
- @testset "write-only mode" begin
- XLSX.openxlsx("mytest.xlsx", mode="w") do f
- f[1]["A1"] = 1
- @test f.source == "mytest.xlsx"
- end
- SAVE_FILES && save_outfile("mytest.xlsx")
- ef = XLSX.readxlsx("mytest.xlsx")
- @test ef["Sheet1"]["A1"] == 1
- f = XLSX.openxlsx("mytest2.xlsx", mode="w")
- @test f.source == "mytest2.xlsx"
- f[1]["A1"] = 1
- XLSX.writexlsx("mytest3.xlsx", f, overwrite=true)
- SAVE_FILES && save_outfile("mytest3.xlsx")
- ef = XLSX.readxlsx("mytest3.xlsx")
- @test ef["Sheet1"]["A1"] == 1
- f = XLSX.newxlsx()
- @test f.source == "blank.xlsx"
- f[1]["A1"] = 1
- XLSX.writexlsx("mytest.xlsx", f, overwrite=true)
- SAVE_FILES && save_outfile("mytest.xlsx")
- ef = XLSX.readxlsx("mytest.xlsx")
- @test ef["Sheet1"]["A1"] == 1
- for f in ["mytest.xlsx", "mytest2.xlsx", "mytest3.xlsx"]
- isfile(f) && rm(f)
- end
- end
-
- @testset "Fix timestamp" begin
- t = Dates.now(Dates.UTC) - Dates.Second(1)
- xf = XLSX.newxlsx()
- f = "docProps/core.xml"
- date_format = Dates.dateformat"yyyy-mm-ddTHH:MM:SSZ"
- i, j = XLSX.get_idces(xf.data[f], "cp:coreProperties", "dcterms:created") # i, j should always be found unless the `blank.xlsx` file is updated
- @test t < DateTime(XML.value(xf.data[f][i][j][1]), date_format)
- i, j = XLSX.get_idces(xf.data[f], "cp:coreProperties", "dcterms:modified")
- @test t < DateTime(XML.value(xf.data[f][i][j][1]), date_format)
- SAVE_FILES && save_outfile(xf)
-
- xf = XLSX.newxlsx(; update_timestamp=false) # do not update timestamp
- i, j = XLSX.get_idces(xf.data[f], "cp:coreProperties", "dcterms:created") # i, j should always be found unless the `blank.xlsx` file is updated
- @test XML.value(xf.data[f][i][j][1]) == "2018-05-22T02:41:32Z"
- i, j = XLSX.get_idces(xf.data[f], "cp:coreProperties", "dcterms:modified")
- @test XML.value(xf.data[f][i][j][1]) == "2018-05-22T02:42:04Z"
- SAVE_FILES && save_outfile(xf)
- end
-
@testset "Book1.xlsx" begin
f = XLSX.readxlsx(joinpath(data_directory, "Book1.xlsx"))
sheet = f["Sheet1"]
@@ -252,7 +199,6 @@
@test sheet2[:, 2:3] == Any[2 3; 5 6; 8 9]
@test sheet2[1:2, 2:3] == Any[2 3; 5 6]
-
@test XLSX.getdata(f, XLSX.SheetCellRef("Sheet1!B2")) == "B2"
@test XLSX.getdata(f, XLSX.SheetCellRange("Sheet1!B2:B3"))[1] == "B2"
@test XLSX.getdata(f, XLSX.SheetCellRange("Sheet1!B2:B3"))[2] == 10.5
@@ -311,6 +257,29 @@
@test XLSX.column_number(emptycell) == 2
end
+
+ @testset "typed values in general.xlsx" begin
+ XLSX.openxlsx(joinpath(data_directory, "general.xlsx")) do f
+ show(IOBuffer(), f)
+ sheet = f["general"]
+ @test sheet["A1"] == "text"
+ @test sheet["B1"] == "regular text"
+ @test sheet["A2"] == "integer"
+ @test sheet["B2"] == 102
+ @test sheet["A3"] == "float"
+ @test isapprox(sheet["B3"], 102.2)
+ @test sheet["A4"] == "date"
+ @test sheet["B4"] == Date(1983, 4, 16)
+ @test sheet["A5"] == "hour"
+ @test sheet["B5"] == Dates.Time(Dates.Hour(19), Dates.Minute(45))
+ @test sheet["A6"] == "datetime"
+ @test sheet["B6"] == Date(2018, 4, 16) + Dates.Time(Dates.Hour(19), Dates.Minute(19), Dates.Second(51))
+ @test f["general!B7"] == -220.0
+ @test f["general!B8"] == -2000
+ @test f["general!B9"] == 100000000000000
+ @test f["general!B10"] == -100000000000000
+ end
+ end
end
@testset "CustomXml" begin
@@ -330,96 +299,75 @@ end
f_copy = XLSX.readxlsx(filename_copy)
test_Xmlread = [["Can't", "write", "this", "template"]]
for sn in XLSX.sheetnames(f_copy)
- sheet = template[sn]
+ sheet = f_copy[sn]
data = [[sheet["Q1"], sheet["Q2"], sheet["Q3"], sheet["Q4"]]]
check_test_data(data, test_Xmlread)
end
isfile(filename_copy) && rm(filename_copy)
end
-@testset "docProps/app.xml" begin # issue #428
+@testset "show xlsx" begin
+ @testset "single sheet" begin
+ xf = XLSX.readxlsx(joinpath(src_data_directory, "blank.xlsx"))
+ io = IOBuffer()
+ show(io, xf)
+ lines = split(String(take!(io)), '\n')
- function parse_heading_pairs(app_xml::String)
- m = match(r"(.*?)"s, app_xml)
- m === nothing && return Tuple{String,Int}[]
- inner = m.captures[1]
- labels = [c.captures[1] for c in eachmatch(r"([^<]*)", inner)]
- counts = [parse(Int, c.captures[1]) for c in eachmatch(r"([^<]*)", inner)]
- return collect(zip(labels, counts))
- end
+ expected_tail = " sheetname size range \n-------------------------------------------------\n Sheet1 1x1 A1:A1 \n"
- function parse_titles_of_parts(app_xml::String)
- m = match(r"(.*?)"s, app_xml)
- m === nothing && return String[]
- inner = m.captures[1]
- return [c.captures[1] for c in eachmatch(r"([^<]*)", inner)]
+ @test join(lines[2:end], '\n') == expected_tail
end
- @testset "plain worksheets: rename + add updates HeadingPairs and TitlesOfParts" begin
- src = joinpath(data_directory, "Book1.xlsx")
- p = "testfiles_plain_worksheets_rename_add.xlsx"
- cp(src, p; force=true)
-
- XLSX.openxlsx(p; mode="rw") do xf
- XLSX.renamesheet!(xf["Sheet1"], "Renamed")
- XLSX.addsheet!(xf, "Added")
- end
- SAVE_FILES && save_outfile(p)
-
- app = String(zip_readentry(ZipReader(read(p)), "docProps/app.xml"))
-
- @test !occursin(">Sheet1<", app) # stale title gone
-
- titles = parse_titles_of_parts(app)
- @test "Renamed" in titles
- @test "Added" in titles
- @test length(titles) == 3
+ @testset "multiple sheets" begin
+ xf = XLSX.readxlsx(joinpath(data_directory, "Book1.xlsx"))
+ io = IOBuffer()
+ show(io, xf)
+ lines = split(String(take!(io)), '\n')
- pairs = parse_heading_pairs(app)
- ws_idx = findfirst(pr -> pr[1] == "Worksheets", pairs)
- @test ws_idx !== nothing
- @test pairs[ws_idx][2] == 3
+ expected_tail = " sheetname size range \n-------------------------------------------------\n Sheet1 7x2 B2:C8 \n Sheet2 3x3 A1:C3 \n"
- isfile(p) && rm(p)
+ @test join(lines[2:end], '\n') == expected_tail
end
- @testset "chartsheet workbook: Worksheets/Charts categories stay separate and correctly counted" begin
- src = joinpath(data_directory, "Chartsheet.xlsx")
- p = "testfiles_chartsheet_workbook_worksheets.xlsx"
- cp(src, p; force=true)
+ @testset "IOBuffer source" begin
+ source = IOBuffer()
+ write(source, read(joinpath(data_directory, "Book1.xlsx")))
+ seekstart(source)
+ xf = XLSX.readxlsx(source)
+ io=IOBuffer()
+ show(io, xf)
+ result = String(take!(io))
+ expected = "XLSXFile(IOBuffer) containing 2 Worksheets\n sheetname size range \n-------------------------------------------------\n Sheet1 7x2 B2:C8 \n Sheet2 3x3 A1:C3 \n"
+
+ @test result == expected
+ end
+end
- XLSX.openxlsx(p; mode="rw") do xf
- XLSX.renamesheet!(xf["Sheet1"], "RenamedData")
- XLSX.addsheet!(xf, "ExtraData")
- end
- SAVE_FILES && save_outfile(p)
-
- app = String(zip_readentry(ZipReader(read(p)), "docProps/app.xml"))
- pairs = parse_heading_pairs(app)
- titles = parse_titles_of_parts(app)
-
- ws_idx = findfirst(pr -> pr[1] == "Worksheets", pairs)
- ch_idx = findfirst(pr -> pr[1] == "Charts", pairs)
- @test ws_idx !== nothing
- @test ch_idx !== nothing
- @test pairs[ws_idx][2] == 2 # RenamedData, ExtraData
- @test pairs[ch_idx][2] == 1 # Chart1 untouched by a worksheet-only operation
-
- @test "RenamedData" in titles
- @test "ExtraData" in titles
- @test "Chart1" in titles
- @test !occursin(">Sheet1<", app)
- @test length(titles) == 3
-
- # Worksheets titles are grouped before Charts titles regardless of tab
- # order — with only one chart, it must be the last title.
- @test titles[end] == "Chart1"
-
- XLSX.openxlsx(p) do xf2
- @test issetequal(XLSX.sheetnames(xf2), ["RenamedData", "ExtraData", "Chart1"])
- end
+# issues #62, #75
+@testset "relative paths" begin
+ let
+ xf = XLSX.readxlsx(joinpath(data_directory, "openpyxl.xlsx"))
+ @test XLSX.sheetnames(xf) == ["Sheet", "Test1"]
+ @test xf["Test1"]["A1"] == "One"
+ @test xf["Test1"]["A2"] == 1
+ show(IOBuffer(), xf)
+ show(IOBuffer(), xf["Sheet"])
+ show(IOBuffer(), xf["Test1"])
+ end
- isfile(p) && rm(p)
+ let
+ dtable = XLSX.readtable(joinpath(data_directory, "openpyxl.xlsx"), "Test1")
+ data, col_names = dtable.data, dtable.column_labels
+ @test data == [[1, 3], [2, 4]]
+ @test col_names == [:One, :Two]
end
+end
+
+# issues #62, #71
+@testset "windows compatibility" begin
+ xf = XLSX.open_xlsx_template(joinpath(data_directory, "issue62_71.xlsx"))
+ @test xf["Sheet1"]["A1"] == "One"
+ @test xf["Sheet1"]["A2"] == 1
+ @test collect(keys(xf.binary_data)) == ["xl/printerSettings/printerSettings1.bin"]
end
diff --git a/test/test_files/Formulas_tests.jl b/test/test_files/Formulas_tests.jl
index bc8b51a1..4432ff9b 100644
--- a/test/test_files/Formulas_tests.jl
+++ b/test/test_files/Formulas_tests.jl
@@ -259,7 +259,7 @@
@test XLSX.getcell(s, "A3") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A3"), "", "", "25", "", true)
@test XLSX.get_formula_from_cache(s, XLSX.CellRef("A3")) == XLSX.ReferencedFormula("SUM(O3:S3)", 4, "A3:A10", nothing)
- s2 = f[2]
+ s2 = f2[2]
@test XLSX.getcell(s2, "A2") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("A2"), "", "", "3", "", false)
@test (XLSX.get_formula_from_cache(s2, XLSX.CellRef("A3"))).id == 2
@test XLSX.get_formula_from_cache(s2, XLSX.CellRef("A3")).unhandled == Dict("ca" => "1")
@@ -303,10 +303,39 @@
# XLSX.writexlsx("shift_test_out.xlsx", f, overwrite=true)
SAVE_FILES && save_outfile(f)
end
-end
+ # issue #299 & 301
+ @testset "empty_v" begin
+ xf = XLSX.openxlsx(joinpath(data_directory, "empty_v.xlsx"), mode="rw")
+ sheet1 = xf["Sheet1"]
+ @test XLSX.getcell(sheet1, "A1") == XLSX.Cell(XLSX.get_workbook(xf), XLSX.CellRef("A1"), "str", "", "", "", true)
+ @test XLSX.get_formula_from_cache(sheet1, XLSX.CellRef("A1")) == XLSX.Formula("\"\"")
+ XLSX.writexlsx("mytest.xlsx", xf, overwrite=true)
+ SAVE_FILES && save_outfile("mytest.xlsx")
+ xf2 = XLSX.readxlsx("mytest.xlsx")
+ sheet1 = xf2["Sheet1"]
+ @test XLSX.getcell(xf2[1], "A1") == XLSX.Cell(XLSX.get_workbook(xf2), XLSX.CellRef("A1"), "str", "", "", "", true)
+ @test XLSX.get_formula_from_cache(sheet1, XLSX.CellRef("A1")) == XLSX.Formula("\"\"")
+ isfile("mytest.xlsx") && rm("mytest.xlsx")
+ end
+
+ @testset "Formula types: emptiness, hashing and copying" begin
+ @test XLSX.Formula() isa XLSX.EmptyFormula
+ f = XLSX.Formula("SUM(A1:A2)")
+ rf = XLSX.ReferencedFormula("A1+1", 0, "B1:B3", Dict("t" => "shared"))
+ fr = XLSX.FormulaReference(0, nothing)
+ @test !isempty(f) && isempty(XLSX.Formula("")) && !isempty(rf) && !isempty(fr)
+ @test hash(f) == hash(XLSX.Formula("SUM(A1:A2)"))
+ @test hash(rf) == hash(copy(rf)) && hash(fr) == hash(copy(fr))
+ c = copy(rf)
+ @test c.unhandled == rf.unhandled && c.unhandled !== rf.unhandled # Dict copied
+ io = IOBuffer()
+ XLSX.add_node_formula!(io, XLSX.CellFormula(f, XLSX.EmptyCellDataFormat()), "")
+ @test occursin("SUM(A1:A2)", String(take!(io)))
+ end
+end
-@testset "parallel formulas" begin
+@testset "formulas on several sheets and threaded reads" begin
f = XLSX.newxlsx()
s = f[1]
@@ -386,28 +415,73 @@ end
for iter in 1:N_ITER
seekstart(io)
- try
- df = XLSX.readtable(io, 1)
- catch e
- println("Error in iteration $iter: $e")
- @test false
- end
+ dt = XLSX.readtable(io, 1)
+ @test length(dt.data[1]) == N_FORMULAS
end
end
- # issue #299 & 301
- @testset "empty_v" begin
- xf = XLSX.openxlsx(joinpath(data_directory, "empty_v.xlsx"), mode="rw")
- sheet1 = xf["Sheet1"]
- @test XLSX.getcell(sheet1, "A1") == XLSX.Cell(XLSX.get_workbook(xf), XLSX.CellRef("A1"), "str", "", "", "", true)
- @test XLSX.get_formula_from_cache(sheet1, XLSX.CellRef("A1")) == XLSX.Formula("\"\"")
- XLSX.writexlsx("mytest.xlsx", xf, overwrite=true)
- SAVE_FILES && save_outfile("mytest.xlsx")
- xf2 = XLSX.readxlsx("mytest.xlsx")
- sheet1 = xf2["Sheet1"]
- @test XLSX.getcell(xf2[1], "A1") == XLSX.Cell(XLSX.get_workbook(xf2), XLSX.CellRef("A1"), "str", "", "", "", true)
- @test XLSX.get_formula_from_cache(sheet1, XLSX.CellRef("A1")) == XLSX.Formula("\"\"")
- isfile("mytest.xlsx") && rm("mytest.xlsx")
+end
+
+@testset "review fixes: formulas" begin
+ @testset "shift_excel_references shifts each reference in place" begin
+ @test XLSX.shift_excel_references("=A1+A10", (1, 0)) == "=A2+A11"
+ @test XLSX.shift_excel_references("=LOG10(A1)+ATAN2(B1,C1)", (4, 0)) == "=LOG10(A5)+ATAN2(B5,C5)"
+ @test XLSX.shift_excel_references("=\$A\$1+A\$1+\$A1", (2, 1)) == "=\$A\$1+B\$1+\$A3"
+ @test XLSX.shift_excel_references("=\"A1\"&'Q1 2024'!B2&DATA2024!C3", (1, 0)) == "=\"A1\"&'Q1 2024'!B3&DATA2024!C4"
+ @test XLSX.shift_excel_references("=SUM(T[QTY1])+B1", (1, 0)) == "=SUM(T[QTY1])+B2"
+ end
+ @testset "getFormula on shared-formula children" begin
+ f = XLSX.newxlsx(); s = f[1]
+ s["A1:A5"] = 1
+ XLSX.setFormula(s, "B1:B5", "=LOG10(A1)+A10")
+ @test XLSX.getFormula(s, "B5") == "=LOG10(A5)+A14"
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "setFormula with 2-argument IF and non-literal OFFSET size" begin
+ f = XLSX.newxlsx(); s = f[1]
+ @test XLSX.setFormula(s, "A1", "=IF(B1>0,1)") == "=IF(B1>0,1)"
+ @test XLSX.setFormula(s, "A2", "=SUM(OFFSET(B1,0,0,C1,D1))") == "=SUM(OFFSET(B1,0,0,C1,D1))"
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "GROUPBY inside a larger formula keeps the rest" begin
+ f = XLSX.newxlsx(); s = f[1]
+ XLSX.setFormula(s, "G1", "=1+GROUPBY(E1:E9,A1:D9,SUM)")
+ @test XLSX.get_formula_from_cache(s, XLSX.CellRef("G1")).formula == "=1+_xlfn.GROUPBY(E1:E9,A1:D9,_xleta.SUM)"
+ end
+ @testset "overwriting the master of a wide shared formula" begin
+ f = XLSX.newxlsx(); s = f[1]
+ s["A1:A5"] = 1
+ XLSX.setFormula(s, "B2:D3", "=A1+1")
+ s["B2"] = 5
+ @test s["B2"] == 5
+ @test XLSX.getFormula(s, "C2") == "=B1+1"
+ @test XLSX.getFormula(s, "D3") == "=C2+1"
+ SAVE_FILES && save_outfile(f)
end
+ @testset "renaming a sheet leaves similarly named sheets alone" begin
+ f = XLSX.newxlsx("Sheet1"); s2 = XLSX.addsheet!(f, "MySheet1"); s3 = XLSX.addsheet!(f, "Other")
+ s2["A1"] = 1
+ XLSX.setFormula(s3, "A1", "=MySheet1!A1+Sheet1!A1")
+ XLSX.setFormula(s2, "B1", "=A1*2")
+ XLSX.renamesheet!(f[1], "Data")
+ @test XLSX.getFormula(s3, "A1") == "=MySheet1!A1+Data!A1"
+ @test XLSX.getFormula(s2, "B1") == "=A1*2"
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "deleting a sheet" begin
+ f = XLSX.newxlsx("Sheet1"); s2 = XLSX.addsheet!(f, "MySheet1")
+ s2["A1"] = 1
+ XLSX.setFormula(f[1], "B1:B3", "=A1+1") # a shared formula on the deleted sheet
+ XLSX.setFormula(s2, "B1", "=A1*2+Sheet1!A1:A2")
+ XLSX.deletesheet!(f, "Sheet1")
+ @test XLSX.getFormula(s2, "B1") == "=A1*2+#REF!"
+ @test XLSX.iserror(s2, "B1")
+ SAVE_FILES && save_outfile(f)
-end
\ No newline at end of file
+ f = XLSX.newxlsx("Q1 (2024)"); s2 = XLSX.addsheet!(f, "Other")
+ XLSX.setFormula(s2, "A1", "='Q1 (2024)'!A1*2")
+ XLSX.deletesheet!(f, "Q1 (2024)")
+ @test XLSX.getFormula(s2, "A1") == "=#REF!*2"
+ SAVE_FILES && save_outfile(f)
+ end
+end
diff --git a/test/test_files/Getcell_tests.jl b/test/test_files/Getcell_tests.jl
index 3f0e99b7..e084a586 100644
--- a/test/test_files/Getcell_tests.jl
+++ b/test/test_files/Getcell_tests.jl
@@ -60,4 +60,60 @@
SAVE_FILES && save_outfile(f)
-end
\ No newline at end of file
+
+ @testset "empty cells, hashing and error strings" begin
+ e = XLSX.EmptyCell(XLSX.CellRef("A1"))
+ @test isempty(e) && !XLSX.iserror(e)
+ @test !isempty(XLSX.Cell(XLSX.CellRef("A1"), UInt64(1), UInt32(0), UInt16(0), XLSX.CT_INT, false))
+ @test hash(e) == hash(XLSX.EmptyCell(XLSX.CellRef("A1")))
+ @test XLSX.get_error_string(nothing) == ""
+ end
+end
+
+@testset "Time and DateTime" begin
+ @test XLSX.excel_value_to_time(0.82291666666666663) == Dates.Time(Dates.Hour(19), Dates.Minute(45))
+ @test XLSX.time_to_excel_value(XLSX.excel_value_to_time(0.2)) == 0.2
+ @test XLSX.excel_value_to_datetime(43206.805447106482, false) == Date(2018, 4, 16) + Dates.Time(Dates.Hour(19), Dates.Minute(19), Dates.Second(51))
+ @test XLSX.excel_value_to_datetime(XLSX.datetime_to_excel_value(Date(2018, 4, 16) + Dates.Time(Dates.Hour(19), Dates.Minute(19), Dates.Second(51)), false), false) == Date(2018, 4, 16) + Dates.Time(Dates.Hour(19), Dates.Minute(19), Dates.Second(51))
+
+ dt = Date(2018, 4, 1)
+ @test XLSX.excel_value_to_date(XLSX.date_to_excel_value(dt, false), false) == dt
+ @test XLSX.excel_value_to_date(XLSX.date_to_excel_value(dt, true), true) == dt
+end
+
+@testset "stream iterator" begin
+ f = XLSX.openxlsx(joinpath(data_directory, "general.xlsx"), enable_cache=false)
+ s = f["table"]
+ for sheetrow in XLSX.eachrow(s)
+ for column in 2:4
+ cell = XLSX.getcell(sheetrow, column)
+ if XLSX.row_number(cell) == 2 && XLSX.column_number(cell) == 2
+ @test XLSX.getdata(s, cell) == "Column B"
+ end
+ if XLSX.row_number(cell) == 12 && XLSX.column_number(cell) == 2
+ @test XLSX.getdata(s, cell) == "trash"
+ end
+ end
+ end
+
+# Issue 446
+ f = XLSX.openxlsx(joinpath(data_directory, "general.xlsx"), enable_cache=false)
+
+ # The stream iterator moves on from row to row (the failure mode was row 1
+ # forever), and yields the rows the file holds, in order.
+ seen = [XLSX.row_number(r) for r in XLSX.eachrow(f[1])]
+ expected = [XLSX.row_number(r) for r in XLSX.eachrow(XLSX.readxlsx(joinpath(data_directory, "general.xlsx"))[1])]
+ @test seen == expected
+ @test length(seen) > 1
+
+end
+
+@testset "Base.eachrow, and the cache flag on every holder" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh[1:3, 1:4] = reshape(collect(1:12), 3, 4)
+
+ @test length(collect(Base.eachrow(sh))) == length(collect(XLSX.eachrow(sh)))
+ itr = XLSX.eachrow(sh)
+ @test XLSX.is_cache_enabled(XLSX.get_workbook(xf)) == XLSX.is_cache_enabled(itr) == XLSX.is_cache_enabled(xf)
+end
diff --git a/test/test_files/Getindex-Setindex_tests.jl b/test/test_files/Getindex-Setindex_tests.jl
index 872e2c89..57d2e3dd 100644
--- a/test/test_files/Getindex-Setindex_tests.jl
+++ b/test/test_files/Getindex-Setindex_tests.jl
@@ -9,20 +9,20 @@
@test sheet1["B8"] == "palavra1"
@test XLSX.getcell(sheet1, "B2") == XLSX.Cell(XLSX.get_workbook(f), XLSX.CellRef("B2"), "s", "", "0", "", false)
- XLSX.getcell(sheet1, "B:C")
- XLSX.getcell(sheet1, "1:2")
- XLSX.getcell(sheet1, 1:2, 1:2)
- XLSX.getcellrange(sheet1, "B2:C3")
- XLSX.getcellrange(f, "Sheet1!B2:C3")
- XLSX.getcellrange(f, "Sheet1!B:C")
- XLSX.getcellrange(f, "Sheet1!2:3")
- XLSX.getcellrange(f, "Sheet1!B2,Sheet1!C3")
- XLSX.getcellrange(sheet1, 2, 2)
- XLSX.getcellrange(sheet1, 2, :)
- XLSX.getcellrange(sheet1, :, 3)
- XLSX.getcellrange(sheet1, 3, :)
- XLSX.getcellrange(sheet1, "B2:C3")
- XLSX.getcellrange(sheet1, "B2,C3:C4")
+ refs(cells) = map(c -> string(c.ref), cells)
+ @test refs(XLSX.getcell(sheet1, "B:C")) == [string(c, r) for r in 2:8, c in "BC"]
+ @test refs(XLSX.getcell(sheet1, "1:2")) == ["B1" "C1"; "B2" "C2"]
+ @test refs(XLSX.getcell(sheet1, 1:2, 1:2)) == ["A1" "B1"; "A2" "B2"]
+ @test refs(XLSX.getcellrange(sheet1, "B2:C3")) == ["B2" "C2"; "B3" "C3"]
+ @test refs(XLSX.getcellrange(f, "Sheet1!B2:C3")) == ["B2" "C2"; "B3" "C3"]
+ @test size(XLSX.getcellrange(f, "Sheet1!B:C")) == (7, 2)
+ @test refs(XLSX.getcellrange(f, "Sheet1!2:3")) == ["B2" "C2"; "B3" "C3"]
+ @test map(refs, XLSX.getcellrange(f, "Sheet1!B2,Sheet1!C3")) == [["B2";;], ["C3";;]]
+ @test refs(XLSX.getcellrange(sheet1, 2, 2)) == ["B2";;]
+ @test refs(XLSX.getcellrange(sheet1, 2, :)) == ["B2" "C2"]
+ @test refs(XLSX.getcellrange(sheet1, :, 3)) == [string(c, r) for r in 2:8, c in "C"]
+ @test refs(XLSX.getcellrange(sheet1, 3, :)) == ["B3" "C3"]
+ @test map(refs, XLSX.getcellrange(sheet1, "B2,C3:C4")) == [["B2";;], ["C3"; "C4";;]]
@test_throws XLSX.XLSXError XLSX.getcellrange(f, "B2:C3")
# a cell can be put in a dict
@@ -43,12 +43,21 @@
@test sheet2[:] == XLSX.getdata(sheet2, :)
@test XLSX.getdata(sheet2, :, [1, 2]) == sheet2["A1:B3"]
+ # getcellrange gives the same cells with and without the cache
f = XLSX.readxlsx(joinpath(data_directory, "Book1.xlsx"))
sheet1 = f["Sheet1"]
- XLSX.getcellrange(sheet1, "B2:C3")
+ @test [XLSX.getdata(sheet1, c) for c in XLSX.getcellrange(sheet1, "B2:C3")] == Any["B2" "C2"; 10.5 21.2]
f = XLSX.openxlsx(joinpath(data_directory, "Book1.xlsx"); enable_cache=false)
sheet1 = f["Sheet1"]
- XLSX.getcellrange(sheet1, "B2:C3")
+ @test [XLSX.getdata(sheet1, c) for c in XLSX.getcellrange(sheet1, "B2:C3")] == Any["B2" "C2"; 10.5 21.2]
+
+ @testset "issue 460: attributes on " begin
+ xf = XLSX.readxlsx(joinpath(data_directory, "issue460_whitespace.xlsx"))
+ sh = xf[1]
+ @test sh["B4"] == " 6000056" # t="str",
+ @test sh["B6"] == 6000056 # numeric,
+ @test all(!ismissing, sh["B2:B7"])
+ end
end
@testset "setindex" begin
@@ -126,7 +135,6 @@ end
@test_throws XLSX.XLSXError s["garbage1:garbage2"] = 1
SAVE_FILES && save_outfile(f)
-
f = XLSX.newxlsx()
s = f[1]
for i in 1:5
@@ -144,11 +152,233 @@ end
@test s[[2, 4], [3, 5]] == [0 0; 0 0]
SAVE_FILES && save_outfile(f)
- @testset "issue 460: attributes on " begin
- xf = XLSX.readxlsx(joinpath(data_directory, "issue460_whitespace.xlsx"))
+
+ @testset "write column" begin
+ filename = "setindex_forms.xlsx"
+ col_data = collect(1:50)
+
+ XLSX.openxlsx(filename, mode="w") do xf
+ sheet = xf[1]
+ sheet[:, 2] = col_data
+ sheet[51:100, 3] = col_data
+ sheet[2, 4, dim=1] = col_data
+ end
+ SAVE_FILES && save_outfile(filename)
+
+ XLSX.openxlsx(filename) do xf
+ sheet = xf[1]
+
+ for (row, val) in enumerate(col_data)
+ @test sheet[row, 2] == val
+ @test sheet[50+row, 3] == val
+ @test sheet[row+1, 4] == val
+ end
+ end
+ end
+
+ @testset "write matrix with anchor cell" begin
+ filename = "setindex_forms.xlsx"
+ test_data = [1 2 3; 4 5 6; 7 8 9]
+ XLSX.openxlsx(filename, mode="w") do xf
+ sheet = xf[1]
+ sheet["A7"] = test_data
+ end
+ SAVE_FILES && save_outfile(filename)
+
+ XLSX.openxlsx(filename) do xf
+ sheet = xf[1]
+ rows, cols = size(test_data)
+ for c in 1:cols, r in 1:rows
+ @test sheet[r+6, c] == test_data[r, c]
+ end
+ end
+
+ xf = XLSX.newxlsx()
+ sheet = xf[1]
+ sheet[7, 1] = test_data
+ XLSX.writexlsx(filename, xf, overwrite=true)
+ SAVE_FILES && save_outfile(filename)
+
+ XLSX.openxlsx(filename) do xf
+ sheet = xf[1]
+ rows, cols = size(test_data)
+ for c in 1:cols, r in 1:rows
+ @test sheet[r+6, c] == test_data[r, c]
+ end
+ end
+ end
+
+ @testset "write matrix with range" begin
+ filename = "setindex_forms.xlsx"
+ test_data = [1 2 3; 4 5 6; 7 8 9]
+ XLSX.openxlsx(filename, mode="w") do xf
+ sheet = xf[1]
+ sheet["A7:C9"] = test_data
+ end
+ SAVE_FILES && save_outfile(filename)
+
+ XLSX.openxlsx(filename) do xf
+ sheet = xf[1]
+ rows, cols = size(test_data)
+ for c in 1:cols, r in 1:rows
+ @test sheet[r+6, c] == test_data[r, c]
+ end
+ end
+
+ xf = XLSX.newxlsx()
+ sheet = xf[1]
+ sheet[7:9, 1:3] = test_data
+ XLSX.writexlsx(filename, xf, overwrite=true)
+ SAVE_FILES && save_outfile(filename)
+
+ XLSX.openxlsx(filename) do xf
+ sheet = xf[1]
+ rows, cols = size(test_data)
+ for c in 1:cols, r in 1:rows
+ @test sheet[r+6, c] == test_data[r, c]
+ end
+ end
+ end
+
+ @testset "write matrix with range mismatch" begin
+ filename = "setindex_forms.xlsx"
+ test_data = [1 2 3; 4 5 6; 7 8 9]
+ XLSX.openxlsx(filename, mode="w") do xf
+ sheet = xf[1]
+ @test_throws XLSX.XLSXError sheet["A7:C10"] = test_data
+ end
+ SAVE_FILES && save_outfile(filename)
+ end
+
+ @testset "write matrix with heterogeneous data types" begin
+ filename = "setindex_forms.xlsx"
+ # issue #97
+ test_data = ["A" "B"; 1 2; "a" "b"; 0 "x"]
+ XLSX.openxlsx(filename, mode="w") do xf
+ sheet = xf[1]
+ sheet["B2"] = test_data
+ end
+ SAVE_FILES && save_outfile(filename)
+
+ XLSX.openxlsx(filename, mode="r") do xf
+ sheet = xf[1]
+ @test sheet["B2"] == "A"
+ @test sheet["C2"] == "B"
+ @test sheet["B3"] == 1
+ @test sheet["C3"] == 2
+ @test sheet["B4"] == "a"
+ @test sheet["C4"] == "b"
+ @test sheet["B5"] == 0
+ @test sheet["C5"] == "x"
+ end
+ end
+
+ @testset "setdata! - references" begin
+ xf = XLSX.newxlsx()
sh = xf[1]
- @test sh["B4"] == " 6000056" # t="str",
- @test sh["B6"] == 6000056 # numeric,
- @test all(!ismissing, sh["B2:B7"])
+ sh[1:3, 1:3] = reshape(collect(1:9), 3, 3)
+
+ XLSX.addDefName(sh, "ws_const", 5)
+ XLSX.addDefName(xf, "wb_const", 6)
+ @test_throws XLSX.XLSXError XLSX.setdata!(sh, "ws_const", 1)
+ @test_throws XLSX.XLSXError XLSX.setdata!(sh, "wb_const", 1)
+ @test_throws XLSX.XLSXError XLSX.setdata!(sh, "not a ref", 1)
+
+ XLSX.setdata!(sh, "B:B", 0)
+ @test all(==(0), sh["B1:B3"])
+ XLSX.setdata!(sh, "3:3", 7)
+ @test all(==(7), sh["A3:C3"])
+ XLSX.setdata!(sh, "A1,C1", 9)
+ @test sh["A1"] == 9 && sh["C1"] == 9
+
+ @test_throws XLSX.XLSXError XLSX.setdata!(sh, "not a ref", [1 2; 3 4])
+ @test_throws XLSX.XLSXError XLSX.setdata!(sh, "not a ref", [1, 2], 1) # was an UndefVarError
+ @test_throws XLSX.XLSXError XLSX.target_cell_ref_from_offset(1, 1, 1, 3)
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "setdata! - a new value restyles an existing cell" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh["A1"] = 1.5
+ XLSX.setFormat(sh, "A1"; format="0.00")
+ sh["A1"] = Dates.Time(12, 30)
+ @test sh["A1"] == Dates.Time(12, 30)
+ sh["A2"] = 1.5
+ XLSX.setFormat(sh, "A2"; format="0.00")
+ sh["A2"] = Dates.DateTime(2024, 1, 2, 3, 4)
+ @test sh["A2"] == Dates.DateTime(2024, 1, 2, 3, 4)
+ sh["A3"] = Dates.Date(2024, 1, 2)
+ sh["A3"] = true
+ @test sh["A3"] === true
+
+ XLSX.setdata!(sh, XLSX.CellRef("B1"), XLSX.Formula("A2+1")) # new cell
+ @test XLSX.getcell(sh, "B1").formula
+ sh["B2"] = 1
+ XLSX.setFormat(sh, "B2"; format="0.00")
+ XLSX.setdata!(sh, XLSX.CellRef("B2"), XLSX.Formula("A2+2")) # styled cell
+ @test XLSX.getcell(sh, "B2").formula
+ SAVE_FILES && save_outfile(xf)
end
-end
\ No newline at end of file
+
+ @testset "setdata! argument forms" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh[1:3, 1:4] = reshape(collect(1:12), 3, 4)
+
+ # setdata! forms
+ XLSX.setdata!(sh, XLSX.CellRef("A5"), Int32(7))
+ @test sh["A5"] === Int64(7)
+ XLSX.setdata!(sh, 6, 1, XLSX.CellValue(sh, "six"))
+ @test sh["A6"] == "six"
+ XLSX.setdata!(sh, 7, 1:2, [1, 2])
+ @test sh["A7"] == 1 && sh["B7"] == 2
+ @test XLSX.CellValue(Int32(3), XLSX.EmptyCellDataFormat()).value === Int64(3)
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ # A vector is written along one row or column, or across from one cell; anywhere
+ # else it was an "ambiguous" MethodError, and is now an XLSXError saying so.
+ @testset "vector values in ws[r, c] = v" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh["A1:D4"] = ""
+ sh[2, 2] = [1, 2, 3] # across from B2, as sh["B2"] = v
+ @test [sh[2, c] for c in 2:4] == [1, 2, 3]
+ sh[3, 1:2] = [5, 6]
+ @test [sh[3, 1], sh[3, 2]] == [5, 6]
+ sh[:, 4] = [7, 8]
+ @test [sh[1, 4], sh[2, 4]] == [7, 8]
+ for (r, c) in ((1:2, 1:2), (1, [1, 3]), ([1, 3], 2), (1:2, :), (:, :), (1:2:3, 1:2:3))
+ @test_throws XLSX.XLSXError (sh[r, c] = [1, 2])
+ end
+ sh[1, [1, 3]] = 9 # any other value still fills each cell
+ @test sh[1, 1] == 9 && sh[1, 3] == 9
+ end
+
+ # The cache keeps written rows unsorted until something needs their order. Reading a
+ # range must sort them first: it once binary-searched them unsorted, returning wrong
+ # values or writing out of bounds (and crashing Julia's GC later).
+ @testset "getdata over a range after rows written out of order" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ for r in (5, 1, 9, 2, 3)
+ sh[r, 1] = r
+ end
+ @test vec(XLSX.getdata(sh, "A2:A3")) == [2, 3]
+
+ rng = Random.Xoshiro(1)
+ for _ in 1:200
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ rows = Random.shuffle(rng, 1:12)[1:rand(rng, 3:10)]
+ for r in rows
+ sh[r, 1] = r
+ end
+ top = rand(rng, 1:10)
+ bottom = rand(rng, top:12)
+ got = vec(XLSX.getdata(sh, XLSX.CellRange(top, 1, bottom, 1)))
+ @test isequal(got, [r in rows ? r : missing for r in top:bottom])
+ end
+ end
+end
diff --git a/test/test_files/Images_tests.jl b/test/test_files/Images_tests.jl
index 3ee1c208..9e2fc48e 100644
--- a/test/test_files/Images_tests.jl
+++ b/test/test_files/Images_tests.jl
@@ -30,6 +30,13 @@
@test startswith(info.media_name, "image")
@test xf.binary_data["xl/media/" * info.media_name] == bytes
@test haskey(xf.data, "xl/drawings/drawing1.xml")
+
+ # A writable buffer is left unchanged
+ buf = IOBuffer(); write(buf, bytes); pos = position(buf)
+ info2 = XLSX.addImage(s, "H2", buf)
+ @test xf.binary_data["xl/media/" * info2.media_name] == bytes
+ @test position(buf) == pos
+ @test take!(buf) == bytes
SAVE_FILES && save_outfile(xf)
end
@@ -180,11 +187,19 @@
@test imgs2[1].sheet == "Sheet1"
isfile(tmp) && rm(tmp)
end
- @testset "prefixed attribute lookup" begin
- blip = parse("""""", XML.Node)[end]
- @test XLSX.get_prefixed_attr(blip, "embed") == "rId7"
- @test XLSX.get_prefixed_attr(blip, "cstate") === nothing # unprefixed: skipped by design
- @test XLSX.get_prefixed_attr(blip, "id") === nothing
- end
-end
\ No newline at end of file
+end
+
+@testset "review fixes: a new image never reuses a taken media name" begin
+ png = read(joinpath(data_directory, "track_start.png"))
+ jpg = read(joinpath(data_directory, "track_start.jpg"))
+ f = XLSX.newxlsx("S1"); s2 = XLSX.addsheet!(f, "S2")
+ XLSX.addImage(f[1], "A1", IOBuffer(png)) # image1.png
+ i2 = XLSX.addImage(s2, "A1", IOBuffer(jpg)) # used by S2
+ XLSX.deletesheet!(f, "S1") # image1.png goes
+ i3 = XLSX.addImage(s2, "D1", IOBuffer(png))
+ @test i3.media_name != i2.media_name
+ @test f.binary_data["xl/media/$(i2.media_name)"] == jpg
+ @test f.binary_data["xl/media/$(i3.media_name)"] == png
+ SAVE_FILES && save_outfile(f)
+end
diff --git a/test/test_files/Merge_tests.jl b/test/test_files/Merge_tests.jl
index de4a399c..622f621f 100644
--- a/test/test_files/Merge_tests.jl
+++ b/test/test_files/Merge_tests.jl
@@ -1,135 +1,156 @@
@testset "merged cells" begin
- XLSX.openxlsx(joinpath(data_directory, "customXml.xlsx")) do f
- @test_throws XLSX.XLSXError XLSX.getMergedCells(f["Mock-up"]) # File isn't writeable
- end
- f = XLSX.openxlsx(joinpath(data_directory, "customXml.xlsx"); mode="rw")
- mc = sort(XLSX.getMergedCells(f["Mock-up"]))
- @test length(mc) == 25
- @test mc == sort(XLSX.CellRange[XLSX.CellRange("D49:H49"), XLSX.CellRange("D72:J72"), XLSX.CellRange("F94:J94"), XLSX.CellRange("F96:J96"), XLSX.CellRange("F84:J84"), XLSX.CellRange("F86:J86"), XLSX.CellRange("D62:J63"), XLSX.CellRange("D51:J53"), XLSX.CellRange("D55:J60"), XLSX.CellRange("D92:J92"), XLSX.CellRange("D82:J82"), XLSX.CellRange("D74:J74"), XLSX.CellRange("D67:J68"), XLSX.CellRange("D47:H47"), XLSX.CellRange("D9:H9"), XLSX.CellRange("D11:G11"), XLSX.CellRange("D12:G12"), XLSX.CellRange("D14:E14"), XLSX.CellRange("D16:E16"), XLSX.CellRange("D32:F32"), XLSX.CellRange("D38:J38"), XLSX.CellRange("D34:J34"), XLSX.CellRange("D18:E18"), XLSX.CellRange("D20:E20"), XLSX.CellRange("D13:G13")])
- s = f["Mock-up"]
- @test XLSX.isMergedCell(f, "Mock-up!D47")
- @test XLSX.isMergedCell(f, "Mock-up!D49"; mergedCells=mc)
- @test XLSX.isMergedCell(s, "H84")
- @test XLSX.isMergedCell(s, "G84"; mergedCells=mc)
- @test XLSX.isMergedCell(s, "Short_Description")
- @test !XLSX.isMergedCell(f, "Mock-up!B2")
- @test !XLSX.isMergedCell(s, "H40"; mergedCells=mc)
- @test !XLSX.isMergedCell(s, "ID"; mergedCells=mc)
- @test_throws XLSX.XLSXError XLSX.isMergedCell(s, "Contiguous"; mergedCells=mc) # Can't test a range
- @test_throws XLSX.XLSXError XLSX.getMergedBaseCell(s, "Location")
-
- @test XLSX.getMergedBaseCell(f[1], "F72") == (baseCell=XLSX.CellRef("D72"), baseValue=Dates.Date("2025-03-24"))
- @test XLSX.getMergedBaseCell(f, "Mock-up!G72") == (baseCell=XLSX.CellRef("D72"), baseValue=Dates.Date("2025-03-24"))
- @test XLSX.getMergedBaseCell(s, "H53") == (baseCell=XLSX.CellRef("D51"), baseValue="Hello World")
- @test XLSX.getMergedBaseCell(s, "G52") == (baseCell=XLSX.CellRef("D51"), baseValue="Hello World")
- @test XLSX.getMergedBaseCell(s, 53, 8) == (baseCell=XLSX.CellRef("D51"), baseValue="Hello World")
- @test XLSX.getMergedBaseCell(s, "Short_Description") == (baseCell=XLSX.CellRef("D51"), baseValue="Hello World")
- @test isnothing(XLSX.getMergedBaseCell(s, "F73"))
- @test isnothing(XLSX.getMergedBaseCell(f, "Mock-up!H73"))
- @test_throws XLSX.XLSXError XLSX.getMergedBaseCell(s, "Location") # Can't get base cell for a range
+ @testset "reading merged cells" begin
+ XLSX.openxlsx(joinpath(data_directory, "customXml.xlsx")) do f
+ @test_throws XLSX.XLSXError XLSX.getMergedCells(f["Mock-up"]) # File isn't writeable
+ end
+ f = XLSX.openxlsx(joinpath(data_directory, "customXml.xlsx"); mode="rw")
+ mc = sort(XLSX.getMergedCells(f["Mock-up"]))
+ @test length(mc) == 25
+ @test mc == sort(XLSX.CellRange[XLSX.CellRange("D49:H49"), XLSX.CellRange("D72:J72"), XLSX.CellRange("F94:J94"), XLSX.CellRange("F96:J96"), XLSX.CellRange("F84:J84"), XLSX.CellRange("F86:J86"), XLSX.CellRange("D62:J63"), XLSX.CellRange("D51:J53"), XLSX.CellRange("D55:J60"), XLSX.CellRange("D92:J92"), XLSX.CellRange("D82:J82"), XLSX.CellRange("D74:J74"), XLSX.CellRange("D67:J68"), XLSX.CellRange("D47:H47"), XLSX.CellRange("D9:H9"), XLSX.CellRange("D11:G11"), XLSX.CellRange("D12:G12"), XLSX.CellRange("D14:E14"), XLSX.CellRange("D16:E16"), XLSX.CellRange("D32:F32"), XLSX.CellRange("D38:J38"), XLSX.CellRange("D34:J34"), XLSX.CellRange("D18:E18"), XLSX.CellRange("D20:E20"), XLSX.CellRange("D13:G13")])
+ s = f["Mock-up"]
+ @test XLSX.isMergedCell(f, "Mock-up!D47")
+ @test XLSX.isMergedCell(f, "Mock-up!D49"; mergedCells=mc)
+ @test XLSX.isMergedCell(s, "H84")
+ @test XLSX.isMergedCell(s, "G84"; mergedCells=mc)
+ @test XLSX.isMergedCell(s, "Short_Description")
+ @test !XLSX.isMergedCell(f, "Mock-up!B2")
+ @test !XLSX.isMergedCell(s, "H40"; mergedCells=mc)
+ @test !XLSX.isMergedCell(s, "ID"; mergedCells=mc)
+ @test_throws XLSX.XLSXError XLSX.isMergedCell(s, "Contiguous"; mergedCells=mc) # Can't test a range
+ @test_throws XLSX.XLSXError XLSX.getMergedBaseCell(s, "Location")
- @test isnothing(XLSX.getMergedCells(f["Document History"]))
- s = f["Document History"]
- @test !XLSX.isMergedCell(f, "Document History!B2")
- @test !XLSX.isMergedCell(s, "C5"; mergedCells=XLSX.getMergedCells(f["Document History"]))
+ @test XLSX.getMergedBaseCell(f[1], "F72") == (baseCell=XLSX.CellRef("D72"), baseValue=Dates.Date("2025-03-24"))
+ @test XLSX.getMergedBaseCell(f, "Mock-up!G72") == (baseCell=XLSX.CellRef("D72"), baseValue=Dates.Date("2025-03-24"))
+ @test XLSX.getMergedBaseCell(s, "H53") == (baseCell=XLSX.CellRef("D51"), baseValue="Hello World")
+ @test XLSX.getMergedBaseCell(s, "G52") == (baseCell=XLSX.CellRef("D51"), baseValue="Hello World")
+ @test XLSX.getMergedBaseCell(s, 53, 8) == (baseCell=XLSX.CellRef("D51"), baseValue="Hello World")
+ @test XLSX.getMergedBaseCell(s, "Short_Description") == (baseCell=XLSX.CellRef("D51"), baseValue="Hello World")
+ @test isnothing(XLSX.getMergedBaseCell(s, "F73"))
+ @test isnothing(XLSX.getMergedBaseCell(f, "Mock-up!H73"))
+ @test_throws XLSX.XLSXError XLSX.getMergedBaseCell(s, "Location") # Can't get base cell for a range
- SAVE_FILES && save_outfile(f)
- f = XLSX.opentemplate(joinpath(data_directory, "testmerge.xlsx"))
- @test XLSX.mergeCells(f, "Sheet1!A1:B2") == 0
- @test f[1]["A1"] == "Tables"
- @test ismissing(f[1]["B2"])
- @test f[1]["C3"] == 4
- @test XLSX.mergeCells(f[1], 4:6, 4:6) == 0
- @test f[1][4, 4] == 9
- @test ismissing(f[1][5, 5])
- @test f[1][7, 7] == 36
- @test XLSX.mergeCells(f[1], "J") == 0
- @test f[1]["J1"] == 9
- @test ismissing(f[1]["J2"])
- @test ismissing(f[1]["J12"])
- @test XLSX.isMergedCell(f[1], "J8")
- mc = XLSX.getMergedCells(f["Sheet1"])
- @test XLSX.isMergedCell(f[1], "J9"; mergedCells=mc)
- @test XLSX.getMergedBaseCell(f[1], "J12") == (baseCell=XLSX.CellRef("J1"), baseValue=9)
-
- @test_throws XLSX.XLSXError XLSX.mergeCells(f[1], "Sheet1!M13:M13") # Single cell
- @test_throws XLSX.XLSXError XLSX.mergeCells(f[1], 1, :) # Overlapping
- @test_throws XLSX.XLSXError XLSX.mergeCells(f[1], 10, :) # Overlapping
- @test_throws XLSX.XLSXError XLSX.mergeCells(f["Sheet1"], "M1:P15") # Outside dimension
- @test_throws XLSX.XLSXError XLSX.mergeCells(f["Sheet1"], "Sheet2!L1:M2") # Sheets don't match
-
- XLSX.writexlsx("outfile.xlsx", f, overwrite=true)
- SAVE_FILES && save_outfile("outfile.xlsx")
-
- XLSX.openxlsx("outfile.xlsx"; mode="rw") do f
- mc = sort(XLSX.getMergedCells(f["Sheet1"]))
- @test length(mc) == 3
- @test mc == sort(XLSX.CellRange[XLSX.CellRange("A1:B2"), XLSX.CellRange("D4:F6"), XLSX.CellRange("J1:J13")])
- @test XLSX.isMergedCell(f[1], "B2")
- @test XLSX.isMergedCell(f[1], 6, 6; mergedCells=mc)
- @test XLSX.getMergedBaseCell(f[1], "F6") == (baseCell=XLSX.CellRef("D4"), baseValue=9)
+ @test isnothing(XLSX.getMergedCells(f["Document History"]))
+ s = f["Document History"]
+ @test !XLSX.isMergedCell(f, "Document History!B2")
+ @test !XLSX.isMergedCell(s, "C5"; mergedCells=XLSX.getMergedCells(f["Document History"]))
+
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "mergeCells, and a round trip" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "testmerge.xlsx"))
+ @test XLSX.mergeCells(f, "Sheet1!A1:B2") == 0
@test f[1]["A1"] == "Tables"
@test ismissing(f[1]["B2"])
@test f[1]["C3"] == 4
+ @test XLSX.mergeCells(f[1], 4:6, 4:6) == 0
@test f[1][4, 4] == 9
@test ismissing(f[1][5, 5])
@test f[1][7, 7] == 36
+ @test XLSX.mergeCells(f[1], "J") == 0
@test f[1]["J1"] == 9
@test ismissing(f[1]["J2"])
@test ismissing(f[1]["J12"])
@test XLSX.isMergedCell(f[1], "J8")
- @test XLSX.isMergedCell(f[1], "J9"; mergedCells=XLSX.getMergedCells(f["Sheet1"]))
+ mc = XLSX.getMergedCells(f["Sheet1"])
+ @test XLSX.isMergedCell(f[1], "J9"; mergedCells=mc)
@test XLSX.getMergedBaseCell(f[1], "J12") == (baseCell=XLSX.CellRef("J1"), baseValue=9)
- end
- SAVE_FILES && save_outfile("outfile.xlsx")
- isfile("outfile.xlsx") && rm("outfile.xlsx")
- f = XLSX.newxlsx()
- s = f[1]
- for i in 1:3, j in 1:3
- s[i, j] = i + j
- end
- XLSX.mergeCells(s, "Sheet1!A:B")
- @test XLSX.getMergedBaseCell(f, "Sheet1!B2") == (baseCell=XLSX.CellRef("A1"), baseValue=2)
- SAVE_FILES && save_outfile(f)
+ @test_throws XLSX.XLSXError XLSX.mergeCells(f[1], "Sheet1!M13:M13") # Single cell
+ @test_throws XLSX.XLSXError XLSX.mergeCells(f[1], 1, :) # Overlapping
+ @test_throws XLSX.XLSXError XLSX.mergeCells(f[1], 10, :) # Overlapping
+ @test_throws XLSX.XLSXError XLSX.mergeCells(f["Sheet1"], "M1:P15") # Outside dimension
+ @test_throws XLSX.XLSXError XLSX.mergeCells(f["Sheet1"], "Sheet2!L1:M2") # Sheets don't match
- f = XLSX.newxlsx()
- s = f[1]
- for i in 1:4, j in 1:4
- s[i, j] = i + j
- end
- XLSX.mergeCells(s, "Sheet1!2:3")
- @test XLSX.getMergedBaseCell(f, "Sheet1!C3") == (baseCell=XLSX.CellRef("A2"), baseValue=3)
- XLSX.mergeCells(s, "Sheet1!4:4")
- @test XLSX.getMergedBaseCell(f, "Sheet1!C4") == (baseCell=XLSX.CellRef("A4"), baseValue=5)
- SAVE_FILES && save_outfile(f)
+ XLSX.writexlsx("outfile.xlsx", f, overwrite=true)
+ SAVE_FILES && save_outfile("outfile.xlsx")
- f = XLSX.newxlsx()
- s = f[1]
- for i in 1:3, j in 1:3
- s[i, j] = i + j
+ XLSX.openxlsx("outfile.xlsx"; mode="rw") do f
+ mc = sort(XLSX.getMergedCells(f["Sheet1"]))
+ @test length(mc) == 3
+ @test mc == sort(XLSX.CellRange[XLSX.CellRange("A1:B2"), XLSX.CellRange("D4:F6"), XLSX.CellRange("J1:J13")])
+ @test XLSX.isMergedCell(f[1], "B2")
+ @test XLSX.isMergedCell(f[1], 6, 6; mergedCells=mc)
+ @test XLSX.getMergedBaseCell(f[1], "F6") == (baseCell=XLSX.CellRef("D4"), baseValue=9)
+ @test f[1]["A1"] == "Tables"
+ @test ismissing(f[1]["B2"])
+ @test f[1]["C3"] == 4
+ @test f[1][4, 4] == 9
+ @test ismissing(f[1][5, 5])
+ @test f[1][7, 7] == 36
+ @test f[1]["J1"] == 9
+ @test ismissing(f[1]["J2"])
+ @test ismissing(f[1]["J12"])
+ @test XLSX.isMergedCell(f[1], "J8")
+ @test XLSX.isMergedCell(f[1], "J9"; mergedCells=XLSX.getMergedCells(f["Sheet1"]))
+ @test XLSX.getMergedBaseCell(f[1], "J12") == (baseCell=XLSX.CellRef("J1"), baseValue=9)
+ end
+ SAVE_FILES && save_outfile("outfile.xlsx")
+ isfile("outfile.xlsx") && rm("outfile.xlsx")
end
- XLSX.mergeCells(s, :, 2:3)
- @test XLSX.getMergedBaseCell(f, "Sheet1!C3") == (baseCell=XLSX.CellRef("B1"), baseValue=3)
- SAVE_FILES && save_outfile(f)
- f = XLSX.newxlsx()
- s = f[1]
- for i in 1:3, j in 1:3
- s[i, j] = i + j
+ @testset "whole-row, whole-column and whole-sheet merges" begin
+ f = XLSX.newxlsx()
+ s = f[1]
+ for i in 1:3, j in 1:3
+ s[i, j] = i + j
+ end
+ XLSX.mergeCells(s, "Sheet1!A:B")
+ @test XLSX.getMergedBaseCell(f, "Sheet1!B2") == (baseCell=XLSX.CellRef("A1"), baseValue=2)
+ SAVE_FILES && save_outfile(f)
+
+ f = XLSX.newxlsx()
+ s = f[1]
+ for i in 1:4, j in 1:4
+ s[i, j] = i + j
+ end
+ XLSX.mergeCells(s, "Sheet1!2:3")
+ @test XLSX.getMergedBaseCell(f, "Sheet1!C3") == (baseCell=XLSX.CellRef("A2"), baseValue=3)
+ XLSX.mergeCells(s, "Sheet1!4:4")
+ @test XLSX.getMergedBaseCell(f, "Sheet1!C4") == (baseCell=XLSX.CellRef("A4"), baseValue=5)
+ SAVE_FILES && save_outfile(f)
+
+ f = XLSX.newxlsx()
+ s = f[1]
+ for i in 1:3, j in 1:3
+ s[i, j] = i + j
+ end
+ XLSX.mergeCells(s, :, 2:3)
+ @test XLSX.getMergedBaseCell(f, "Sheet1!C3") == (baseCell=XLSX.CellRef("B1"), baseValue=3)
+ SAVE_FILES && save_outfile(f)
+
+ f = XLSX.newxlsx()
+ s = f[1]
+ for i in 1:3, j in 1:3
+ s[i, j] = i + j
+ end
+ XLSX.mergeCells(s, :, :)
+ @test XLSX.getMergedBaseCell(f, "Sheet1!B2") == (baseCell=XLSX.CellRef("A1"), baseValue=2)
+ SAVE_FILES && save_outfile(f)
+
+ f = XLSX.newxlsx()
+ s = f[1]
+ for i in 1:3, j in 1:3
+ s[i, j] = i + j
+ end
+ XLSX.mergeCells(s, :)
+ @test XLSX.getMergedBaseCell(f, "Sheet1!B2") == (baseCell=XLSX.CellRef("A1"), baseValue=2)
+ SAVE_FILES && save_outfile(f)
end
- XLSX.mergeCells(s, :, :)
- @test XLSX.getMergedBaseCell(f, "Sheet1!B2") == (baseCell=XLSX.CellRef("A1"), baseValue=2)
- SAVE_FILES && save_outfile(f)
- f = XLSX.newxlsx()
- s = f[1]
- for i in 1:3, j in 1:3
- s[i, j] = i + j
+ @testset "cells outside the dimension, and row heights" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh[1:2, 1:2] = [1 2; 3 4]
+ @test XLSX.isMergedCell(sh, "Z99") === false # outside the dimension: empty, not merged
+ @test isnothing(XLSX.getMergedBaseCell(sh, "Z99"))
+ @test isnothing(XLSX.getMergedBaseCell(sh, "A1")) # no merged cells at all
+
+ @test_throws XLSX.XLSXError XLSX.setRowHeight(sh, "A1"; height=-1)
+ @test XLSX.setRowHeight(sh, "A1") == 0 # no height: no-op
+ @test XLSX.setRowHeight(sh, "A10"; height=20) == -1 # row has no cells
+ SAVE_FILES && save_outfile(xf)
end
- XLSX.mergeCells(s, :)
- @test XLSX.getMergedBaseCell(f, "Sheet1!B2") == (baseCell=XLSX.CellRef("A1"), baseValue=2)
- SAVE_FILES && save_outfile(f)
end
@testset "unmerge cells" begin
@@ -237,4 +258,44 @@ end
SAVE_FILES && save_outfile(f2)
SAVE_FILES && save_outfile(g)
SAVE_FILES && save_outfile(f)
+
+ @testset "every argument form names the merged range" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh[1:3, 1:4] = reshape(collect(1:12), 3, 4)
+ XLSX.setdata!(sh, XLSX.CellRef("A5"), Int32(7))
+ XLSX.setdata!(sh, 6, 1, XLSX.CellValue(sh, "six"))
+ XLSX.setdata!(sh, 7, 1:2, [1, 2])
+
+ # merged cells: each argument form names exactly the merged range, so unmerges it
+ merged() = something(XLSX.getMergedCells(sh), XLSX.CellRange[])
+ for (rng, unmerge) in (("A1:A7", () -> XLSX.removeMergedCells(sh, XLSX.ColumnRange("A:A"))),
+ ("A1:A7", () -> XLSX.removeMergedCells(sh, XLSX.SheetColumnRange("Sheet1!A:A"))),
+ ("A1:A7", () -> XLSX.removeMergedCells(sh, :, 1)),
+ ("A1:D1", () -> XLSX.removeMergedCells(sh, XLSX.RowRange("1:1"))),
+ ("A1:D1", () -> XLSX.removeMergedCells(sh, XLSX.SheetRowRange("Sheet1!1:1"))),
+ ("A1:D1", () -> XLSX.removeMergedCells(sh, 1, :)),
+ ("A1:D7", () -> XLSX.removeMergedCells(sh, :, :)))
+ XLSX.mergeCells(sh, rng)
+ @test length(merged()) == 1
+ unmerge()
+ @test isempty(merged())
+ end
+ XLSX.mergeCells(sh, XLSX.NonContiguousRange("Sheet1!A1:B2,Sheet1!C1:D2"))
+ @test length(merged()) == 2
+ XLSX.removeMergedCells(sh)
+ @test isempty(merged())
+ SAVE_FILES && save_outfile(xf)
+ end
+end
+
+@testset "review fixes: mergeCells goes where the schema puts it" begin
+ f = XLSX.newxlsx(); s = f[1]; s["A1:C3"] = 1
+ doc = XLSX.xmlroot(f.workbook, s.relationship_id)
+ k, l = XLSX.get_idces(doc, "worksheet", "sheetData")
+ insert!(doc[k].children, l + 1, XML.Element("autoFilter"; ref="A1:C3"))
+ XLSX.mergeCells(s, "A1:B1")
+ names = [XLSX.localname(c) for c in XLSX.xml_elements(doc[k])]
+ @test findfirst(==("autoFilter"), names) < findfirst(==("mergeCells"), names)
+ SAVE_FILES && save_outfile(f)
end
diff --git a/test/test_files/Namespace_tests.jl b/test/test_files/Namespace_tests.jl
index 4398e3c4..22006ab8 100644
--- a/test/test_files/Namespace_tests.jl
+++ b/test/test_files/Namespace_tests.jl
@@ -1,3 +1,5 @@
+_root_of(s::AbstractString) = XLSX.xml_root_element(parse(s, XLSX.XML.Node))
+
@testset "no default namespace" begin
# issues #380, #362, #267, #170
@@ -41,8 +43,8 @@
@test XLSX.getFormula(f2[2], "B16") == "=sum(B2:B15)"
@test XLSX.getFormula(f2[2], "M16") == "=sum(M2:M15)"
- @test XLSX.hassheet(f, "newSheet")
- @test XLSX.getImages(f["newSheet"]) == [(sheet = "newSheet", media_name = "image1.jpg", from = "B17", to = "E27")]
+ @test XLSX.hassheet(f2, "newSheet")
+ @test XLSX.getImages(f2["newSheet"]) == [(sheet = "newSheet", media_name = "image1.jpg", from = "B17", to = "E27")]
for row = 1:16
for col = 1:14
@@ -85,7 +87,6 @@
XLSX.writexlsx("mytest.xlsx", f, overwrite=true)
SAVE_FILES && save_outfile("mytest.xlsx")
- SAVE_FILES && save_outfile(f2)
f2 = XLSX.readxlsx("mytest.xlsx")
@test XLSX.get_dimension(f2[1])==XLSX.CellRange("A1:R1001")
@@ -133,4 +134,76 @@
end
end
isfile("mytest.xlsx") && rm("mytest.xlsx")
-end
\ No newline at end of file
+end
+
+@testset "namespace resolution" begin
+ @testset "get_default_namespace - single prefixed namespace" begin
+ r = _root_of("""""")
+ @test XLSX.get_default_namespace(r) == _TRANS_MAIN
+ end
+
+ @testset "get_default_namespace - unprefixed default preferred" begin
+ r = _root_of("""""")
+ @test XLSX.get_default_namespace(r) == _TRANS_MAIN
+ end
+
+ @testset "get_default_namespace - prefixed spreadsheet ns as fallback" begin
+ # No unprefixed default (issues #380/#362/#267/#170)
+ r = _root_of("""""")
+ @test XLSX.get_default_namespace(r) == _TRANS_MAIN
+ end
+
+ @testset "get_default_namespace - none found errors" begin
+ r = _root_of("""""")
+ @test_throws XLSX.XLSXError XLSX.get_default_namespace(r)
+ end
+
+ @testset "get_default_namespace_prefix - no namespaces at all" begin
+ r = _root_of("")
+ @test isnothing(XLSX.get_default_namespace_prefix(r))
+
+ r2 = _root_of("""""")
+ @test isnothing(XLSX.get_default_namespace_prefix(r2))
+ end
+
+ @testset "build_ns_dict! - part still held as a raw String" begin
+ # Covers the `val isa String` branch: a read part whose data hasn't been
+ # parsed yet has its prefix sniffed from the raw text instead.
+ xf = XLSX.newxlsx()
+ f = "xl/styles.xml"
+ @test haskey(xf.files, f)
+
+ xf.data[f] = """"""
+ delete!(xf.namespace, f)
+
+ XLSX.build_ns_dict!(xf)
+ @test xf.namespace[f] == "x"
+ @test xf.data[f] isa String # left unparsed by build_ns_dict!
+ end
+
+ @testset "_get_ns_prefix_from_string - default vs prefixed vs absent" begin
+ @test isnothing(XLSX._get_ns_prefix_from_string(nothing))
+ @test isnothing(XLSX._get_ns_prefix_from_string(""""""))
+ @test XLSX._get_ns_prefix_from_string("""""") == "x"
+ @test isnothing(XLSX._get_ns_prefix_from_string(""""""))
+ end
+
+ @testset "get_sst_prefix - prefixed shared strings namespace" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+
+ xf.namespace["xl/sharedStrings.xml"] = "x"
+ @test XLSX.get_sst_prefix(sh) == "x:"
+
+ xf.namespace["xl/sharedStrings.xml"] = nothing
+ @test XLSX.get_sst_prefix(sh) == ""
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "prefixed attribute lookup" begin
+ blip = parse("""""", XML.Node)[end]
+ @test XLSX.get_prefixed_attr(blip, "embed") == "rId7"
+ @test XLSX.get_prefixed_attr(blip, "cstate") === nothing # unprefixed: skipped by design
+ @test XLSX.get_prefixed_attr(blip, "id") === nothing
+ end
+end
diff --git a/test/test_files/Panes_tests.jl b/test/test_files/Panes_tests.jl
index 03d2becd..9e8bfb37 100644
--- a/test/test_files/Panes_tests.jl
+++ b/test/test_files/Panes_tests.jl
@@ -370,7 +370,7 @@
src = joinpath(data_directory, "No-Default_NameSpace.xlsx")
@test isfile(src)
- tmp = joinpath(data_directory, "mytest.xlsx")
+ tmp = "panes_namespaced.xlsx"
try
# -- Round 1: open the pristine fixture, apply panes to both
@@ -431,4 +431,18 @@
end
end
+
+ @testset "splitFreeze from an anchor cell" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh[1:3, 1:4] = reshape(collect(1:12), 3, 4)
+
+ XLSX.splitFreeze(sh, "B3")
+ doc = XLSX.get_worksheet_xml_document(sh)
+ i, j = XLSX.get_idces(doc, "worksheet", "sheetViews")
+ k, l = XLSX.get_idces(doc[i][j], "sheetView", "pane")
+ pane = doc[i][j][k][l]
+ @test pane["topLeftCell"] == "B3" && pane["xSplit"] == "1" && pane["ySplit"] == "2"
+ SAVE_FILES && save_outfile(xf)
+ end
end
diff --git a/test/test_files/Ranges_tests.jl b/test/test_files/Ranges_tests.jl
index 76050ebb..72f62221 100644
--- a/test/test_files/Ranges_tests.jl
+++ b/test/test_files/Ranges_tests.jl
@@ -122,4 +122,20 @@
rng = XLSX.CellRange("A2:C4")
@test collect(rng) == [XLSX.CellRef("A2"), XLSX.CellRef("B2"), XLSX.CellRef("C2"), XLSX.CellRef("A3"), XLSX.CellRef("B3"), XLSX.CellRef("C3"), XLSX.CellRef("A4"), XLSX.CellRef("B4"), XLSX.CellRef("C4")]
end
-end
\ No newline at end of file
+
+ @testset "Base methods on references and ranges" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh[1:3, 1:4] = reshape(collect(1:12), 3, 4)
+
+ # cell ranges and Base methods on references
+ @test XLSX.getcellrange(sh, :) == XLSX.getcellrange(sh, XLSX.get_dimension(sh))
+ @test isless(XLSX.CellRef("A2"), XLSX.CellRef("B1"))
+ @test 2 in XLSX.ColumnRange("A:C") && 2 in XLSX.RowRange("1:3")
+ @test convert(XLSX.RowRange, "2:4") == XLSX.RowRange("2:4")
+ @test convert(XLSX.RowRange, XLSX.RowRange("2:4")) == XLSX.RowRange("2:4")
+ srr = XLSX.SheetRowRange("Sheet1!2:4")
+ @test string(srr) == "Sheet1!2:4"
+ @test hash(srr) == hash(XLSX.SheetRowRange("Sheet1!2:4"))
+ end
+end
diff --git a/test/test_files/Read-coverage_tests.jl b/test/test_files/Read-internals_tests.jl
similarity index 67%
rename from test/test_files/Read-coverage_tests.jl
rename to test/test_files/Read-internals_tests.jl
index 4e29024f..d08ac2fd 100644
--- a/test/test_files/Read-coverage_tests.jl
+++ b/test/test_files/Read-internals_tests.jl
@@ -1,16 +1,13 @@
-# Additional testsets for read.jl coverage.
+# Reading internals: workbook parsing, the worksheet cache fill and its sheet stubs,
+# cell value parsing, and the sheet dimension.
#
# Most of these drive internals directly rather than through a fixture file:
-# the uncovered branches are mostly strict-OOXML and malformed-package paths
-# that would otherwise need a purpose-built .xlsx each.
+# the branches they reach are mostly malformed-package paths that would otherwise
+# need a purpose-built .xlsx each.
-const _STRICT_MAIN = "http://purl.oclc.org/ooxml/spreadsheetml/main"
-const _TRANS_MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
const _CT_SHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
const _CT_TEMPLATE = "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml"
-_root_of(s::AbstractString) = XLSX.xml_root_element(parse(s, XLSX.XML.Node))
-
# Locate a child of `root` by localname and an attribute value.
function _find_child(root, tag::AbstractString, attr::AbstractString, val::AbstractString)
for (i, c) in enumerate(XLSX.XML.children(root))
@@ -20,175 +17,7 @@ function _find_child(root, tag::AbstractString, attr::AbstractString, val::Abstr
return (nothing, nothing)
end
-@testset "read.jl coverage" begin
-
- @testset "Worksheet ranges across cache modes" begin
- for filename in ("simple.xlsx", "strict.xlsx", "NoDim.xlsx")
- bytes = read(joinpath(data_directory, filename))
- writable = XLSX.openxlsx(IOBuffer(bytes); mode = "rw")
- expected = writable[1][:]
- range = "A1:" * XLSX.encode_column_number(size(expected, 2)) * string(size(expected, 1))
- for openfile in (XLSX.readxlsx,
- io -> XLSX.openxlsx(io; enable_cache = true),
- io -> XLSX.openxlsx(io; enable_cache = false))
- xf = openfile(IOBuffer(bytes))
- @test XLSX.sheetnames(xf) == XLSX.sheetnames(writable)
- @test isequal(xf[1][range], expected)
- @test isequal(xf[1][range], expected)
- end
- end
- end
-
-
-# ===========================================================================
-# Namespace resolution
-# ===========================================================================
-
- @testset "get_default_namespace - single prefixed namespace" begin
- r = _root_of("""""")
- @test XLSX.get_default_namespace(r) == _TRANS_MAIN
- end
-
- @testset "get_default_namespace - unprefixed default preferred" begin
- r = _root_of("""""")
- @test XLSX.get_default_namespace(r) == _TRANS_MAIN
- end
-
- @testset "get_default_namespace - prefixed spreadsheet ns as fallback" begin
- # No unprefixed default (issues #380/#362/#267/#170)
- r = _root_of("""""")
- @test XLSX.get_default_namespace(r) == _TRANS_MAIN
- end
-
- @testset "get_default_namespace - none found errors" begin
- r = _root_of("""""")
- @test_throws XLSX.XLSXError XLSX.get_default_namespace(r)
- end
-
- @testset "get_default_namespace_prefix - no namespaces at all" begin
- r = _root_of("")
- @test isnothing(XLSX.get_default_namespace_prefix(r))
-
- r2 = _root_of("""""")
- @test isnothing(XLSX.get_default_namespace_prefix(r2))
- end
-
- @testset "build_ns_dict! - part still held as a raw String" begin
- # Covers the `val isa String` branch: a read part whose data hasn't been
- # parsed yet has its prefix sniffed from the raw text instead.
- xf = XLSX.newxlsx()
- f = "xl/styles.xml"
- @test haskey(xf.files, f)
-
- xf.data[f] = """"""
- delete!(xf.namespace, f)
-
- XLSX.build_ns_dict!(xf)
- @test xf.namespace[f] == "x"
- @test xf.data[f] isa String # left unparsed by build_ns_dict!
- end
-
- @testset "_get_ns_prefix_from_string - default vs prefixed vs absent" begin
- @test isnothing(XLSX._get_ns_prefix_from_string(nothing))
- @test isnothing(XLSX._get_ns_prefix_from_string(""""""))
- @test XLSX._get_ns_prefix_from_string("""""") == "x"
- @test isnothing(XLSX._get_ns_prefix_from_string(""""""))
- end
-
- @testset "get_sst_prefix - prefixed shared strings namespace" begin
- xf = XLSX.newxlsx()
- sh = xf[1]
-
- xf.namespace["xl/sharedStrings.xml"] = "x"
- @test XLSX.get_sst_prefix(sh) == "x:"
-
- xf.namespace["xl/sharedStrings.xml"] = nothing
- @test XLSX.get_sst_prefix(sh) == ""
- SAVE_FILES && save_outfile(xf)
- end
-
-
- # ===========================================================================
- # Strict OOXML detection and conversion
- # ===========================================================================
-
- @testset "is_strict_ooxml - strict namespace without conformance attribute" begin
- xf = XLSX.newxlsx()
- xf.data["xl/workbook.xml"] = parse(
- """""", XLSX.XML.Node)
- @test XLSX.is_strict_ooxml(xf) == true
- end
-
- @testset "is_strict_ooxml - conformance attribute" begin
- xf = XLSX.newxlsx()
- xf.data["xl/workbook.xml"] = parse(
- """""", XLSX.XML.Node)
- @test XLSX.is_strict_ooxml(xf) == true
- end
-
- @testset "is_strict_ooxml - falls back to relationship types in _rels/.rels" begin
- xf = XLSX.newxlsx()
- # workbook root gives no hint at all
- xf.data["xl/workbook.xml"] = parse("""""", XLSX.XML.Node)
- xf.data["_rels/.rels"] = parse(
- """
-
- """, XLSX.XML.Node)
- @test XLSX.is_strict_ooxml(xf) == true
- end
-
- @testset "is_strict_ooxml - ordinary transitional file" begin
- xf = XLSX.newxlsx()
- @test XLSX.is_strict_ooxml(xf) == false
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "_strict_to_transitional_node! - remaps and drops conformance" begin
- node = XLSX.XML.Element("workbook"; xmlns=_STRICT_MAIN, conformance="strict")
- XLSX._strict_to_transitional_node!(node, "xl/workbook.xml")
-
- @test XLSX.get_attr(node, "xmlns") == _TRANS_MAIN
- @test XLSX.get_attr(node, "conformance", "") == ""
- end
-
- @testset "_strict_to_transitional_node! - node with no attributes is a no-op" begin
- node = XLSX.XML.Element("workbook")
- @test isnothing(XLSX._strict_to_transitional_node!(node, "xl/workbook.xml"))
- end
-
- @testset "_strict_to_transitional_node! - unknown purl namespace errors" begin
- node = XLSX.XML.Element("thing"; xmlns="http://purl.oclc.org/ooxml/notAThing/main")
- @test_throws XLSX.XLSXError XLSX._strict_to_transitional_node!(node, "xl/thing.xml")
- end
-
- @testset "convert_strict_to_transitional! - part held as a raw String is parsed" begin
- xf = XLSX.newxlsx()
- f = "xl/styles.xml"
- xf.data[f] = """"""
-
- XLSX.convert_strict_to_transitional!(xf, 1)
-
- @test xf.data[f] isa XLSX.XML.Node
- @test XLSX.get_attr(XLSX.xml_root_element(xf.data[f]), "xmlns") == _TRANS_MAIN
- SAVE_FILES && save_outfile(xf)
- end
-
- @testset "convert_strict_to_transitional! - worksheet String substitution (pass 3)" begin
- xf = XLSX.newxlsx()
- f = XLSX.get_relationship_target_by_id("xl", XLSX.get_workbook(xf), xf[1].relationship_id)
- xf.data[f] = """"""
-
- XLSX.convert_strict_to_transitional!(xf, 3)
-
- data = xf.data[f]
- @test data isa String
- @test occursin(_TRANS_MAIN, data)
- @test !occursin("purl.oclc.org", data)
- @test !occursin("conformance", data)
- SAVE_FILES && save_outfile(xf)
- end
-
+@testset "workbook parsing and file arrays" begin
# ===========================================================================
# Content types / workbook parsing
@@ -298,48 +127,6 @@ end
SAVE_FILES && save_outfile(xf)
end
-
- # ===========================================================================
- # parse_defined_name_value
- # ===========================================================================
-
- @testset "parse_defined_name_value - relative sheet cell reference" begin
- v, isabs = XLSX.parse_defined_name_value("Sheet1!A1")
- @test v == XLSX.SheetCellRef("Sheet1!A1")
- @test isabs == false
- end
-
- @testset "parse_defined_name_value - relative sheet cell range" begin
- v, isabs = XLSX.parse_defined_name_value("Sheet1!A1:B2")
- @test v == XLSX.SheetCellRange("Sheet1!A1:B2")
- @test isabs == false
- end
-
- @testset "parse_defined_name_value - absolute forms" begin
- v, isabs = XLSX.parse_defined_name_value("Sheet1!\$A\$1")
- @test v == XLSX.SheetCellRef("Sheet1!A1")
- @test isabs == true
-
- v2, isabs2 = XLSX.parse_defined_name_value("Sheet1!\$A\$1:\$B\$2")
- @test v2 == XLSX.SheetCellRange("Sheet1!A1:B2")
- @test isabs2 == true
- end
-
- @testset "parse_defined_name_value - empty string" begin
- v, isabs = XLSX.parse_defined_name_value("")
- @test ismissing(v)
- @test isabs == false
- end
-
- @testset "parse_defined_name_value - quoted, numeric and fallback strings" begin
- @test XLSX.parse_defined_name_value("\"hello\"") == ("hello", false)
- @test ismissing(first(XLSX.parse_defined_name_value("\"\"")))
- @test XLSX.parse_defined_name_value("42") == (42, false)
- @test XLSX.parse_defined_name_value("3.5") == (3.5, false)
- @test XLSX.parse_defined_name_value("SomethingElse") == ("SomethingElse", false)
- end
-
-
# ===========================================================================
# process_file failure path
# ===========================================================================
@@ -356,47 +143,10 @@ end
@test_throws XLSX.XLSXError XLSX.process_file(reader, "absent.xml")
end
-
- # ===========================================================================
- # Source-not-found and bad-argument paths on the public read entry points
- # ===========================================================================
-
- @testset "readtable - file not found, every arity" begin
- missing_file = "definitely_not_a_file_12345.xlsx"
- @test !isfile(missing_file)
-
- @test_throws XLSX.XLSXError XLSX.readtable(missing_file)
- @test_throws XLSX.XLSXError XLSX.readtable(missing_file, "Sheet1")
- @test_throws XLSX.XLSXError XLSX.readtable(missing_file, "Sheet1", XLSX.ColumnRange("A:B"))
- end
-
- @testset "readtable - columns argument is not a valid column range" begin
- outfile = "read_badrange.xlsx"
- XLSX.writetable(outfile, [[1, 2], [3, 4]], ["a", "b"]; overwrite=true)
- SAVE_FILES && save_outfile(outfile)
-
- @test_throws XLSX.XLSXError XLSX.readtable(outfile, 1, "not a range")
- @test_throws XLSX.XLSXError XLSX.readtable(outfile, 1, "A1:B2") # cell range, not columns
-
- isfile(outfile) && rm(outfile)
- end
-
- @testset "readtransposedtable - file not found, every arity" begin
- missing_file = "definitely_not_a_file_12345.xlsx"
-
- @test_throws XLSX.XLSXError XLSX.readtransposedtable(missing_file)
- @test_throws XLSX.XLSXError XLSX.readtransposedtable(missing_file, "Sheet1")
- @test_throws XLSX.XLSXError XLSX.readtransposedtable(missing_file, "Sheet1", "1:3")
- end
-
- @testset "openxlsx / parse_file_mode - argument errors" begin
- missing_file = "definitely_not_a_file_12345.xlsx"
-
- @test_throws XLSX.XLSXError XLSX.openxlsx(missing_file)
- @test_throws XLSX.XLSXError XLSX.openxlsx(identity, missing_file)
- @test_throws XLSX.XLSXError XLSX.openxlsx(missing_file; mode="q")
- @test XLSX.parse_file_mode("wr") == (true, true)
- @test XLSX.parse_file_mode("RW") == (true, true)
+ @testset "FileArray" begin
+ p = joinpath(data_directory, "general.xlsx")
+ fa = XLSX.FileArray(p)
+ @test fa[1] == read(p)[1]
end
end
@@ -480,7 +230,6 @@ end
end
end
-
# The cache fill reads each `` in the same cursor walk as its children (#462). It
# must build exactly the cells `Cell(::LazyNode, …)` builds from the same XML.
@@ -595,7 +344,6 @@ end
end
end
-
# `_parse_cell_float` must give exactly what `parse(Float64, s)` gives, bit for bit,
# and throw exactly where it throws (#462: cell floats are parsed with Parsers.jl).
@testset "cell value float parsing" begin
@@ -694,3 +442,193 @@ end
end
@test nbad == 0
end
+
+@testset "sheet dimensions and ranges across cache modes" begin
+ @testset "Read-only range discovery" begin
+ bytes = read(joinpath(data_directory, "NoDim.xlsx"))
+ expected = XLSX.openxlsx(IOBuffer(bytes); mode = "rw")[1]["A1:K116"]
+ factories = (XLSX.readxlsx,
+ io -> XLSX.openxlsx(io; enable_cache = true),
+ io -> XLSX.openxlsx(io; enable_cache = false))
+ cellvalues(ws, cells) = map(c -> XLSX.getdata(ws, c), cells)
+ ranges = (
+ (ws -> ws[:], expected),
+ (ws -> ws[:, :], expected),
+ (ws -> XLSX.getdata(ws), expected),
+ (ws -> ws[1:2, :], expected[1:2, :]),
+ (ws -> ws[:, 1:2], expected[:, 1:2]),
+ (ws -> ws[[1, 2], :], expected[1:2, :]),
+ (ws -> ws[:, [1, 2]], expected[:, 1:2]),
+ (ws -> ws["1:2"], expected[1:2, :]),
+ (ws -> ws["A:B"], expected[:, 1:2]),
+ (ws -> cellvalues(ws, XLSX.getcellrange(ws, :)), expected),
+ (ws -> cellvalues(ws, XLSX.getcell(ws, :)), expected),
+ (ws -> cellvalues(ws, XLSX.getcell(ws, 1:2, :)), expected[1:2, :]),
+ (ws -> cellvalues(ws, XLSX.getcell(ws, :, 1:2)), expected[:, 1:2]),
+ (ws -> cellvalues(ws, XLSX.getcellrange(ws, "1:2")), expected[1:2, :]),
+ (ws -> cellvalues(ws, XLSX.getcellrange(ws, "A:B")), expected[:, 1:2]),
+ )
+ for openfile in factories
+ for (readrange, reference) in ranges
+ ws = openfile(IOBuffer(bytes))[1]
+ @test isequal(readrange(ws), reference)
+ @test ws.dimension == XLSX.CellRange("A1:K116") # recorded by the first read
+ @test isequal(readrange(ws), reference)
+ end
+ ws = openfile(IOBuffer(bytes))[1]
+ @test axes(ws, 1) == 1:116
+ @test axes(ws, 2) == 1:11
+ @test XLSX.get_dimension(ws) == XLSX.CellRange("A1:K116")
+ !XLSX.is_cache_enabled(ws) && @test ws.cache === nothing
+
+ ws = openfile(IOBuffer(bytes))[1]
+ sprint(show, ws)
+ @test ws.cache === nothing
+ @test ws.dimension === nothing
+
+ ws = openfile(IOBuffer(read(joinpath(data_directory, "simple.xlsx"))))[1]
+ @test XLSX.get_dimension(ws) == XLSX.CellRange("A1:B3")
+ @test ws.cache === nothing
+ end
+
+ # An uncached row iterator records the dimension only when it runs to the end.
+ ws = XLSX.openxlsx(IOBuffer(bytes); enable_cache = false)[1]
+ for _ in XLSX.eachrow(ws) end
+ @test ws.dimension == XLSX.CellRange("A1:K116")
+ ws = XLSX.openxlsx(IOBuffer(bytes); enable_cache = false)[1]
+ for _ in XLSX.eachrow(ws)
+ break
+ end
+ @test ws.dimension === nothing
+
+ # A recorded dimension is never recomputed.
+ for enable_cache in (true, false)
+ ws = XLSX.openxlsx(joinpath(data_directory, "simple.xlsx"); enable_cache)[1]
+ dimension = ws.dimension
+ ws[:]; ws[1:2, :]; ws[:, 2]; axes(ws, 2)
+ for _ in XLSX.eachrow(ws) end
+ @test ws.dimension === dimension
+ end
+
+ sparse = Matrix{Any}(missing, 5, 3)
+ sparse[1, 1] = 11
+ sparse[5, 3] = 22
+ styled = Matrix{Any}(missing, 3, 3)
+ styled[1, 1] = 11
+ inline = Matrix{Any}(missing, 2, 2)
+ inline[1, 1] = "hi"
+ inline[2, 2] = 5
+ emptyrow = Matrix{Any}(missing, 4, 1)
+ emptyrow[1, 1] = 1
+ sheets = (
+ ("", "A1:A1", fill(missing, 1, 1)),
+ ("
", "A1:A1", fill(missing, 1, 1)),
+ ("11
22
", "C3:E7", sparse),
+ # a formatting-only cell extends the dimension
+ ("11
", "B2:D4", styled),
+ ("hi
5
", "A1:B2", inline),
+ # a row without cells still extends the rows
+ ("1
", "B2:B5", emptyrow),
+ )
+ for (sheetdata, dimension, reference) in sheets
+ path = patch_xlsx_entry(joinpath(src_data_directory, "blank.xlsx"), "xl/worksheets/sheet1.xml",
+ _ -> "" * sheetdata * "")
+ try
+ rng = XLSX.CellRange(dimension)
+ top, left = rng.start.row_number, rng.start.column_number
+ for openfile in factories
+ ws = openfile(IOBuffer(read(path)))[1]
+ @test isequal(ws[:], reference)
+ @test isequal(ws[:], reference)
+ @test XLSX.get_dimension(ws) == rng
+ !XLSX.is_cache_enabled(ws) && @test ws.cache === nothing
+
+ # partial reads take the other extent from the sheet
+ ws = openfile(IOBuffer(read(path)))[1]
+ @test isequal(ws[top:top, :], reference[1:1, :])
+ ws = openfile(IOBuffer(read(path)))[1]
+ @test isequal(ws[:, left], reference[:, 1:1])
+ ws = openfile(IOBuffer(read(path)))[1]
+ @test axes(ws, 1) == rng.start.row_number:rng.stop.row_number
+ @test axes(ws, 2) == rng.start.column_number:rng.stop.column_number
+ end
+ finally
+ rm(path)
+ end
+ end
+ end
+
+ noDim = XLSX.openxlsx(joinpath(data_directory, "NoDim.xlsx"), mode="rw")
+ Dim = XLSX.readxlsx(joinpath(data_directory, "customXml.xlsx"))
+ @test noDim[1].dimension == Dim[1].dimension
+ @test noDim[2].dimension == Dim[2].dimension
+
+ f = XLSX.newxlsx()
+ s = f[1]
+ for i = 10:20, j = 10:20
+ s[i, j] = i + j
+ end
+ XLSX.set_dimension!(s, XLSX.CellRange(XLSX.CellRef("J10"), XLSX.CellRef("T20")))
+ @test XLSX.get_dimension(s) == XLSX.CellRange(XLSX.CellRef("J10"), XLSX.CellRef("T20"))
+ s["A1"] = 2
+ @test XLSX.get_dimension(s) == XLSX.CellRange(XLSX.CellRef("A1"), XLSX.CellRef("T20"))
+ SAVE_FILES && save_outfile(f)
+
+ @testset "Worksheet ranges across cache modes" begin
+ for filename in ("simple.xlsx", "strict.xlsx", "NoDim.xlsx")
+ bytes = read(joinpath(data_directory, filename))
+ writable = XLSX.openxlsx(IOBuffer(bytes); mode = "rw")
+ expected = writable[1][:]
+ range = "A1:" * XLSX.encode_column_number(size(expected, 2)) * string(size(expected, 1))
+ for openfile in (XLSX.readxlsx,
+ io -> XLSX.openxlsx(io; enable_cache = true),
+ io -> XLSX.openxlsx(io; enable_cache = false))
+ xf = openfile(IOBuffer(bytes))
+ @test XLSX.sheetnames(xf) == XLSX.sheetnames(writable)
+ @test isequal(xf[1][range], expected)
+ @test isequal(xf[1][range], expected)
+ end
+ end
+ end
+end
+
+@testset "review fixes: reading" begin
+ @testset "a worksheet part not named sheetN.xml" begin
+ XLSX.writetable("base_part.xlsx", [[1, 2, 3]], ["a"]; overwrite=true)
+ r = ZipReader(read("base_part.xlsx"))
+ open("renamed_part.xlsx", "w") do io
+ ZipWriter(io) do w
+ for n in zip_names(r)
+ bytes = zip_readentry(r, n)
+ if n in ("xl/_rels/workbook.xml.rels", "[Content_Types].xml")
+ bytes = Vector{UInt8}(replace(String(bytes), "worksheets/sheet1.xml" => "worksheets/Data.xml"))
+ end
+ zip_newfile(w, n == "xl/worksheets/sheet1.xml" ? "xl/worksheets/Data.xml" : n)
+ write(w, bytes)
+ end
+ end
+ end
+ @test XLSX.readxlsx("renamed_part.xlsx")[1]["A3"] == 2
+ @test XLSX.readtable("renamed_part.xlsx", 1).data == Any[[1, 2, 3]]
+ XLSX.openxlsx("renamed_part.xlsx"; mode="rw") do xf
+ xf[1]["B1"] = "new"
+ end
+ @test XLSX.readxlsx("renamed_part.xlsx")[1]["B1"] == "new"
+ SAVE_FILES && save_outfile("renamed_part.xlsx")
+ end
+ @testset "negative number in a date-formatted cell" begin
+ f = XLSX.newxlsx(); s = f[1]
+ st = Int(XLSX.get_num_style_index(s, 14).id)
+ @test XLSX.process_tv(f.workbook, "", "-1", st) == (XLSX.CT_INT, reinterpret(UInt64, Int64(-1)))
+ @test XLSX.process_tv(f.workbook, "", "-1.5", st)[1] == XLSX.CT_FLOAT
+ end
+ @testset "a datetime that rounds up to midnight" begin
+ @test XLSX.excel_value_to_datetime(45000.999999999, false) == DateTime(2023, 3, 16)
+ end
+ @testset "too-short file and missing sheet index give XLSXError" begin
+ touch("empty.xlsx")
+ @test_throws XLSX.XLSXError XLSX.readxlsx("empty.xlsx")
+ XLSX.writetable("one_sheet.xlsx", [[1, 2]], ["a"]; overwrite=true)
+ @test_throws XLSX.XLSXError XLSX.readtable("one_sheet.xlsx", 5)
+ end
+end
diff --git a/test/test_files/Strict-format_tests.jl b/test/test_files/Strict-format_tests.jl
index 9e952066..3e12eaa5 100644
--- a/test/test_files/Strict-format_tests.jl
+++ b/test/test_files/Strict-format_tests.jl
@@ -1,3 +1,5 @@
+const _STRICT_MAIN = "http://purl.oclc.org/ooxml/spreadsheetml/main"
+
@testset "Strict formats" begin
@testset "Simple" begin
XLSX.openxlsx(joinpath(data_directory, "strict.xlsx")) do f
@@ -192,4 +194,83 @@
isfile("mytest.xlsx") && rm("mytest.xlsx")
end
end
-end
\ No newline at end of file
+end
+
+@testset "detecting and converting Strict OOXML" begin
+ @testset "is_strict_ooxml - strict namespace without conformance attribute" begin
+ xf = XLSX.newxlsx()
+ xf.data["xl/workbook.xml"] = parse(
+ """""", XLSX.XML.Node)
+ @test XLSX.is_strict_ooxml(xf) == true
+ end
+
+ @testset "is_strict_ooxml - conformance attribute" begin
+ xf = XLSX.newxlsx()
+ xf.data["xl/workbook.xml"] = parse(
+ """""", XLSX.XML.Node)
+ @test XLSX.is_strict_ooxml(xf) == true
+ end
+
+ @testset "is_strict_ooxml - falls back to relationship types in _rels/.rels" begin
+ xf = XLSX.newxlsx()
+ # workbook root gives no hint at all
+ xf.data["xl/workbook.xml"] = parse("""""", XLSX.XML.Node)
+ xf.data["_rels/.rels"] = parse(
+ """
+
+ """, XLSX.XML.Node)
+ @test XLSX.is_strict_ooxml(xf) == true
+ end
+
+ @testset "is_strict_ooxml - ordinary transitional file" begin
+ xf = XLSX.newxlsx()
+ @test XLSX.is_strict_ooxml(xf) == false
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "_strict_to_transitional_node! - remaps and drops conformance" begin
+ node = XLSX.XML.Element("workbook"; xmlns=_STRICT_MAIN, conformance="strict")
+ XLSX._strict_to_transitional_node!(node, "xl/workbook.xml")
+
+ @test XLSX.get_attr(node, "xmlns") == _TRANS_MAIN
+ @test XLSX.get_attr(node, "conformance", "") == ""
+ end
+
+ @testset "_strict_to_transitional_node! - node with no attributes is a no-op" begin
+ node = XLSX.XML.Element("workbook")
+ @test isnothing(XLSX._strict_to_transitional_node!(node, "xl/workbook.xml"))
+ end
+
+ @testset "_strict_to_transitional_node! - unknown purl namespace errors" begin
+ node = XLSX.XML.Element("thing"; xmlns="http://purl.oclc.org/ooxml/notAThing/main")
+ @test_throws XLSX.XLSXError XLSX._strict_to_transitional_node!(node, "xl/thing.xml")
+ end
+
+ @testset "convert_strict_to_transitional! - part held as a raw String is parsed" begin
+ xf = XLSX.newxlsx()
+ f = "xl/styles.xml"
+ xf.data[f] = """"""
+
+ XLSX.convert_strict_to_transitional!(xf, 1)
+
+ @test xf.data[f] isa XLSX.XML.Node
+ @test XLSX.get_attr(XLSX.xml_root_element(xf.data[f]), "xmlns") == _TRANS_MAIN
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "convert_strict_to_transitional! - worksheet String substitution (pass 3)" begin
+ xf = XLSX.newxlsx()
+ f = XLSX.get_relationship_target_by_id("xl", XLSX.get_workbook(xf), xf[1].relationship_id)
+ xf.data[f] = """"""
+
+ XLSX.convert_strict_to_transitional!(xf, 3)
+
+ data = xf.data[f]
+ @test data isa String
+ @test occursin(_TRANS_MAIN, data)
+ @test !occursin("purl.oclc.org", data)
+ @test !occursin("conformance", data)
+ SAVE_FILES && save_outfile(xf)
+ end
+end
diff --git a/test/test_files/Strings_tests.jl b/test/test_files/Strings_tests.jl
index 4c3f28bb..51375533 100644
--- a/test/test_files/Strings_tests.jl
+++ b/test/test_files/Strings_tests.jl
@@ -1,22 +1,3 @@
-# Rewrite a single entry of an xlsx zip into a new file in the working directory. Used to inject XML that
-# Excel itself never produces (inlineStr cells), so the `_rewrite_node` path in
-# cell.jl actually gets exercised.
-function patch_xlsx_entry(src::AbstractString, entry::AbstractString, patch::Function)
- r = ZipReader(read(src))
- names = zip_names(r)
- entry in names || error("no `$entry` in $src")
- dst = "patched_" * basename(src)
- ZipWriter(dst) do w
- for n in names
- bytes = zip_readentry(r, n)
- n == entry && (bytes = Vector{UInt8}(patch(String(bytes))))
- zip_newfile(w, n; compress=true)
- write(w, bytes)
- end
- end
- return dst
-end
-
# `xl/sharedStrings.xml` must be well-formed and must decode back to the original text.
function check_sst(path::AbstractString)
sst = String(zip_readentry(ZipReader(read(path)), "xl/sharedStrings.xml"))
@@ -87,44 +68,45 @@ end
@test ismissing(sheet["J2"])
isfile("mydata.xlsx") && rm("mydata.xlsx")
-# (issue #447)
- src = joinpath(data_directory, "one_and_two_start.xlsx")
- sheetxml = "xl/worksheets/sheet1.xml"
-
- # (text written into , expected value after round-trip)
- cases = [
- ("one & two", "one & two"),
- ("a <b> c", "a c"),
- ("", "x & y"),
- ("quote \" and '", "quote \" and '"),
- # Multiple runs: exercises the recursive branch of `_rewrite_node` and the
- # attribute path (`rFont val`), which the single- cases never reach.
- ("""one & two""" *
- """ <then> """ *
- """three""",
- "one & two three"),
- ]
-
- for (inner, expected) in cases
- f = patch_xlsx_entry(src, sheetxml, s -> set_a1_inline(s, inner))
- xf = XLSX.openxlsx(f, mode="rw")
- @test xf[1]["A1"] == expected # read decodes correctly (passes pre-fix)
-
- out = "strings_inline_escape_out.xlsx"
- XLSX.writexlsx(out, xf, overwrite=true)
-
- sst = check_sst(out) # throws pre-fix
- @test !occursin(r"&(?!amp;|lt;|gt;|apos;|quot;|#)", sst)
- @test XLSX.readxlsx(out)[1]["A1"] == expected
-
- SAVE_FILES && save_outfile(xf)
- isfile(f) && rm(f)
- isfile(out) && rm(out)
+ @testset "markup in inline strings is escaped on write (issue #447)" begin
+ src = joinpath(data_directory, "one_and_two_start.xlsx")
+ sheetxml = "xl/worksheets/sheet1.xml"
+
+ # (text written into , expected value after round-trip)
+ cases = [
+ ("one & two", "one & two"),
+ ("a <b> c", "a c"),
+ ("", "x & y"),
+ ("quote \" and '", "quote \" and '"),
+ # Multiple runs: exercises the recursive branch of `_rewrite_node` and the
+ # attribute path (`rFont val`), which the single- cases never reach.
+ ("""one & two""" *
+ """ <then> """ *
+ """three""",
+ "one & two three"),
+ ]
+
+ for (inner, expected) in cases
+ f = patch_xlsx_entry(src, sheetxml, s -> set_a1_inline(s, inner))
+ xf = XLSX.openxlsx(f, mode="rw")
+ @test xf[1]["A1"] == expected # read decodes correctly (passes pre-fix)
+
+ out = "strings_inline_escape_out.xlsx"
+ XLSX.writexlsx(out, xf, overwrite=true)
+
+ sst = check_sst(out) # throws pre-fix
+ @test !occursin(r"&(?!amp;|lt;|gt;|apos;|quot;|#)", sst)
+ @test XLSX.readxlsx(out)[1]["A1"] == expected
+
+ SAVE_FILES && save_outfile(xf)
+ isfile(f) && rm(f)
+ isfile(out) && rm(out)
+ end
end
end
@testset "rich text formats" begin
- @testset "setAttributes" begin
+ @testset "setFont, setUniformFont and setUniformStyle on rich text" begin
xf = XLSX.readxlsx(joinpath(data_directory, "is.xlsx"))
sh = xf["Sheet1"]
@test_throws XLSX.XLSXError XLSX.setFont(sh, "A1"; name="Palatino") # Must be in write mode for rich text formats
@@ -138,7 +120,11 @@ end
@test r.runs[4].atts == Dict(:bold => true, :color => "FF000000", :size => 11)
@test r.runs[6].text == "ty"
@test r.runs[6].atts == Dict(:color => "FFFF0000", :size => 11)
- @test XLSX.getFont(sh, "A1").font == Dict("name" => Dict("val" => "Palatino"), "sz" => Dict("val" => "11"), "color" => Dict("theme" => "1"))
+ # An empty cell holds no rich text, so nothing rather than an error.
+ @test isnothing(XLSX.getRichTextString(sh, "Z99"))
+ @test isnothing(XLSX.getRichTextString(sh, 99, 26))
+ @test isnothing(XLSX.getRichTextString(xf, "Sheet1!Z99")) # outside the dimension too
+ @test XLSX.getFont(sh, "A1").font ==Dict("name" => Dict("val" => "Palatino"), "sz" => Dict("val" => "11"), "color" => Dict("theme" => "1"))
XLSX.setFont(sh, "A1:F2"; name="Palatino")
r = XLSX.getRichTextString(sh, "B2")
@test r.runs[4].text == "k"
@@ -225,6 +211,56 @@ end
SAVE_FILES && save_outfile(xf)
end
+ # A run's own properties override the cell font, so a font change removes the ones
+ # it sets from every run. `vertAlign` was missed, and a uniform style threw on it.
+ @testset "every font property reaches the runs of rich text" begin
+ function rich_sheet()
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh["A1"] = XLSX.RichTextString([XLSX.RichTextRun("ab", [:bold => true]),
+ XLSX.RichTextRun("cd", [:italic => true, :vertAlign => "subscript"])])
+ sh["A2"] = XLSX.RichTextString([XLSX.RichTextRun("ef", [:bold => true]),
+ XLSX.RichTextRun("gh", [:vertAlign => "subscript"])])
+ return xf, sh
+ end
+ run_atts(sh, ref) = [r.atts for r in XLSX.getRichTextString(sh, ref).runs]
+
+ xf, sh = rich_sheet()
+ XLSX.setFont(sh, "A1"; vertAlign="superscript")
+ @test run_atts(sh, "A1") == [Dict(:bold => true), Dict(:italic => true)]
+ @test XLSX.getFont(sh, "A1").font["vertAlign"] == Dict("val" => "superscript")
+
+ xf, sh = rich_sheet()
+ XLSX.setUniformFont(sh, "A1:A2"; vertAlign="superscript")
+ @test run_atts(sh, "A1") == [Dict(:bold => true), Dict(:italic => true)]
+ @test run_atts(sh, "A2")[2] === nothing || isempty(run_atts(sh, "A2")[2])
+
+ xf, sh = rich_sheet()
+ XLSX.setFont(sh, "A1"; vertAlign="superscript")
+ XLSX.setUniformStyle(sh, "A1:A2")
+ @test XLSX.getFont(sh, "A2").font["vertAlign"] == Dict("val" => "superscript")
+ @test all(a -> isnothing(a) || !haskey(a, :vertAlign), run_atts(sh, "A2"))
+
+ # A font size Excel wrote as a fraction is a tag like any other.
+ xf, sh = rich_sheet()
+ cell = XLSX.getcell(sh, "A1")
+ font = XLSX.CellFont(0, Dict{String,Union{Dict{String,String},Nothing}}("sz" => Dict("val" => "10.5")), "1")
+ @test XLSX.update_sharedString_font(sh, cell, font) isa Integer
+
+ # Run properties the new font doesn't set are kept, including ones XLSX.jl
+ # doesn't otherwise read, such as `charset` and `outline`.
+ xf, sh = rich_sheet()
+ wb = XLSX.get_workbook(sh)
+ si = "abcd"
+ cell = XLSX.getcell(sh, "A1")
+ cell.value = reinterpret(UInt64, Int64(XLSX.add_formatted_string!(wb, si)))
+ XLSX.setFont(sh, "A1"; bold=true)
+ s = XLSX.get_sst(wb).shared_strings[reinterpret(Int64, XLSX.getcell(sh, "A1").value) + 1]
+ @test occursin("charset", s) && occursin("outline", s) && occursin("", s)
+ SAVE_FILES && save_outfile(xf)
+ end
+
@static if VERSION >= v"1.11-"
@testset "styledStrings" begin
f=XLSX.newxlsx()
@@ -467,4 +503,99 @@ end
SAVE_FILES && save_outfile(f)
end
-end
\ No newline at end of file
+
+ @testset "RichTextRun and RichTextString as strings" begin
+ run = XLSX.RichTextRun("ab", nothing)
+ @test length(run) == 2
+ @test hash(run) == hash(XLSX.RichTextRun("ab"))
+ rts = XLSX.RichTextString("ab", [run])
+ @test String(rts) == "ab" && ncodeunits(rts) == 2
+ @test codeunit(rts) == UInt8 && codeunit(rts, 1) == UInt8('a')
+ @test isvalid(rts, 1) && rts[1] == XLSX.RichTextString("a", [XLSX.RichTextRun("a")])
+ @test hash(rts) == hash(XLSX.RichTextString("ab", [XLSX.RichTextRun("ab")]))
+
+ xf = XLSX.opentemplate(joinpath(data_directory, "is.xlsx"))
+ sh = xf["Sheet1"]
+ XLSX.setFont(sh, "A1"; name="Palatino")
+ @test XLSX.getRichTextString(sh, 1, 1) == XLSX.getRichTextString(sh, "A1")
+ @test XLSX.getRichTextString(xf, "Sheet1!A1") == XLSX.getRichTextString(sh, "A1")
+ @test XLSX.get_sst(xf) === XLSX.get_sst(XLSX.get_workbook(xf))
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "rich text: every color form" begin
+ xf = XLSX.newxlsx()
+ s = xf[1]
+ sst(ref) = XLSX.get_workbook(s).sst.shared_strings[Int(XLSX.getcell(s, ref).value)+1]
+
+ runs = [XLSX.RichTextRun("a", [:color => :coral]),
+ XLSX.RichTextRun("b", [:color => colorant"seagreen"]),
+ XLSX.RichTextRun("c", [:color => XLSX.SchemeColor(:accent1; lumMod = 60, lumOff = 40)]),
+ XLSX.RichTextRun("d", [:color => "accent2"]),
+ XLSX.RichTextRun("e", [:color => :tx1])]
+ s["A1"] = XLSX.RichTextString(runs)
+ @test sst("A1") == "a" *
+ "b" *
+ "c" *
+ "d" *
+ "e"
+
+ # Reading resolves theme colors to RGB, as before
+ rts = XLSX.getRichTextString(s, "A1")
+ @test rts.runs[1].atts[:color] == "FFFF7F50"
+ @test rts.runs[3].atts[:color] isa String
+
+ # Equality and hashing treat every spelling of one color alike
+ r(c) = XLSX.RichTextRun("x", [:color => c])
+ @test r("red") == r(:red) == r(colorant"red") == r("FFFF0000")
+ @test hash(r("red")) == hash(r(colorant"red"))
+ @test r(:accent1) == r(XLSX.SchemeColor(:accent1)) == r("accent1")
+ @test hash(r(:accent1)) == hash(r(XLSX.SchemeColor(:accent1)))
+ @test r(:tx1) == r(:dk1)
+ @test r(:accent1) != r("red")
+
+ # A chart-only theme color (shade) is a valid run color: comparing it does not throw,
+ # but a cell cannot hold it
+ shaded = r(XLSX.SchemeColor(:accent1; shade = 50))
+ @test shaded == r(XLSX.SchemeColor(:accent1; shade = 50))
+ @test shaded != r(:accent1)
+ @test_throws XLSX.XLSXError (s["A2"] = XLSX.RichTextString([shaded, XLSX.RichTextRun("y")]))
+
+ # An invalid color is caught when the run is built
+ @test_throws XLSX.XLSXError XLSX.RichTextRun("x", [:color => "notacolor"])
+ @test_throws XLSX.XLSXError XLSX.RichTextRun("x", [:color => 42])
+
+ # A single run is applied to the whole cell with setFont
+ s["A3"] = XLSX.RichTextString([XLSX.RichTextRun("one", [:color => XLSX.SchemeColor(:accent3; lumMod = 75)])])
+ @test XLSX.getFont(s, "A3").font["color"] == Dict("theme" => "6", "tint" => "-0.25")
+
+ # In chart text, theme colors stay theme colors
+ @test XLSX.Charts._rich_run_props(Dict(:color => :accent1)).fill.fgcolor == XLSX.SchemeColor(:accent1)
+ @test XLSX.Charts._rich_run_props(Dict(:color => XLSX.SchemeColor(:accent1; shade = 50))).fill.fgcolor ==
+ XLSX.SchemeColor(:accent1; shade = 50)
+ @test XLSX.Charts._rich_run_props(Dict(:color => colorant"red")).fill.fgcolor == "FFFF0000"
+ SAVE_FILES && save_outfile(xf)
+ end
+end
+
+@testset "review fixes: rich text" begin
+ f = XLSX.newxlsx(); s = f[1]
+ s["A1"] = XLSX.RichTextString(XLSX.RichTextRun("plain"))
+ s["A2"] = XLSX.RichTextString(XLSX.RichTextRun("u", [:under => true, :size => 12.5]))
+ @test s["A1"] == "plain" && s["A2"] == "u"
+ @test XLSX.getFont(s, "A2").font["u"] === nothing
+ @test XLSX.getFont(s, "A2").font["sz"]["val"] == "12.5"
+ SAVE_FILES && save_outfile(f)
+
+ rt = XLSX.getRichTextString(f.workbook, "abcd")
+ @test rt.runs[1].atts == Dict{Symbol,Any}(:size => 10.5)
+ @test rt.runs[2].atts == Dict{Symbol,Any}(:bold => true)
+
+ r = XLSX.RichTextString(XLSX.RichTextRun("é"), XLSX.RichTextRun("ab", [:bold => true]))
+ @test r[3:4] == XLSX.RichTextString(XLSX.RichTextRun("ab", [:bold => true]))
+ @test r[1:3].runs == [XLSX.RichTextRun("é"), XLSX.RichTextRun("a", [:bold => true])]
+
+ a = XLSX.RichTextRun("x", [:bold => true, :size => 12, :italic => true, :name => "Arial", :color => "red", :under => true, :strike => true, :vertAlign => "subscript"])
+ b = XLSX.RichTextRun("x", [:vertAlign => "subscript", :strike => true, :under => true, :color => "red", :name => "Arial", :italic => true, :size => 12, :bold => true])
+ @test a == b && hash(a) == hash(b)
+end
diff --git a/test/test_files/Styles_tests.jl b/test/test_files/Styles_tests.jl
index 70329227..c0fbc700 100644
--- a/test/test_files/Styles_tests.jl
+++ b/test/test_files/Styles_tests.jl
@@ -8,7 +8,7 @@ end
@testset "Styles" begin
- @testset "Original" begin
+ @testset "low-level styles API and number-format classification" begin
using XLSX: CellValue, id, getcell, setdata!, CellRef
xfile = XLSX.open_empty_template()
wb = XLSX.get_workbook(xfile)
@@ -226,29 +226,6 @@ end
SAVE_FILES && save_outfile(xfile)
end
- @testset "number formats" begin
- XLSX.openxlsx(joinpath(data_directory, "general.xlsx")) do f
- show(IOBuffer(), f)
- sheet = f["general"]
- @test sheet["A1"] == "text"
- @test sheet["B1"] == "regular text"
- @test sheet["A2"] == "integer"
- @test sheet["B2"] == 102
- @test sheet["A3"] == "float"
- @test isapprox(sheet["B3"], 102.2)
- @test sheet["A4"] == "date"
- @test sheet["B4"] == Date(1983, 4, 16)
- @test sheet["A5"] == "hour"
- @test sheet["B5"] == Dates.Time(Dates.Hour(19), Dates.Minute(45))
- @test sheet["A6"] == "datetime"
- @test sheet["B6"] == Date(2018, 4, 16) + Dates.Time(Dates.Hour(19), Dates.Minute(19), Dates.Second(51))
- @test f["general!B7"] == -220.0
- @test f["general!B8"] == -2000
- @test f["general!B9"] == 100000000000000
- @test f["general!B10"] == -100000000000000
- end
- end
-
@testset "setFont" begin
f = XLSX.newxlsx()
@@ -372,7 +349,6 @@ end
@test XLSX.getFont(xfile, "Sheet1!B1").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF00FF00"))
@test XLSX.getFont(xfile, "Sheet1!B2").font == Dict("b" => nothing, "i" => nothing, "sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF00FF00"))
-
XLSX.writexlsx("output.xlsx", xfile, overwrite=true)
SAVE_FILES && save_outfile("output.xlsx")
@test isfile("output.xlsx")
@@ -490,7 +466,7 @@ end
@test XLSX.getBorder(s, "A1").border == Dict("left" => Dict("rgb" => "FF111111", "style" => "hair"), "bottom" => Dict("rgb" => "FF111111", "style" => "hair"), "right" => Dict("rgb" => "FF111111", "style" => "hair"), "top" => Dict("rgb" => "FF111111", "style" => "hair"), "diagonal" => Dict("rgb" => "FF111111", "style" => "hair", "direction" => "both"))
@test XLSX.getBorder(s, "D10").border == Dict("left" => Dict("rgb" => "FF111111", "style" => "hair"), "bottom" => Dict("rgb" => "FF111111", "style" => "hair"), "right" => Dict("rgb" => "FF111111", "style" => "hair"), "top" => Dict("rgb" => "FF111111", "style" => "hair"), "diagonal" => Dict("rgb" => "FF111111", "style" => "hair", "direction" => "both"))
@test XLSX.getcell(s, "D11") isa XLSX.EmptyCell
- @test_throws XLSX.XLSXError XLSX.getBorder(s, "D11") # Cannot get a border outside sheet dimension.
+ @test isnothing(XLSX.getBorder(s, "D11")) # outside the sheet dimension, so empty
SAVE_FILES && save_outfile(f)
f = XLSX.newxlsx()
@@ -554,8 +530,8 @@ end
@test XLSX.getBorder(s, "B4").border == Dict("left" => Dict("rgb" => "FFFF0004", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing)
XLSX.setBorder(s, 3:2:5, [2, 3]; left=["color" => "FFFF0005", "style" => "thick"])
@test XLSX.getBorder(s, "C5").border == Dict("left" => Dict("rgb" => "FFFF0005", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing)
- @test_throws XLSX.XLSXError XLSX.setFont(s, "1:10"; left=["color" => "FFFF0005", "style" => "thick"])
- @test_throws XLSX.XLSXError XLSX.setFont(s, "A:K"; left=["color" => "FFFF0005", "style" => "thick"])
+ @test_throws XLSX.XLSXError XLSX.setBorder(s, "1:10"; left=["color" => "FFFF0005", "style" => "thick"])
+ @test_throws XLSX.XLSXError XLSX.setBorder(s, "A:K"; left=["color" => "FFFF0005", "style" => "thick"])
SAVE_FILES && save_outfile(f)
f = XLSX.open_xlsx_template(joinpath(data_directory, "Borders.xlsx"))
@@ -606,77 +582,6 @@ end
@test_throws XLSX.XLSXError XLSX.getBorder(s, "Contiguous")
SAVE_FILES && save_outfile(f)
- f = XLSX.open_empty_template()
- s = f["Sheet1"]
- s["A1"] = ""
- s["F21"] = ""
- # All these cells are empty.
- @test XLSX.setUniformFont(s, "A2:B4"; size=12, name="Times New Roman", color="chocolate4") == -1
- @test XLSX.setUniformBorder(f, "Sheet1!A2:D4"; left=["style" => "dotted", "color" => "chocolate4"],
- right=["style" => "medium", "color" => "FF765000"],
- top=["style" => "thick", "color" => "FF230000"],
- bottom=["style" => "medium", "color" => "chocolate4"],
- diagonal=["style" => "none"]
- ) == -1
- @test XLSX.setUniformFill(s, "B2:D4"; pattern="gray125", bgColor="FF000000") == -1
- @test XLSX.setFont(s, "A2:F20"; size=18, name="Arial") == -1
- @test XLSX.setBorder(f, "Sheet1!B2:D4"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "chocolate4"], diagonal=["style" => "hair"]) == -1
- @test XLSX.setAlignment(s, "A2:F20"; horizontal="right", wrapText=true) == -1
- @test XLSX.setUniformFill(s, [2, 4], 2:4; pattern="gray125", bgColor="FF000000") == -1
- @test XLSX.setFont(s, [2, 4], 2:4; size=18, name="Arial") == -1
- @test XLSX.setBorder(s, [2, 4], :; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "chocolate4"], diagonal=["style" => "hair"]) == -1
- @test XLSX.setAlignment(s, [2, 4], 2:4; horizontal="right", wrapText=true) == -1
- @test XLSX.setUniformFill(s, "B2,C2"; pattern="gray125", bgColor="FF000000") == -1
- @test XLSX.setFont(s, "A2,A4"; size=18, name="Arial") == -1
- @test XLSX.setBorder(f, "Sheet1!B2,Sheet1!C2"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "chocolate4"], diagonal=["style" => "hair"]) == -1
- @test XLSX.setAlignment(s, "A2,B3:C4"; horizontal="right", wrapText=true) == -1
- @test XLSX.setUniformAlignment(s, "B2,D2"; horizontal="right", wrapText=true) == -1
- @test XLSX.setUniformStyle(s, "B2:D2,E3") == -1
- @test_throws XLSX.XLSXError XLSX.setUniformFill(s, "B2,B2"; pattern="gray125", bgColor="FF000000")
- @test_throws XLSX.XLSXError XLSX.setFont(s, "A2,A2"; size=18, name="Arial")
- @test_throws XLSX.XLSXError XLSX.setBorder(f, "Sheet1!B2,Sheet1!B2"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "chocolate4"], diagonal=["style" => "hair"])
- @test_throws XLSX.XLSXError XLSX.setAlignment(s, "A2,A2:A2"; horizontal="right", wrapText=true)
- @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "B2,B2"; horizontal="right", wrapText=true)
- @test_throws XLSX.XLSXError XLSX.setUniformStyle(s, "B2:B2,B2")
- SAVE_FILES && save_outfile(f)
-
- f = XLSX.open_empty_template()
- s = f["Sheet1"]
- # All these cells are outside the sheet dimension.
- @test_throws XLSX.XLSXError XLSX.setUniformFont(s, "A1:B4"; size=12, name="Times New Roman", color="chocolate4")
- @test_throws XLSX.XLSXError XLSX.setUniformBorder(f, "Sheet1!A1:D4"; left=["style" => "dotted", "color" => "chocolate4"],
- right=["style" => "medium", "color" => "FF765000"],
- top=["style" => "thick", "color" => "FF230000"],
- bottom=["style" => "medium", "color" => "chocolate4"],
- diagonal=["style" => "none"]
- )
- @test_throws XLSX.XLSXError XLSX.setUniformFill(s, "B2:D4"; pattern="gray125", bgColor="FF000000")
- @test_throws XLSX.XLSXError XLSX.setFont(s, "A1:F20"; size=18, name="Arial")
- @test_throws XLSX.XLSXError XLSX.setBorder(f, "Sheet1!B2:D4"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "chocolate4"], diagonal=["style" => "hair"])
- @test_throws XLSX.XLSXError XLSX.setAlignment(s, "A1:F20"; horizontal="right", wrapText=true)
- @test_throws XLSX.XLSXError XLSX.setFill(f, "Sheet1!A1"; pattern="none", fgColor="88FF8800")
- @test_throws XLSX.XLSXError XLSX.setFont(s, "A1"; size=18, name="Arial")
- @test_throws XLSX.XLSXError XLSX.setBorder(f, "Sheet1!B2"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "FF8B4513"], diagonal=["style" => "hair"])
- @test_throws XLSX.XLSXError XLSX.setFill(s, "F20"; pattern="none", fgColor="88FF8800")
- SAVE_FILES && save_outfile(f)
-
- f = XLSX.open_xlsx_template(joinpath(data_directory, "customXml.xlsx"))
- s = f["Mock-up"]
-
- # Can't set a uniform attribute to a single cell.
- @test_throws MethodError XLSX.setUniformFill(s, "D4"; pattern="gray125", bgColor="FF000000")
- @test_throws MethodError XLSX.setUniformFill(s, "ID"; pattern="darkTrellis", fgColor="FF222222", bgColor="FFDDDDDD")
- @test_throws MethodError XLSX.setUniformFont(s, "B4"; size=12, name="Times New Roman", color="FF040404")
- @test_throws MethodError XLSX.setUniformBorder(f[2], "B4"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "FF8B4513"], diagonal=["style" => "hair"])
- @test_throws MethodError XLSX.setUniformStyle(s, "ID")
- @test_throws MethodError XLSX.setUniformBorder(f, "Mock-up!D4"; left=["style" => "dotted", "color" => "FF000FF0"],
- right=["style" => "medium", "color" => "FF765000"],
- top=["style" => "thick", "color" => "FF230000"],
- bottom=["style" => "medium", "color" => "FF0000FF"],
- diagonal=["style" => "none"]
- )
- SAVE_FILES && save_outfile(f)
-
f = XLSX.newxlsx()
s = f[1]
s[1:6, 1:6] = ""
@@ -826,6 +731,15 @@ end
@test XLSX.getBorder(s, "A2").border == Dict("left" => Dict("rgb" => "FFFFFFF0", "style" => "dotted"), "bottom" => Dict("rgb" => "FFFFFFF0", "style" => "dotted"), "right" => Dict("rgb" => "FFFFFFF0", "style" => "dotted"), "top" => nothing, "diagonal" => nothing)
SAVE_FILES && save_outfile(f)
+
+ @testset "borders through the XLSXFile" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh[1:2, 1:2] = [1 2; 3 4]
+ XLSX.setBorder(xf, "Sheet1!A1:B2"; outside=["style" => "thin", "color" => "black"])
+ @test XLSX.getBorder(sh, "A1").border["top"]["style"] == "thin"
+ SAVE_FILES && save_outfile(xf)
+ end
end
@testset "setFill" begin
@@ -853,7 +767,7 @@ end
@test XLSX.getFill(s, "D18").fill == Dict("patternFill" => Dict("bgindexed" => "64", "patternType" => "lightVertical", "fgtint" => "-0.499984740745262", "fgtheme" => "2"))
@test XLSX.getFill(s, "D20").fill == Dict("patternFill" => Dict("bgindexed" => "64", "patternType" => "lightVertical", "fgtint" => "-0.499984740745262", "fgtheme" => "2"))
@test XLSX.getFill(s, "J18").fill == Dict("patternFill" => Dict("bgindexed" => "64", "patternType" => "lightVertical", "fgtint" => "-0.499984740745262", "fgtheme" => "2"))
- @test XLSX.getFill(s, "J18").fill == Dict("patternFill" => Dict("bgindexed" => "64", "patternType" => "lightVertical", "fgtint" => "-0.499984740745262", "fgtheme" => "2"))
+ @test XLSX.getFill(s, "J20").fill == Dict("patternFill" => Dict("bgindexed" => "64", "patternType" => "lightVertical", "fgtint" => "-0.499984740745262", "fgtheme" => "2"))
XLSX.setFill(s, "Contiguous"; pattern="lightVertical") # Default colors unchanged
@test XLSX.getFill(s, "D23").fill == Dict("patternFill" => Dict("patternType" => "lightVertical", "bgindexed" => "64", "fgtheme" => "0"))
@@ -1184,7 +1098,6 @@ end
@test XLSX.getFormat(s, "E2").format == Dict("numFmt" => Dict("numFmtId" => "14", "formatCode" => "m/d/yyyy"))
@test XLSX.getFormat(s, "F2").format == Dict("numFmt" => Dict("numFmtId" => "14", "formatCode" => "m/d/yyyy"))
-
@test XLSX.setFormat(s, "A2"; format="""_-£* #,##0.00_-;-£* #,##0.00_-;_-£* "-"??_-;_-@_-""") == 164
@test XLSX.getFormat(s, "A2").format == Dict("numFmt" => Dict("numFmtId" => "164", "formatCode" => "_-£* #,##0.00_-;-£* #,##0.00_-;_-£* \"-\"??_-;_-@_-"))
@test XLSX.setFormat(s, "A2"; format="_-£* #,##0.00_-;-£* #,##0.00_-;_-£* \"-\"??_-;_-@_-") == 164
@@ -1200,7 +1113,6 @@ end
@test XLSX.getFormat(s, "E2").format == Dict("numFmt" => Dict("numFmtId" => "0", "formatCode" => "General"))
@test XLSX.getFormat(f, "Sheet1!F2").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)"))
-
@test XLSX.setFormat(f, "Sheet1!E1:F5"; format="#,##0.000") == -1
@test XLSX.setFormat(s, "F1:F5"; format="#,##0.000") == -1
@test XLSX.getFormat(s, "E2").format == Dict("numFmt" => Dict("numFmtId" => "165", "formatCode" => "#,##0.000"))
@@ -1210,7 +1122,6 @@ end
@test_throws XLSX.XLSXError XLSX.setFormat(s, "Sheet1!E1:F5,Sheet1!Z100"; format="Currency")
@test_throws XLSX.XLSXError XLSX.setFormat(s, "E2"; format="ffzz345")
-
XLSX.writexlsx("test.xlsx", f, overwrite=true)
SAVE_FILES && save_outfile("test.xlsx")
@@ -1322,9 +1233,9 @@ end
@test XLSX.getcell(s, "E8").style == cell_style
XLSX.setFont(s, "A11:F15"; size=10, name="Times New Roman")
- cell_style = XLSX.getcell(s, "E6").style
- @test XLSX.setUniformStyle(s, [6, 7, 8, 9, 10], :) == cell_style
- @test XLSX.getcell(s, "Z8").style == cell_style
+ cell_style = XLSX.getcell(s, "E11").style
+ @test XLSX.setUniformStyle(s, [11, 12, 13, 14, 15], :) == cell_style
+ @test XLSX.getcell(s, "Z13").style == cell_style
XLSX.setFont(s, "A16"; size=80, name="Ariel")
cell_style = XLSX.getcell(s, "A16").style
@@ -1545,9 +1456,9 @@ end
end
- @testset "No cache" begin
+ @testset "read-only files reject style getters" begin
XLSX.openxlsx(joinpath(data_directory, "customXml.xlsx"); mode="r", enable_cache=true) do f
- @test_throws XLSX.XLSXError XLSX.getRowHeight(f, "Mock-up!B2") ≈ 23.25 # File not writable
+ @test_throws XLSX.XLSXError XLSX.getRowHeight(f, "Mock-up!B2") # File not writable
@test_throws XLSX.XLSXError XLSX.getColumnWidth(f, "Mock-up!B2") # File not writable
end
XLSX.openxlsx(joinpath(data_directory, "customXml.xlsx"); mode="r", enable_cache=false) do f
@@ -1573,7 +1484,7 @@ end
end
end
- @testset "indexing setAttribute" begin
+ @testset "setters on every target form" begin
f = XLSX.newxlsx() # Empty XLSXFile
s = f[1] #1×1 XLSX.Worksheet: ["Sheet1"](A1:A1)
@@ -1595,9 +1506,9 @@ end
@test XLSX.setFont(s, 2:3, 1:3; color="grey42") == -1
# Outside sheet dimension
- @test_throws XLSX.XLSXError XLSX.getFont(s, 2, 4)
- @test_throws XLSX.XLSXError XLSX.getFont(s, 4, 2)
- @test_throws XLSX.XLSXError XLSX.getFont(s, 4, 4)
+ @test isnothing(XLSX.getFont(s, 2, 4)) # outside the dimension: an empty cell
+ @test isnothing(XLSX.getFont(s, 4, 2)) # outside the dimension: an empty cell
+ @test isnothing(XLSX.getFont(s, 4, 4)) # outside the dimension: an empty cell
s[1:3, 1:3] = ""
default_font = XLSX.getDefaultFont(s).font
@@ -1625,10 +1536,10 @@ end
@test_throws XLSX.XLSXError XLSX.setBorder(s, :, 1; allsides=["color" => "grey42", "style" => "thick"])
@test_throws XLSX.XLSXError XLSX.setBorder(s, :; allsides=["color" => "grey42", "style" => "thick"])
@test_throws XLSX.XLSXError XLSX.setBorder(s, :, :; allsides=["color" => "grey42", "style" => "thick"])
- @test_throws XLSX.XLSXError XLSX.setBorder(s, [2, 3], 1:3; allsides=["color" => "grey42", "style" => "thick"]) == -1
- @test_throws XLSX.XLSXError XLSX.getBorder(s, 2, 1)
- @test_throws XLSX.XLSXError XLSX.getBorder(s, 3, 2)
- @test_throws XLSX.XLSXError XLSX.getBorder(s, 2, 3)
+ @test_throws XLSX.XLSXError XLSX.setBorder(s, [2, 3], 1:3; allsides=["color" => "grey42", "style" => "thick"])
+ @test isnothing(XLSX.getBorder(s, 2, 1)) # outside the dimension: an empty cell
+ @test isnothing(XLSX.getBorder(s, 3, 2)) # outside the dimension: an empty cell
+ @test isnothing(XLSX.getBorder(s, 2, 3)) # outside the dimension: an empty cell
s[1:3, 1:3] = ""
XLSX.setBorder(s, "A1"; allsides=["color" => "grey42", "style" => "thick"])
@test XLSX.getBorder(s, "A1").border == Dict("left" => Dict("rgb" => "FF6B6B6B", "style" => "thick"), "bottom" => Dict("rgb" => "FF6B6B6B", "style" => "thick"), "right" => Dict("rgb" => "FF6B6B6B", "style" => "thick"), "top" => Dict("rgb" => "FF6B6B6B", "style" => "thick"), "diagonal" => nothing)
@@ -1652,10 +1563,10 @@ end
@test_throws XLSX.XLSXError XLSX.setFill(s, 1, :; pattern="lightVertical", fgColor="Red", bgColor="blue")
@test_throws XLSX.XLSXError XLSX.setFill(s, :, :; pattern="lightVertical", fgColor="Red", bgColor="blue")
@test_throws XLSX.XLSXError XLSX.setFill(s, :; pattern="lightVertical", fgColor="Red", bgColor="blue")
- @test_throws XLSX.XLSXError XLSX.setFill(s, [2, 3], 1:3; pattern="lightVertical", fgColor="Red", bgColor="blue") == -1
- @test_throws XLSX.XLSXError XLSX.getFill(s, 2, 1)
- @test_throws XLSX.XLSXError XLSX.getFill(s, 3, 2)
- @test_throws XLSX.XLSXError XLSX.getFill(s, 2, 3)
+ @test_throws XLSX.XLSXError XLSX.setFill(s, [2, 3], 1:3; pattern="lightVertical", fgColor="Red", bgColor="blue")
+ @test isnothing(XLSX.getFill(s, 2, 1)) # outside the dimension: an empty cell
+ @test isnothing(XLSX.getFill(s, 3, 2)) # outside the dimension: an empty cell
+ @test isnothing(XLSX.getFill(s, 2, 3)) # outside the dimension: an empty cell
s[1:3, 1:3] = ""
XLSX.setFill(s, "A1"; pattern="lightVertical", fgColor="Red", bgColor="blue")
@test XLSX.getFill(s, "A1").fill == Dict("patternFill" => Dict("bgrgb" => "FF0000FF", "patternType" => "lightVertical", "fgrgb" => "FFFF0000"))
@@ -1679,10 +1590,10 @@ end
@test_throws XLSX.XLSXError XLSX.setAlignment(s, 1, :; horizontal="right", vertical="justify", wrapText=true)
@test_throws XLSX.XLSXError XLSX.setAlignment(s, :, :; horizontal="right", vertical="justify", wrapText=true)
@test_throws XLSX.XLSXError XLSX.setAlignment(s, :; horizontal="right", vertical="justify", wrapText=true)
- @test_throws XLSX.XLSXError XLSX.setAlignment(s, [2, 3], 1:3; horizontal="right", vertical="justify", wrapText=true) == -1
- @test_throws XLSX.XLSXError XLSX.getAlignment(s, 2, 1)
- @test_throws XLSX.XLSXError XLSX.getAlignment(s, 3, 2)
- @test_throws XLSX.XLSXError XLSX.getAlignment(s, 2, 3)
+ @test_throws XLSX.XLSXError XLSX.setAlignment(s, [2, 3], 1:3; horizontal="right", vertical="justify", wrapText=true)
+ @test isnothing(XLSX.getAlignment(s, 2, 1)) # outside the dimension: an empty cell
+ @test isnothing(XLSX.getAlignment(s, 3, 2)) # outside the dimension: an empty cell
+ @test isnothing(XLSX.getAlignment(s, 2, 3)) # outside the dimension: an empty cell
s[1:3, 1:3] = ""
XLSX.setAlignment(s, "A1"; horizontal="right", vertical="justify", wrapText=true)
@test XLSX.getAlignment(s, "A1").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1"))
@@ -1706,10 +1617,10 @@ end
@test_throws XLSX.XLSXError XLSX.setFormat(s, 1, :; format="Percentage")
@test_throws XLSX.XLSXError XLSX.setFormat(s, :, :; format="Percentage")
@test_throws XLSX.XLSXError XLSX.setFormat(s, :; format="Percentage")
- @test_throws XLSX.XLSXError XLSX.setFormat(s, [2, 3], 1:3; format="Percentage") == -1
- @test_throws XLSX.XLSXError XLSX.getFormat(s, 2, 1)
- @test_throws XLSX.XLSXError XLSX.getFormat(s, 3, 2)
- @test_throws XLSX.XLSXError XLSX.getFormat(s, 2, 3)
+ @test_throws XLSX.XLSXError XLSX.setFormat(s, [2, 3], 1:3; format="Percentage")
+ @test isnothing(XLSX.getFormat(s, 2, 1)) # outside the dimension: an empty cell
+ @test isnothing(XLSX.getFormat(s, 3, 2)) # outside the dimension: an empty cell
+ @test isnothing(XLSX.getFormat(s, 2, 3)) # outside the dimension: an empty cell
s[1:3, 1:3] = ""
XLSX.setFormat(s, "A1"; format="#,##0.000;(#,##0.000)")
@test XLSX.getFormat(s, "A1").format == Dict("numFmt" => Dict("numFmtId" => "164", "formatCode" => "#,##0.000;(#,##0.000)"))
@@ -1783,19 +1694,158 @@ end
@test XLSX.getBorder(s, 4, 4).border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF9BCD9B"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing)
SAVE_FILES && save_outfile(f)
- end
+ @testset "empty cells, cells outside the dimension, and single cells" begin
+ f = XLSX.open_empty_template()
+ s = f["Sheet1"]
+ s["A1"] = ""
+ s["F21"] = ""
+ # All these cells are empty.
+ @test XLSX.setUniformFont(s, "A2:B4"; size=12, name="Times New Roman", color="chocolate4") == -1
+ @test XLSX.setUniformBorder(f, "Sheet1!A2:D4"; left=["style" => "dotted", "color" => "chocolate4"],
+ right=["style" => "medium", "color" => "FF765000"],
+ top=["style" => "thick", "color" => "FF230000"],
+ bottom=["style" => "medium", "color" => "chocolate4"],
+ diagonal=["style" => "none"]
+ ) == -1
+ @test XLSX.setUniformFill(s, "B2:D4"; pattern="gray125", bgColor="FF000000") == -1
+ @test XLSX.setFont(s, "A2:F20"; size=18, name="Arial") == -1
+ @test XLSX.setBorder(f, "Sheet1!B2:D4"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "chocolate4"], diagonal=["style" => "hair"]) == -1
+ @test XLSX.setAlignment(s, "A2:F20"; horizontal="right", wrapText=true) == -1
+ @test XLSX.setUniformFill(s, [2, 4], 2:4; pattern="gray125", bgColor="FF000000") == -1
+ @test XLSX.setFont(s, [2, 4], 2:4; size=18, name="Arial") == -1
+ @test XLSX.setBorder(s, [2, 4], :; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "chocolate4"], diagonal=["style" => "hair"]) == -1
+ @test XLSX.setAlignment(s, [2, 4], 2:4; horizontal="right", wrapText=true) == -1
+ @test XLSX.setUniformFill(s, "B2,C2"; pattern="gray125", bgColor="FF000000") == -1
+ @test XLSX.setFont(s, "A2,A4"; size=18, name="Arial") == -1
+ @test XLSX.setBorder(f, "Sheet1!B2,Sheet1!C2"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "chocolate4"], diagonal=["style" => "hair"]) == -1
+ @test XLSX.setAlignment(s, "A2,B3:C4"; horizontal="right", wrapText=true) == -1
+ @test XLSX.setUniformAlignment(s, "B2,D2"; horizontal="right", wrapText=true) == -1
+ @test XLSX.setUniformStyle(s, "B2:D2,E3") == -1
+ @test_throws XLSX.XLSXError XLSX.setUniformFill(s, "B2,B2"; pattern="gray125", bgColor="FF000000")
+ @test_throws XLSX.XLSXError XLSX.setFont(s, "A2,A2"; size=18, name="Arial")
+ @test_throws XLSX.XLSXError XLSX.setBorder(f, "Sheet1!B2,Sheet1!B2"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "chocolate4"], diagonal=["style" => "hair"])
+ @test_throws XLSX.XLSXError XLSX.setAlignment(s, "A2,A2:A2"; horizontal="right", wrapText=true)
+ @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "B2,B2"; horizontal="right", wrapText=true)
+ @test_throws XLSX.XLSXError XLSX.setUniformStyle(s, "B2:B2,B2")
+ SAVE_FILES && save_outfile(f)
- @testset "existing formatting" begin
- f = XLSX.opentemplate(joinpath(data_directory, "customXml.xlsx"))
- s = f[1]
- s["B2"] = pi
- s["D20"] = "Hello World"
- s["J45"] = Dates.Date(2025, 01, 24)
- @test XLSX.getFont(s, "B2").font == Dict("name" => Dict("val" => "Calibri"), "family" => Dict("val" => "2"), "b" => nothing, "sz" => Dict("val" => "18"), "color" => Dict("theme" => "1"), "scheme" => Dict("val" => "minor"))
- @test XLSX.getFill(s, "D20").fill == Dict("patternFill" => Dict("bgindexed" => "64", "patternType" => "solid", "fgtint" => "-0.499984740745262", "fgtheme" => "2"))
- @test XLSX.getBorder(s, "J45").border == Dict("left" => Dict("indexed" => "64", "style" => "thin"), "bottom" => Dict("indexed" => "64", "style" => "thin"), "right" => Dict("indexed" => "64", "style" => "thin"), "top" => Dict("indexed" => "64", "style" => "thin"), "diagonal" => nothing)
- SAVE_FILES && save_outfile(f)
+ f = XLSX.open_empty_template()
+ s = f["Sheet1"]
+ # All these cells are outside the sheet dimension.
+ @test_throws XLSX.XLSXError XLSX.setUniformFont(s, "A1:B4"; size=12, name="Times New Roman", color="chocolate4")
+ @test_throws XLSX.XLSXError XLSX.setUniformBorder(f, "Sheet1!A1:D4"; left=["style" => "dotted", "color" => "chocolate4"],
+ right=["style" => "medium", "color" => "FF765000"],
+ top=["style" => "thick", "color" => "FF230000"],
+ bottom=["style" => "medium", "color" => "chocolate4"],
+ diagonal=["style" => "none"]
+ )
+ @test_throws XLSX.XLSXError XLSX.setUniformFill(s, "B2:D4"; pattern="gray125", bgColor="FF000000")
+ @test_throws XLSX.XLSXError XLSX.setFont(s, "A1:F20"; size=18, name="Arial")
+ @test_throws XLSX.XLSXError XLSX.setBorder(f, "Sheet1!B2:D4"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "chocolate4"], diagonal=["style" => "hair"])
+ @test_throws XLSX.XLSXError XLSX.setAlignment(s, "A1:F20"; horizontal="right", wrapText=true)
+ @test_throws XLSX.XLSXError XLSX.setFill(f, "Sheet1!A1"; pattern="none", fgColor="88FF8800")
+ @test_throws XLSX.XLSXError XLSX.setFont(s, "A1"; size=18, name="Arial")
+ @test_throws XLSX.XLSXError XLSX.setBorder(f, "Sheet1!B2"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "FF8B4513"], diagonal=["style" => "hair"])
+ @test_throws XLSX.XLSXError XLSX.setFill(s, "F20"; pattern="none", fgColor="88FF8800")
+ SAVE_FILES && save_outfile(f)
+
+ f = XLSX.open_xlsx_template(joinpath(data_directory, "customXml.xlsx"))
+ s = f["Mock-up"]
+
+ # Can't set a uniform attribute to a single cell.
+ @test_throws MethodError XLSX.setUniformFill(s, "D4"; pattern="gray125", bgColor="FF000000")
+ @test_throws MethodError XLSX.setUniformFill(s, "ID"; pattern="darkTrellis", fgColor="FF222222", bgColor="FFDDDDDD")
+ @test_throws MethodError XLSX.setUniformFont(s, "B4"; size=12, name="Times New Roman", color="FF040404")
+ @test_throws MethodError XLSX.setUniformBorder(f[2], "B4"; left=["style" => "hair"], right=["color" => "FF8B4513"], top=["style" => "hair"], bottom=["color" => "FF8B4513"], diagonal=["style" => "hair"])
+ @test_throws MethodError XLSX.setUniformStyle(s, "ID")
+ @test_throws MethodError XLSX.setUniformBorder(f, "Mock-up!D4"; left=["style" => "dotted", "color" => "FF000FF0"],
+ right=["style" => "medium", "color" => "FF765000"],
+ top=["style" => "thick", "color" => "FF230000"],
+ bottom=["style" => "medium", "color" => "FF0000FF"],
+ diagonal=["style" => "none"]
+ )
+ SAVE_FILES && save_outfile(f)
+ end
+
+ # A range that resolves to a single cell should return that cell's attribute id,
+ # not -1. Multi-cell ranges keep returning -1.
+ @testset "single-cell ranges return an id" begin
+
+ @testset "setFormat - all single-cell spellings agree" begin
+ _, s = onecell()
+ @test XLSX.setFormat(s, XLSX.CellRef("A1"); format="#,##0") == 3 # reference case
+ @test XLSX.setFormat(s, "A1"; format="#,##0") == 3
+ @test XLSX.setFormat(s, "A1:A1"; format="#,##0") == 3 # 1x1 CellRange
+ @test XLSX.setFormat(s, XLSX.CellRange("A1:A1"); format="#,##0") == 3
+ @test XLSX.setFormat(s, 1, 1; format="#,##0") == 3 # Integer, Integer
+ @test XLSX.setFormat(s, 1:1, 1:1; format="#,##0") == 3 # UnitRange
+ @test XLSX.setFormat(s, "A:A"; format="#,##0") == 3 # column range, 1x1 sheet
+ @test XLSX.setFormat(s, "1:1"; format="#,##0") == 3 # row range, 1x1 sheet
+ @test XLSX.setFormat(s, :, :; format="#,##0") == 3 # colon, 1x1 sheet
+
+ # single-cell non-contiguous range (process_ncranges)
+ nc = XLSX.NonContiguousRange(s.name, Union{XLSX.CellRef,XLSX.CellRange}[XLSX.CellRef("A1")])
+ @test XLSX.setFormat(s, nc; format="#,##0") == 3
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(s))
+ end
+
+ @testset "multi-cell ranges still return -1" begin
+ f = XLSX.newxlsx()
+ s = f[1]
+ s["A1"] = 1
+ s["B1"] = 2
+ @test XLSX.setFormat(s, "A1:B1"; format="#,##0") == -1
+ @test XLSX.setFormat(s, 1, 1:2; format="#,##0") == -1
+ @test XLSX.setFormat(s, "A1,B1"; format="#,##0") == -1
+ # ... but each cell did get the format
+ @test XLSX.getFormat(s, "A1").numFmtId == 3
+ @test XLSX.getFormat(s, "B1").numFmtId == 3
+ SAVE_FILES && save_outfile(f)
+ end
+
+ @testset "other setters agree with the CellRef form" begin
+ _, s = onecell()
+ @test XLSX.setFont(s, "A1:A1"; bold=true) == XLSX.setFont(s, XLSX.CellRef("A1"); bold=true)
+ @test XLSX.setFill(s, "A1:A1"; pattern="solid", fgColor="FFFFFF00") ==
+ XLSX.setFill(s, XLSX.CellRef("A1"); pattern="solid", fgColor="FFFFFF00")
+ @test XLSX.setAlignment(s, "A1:A1"; horizontal="right") ==
+ XLSX.setAlignment(s, XLSX.CellRef("A1"); horizontal="right")
+ @test XLSX.setBorder(s, "A1:A1"; allsides=["style" => "thin"]) ==
+ XLSX.setBorder(s, XLSX.CellRef("A1"); allsides=["style" => "thin"])
+ SAVE_FILES && save_outfile(XLSX.get_xlsxfile(s))
+ end
+
+ @testset "empty cell in a single-cell range still throws" begin
+ f = XLSX.newxlsx()
+ s = f[1]
+ s["A1"] = 1
+ s["C1"] = 3 # B1 is empty but inside the dimension
+ @test_throws XLSX.XLSXError XLSX.setFormat(s, "B1:B1"; format="#,##0")
+ # a multi-cell range skips empties silently
+ @test XLSX.setFormat(s, "A1:C1"; format="#,##0") == -1
+ SAVE_FILES && save_outfile(f)
+ end
+
+ end
+
+ @testset "vector, step-range and colon targets" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh[1:3, 1:4] = reshape(collect(1:12), 3, 4)
+
+ # formats over vector / step-range columns, and colon rows
+ XLSX.setFormat(sh, 1, [1, 3]; format="0.00")
+ @test XLSX.getFormat(sh, "A1").format["numFmt"]["formatCode"] == XLSX.getFormat(sh, "C1").format["numFmt"]["formatCode"] == "0.00"
+ XLSX.setUniformFormat(sh, 2, 1:2:3; format="0.0")
+ @test XLSX.getcell(sh, "A2").style == XLSX.getcell(sh, "C2").style
+ @test XLSX.getFormat(sh, "C2").format["numFmt"]["formatCode"] == "0.0"
+ XLSX.setUniformFill(sh, 3, :; pattern="solid", fgColor="yellow")
+ @test XLSX.getcell(sh, "A3").style == XLSX.getcell(sh, "D3").style
+ @test XLSX.getFill(sh, "D3").fill["patternFill"]["fgrgb"] == "FFFFFF00"
+ SAVE_FILES && save_outfile(xf)
+ end
end
+
@testset "Styles caching (issue #426)" begin
# Builds a workbook with K rows in column 1, each holding a numeric value.
@@ -2081,68 +2131,17 @@ end
end
end
end
-#=
-A range that resolves to a single cell should return that cell's attribute id,
-not -1. Multi-cell ranges keep returning -1.
-=#
-
- @testset "single-cell ranges return an id" begin
-
- @testset "setFormat - all single-cell spellings agree" begin
- _, s = onecell()
- @test XLSX.setFormat(s, XLSX.CellRef("A1"); format="#,##0") == 3 # reference case
- @test XLSX.setFormat(s, "A1"; format="#,##0") == 3
- @test XLSX.setFormat(s, "A1:A1"; format="#,##0") == 3 # 1x1 CellRange
- @test XLSX.setFormat(s, XLSX.CellRange("A1:A1"); format="#,##0") == 3
- @test XLSX.setFormat(s, 1, 1; format="#,##0") == 3 # Integer, Integer
- @test XLSX.setFormat(s, 1:1, 1:1; format="#,##0") == 3 # UnitRange
- @test XLSX.setFormat(s, "A:A"; format="#,##0") == 3 # column range, 1x1 sheet
- @test XLSX.setFormat(s, "1:1"; format="#,##0") == 3 # row range, 1x1 sheet
- @test XLSX.setFormat(s, :, :; format="#,##0") == 3 # colon, 1x1 sheet
-
- # single-cell non-contiguous range (process_ncranges)
- nc = XLSX.NonContiguousRange(s.name, Union{XLSX.CellRef,XLSX.CellRange}[XLSX.CellRef("A1")])
- @test XLSX.setFormat(s, nc; format="#,##0") == 3
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(s))
- end
-
- @testset "multi-cell ranges still return -1" begin
- f = XLSX.newxlsx()
- s = f[1]
- s["A1"] = 1
- s["B1"] = 2
- @test XLSX.setFormat(s, "A1:B1"; format="#,##0") == -1
- @test XLSX.setFormat(s, 1, 1:2; format="#,##0") == -1
- @test XLSX.setFormat(s, "A1,B1"; format="#,##0") == -1
- # ... but each cell did get the format
- @test XLSX.getFormat(s, "A1").numFmtId == 3
- @test XLSX.getFormat(s, "B1").numFmtId == 3
- SAVE_FILES && save_outfile(f)
- end
-
- @testset "other setters agree with the CellRef form" begin
- _, s = onecell()
- @test XLSX.setFont(s, "A1:A1"; bold=true) == XLSX.setFont(s, XLSX.CellRef("A1"); bold=true)
- @test XLSX.setFill(s, "A1:A1"; pattern="solid", fgColor="FFFFFF00") ==
- XLSX.setFill(s, XLSX.CellRef("A1"); pattern="solid", fgColor="FFFFFF00")
- @test XLSX.setAlignment(s, "A1:A1"; horizontal="right") ==
- XLSX.setAlignment(s, XLSX.CellRef("A1"); horizontal="right")
- @test XLSX.setBorder(s, "A1:A1"; allsides=["style" => "thin"]) ==
- XLSX.setBorder(s, XLSX.CellRef("A1"); allsides=["style" => "thin"])
- SAVE_FILES && save_outfile(XLSX.get_xlsxfile(s))
- end
-
- @testset "empty cell in a single-cell range still throws" begin
- f = XLSX.newxlsx()
- s = f[1]
- s["A1"] = 1
- s["C1"] = 3 # B1 is empty but inside the dimension
- @test_throws XLSX.XLSXError XLSX.setFormat(s, "B1:B1"; format="#,##0")
- # a multi-cell range skips empties silently
- @test XLSX.setFormat(s, "A1:C1"; format="#,##0") == -1
- SAVE_FILES && save_outfile(f)
- end
+ @testset "existing formatting" begin
+ f = XLSX.opentemplate(joinpath(data_directory, "customXml.xlsx"))
+ s = f[1]
+ s["B2"] = pi
+ s["D20"] = "Hello World"
+ s["J45"] = Dates.Date(2025, 01, 24)
+ @test XLSX.getFont(s, "B2").font == Dict("name" => Dict("val" => "Calibri"), "family" => Dict("val" => "2"), "b" => nothing, "sz" => Dict("val" => "18"), "color" => Dict("theme" => "1"), "scheme" => Dict("val" => "minor"))
+ @test XLSX.getFill(s, "D20").fill == Dict("patternFill" => Dict("bgindexed" => "64", "patternType" => "solid", "fgtint" => "-0.499984740745262", "fgtheme" => "2"))
+ @test XLSX.getBorder(s, "J45").border == Dict("left" => Dict("indexed" => "64", "style" => "thin"), "bottom" => Dict("indexed" => "64", "style" => "thin"), "right" => Dict("indexed" => "64", "style" => "thin"), "top" => Dict("indexed" => "64", "style" => "thin"), "diagonal" => nothing)
+ SAVE_FILES && save_outfile(f)
end
#=
@@ -2264,4 +2263,268 @@ writing a number into a cell carrying a date/time format.
end
-end
\ No newline at end of file
+
+ @testset "formatting through defined names and odd references" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ sh[1:3, 1:3] = reshape(collect(1:9), 3, 3)
+ XLSX.addDefName(sh, "ws_const", 5)
+ XLSX.addDefName(xf, "wb_const", 6)
+
+ # a constant name is not a cell, whichever way it is reached
+ @test_throws XLSX.XLSXError XLSX.setFont(xf, "wb_const"; bold=true)
+ @test_throws XLSX.XLSXError XLSX.setFont(sh, "ws_const"; bold=true)
+ @test_throws XLSX.XLSXError XLSX.setFont(sh, "wb_const"; bold=true)
+ @test_throws XLSX.XLSXError XLSX.getFont(sh, "wb_const")
+ @test_throws XLSX.XLSXError XLSX.getFont(sh, "A1:B2") # a getter takes one cell
+ @test isnothing(XLSX.getFont(xf, "Sheet1!Z99")) # outside the dimension: empty
+ @test isnothing(XLSX.getFormula(xf, "Sheet1!Z99"))
+ @test isnothing(XLSX.getFormula(sh, "Z99"))
+
+ # non-contiguous ranges, with and without the sheet name
+ XLSX.setFont(sh, "A1,C1"; bold=true)
+ @test XLSX.getFont(sh, "A1").font["b"] === nothing && XLSX.getFont(sh, "C1").font["b"] === nothing
+ XLSX.setFont(sh, "Sheet1!A2,Sheet1!C2"; italic=true)
+ @test haskey(XLSX.getFont(sh, "C2").font, "i")
+ @test_throws XLSX.XLSXError XLSX.setFont(sh, XLSX.NonContiguousRange("Sheet1!A1,Sheet1!Z99"); bold=true)
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "style classification forms, and colorants" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ wb = XLSX.get_workbook(xf)
+ sh["A1"] = 1.5
+ XLSX.setFormat(sh, "A1"; format="0.00")
+ sid = string(XLSX.getcell(sh, "A1").style)
+ @test XLSX.styles_is_float(sh, sid) == XLSX.styles_is_float(wb, parse(Int, sid))
+ @test XLSX.styles_is_datetime(sh, sid) == XLSX.styles_is_datetime(wb, parse(Int, sid))
+ @test !isempty(XLSX.CellDataFormat(1)) && isempty(XLSX.EmptyCellDataFormat())
+ @test XLSX.get_colorant(:red) == XLSX.get_colorant("red")
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ @testset "every color form in setFont, setFill and setBorder" begin
+ xf = XLSX.newxlsx()
+ sh = xf[1]
+ for r in 1:8, c in 1:3
+ sh[r, c] = "x"
+ end
+
+ # Font
+ XLSX.setFont(sh, "A1"; color = :coral)
+ XLSX.setFont(sh, "A2"; color = colorant"seagreen")
+ XLSX.setFont(sh, "A3"; color = RGBA(1, 0, 0, 0.5))
+ XLSX.setFont(sh, "A4"; color = XLSX.SchemeColor(:accent1; lumMod = 60, lumOff = 40))
+ XLSX.setFont(sh, "A5"; color = "accent2")
+ XLSX.setFont(sh, "A6"; color = :tx1)
+ XLSX.setUniformFont(sh, "A7:A8"; color = XLSX.SchemeColor(:accent3; lumMod = 75))
+ @test XLSX.getFont(sh, "A1").font["color"] == Dict("rgb" => "FFFF7F50")
+ @test XLSX.getFont(sh, "A2").font["color"] == Dict("rgb" => "FF2E8B57")
+ @test XLSX.getFont(sh, "A3").font["color"] == Dict("rgb" => "80FF0000")
+ @test XLSX.getFont(sh, "A4").font["color"] == Dict("theme" => "4", "tint" => "0.4")
+ @test XLSX.getFont(sh, "A5").font["color"] == Dict("theme" => "5")
+ @test XLSX.getFont(sh, "A6").font["color"] == Dict("theme" => "1")
+ @test XLSX.getFont(sh, "A8").font["color"] == Dict("theme" => "6", "tint" => "-0.25")
+
+ # A new color replaces a theme color completely, leaving no stale tint
+ XLSX.setFont(sh, "A4"; color = "red")
+ @test XLSX.getFont(sh, "A4").font["color"] == Dict("rgb" => "FFFF0000")
+
+ # Fill
+ XLSX.setFill(sh, "B1"; pattern = "solid", fgColor = :coral)
+ XLSX.setFill(sh, "B2"; pattern = "solid", fgColor = XLSX.SchemeColor(:accent1; lumMod = 75), bgColor = colorant"blue")
+ XLSX.setUniformFill(sh, "B3:B4"; pattern = "solid", fgColor = :accent6)
+ @test XLSX.getFill(sh, "B1").fill["patternFill"]["fgrgb"] == "FFFF7F50"
+ f2 = XLSX.getFill(sh, "B2").fill["patternFill"]
+ @test f2["fgtheme"] == "4" && f2["fgtint"] == "-0.25" && !haskey(f2, "fgrgb")
+ @test f2["bgrgb"] == "FF0000FF"
+ @test XLSX.getFill(sh, "B4").fill["patternFill"]["fgtheme"] == "9"
+
+ XLSX.setFill(sh, "B2"; fgColor = "yellow") # replaces theme and tint
+ f2 = XLSX.getFill(sh, "B2").fill["patternFill"]
+ @test f2["fgrgb"] == "FFFFFF00" && !haskey(f2, "fgtheme") && !haskey(f2, "fgtint")
+
+ # Border
+ XLSX.setBorder(sh, "C1"; left = ["style" => "thin", "color" => :coral])
+ XLSX.setBorder(sh, "C2"; allsides = ["style" => "thick", "color" => XLSX.SchemeColor(:accent2)])
+ XLSX.setBorder(sh, "C3"; diagonal = ["style" => "thin", "color" => colorant"red", "direction" => "up"])
+ XLSX.setOutsideBorder(sh, "C5:C6"; outside = ["style" => "medium", "color" => :tx2])
+ XLSX.setUniformBorder(sh, "C7:C8"; top = ["style" => "thin", "color" => "accent4"])
+ @test XLSX.getBorder(sh, "C1").border["left"] == Dict("style" => "thin", "rgb" => "FFFF7F50")
+ @test XLSX.getBorder(sh, "C2").border["bottom"] == Dict("style" => "thick", "theme" => "5")
+ @test XLSX.getBorder(sh, "C3").border["diagonal"]["rgb"] == "FFFF0000"
+ @test XLSX.getBorder(sh, "C5").border["top"] == Dict("style" => "medium", "theme" => "3")
+ @test XLSX.getBorder(sh, "C8").border["top"] == Dict("style" => "thin", "theme" => "7")
+
+ XLSX.setBorder(sh, "C2"; bottom = ["color" => "red"]) # keeps style, replaces theme
+ @test XLSX.getBorder(sh, "C2").border["bottom"] == Dict("style" => "thick", "rgb" => "FFFF0000")
+
+ # Strings still work in a Vector{Pair{String,String}}, and bad values still error
+ XLSX.setBorder(sh, "C4"; right = ["style" => "thin", "color" => "FF00FF00"])
+ @test XLSX.getBorder(sh, "C4").border["right"] == Dict("style" => "thin", "rgb" => "FF00FF00")
+ @test_throws XLSX.XLSXError XLSX.setBorder(sh, "C4"; left = ["style" => :thin])
+ @test_throws XLSX.XLSXError XLSX.setBorder(sh, "C4"; left = ["style" => "thin", "color" => 42])
+ @test_throws XLSX.XLSXError XLSX.setFont(sh, "A1"; color = XLSX.SchemeColor(:accent1; shade = 50))
+ @test_throws XLSX.XLSXError XLSX.setFill(sh, "B1"; fgColor = "notacolor")
+
+ # The theme colors survive a save and reload
+ io = IOBuffer()
+ XLSX.writexlsx(io, xf)
+ xf2 = XLSX.readxlsx(seekstart(io))
+ @test XLSX.getFont(xf2["Sheet1"], "A8").font["color"] == Dict("theme" => "6", "tint" => "-0.25")
+ @test XLSX.getFill(xf2["Sheet1"], "B4").fill["patternFill"]["fgtheme"] == "9"
+ @test XLSX.getBorder(xf2["Sheet1"], "C8").border["top"] == Dict("style" => "thin", "theme" => "7")
+ SAVE_FILES && save_outfile(xf)
+ end
+
+ # A whole row or column given as `(row, :)` or `(:, col)` must take the uniform path,
+ # not the per-cell one. Cells start with different attributes so the two differ.
+ @testset "setUniform* on a whole row or column is uniform" begin
+ cases = (
+ (XLSX.setUniformFont, XLSX.setFont, (s, c) -> XLSX.getFont(s, c).fontId, (; size=14), (; bold=true), (; italic=true)),
+ (XLSX.setUniformBorder, XLSX.setBorder, (s, c) -> XLSX.getBorder(s, c).borderId, (; top=["style" => "thin"]), (; left=["style" => "thick"]), (; right=["style" => "thick"])),
+ (XLSX.setUniformFill, XLSX.setFill, (s, c) -> XLSX.getFill(s, c).fillId, (; pattern="solid", fgColor="red"), (; pattern="gray125"), (; pattern="darkGrid")),
+ (XLSX.setUniformAlignment, XLSX.setAlignment, (s, c) -> XLSX.getAlignment(s, c).alignment, (; wrapText=true), (; horizontal="left"), (; horizontal="right")),
+ (XLSX.setUniformFormat, XLSX.setFormat, (s, c) -> XLSX.getFormat(s, c).numFmtId, (; format="Percentage"), (; format="Currency"), (; format="0.000")),
+ )
+ for (setuniform, setone, attribute, kws, first_kws, other_kws) in cases, (target, other) in (((1, :), "B1"), ((:, 1), "A2"))
+ f = XLSX.newxlsx()
+ s = f[1]
+ s["A1:B2"] = 1.5
+ setone(s, "A1"; first_kws...)
+ setone(s, other; other_kws...)
+ setuniform(s, target...; kws...)
+ @test attribute(s, "A1") == attribute(s, other)
+ end
+ end
+
+ @testset "setUniformAlignment on a vector of columns aligns only those columns" begin
+ horizontal(s, c) = (a = XLSX.getAlignment(s, c); isnothing(a) ? nothing : get(something(a.alignment["alignment"], Dict()), "horizontal", nothing))
+ f = XLSX.newxlsx()
+ s = f[1]
+ s["A1:C3"] = 1
+ XLSX.setUniformAlignment(s, :, [2]; horizontal="right")
+ @test [horizontal(s, c) for c in ("A1", "B1", "C1", "B3")] == [nothing, "right", nothing, "right"]
+
+ f = XLSX.newxlsx()
+ s = f[1]
+ s["A1:C3"] = 1
+ XLSX.setUniformAlignment(s, [2], :; horizontal="right")
+ @test [horizontal(s, c) for c in ("A1", "A2", "C2", "A3")] == [nothing, "right", "right", nothing]
+ end
+
+ @testset "setters in a family accept the same target forms" begin
+ targets(f) = Set(Base.tuple_type_tail(m.sig) for m in methods(f))
+ for f in (XLSX.setFill, XLSX.setAlignment, XLSX.setFormat, XLSX.setBorder)
+ @test targets(f) == targets(XLSX.setFont)
+ end
+ for f in (XLSX.setUniformBorder, XLSX.setUniformFill, XLSX.setUniformAlignment, XLSX.setUniformFormat)
+ @test targets(f) == targets(XLSX.setUniformFont)
+ end
+ end
+
+ @testset "an unrecognized keyword is an XLSXError naming the setter and the keyword" begin
+ f = XLSX.newxlsx()
+ s = f[1]
+ s["A1:C3"] = 1
+ err(g) = try g(); nothing catch e; e end
+ for (setter, kws) in (
+ (XLSX.setFont, (; bold=true, colour="red")),
+ (XLSX.setFill, (; pattern="solid", colour="red")),
+ (XLSX.setAlignment, (; wrap=true)),
+ (XLSX.setFormat, (; fmt="0.00")),
+ (XLSX.setBorder, (; sides=["style" => "thin"])),
+ (XLSX.setUniformFont, (; colour="red")),
+ (XLSX.setUniformFill, (; colour="red")),
+ (XLSX.setUniformAlignment, (; wrap=true)),
+ (XLSX.setUniformFormat, (; fmt="0.00")),
+ (XLSX.setUniformBorder, (; sides=["style" => "thin"])),
+ )
+ for call in ((g; kw...) -> g(s, "A1:B2"; kw...),
+ (g; kw...) -> g(s, 1:2, 1:2; kw...),
+ (g; kw...) -> g(f, "Sheet1!A1:B2"; kw...))
+ e = err(() -> call(setter; kws...))
+ @test e isa XLSX.XLSXError
+ @test occursin("`$(nameof(setter))`", e.msg)
+ bad = filter(k -> k ∉ (:bold, :pattern), keys(kws))
+ @test all(k -> occursin("`$k`", e.msg), bad)
+ end
+ end
+ @test occursin("Unrecognized keyword arguments `a`, `b` for `setFont`", err(() -> XLSX.setFont(s, "A1"; a=1, b=2)).msg)
+ end
+
+ @testset "`outside` needs a range" begin
+ f = XLSX.newxlsx()
+ s = f[1]
+ s["A1:C3"] = 1
+ @test_throws XLSX.XLSXError XLSX.setBorder(s, "A1"; outside=["style" => "thin"])
+ @test_throws XLSX.XLSXError XLSX.setUniformBorder(s, "A1:B2"; outside=["style" => "thin"])
+ @test XLSX.setBorder(s, "A1:C3"; outside=["style" => "thin"]) == -1
+ @test XLSX.getBorder(s, "A1").border["top"]["style"] == "thin"
+ end
+end
+
+@testset "review fixes: number formats and fonts" begin
+ @testset "custom numFmt ids come from the file, not positions" begin
+ f = XLSX.newxlsx(); s = f[1]; wb = f.workbook
+ s["A1"] = 1.5; s["A2"] = 2.5; s["A3"] = 3.5
+ XLSX.setFormat(s, "A1"; format="0.000")
+ xroot = XLSX.styles_xmlroot(wb)
+ i, j = XLSX.get_idces(xroot, "styleSheet", "numFmts")
+ XLSX.xml_elements(xroot[i][j])[1]["numFmtId"] = "170" # a gap, as files may have
+ wb.numFmt_cache = nothing
+ @test XLSX.setFormat(s, "A2"; format="0.000") == 170
+ @test XLSX.setFormat(s, "A3"; format="0.0000") == 171
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "built-in format names in any case" begin
+ f = XLSX.newxlsx(); s = f[1]; s["A1"] = 0.5
+ @test XLSX.setFormat(s, "A1"; format="percentage") == 9
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "fractional font size" begin
+ f = XLSX.newxlsx(); s = f[1]; s["A1"] = "x"
+ XLSX.setFont(s, "A1"; size=10.5)
+ @test XLSX.getFont(s, "A1").font["sz"]["val"] == "10.5"
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "a font with stays not bold" begin
+ @test XLSX._merge_flag_tag("b", nothing, Dict("b" => Dict("val" => "0"))) === missing
+ @test XLSX._merge_flag_tag("b", nothing, Dict("b" => nothing)) === nothing
+ end
+ @testset "column widths split existing spans and stay sorted" begin
+ f = XLSX.newxlsx(); s = f[1]; s["A1:L1"] = 1
+ XLSX.setColumnWidth(s, "A1:E1"; width=20)
+ XLSX.setColumnWidth(s, "C1"; width=8)
+ XLSX.setColumnWidth(s, "K1"; width=5)
+ XLSX.setColumnWidth(s, "H1"; width=5)
+ doc = XLSX.xmlroot(f.workbook, s.relationship_id)
+ i, j = XLSX.get_idces(doc, "worksheet", "cols")
+ spans = [(parse(Int, c["min"]), parse(Int, c["max"])) for c in XLSX.xml_elements(doc[i][j])]
+ @test spans == [(1, 2), (3, 3), (4, 5), (8, 8), (11, 11)]
+ @test XLSX.getColumnWidth(s, "B1") ≈ 20 + XLSX.EXCEL_COLUMN_WIDTH_PADDING
+ @test XLSX.getColumnWidth(s, "C1") ≈ 8 + XLSX.EXCEL_COLUMN_WIDTH_PADDING
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "a width set inside a spanning read from a file" begin
+ f = XLSX.newxlsx(); f[1]["A1:H3"] = 1
+ XLSX.writexlsx("span_base.xlsx", f; overwrite=true)
+ r = ZipReader(read("span_base.xlsx"))
+ open("span.xlsx", "w") do io # one definition for A:E, as Excel writes it
+ ZipWriter(io) do w
+ for n in zip_names(r)
+ bytes = zip_readentry(r, n)
+ n == "xl/worksheets/sheet1.xml" && (bytes = Vector{UInt8}(replace(String(bytes),
+ " " df; overwrite=true)
+ SAVE_FILES && save_outfile("hint.xlsx")
+ @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT"]
+
+ XLSX.writetable("hint.xlsx", ["REPORT_A" => df, :REPORT_B => df]; overwrite=true)
+ SAVE_FILES && save_outfile("hint.xlsx")
+ @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT_A", "REPORT_B"]
+
+ XLSX.writetable("hint.xlsx", "REPORT_A" => df, :REPORT_B => df; overwrite=true)
+ SAVE_FILES && save_outfile("hint.xlsx")
+ @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT_A", "REPORT_B"]
+
+ XLSX.writetable("hint.xlsx", split("REPORT_A REPORT_B")[1] => df; overwrite=true)
+ SAVE_FILES && save_outfile("hint.xlsx")
+ @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT_A"]
+
+ # --- still rejected, with a hint naming the actual problem ---
+
+ @test throws_with(() -> XLSX.writetable("hint.xlsx", [("REPORT", cols)]),
+ "(sheetname, data, columnnames)", "2-tuple")
+
+ @test throws_with(() -> XLSX.writetable("hint.xlsx", Any[("REPORT", cols, colnames), 42]),
+ "(sheetname, data, columnnames)", "element 2", "not a tuple")
+
+ @test throws_with(() -> XLSX.writetable("hint.xlsx", [(1, cols, colnames)]),
+ "(sheetname, data, columnnames)", "neither `AbstractString` nor `Symbol`")
+
+ @test throws_with(() -> XLSX.writetable("hint.xlsx", [1 => df]),
+ "Sheet name must be an `AbstractString` or `Symbol`", string(Int))
+
+ @test throws_with(() -> XLSX.writetable("hint.xlsx", 1 => df),
+ "name => table", string(Int))
+
+ # `writetable!` writes into an existing sheet, so a pair is never valid there
+ XLSX.openxlsx("hint.xlsx", mode="w") do xf
+ @test throws_with(() -> XLSX.writetable!(xf[1], :REPORT => df),
+ "name => table", "Symbol")
+ end
+ SAVE_FILES && save_outfile("hint.xlsx")
+
+ # --- no hint appended where none applies ---
+
+ @test throws_with(() -> XLSX.writetable("hint.xlsx", 42),
+ "does not implement Tables.jl interface")
+ @test XLSX._shape_hint(Any[]) == ""
+ @test XLSX._shape_hint(42) == ""
+ @test XLSX._shape_hint([1, 2, 3]) == ""
+
+ # --- dispatch pins: the typed methods must keep winning over the fallback ---
+
+ m_fallback = which(XLSX.writetable, Tuple{String,Int})
+
+ # `#3` still owns its own shape; the fallback is a fallback
+ @test which(XLSX.writetable,
+ Tuple{String,Vector{Tuple{String,Vector{Any},Vector{String}}}}) !== m_fallback
+
+ # `#6` wins over `#4` for a single pair, and over `#1` for two
+ @test which(XLSX.writetable, Tuple{String,Pair{String,Any}}) !== m_fallback
+ @test which(XLSX.writetable, Tuple{String,Pair{Symbol,Any}}) !== m_fallback
+ @test which(XLSX.writetable, Tuple{String,Pair{String,Any},Pair{String,Any}}) !==
+ which(XLSX.writetable, Tuple{String,Vector{Any},Vector{String}})
+
+ @test throws_with(() -> XLSX.writetable("hint.xlsx"; Sales=df),
+ "must be a `(data, columnnames)` tuple", "writetable(filename, :Sales => table)")
+
+ @test throws_with(() -> XLSX.writetable("hint.xlsx"; Sales=(cols, colnames, 42)),
+ "must be a `(data, columnnames)` tuple")
+ # valid form unaffected
+ XLSX.writetable("hint.xlsx"; overwrite=true, REPORT_A=(cols, colnames), REPORT_B=(cols, colnames))
+ SAVE_FILES && save_outfile("hint.xlsx")
+ @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT_A", "REPORT_B"]
+
+ isfile("hint.xlsx") && rm("hint.xlsx")
+ end
+
# delete files created by this testset
delete_files = ["output_table.xlsx", "output_tables.xlsx", "mytest.xlsx"]
for f in delete_files
@@ -1021,13 +1204,15 @@ end
@test df[!, "Column D"] == collect(Date(2018, 4, 21):Dates.Day(1):Date(2018, 4, 28))
@test all(ismissing.(df[!, "Column G"]))
- file = joinpath(@__DIR__, "test_report.xlsx")
+ file = "test_report.xlsx"
try
df1 = DataFrames.DataFrame(COL1=[10, 20, 30], COL2=["Fist", "Sec", "Third"])
df2 = DataFrames.DataFrame(AA=["aa", "bb"], AB=[10.1, 10.2])
XLSX.writetable(file, "REPORT_A" => df1, "REPORT_B" => df2, overwrite=true)
SAVE_FILES && save_outfile(file)
+ @test isequal(XLSX.readto(file, "REPORT_A", DataFrames.DataFrame), df1)
+ @test isequal(XLSX.readto(file, "REPORT_B", DataFrames.DataFrame), df2)
finally
isfile(file) && rm(file)
end
@@ -1065,119 +1250,6 @@ end
@test eltype.(eachcol(df)) == [Int64, String, Int64, Any, Int64, String, Any, Int64, Int64]
end
-
- @testset "writetable shape hints" begin
-
- df = DataFrames.DataFrame(a=[1, 2], b=["x", "y"])
- cols = [[1, 2], ["x", "y"]]
- colnames = ["a", "b"]
-
- # --- tuple-vector shapes, now accepted via the `#4` fallback branch ---
-
- XLSX.writetable("hint.xlsx", [(:REPORT, cols, colnames)]; overwrite=true)
- SAVE_FILES && save_outfile("hint.xlsx")
- @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT"]
-
- XLSX.writetable("hint.xlsx", [("REPORT", DataFrames.eachcol(df), colnames)]; overwrite=true)
- SAVE_FILES && save_outfile("hint.xlsx")
- @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT"]
-
- XLSX.writetable("hint.xlsx", Any[("REPORT", cols, colnames)]; overwrite=true)
- SAVE_FILES && save_outfile("hint.xlsx")
- @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT"]
-
- XLSX.writetable("hint.xlsx", [(:A, cols, colnames), ("B", cols, colnames)]; overwrite=true)
- SAVE_FILES && save_outfile("hint.xlsx")
- @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["A", "B"]
-
- # non-string column labels are stringified, matching `writetable!`
- XLSX.writetable("hint.xlsx", [("REPORT", cols, [1, 2])]; overwrite=true)
- SAVE_FILES && save_outfile("hint.xlsx")
- XLSX.openxlsx("hint.xlsx") do xf
- @test xf[1]["A1"] == "1" && xf[1]["B1"] == "2"
- end
-
- # the normalized vector must be concretely typed or `#4` would re-enter itself
- @test XLSX._normalize_sheet_specs([(:A, cols, colnames)]) isa
- Vector{Tuple{String,Vector{Any},Vector{String}}}
-
- # --- pair forms, both key types ---
-
- XLSX.writetable("hint.xlsx", :REPORT => df; overwrite=true)
- SAVE_FILES && save_outfile("hint.xlsx")
- @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT"]
-
- XLSX.writetable("hint.xlsx", ["REPORT_A" => df, :REPORT_B => df]; overwrite=true)
- SAVE_FILES && save_outfile("hint.xlsx")
- @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT_A", "REPORT_B"]
-
- XLSX.writetable("hint.xlsx", "REPORT_A" => df, :REPORT_B => df; overwrite=true)
- SAVE_FILES && save_outfile("hint.xlsx")
- @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT_A", "REPORT_B"]
-
- XLSX.writetable("hint.xlsx", split("REPORT_A REPORT_B")[1] => df; overwrite=true)
- SAVE_FILES && save_outfile("hint.xlsx")
- @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT_A"]
-
- # --- still rejected, with a hint naming the actual problem ---
-
- @test throws_with(() -> XLSX.writetable("hint.xlsx", [("REPORT", cols)]),
- "(sheetname, data, columnnames)", "2-tuple")
-
- @test throws_with(() -> XLSX.writetable("hint.xlsx", Any[("REPORT", cols, colnames), 42]),
- "(sheetname, data, columnnames)", "element 2", "not a tuple")
-
- @test throws_with(() -> XLSX.writetable("hint.xlsx", [(1, cols, colnames)]),
- "(sheetname, data, columnnames)", "neither `AbstractString` nor `Symbol`")
-
- @test throws_with(() -> XLSX.writetable("hint.xlsx", [1 => df]),
- "Sheet name must be an `AbstractString` or `Symbol`", string(Int))
-
- @test throws_with(() -> XLSX.writetable("hint.xlsx", 1 => df),
- "name => table", string(Int))
-
- # `writetable!` writes into an existing sheet, so a pair is never valid there
- XLSX.openxlsx("hint.xlsx", mode="w") do xf
- @test throws_with(() -> XLSX.writetable!(xf[1], :REPORT => df),
- "name => table", "Symbol")
- end
- SAVE_FILES && save_outfile("hint.xlsx")
-
- # --- no hint appended where none applies ---
-
- @test throws_with(() -> XLSX.writetable("hint.xlsx", 42),
- "does not implement Tables.jl interface")
- @test XLSX._shape_hint(Any[]) == ""
- @test XLSX._shape_hint(42) == ""
- @test XLSX._shape_hint([1, 2, 3]) == ""
-
- # --- dispatch pins: the typed methods must keep winning over the fallback ---
-
- m_fallback = which(XLSX.writetable, Tuple{String,Int})
-
- # `#3` still owns its own shape; the fallback is a fallback
- @test which(XLSX.writetable,
- Tuple{String,Vector{Tuple{String,Vector{Any},Vector{String}}}}) !== m_fallback
-
- # `#6` wins over `#4` for a single pair, and over `#1` for two
- @test which(XLSX.writetable, Tuple{String,Pair{String,Any}}) !== m_fallback
- @test which(XLSX.writetable, Tuple{String,Pair{Symbol,Any}}) !== m_fallback
- @test which(XLSX.writetable, Tuple{String,Pair{String,Any},Pair{String,Any}}) !==
- which(XLSX.writetable, Tuple{String,Vector{Any},Vector{String}})
-
- @test throws_with(() -> XLSX.writetable("hint.xlsx"; Sales=df),
- "must be a `(data, columnnames)` tuple", "writetable(filename, :Sales => table)")
-
- @test throws_with(() -> XLSX.writetable("hint.xlsx"; Sales=(cols, colnames, 42)),
- "must be a `(data, columnnames)` tuple")
- # valid form unaffected
- XLSX.writetable("hint.xlsx"; overwrite=true, REPORT_A=(cols, colnames), REPORT_B=(cols, colnames))
- SAVE_FILES && save_outfile("hint.xlsx")
- @test XLSX.sheetnames(XLSX.readxlsx("hint.xlsx")) == ["REPORT_A", "REPORT_B"]
-
- isfile("hint.xlsx") && rm("hint.xlsx")
- end
-
@testset "empty shared strings read as missing (issue 460 follow-up)" begin
f = joinpath(data_directory, "empty_sst_numeric.xlsx")
@@ -1205,256 +1277,3 @@ end
end
end
end
-
-
-#=
-#462 Stage 4: `iterate(::TableRowIterator)` and `gettable` now share one row-stepping
-function, and `gettable` writes values straight into columns. The table-row iterator
-and `gettable` as they were before are kept below as the reference: the new code must
-give exactly the same rows, values, types and `stop_in_row_function` calls.
-=#
-
-# The pre-#462 `iterate(::TableRowIterator)`, verbatim apart from qualified names.
-function _old_handle_gap(itr, table_row_index, col_count, expected_row, actual_row, sheet_row, sheet_row_iterator_state)
- itr.stop_in_empty_row && return nothing
- itr.keep_empty_rows || return :skip
- table_row = XLSX.TableRow(table_row_index, itr.index, fill(missing, col_count))
- XLSX._should_stop(itr, table_row) && return nothing
- newstate = XLSX.TableRowIteratorState(table_row_index, expected_row, sheet_row_iterator_state,
- actual_row - expected_row - 1, sheet_row)
- return table_row, newstate
-end
-
-function _old_return_table_row(itr, table_row_index, actual_row, sheet_row, sheet_row_iterator_state)
- table_row = XLSX.TableRow(table_row_index, itr.index, sheet_row, itr.missing_strings)
- XLSX._should_stop(itr, table_row) && return nothing
- newstate = XLSX.TableRowIteratorState(table_row_index, actual_row, sheet_row_iterator_state, 0, nothing)
- return table_row, newstate
-end
-
-function _old_iterate(itr, state)
- table_row_index = state.table_row_index + 1
- col_count = length(XLSX.sheet_column_numbers(itr.index))
- if state.missing_rows > 0
- table_row = XLSX.TableRow(table_row_index, itr.index, fill(missing, col_count))
- XLSX._should_stop(itr, table_row) && return nothing
- newstate = XLSX.TableRowIteratorState(table_row_index, state.sheet_row_index + 1,
- state.sheet_row_iterator_state, state.missing_rows - 1, state.row_pending)
- return table_row, newstate
- end
- local sheet_row, sheet_row_iterator_state
- if !isnothing(state.row_pending)
- sheet_row = state.row_pending
- sheet_row_iterator_state = state.sheet_row_iterator_state
- else
- next = iterate(itr.itr, state.sheet_row_iterator_state)
- isnothing(next) && return nothing
- sheet_row, sheet_row_iterator_state = next
- end
- actual_row = XLSX.row_number(sheet_row)
- expected_row = state.sheet_row_index + 1
- if actual_row > expected_row
- result = _old_handle_gap(itr, table_row_index, col_count, expected_row, actual_row, sheet_row, sheet_row_iterator_state)
- result === :skip || return result
- end
- result = XLSX._skip_empty_rows(itr, sheet_row, sheet_row_iterator_state)
- isnothing(result) && return nothing
- sheet_row, sheet_row_iterator_state = result
- return _old_return_table_row(itr, table_row_index, XLSX.row_number(sheet_row), sheet_row, sheet_row_iterator_state)
-end
-
-# All rows the old iterator gives, as (table row, values).
-function _old_tablerows(itr)
- out = Tuple{Int,Vector{Any}}[]
- next = isnothing(itr.resume) ? iterate(itr.itr) : itr.resume
- while !isnothing(next) && XLSX.row_number(next[1]) < itr.first_data_row
- next = iterate(itr.itr, next[2])
- end
- isnothing(next) && return out
- sheet_row, sheet_row_state = next
- state = XLSX.TableRowIteratorState(0, itr.first_data_row - 1, sheet_row_state, 0, sheet_row)
- while true
- r = _old_iterate(itr, state)
- isnothing(r) && break
- row, state = r
- push!(out, (row.row, Any[row.cell_values...]))
- end
- return out
-end
-
-# The pre-#462 `gettable(::TableRowIterator)`, over the old iterator's rows.
-function _old_gettable(itr; infer_eltypes::Bool = true)
- n = XLSX.table_columns_count(itr)
- data = Any[Any[] for _ in 1:n]
- for (_, values) in _old_tablerows(itr), ci in 1:n
- push!(data[ci], values[ci])
- end
- if infer_eltypes
- for i in eachindex(data)
- T = XLSX.infer_eltype(data[i])
- T !== Any && (data[i] = convert(Vector{T}, data[i]))
- end
- end
- return XLSX.DataTable(data, XLSX.get_column_labels(itr))
-end
-
-_same_values(a, b) = length(a) == length(b) &&
- all(isequal(x, y) && typeof(x) == typeof(y) for (x, y) in zip(a, b))
-
-# Old vs new for one sheet and keyword set: iteration and `gettable`, with the
-# callback calls compared too. Returns a mismatch description or `nothing`.
-function _stage4_check(ws, cols, kw::NamedTuple, stop_kind::Symbol, infer_eltypes::Bool)
- mk(log) = isnothing(cols) ?
- XLSX.eachtablerow(ws; kw..., stop_in_row_function = _diff_stop_fn(stop_kind, log)) :
- XLSX.eachtablerow(ws, cols; kw..., stop_in_row_function = _diff_stop_fn(stop_kind, log))
- probe = _diff_run(() -> mk(_DiffCall[]))
- probe isa Exception && return nothing # construction is unchanged code
-
- # iteration
- la, lb = _DiffCall[], _DiffCall[]
- old_rows = _diff_run(() -> _old_tablerows(mk(la)))
- new_rows = _diff_run(() -> [(r.row, Any[r.cell_values...]) for r in mk(lb)])
- if old_rows isa Exception || new_rows isa Exception
- (old_rows isa Exception && new_rows isa Exception && typeof(old_rows) == typeof(new_rows)) ||
- return "iteration: $(_diff_describe(old_rows)) vs $(_diff_describe(new_rows))"
- else
- length(old_rows) == length(new_rows) || return "iteration: $(length(old_rows)) vs $(length(new_rows)) rows"
- for (i, (a, b)) in enumerate(zip(old_rows, new_rows))
- (a[1] == b[1] && _same_values(a[2], b[2])) || return "iteration row $i: $a vs $b"
- end
- end
- m = _diff_compare_calls(la, lb)
- m === nothing || return "iteration: " * m
-
- # gettable
- la, lb = _DiffCall[], _DiffCall[]
- old_t = _diff_run(() -> _old_gettable(mk(la); infer_eltypes))
- new_t = _diff_run(() -> XLSX.gettable(mk(lb); infer_eltypes))
- m = _diff_compare(old_t, new_t)
- m === nothing || return "gettable: " * m
- m = _diff_compare_calls(la, lb)
- m === nothing || return "gettable: " * m
- return nothing
-end
-
-@testset "table rows and gettable unchanged (#462 Stage 4)" begin
- stops = (:none, :at2, :col1missing, :always, :never)
-
- @testset "empty-row corpus" begin
- mismatches = String[]
- n = 0
- sheets = Pair{String,Vector{UInt8}}[]
- for len in 0:3, states in Iterators.product(ntuple(_ -> _DIFF_ROW_STATES, len)...)
- push!(sheets, "corpus $(collect(states))" => _diff_corpus_sheet(collect(states)))
- end
- append!(sheets, ["special \"$k\"" => v for (k, v) in _diff_special_sheets()])
- for (label, bytes) in sheets
- ws = XLSX.readxlsx(IOBuffer(bytes))[1]
- for header in (true, false), stop_in_empty_row in (true, false), keep_empty_rows in (true, false),
- first_row in (nothing, 1, 2, 3, 5), cols in _DIFF_COLUMNS
- n += 1
- kw = (; header, stop_in_empty_row, keep_empty_rows, first_row,
- missing_strings = _DIFF_MS_OPTIONS[mod1(n, 4)])
- m = _stage4_check(ws, cols, kw, stops[mod1(n, 5)], isodd(n ÷ 5))
- m === nothing || push!(mismatches, "$label cols=$(repr(cols)) $kw: $m")
- end
- end
- foreach(println, first(mismatches, 20))
- @test isempty(mismatches)
- @test n > 100_000
- end
-
- @testset "test/data workbooks" begin
- mismatches = String[]
- n = 0
- for file in sort(readdir(data_directory))
- any(ext -> endswith(lowercase(file), ext), (".xlsx", ".xlsm", ".xltx", ".xltm")) || continue
- xf = try XLSX.readxlsx(joinpath(data_directory, file)) catch; continue end
- wb = XLSX.get_workbook(xf)
- for ws in wb.sheets
- XLSX.is_chartsheet(wb, ws.name) && continue
- dim = _diff_run(() -> XLSX.get_dimension(ws))
- columns, first_rows = Any[nothing], Any[nothing, 1, 2]
- if dim isa XLSX.CellRange
- c1, c2 = XLSX.column_number(dim.start), XLSX.column_number(dim.stop)
- col(i) = XLSX.encode_column_number(i)
- append!(columns, ["$(col(c1)):$(col(c2))", "$(col(c1)):$(col(c1))", "$(col(c1)):$(col(c2 + 2))"])
- push!(first_rows, XLSX.row_number(dim.stop))
- end
- for header in (true, false), stop_in_empty_row in (true, false), keep_empty_rows in (true, false),
- first_row in first_rows, cols in columns
- n += 1
- kw = (; header, stop_in_empty_row, keep_empty_rows, first_row,
- missing_strings = _DIFF_MS_OPTIONS[mod1(n, 4)])
- m = _stage4_check(ws, cols, kw, stops[mod1(n, 5)], isodd(n ÷ 5))
- m === nothing || push!(mismatches, "$file $(ws.name) cols=$(repr(cols)) $kw: $m")
- end
- end
- end
- foreach(println, first(mismatches, 20))
- @test isempty(mismatches)
- @test n > 10_000
- end
-
- fixtures = get(ENV, "XLSX_DIFF_FIXTURES", "")
- if !isempty(fixtures)
- @testset "bench fixtures" begin
- for file in sort(readdir(fixtures))
- startswith(file, "xl_") || continue
- ws = XLSX.readxlsx(joinpath(fixtures, file))["Data"]
- for stop_in_empty_row in (true, false), keep_empty_rows in (true, false)
- kw = (; first_row = 5, stop_in_empty_row, keep_empty_rows)
- m = _stage4_check(ws, "A:CF", kw, :none, true)
- m === nothing || println("$file $kw: $m")
- @test m === nothing
- end
- end
- end
- end
-
- @testset "column types" begin
- # cells for B2:B4, and the column eltype `gettable` should infer
- cases = [
- ("1", "2.5", "3") => Float64,
- ("1", "2", "3") => Int64,
- ("45000", "45000.5", "45001") => Dates.DateTime,
- ("x", "2", "3") => Any,
- ("1", "", "0") => Union{Missing, Bool},
- ("", "", "1") => Union{Missing, Int64},
- ]
- for ((b2, b3, b4), T) in cases
- rows = "hk
" *
- "$(b2)1
$(b3)1
" *
- "$(b4)1
"
- ws = XLSX.readxlsx(IOBuffer(_diff_build_xlsx(rows, String[])))[1]
- for infer_eltypes in (true, false)
- @test _stage4_check(ws, "B:C", (;), :none, infer_eltypes) === nothing
- end
- @test eltype(XLSX.gettable(ws, "B:C").data[1]) == T
- @test eltype(XLSX.gettable(ws, "B:C"; infer_eltypes = false).data[1]) == Any
- end
- # all missing, and no data rows at all
- rows = "h
|
1
"
- ws = XLSX.readxlsx(IOBuffer(_diff_build_xlsx(rows, String[])))[1]
- kw = (; stop_in_empty_row = false, keep_empty_rows = true)
- @test _stage4_check(ws, "B:B", kw, :none, true) === nothing
- @test eltype(XLSX.gettable(ws, "B:B"; kw...).data[1]) == Any
- rows = "h
"
- ws = XLSX.readxlsx(IOBuffer(_diff_build_xlsx(rows, String[])))[1]
- @test _stage4_check(ws, "B:B", (;), :none, true) === nothing
- @test isempty(XLSX.gettable(ws, "B:B").data[1])
- end
-
- @testset "TableRow length and collect" begin
- ws = XLSX.readxlsx(joinpath(data_directory, "general.xlsx"))["table"]
- nrows = 0
- for r in XLSX.eachtablerow(ws)
- nrows += 1
- n = length(XLSX.get_column_labels(r))
- @test length(r) == n
- @test isequal(collect(r), Any[r[i] for i in 1:n])
- @test eltype(collect(r)) == XLSX.CellConcreteType
- end
- @test nrows > 0
- end
-end
diff --git a/test/test_files/Write-Save_tests.jl b/test/test_files/Write-Save_tests.jl
index da6b2ac9..5bda7a05 100644
--- a/test/test_files/Write-Save_tests.jl
+++ b/test/test_files/Write-Save_tests.jl
@@ -46,4 +46,151 @@ end
isfile("saveable.xlsx") && rm("saveable.xlsx")
SAVE_FILES && save_outfile(f2)
SAVE_FILES && save_outfile(f)
-end
\ No newline at end of file
+
+ @testset "save back to an IO" begin
+ src = XLSX.newxlsx(); src[1]["A1"] = "original"
+ XLSX.addsheet!(src, "big")
+ src["big"]["A1:A2000"] = [string(i, "-", i^2) for i in 1:2000, _ in 1:1]
+ bytes = (io = IOBuffer(); XLSX.writexlsx(io, src); take!(io))
+
+ # do-block "rw" replaces the buffer's content, truncating when the result is shorter
+ buf = IOBuffer(copy(bytes); read=true, write=true)
+ XLSX.openxlsx(buf; mode="rw") do xf
+ XLSX.deletesheet!(xf, "big")
+ xf[1]["A1"] = "short"
+ xf[1]["B1"] = 2
+ end
+ @test buf.size < length(bytes)
+ seekstart(buf)
+ f4 = XLSX.readxlsx(buf)
+ @test f4[1]["A1"] == "short"
+ @test f4[1]["B1"] == 2
+
+ # savexlsx does the same
+ buf = IOBuffer(copy(bytes); read=true, write=true)
+ xf = XLSX.openxlsx(buf; mode="rw")
+ xf[1]["C1"] = 3
+ @test XLSX.savexlsx(xf) === nothing
+ seekstart(buf)
+ @test XLSX.readxlsx(buf)[1]["C1"] == 3
+
+ # a read-only buffer can't be overwritten
+ xf = XLSX.openxlsx(IOBuffer(copy(bytes)); mode="rw")
+ @test_throws XLSX.XLSXError XLSX.savexlsx(xf)
+ end
+end
+
+# issue #67
+@testset "row_index" begin
+ filename = "test_pr67.xlsx"
+ XLSX.openxlsx(filename, mode="w") do xf
+ xf[1]["A2"] = 5
+ xf[1]["A1"] = 7
+ end
+ @test isfile(filename)
+ @test XLSX.readxlsx(filename)[1]["A1:A2"] == Any[7; 5;;] # rows written out of order read back in order
+ SAVE_FILES && save_outfile(filename)
+ isfile(filename) && rm(filename)
+end
+
+@testset "Edit Template" begin
+ new_filename = "new_file_from_empty_template.xlsx"
+ f = XLSX.open_empty_template()
+ f["Sheet1"]["A1"] = "Hello"
+ f["Sheet1"]["A2"] = 10
+ XLSX.writexlsx(new_filename, f, overwrite=true)
+ SAVE_FILES && save_outfile(new_filename)
+
+ f = XLSX.readxlsx(new_filename)
+ @test f["Sheet1"]["A1"] == "Hello"
+ @test f["Sheet1"]["A2"] == 10
+
+ isfile(new_filename) && rm(new_filename)
+end
+
+@testset "Fix timestamp" begin
+ t = Dates.now(Dates.UTC) - Dates.Second(1)
+ xf = XLSX.newxlsx()
+ f = "docProps/core.xml"
+ date_format = Dates.dateformat"yyyy-mm-ddTHH:MM:SSZ"
+ i, j = XLSX.get_idces(xf.data[f], "cp:coreProperties", "dcterms:created") # i, j should always be found unless the `blank.xlsx` file is updated
+ @test t < DateTime(XML.value(xf.data[f][i][j][1]), date_format)
+ i, j = XLSX.get_idces(xf.data[f], "cp:coreProperties", "dcterms:modified")
+ @test t < DateTime(XML.value(xf.data[f][i][j][1]), date_format)
+ SAVE_FILES && save_outfile(xf)
+
+ xf = XLSX.newxlsx(; update_timestamp=false) # do not update timestamp
+ i, j = XLSX.get_idces(xf.data[f], "cp:coreProperties", "dcterms:created") # i, j should always be found unless the `blank.xlsx` file is updated
+ @test XML.value(xf.data[f][i][j][1]) == "2018-05-22T02:41:32Z"
+ i, j = XLSX.get_idces(xf.data[f], "cp:coreProperties", "dcterms:modified")
+ @test XML.value(xf.data[f][i][j][1]) == "2018-05-22T02:42:04Z"
+ SAVE_FILES && save_outfile(xf)
+end
+
+@testset "writetable refuses to overwrite an existing file" begin
+ @test_throws XLSX.XLSXError XLSX.savexlsx(XLSX.newxlsx()) # blank: no file name
+
+ outfile = "errbranch_exists.xlsx"
+ XLSX.writetable(outfile, [[1, 2]], ["a"]; overwrite=true)
+ @test_throws XLSX.XLSXError XLSX.writetable(outfile; Sheet1=([[1]], ["a"]))
+ @test_throws XLSX.XLSXError XLSX.writetable(outfile, [("Sheet1", [[1]], ["a"])])
+ SAVE_FILES && save_outfile(outfile)
+ isfile(outfile) && rm(outfile)
+end
+
+@testset "review fixes: write" begin
+ @testset "hidden sheets stay hidden" begin
+ f = XLSX.newxlsx("A"); s2 = XLSX.addsheet!(f, "B")
+ s2.is_hidden = true
+ XLSX.writexlsx("hidden.xlsx", f; overwrite=true)
+ @test XLSX.readxlsx("hidden.xlsx")["B"].is_hidden
+ SAVE_FILES && save_outfile("hidden.xlsx")
+ end
+ @testset "hidden and veryHidden sheets read from a file survive an edit" begin
+ f = XLSX.newxlsx("Visible"); XLSX.addsheet!(f, "Hidden"); XLSX.addsheet!(f, "VeryHidden")
+ XLSX.writexlsx("states_base.xlsx", f; overwrite=true)
+ r = ZipReader(read("states_base.xlsx"))
+ open("states.xlsx", "w") do io # mark them as Excel does
+ ZipWriter(io) do w
+ for n in zip_names(r)
+ bytes = zip_readentry(r, n)
+ n == "xl/workbook.xml" && (bytes = Vector{UInt8}(replace(String(bytes),
+ r"(]*name=\"Hidden\")" => s"\1 state=\"hidden\"",
+ r"(]*name=\"VeryHidden\")" => s"\1 state=\"veryHidden\"")))
+ zip_newfile(w, n); write(w, bytes)
+ end
+ end
+ end
+ XLSX.openxlsx("states.xlsx"; mode="rw") do xf
+ xf[1]["A1"] = "edited"
+ end
+ states = Dict(m[1] => m[2] for m in eachmatch(r"]*name=\"([^\"]+)\"[^>]*state=\"([^\"]+)\"",
+ String(ZipReader(read("states.xlsx")) |> z -> zip_readentry(z, "xl/workbook.xml"))))
+ @test states == Dict("Hidden" => "hidden", "VeryHidden" => "veryHidden")
+ SAVE_FILES && save_outfile("states.xlsx")
+ end
+ @testset "dimension grows on all sides at once" begin
+ f = XLSX.newxlsx(); s = f[1]
+ s["B2:D10"] = 1
+ XLSX.set_dimension!(s, XLSX.CellRange("B2:D10"))
+ s["A20"] = 2
+ @test XLSX.get_dimension(s) == XLSX.CellRange("A2:D20")
+ SAVE_FILES && save_outfile(f)
+ end
+ @testset "rw do-block that throws leaves the file unchanged" begin
+ XLSX.writetable("rw_throw.xlsx", [[1, 2]], ["a"]; overwrite=true)
+ @test_throws ErrorException XLSX.openxlsx("rw_throw.xlsx"; mode="rw") do xf
+ xf[1]["A2"] = "partially edited"
+ error("boom")
+ end
+ @test XLSX.readxlsx("rw_throw.xlsx")[1]["A2"] == 1
+ end
+ @testset "sheet names are unique ignoring case" begin
+ f = XLSX.newxlsx("Sheet1")
+ @test XLSX.addsheet!(f, "SHEET1").name == "SHEET1 1"
+ @test_throws XLSX.XLSXError XLSX.renamesheet!(f[2], "sheet1")
+ XLSX.renamesheet!(f[1], "SHEET1") # a new case of its own name
+ @test XLSX.sheetnames(f)[1] == "SHEET1"
+ SAVE_FILES && save_outfile(f)
+ end
+end
diff --git a/test/test_files/XML_tests.jl b/test/test_files/XML_tests.jl
index ce980ecf..af139a3e 100644
--- a/test/test_files/XML_tests.jl
+++ b/test/test_files/XML_tests.jl
@@ -277,9 +277,26 @@ end
end
rm(src)
rm("trailing_out.xlsx")
+end
+@testset "insert_index" begin
# Nothing in the order list precedes the target: insert at the top, not after the first child.
root = XML.Element("worksheet", XML.Element("pageMargins"))
@test XLSX.insert_index(root, "sheetPr", XLSX.WORKSHEET_ORDER) == 0
@test XLSX.insert_index(XML.Element("worksheet"), "sheetPr", XLSX.WORKSHEET_ORDER) == 0
end
+
+@testset "remove_attr! - node with no attributes, and absent key" begin
+ bare = XLSX.XML.Element("x")
+ @test isnothing(XLSX.remove_attr!(bare, "anything"))
+
+ node = XLSX.XML.Element("x"; a="1", b="2")
+ @test isnothing(XLSX.remove_attr!(node, "notThere"))
+ @test XLSX.get_attr(node, "a") == "1"
+ @test XLSX.get_attr(node, "b") == "2"
+
+ XLSX.remove_attr!(node, "a")
+ @test XLSX.get_attr(node, "a", "") == ""
+ @test !haskey(XML.attributes(node), "a")
+ @test XLSX.get_attr(node, "b") == "2"
+end
diff --git a/test/test_files/helpers.jl b/test/test_files/helpers.jl
new file mode 100644
index 00000000..48c38156
--- /dev/null
+++ b/test/test_files/helpers.jl
@@ -0,0 +1,151 @@
+#=
+Helpers shared by more than one test file. Included first by `runtests.jl`;
+helpers used by a single file live in that file.
+=#
+
+# ── Saving test outputs (see `SAVE_FILES` in runtests.jl) ─────────────────────
+
+function current_testset_label()
+ ts = Test.get_testset()
+ return ts === nothing ? "untitled" : ts.description
+end
+
+function sanitize_filename(s::AbstractString)
+ return replace(s, r"[^A-Za-z0-9_-]" => "_")
+end
+
+const outfile_counter = Ref(0)
+
+function save_outfile(xf::XLSX.XLSXFile)
+ outfile_counter[] += 1
+ label = sanitize_filename(current_testset_label())
+ # Excel won't open a macro-enabled workbook saved with an `.xlsx` extension
+ src_ext = xf.source isa AbstractString ? lowercase(splitext(xf.source)[2]) : ""
+ ext = (src_ext in (".xlsm", ".xltm") || xf.template_type == XLSX.XLTMTemplate) ? ".xlsm" : ".xlsx"
+ fname = "outfile_$(lpad(outfile_counter[], 3, '0'))_$(label)$(ext)"
+ XLSX.writexlsx(joinpath(outdir, fname), xf)
+end
+
+function save_outfile(name::AbstractString)
+ outfile_counter[] += 1
+ label = sanitize_filename(current_testset_label())
+ ext = splitext(name)[2] # preserves ".xlsx", ".xlsm", ".xltx", ".xltm", etc.
+ fname = "outfile_$(lpad(outfile_counter[], 3, '0'))_$(label)$(ext)"
+ cp(name, joinpath(outdir, fname))
+end
+
+function save_outfile(io::IO)
+ outfile_counter[] += 1
+ label = sanitize_filename(current_testset_label())
+ fname = "outfile_$(lpad(outfile_counter[], 3, '0'))_$(label).xlsx"
+ pos = position(io)
+ seekstart(io)
+ open(joinpath(outdir, fname), "w") do out
+ write(out, io)
+ end
+ seek(io, pos)
+end
+
+# ── Checking table data ───────────────────────────────────────────────────────
+
+# Checks whether `data` equals `test_data`
+function check_test_data(data::Vector{S}, test_data::Vector{T}) where {S,T}
+
+ @test length(data) == length(test_data)
+
+ function size_of_data(d::Vector{T}) where {T}
+ isempty(d) && return (0, 0)
+ return length(d[1]), length(d)
+ end
+
+ rows, cols = size_of_data(test_data)
+
+ for col in 1:cols
+ @test length(data[col]) == length(test_data[col])
+ end
+
+ for row in 1:rows, col in 1:cols
+ test_value = test_data[col][row]
+ value = data[col][row]
+
+ if test_value === nothing
+ @test ismissing(value)
+ elseif ismissing(test_value) || (isa(test_value, AbstractString) && isempty(test_value))
+ @test ismissing(value) || (isa(value, AbstractString) && isempty(value))
+ else
+ if isa(test_value, Integer) || isa(value, Integer)
+ @test isa(test_value, Integer)
+ @test isa(value, Integer)
+ end
+
+ if isa(test_value, Real) && !isa(test_value, Integer)
+ @test isapprox(value, test_value)
+ else
+ @test value == test_value
+ end
+ end
+ end
+
+ nothing
+end
+
+# ── Patching xlsx files ───────────────────────────────────────────────────────
+
+# Rewrite a single entry of an xlsx zip into a new file in the working directory. Used to inject XML that
+# Excel itself never produces (inlineStr cells), so the `_rewrite_node` path in
+# cell.jl actually gets exercised.
+function patch_xlsx_entry(src::AbstractString, entry::AbstractString, patch::Function)
+ r = ZipReader(read(src))
+ names = zip_names(r)
+ entry in names || error("no `$entry` in $src")
+ dst = "patched_" * basename(src)
+ ZipWriter(dst) do w
+ for n in names
+ bytes = zip_readentry(r, n)
+ n == entry && (bytes = Vector{UInt8}(patch(String(bytes))))
+ zip_newfile(w, n; compress=true)
+ write(w, bytes)
+ end
+ end
+ return dst
+end
+
+# ── Minimal workbook builder (differential tests, Read-internals) ───────────
+# Compact XML, as Excel writes it. Style 0 = General, 1 = date (numFmt 14),
+# 2 = a fill only (for styled-but-empty cells).
+
+const _DIFF_CT = """
+"""
+const _DIFF_RELS = """
+"""
+const _DIFF_WB = """
+"""
+const _DIFF_WBRELS = """
+"""
+const _DIFF_STYLES = """
+"""
+
+function _diff_build_xlsx(rows_xml::AbstractString, sst::Vector{String})::Vector{UInt8}
+ sst_xml = "\n" *
+ "" *
+ join(isempty(s) ? "" : "$s" for s in sst) * ""
+ sheet_xml = "\n" *
+ "" *
+ "$rows_xml"
+ io = IOBuffer()
+ ZipArchives.ZipWriter(io) do w
+ ZipArchives.zip_writefile(w, "[Content_Types].xml", codeunits(_DIFF_CT))
+ ZipArchives.zip_writefile(w, "_rels/.rels", codeunits(_DIFF_RELS))
+ ZipArchives.zip_writefile(w, "xl/workbook.xml", codeunits(_DIFF_WB))
+ ZipArchives.zip_writefile(w, "xl/_rels/workbook.xml.rels", codeunits(_DIFF_WBRELS))
+ ZipArchives.zip_writefile(w, "xl/styles.xml", codeunits(_DIFF_STYLES))
+ ZipArchives.zip_writefile(w, "xl/sharedStrings.xml", codeunits(sst_xml))
+ ZipArchives.zip_writefile(w, "xl/worksheets/sheet1.xml", codeunits(sheet_xml))
+ end
+ return take!(io)
+end
+
+# ── OOXML namespaces ──────────────────────────────────────────────────────────
+
+const _TRANS_MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"