diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96b49aba..f993cdba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,6 @@ name: CI on: +# workflow_dispatch: # <-- Add this to allow manual execution pull_request: branches: - master @@ -15,10 +16,10 @@ jobs: fail-fast: false matrix: version: - - '1.7' - '1.8' - '1.9' - '1' # automatically expands to the latest stable 1.x release of Julia + - 'lts' - 'pre' - 'nightly' os: diff --git a/Project.toml b/Project.toml index 31eaa8b9..6754c56c 100644 --- a/Project.toml +++ b/Project.toml @@ -7,21 +7,34 @@ version = "0.10.5-dev" [deps] Artifacts = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" +Colors = "5ae59095-9a9b-59fe-a467-6f913c188581" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +Mmap = "a63ad114-7e13-5084-954f-fe012c677804" +PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" +UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" XML = "72c71f33-b9b6-44de-8c94-c961784809e2" ZipArchives = "49080126-0e18-4c2a-b176-c102e4b3760c" [compat] +Colors = "0.13.0" +Distributions = "0.25.0" +Mmap = "1" +PrecompileTools = "1" +Random = "1.10.0" Tables = "1" +UUIDs = "1.8" XML = "0.3.5" ZipArchives = "2" -julia = "1.7" +julia = "1.8" [extras] DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test", "DataFrames"] +test = ["Test", "Distributions", "Random", "DataFrames"] diff --git a/README.md b/README.md index b4cc42ce..4eb42a27 100644 --- a/README.md +++ b/README.md @@ -19,26 +19,70 @@ Excel file reader/writer for the Julia language. +## Introduction + +**XLSX.jl** is a Julia package to read and write +[Excel](https://products.office.com/excel) spreadsheet files. + +Internally, an Excel XLSX file is just a +[Zip](https://en.wikipedia.org/wiki/Zip_(file_format)) file with a set of +[XML](https://en.wikipedia.org/wiki/XML) files inside. +The formats for these XML files are described in +the [Standard ECMA-376](https://ecma-international.org/publications-and-standards/standards/ecma-376/). + +This package follows the EMCA-376 to parse and generate XLSX files. + +## Requirements + +* Julia v1.8 + +* Linux, macOS or Windows. + ## Installation +From a Julia session, run: + ```julia +julia> using Pkg + julia> Pkg.add("XLSX") ``` -## Documentation +## Source Code -Package documentation is hosted at https://felipenoris.github.io/XLSX.jl/stable. +The source code for this package is hosted at +[https://github.com/felipenoris/XLSX.jl](https://github.com/felipenoris/XLSX.jl). + +## License + +The source code for the package **XLSX.jl** is licensed under +the [MIT License](https://raw.githubusercontent.com/felipenoris/XLSX.jl/master/LICENSE). + +## Getting Help + +If you're having any trouble, have any questions about this package +or want to ask for a new feature, +just open a new [issue](https://github.com/felipenoris/XLSX.jl/issues). + +## Contributing + +Contributions are always welcome! + +To contribute, fork the project on [GitHub](https://github.com/felipenoris/XLSX.jl) +and send a Pull Request. ## References -* [ECMA Open XML White Paper](https://www.ecma-international.org/news/TC45_current_work/OpenXML%20White%20Paper.pdf) +* [ECMA Open XML White Paper](https://www.ecma-international.org/wp-content/uploads/OpenXML-White-Paper.pdf) -* [ECMA-376](https://www.ecma-international.org/publications/standards/Ecma-376.htm) +* [ECMA-376](https://ecma-international.org/publications-and-standards/standards/ecma-376/) -* [Excel file limits](https://support.office.com/en-gb/article/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3) +* [Excel file limits](https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3) ## Alternative Packages +* [LibXLSXWriter.jl](https://github.com/jaakkor2/LibXLSXWriter.jl) + * [ExcelFiles.jl](https://github.com/davidanthoff/ExcelFiles.jl) * [ExcelReaders.jl](https://github.com/davidanthoff/ExcelReaders.jl) diff --git a/data/Borders.xlsx b/data/Borders.xlsx index 345a63e5..ac409a1d 100644 Binary files a/data/Borders.xlsx and b/data/Borders.xlsx differ diff --git a/data/NoDim.xlsx b/data/NoDim.xlsx new file mode 100644 index 00000000..bcd046a9 Binary files /dev/null and b/data/NoDim.xlsx differ diff --git a/data/Template File.xltx b/data/Template File.xltx new file mode 100644 index 00000000..a39c0599 Binary files /dev/null and b/data/Template File.xltx differ diff --git a/data/customXml.xlsx b/data/customXml.xlsx index 2dc7f9fa..8f521397 100644 Binary files a/data/customXml.xlsx and b/data/customXml.xlsx differ diff --git a/data/empty_v.xlsx b/data/empty_v.xlsx new file mode 100644 index 00000000..43b397f7 Binary files /dev/null and b/data/empty_v.xlsx differ diff --git a/data/general.xlsx b/data/general.xlsx index a5c92d67..96b6a300 100644 Binary files a/data/general.xlsx and b/data/general.xlsx differ diff --git a/data/testmerge.xlsx b/data/testmerge.xlsx new file mode 100644 index 00000000..7dcf33b7 Binary files /dev/null and b/data/testmerge.xlsx differ diff --git a/docs/src/api.md b/docs/api.md similarity index 77% rename from docs/src/api.md rename to docs/api.md index 2a2b32bb..e32c281c 100644 --- a/docs/src/api.md +++ b/docs/api.md @@ -1,6 +1,9 @@ # API Reference +```@index +``` + ```@docs XLSX.XLSXFile XLSX.readxlsx @@ -14,6 +17,8 @@ XLSX.hassheet XLSX.Worksheet XLSX.rename! XLSX.addsheet! +XLSX.copysheet! +XLSX.deletesheet! XLSX.readdata XLSX.getdata XLSX.getcell @@ -23,17 +28,18 @@ XLSX.row_number XLSX.column_number XLSX.eachrow XLSX.readtable +XLSX.readdf XLSX.gettable XLSX.eachtablerow XLSX.writetable XLSX.writetable! +XLSX.setConditionalFormat XLSX.setFormat XLSX.setUniformFormat XLSX.setFont XLSX.setUniformFont XLSX.setBorder XLSX.setUniformBorder -XLSX.setOutsideBorder XLSX.setFill XLSX.setUniformFill XLSX.setAlignment @@ -41,4 +47,9 @@ XLSX.setUniformAlignment XLSX.setUniformStyle XLSX.setColumnWidth XLSX.setRowHeight +XLSX.getMergedCells +XLSX.isMergedCell +XLSX.getMergedBaseCell +XLSX.mergeCells +XLSX.addDefinedName ``` diff --git a/docs/make.jl b/docs/make.jl index 5dcb4525..f62c794d 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -7,9 +7,19 @@ makedocs( pages = [ "Home" => "index.md", "Tutorial" => "tutorial.md", - "API Reference" => "api.md", - "Migration Guides" => "migration.md", - ], + "Formatting Guide" => Any[ + "Cell formats" => "formatting/cellFormatting.md", + "Conditional formats" => "formatting/conditionalFormatting.md", + "Working with merged cells" => "formatting/mergedCells.md", + "Examples" => "formatting/examples.md" + ], + "Migration Guide" => "migration.md", + "API Reference" => Any[ + "Files and worksheets" => "api/files.md", + "Cells and data" => "api/data.md", + "Formats" => "api/formats.md", + ] + ], checkdocs=:none, ) diff --git a/docs/src/api/data.md b/docs/src/api/data.md new file mode 100644 index 00000000..c3639141 --- /dev/null +++ b/docs/src/api/data.md @@ -0,0 +1,31 @@ +# Cells and data + +## Cell referencing + +```@docs +XLSX.CellRef +XLSX.row_number +XLSX.column_number +XLSX.eachrow +XLSX.eachtablerow +``` + +## Cell data + +```@docs +XLSX.readdata +XLSX.getdata +XLSX.getcell +XLSX.getcellrange +XLSX.gettable +XLSX.readtable +XLSX.readto +XLSX.writetable +XLSX.writetable! +``` + +## Defined names + +```@docs +XLSX.addDefinedName +``` diff --git a/docs/src/api/files.md b/docs/src/api/files.md new file mode 100644 index 00000000..d6e61fc6 --- /dev/null +++ b/docs/src/api/files.md @@ -0,0 +1,26 @@ +# Files and worksheets + +## Files + +```@docs +XLSX.XLSXFile +XLSX.readxlsx +XLSX.openxlsx +XLSX.opentemplate +XLSX.newxlsx +XLSX.writexlsx +XLSX.savexlsx +``` + +## Worksheets + +```@docs +XLSX.Worksheet +XLSX.sheetnames +XLSX.sheetcount +XLSX.hassheet +XLSX.rename! +XLSX.addsheet! +XLSX.copysheet! +XLSX.deletesheet! +``` diff --git a/docs/src/api/formats.md b/docs/src/api/formats.md new file mode 100644 index 00000000..17adef9a --- /dev/null +++ b/docs/src/api/formats.md @@ -0,0 +1,42 @@ +# Cells and data + +## Cell format + +```@docs +XLSX.setFormat +XLSX.setUniformFormat +XLSX.setFont +XLSX.setUniformFont +XLSX.setBorder +XLSX.setUniformBorder +XLSX.setFill +XLSX.setUniformFill +XLSX.setAlignment +XLSX.setUniformAlignment +XLSX.setUniformStyle +``` + +## Conditional format + +```@docs +XLSX.getConditionalFormats +XLSX.setConditionalFormat +``` + +## Column width and row height + +```@docs +XLSX.getColumnWidth +XLSX.setColumnWidth +XLSX.getRowHeight +XLSX.setRowHeight +``` + +# Merged Cells + +```@docs +XLSX.getMergedCells +XLSX.isMergedCell +XLSX.getMergedBaseCell +XLSX.mergeCells +``` diff --git a/docs/src/formatting/cellFormatting.md b/docs/src/formatting/cellFormatting.md new file mode 100644 index 00000000..12ead670 --- /dev/null +++ b/docs/src/formatting/cellFormatting.md @@ -0,0 +1,316 @@ + +# Cell formats + +## Excel formatting + +Each cell in an Excel spreadsheet may refer to an Excel `style`. Multiple cells can +refer to the same `style` and therefore have a uniform appearance. A `style` defines +the cell's `alignment` directly (as part of the `style` definition), but it may also +refer to further formatting definitions for `font`, `fill`, `border`, `format`. +Multiple `style`s may each refer to the same `fill` definition or the same `font` +definition, etc, and therefore share these formatting characteristics. +This hierarchy can be shown like this: +``` + `Cell` + │ + `Style` => `Alignment` + │ + ┌──────────┬────┴─────┬─────────┐ + │ │ │ │ +`font` `fill` `border` `format` +``` +A family of setter functions is provided to set each of the formatting characteristics +Excel uses. These are applied to cells, and the functions deal with the relationships +between the individual characteristics, the overarching `style` and the cell(s) themselves. + +## Setting format attributes of a cell + +Set the font attributes of a cell using [`XLSX.setFont`](@ref). For example, to set cells `A1` and +`A5` in the `general` sheet of a workbook to specific `font` values, use: + +```julia + +julia> using XLSX + +julia> f=XLSX.opentemplate("general.xlsx") +XLSXFile("general.xlsx") containing 13 Worksheets + sheetname size range +------------------------------------------------- + general 10x6 A1:F10 + table3 5x6 A2:F6 + table4 4x3 E12:G15 + table 12x8 A2:H13 + table2 5x3 A1:C5 + empty 1x1 A1:A1 + table5 6x1 C3:C8 + table6 8x2 B1:C8 + table7 7x2 B2:C8 + lookup 4x9 B2:J5 + header_error 3x4 B2:E4 + named_ranges_2 4x5 A1:E4 + named_ranges 14x6 A2:F15 + +julia> s=f["general"] +10×6 XLSX.Worksheet: ["general"](A1:F10) + +julia> XLSX.setFont(s, "A1"; name="Arial", size=24, color="blue", bold=true) +2 + +julia> XLSX.setFont(s, "A5"; name="Arial", size=24, color="blue", bold=true) +2 +``` + +The function returns the `fontId` that has been used to define this combination +of attributes. + +There are more `font` attributes that can be set. Setting attributes for a cell +that already has some, merges the new attributes with the old. Thus: + +```julia +julia> XLSX.setFont(s, "A5"; italic=true, under="double", bold=false) +3 +``` + +will over-ride the `bold` setting that was previously defined and add a double +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). + +## Formatting multiple cells at once + +### Applying `setAttribute` to multiple cells + +Each of the setter functions can be applied to multiple cells at once using cell-ranges, +row- or column-ranges or non-contiguous ranges. Additionally, indexing can use integer +indices for rows and columns, vectors of index values, unit- or step-ranges. This makes +it easy to apply formatting to many cells at once. + +Thus, for example: + +```julia + +julia> using XLSX + +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> s[1:100, 1:100] = "" # Ensure these aren't `EmptyCell`s. +"" + +julia> XLSX.setFont(s, "A1:CV100"; name="Arial", size=24, color="blue", bold=true) +-1 # Returns -1 on a range. + +julia> XLSX.setBorder(s, "A1:CV100"; allsides = ["style" => "thin", "color" => "black"]) +-1 + +julia> XLSX.setAlignment(s, [10, 50, 90], 1:100; wrapText=true) # Wrap text in the specified rows. +-1 + +julia> XLSX.setAlignment(s, 1:100, 2:2:100; rotation=90) # Rotate text 90° every second column in the first 100 rows. +-1 +``` + +It is even possible to use defined names to index these functions: + +```julia + +julia> XLSX.addDefinedName(s, "my_name", "A1,B20,C30") # Define a non-contiguous named range. +XLSX.DefinedNameValue(Sheet1!A1,Sheet1!B20,Sheet1!C30, Bool[1, 1, 1]) + +julia> XLSX.setFill(s, "my_name"; pattern="solid", fgColor="coral") +-1 +``` + +When setting format attributes over a range of cells as decribed, the new attributes are merged +with existing on a cell by cell basis. If you set the font name on a range of cells that previously +all had different font colors, the color differences will persist even as the font name is applied +to the range consistently. + +### Setting uniform attributes + +Sometimes it is useful to be able to apply a fully consistent set of format attributes to a range of +cells, over-riding any pre-existing differences. This is the purpose of the `setUniformAttribute` +family of functions. These functions update the attributes of the first cell in the range and then +apply the relevant attribute Id to the rest of the cells in the range. Thus: + +```julia +julia> XLSX.setUniformBorder(s, "A1:CV100"; allsides = ["color" => "green"], diagonal = ["direction"=>"both", "color"=>"red"]) +2 # This is the `borderId` that has now been uniformly applied to every cell. +``` + +This sets the border color in cell `A1` to be green and adds red diagonal lines across the cell. +It then applies all the `Border` attributes of cell `A1` uniformly to all the other cells in the range, +overriding their previous attributes. + +All the format setter functions have `setUniformAttribute` versions, too. See [`XLSX.setUniformBorder`](@ref), +[`XLSX.setUniformFill`](@ref), [`XLSX.setUniformFormat`](@ref) and [`XLSX.setUniformAlignment`](@ref). + +### Setting uniform styles + +It is possible to use each of the `setUniformAttribute` functions in turn to ensure every possible +attribute is consistently applied to a range of cells. However, if perfect uniformity is required, +then `setUniformStyle` is considerably more efficient. It will simply take the `styleId` of the +first cell in the range and apply it uniformly to each cell in the range. This ensures that all +of font, fill, border, format, and alignment are all completely consistent across the range: + +```julia +julia> XLSX.setUniformStyle(s, "A1:CV100") # set all formatting attributes to be uniformly tha same as cell A1. +7 # this is the `styleId` that has now been applied to all cells in the range +``` + +### Illustrating the different approaches + +To illustrate the differences between applying `setAttribute`, `setUniformAttribute` and `setUinformStyle`, +consider the following worksheet, which has very hetrogeneous formatting across the three cells: + +![image|320x500](../images/multicell.png) + +We can apply `setBorder()` to add a top border to each cell: + +```julia +julia> XLSX.setBorder(s, "B2,D2,F2"; top=["style"=>"thick", "color"=>"red"]) +-1 +``` +This merges the new top border definition with the other, existing border attributes, to get + +![image|320x500](../images/multicell2.png) + +Alternatively, we can apply `setUniformBorder()`, which will update the borders of cell `B2` +and then apply all the border attributes of `B2` to the other cells, overwriting the previous +settings: + +```julia +julia> XLSX.setUniformBorder(s, "B2,D2,F2"; top=["style"=>"thick", "color"=>"red"]) +4 +``` + +This makes the border formatting entirely consistent across the cells but leaves the other formatting +attributes (font, fill, format, alignment) as they were. + +![image|320x500](../images/multicell3.png) + +Finally, we can set `B2` to have the formatting we want, and then apply a uniform style to all three cells. + +```julia +julia> XLSX.setBorder(s, "B2"; top=["style"=>"thick", "color"=>"red"]) +4 + +julia> XLSX.setUniformStyle(s, "B2,D2,F2") +19 +``` +Which results in all formatting attributes being entirely consistent across the cells. + +![image|320x500](../images/multicell4.png) + +### Performance differences between methods + +To illustrtate the relative performance of these three methods, applied to a million cells: +```julia +using XLSX +function setup() + f = XLSX.newxlsx() + s = f[1] + s[1:1000, 1:1000] = pi + return f +end +do_format(f) = XLSX.setFormat(f[1], 1:1000, 1:1000; format="0.0000") +do_uniform_format(f) = XLSX.setUniformFormat(f[1], 1:1000, 1:1000; format="0.0000") +function do_format_styles(f) + XLSX.setFormat(f[1], "A1"; format="0.0000") + XLSX.setUniformStyle(f[1], 1:1000, 1:1000) +end +function timeit() + f = setup() + do_format(f) + do_uniform_format(f) + do_format_styles(f) + f = setup() + print("Using `setFormat` : ") + @time do_format(f) + f = setup() + print("Using `setUniformFormat` : ") + @time do_uniform_format(f) + f = setup() + print("Using `setUniformStyle` : ") + @time do_format_styles(f) + return f +end +f=timeit() +``` + +which yields the following timings: + +``` +Using `setFormat` : 10.966803 seconds (256.00 M allocations: 19.771 GiB, 18.81% gc time) +Using `setUniformFormat` : 2.222868 seconds (31.00 M allocations: 1.137 GiB, 19.48% gc time) +Using `setUniformStyles` : 0.519658 seconds (14.00 M allocations: 416.587 MiB) +``` + +The same test, using the more involved `setBorder` function + +```julia +do_format(f) = XLSX.setBorder(f[1], 1:1000, 1:1000; + 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"] + ) +``` + +gives + +``` +Using `setBorder` : 29.536010 seconds (759.00 M allocations: 64.286 GiB, 22.01% gc time) +Using `setUniformBorder` : 2.052018 seconds (31.00 M allocations: 1.197 GiB, 13.18% gc time) +Using `setUniformStyles` : 0.599491 seconds (14.00 M allocations: 416.586 MiB, 15.20% gc time) +``` + +If maintaining heterogeneous formatting attributes is not important, it is more efficient to +apply `setUinformAttribute` functions rather than `setAttribute` functions, especially on large +cell ranges, and more efficient still to use `setUniformStyle`. + +## Copying formatting attributes + +It is possible to use non-contiguous ranges to copy format attributes from any cell to any other cells, +whether you are also updating the source cell's format or not. + +```julia +julia> XLSX.setBorder(s, "BB50"; allsides = ["style" => "medium", "color" => "yellow"]) +3 # Cell BB50 now has the border format I want! + +julia> XLSX.setUniformBorder(s, "BB50,A1:CV100") # Make cell BB50 the first (reference) cell in a non-contiguous range. +3 + +julia> XLSX.setUniformStyle(s, "BB50,A1:CV100") # Or if I want to apply all formatting attributes from BB50 to the range. +11 +``` + +## Setting column width and row height + +Two functions offer the ability to set the column width and row height within a worksheet. These can use +all of the indexing options described above. For example: + +```julia +julia> XLSX.setRowHeight(s, "A2:A5"; height=25) # Rows 1 to 5 (columns ignored) + +julia> XLSX.setColumnWidth(s, 5:5:100; width=50) # Every 5th column. +``` + +Excel applies some padding to user specified widths and heights. The two functions described here attempt +to do something similar but it is not an exact match to what Excel does. User specified row heights and +column widths will therefore differ by a small amount from the values you would see setting the same +widths in Excel itself. + diff --git a/docs/src/formatting/conditionalFormatting.md b/docs/src/formatting/conditionalFormatting.md new file mode 100644 index 00000000..96d18877 --- /dev/null +++ b/docs/src/formatting/conditionalFormatting.md @@ -0,0 +1,1439 @@ + +# Conditional formats + +## Applying conditional formats + +In Excel, a conditional format is a format that is applied if the content of a cell meets some criterion +but not otherwise. Such conditional formatting is generally straightforward to apply using the +`setAttribute()` functions or the `setConditionalFormat()` function described here. + +!!! note + + In Excel, conditional formats are dynamic. If the cell values change, the formats are updated based + on application of the condition to the new values. + + The examples of conditional formatting given here a mix of static and dynamic formats. + + Static conditional formats apply formatting based on the current cell values at the time the format + is set, but the formats are then static regardless of updates to cell values. They can be updated + by re-running the conditional formatting functions described but otherwise remain unchanged. Static + formats are created by applying the `setAttribute()` functions described above. + + Dynamic conditional formatting, using the native Excel conditional format functionality, is possible + using the `setConditionalFormat()` function, giving access to all of Excel's options. + +### Static conditional formats + +As an example, a simple function to set true values in a range to use a bold green font color and +false values to use a bold red color a could be defined as follows: + +```julia +function trueorfalse(sheet, rng) # Use green or red font for true or false respectively + for c in rng + if !ismissing(sheet[c]) && sheet[c] isa Bool + XLSX.setFont(sheet, c, bold=true, color = sheet[c] ? "FF548235" : "FFC00000") + end + end +end +``` + +Applying this function over any range will conditionally color cells green or red if they are +true or false respectively: + +```julia +trueorfalse(sheet, XLSX.CellRange("E3:L6")) +``` + +Similarly, a function can be defined to fill any cells containing missing values to be filled with a grey +color and have diagonal borders applied: + +```julia +function blankmissing(sheet, rng) # Fill with grey and apply both diagonal borders on cells + for c in rng # with missing values + if ismissing(sheet[c]) + XLSX.setFill(sheet, c; pattern = "solid", fgColor = "lightgrey") + XLSX.setBorder(sheet, c; diagonal = ["style" => "thin", "color" => "black"]) + end + end +end +``` + +This can then be applied to a range of cells to conditionally apply the format: + +```julia +blankmissing(sheet, XLSX.CellRange("B3:L6")) +``` + +### Dynamic conditional formats + +XLSX.jl provides a function to create native Excel conditional formats that will be saved +as part of an `XLSXFile` and which will update dynamically if the values in the cell range +to which the formatting is applied are subsequently updated. + +`XLSX.setConditionalFormat(sheet, CellRange, :type; kwargs...)` + +Excel uses a range of `:type` values to describe these conditional formats and the same values +are used here, as follows: +- `:cellIs` +- `:top10` +- `:aboveAverage` +- `:containsText` +- `:notContainsText` +- `:beginsWith` +- `:endsWith` +- `:timePeriod` +- `:containsErrors` +- `:notContainsErrors` +- `:containsBlanks` +- `:notContainsBlanks` +- `:uniqueValues` +- `:duplicateValues` +- `:expression` +- `:dataBar` +- `:colorScale` +- `:iconSet` + +Use of these different `:type`s is illustrated in the following sections. +For more details on the range of `:type` values and their associated keyword +options, refer to [XLSX.setConditionalFormat()](@ref). + +#### Cell Value + +It is possible to format each cell in a range when the cell's value meets a specified condition using one +of a number of built-in cell format options or using custom formatting. This group of formatting options +represents the greatest range of conditional formatting options available in Excel and the most often +used. All the functions of `Highlight Cells Rules` and `Top/Bottom Rules` are provided. + +![image|320x500](../images/cell1.png) ![image|100x500](../images/blank.png) ![image|320x500](../images/cell2.png) + +The following `:type` values are used to set conditional formats by making direct comparisons to a cell's value: +- `:cellIs` +- `:top10` +- `:aboveAverage` +- `:containsText` +- `:notContainsText` +- `:beginsWith` +- `:endsWith` +- `:timePeriod` +- `:containsErrors` +- `:notContainsErrors` +- `:containsBlanks` +- `:notContainsBlanks` +- `:uniqueValues` +- `:duplicateValues` + +Each of these formatting types needs a set of keyword options to fully define its operation. +This can be exemplified by considering the `:cellIs` type. Like the other conditional formats +in this group, `:cellIs` needs an `operator` keyword to define the test to make to determine +whether or not to apply the formatting. Valid `operator` values for `:cellIs` are: + +- `greaterThan` (cell > `value`) +- `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`) + +Each of these need the keyword `value` to be specified and, for `between` and `notBetween`, `value2` +must also be specified. + +Like all the cell value formatting types, `:cellIs` can use one of six built-in Excel formats, as +illustrated here for the `greaterThan` comparison. + +![image|320x500](../images/cellvalue-formats.png) + +These six built-in formatting options are available by name in XLSX.jl by specifying the `dxStyle` +keyword with one of the following values: +* `redfilltext` +* `yellowfilltext` +* `greenfilltext` +* `redfill` +* `redtext` +* `redborder` + +Thus, for example, to create a simple `XLSXFile` from scratch and then apply some +`:cellIs` conditional formats to its cells: + +```julia +julia> columns = [ [1, 2, 3, 4], ["Hey", "You", "Out", "There"], [10.2, 20.3, 30.4, 40.5] ] +3-element Vector{Vector}: + [1, 2, 3, 4] + ["Hey", "You", "Out", "There"] + [10.2, 20.3, 30.4, 40.5] + +julia> colnames = [ "integers", "strings", "floats" ] +3-element Vector{String}: + "integers" + "strings" + "floats" + +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, colnames) + +julia> s[1:5, 1:3] +5×3 Matrix{Any}: + "integers" "strings" "floats" + 1 "Hey" 10.2 + 2 "You" 20.3 + 3 "Out" 30.4 + 4 "There" 40.5 + +julia> XLSX.setConditionalFormat(s, "A2:A5", :cellIs; # Cells with a value > 2 to have red text and light red fill. + operator="greaterThan", + value="2", + dxStyle="redfilltext") +0 + +julia> XLSX.setConditionalFormat(s, "B2:B5", :containsText; # Cells with text containing "u" to have green text and light green fill. + value="u", + dxStyle="greenfilltext") +0 + +julia> XLSX.setConditionalFormat(s, "C2:C5", :top10; # Cells with values in the top 10% of values in the range to have a red border. + operator ="topN%", + value="10" + dxStyle="redborder") +0 + +``` + +![image|320x500](../images/simple-cellvalue-example.png) + +Alternatively, it is possible to specify custom format options to match the options offered in Excel +under the `Custom Format...` option: + +![image|320x500](../images/custom-formats.png) + +!!! note + + In the image above, the font name and size selectors are greyed out. 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, for simplicity this function sets all the + border attributes for each side of a cell to be the same. + +For example, starting with the same simple `XLSXFile` as above, we can apply the following custom formats: + +```julia +julia> XLSX.setConditionalFormat(s, "A2:A5", :cellIs; + operator="greaterThan", + value="2", + font=["color" => "coral", "bold"=>"true"], + fill=["pattern"=>"solid", "bgColor"=>"cornsilk"], + border=["style"=>"dashed", "color"=>"orangered4"], + format=["format"=>"0.000"]) +0 + +julia> XLSX.setConditionalFormat(s, "B2:B5", :containsText; + value="u", + font=["color" => "steelblue4", "italic"=>"true"], + fill=["pattern"=>"darkTrellis", "fgColor"=>"lawngreen", "bgColor"=>"cornsilk"], + border=["style"=>"double", "color"=>"magenta3"]) +0 + +julia> XLSX.setConditionalFormat(s, "C2:C5", :top10; + operator ="topN%", + value="10", + font=["color" => "magenta3", "strike"=>"true"], + fill=["pattern"=>"lightVertical", "fgColor"=>"lawngreen", "bgColor"=>"cornsilk"], + border=["style"=>"double", "color"=>"cyan"]) +0 + +julia> XLSX.getConditionalFormats(s) +3-element Vector{Pair{XLSX.CellRange, NamedTuple}}: + C2:C5 => (type = "top10", priority = 3) + B2:B5 => (type = "containsText", priority = 2) + A2:A5 => (type = "cellIs", priority = 1) + +``` + +![image|320x500](../images/custom-cellvalue-example.png) + +Each of the conditional format `type`s in the cell value group take similar keyword options but +the specific details vary for each. For more details, refer to [XLSX.setConditionalFormat()](@ref). + +#### Expressions + +It is possible to use an Excel formula directly to determine whether to apply a conditional format. +Any expression that evaluates to true or false can be used. + +![image|320x500](../images/expression.png) + +For example, to compare one column with another and apply a conditional format accordingly: + +```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> XLSX.writetable!(s, [rand(10), rand(10), rand(10), rand(10)], ["col1", "col2", "col3", "col4"]) + +julia> s[:] +11×4 Matrix{Any}: + "col1" "col2" "col3" "col4" + 0.810579 0.13742 0.0146856 0.654739 + 0.169043 0.623955 0.713874 0.103253 + 0.198619 0.19622 0.0818595 0.863316 + 0.353214 0.0949461 0.961917 0.812889 + 0.343781 0.0957323 0.061183 0.822921 + 0.34115 0.243949 0.527914 0.758945 + 0.161748 0.744446 0.119521 0.52732 + 0.39707 0.284588 0.501409 0.374944 + 0.327938 0.191197 0.943983 0.755799 + 0.0314949 0.560541 0.526068 0.45253 + +julia> XLSX.setConditionalFormat(s, "A2:A10", :expression; formula = "A2>B2", dxStyle = "redfilltext") +0 + +julia> XLSX.setConditionalFormat(s, "C2:D10", :expression; formula = "C2>\$B2", dxStyle = "greenfilltext") +0 +``` +![image|320x500](../images/simpleComparison.png) + +Column A uses relative referencing. Columns C and D use an absolute reference for the column but not the +row of the comparison reference. + +The following example uses absolute references on rows and compares the average of each column with the +average of the preceding column. + +```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> XLSX.writetable!(s, [rand(10).*1000, rand(10).*1000, rand(10).*1000, rand(10).*1000], ["2022", "2023", "2024", "2025"]) + +julia> XLSX.setConditionalFormat(s, "B2:D11", :expression; formula = "average(B\$2:B\$11) > average(A\$2:A\$11)", dxStyle = "greenfilltext") +0 + +julia> XLSX.setConditionalFormat(s, "B2:D11", :expression; formula = "average(B\$2:B\$11) < average(A\$2:A\$11)", dxStyle = "redfilltext") +0 +``` +![image|320x500](../images/averageComparison.png) + +(Row 13 above is the average of each column, calculated in Excel) + +When a formula uses relative references, the relative position (offset) of the reference to the base cell in the +range to which the condition is applied is used consistently throughout the range. +This is illustrated in the following example: + +```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> XLSX.setConditionalFormat(s, "A1:E5", :expression; formula = "E5 < 50", dxStyle = "redfilltext") +0 +``` +![image|320x500](../images/relativeComparison.png) + +The format applied in cell `A1` is determined by comparison of cell `E5` to the value 50. In `B2` it is +based on cell `F6`, in `C3`, on cell `G7` and so on throughtout the range. + +Text based comparisons in Excel are not case sensitive by default, but can be forced to be so: + +```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> s[1:3,1:3]="HELLO WORLD" +"HELLO WORLD" + +julia> s["A1"] = "Hello World" +"Hello World" + +julia> s["B2"] = "Hello World" +"Hello World" + +julia> s["C3"] = "Hello World" +"Hello World" + +julia> XLSX.setConditionalFormat(s, "A1:A3", :expression; formula = "A1=\"hello world\"", dxStyle = "redfilltext") +0 + +julia> XLSX.setConditionalFormat(s, "B1:B3", :expression; formula = "B1=\"HELLO WORLD\"", dxStyle = "redfilltext") +0 + +julia> XLSX.setConditionalFormat(s, "C1:C3", :expression; formula = "exact(\"Hello World\", C1)", dxStyle = "greenfilltext") +0 +``` +![image|320x500](../images/caseSensitiveComparison.png) + +#### Data Bar + +A `:dataBar` conditional format can be applied to a range of cells. +In Excel there are twelve built-in data bars available, but it is possible +to customise many elements of these. + +![image|320x500](../images/dataBars.png) + +In XLSX.jl, the twelve built-in data bars are named as follows +(layout follows image) + +| | | | | +|:--------------:|:--------------:|:---------------:|:---------------:| +| Gradient fill | bluegrad | greengrad | redgrad | +| | orangegrad | lightbluegrad | purplegrad | +| Solid fill | blue | green | red | +| | orange | lightblue | purple | + + +Choose one of these data bars by name using the `databar` keyword. If no `databar` +is specified, `bluegrad` is the default choice. For example + +```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> s[1:10, 1]=1:10 +1:10 + +julia> s[1:10, 3]=1:10 +1:10 + +julia> XLSX.setConditionalFormat(s, "A1:A10", :dataBar) # Defaults to `databar="bluegrad"` +0 + +julia> XLSX.setConditionalFormat(s, "C1:C10", :dataBar; databar="orange") +0 + +``` +![image|320x500](../images/simpleDataBar.png) + +All of the options provided by Excel can be adjusted using the provided keyword options. + +![image|320x500](../images/dataBarOptions.png) + +![image|320x500](../images/negAndAxisOptions.png) + +For example, the end points of the bar scale can be defined by setting the `min_type` and `max_type` +keywords to `num` (for an absolute number value), `percent`, `percentile`, `formula` or `min` or `max`. +The default type is `automatic`. + +For the first three type options, a value must also be given by setting `min_val`, `max_val`. +The value may be taken from a cell by setting `min_val`, `max_val` to a cell reference. When the type is +set to `formula`, any valid formula yielding a value can be given. Cell references must use absolute referencing. +Types `min` and `max` set the scale endpoints to be exactly the minimum and maximum values of the data in the +cell range whereas using `automatic` allows Excel flexibility to make minor adjustments to these endpoints, +e.g. to improve appearance. + +```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> s[1:10, 5]=1:10 +1:10 + +julia> s[1:10, 1]=1:10 +1:10 + +julia> s[1:10, 3]=1:10 +1:10 + +julia> XLSX.setConditionalFormat(s, "A1:A10", :dataBar) +0 + +julia> XLSX.setConditionalFormat(s, "C1:C10", :dataBar; databar="purple", min_type="num", max_type="num", min_val="2", max_val="8") +0 + +julia> XLSX.setConditionalFormat(s, "E1:E10", :dataBar; databar="greengrad", min_type="percent", max_type="percent", min_val="35", max_val="65") +0 +``` + +![image|320x500](../images/minmaxDataBar.png) + +Choose whether to hide values using `showVal="false"`, convert a gradient fill to solid (or vice versa) +with `gradient="false"` (`gradient="true"`) and add borders to data bars with `borders="true"`. + +```julia +julia> XLSX.setConditionalFormat(s, "A1:A10", :dataBar) +0 + +julia> XLSX.setConditionalFormat(s, "C1:C10", :dataBar, showVal="false", gradient="false") +0 + +julia> XLSX.setConditionalFormat(s, "E1:E10", :dataBar; databar=purple, borders="true", gradient="true") +0 +``` +![image|320x500](../images/borderAndGrad.png) + +Change bar colors using `fill_col=` and border colors using `border_col=`. Colors are specified using an 8-digit hexadecimal as `"FFRRGGBB"` or using any named color from [Colors.jl](https://juliagraphics.github.io/Colors.jl/stable/namedcolors/). + +By default, negative values are shown with red bars and borders. Override these defaults by setting `sameNegFill = "true"`and `sameNegBorders="true"` to use the same colors as positive bars. Alternatively, to use any available color, set `neg_fill_col=` and `neg_border_col=`. + +```julia +julia> XLSX.setConditionalFormat(s, "A1:A11", :dataBar) +0 + +julia> XLSX.setConditionalFormat(s, "C1:C11", :dataBar; sameNegFill="true", sameNegBorders="true") +0 + +julia> XLSX.setConditionalFormat(s, "E1:E11", :dataBar; fill_col="cyan", border_col="blue", neg_fill_col="lemonchiffon1", neg_border_col="goldenrod4") +0 + +``` +![image|320x500](../images/customColors.png) + +By default, Excel positions the axis automatically, based on the range of the cell data. +Control the location of the axis using `axis_pos = "middle"` to locate it in the middle +of the column width or `axis_pos = "none"` to remove the axis. Excel chooses the direction +of the bars according to the context of the cell data. Force (postive) bars to go `leftToRight` +or `rightToLeft` using the `direction` key word. Change the color of the axis with `axis_col`. + +```julia +julia> s[1:10, 1]=1:10 +1:10 + +julia> s[1:10,3]=-5:4 +-5:4 + +julia> s[1:10,5]=1:10 +1:10 + +julia> XLSX.setConditionalFormat(s, "A1:A10", :dataBar) +0 + +julia> XLSX.setConditionalFormat(s, "C1:C10", :dataBar; direction="rightToLeft", axis_pos="middle", axis_col="magenta") +0 + +julia> XLSX.setConditionalFormat(s, "E1:E10", :dataBar; direction="leftToRight", min_type="num", min_val="-5", axis_pos="none") +0 + +``` +![image|320x500](../images/axisOptions.png) + +#### Color Scale + +It is possible to apply a `:colorScale` formatting type to a range of cells. +In Excel there are twelve built-in color scales available, but it is possible to create +custom color scales, too. + +![image|320x500](../images/colorScales.png) + +In XLSX.jl, the twelve built-in scales are named by their end/mid/start colors as follows +(layout follows image) + +| | | | | +|:----------------:|:----------------:|:---------------:|:---------------:| +| greenyellowred | redyellowgreen | greenwhitered | redwhitegreen | +| bluewhitered | redwhiteblue | whitered | redwhite | +| greenwhite | whitegreen | greenyellow | yellowgreen | + +The default colorscale is `greenyellow`. To use a different built-in color scale, +specify the name using the keyword `colorscale`, thus: + +```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 +``` + +A custom color scale may be defined by the colors at each end of the scale and (optionally) by some +mid-point color, too. Colors can be specified using hex RGB values or by name using any of the colors +in [Colors.jl](https://juliagraphics.github.io/Colors.jl/stable/namedcolors/). + +In Excel, the colorScale options (for a 3 color scale) look like this: + +![image|320x500](../images/colorScaleOptions.png) + +The end points (and optional mid-point) can be defined using an absolute number (`num`), a `percent`, +a `percentile` or as a `min` or `max`. For the first three options, a value must also be given. +The value may be taken from a cell by setting `min_val`, `mid_val` or `max_val` to a cell reference. +Thus, you can apply a custom 3-color scale using, for example: + +```julia +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 +``` +![image|320x500](../images/custom-colorscale.png) + +#### Icon Set + +It is possible to apply an `:iconSet` formatting type to a range of cells. +In Excel there are twenty built-in icon sets available, but it is possible to +create a custom icon set from the 52 built-in icons, too. + +![image|320x500](../images/iconSets.png) + +In XLSX.jl, the twenty built-in icon sets are named as follows +(layout follows image) + +| | | | +|:--------------:|:--------------:|:---------------:| +| Directional | 3Arrows | 3ArrowsGray | +| | 3Triangles | 4ArrowsGray | +| | 4Arrows | 5ArrowsGray | +| | 5Arrows | | +| Shapes | 3TrafficLights | 3TrafficLights2 | +| | 3Signs | 4TrafficLights | +| | 4BlackToRed | | +| Indicators | 3Symbols | 3Symbols2 | +| | 3Flags | | +| Ratings | 3Stars | 4Ratings | +| | 5Quarters | 5Ratings | +| | 5Boxes | | + +Choose one of these icon sets by name using the `iconset` keyword. If no `iconset` +is specified, `3TrafficLights` is the default choice. For example + +```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> s[1:10, 1]=1:10 +1:10 + +julia> XLSX.setConditionalFormat(s, "A1:A10", :iconSet) +0 +``` +![image|320x500](../images/basicIconSet.png) + +All of the options to control an iconSet in Excel are available. The iconSet options +(for a 4-icon set) look like this: + +![image|320x500](../images/iconSetOptions.png) + +Each icon set includes a default set of thresholds defining which symbol to use. These +relate the cell value to the range of values in the cell range to which the conditional +format is being applied. This can be illustrated (for a 4-icon set) as follows: + +``` + Range ┌─────────────────┬─────────────────┬─────────────────┬────────────────┐ Range + Minimum ->│ Icon 1 │ Icon 2 │ Icon 3 │ Icon 4 │<- Maximum + `min_val` `mid_val` `max_val` + threshold threshold threshold +``` +The starting value for the first icon is always the minimum value of the range, and the stopping +value for the last icon is always the maximum value in the range. No cells will have values for +which an icon cannot be assigned. The internal thresholds for transition from one icon to the +next are defined (in a 3-icon set) by `min_val` and `max_val`. In a 4-icon set, an additional +threshold, `mid-val`, is required and in a 5-icon set, `mid2_val` is needed as well. + +The type of these thresholds can be defined in terms of `percent` (of the range), `percentile` +or simply with a `num` (number) (e.g. as `min_type="percent"`). For each threshold, +the value can either be given as a number (as a String) or as a simple cell reference. +Alternatively, specifying the type as `formula` allows the value to be determined by any +valid Excel formula. + +!!! note + + Cell references used to define threshold values in an iconSet MUST always be given as absolute + cell references (e.g. `"\$A\$4"`). Relative references should not be used. + +Using the example above, change both the type and value of the thresholds like this: + +```julia +julia> XLSX.setConditionalFormat(s, "A1:A10", :iconSet; + min_type="num", max_type="num", + min_val="2", max_val="9") +0 +``` +![image|320x500](../images/newValIconSet.png) + +To suppress the values in cells and just show the icons, use `showVal="false"`, to reverse the icon ordering +use `reverse="true"` and to change the default comparison from `>=` to `>` set `min_gte="false"` (and +equivalent for mid, mid2 and max): +```julia +julia> XLSX.writetable!(s, [collect(1:10), collect(1:10), collect(1:10), collect(1:10)], + ["normal", "showVal=\"false\"", "reverse=\"true\"", "min_gte=\"false\""]) + +julia> XLSX.setConditionalFormat(s, "A2:A11", :iconSet; + min_type="num", max_type="num", + min_val="3", max_val="8") +0 + +julia> XLSX.setConditionalFormat(s, "B2:B11", :iconSet; + min_type="num", max_type="num", + min_val="3", max_val="8", + showVal="false") +0 + +julia> XLSX.setConditionalFormat(s, "C2:C11", :iconSet; + min_type="num", max_type="num", + min_val="3", max_val="8", + reverse="true") +0 + +julia> XLSX.setConditionalFormat(s, "D2:D11", :iconSet; + min_type="num", max_type="num", + min_val="3", max_val="8", + min_gte="false", max_gte="false") +0 +``` + +![image|320x500](../images/showValIcons.png) + +Create a custom icon set by specifying `iconset="Custom"`. The icons to use in the custom set are +defined with `icon_list` keyword, which takes a vector of integers defining which of the 52 built +in icons to use. Use of the val and type keywords dictate the number of icons to use. If `mid_type` +and `mid_val` are both defined, but not `mid2_val` or `mid2_type`, then a 4-icon set will be used. +If both sets of keywords are defined, a 5-icon set is used and if neither is set, a 3-icon set will +be used. + +This is illustrated with code below, which produces a key defining which integer to use +in `icon_list` to represent any desired icon: +```julia +using XLSX +f=XLSX.newxlsx() +s=f[1] +for i = 0:3 + for j=1:13 + s[i+1,j]=i*13+j + end +end +for j=1:13 + XLSX.setConditionalFormat(s, 1:4, j, :iconSet; # Create a custom 4-icon set in each column. + iconset="Custom", + icon_list=[j, 13+j, 26+j, 39+j], + min_type="percent", mid_type="percent", max_type="percent", + min_val="25", mid_val="50", max_val="75" + ) +end +XLSX.setColumnWidth(s, 1:13, width=6.4) +XLSX.setRowHeight(s, 1:4, height=27.75) +XLSX.setAlignment(s, "A1:M4", horizontal="center", vertical="center") +XLSX.setBorder(s, "A1:M4", allsides = ["style"=>"thin","color"=>"black"]) +XLSX.writexlsx("iconKey.xlsx", f, overwrite=true) +``` +![image|320x500](../images/iconKey.png) + +Specifying too few icons in `icon_list` throws an error while any extra will simply be ignored. + +#### Specifying cell references in Conditional Formats + +##### Cell Ranges + +Cell ranges for conditional formats are always absolute refences. The specified range to which a +conditional format is to be applied is always treated as an absolute cell references so that, +for example +```julia +julia> XLSX.setConditionalFormat(s, "A2:C5", :colorScale; colorscale="greenyellow") +``` +will be converted automatically to the range "\$A\$2:\$C\$5" by Excel itself. There is therefore no need to specify +absolute cell ranges when calling `setCondtionalFormat()` + +##### Relative and absolute cell references + +Cell references used to specify `value` or `value2` or in any `formula` (for `:expression` type +conditional formats only) may be either absolute or relative. As in Excel, an absolute reference +is defined using a `$` prefix to either or both the row or the column part of the cell reference +but here the `$` must be appropriately escaped. Thus: + +```julia +value = "B2" # relative reference +value = "\$B\$2" # (escaped) absolute reference +``` + +The cell used in a comparison is adjusted for each cell in the range if a relative reference is used. This is +illustrated in the following example. Cells in column A are referenced to column B using a relative reference, +meaning `A2` is compared with `B2` but `A3` is compared with `B3` and so on until `A5` is compared with `B5`. +In contrast, column B is referenced to cell `C2` using an absolute reference. Each cell in column B is compared +with cell `C2`. + +```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> col1=rand(5) +5-element Vector{Float64}: + 0.6283728884101448 + 0.7516580026008692 + 0.2738854683970795 + 0.13517788102005834 + 0.4659468387663539 + +julia> col2=rand(5) +5-element Vector{Float64}: + 0.7582186445697804 + 0.739539172599636 + 0.4389109821689414 + 0.14156225872248773 + 0.10715394525726485 + +julia> XLSX.writetable!(s, [col1, col2],["col1", "col2"]) + +julia> s["C2"]=0.5 +0.5 + +julia> s[:] +6×3 Matrix{Any}: + "col1" "col2" missing + 0.628373 0.758219 0.5 + 0.751658 0.739539 missing + 0.273885 0.438911 missing + 0.135178 0.141562 missing + 0.465947 0.107154 missing + +julia> XLSX.setConditionalFormat(s, "A2:A6", :cellIs; operator="greaterThan", value="B2", dxStyle="redfilltext") +0 + +julia> XLSX.setConditionalFormat(s, "B2:B6", :cellIs; operator="greaterThan", value="\$C\$2", dxStyle="greenfilltext") +0 + +``` +![image|320x500](../images/relative-CellRef.png) + +!!! note + + It is not possible to use relative cell references in conditional format types `:dataBar`, + `:colorScale` or `:iconSet`. + +!!! note + + Excel permits cell references to cells in other sheets for comparisons in conditional formats + (e.g. "OtherSheet!A1"), but this is handled differently internally than references within the + same sheet. This functionality is not universally implemented in XLSX.jl yet. + +#### Overlaying conditional formats + +It is possible to overlay multiple conditional formats over each other in a +cell range or even in different, overlapping cell ranges. Starting with a table of +integers, we can apply three different conditional formats sequentially. Excel applies +these in priority order (priority 1 is higher priority than priority 2) which is the +same as the order in which they were defined with `setConditionalFormat`. + +```julia +julia> s[1:5, 1:3] +5×3 Matrix{Any}: + "first" "middle" "last" + 1 15 9 + 12 6 10 + 3 17 11 + 14 8 2 + +julia> XLSX.setConditionalFormat(f["Sheet1"], "A2:C5", :colorScale; colorscale="greenyellowred") +0 + +julia> XLSX.setConditionalFormat(s, "A2:C5", :top10; + operator ="topN", + value="3", + font=["color"=>"magenta3", "strike"=>"true"], + fill=["pattern"=>"lightVertical", "fgColor"=>"lawngreen", "bgColor"=>"cornsilk"], + border=["style"=>"double", "color"=>"cyan"]) +0 + +julia> XLSX.setConditionalFormat(s, "A2:A5", :cellIs; + operator="lessThan", + value="2", + font=["color"=>"coral", "bold"=>"true"], + fill=["pattern"=>"lightHorizontal", "fgColor"=>"cornsilk"], + border=["style"=>"dashed", "color"=>"orangered4"]) +0 + +julia> XLSX.getConditionalFormats(s) +3-element Vector{Pair{XLSX.CellRange, NamedTuple}}: + A2:A5 => (type = "cellIs", priority = 3) + A2:C5 => (type = "colorScale", priority = 1) + A2:C5 => (type = "top10", priority = 2) + +``` + +![image|320x500](../images/multiple-cellvalue-example.png) + +When applying multiple overlayed formats, it is possible to make the formatting stop if any cell meets +one of the conditions, so that lower proirity conditional formats are not applied to that cell. This is +achieved with the `stopIfTrue` keyword. It is not possible to apply `stopIfTrue` to `:dataBar`, +`:colorScale` or `:iconSet` types. + +The example below illustrates how `stopIfTrue` is used to stop further conditional formats from being +applied to cells to which red borders are applied: + +```julia +julia> s[1:5, 1:3] +5×3 Matrix{Any}: + "first" "middle" "last" + 1 15 9 + 12 6 10 + 3 17 11 + 14 8 2 + +julia> XLSX.setConditionalFormat(s, "A2:C5", :cellIs; # No further conditions will be evaluated if this condition is met. + operator ="greaterThan", + value="9", + stopIfTrue="true", + dxStyle = "redborder") +0 + +julia> XLSX.setConditionalFormat(s, "A2:C5", :top10; # Won't apply if the max value in the range is > 9. + operator ="topN", + value="1", + dxStyle = "redfilltext") +0 + +julia> XLSX.setConditionalFormat(s, "A2:C5", :colorScale; colorscale="greenyellow") # Won't apply to any cell with a value > 9 +0 +``` + +![image|320x500](../images/stop-if-true.png) + +Overlaying the same three conditional formats without setting the `stopIfTrue` option +will result in the following, instead: + +![image|320x500](../images/no-stop-if-true.png) + +It is possible to overlay `:colorScale`s, `:dataBar`s and `:iconSet`s in the same or +overlapping cell ranges. + +```juliaf=XLSX.newxlsx() +julia> +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, [rand(10),rand(10),rand(10),rand(10),rand(10),rand(10),rand(10)],["col1","col2","col3","col4","col5","col6","col7"]) + +julia> XLSX.setConditionalFormat(s, "A5:E8", :dataBar; direction="rightToLeft") +0 + +julia> XLSX.setConditionalFormat(s, "C5:G8", :iconSet; iconset="5Arrows") +0 + +julia> XLSX.setConditionalFormat(s, "C2:E11", :colorScale; colorscale="greenyellowred") +0 + +julia> XLSX.setFormat(s, "A2:G11"; format="#0.00") +-1 + +``` +![image|320x500](../images/moreMixed.png) + +## Working with merged cells + +Worksheets may contain merged cells. XLSX.jl provides functions to identify the merged cells in a worksheet, +to determine if a cell is part of a merged range and to determine the value of a merged cell range from any +cell in that range. + +```julia + +julia> using XLSX + +julia> f=XLSX.opentemplate("customXml.xlsx") +XLSXFile("customXml.xlsx") containing 2 Worksheets + sheetname size range +------------------------------------------------- + Mock-up 116x11 A1:K116 + Document History 17x3 A1:C17 + +julia> XLSX.getMergedCells(f[1]) +25-element Vector{XLSX.CellRange}: + D49:H49 + D72:J72 + F94:J94 + F96:J96 + F84:J84 + F86:J86 + D62:J63 + D51:J53 + D55:J60 + D92:J92 + D82:J82 + D74:J74 + D67:J68 + D47:H47 + D9:H9 + D11:G11 + D12:G12 + D14:E14 + D16:E16 + D32:F32 + D38:J38 + D34:J34 + D18:E18 + D20:E20 + D13:G13 + +julia> XLSX.isMergedCell(f[1], "D13") +true + +julia> XLSX.isMergedCell(f[1], "H13") +false + +julia> XLSX.getMergedBaseCell(f[1], "E18") # E18 is a merged cell. The base cell in the merged range is D18. +(baseCell = D18, baseValue = "Here") # The base cell in the merged range is D18 and it's value is "Here". +``` + +It is also possible to create new merged cells: + +```julia + +julia> XLSX.isMergedCell(f[1], "F5") +false + +julia> XLSX.isMergedCell(f[1], "J8") +false + +julia> XLSX.mergeCells(s, "F5:J8") + +julia> s["F5"] = pi +π = 3.1415926535897... + +julia> XLSX.isMergedCell(f[1], "J8") +true + +julia> XLSX.isMergedCell(f[1], "F5") +true + +julia> XLSX.getMergedBaseCell(f[1], "J8") +(baseCell = F5, baseValue = 3.141592653589793) +``` + +It is not allowed to create new merged cells that overlap at all with any existing merged cells. + +!!! warning + + It is possible to write into any merged cell using `XLSX.jl`, even those that are not the + base cell of the merged range. This is illustrated below: + + ```julia + + julia> using XLSX + + 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> s["A1:A3"]=5 + 5 + ``` + + This produces the simple sheet shown. + + ![image|320x500](../images/simple-unmerged.png) + + Merging the three cells `A1:A3` sets the cells `A2` and `A3` to missing just as Excel does. + + ``` + julia> s["A1"] + 5 + + julia> s["A2"] + 5 + + julia> s["A3"] + 5 + + julia> XLSX.mergeCells(s, "A1:A3") + 0 + + julia> s["A1"] + 5 + + julia> s["A2"] + missing + + julia> s["A3"] + missing + ``` + + ![image|320x500](../images/after-merge.png) + + However, even after the merge, it is possible to explicitly write into the merged cells. + These written values will not be visible in Excel but can still be accessed by reference. + + ``` + julia> s["A2"]="text here now" + "text here now" + + julia> s["A1"] + 5 + + julia> s["A2"] + "text here now" + + julia> s["A3"] + missing + + julia> XLSX.getMergedBaseCell(s, "A2") + (baseCell = A1, baseValue = 5) + + ``` + + The cell `A2` remains merged, and this is how Excel displays it. The assigned cell value + won't be visible in Excel, but it can be referenced in a formula as shown here, where + cell `B2` references cell `A2` in its formula ("=A2"): + + ![image|320x500](../images/Written-to-merged-cell.png) + + Assigning values to cells in a merged range like this is prevented in Excel itself by the UI + although it is possible using VBA. There is currently no check to prevent this in `XLSX.jl`. + See [#241](https://github.com/felipenoris/XLSX.jl/issues/241) + +## Examples + +### Applying formatting to an existing table + +Consider a simple table, created from scratch, like this: + +```julia +using XLSX +using Dates + +# First create some data in an empty XLSXfile +xf = XLSX.newxlsx() +sheet = xf["Sheet1"] + +col_names = ["Integers", "Strings", "Floats", "Booleans", "Dates", "Times", "DateTimes", "AbstractStrings", "Rational", "Irrationals", "MixedStringNothingMissing"] +data = Vector{Any}(undef, 11) +data[1] = [1, 2, missing, UInt8(4)] +data[2] = ["Hey", "You", "Out", "There"] +data[3] = [101.5, 102.5, missing, 104.5] +data[4] = [true, false, missing, true] +data[5] = [Date(2018, 2, 1), Date(2018, 3, 1), Date(2018, 5, 20), Date(2018, 6, 2)] +data[6] = [Dates.Time(19, 10), Dates.Time(19, 20), Dates.Time(19, 30), Dates.Time(0, 0)] +data[7] = [Dates.DateTime(2018, 5, 20, 19, 10), Dates.DateTime(2018, 5, 20, 19, 20), Dates.DateTime(2018, 5, 20, 19, 30), Dates.DateTime(2018, 5, 20, 19, 40)] +data[8] = SubString.(["Hey", "You", "Out", "There"], 1, 2) +data[9] = [1 // 2, 1 // 3, missing, 22 // 3] +data[10] = [pi, sqrt(2), missing, sqrt(5)] +data[11] = [nothing, "middle", missing, "rotated"] + +XLSX.writetable!( + sheet, + data, + col_names; + anchor_cell=XLSX.CellRef("B2"), + write_columnnames=true, +) + +XLSX.writexlsx("mytable_unformatted.xlsx", xf, overwrite=true) +``` + +By default, this table will look like this in Excel: + +![image|320x500](../images/unformatted-table.png) + +We can apply some formatting choices to change the table's appearance: + +![image|320x500](../images/formatted-table.png) + +This is achieved with the following code: + +```julia +# Cell borders +XLSX.setUniformBorder(sheet, "B2:L6"; + top = ["style" => "hair", "color" => "FF000000"], + bottom = ["style" => "hair", "color" => "FF000000"], + left = ["style" => "thin", "color" => "FF000000"], + right = ["style" => "thin", "color" => "FF000000"] +) +XLSX.setBorder(sheet, "B2:L2"; bottom = ["style" => "medium", "color" => "FF000000"]) +XLSX.setBorder(sheet, "B6:L6"; top = ["style" => "double", "color" => "FF000000"]) +XLSX.setOutsideBorder(sheet, "B2:L6"; outside = ["style" => "thick", "color" => "FF000000"]) + +# Cell fill +XLSX.setFill(sheet, "B2:L2"; pattern = "solid", fgColor = "FF444444") + +# Cell fonts +XLSX.setFont(sheet, "B2:L2"; bold=true, color = "FFFFFFFF") +XLSX.setFont(sheet, "B3:L6"; color = "FF444444") +XLSX.setFont(sheet, "C3"; name = "Times New Roman") +XLSX.setFont(sheet, "C6"; name = "Wingdings", color = "FF2F75B5") + +# Cell alignment +XLSX.setAlignment(sheet, "L2"; wrapText = true) +XLSX.setAlignment(sheet, "I4"; horizontal="right") +XLSX.setAlignment(sheet, "I6"; horizontal="right") +XLSX.setAlignment(sheet, "C4"; indent=2) +XLSX.setAlignment(sheet, "F4"; vertical="top") +XLSX.setAlignment(sheet, "G4"; vertical="center") +XLSX.setAlignment(sheet, "L4"; horizontal="center", vertical="center") +XLSX.setAlignment(sheet, "G3:G6"; horizontal = "center") +XLSX.setAlignment(sheet, "H3:H6"; shrink = true) +XLSX.setAlignment(sheet, "L6"; horizontal = "center", rotation = 90, wrapText=true) + +# Row height and column width +XLSX.setRowHeight(sheet, "B4"; height=50) +XLSX.setRowHeight(sheet, "B6"; height=15) +XLSX.setColumnWidth(sheet, "I"; width = 20.5) + +# Conditional formatting +function blankmissing(sheet, rng) # Fill with grey and apply both diagonal borders on cells + for c in rng # with missing values + if ismissing(sheet[c]) + XLSX.setFill(sheet, c; pattern = "solid", fgColor = "grey") + XLSX.setBorder(sheet, c; diagonal = ["style" => "thin", "color" => "black"]) + end + end +end +function trueorfalse(sheet, rng) # Use green or red font for true or false respectively + for c in rng + if !ismissing(sheet[c]) && sheet[c] isa Bool + XLSX.setFont(sheet, c, bold=true, color = sheet[c] ? "FF548235" : "FFC00000") + end + end +end +function redgreenminmax(sheet, rng) # Fill light green / light red the cell with maximum / minimum value + mn, mx = extrema(x for x in sheet[rng] if !ismissing(x)) + for c in rng + if !ismissing(sheet[c]) + if sheet[c] == mx + XLSX.setFill(sheet, c; pattern = "solid", fgColor = "FFC6EFCE") + elseif sheet[c] == mn + XLSX.setFill(sheet, c; pattern = "solid", fgColor = "FFFFC7CE") + end + end + end +end + +blankmissing(sheet, XLSX.CellRange("B3:L6")) +trueorfalse(sheet, XLSX.CellRange("B2:L6")) +redgreenminmax(sheet, XLSX.CellRange("D3:D6")) +redgreenminmax(sheet, XLSX.CellRange("J3:J6")) +redgreenminmax(sheet, XLSX.CellRange("K3:K6")) + +# Number formats +XLSX.setFormat(sheet, "J3"; format = "Percentage") +XLSX.setFormat(sheet, "J4"; format = "Currency") +XLSX.setFormat(sheet, "J6"; format = "Number") +XLSX.setFormat(sheet, "K3"; format = "0.0") +XLSX.setFormat(sheet, "K4"; format = "0.000") +XLSX.setFormat(sheet, "K6"; format = "0.0000") + +# Save to an actual XLSX file +XLSX.writexlsx("mytable_formatted.xlsx", xf, overwrite=true) +``` + +### Creating a formatted form + +There is a file, customXml.xlsx, in the \data folder of this project that looks like a template +file - a form to be filled in. The code below creates this form from scratch and makes +extensive use of vector indexing for rows and columns and of non-contiguous ranges: + +```julia +using XLSX + +f = XLSX.newxlsx() +s = f[1] +s["A1:K116"] = "" + +s["B2"] = "Catalogue Entry Form" + +s["B5"] = "User Data" +s["B7"] = "Recipient ID" +s["B9"] = "Recipient Name" +s["B11"] = "Address 1" +s["B12"] = "Address 2" +s["B13"] = "Address 3" +s["B14"] = "Town" +s["B16"] = "Postcode" +s["B18"] = "Ward" +s["B20"] = "Region" +s["H18"] = "Local Authority" +s["H20"] = "UK Constituency" +s["B22"] = "GrantID" +s["D22"] = "Grant Date" +s["F22"] = "Grant Amount" +s["H22"] = "Grant Title" +s["J22"] = "Distributor" +s["B32"] = "Distributor" + +s["B30"] = "Creator" +s["B34"] = "Created by" +s["D36"] = "Email" +s["H36"] = "Phone" +s["B38"] = "Grant Manager" +s["D40"] = "Email" +s["H40"] = "Phone number" + +s["B43"] = "Summary" +s["B45"] = "Summary ID" +s["H45"] = "Date Created" +s["B47"] = "Summary Name" +s["B49"] = "Headline" +s["B51"] = "Short Description" +s["B55"] = "Long Description" +s["B62"] = "Quote 1" +s["D65"] = "Quote Attribution" +s["H65"] = "Quote Date" +s["B67"] = "Quote 2" +s["D70"] = "Quote Attribution" +s["H70"] = "Quote Date" +s["B72"] = "Keywords" +s["B74"] = "Website" +s["B76"] = "Social media handles" +s["D76"] = "Twitter" +s["D78"] = "Facebook" +s["D80"] = "Instagram" +s["H76"] = "LinkedIn" +s["H78"] = "TikTok" +s["H80"] = "YouTube" +s["B82"] = "Image 1 filename" +s["D84"] = "Alt-Text" +s["D86"] = "Image Attribution" +s["D88"] = "Image Date" +s["D90"] = "Confirm permission to use image" +s["B92"] = "Image 2 filename" +s["D94"] = "Alt-Text" +s["D96"] = "Image Attribution" +s["D98"] = "Image Date" +s["D100"] = "Confirm permission to use image" + +s["B103"] = "Penultimate category" +s["B105"] = "Competition Details" +s["D105"] = "Last year of entry" +s["D107"] = "Year of last win" +s["H105"] = "Categories of entry" +s["H107"] = "Categories of win" + +s["B110"] = "Last category" +s["B112"] = "Use for Comms" +s["D112"] = "Comms Priority" +s["F112"] = "Comms End Date" + +XLSX.setColumnWidth(s, 1:2:11; width=1.3) +XLSX.setColumnWidth(s, 2:2:10; width=18) +XLSX.setRowHeight(s, :; height=15) +XLSX.setRowHeight(s, [3, 4, 19, 28, 29, 35, 39, 41, 42, 64, 69, 77, 79, 83, 85, 87, 89, 93, 95, 97, 99, 101, 102, 106, 108, 109, 116]; height=5.5) +XLSX.setRowHeight(s, [5, 30, 43, 103, 110]; height=18) +XLSX.setRowHeight(s, 2; height=23) + +XLSX.setFont(s, "B2"; size=18, bold=true) +XLSX.setUniformFont(s, [5, 30, 43, 103, 110], 2; size=14, bold=true) + +XLSX.setUniformFill(s, [1, 2, 3, 4, 5, 6, 8, 10, 15, 17, 19, 21, 28, 29, 30, 31, 33, 35, 37, 39, 41, 42, 43, 44, 46, 48, 50, 52, 53, 54, 56, 57, 58, 59, 60, 61, 63, 64, 66, 68, 69, 71, 73, 75, 77, 79, 81, 83, 85, 87, 89, 91, 93, 95, 97, 99, 101, 102, 103, 104, 106, 108, 109, 110, 111, 115, 116], :; pattern="solid", fgColor="lightgrey") +XLSX.setUniformFill(s, :, [1, 3, 5, 7, 9, 11]; pattern="solid", fgColor="lightgrey") +XLSX.setFill(s, "F7,H7,J7,J9,H11:J16,F14,F16:F20,H32:J32,B36,B40,F45,J47:J49,B65,B70,B78:B80,B84:B90,B94:B100,H88:J90,H98:J100,B107,F114,H112:J115"; pattern="solid", fgColor="lightgrey") +XLSX.setFill(s, "D18,D20,J18,J20,D45"; pattern="solid", fgColor="darkgrey") +XLSX.setFill(s, "B112:B114,D112:D115"; pattern="solid", fgColor="white") +XLSX.setFill(s, "E90,E100,D115"; pattern="none") + +XLSX.mergeCells(s, "D9:H9") +XLSX.mergeCells(s, "D11:G11,D12:G12,D13:G13") +XLSX.mergeCells(s, "D32:F32,D34:J34,D38:J38") +XLSX.mergeCells(s, "D47:H47,D49:H49") +XLSX.mergeCells(s, "D51:J53,D55:J60") +XLSX.mergeCells(s, "D62:J63,D67:J68") +XLSX.mergeCells(s, "D72:J72,D74:J74") +XLSX.mergeCells(s, "D82:J82,F84:J84,F86:J86") +XLSX.mergeCells(s, "D92:J92,F94:J94,F96:J96") + +XLSX.setAlignment(s, "D51:J53,D55:J60,D62:J63,D67:J68"; vertical="top", wrapText=true) + +XLSX.setBorder(s, "A1:K3"; outside = ["style" => "medium", "color" => "black"]) +XLSX.setBorder(s, "A4:K28"; outside = ["style" => "medium", "color" => "black"]) +XLSX.setBorder(s, "A29:K41"; outside = ["style" => "medium", "color" => "black"]) +XLSX.setBorder(s, "A42:K101"; outside = ["style" => "medium", "color" => "black"]) +XLSX.setBorder(s, "A102:K108"; outside = ["style" => "medium", "color" => "black"]) +XLSX.setBorder(s, "A109:K116"; outside = ["style" => "medium", "color" => "black"]) + +XLSX.setBorder(s, "B7:D7,B9:H9"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B11:G13,B14:D14,B16:D16"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B18:D18,B20:D20,H18:J18,H20:J20"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setUniformBorder(s, "B22:J27"; allsides = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B32:F32"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B34:C34,D34:J34,D36:F36,H36:J36"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B38:C38,D38:J38,D40:F40,H40:J40"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D34:J36,D38:J40"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B45:D45,H45:J45"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B47:H47,B49:H49"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B51:C51,B55:C55"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D51:J53,D55:J60"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B62:C62,D65:F65,H65:J65"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B67:C67,D70:F70,H70:J70"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D62:J63,D67:J68"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D62:J65,D67:J70"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B72:J72,B74:J74"; allsides = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B76:F76,H76:J76,D78:F78,H78:J78,D80:F80,H80:J80"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D76:J80"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B82:J82,D84:J84,D86:J86,D88:F88,D90:F90"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D82:J90"; outside = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B92:J92,D94:J94,D96:J96,D98:F98,D100:F100"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D92:J100"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B105:F105,H105:J105,D107:F107,H107:J107"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D105:J107"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "F112,F113"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B112:B114,D112:D115"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.writexlsx("myNewTemplate.xlsx", f, overwrite=true) +``` \ No newline at end of file diff --git a/docs/src/formatting/examples.md b/docs/src/formatting/examples.md new file mode 100644 index 00000000..b59a906b --- /dev/null +++ b/docs/src/formatting/examples.md @@ -0,0 +1,297 @@ + + +# Examples + +## Applying cell format to an existing table + +Consider a simple table, created from scratch, like this: + +```julia +using XLSX +using Dates + +# First create some data in an empty XLSXfile +xf = XLSX.newxlsx() +sheet = xf["Sheet1"] + +col_names = ["Integers", "Strings", "Floats", "Booleans", "Dates", "Times", "DateTimes", "AbstractStrings", "Rational", "Irrationals", "MixedStringNothingMissing"] +data = Vector{Any}(undef, 11) +data[1] = [1, 2, missing, UInt8(4)] +data[2] = ["Hey", "You", "Out", "There"] +data[3] = [101.5, 102.5, missing, 104.5] +data[4] = [true, false, missing, true] +data[5] = [Date(2018, 2, 1), Date(2018, 3, 1), Date(2018, 5, 20), Date(2018, 6, 2)] +data[6] = [Dates.Time(19, 10), Dates.Time(19, 20), Dates.Time(19, 30), Dates.Time(0, 0)] +data[7] = [Dates.DateTime(2018, 5, 20, 19, 10), Dates.DateTime(2018, 5, 20, 19, 20), Dates.DateTime(2018, 5, 20, 19, 30), Dates.DateTime(2018, 5, 20, 19, 40)] +data[8] = SubString.(["Hey", "You", "Out", "There"], 1, 2) +data[9] = [1 // 2, 1 // 3, missing, 22 // 3] +data[10] = [pi, sqrt(2), missing, sqrt(5)] +data[11] = [nothing, "middle", missing, "rotated"] + +XLSX.writetable!( + sheet, + data, + col_names; + anchor_cell=XLSX.CellRef("B2"), + write_columnnames=true, +) + +XLSX.writexlsx("mytable_unformatted.xlsx", xf, overwrite=true) +``` + +By default, this table will look like this in Excel: + +![image|320x500](../images/unformatted-table.png) + +We can apply some formatting choices to change the table's appearance: + +![image|320x500](../images/formatted-table.png) + +This is achieved with the following code: + +```julia +# Cell borders +XLSX.setUniformBorder(sheet, "B2:L6"; + top = ["style" => "hair", "color" => "FF000000"], + bottom = ["style" => "hair", "color" => "FF000000"], + left = ["style" => "thin", "color" => "FF000000"], + right = ["style" => "thin", "color" => "FF000000"] +) +XLSX.setBorder(sheet, "B2:L2"; bottom = ["style" => "medium", "color" => "FF000000"]) +XLSX.setBorder(sheet, "B6:L6"; top = ["style" => "double", "color" => "FF000000"]) +XLSX.setOutsideBorder(sheet, "B2:L6"; outside = ["style" => "thick", "color" => "FF000000"]) + +# Cell fill +XLSX.setFill(sheet, "B2:L2"; pattern = "solid", fgColor = "FF444444") + +# Cell fonts +XLSX.setFont(sheet, "B2:L2"; bold=true, color = "FFFFFFFF") +XLSX.setFont(sheet, "B3:L6"; color = "FF444444") +XLSX.setFont(sheet, "C3"; name = "Times New Roman") +XLSX.setFont(sheet, "C6"; name = "Wingdings", color = "FF2F75B5") + +# Cell alignment +XLSX.setAlignment(sheet, "L2"; wrapText = true) +XLSX.setAlignment(sheet, "I4"; horizontal="right") +XLSX.setAlignment(sheet, "I6"; horizontal="right") +XLSX.setAlignment(sheet, "C4"; indent=2) +XLSX.setAlignment(sheet, "F4"; vertical="top") +XLSX.setAlignment(sheet, "G4"; vertical="center") +XLSX.setAlignment(sheet, "L4"; horizontal="center", vertical="center") +XLSX.setAlignment(sheet, "G3:G6"; horizontal = "center") +XLSX.setAlignment(sheet, "H3:H6"; shrink = true) +XLSX.setAlignment(sheet, "L6"; horizontal = "center", rotation = 90, wrapText=true) + +# Row height and column width +XLSX.setRowHeight(sheet, "B4"; height=50) +XLSX.setRowHeight(sheet, "B6"; height=15) +XLSX.setColumnWidth(sheet, "I"; width = 20.5) + +# Conditional formatting +function blankmissing(sheet, rng) # Fill with grey and apply both diagonal borders on cells + for c in rng # with missing values + if ismissing(sheet[c]) + XLSX.setFill(sheet, c; pattern = "solid", fgColor = "grey") + XLSX.setBorder(sheet, c; diagonal = ["style" => "thin", "color" => "black"]) + end + end +end +function trueorfalse(sheet, rng) # Use green or red font for true or false respectively + for c in rng + if !ismissing(sheet[c]) && sheet[c] isa Bool + XLSX.setFont(sheet, c, bold=true, color = sheet[c] ? "FF548235" : "FFC00000") + end + end +end +function redgreenminmax(sheet, rng) # Fill light green / light red the cell with maximum / minimum value + mn, mx = extrema(x for x in sheet[rng] if !ismissing(x)) + for c in rng + if !ismissing(sheet[c]) + if sheet[c] == mx + XLSX.setFill(sheet, c; pattern = "solid", fgColor = "FFC6EFCE") + elseif sheet[c] == mn + XLSX.setFill(sheet, c; pattern = "solid", fgColor = "FFFFC7CE") + end + end + end +end + +blankmissing(sheet, XLSX.CellRange("B3:L6")) +trueorfalse(sheet, XLSX.CellRange("B2:L6")) +redgreenminmax(sheet, XLSX.CellRange("D3:D6")) +redgreenminmax(sheet, XLSX.CellRange("J3:J6")) +redgreenminmax(sheet, XLSX.CellRange("K3:K6")) + +# Number formats +XLSX.setFormat(sheet, "J3"; format = "Percentage") +XLSX.setFormat(sheet, "J4"; format = "Currency") +XLSX.setFormat(sheet, "J6"; format = "Number") +XLSX.setFormat(sheet, "K3"; format = "0.0") +XLSX.setFormat(sheet, "K4"; format = "0.000") +XLSX.setFormat(sheet, "K6"; format = "0.0000") + +# Save to an actual XLSX file +XLSX.writexlsx("mytable_formatted.xlsx", xf, overwrite=true) +``` + +## Creating a formatted form + +There is a file, customXml.xlsx, in the \data folder of this project that looks like a template +file - a form to be filled in. The code below creates this form from scratch and makes +extensive use of vector indexing for rows and columns and of non-contiguous ranges: + +```julia +using XLSX + +f = XLSX.newxlsx() +s = f[1] +s["A1:K116"] = "" + +s["B2"] = "Catalogue Entry Form" + +s["B5"] = "User Data" +s["B7"] = "Recipient ID" +s["B9"] = "Recipient Name" +s["B11"] = "Address 1" +s["B12"] = "Address 2" +s["B13"] = "Address 3" +s["B14"] = "Town" +s["B16"] = "Postcode" +s["B18"] = "Ward" +s["B20"] = "Region" +s["H18"] = "Local Authority" +s["H20"] = "UK Constituency" +s["B22"] = "GrantID" +s["D22"] = "Grant Date" +s["F22"] = "Grant Amount" +s["H22"] = "Grant Title" +s["J22"] = "Distributor" +s["B32"] = "Distributor" + +s["B30"] = "Creator" +s["B34"] = "Created by" +s["D36"] = "Email" +s["H36"] = "Phone" +s["B38"] = "Grant Manager" +s["D40"] = "Email" +s["H40"] = "Phone number" + +s["B43"] = "Summary" +s["B45"] = "Summary ID" +s["H45"] = "Date Created" +s["B47"] = "Summary Name" +s["B49"] = "Headline" +s["B51"] = "Short Description" +s["B55"] = "Long Description" +s["B62"] = "Quote 1" +s["D65"] = "Quote Attribution" +s["H65"] = "Quote Date" +s["B67"] = "Quote 2" +s["D70"] = "Quote Attribution" +s["H70"] = "Quote Date" +s["B72"] = "Keywords" +s["B74"] = "Website" +s["B76"] = "Social media handles" +s["D76"] = "Twitter" +s["D78"] = "Facebook" +s["D80"] = "Instagram" +s["H76"] = "LinkedIn" +s["H78"] = "TikTok" +s["H80"] = "YouTube" +s["B82"] = "Image 1 filename" +s["D84"] = "Alt-Text" +s["D86"] = "Image Attribution" +s["D88"] = "Image Date" +s["D90"] = "Confirm permission to use image" +s["B92"] = "Image 2 filename" +s["D94"] = "Alt-Text" +s["D96"] = "Image Attribution" +s["D98"] = "Image Date" +s["D100"] = "Confirm permission to use image" + +s["B103"] = "Penultimate category" +s["B105"] = "Competition Details" +s["D105"] = "Last year of entry" +s["D107"] = "Year of last win" +s["H105"] = "Categories of entry" +s["H107"] = "Categories of win" + +s["B110"] = "Last category" +s["B112"] = "Use for Comms" +s["D112"] = "Comms Priority" +s["F112"] = "Comms End Date" + +XLSX.setColumnWidth(s, 1:2:11; width=1.3) +XLSX.setColumnWidth(s, 2:2:10; width=18) +XLSX.setRowHeight(s, :; height=15) +XLSX.setRowHeight(s, [3, 4, 19, 28, 29, 35, 39, 41, 42, 64, 69, 77, 79, 83, 85, 87, 89, 93, 95, 97, 99, 101, 102, 106, 108, 109, 116]; height=5.5) +XLSX.setRowHeight(s, [5, 30, 43, 103, 110]; height=18) +XLSX.setRowHeight(s, 2; height=23) + +XLSX.setFont(s, "B2"; size=18, bold=true) +XLSX.setUniformFont(s, [5, 30, 43, 103, 110], 2; size=14, bold=true) + +XLSX.setUniformFill(s, [1, 2, 3, 4, 5, 6, 8, 10, 15, 17, 19, 21, 28, 29, 30, 31, 33, 35, 37, 39, 41, 42, 43, 44, 46, 48, 50, 52, 53, 54, 56, 57, 58, 59, 60, 61, 63, 64, 66, 68, 69, 71, 73, 75, 77, 79, 81, 83, 85, 87, 89, 91, 93, 95, 97, 99, 101, 102, 103, 104, 106, 108, 109, 110, 111, 115, 116], :; pattern="solid", fgColor="lightgrey") +XLSX.setUniformFill(s, :, [1, 3, 5, 7, 9, 11]; pattern="solid", fgColor="lightgrey") +XLSX.setFill(s, "F7,H7,J7,J9,H11:J16,F14,F16:F20,H32:J32,B36,B40,F45,J47:J49,B65,B70,B78:B80,B84:B90,B94:B100,H88:J90,H98:J100,B107,F114,H112:J115"; pattern="solid", fgColor="lightgrey") +XLSX.setFill(s, "D18,D20,J18,J20,D45"; pattern="solid", fgColor="darkgrey") +XLSX.setFill(s, "B112:B114,D112:D115"; pattern="solid", fgColor="white") +XLSX.setFill(s, "E90,E100,D115"; pattern="none") + +XLSX.mergeCells(s, "D9:H9") +XLSX.mergeCells(s, "D11:G11,D12:G12,D13:G13") +XLSX.mergeCells(s, "D32:F32,D34:J34,D38:J38") +XLSX.mergeCells(s, "D47:H47,D49:H49") +XLSX.mergeCells(s, "D51:J53,D55:J60") +XLSX.mergeCells(s, "D62:J63,D67:J68") +XLSX.mergeCells(s, "D72:J72,D74:J74") +XLSX.mergeCells(s, "D82:J82,F84:J84,F86:J86") +XLSX.mergeCells(s, "D92:J92,F94:J94,F96:J96") + +XLSX.setAlignment(s, "D51:J53,D55:J60,D62:J63,D67:J68"; vertical="top", wrapText=true) + +XLSX.setBorder(s, "A1:K3"; outside = ["style" => "medium", "color" => "black"]) +XLSX.setBorder(s, "A4:K28"; outside = ["style" => "medium", "color" => "black"]) +XLSX.setBorder(s, "A29:K41"; outside = ["style" => "medium", "color" => "black"]) +XLSX.setBorder(s, "A42:K101"; outside = ["style" => "medium", "color" => "black"]) +XLSX.setBorder(s, "A102:K108"; outside = ["style" => "medium", "color" => "black"]) +XLSX.setBorder(s, "A109:K116"; outside = ["style" => "medium", "color" => "black"]) + +XLSX.setBorder(s, "B7:D7,B9:H9"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B11:G13,B14:D14,B16:D16"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B18:D18,B20:D20,H18:J18,H20:J20"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setUniformBorder(s, "B22:J27"; allsides = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B32:F32"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B34:C34,D34:J34,D36:F36,H36:J36"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B38:C38,D38:J38,D40:F40,H40:J40"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D34:J36,D38:J40"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B45:D45,H45:J45"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B47:H47,B49:H49"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B51:C51,B55:C55"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D51:J53,D55:J60"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B62:C62,D65:F65,H65:J65"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B67:C67,D70:F70,H70:J70"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D62:J63,D67:J68"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D62:J65,D67:J70"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B72:J72,B74:J74"; allsides = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B76:F76,H76:J76,D78:F78,H78:J78,D80:F80,H80:J80"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D76:J80"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B82:J82,D84:J84,D86:J86,D88:F88,D90:F90"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D82:J90"; outside = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B92:J92,D94:J94,D96:J96,D98:F98,D100:F100"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D92:J100"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "B105:F105,H105:J105,D107:F107,H107:J107"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "D105:J107"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.setBorder(s, "F112,F113"; allsides = ["style" => "thin", "color" => "black"]) +XLSX.setBorder(s, "B112:B114,D112:D115"; outside = ["style" => "thin", "color" => "black"]) + +XLSX.writexlsx("myNewTemplate.xlsx", f, overwrite=true) +``` \ No newline at end of file diff --git a/docs/src/formatting/mergedCells.md b/docs/src/formatting/mergedCells.md new file mode 100644 index 00000000..d1e86e19 --- /dev/null +++ b/docs/src/formatting/mergedCells.md @@ -0,0 +1,168 @@ + +# Working with merged cells + +Worksheets may contain merged cells. XLSX.jl provides functions to identify the merged cells in a worksheet, +to determine if a cell is part of a merged range and to determine the value of a merged cell range from any +cell in that range. + +```julia + +julia> using XLSX + +julia> f=XLSX.opentemplate("customXml.xlsx") +XLSXFile("customXml.xlsx") containing 2 Worksheets + sheetname size range +------------------------------------------------- + Mock-up 116x11 A1:K116 + Document History 17x3 A1:C17 + +julia> XLSX.getMergedCells(f[1]) +25-element Vector{XLSX.CellRange}: + D49:H49 + D72:J72 + F94:J94 + F96:J96 + F84:J84 + F86:J86 + D62:J63 + D51:J53 + D55:J60 + D92:J92 + D82:J82 + D74:J74 + D67:J68 + D47:H47 + D9:H9 + D11:G11 + D12:G12 + D14:E14 + D16:E16 + D32:F32 + D38:J38 + D34:J34 + D18:E18 + D20:E20 + D13:G13 + +julia> XLSX.isMergedCell(f[1], "D13") +true + +julia> XLSX.isMergedCell(f[1], "H13") +false + +julia> XLSX.getMergedBaseCell(f[1], "E18") # E18 is a merged cell. The base cell in the merged range is D18. +(baseCell = D18, baseValue = "Here") # The base cell in the merged range is D18 and it's value is "Here". +``` + +It is also possible to create new merged cells: + +```julia + +julia> XLSX.isMergedCell(f[1], "F5") +false + +julia> XLSX.isMergedCell(f[1], "J8") +false + +julia> XLSX.mergeCells(s, "F5:J8") + +julia> s["F5"] = pi +π = 3.1415926535897... + +julia> XLSX.isMergedCell(f[1], "J8") +true + +julia> XLSX.isMergedCell(f[1], "F5") +true + +julia> XLSX.getMergedBaseCell(f[1], "J8") +(baseCell = F5, baseValue = 3.141592653589793) +``` + +It is not allowed to create new merged cells that overlap at all with any existing merged cells. + +!!! warning + + It is possible to write into any merged cell using `XLSX.jl`, even those that are not the + base cell of the merged range. This is illustrated below: + + ```julia + + julia> using XLSX + + 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> s["A1:A3"]=5 + 5 + ``` + + This produces the simple sheet shown. + + ![image|320x500](../images/simple-unmerged.png) + + Merging the three cells `A1:A3` sets the cells `A2` and `A3` to missing just as Excel does. + + ``` + julia> s["A1"] + 5 + + julia> s["A2"] + 5 + + julia> s["A3"] + 5 + + julia> XLSX.mergeCells(s, "A1:A3") + 0 + + julia> s["A1"] + 5 + + julia> s["A2"] + missing + + julia> s["A3"] + missing + ``` + + ![image|320x500](../images/after-merge.png) + + However, even after the merge, it is possible to explicitly write into the merged cells. + These written values will not be visible in Excel but can still be accessed by reference. + + ``` + julia> s["A2"]="text here now" + "text here now" + + julia> s["A1"] + 5 + + julia> s["A2"] + "text here now" + + julia> s["A3"] + missing + + julia> XLSX.getMergedBaseCell(s, "A2") + (baseCell = A1, baseValue = 5) + + ``` + + The cell `A2` remains merged, and this is how Excel displays it. The assigned cell value + won't be visible in Excel, but it can be referenced in a formula as shown here, where + cell `B2` references cell `A2` in its formula ("=A2"): + + ![image|320x500](../images/Written-to-merged-cell.png) + + Assigning values to cells in a merged range like this is prevented in Excel itself by the UI + although it is possible using VBA. There is currently no check to prevent this in `XLSX.jl`. + See [#241](https://github.com/felipenoris/XLSX.jl/issues/241) + diff --git a/docs/src/images/Written-to-merged-cell.png b/docs/src/images/Written-to-merged-cell.png new file mode 100644 index 00000000..6490039a Binary files /dev/null and b/docs/src/images/Written-to-merged-cell.png differ diff --git a/docs/src/images/after-merge.png b/docs/src/images/after-merge.png new file mode 100644 index 00000000..b56b8612 Binary files /dev/null and b/docs/src/images/after-merge.png differ diff --git a/docs/src/images/allIcons.png b/docs/src/images/allIcons.png new file mode 100644 index 00000000..0aadd892 Binary files /dev/null and b/docs/src/images/allIcons.png differ diff --git a/docs/src/images/averageComparison.png b/docs/src/images/averageComparison.png new file mode 100644 index 00000000..ef3ca499 Binary files /dev/null and b/docs/src/images/averageComparison.png differ diff --git a/docs/src/images/axisOptions.png b/docs/src/images/axisOptions.png new file mode 100644 index 00000000..1f4f4b5b Binary files /dev/null and b/docs/src/images/axisOptions.png differ diff --git a/docs/src/images/basicIconSet.png b/docs/src/images/basicIconSet.png new file mode 100644 index 00000000..f5fc273d Binary files /dev/null and b/docs/src/images/basicIconSet.png differ diff --git a/docs/src/images/blank.png b/docs/src/images/blank.png new file mode 100644 index 00000000..89ee7cae Binary files /dev/null and b/docs/src/images/blank.png differ diff --git a/docs/src/images/borderAndGrad.png b/docs/src/images/borderAndGrad.png new file mode 100644 index 00000000..f4235949 Binary files /dev/null and b/docs/src/images/borderAndGrad.png differ diff --git a/docs/src/images/caseSensitiveComparison.png b/docs/src/images/caseSensitiveComparison.png new file mode 100644 index 00000000..0bc3a267 Binary files /dev/null and b/docs/src/images/caseSensitiveComparison.png differ diff --git a/docs/src/images/cell1.png b/docs/src/images/cell1.png new file mode 100644 index 00000000..7d206bfa Binary files /dev/null and b/docs/src/images/cell1.png differ diff --git a/docs/src/images/cell2.png b/docs/src/images/cell2.png new file mode 100644 index 00000000..7d35ea4b Binary files /dev/null and b/docs/src/images/cell2.png differ diff --git a/docs/src/images/cellvalue-formats.png b/docs/src/images/cellvalue-formats.png new file mode 100644 index 00000000..f34cd791 Binary files /dev/null and b/docs/src/images/cellvalue-formats.png differ diff --git a/docs/src/images/colorScaleOptions.png b/docs/src/images/colorScaleOptions.png new file mode 100644 index 00000000..70db1a1b Binary files /dev/null and b/docs/src/images/colorScaleOptions.png differ diff --git a/docs/src/images/colorScales.png b/docs/src/images/colorScales.png new file mode 100644 index 00000000..5123a78f Binary files /dev/null and b/docs/src/images/colorScales.png differ diff --git a/docs/src/images/containsText.png b/docs/src/images/containsText.png new file mode 100644 index 00000000..ab103a2e Binary files /dev/null and b/docs/src/images/containsText.png differ diff --git a/docs/src/images/custom-cellvalue-example.png b/docs/src/images/custom-cellvalue-example.png new file mode 100644 index 00000000..b4df58db Binary files /dev/null and b/docs/src/images/custom-cellvalue-example.png differ diff --git a/docs/src/images/custom-colorscale.png b/docs/src/images/custom-colorscale.png new file mode 100644 index 00000000..e8fb2042 Binary files /dev/null and b/docs/src/images/custom-colorscale.png differ diff --git a/docs/src/images/custom-formats.png b/docs/src/images/custom-formats.png new file mode 100644 index 00000000..3807698e Binary files /dev/null and b/docs/src/images/custom-formats.png differ diff --git a/docs/src/images/customColors.png b/docs/src/images/customColors.png new file mode 100644 index 00000000..13b60cf8 Binary files /dev/null and b/docs/src/images/customColors.png differ diff --git a/docs/src/images/dataBarOptions.png b/docs/src/images/dataBarOptions.png new file mode 100644 index 00000000..a06fc418 Binary files /dev/null and b/docs/src/images/dataBarOptions.png differ diff --git a/docs/src/images/dataBars.png b/docs/src/images/dataBars.png new file mode 100644 index 00000000..230d5dc0 Binary files /dev/null and b/docs/src/images/dataBars.png differ diff --git a/docs/src/images/errorBlank.png b/docs/src/images/errorBlank.png new file mode 100644 index 00000000..24d4dbd5 Binary files /dev/null and b/docs/src/images/errorBlank.png differ diff --git a/docs/src/images/expression.png b/docs/src/images/expression.png new file mode 100644 index 00000000..8d23fb6a Binary files /dev/null and b/docs/src/images/expression.png differ diff --git a/docs/src/images/formatted-table.png b/docs/src/images/formatted-table.png new file mode 100644 index 00000000..fbd0edf3 Binary files /dev/null and b/docs/src/images/formatted-table.png differ diff --git a/docs/src/images/iconKey.png b/docs/src/images/iconKey.png new file mode 100644 index 00000000..29e73f66 Binary files /dev/null and b/docs/src/images/iconKey.png differ diff --git a/docs/src/images/iconSetOptions.png b/docs/src/images/iconSetOptions.png new file mode 100644 index 00000000..d6aabd24 Binary files /dev/null and b/docs/src/images/iconSetOptions.png differ diff --git a/docs/src/images/iconSets.png b/docs/src/images/iconSets.png new file mode 100644 index 00000000..0971748e Binary files /dev/null and b/docs/src/images/iconSets.png differ diff --git a/docs/src/images/minmaxDataBar.png b/docs/src/images/minmaxDataBar.png new file mode 100644 index 00000000..87b511bf Binary files /dev/null and b/docs/src/images/minmaxDataBar.png differ diff --git a/docs/src/images/moreMixed.png b/docs/src/images/moreMixed.png new file mode 100644 index 00000000..ffd6562b Binary files /dev/null and b/docs/src/images/moreMixed.png differ diff --git a/docs/src/images/multicell.png b/docs/src/images/multicell.png new file mode 100644 index 00000000..6c952f3f Binary files /dev/null and b/docs/src/images/multicell.png differ diff --git a/docs/src/images/multicell2.png b/docs/src/images/multicell2.png new file mode 100644 index 00000000..d6013b72 Binary files /dev/null and b/docs/src/images/multicell2.png differ diff --git a/docs/src/images/multicell3.png b/docs/src/images/multicell3.png new file mode 100644 index 00000000..5639e68b Binary files /dev/null and b/docs/src/images/multicell3.png differ diff --git a/docs/src/images/multicell4.png b/docs/src/images/multicell4.png new file mode 100644 index 00000000..990b1af8 Binary files /dev/null and b/docs/src/images/multicell4.png differ diff --git a/docs/src/images/multiple-cellvalue-example.png b/docs/src/images/multiple-cellvalue-example.png new file mode 100644 index 00000000..85da5cb2 Binary files /dev/null and b/docs/src/images/multiple-cellvalue-example.png differ diff --git a/docs/src/images/negAndAxisOptions.png b/docs/src/images/negAndAxisOptions.png new file mode 100644 index 00000000..47b27a84 Binary files /dev/null and b/docs/src/images/negAndAxisOptions.png differ diff --git a/docs/src/images/newValIconSet.png b/docs/src/images/newValIconSet.png new file mode 100644 index 00000000..80f89311 Binary files /dev/null and b/docs/src/images/newValIconSet.png differ diff --git a/docs/src/images/no-stop-if-true.png b/docs/src/images/no-stop-if-true.png new file mode 100644 index 00000000..0c68da28 Binary files /dev/null and b/docs/src/images/no-stop-if-true.png differ diff --git a/docs/src/images/relative-CellRef.png b/docs/src/images/relative-CellRef.png new file mode 100644 index 00000000..fa6e9a0d Binary files /dev/null and b/docs/src/images/relative-CellRef.png differ diff --git a/docs/src/images/relativeComparison.png b/docs/src/images/relativeComparison.png new file mode 100644 index 00000000..f23ea8c8 Binary files /dev/null and b/docs/src/images/relativeComparison.png differ diff --git a/docs/src/images/showValIcons.png b/docs/src/images/showValIcons.png new file mode 100644 index 00000000..b7988618 Binary files /dev/null and b/docs/src/images/showValIcons.png differ diff --git a/docs/src/images/simple-cellvalue-example.png b/docs/src/images/simple-cellvalue-example.png new file mode 100644 index 00000000..5f1c6d57 Binary files /dev/null and b/docs/src/images/simple-cellvalue-example.png differ diff --git a/docs/src/images/simple-unmerged.png b/docs/src/images/simple-unmerged.png new file mode 100644 index 00000000..aab765e4 Binary files /dev/null and b/docs/src/images/simple-unmerged.png differ diff --git a/docs/src/images/simpleComparison.png b/docs/src/images/simpleComparison.png new file mode 100644 index 00000000..1c5ca1de Binary files /dev/null and b/docs/src/images/simpleComparison.png differ diff --git a/docs/src/images/simpleDataBar.png b/docs/src/images/simpleDataBar.png new file mode 100644 index 00000000..9f64fd8b Binary files /dev/null and b/docs/src/images/simpleDataBar.png differ diff --git a/docs/src/images/stop-if-true.png b/docs/src/images/stop-if-true.png new file mode 100644 index 00000000..370143c6 Binary files /dev/null and b/docs/src/images/stop-if-true.png differ diff --git a/docs/src/images/timePeriod-9thMay2025.png b/docs/src/images/timePeriod-9thMay2025.png new file mode 100644 index 00000000..a6ae35d1 Binary files /dev/null and b/docs/src/images/timePeriod-9thMay2025.png differ diff --git a/docs/src/images/topN.png b/docs/src/images/topN.png new file mode 100644 index 00000000..14d0e94a Binary files /dev/null and b/docs/src/images/topN.png differ diff --git a/docs/src/images/unformatted-table.png b/docs/src/images/unformatted-table.png new file mode 100644 index 00000000..335554b6 Binary files /dev/null and b/docs/src/images/unformatted-table.png differ diff --git a/docs/src/index.md b/docs/src/index.md index c1358408..2d131cf1 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -10,13 +10,13 @@ Internally, an Excel XLSX file is just a [Zip](https://en.wikipedia.org/wiki/Zip_(file_format)) file with a set of [XML](https://en.wikipedia.org/wiki/XML) files inside. The formats for these XML files are described in -the [Standard ECMA-376](https://www.ecma-international.org/publications/standards/Ecma-376.htm). +the [Standard ECMA-376](https://ecma-international.org/publications-and-standards/standards/ecma-376/). This package follows the EMCA-376 to parse and generate XLSX files. ## Requirements -* Julia v1.6 +* Julia v1.8 * Linux, macOS or Windows. @@ -63,6 +63,8 @@ and send a Pull Request. ## Alternative Packages +* [LibXLSXWriter.jl](https://github.com/jaakkor2/LibXLSXWriter.jl) + * [ExcelFiles.jl](https://github.com/davidanthoff/ExcelFiles.jl) * [ExcelReaders.jl](https://github.com/davidanthoff/ExcelReaders.jl) diff --git a/docs/src/migration.md b/docs/src/migration.md index d0f0921f..aeffb3d1 100644 --- a/docs/src/migration.md +++ b/docs/src/migration.md @@ -1,4 +1,11 @@ -# Migration Guides +# Migration Guide + +!!! note + + This migration guide was introduced to describe migrating from a pre v0.8 version + of XLSX.jl to v0.8. + + It is a largely historic document now. ## Migrating Legacy Code to v0.8 diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md index f50c135e..4aa0edda 100644 --- a/docs/src/tutorial.md +++ b/docs/src/tutorial.md @@ -102,18 +102,31 @@ that implements [`Tables.jl`](https://github.com/JuliaData/Tables.jl) interface. You can use it to create a `DataFrame` from [DataFrames.jl](https://github.com/JuliaData/DataFrames.jl). Check the docstring for `gettable` method for more advanced options. -There's also a helper method [`XLSX.readtable`](@ref) to read from file directly, as shown in the following example. +There are also two helper methods [`XLSX.readtable`](@ref) and [`XLSX.readto`](@ref) to read from file +directly, as shown in the following examples. ```julia julia> using DataFrames, XLSX -julia> df = DataFrame(XLSX.readtable("myfile.xlsx", "mysheet")) -3×2 DataFrames.DataFrame -│ Row │ HeaderA │ HeaderB │ -├─────┼─────────┼──────────┤ -│ 1 │ 1 │ "first" │ -│ 2 │ 2 │ "second" │ -│ 3 │ 3 │ "third" │ +julia> df = DataFrame(XLSX.readtable("myfile.xlsx", "mysheet")) # Returns a `Tables.jl` table that `DataFrame` can accept +3×2 DataFrame + Row │ HeaderA HeaderB + │ Int64 String +─────┼────────────────── + 1 │ 1 first + 2 │ 2 second + 3 │ 3 third + +julia> df = XLSX.readto("myfile.xlsx", "mysheet", DataFrame) # Returns a `DataFrame` directly. +3×2 DataFrame + Row │ HeaderA HeaderB + │ Int64 String +─────┼────────────────── + 1 │ 1 first + 2 │ 2 second + 3 │ 3 third + + ``` ## Reading Cells as a Julia Matrix @@ -185,8 +198,10 @@ The method `XLSX.openxlsx` has a `enable_cache` option to control worksheet cell Cache is enabled by default, so if you read a worksheet cell twice it will use the cached value instead of reading from disk in the second time. -If `enable_cache=false`, worksheet cells will always be read from disk. -This is useful when you want to read a spreadsheet that doesn't fit into memory. +If `enable_cache=false`, worksheet cells will always be read from disk. In addition, if `enable_cache=false` +and `openxlsx` is used with do-syntax, the xlsx file itself will be opened usning `Mmap.mmap` so that the +zip archives themselves are not read into memory. This is useful when you want to read a spreadsheet that +doesn't fit into memory. The following example shows how you would read worksheet cells, one row at a time, where `myfile.xlsx` is a spreadsheet that doesn't fit into memory. @@ -258,7 +273,7 @@ end ### Edit Existing Files Opening a file in `read-write` mode with `XLSX.openxlsx` will open an existing Excel file for editing. -This will preserve existing data in the original file. +This will preserve existing data and formatting in the original file. ```julia XLSX.openxlsx("my_new_file.xlsx", mode="rw") do xf diff --git a/src/XLSX.jl b/src/XLSX.jl index 59309249..3c3d6fed 100644 --- a/src/XLSX.jl +++ b/src/XLSX.jl @@ -7,7 +7,14 @@ import Printf.@printf import ZipArchives import XML import Tables +import Unicode +import Colors import Base.convert +import UUIDs +import Mmap + +import PrecompileTools as PCT # this is a small dependency. PCT avoids namespace conflict with ZipArchives (I think) + const SPREADSHEET_NAMESPACE_XPATH_ARG = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" const EXCEL_MAX_COLS = 16_384 # total columns supported by Excel per sheet @@ -26,7 +33,33 @@ include("workbook.jl") include("worksheet.jl") include("cell.jl") include("styles.jl") +include("cellformat-helpers.jl") include("cellformats.jl") +include("conditional-format-helpers.jl") # must load before conditional-formats.jl +include("conditional-formats.jl") include("write.jl") +PCT.@setup_workload begin + # 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(_relocatable_data_path(), "blank.xlsx"), mode="rw") + f[1]["A1:Z26"] = "hello World" + openxlsx(s, mode="w") do xf + xf[1][1:26, 1:26] = pi + end + _ = XLSX.readtable(seekstart(s), 1, "A:Z") + f= openxlsx(seekstart(s), mode="rw") + 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) + end +end + end # module XLSX diff --git a/src/cell.jl b/src/cell.jl index 113b6907..24a52981 100644 --- a/src/cell.jl +++ b/src/cell.jl @@ -38,7 +38,9 @@ function Cell(c::XML.LazyNode) # t (Cell Data Type) is an enumeration representing the cell's data type. The possible values for this attribute are defined by the ST_CellType simple type (§18.18.11). # s (Style Index) is the index of this cell's style. Style records are stored in the Styles Part. - @assert XML.tag(c) == "c" "`Cell` Expects a `c` (cell) XML node." + if XML.tag(c) != "c" + throw(XLSXError("`Cell` Expects a `c` (cell) XML node.")) + end a = XML.attributes(c) # Dict of cell attributes @@ -75,7 +77,7 @@ function Cell(c::XML.LazyNode) elseif length(c) == 1 v= XML.value(c[1]) else - error("Too amny children in `t` node. Expected >=1, found: $(length(c))") + throw(XLSXError("Too amny children in `t` node. Expected >=1, found: $(length(c))")) end end end @@ -85,18 +87,18 @@ function Cell(c::XML.LazyNode) # we should have only one v element if found_v - error("Unsupported: cell $(ref) has more than 1 `v` elements.") + throw(XLSXError("Unsupported: cell $(ref) has more than 1 `v` elements.")) else found_v = true end - v = XML.unescape(XML.simple_value(c_child_element)) + v = length(c_child_element)==0 ? "" : XML.unescape(XML.simple_value(c_child_element)) elseif XML.tag(c_child_element) == "f" # we should have only one f element if found_f - error("Unsupported: cell $(ref) has more than 1 `f` elements.") + throw(XLSXError("Unsupported: cell $(ref) has more than 1 `f` elements.")) else found_f = true end @@ -111,17 +113,17 @@ end function parse_formula_from_element(c_child_element) :: AbstractFormula if XML.tag(c_child_element) != "f" - error("Expected nodename `f`. Found: `$(XML.tag(c_child_element))`") + throw(XLSXError("Expected nodename `f`. Found: `$(XML.tag(c_child_element))`")) end if XML.is_simple(c_child_element) - formula_string = XML.simple_value(c_child_element) + formula_string = XML.unescape(XML.simple_value(c_child_element)) else fs = [x for x in XML.children(c_child_element) if XML.nodetype(x) == XML.Text] if length(fs)==0 formula_string="" else - formula_string=XML.value(fs[1]) + formula_string=XML.unescape(XML.value(fs[1])) end end @@ -131,7 +133,7 @@ function parse_formula_from_element(c_child_element) :: AbstractFormula if haskey(a, "ref") && haskey(a, "t") && a["t"] == "shared" - haskey(a, "si") || error("Expected shared formula to have an index. `si` attribute is missing: $c_child_element") + haskey(a, "si") || throw(XLSXError("Expected shared formula to have an index. `si` attribute is missing: $c_child_element")) return ReferencedFormula( formula_string, @@ -141,7 +143,7 @@ function parse_formula_from_element(c_child_element) :: AbstractFormula elseif haskey(a, "t") && a["t"] == "shared" - haskey(a, "si") || error("Expected shared formula to have an index. `si` attribute is missing: $c_child_element") + haskey(a, "si") || throw(XLSXError("Expected shared formula to have an index. `si` attribute is missing: $c_child_element")) return FormulaReference( parse(Int, a["si"]), @@ -238,7 +240,7 @@ function getdata(ws::Worksheet, cell::Cell) :: CellValueType elseif cell.value == "1" return true else - error("Unknown boolean value: $(cell.value).") + throw(XLSXError("Unknown boolean value: $(cell.value).")) end elseif cell.datatype == "str" # plain string @@ -249,17 +251,21 @@ function getdata(ws::Worksheet, cell::Cell) :: CellValueType end end - error("Couldn't parse data for $cell.") + throw(XLSXError("Couldn't parse data for $cell.")) end function _celldata_datetime(v::AbstractString, _is_date_1904::Bool) :: Union{Dates.DateTime, Dates.Date, Dates.Time} # does not allow empty string - @assert !isempty(v) "Cannot convert an empty string into a datetime value." + if isempty(v) + throw(XLSXError("Cannot convert an empty string into a datetime value.")) + end if occursin(".", v) || v == "0" time_value = parse(Float64, v) - @assert time_value >= 0 + if time_value < 0 + throw(XLSXError("Cannot have a datetime value < 0. Got $time_value")) + end if time_value <= 1 # Time @@ -279,8 +285,11 @@ end # To represent Time, Excel uses the decimal part # of a floating point number. `1` equals one day. function excel_value_to_time(x::Float64) :: Dates.Time - @assert x >= 0 && x <= 1 - return Dates.Time(Dates.Nanosecond(round(Int, x * 86400) * 1E9 )) + if x >= 0 && x <= 1 + return Dates.Time(Dates.Nanosecond(round(Int, x * 86400) * 1E9 )) + else + throw(XLSXError("A value must be between 0 and 1 to be converted to time. Got $x")) + end end time_to_excel_value(x::Dates.Time) :: Float64 = Dates.value(x) / ( 86400 * 1E9 ) @@ -307,7 +316,9 @@ end # The integer part represents the Date. # See also XLSX.isdate1904. function excel_value_to_datetime(x::Float64, _is_date_1904::Bool) :: Dates.DateTime - @assert x >= 0 + if x < 0 + throw(XLSXError("Cannot have a datetime value < 0. Got $x")) + end local dt::Dates.Date local hr::Dates.Time diff --git a/src/cellformat-helpers.jl b/src/cellformat-helpers.jl new file mode 100644 index 00000000..d29bf600 --- /dev/null +++ b/src/cellformat-helpers.jl @@ -0,0 +1,978 @@ + +const font_tags = ["b", "i", "u", "strike", "outline", "shadow", "condense", "extend", "sz", "color", "name", "scheme"] +const border_tags = ["left", "right", "top", "bottom", "diagonal"] +const fill_tags = ["patternFill"] +const builtinFormats = Dict( + "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" => "@" +) +const builtinFormatNames = Dict( + "General" => 0, + "Number" => 2, + "Currency" => 7, + "Percentage" => 9, + "ShortDate" => 14, + "LongDate" => 15, + "Time" => 21, + "Scientific" => 48 +) +const floatformats = r""" +\.[0#?]| +[0#?]e[+-]?[0#?]| +[0#?]/[0#?]| +% +"""ix + +# +# -- A bunch of helper functions ... +# + +function copynode(o::XML.Node) + n = XML.Node(o.nodetype, o.tag, o.attributes, o.value, isnothing(o.children) ? nothing : [copynode(x) for x in o.children]) +# n = deepcopy(o) + return n +end +function do_sheet_names_match(ws::Worksheet, rng::T) where {T<:Union{SheetCellRef,AbstractSheetCellRange}} + if ws.name == rng.sheet + return true + else + throw(XLSXError("Worksheet `$(ws.name)` does not match sheet in cell reference: `$(rng.sheet)`")) + end +end +function buildNode(tag::String, attributes::Dict{String,Union{Nothing,Dict{String,String}}})::XML.Node + if tag == "font" + attribute_tags = font_tags + elseif tag == "border" + attribute_tags = border_tags + elseif tag == "fill" + attribute_tags = fill_tags + else + throw(XLSXError("Unknown tag: $tag")) + end + new_node = XML.Element(tag) + for a in attribute_tags # Use this as a device to keep ordering constant for Excel + if tag == "font" + if haskey(attributes, a) + if isnothing(attributes[a]) + cnode = XML.Element(a) + else + cnode = XML.Node(XML.Element, a, XML.OrderedDict{String,String}(), nothing, tag ∈ ["border", "fill"] ? Vector{XML.Node}() : nothing) + for (k, v) in attributes[a] + cnode[k] = v + end + end + push!(new_node, cnode) + end + elseif tag == "border" + if haskey(attributes, a) + if isnothing(attributes[a]) + cnode = XML.Element(a) + else + cnode = XML.Node(XML.Element, a, XML.OrderedDict{String,String}(), nothing, tag ∈ ["border", "fill"] ? Vector{XML.Node}() : nothing) + color = XML.Element("color") + for (k, v) in attributes[a] + if k == "style" && v != "none" + cnode[k] = v + elseif k == "direction" + if v in ["up", "both"] + new_node["diagonalUp"] = "1" + end + if v in ["down", "both"] + new_node["diagonalDown"] = "1" + end + else + color[k] = v + end + end + if length(XML.attributes(color)) > 0 # Don't push an empty color. + push!(cnode, color) + end + end + push!(new_node, cnode) + end + elseif tag == "fill" + if haskey(attributes, a) + if isnothing(attributes[a]) + cnode = XML.Element(a) + else + cnode = XML.Node(XML.Element, a, XML.OrderedDict{String,String}(), nothing, tag ∈ ["border", "fill"] ? Vector{XML.Node}() : nothing) + patternfill = XML.Element("patternFill") + fgcolor = XML.Element("fgColor") + bgcolor = XML.Element("bgColor") + for (k, v) in attributes[a] + if k == "patternType" + patternfill[k] = v + elseif first(k, 2) == "fg" + fgcolor[k[3:end]] = v + elseif first(k, 2) == "bg" + bgcolor[k[3:end]] = v + end + end + if !haskey(patternfill, "patternType") + throw(XLSXError("No `patternType` attribute found.")) + end + length(XML.attributes(fgcolor)) > 0 && push!(patternfill, fgcolor) + length(XML.attributes(bgcolor)) > 0 && push!(patternfill, bgcolor) + end + push!(new_node, patternfill) + end + #else + end + end + return new_node +end +function isInDim(ws::Worksheet, dim::CellRange, rng::CellRange) + if !issubset(rng, dim) + throw(XLSXError("Cell range $rng is out of bounds. Worksheet `$(ws.name)` only has dimension `$dim`.")) + end + return true +end +function isInDim(ws::Worksheet, dim::CellRange, row, col) + if maximum(row) > dim.stop.row_number || minimum(row) < dim.start.row_number + throw(XLSXError("Row range $row is out of bounds. Worksheet `$(ws.name)` only has dimension `$dim`.")) + end + if maximum(col) > dim.stop.column_number || minimum(col) < dim.start.column_number + throw(XLSXError("Column range $col is out of bounds. Worksheet `$(ws.name)` only has dimension `$dim`.")) + end + return true +end +function get_new_formatId(wb::Workbook, format::String)::Int + if haskey(builtinFormatNames, uppercasefirst(format)) # User specified a format by name + return builtinFormatNames[format] + else # user specified a format code + code = lowercase(format) + code = remove_formatting(code) + if !occursin(floatformats, code) && !any(map(x -> occursin(x, code), DATETIME_CODES)) # Only a very weak test! + 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.children(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=XML.escape(format) + ) + + return styles_add_cell_attribute(wb, format_node, "numFmts") + PREDEFINED_NUMFMT_COUNT + end + end +end +function update_template_xf(ws::Worksheet, existing_style::CellDataFormat, attributes::Vector{String}, vals::Vector{String})::CellDataFormat + old_cell_xf = styles_cell_xf(ws.package.workbook, Int(existing_style.id)) + new_cell_xf = copynode(old_cell_xf) + if length(attributes) != length(vals) + throw(XLSXError("Attributes and values must be of the same length.")) + end + for (a, v) in zip(attributes, vals) + new_cell_xf[a] = v + end + return styles_add_cell_xf(ws.package.workbook, new_cell_xf) +end +function update_template_xf(ws::Worksheet, allXfNodes::Vector{XML.Node}, existing_style::CellDataFormat, attributes::Vector{String}, vals::Vector{String})::CellDataFormat + old_cell_xf = styles_cell_xf(allXfNodes, Int(existing_style.id)) + new_cell_xf = copynode(old_cell_xf) + if length(attributes) != length(vals) + throw(XLSXError("Attributes and values must be of the same length.")) + end + for (a, v) in zip(attributes, vals) + new_cell_xf[a] = v + end + return styles_add_cell_xf(ws.package.workbook, new_cell_xf) +end +function update_template_xf(ws::Worksheet, existing_style::CellDataFormat, alignment::XML.Node)::CellDataFormat + old_cell_xf = styles_cell_xf(ws.package.workbook, Int(existing_style.id)) + new_cell_xf = copynode(old_cell_xf) + if isnothing(new_cell_xf.children) + new_cell_xf=XML.Node(new_cell_xf, alignment) + elseif length(XML.children(new_cell_xf)) == 0 + push!(new_cell_xf, alignment) + else + new_cell_xf[1] = alignment + end + return styles_add_cell_xf(ws.package.workbook, new_cell_xf) +end +function update_template_xf(ws::Worksheet, allXfNodes::Vector{XML.Node}, existing_style::CellDataFormat, alignment::XML.Node)::CellDataFormat + old_cell_xf = styles_cell_xf(allXfNodes, Int(existing_style.id)) + new_cell_xf = copynode(old_cell_xf) + if isnothing(new_cell_xf.children) + new_cell_xf=XML.Node(new_cell_xf, alignment) + elseif length(XML.children(new_cell_xf)) == 0 + push!(new_cell_xf, alignment) + else + new_cell_xf[1] = alignment + end + return styles_add_cell_xf(ws.package.workbook, new_cell_xf) +end + +# Only used in testing! +function styles_add_cell_font(wb::Workbook, attributes::Dict{String,Union{Dict{String,String},Nothing}})::Int + new_font = buildNode("font", attributes) + return styles_add_cell_attribute(wb, new_font, "fonts") +end + +# Used by setFont(), setBorder(), setFill(), setAlignment() and setNumFmt() +function styles_add_cell_attribute(wb::Workbook, new_att::XML.Node, att::String)::Int + xroot = styles_xmlroot(wb) + i, j = get_idces(xroot, "styleSheet", att) + existing_elements_count = length(XML.children(xroot[i][j])) + if parse(Int, xroot[i][j]["count"]) != existing_elements_count + throw(XLSXError("Wrong number of elements elements found: $existing_elements_count. Expected $(parse(Int, xroot[i][j]["count"])).")) + end + + # Check new_att doesn't duplicate any existing att. If yes, use that rather than create new. + for (k, node) in enumerate(XML.children(xroot[i][j])) + if XML.tag(new_att) == "numFmt" # mustn't compare numFmtId attribute for formats + if node["formatCode"] == new_att["formatCode"] + return k - 1 # CellDataFormat is zero-indexed + end + else + if node == new_att + return k - 1 # CellDataFormat is zero-indexed + end + end + end + + push!(xroot[i][j], new_att) + xroot[i][j]["count"] = string(existing_elements_count + 1) + + return existing_elements_count # turns out this is the new index (because it's zero-based) +end +function process_sheetcell(f::Function, xl::XLSXFile, sheetcell::String; kw...)::Int + if is_workbook_defined_name(xl, sheetcell) + v = get_defined_name_value(xl.workbook, sheetcell) + if is_defined_name_value_a_constant(v) + throw(XLSXError("Can only assign attributes to cells but `$(sheetcell)` is a constant: $(sheetcell)=$v.")) + elseif is_defined_name_value_a_reference(v) + newid = process_sheetcell(f, xl, string(v); kw...) + else + throw(XLSXError("Unexpected defined name value: $v.")) + end + elseif is_valid_non_contiguous_sheetcellrange(sheetcell) + sheetncrng = NonContiguousRange(sheetcell) + !hassheet(xl, sheetncrng.sheet) && throw(XLSXError("Sheet $(sheetncrng.sheet) not found.")) + newid = f(xl[sheetncrng.sheet], sheetncrng; kw...) + elseif is_valid_sheet_column_range(sheetcell) + sheetcolrng = SheetColumnRange(sheetcell) + !hassheet(xl, sheetcolrng.sheet) && throw(XLSXError("Sheet $(sheetcolrng.sheet) not found.")) + newid = f(xl[sheetcolrng.sheet], sheetcolrng.colrng; kw...) + elseif is_valid_sheet_row_range(sheetcell) + sheetrowrng = SheetRowRange(sheetcell) + !hassheet(xl, sheetrowrng.sheet) && throw(XLSXError("Sheet $(sheetrowrng.sheet) not found.")) + newid = f(xl[sheetrowrng.sheet], sheetrowrng.rowrng; kw...) + elseif is_valid_sheet_cellrange(sheetcell) + sheetcellrng = SheetCellRange(sheetcell) + !hassheet(xl, sheetcellrng.sheet) && throw(XLSXError("Sheet $(sheetcellrng.sheet) not found.")) + newid = f(xl[sheetcellrng.sheet], sheetcellrng.rng; kw...) + elseif is_valid_sheet_cellname(sheetcell) + ref = SheetCellRef(sheetcell) + !hassheet(xl, ref.sheet) && throw(XLSXError("Sheet $(ref.sheet) not found.")) + newid = f(getsheet(xl, ref.sheet), ref.cellref; kw...) + else + throw(XLSXError("Invalid sheet cell reference: $sheetcell")) + end + return newid +end +function process_ranges(f::Function, ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int + # Moved the tests for defined names to be first in case a name looks like a column name (e.g. "ID") + if is_worksheet_defined_name(ws, ref_or_rng) + v = get_defined_name_value(ws, ref_or_rng) + if is_defined_name_value_a_constant(v) + 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) + wb = get_workbook(ws) + newid = f(get_xlsxfile(wb), string(v); kw...) + else + throw(XLSXError("Unexpected defined name value: $v.")) + end + elseif is_workbook_defined_name(get_workbook(ws), ref_or_rng) + wb = get_workbook(ws) + v = get_defined_name_value(wb, ref_or_rng) + if is_defined_name_value_a_constant(v) + 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) + if is_valid_non_contiguous_range(string(v)) + _ = f.(Ref(get_xlsxfile(wb)), replace.(split(string(v), ","), "'" => "", "\$" => ""); kw...) + newid = -1 + else + newid = f(get_xlsxfile(wb), replace(string(v), "'" => "", "\$" => ""); kw...) + end + else + throw(XLSXError("Unexpected defined name value: $v.")) + end + elseif is_valid_column_range(ref_or_rng) + newid = f(ws, ColumnRange(ref_or_rng); kw...) + elseif is_valid_row_range(ref_or_rng) + newid = f(ws, RowRange(ref_or_rng); kw...) + elseif is_valid_cellrange(ref_or_rng) + newid = f(ws, CellRange(ref_or_rng); kw...) + elseif is_valid_cellname(ref_or_rng) + newid = f(ws, CellRef(ref_or_rng); kw...) + elseif is_valid_sheet_cellname(ref_or_rng) + newid = f(ws, SheetCellRef(ref_or_rng); kw...) + elseif is_valid_sheet_cellrange(ref_or_rng) + newid = f(ws, SheetCellRange(ref_or_rng); kw...) + elseif is_valid_sheet_column_range(ref_or_rng) + newid = f(ws, SheetColumnRange(ref_or_rng); kw...) + elseif is_valid_sheet_row_range(ref_or_rng) + newid = f(ws, SheetRowRange(ref_or_rng); kw...) + elseif is_valid_non_contiguous_cellrange(ref_or_rng) + newid = f(ws, NonContiguousRange(ws, ref_or_rng); kw...) + elseif is_valid_non_contiguous_sheetcellrange(ref_or_rng) + nc = NonContiguousRange(ref_or_rng) + newid = do_sheet_names_match(ws, nc) && f(ws, nc; kw...) + else + throw(XLSXError("Invalid cell reference or range: $ref_or_rng")) + end + return newid +end +function process_columnranges(f::Function, ws::Worksheet, colrng::ColumnRange; kw...)::Int + bounds = column_bounds(colrng) + dim = (get_dimension(ws)) + left = bounds[begin] + right = bounds[end] + top = dim.start.row_number + bottom = dim.stop.row_number + + OK = dim.start.column_number <= left + OK &= dim.stop.column_number >= right + OK &= dim.start.row_number <= top + OK &= dim.stop.row_number >= bottom + + if OK + rng = CellRange(top, left, bottom, right) + return f(ws, rng; kw...) + else + throw(XLSXError("Column range $colrng is out of bounds. Worksheet `$(ws.name)` only has dimension `$dim`.")) + end +end +function process_rowranges(f::Function, ws::Worksheet, rowrng::RowRange; kw...)::Int + bounds = row_bounds(rowrng) + dim = (get_dimension(ws)) + top = bounds[begin] + bottom = bounds[end] + left = dim.start.column_number + right = dim.stop.column_number + + OK = dim.start.column_number <= left + OK &= dim.stop.column_number >= right + OK &= dim.start.row_number <= top + OK &= dim.stop.row_number >= bottom + + if OK + rng = CellRange(top, left, bottom, right) + return f(ws, rng; kw...) + else + throw(XLSXError("Row range $rowrng is out of bounds. Worksheet `$(ws.name)` only has dimension `$dim`.")) + end +end +function process_ncranges(f::Function, ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int + bounds = nc_bounds(ncrng) + if length(ncrng) == 1 + single = true + else + single = false + end + dim = (get_dimension(ws)) + OK = dim.start.column_number <= bounds.start.column_number + OK &= dim.stop.column_number >= bounds.stop.column_number + OK &= dim.start.row_number <= bounds.start.row_number + OK &= dim.stop.row_number >= bounds.stop.row_number + if OK + for r in ncrng.rng + if r isa CellRef && getcell(ws, r) isa EmptyCell + single && throw(XLSXError("Cannot set format for an `EmptyCell`: $(r.name). Set the value first.")) + continue + end + _ = f(ws, r; kw...) + end + return -1 + else + throw(XLSXError("Non-contiguous range $ncrng is out of bounds. Worksheet `$(ws.name)` only has dimension `$dim`.")) + end +end +function process_cellranges(f::Function, ws::Worksheet, rng::CellRange; kw...)::Int + if length(rng) == 1 + single = true + else + single = false + end + isInDim(ws, get_dimension(ws), rng) + for cellref in rng + if getcell(ws, cellref) isa EmptyCell + single && throw(XLSXError("Cannot set format for an `EmptyCell`: $(cellref.name). Set the value first.")) + continue + end + _ = f(ws, cellref; kw...) + end + return -1 # Each cell may have a different attribute Id so we can't return a single value. +end +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 +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 == "" + return nothing + end + cell_style = styles_cell_xf(wb, parse(Int, cell.style)) + return f(wb, cell_style; kw...) +end +function process_get_cellname(f::Function, ws::Worksheet, ref_or_rng::AbstractString; kw...) + if is_workbook_defined_name(get_workbook(ws), ref_or_rng) + wb = get_workbook(ws) + v = get_defined_name_value(wb, ref_or_rng) + if is_defined_name_value_a_constant(v) + 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), replace(string(v), "'" => ""); kw...) + else + throw(XLSXError("Unexpected defined name value: $v.")) + end + elseif is_valid_cellname(ref_or_rng) + new_att = f(ws, CellRef(ref_or_rng); kw...) + else + throw(XLSXError("Invalid cell reference: $ref_or_rng")) + end + return new_att +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 + + return f(ws, rng; 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 + isInDim(ws, dim, row, col) + if length(row) == 1 && length(col) == 1 + single = true + else + single = false + end + for a in row + for b in col + cellref = CellRef(a, b) + if getcell(ws, cellref) isa EmptyCell + single && throw(XLSXError("Cannot set attribute for an `EmptyCell`: $(cellref.name). Set the value first.")) + continue + end + f(ws, cellref; kw...) + end + end + return -1 +end +function process_vecint(f::Function, ws::Worksheet, row, col; kw...) + if length(col) == 1 && length(row) == 1 + single = true + else + single = false + end + dim = get_dimension(ws) + isInDim(ws, dim, row, col) + for a in row, b in col + cellref = CellRef(a, b) + if getcell(ws, cellref) isa EmptyCell + single && throw(XLSXError("Cannot set format for an `EmptyCell`: $(cellref.name). Set the value first.")) + continue + end + f(ws, cellref; kw...) + end + return -1 +end + +# +# - Used for indexing `setUniformAttribute` family of functions +# + +# +# Most setUniform functions (but not Style or Alignment - see below) +# +function process_uniform_core(f::Function, ws::Worksheet, allXfNodes::Vector{XML.Node}, cellref::CellRef, atts::Vector{String}, newid::Union{Int,Nothing}, first::Bool; kw...) + cell = getcell(ws, cellref) + if cell isa EmptyCell # Can't add a attribute to an empty cell. + return newid, first + end + if first # Get the attribute of the first cell in the range. + newid = f(ws, cellref; kw...) + first = false + else # Apply the same attribute to the rest of the cells in the range. + if cell.style == "" + cell.style = string(get_num_style_index(ws, allXfNodes, 0).id) + end + cell.style = string(update_template_xf(ws, allXfNodes, CellDataFormat(parse(Int, cell.style)), atts, [string(newid), "1"]).id) + end + return newid, first +end +function process_uniform_attribute(f::Function, ws::Worksheet, rng::CellRange, atts::Vector{String}; kw...) + if !get_xlsxfile(ws).use_cache_for_sheet_data + throw(XLSXError("Cannot set uniform attributes because cache is not enabled.")) + end + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(get_workbook(ws))) + let newid::Union{Int,Nothing}, first::Bool + newid = nothing + first = true + isInDim(ws, get_dimension(ws), rng) + for cellref in rng + newid, first = process_uniform_core(f, ws, allXfNodes, cellref, atts, newid, first; kw...) + end + if first + newid = -1 + end + return newid + end +end +function process_uniform_ncranges(f::Function, ws::Worksheet, ncrng::NonContiguousRange, atts::Vector{String}; kw...)::Int + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(get_workbook(ws))) + bounds = nc_bounds(ncrng) + if length(ncrng) == 1 + single = true + else + single = false + end + dim = (get_dimension(ws)) + OK = dim.start.column_number <= bounds.start.column_number + OK &= dim.stop.column_number >= bounds.stop.column_number + OK &= dim.start.row_number <= bounds.start.row_number + OK &= dim.stop.row_number >= bounds.stop.row_number + if OK + let newid::Union{Int,Nothing}, first::Bool + newid = nothing + first = true + 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`: $(r.name). Set the value first.")) + continue + end + newid, first = process_uniform_core(f, ws, allXfNodes, r, atts, newid, first; kw...) + else + for c in r + newid, first = process_uniform_core(f, ws, allXfNodes, c, atts, newid, first; kw...) + end +# else +# throw(XLSXError("Something wrong here!")) + end + end + if first + newid = -1 + end + return newid + end + else + throw(XLSXError("Non-contiguous range $ncrng is out of bounds. Worksheet `$(ws.name)` only has dimension `$dim`.")) + end +end +function process_uniform_veccolon(f::Function, ws::Worksheet, row, col, atts::Vector{String}; kw...) + dim = get_dimension(ws) + @assert isnothing(row) || isnothing(col) "Something wrong here!" + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(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 + isInDim(ws, dim, row, col) + let newid::Union{Int,Nothing}, first::Bool + newid = nothing + first = true + for a in row + for b in col + cellref = CellRef(a, b) + if getcell(ws, cellref) isa EmptyCell + continue + end + newid, first = process_uniform_core(f, ws, allXfNodes, cellref, atts, newid, first; kw...) + end + end + if first + newid = -1 + end + return newid + end +end +function process_uniform_vecint(f::Function, ws::Worksheet, row, col, atts::Vector{String}; kw...) + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(get_workbook(ws))) + let newid::Union{Int,Nothing}, first::Bool + dim = get_dimension(ws) + newid = nothing + first = true + isInDim(ws, dim, row, col) + for a in row, b in col + cellref = CellRef(a, b) + if getcell(ws, cellref) isa EmptyCell + continue + end + newid, first = process_uniform_core(f, ws, allXfNodes, cellref, atts, newid, first; kw...) + end + if first + newid = -1 + end + return newid + end +end + +# +# UniformStyles +# +function process_uniform_core(ws::Worksheet, cellref::CellRef, newid::Union{Int,Nothing}, first::Bool) + cell = getcell(ws, cellref) + if cell isa EmptyCell # Can't add a attribute to an empty cell. + return newid, first + end + if first # Get the style of the first cell in the range. + newid = parse(Int, cell.style) + first = false + else # Apply the same style to the rest of the cells in the range. + cell.style = string(newid) + end + return newid, first +end +function process_uniform_ncranges(ws::Worksheet, ncrng::NonContiguousRange)::Int + bounds = nc_bounds(ncrng) + if length(ncrng) == 1 + single = true + else + single = false + end + dim = (get_dimension(ws)) + OK = dim.start.column_number <= bounds.start.column_number + OK &= dim.stop.column_number >= bounds.stop.column_number + OK &= dim.start.row_number <= bounds.start.row_number + OK &= dim.stop.row_number >= bounds.stop.row_number + if OK + let newid::Union{Int,Nothing}, first::Bool + newid = nothing + first = true + 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`: $(r.name). Set the value first.")) + continue + end + newid, first = process_uniform_core(ws, r, newid, first) + else + for c in r + newid, first = process_uniform_core(ws, c, newid, first) + end +# else +# throw(XLSXError("Something wrong here!")) + end + end + if first + newid = -1 + end + return newid + end + else + 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 +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 + isInDim(ws, dim, row, col) + let newid::Union{Int,Nothing}, first::Bool + newid = nothing + first = true + for a in row + for b in col + cellref = CellRef(a, b) + if getcell(ws, cellref) isa EmptyCell + continue + end + newid, first = process_uniform_core(ws, cellref, newid, first) + end + end + if first + newid = -1 + end + return newid + end +end +function process_uniform_vecint(ws::Worksheet, row, col) + let newid::Union{Int,Nothing}, first::Bool + dim = get_dimension(ws) + newid = nothing + first = true + isInDim(ws, dim, row, col) + for a in row, b in col + cellref = CellRef(a, b) + if getcell(ws, cellref) isa EmptyCell + continue + end + newid, first = process_uniform_core(ws, cellref, newid, first) + end + if first + newid = -1 + end + return newid + end +end + +# +# Alignment is different +# +function process_uniform_core(f::Function, ws::Worksheet, allXfNodes::Vector{XML.Node}, cellref::CellRef, newid::Union{Int,Nothing}, first::Bool, alignment_node::Union{XML.Node,Nothing}; kw...) # setUniformAlignment is different + cell = getcell(ws, cellref) + if cell isa EmptyCell # Can't add a attribute to an empty cell. + return newid, first, alignment_node + end + if first # Get the attribute of the first cell in the range. + newid = f(ws, cellref; kw...) + new_alignment = getAlignment(ws, cellref).alignment["alignment"] + alignment_node = XML.Node(XML.Element, "alignment", new_alignment, nothing, nothing) + first = false + else # Apply the same attribute to the rest of the cells in the range. + if cell.style == "" + cell.style = string(get_num_style_index(ws, allXfNodes, 0).id) + end + cell.style = string(update_template_xf(ws, allXfNodes, CellDataFormat(parse(Int, cell.style)), alignment_node).id) + end + 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 + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(get_workbook(ws))) + let newid::Union{Int,Nothing}, first::Bool, alignment_node::Union{XML.Node,Nothing} + newid = nothing + first = true + alignment_node = nothing + isInDim(ws, get_dimension(ws), rng) + for cellref in rng + if getcell(ws, cellref) isa EmptyCell + continue + end + newid, first, alignment_node = process_uniform_core(f, ws, allXfNodes, cellref, newid, first, alignment_node; kw...) + end + if first + newid = -1 + end + return newid + end +end +function process_uniform_ncranges(f::Function, ws::Worksheet, ncrng::NonContiguousRange; kw...)::Int + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(get_workbook(ws))) + bounds = nc_bounds(ncrng) + if length(ncrng) == 1 + single = true + else + single = false + end + dim = (get_dimension(ws)) + OK = dim.start.column_number <= bounds.start.column_number + OK &= dim.stop.column_number >= bounds.stop.column_number + OK &= dim.start.row_number <= bounds.start.row_number + OK &= dim.stop.row_number >= bounds.stop.row_number + if OK + let newid::Union{Int,Nothing}, first::Bool, alignment_node::Union{XML.Node,Nothing} + newid = nothing + 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`: $(r.name). 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`: $(r.name). Set the value first.")) + continue + end + newid, first, alignment_node = process_uniform_core(f, ws, allXfNodes, r, newid, first, alignment_node; kw...) + else + 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 + newid = -1 + end + return newid + end + else + throw(XLSXError("Non-contiguous range $ncrng is out of bounds. Worksheet `$(ws.name)` only has dimension `$dim`.")) + end +end +function process_uniform_veccolon(f::Function, ws::Worksheet, row, col; kw...) + dim = get_dimension(ws) + if dim === nothing + throw(XLSXError("No worksheet dimension found")) + else + @assert isnothing(row) || isnothing(col) "Something wrong here!" + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(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 + 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 + cellref = CellRef(a, b) + if getcell(ws, cellref) isa EmptyCell + continue + end + newid, first, alignment_node = process_uniform_core(f, ws, allXfNodes, cellref, newid, first, alignment_node; kw...) + end + end + if first + newid = -1 + end + return newid + end + end +end +function process_uniform_vecint(f::Function, ws::Worksheet, row, col; kw...) + let newid::Union{Int,Nothing}, first::Bool, alignment_node::Union{XML.Node,Nothing} + dim = get_dimension(ws) + if dim === nothing + throw(XLSXError("No worksheet dimension found")) + end + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(get_workbook(ws))) + newid = nothing + first = true + alignment_node = nothing + isInDim(ws, dim, row, col) + for a in row, b in col + cellref = CellRef(a, b) + if getcell(ws, cellref) isa EmptyCell + continue + end + newid, first, alignment_node = process_uniform_core(f, ws, allXfNodes, cellref, newid, first, alignment_node; kw...) + end + if first + newid = -1 + end + return newid + end +end + +# Check if a string is a valid named color in Colors.jl and convert to "FFRRGGBB" if it is. +function get_colorant(color_string::String) + try + c = Colors.parse(Colors.Colorant, color_string) + rgb = Colors.hex(c, :RRGGBB) + return "FF" * rgb + catch + return nothing + end +end +function get_color(s::String)::String + if occursin(r"^[0-9A-F]{8}$", s) # is a valid 8 digit hexadecimal color + return s + end + c = get_colorant(s) + if isnothing(c) + throw(XLSXError("Invalid color specified: $s. Either give a valid color name (from Colors.jl) or an 8-digit rgb color in the form FFRRGGBB")) + end + return c +end diff --git a/src/cellformats.jl b/src/cellformats.jl index 0c80d10b..4c3ca225 100644 --- a/src/cellformats.jl +++ b/src/cellformats.jl @@ -1,401 +1,4 @@ -const font_tags = ["b", "i", "u", "strike", "outline", "shadow", "condense", "extend", "sz", "color", "name", "scheme"] -const border_tags = ["left", "right", "top", "bottom", "diagonal"] -const fill_tags = ["patternFill"] -const builtinFormats = Dict( - "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" => "@" - ) - const builtinFormatNames = Dict( - "General" => 0, - "Number" => 2, - "Currency" => 7, - "Percentage" => 9, - "ShortDate" => 14, - "LongDate" => 15, - "Time" => 21, - "Scientific" => 48 - ) -const floatformats = r""" -\.[0#?]| -[0#?]e[+-]?[0#?]| -[0#?]/[0#?]| -% -"""ix - -# -# -- A bunch of helper functions first... -# - -function copynode(o::XML.Node) - n = XML.parse(XML.Node, XML.write(o))[1] - n = XML.Node(n.nodetype, n.tag, isnothing(n.attributes) ? XML.OrderedDict{String,String}() : n.attributes, n.value, isnothing(n.children) ? Vector{XML.Node}() : n.children) - return n -end -function buildNode(tag::String, attributes::Dict{String,Union{Nothing,Dict{String,String}}})::XML.Node - if tag == "font" - attribute_tags = font_tags - elseif tag == "border" - attribute_tags = border_tags - elseif tag == "fill" - attribute_tags = fill_tags - else - error("Unknown tag: $tag") - end - new_node = XML.Element(tag) - for a in attribute_tags # Use this as a device to keep ordering constant for Excel - if tag == "font" - if haskey(attributes, a) - if isnothing(attributes[a]) - cnode = XML.Element(a) - else - cnode = XML.Node(XML.Element, a, XML.OrderedDict{String,String}(), nothing, tag ∈ ["border", "fill"] ? Vector{XML.Node}() : nothing) - for (k, v) in attributes[a] - cnode[k] = v - end - end - push!(new_node, cnode) - end - elseif tag == "border" - if haskey(attributes, a) - if isnothing(attributes[a]) - cnode = XML.Element(a) - else - cnode = XML.Node(XML.Element, a, XML.OrderedDict{String,String}(), nothing, tag ∈ ["border", "fill"] ? Vector{XML.Node}() : nothing) - color = XML.Element("color") - for (k, v) in attributes[a] - if k == "style" && v != "none" - cnode[k] = v - elseif k == "direction" - if v in ["up", "both"] - new_node["diagonalUp"] = "1" - end - if v in ["down", "both"] - new_node["diagonalDown"] = "1" - end - else#if k == "rgb" - color[k] = v - #else - #error("Incorect border attribute found: $k") # shouldn't happen! - end - end - if length(XML.attributes(color)) > 0 # Don't push an empty color. - push!(cnode, color) - end - end - push!(new_node, cnode) - end - elseif tag == "fill" - if haskey(attributes, a) - if isnothing(attributes[a]) - cnode = XML.Element(a) - else - cnode = XML.Node(XML.Element, a, XML.OrderedDict{String,String}(), nothing, tag ∈ ["border", "fill"] ? Vector{XML.Node}() : nothing) - patternfill = XML.Element("patternFill") - fgcolor = XML.Element("fgColor") - bgcolor = XML.Element("bgColor") - for (k, v) in attributes[a] - if k == "patternType" - patternfill[k] = v - elseif first(k, 2) == "fg" - fgcolor[k[3:end]] = v - elseif first(k, 2) == "bg" - bgcolor[k[3:end]] = v - end - end - @assert haskey(patternfill, "patternType") "No `patternType` attribute found." - length(XML.attributes(fgcolor)) > 0 && push!(patternfill, fgcolor) - length(XML.attributes(bgcolor)) > 0 && push!(patternfill, bgcolor) - end - push!(new_node, patternfill) - end - else - end - end - return new_node -end -function unlink_cols(node::XML.Node) # removes each `col` from a `cols` XML node. - new_cols = XML.Element("cols") - a = XML.attributes(node) - if !isnothing(a) # Copy attributes across to new node - for (k, v) in XML.attributes(node) - new_cols[k] = v - end - end - for child in XML.children(node) # Copy any child nodes that are not cols across to new node - if XML.tag(child) != "col" # Shouldn't be any. - push!(new_cols, child) - end - end - return new_cols -end -function update_template_xf(ws::Worksheet, existing_style::CellDataFormat, attributes::Vector{String}, vals::Vector{String})::CellDataFormat - old_cell_xf = styles_cell_xf(ws.package.workbook, Int(existing_style.id)) - new_cell_xf = copynode(old_cell_xf) - @assert length(attributes) == length(vals) "Attributes and values must be of the same length." - for (a, v) in zip(attributes, vals) - new_cell_xf[a] = v - end - return styles_add_cell_xf(ws.package.workbook, new_cell_xf) -end -function update_template_xf(ws::Worksheet, existing_style::CellDataFormat, alignment::XML.Node)::CellDataFormat - old_cell_xf = styles_cell_xf(ws.package.workbook, Int(existing_style.id)) - new_cell_xf = copynode(old_cell_xf) - if length(XML.children(new_cell_xf))==0 - push!(new_cell_xf, alignment) - else - new_cell_xf[1] = alignment - end - return styles_add_cell_xf(ws.package.workbook, new_cell_xf) -end - -# Only used in testing! -function styles_add_cell_font(wb::Workbook, attributes::Dict{String,Union{Dict{String,String},Nothing}})::Int - new_font = buildNode("font", attributes) - return styles_add_cell_attribute(wb, new_font, "fonts") -end - -# Used by setFont(), setBorder(), setFill(), setAlignment() and setNumFmt() -function styles_add_cell_attribute(wb::Workbook, new_att::XML.Node, att::String)::Int - xroot = styles_xmlroot(wb) - i, j = get_idces(xroot, "styleSheet", att) - existing_elements_count = length(XML.children(xroot[i][j])) - @assert parse(Int, xroot[i][j]["count"]) == existing_elements_count "Wrong number of elements elements found: $existing_elements_count. Expected $(parse(Int, xroot[i][j]["count"]))." - - # Check new_att doesn't duplicate any existing att. If yes, use that rather than create new. - for (k, node) in enumerate(XML.children(xroot[i][j])) - if XML.tag(new_att) == "numFmt" # mustn't compare numFmtId attribute for formats - if XML.parse(XML.Node, XML.write(node))[1]["formatCode"] == XML.parse(XML.Node, XML.write(new_att))[1]["formatCode"] # XML.jl defines `Base.:(==)` - return k - 1 # CellDataFormat is zero-indexed - end - else - if XML.parse(XML.Node, XML.write(node))[1] == XML.parse(XML.Node, XML.write(new_att))[1] # XML.jl defines `Base.:(==)` - return k - 1 # CellDataFormat is zero-indexed - end - end - end - - push!(xroot[i][j], new_att) - xroot[i][j]["count"] = string(existing_elements_count + 1) - - return existing_elements_count # turns out this is the new index (because it's zero-based) -end -function process_sheetcell(f::Function, xl::XLSXFile, sheetcell::String; kw...)::Int - if is_workbook_defined_name(xl, sheetcell) - v = get_defined_name_value(xl.workbook, sheetcell) - if is_defined_name_value_a_constant(v) - error("Can only assign attributes to cells but `$(sheetcell)` is a constant: $(sheetcell)=$v.") - elseif is_defined_name_value_a_reference(v) - newid = process_ranges(f, xl, string(v); kw...) - else - error("Unexpected defined name value: $v.") - end - elseif is_valid_sheet_column_range(sheetcell) - sheetcolrng = SheetColumnRange(sheetcell) - newid = f(xl[sheetcolrng.sheet], sheetcolrng.colrng; kw...) - elseif is_valid_sheet_cellrange(sheetcell) - sheetcellrng = SheetCellRange(sheetcell) - newid = f(xl[sheetcellrng.sheet], sheetcellrng.rng; kw...) - elseif is_valid_sheet_cellname(sheetcell) - ref = SheetCellRef(sheetcell) - @assert hassheet(xl, ref.sheet) "Sheet $(ref.sheet) not found." - newid = f(getsheet(xl, ref.sheet), ref.cellref; kw...) - else - error("Invalid sheet cell reference: $sheetcell") - end - return newid -end -function process_ranges(f::Function, ws::Worksheet, ref_or_rng::AbstractString; kw...)::Int - # Moved the tests for defined names to be first in case a name looks like a column name (e.g. "ID") - if is_worksheet_defined_name(ws, ref_or_rng) - v = get_defined_name_value(ws, ref_or_rng) - if is_defined_name_value_a_constant(v) - error("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) - wb = get_workbook(ws) - newid = f(get_xlsxfile(wb), string(v); kw...) - else - error("Unexpected defined name value: $v.") - end - elseif is_workbook_defined_name(get_workbook(ws), ref_or_rng) - wb = get_workbook(ws) - v = get_defined_name_value(wb, ref_or_rng) - if is_defined_name_value_a_constant(v) - error("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) - if is_non_contiguous_range(v) - _ = f.(Ref(get_xlsxfile(wb)), replace.(split(string(v), ","), "'" => "", "\$" => ""); kw...) - newid = -1 - else - newid = f(get_xlsxfile(wb), replace(string(v), "'" => "", "\$" => ""); kw...) - end - else - error("Unexpected defined name value: $v.") - end - elseif is_valid_column_range(ref_or_rng) - colrng = ColumnRange(ref_or_rng) - newid = f(ws, colrng; kw...) - elseif is_valid_cellrange(ref_or_rng) - rng = CellRange(ref_or_rng) - newid = f(ws, rng; kw...) - elseif is_valid_cellname(ref_or_rng) - newid = f(ws, CellRef(ref_or_rng); kw...) - else - error("Invalid cell reference or range: $ref_or_rng") - end - return newid -end -function process_columnranges(f::Function, ws::Worksheet, colrng::ColumnRange; kw...)::Int - bounds = column_bounds(colrng) - dim = (get_dimension(ws)) - - left = bounds[begin] - right = bounds[end] - top = dim.start.row_number - bottom = dim.stop.row_number - - OK = dim.start.column_number <= left - OK &= dim.stop.column_number >= right - OK &= dim.start.row_number <= top - OK &= dim.stop.row_number >= bottom - - if OK - rng = CellRange(top, left, bottom, right) - return f(ws, rng; kw...) - else - error("Column range $colrng is out of bounds. Worksheet `$(ws.name)` only has dimension `$dim`.") - end -end -function process_cellranges(f::Function, ws::Worksheet, rng::CellRange; kw...)::Int - for cellref in rng - if getcell(ws, cellref) isa EmptyCell - continue - end - _ = f(ws, cellref; kw...) - end - return -1 # Each cell may have a different attribute Id so we can't return a single value. -end -function process_get_sheetcell(f::Function, xl::XLSXFile, sheetcell::String) - ref = SheetCellRef(sheetcell) - @assert hassheet(xl, ref.sheet) "Sheet $(ref.sheet) not found." - return f(getsheet(xl, ref.sheet), ref.cellref) -end -function process_get_cellref(f::Function, ws::Worksheet, cellref::CellRef) - wb = get_workbook(ws) - cell = getcell(ws, cellref) - - if cell isa EmptyCell || cell.style == "" - return nothing - end - - cell_style = styles_cell_xf(wb, parse(Int, cell.style)) - return f(wb, cell_style) -end -function process_get_cellname(f::Function, ws::Worksheet, ref_or_rng::AbstractString) - if is_workbook_defined_name(get_workbook(ws), ref_or_rng) - wb = get_workbook(ws) - v = get_defined_name_value(wb, ref_or_rng) - if is_defined_name_value_a_constant(v) # Can these have fonts? - error("Can only assign borderds 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), replace(string(v), "'" => "")) - else - error("Unexpected defined name value: $v.") - end - elseif is_valid_cellname(ref_or_rng) - new_att = f(ws, CellRef(ref_or_rng)) - else - error("Invalid cell reference or range: $ref_or_rng") - end - return new_att -end -function process_uniform_attribute(f::Function, ws::Worksheet, rng::CellRange, atts::Vector{String}; kw...) - - @assert get_xlsxfile(ws).use_cache_for_sheet_data "Cannot set uniform attributes because cache is not enabled." - - let newid - first = true - for cellref in rng - cell = getcell(ws, cellref) - if cell isa EmptyCell # Can't add a attribute to an empty cell. - continue - end - if first # Get the attribute of the first cell in the range. - newid = f(ws, cellref; kw...) - first = false - else # Apply the same attribute to the rest of the cells in the range. - if cell.style == "" - cell.style = string(get_num_style_index(ws, 0).id) - end - cell.style = string(update_template_xf(ws, CellDataFormat(parse(Int, cell.style)), atts, ["$newid", "1"]).id) - end - end - if first - newid = -1 - end - return newid - end -end -function process_uniform_attribute(f::Function, ws::Worksheet, rng::CellRange; kw...) - - @assert get_xlsxfile(ws).use_cache_for_sheet_data "Cannot set uniform attributes because cache is not enabled." - - let newid, alignment_node - first = true - for cellref in rng - cell = getcell(ws, cellref) - if cell isa EmptyCell # Can't add a attribute to an empty cell. - continue - end - if first # Get the attribute of the first cell in the range. - newid = f(ws, cellref; kw...) - new_alignment = getAlignment(ws, cellref).alignment["alignment"] - alignment_node = XML.Node(XML.Element, "alignment", new_alignment, nothing, nothing) - first = false - else # Apply the same attribute to the rest of the cells in the range. - if cell.style == "" - cell.style = string(get_num_style_index(ws, 0).id) - end - cell.style = string(update_template_xf(ws, CellDataFormat(parse(Int, cell.style)), alignment_node).id) - end - end - if first - newid = -1 - end - return newid - end -end - # ========================================================================================== # # -- Get and set font attributes @@ -405,8 +8,12 @@ end 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 -a named cell or named range in a worksheet or XLSXfile. +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. @@ -414,7 +21,7 @@ Font attributes are specified using keyword arguments: - `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 using an 8-digit hexadecimal RGB value. +- `color::String = nothing` : set the font color. - `name::String = nothing` : set the font name. Only the attributes specified will be changed. If an attribute is not specified, the current @@ -422,18 +29,21 @@ 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. +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 only be defined as rgb values. -- The first two digits represent transparency (α). FF is fully opaque, while 00 is fully transparent. +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 +([here](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. @@ -447,49 +57,82 @@ For cell ranges, column ranges and named ranges, the value returned is -1. ```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="FFB3081F") # 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="FF8888FF", under="single") # Column 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!A:B"; italic=true, color="FF8888FF", 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="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...) -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 - )::Int - - @assert get_xlsxfile(sh).use_cache_for_sheet_data "Cannot set font because cache is not enabled." +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 +)::Int + + if !get_xlsxfile(sh).use_cache_for_sheet_data + throw(XLSXError("Cannot set font because cache is not enabled.")) + end wb = get_workbook(sh) cell = getcell(sh, cellref) - @assert !(cell isa EmptyCell) "Cannot set attribute for an `EmptyCell`: $(cellref.name). Set the value first." + if cell isa EmptyCell + throw(XLSXError("Cannot set attribute for an `EmptyCell`: $(cellref.name). Set the value first.")) + end + + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(wb)) if cell.style == "" - cell.style = string(get_num_style_index(sh, 0).id) + cell.style = string(get_num_style_index(sh, allXfNodes, 0).id) end - cell_style = styles_cell_xf(wb, parse(Int, cell.style)) + cell_style = styles_cell_xf(allXfNodes, parse(Int, cell.style)) new_font_atts = Dict{String,Union{Dict{String,String},Nothing}}() cell_font = getFont(wb, cell_style) @@ -506,7 +149,9 @@ function setFont(sh::Worksheet, cellref::CellRef; new_font_atts["i"] = nothing end elseif a == "u" - @assert isnothing(under) || under ∈ ["none", "single", "double"] "Invalid value for under: $under. Must be one of: `none`, `single`, `double`." + if !isnothing(under) && under ∉ ["none", "single", "double"] + throw(XLSXError("Invalid value for under: $under. Must be one of: `none`, `single`, `double`.")) + end if isnothing(under) && haskey(old_font_atts, "u") new_font_atts["u"] = old_font_atts["u"] elseif !isnothing(under) @@ -521,14 +166,13 @@ function setFont(sh::Worksheet, cellref::CellRef; new_font_atts["strike"] = nothing end elseif a == "color" - @assert isnothing(color) || occursin(r"^[0-9A-F]{8}$", color) "Invalid color value: $color. Must be an 8-digit hexadecimal RGB value." if isnothing(color) && haskey(old_font_atts, "color") new_font_atts["color"] = old_font_atts["color"] elseif !isnothing(color) - new_font_atts["color"] = Dict("rgb" => color) + new_font_atts["color"] = Dict("rgb" => get_color(color)) end elseif a == "sz" - @assert isnothing(size) || (size > 0 && size < 410) "Invalid size value: $size. Must be between 1 and 409." + (!isnothing(size) && (size < 1 || size > 409)) && throw(XLSXError("Invalid size value: $size. Must be between 1 and 409.")) if isnothing(size) && haskey(old_font_atts, "sz") new_font_atts["sz"] = old_font_atts["sz"] elseif !isnothing(size) @@ -550,7 +194,7 @@ function setFont(sh::Worksheet, cellref::CellRef; new_fontid = styles_add_cell_attribute(wb, font_node, "fonts") - newstyle = string(update_template_xf(sh, CellDataFormat(parse(Int, cell.style)), ["fontId", "applyFont"], ["$new_fontid", "1"]).id) + newstyle = string(update_template_xf(sh, allXfNodes, CellDataFormat(parse(Int, cell.style)), ["fontId", "applyFont"], [string(new_fontid), "1"]).id) cell.style = newstyle return new_fontid end @@ -559,8 +203,12 @@ end setUniformFont(sh::Worksheet, cr::String; kw...) -> ::Int setUniformFont(xf::XLSXFile, cr::String, kw...) -> ::Int -Set the font used by a cell range, a column range or a named range in a -worksheet or XLSXfile to be uniformly the same font. + 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 @@ -580,6 +228,9 @@ 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. @@ -595,24 +246,46 @@ julia> setUniformFont(sh, "A:B"; italic=true, color="FF8888FF", under="single") 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(xf, "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} - -Get the font used by a single cell at reference `cr` in a worksheet `sh` or XLSXfile `xf`. + + 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 @@ -645,6 +318,10 @@ e.g. `"color" => ("theme" => "1")`. julia> getFont(sh, "A1") julia> getFont(xf, "Sheet1!A1") + +julia> getFont(sh, "Sheet1!A1") + +julia> getFont(sh, 1, 1) ``` """ @@ -652,24 +329,28 @@ 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} - @assert get_xlsxfile(wb).use_cache_for_sheet_data "Cannot get font because cache is not enabled." + 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" xroot = styles_xmlroot(wb) - font_elements = find_all_nodes("/$SPREADSHEET_NAMESPACE_XPATH_ARG:styleSheet/$SPREADSHEET_NAMESPACE_XPATH_ARG:fonts", xroot)[begin] - @assert parse(Int, font_elements["count"]) == length(XML.children(font_elements)) "Unexpected number of font definitions found : $(length(XML.children(font_elements))). Expected $(parse(Int, font_elements["count"]))" + font_elements = find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":fonts", xroot)[begin] + if parse(Int, font_elements["count"]) != length(XML.children(font_elements)) + throw(XLSXError("Unexpected number of font definitions found : $(length(XML.children(font_elements))). Expected $(parse(Int, font_elements["count"]))")) + end current_font = XML.children(font_elements)[parse(Int, fontid)+1] # Zero based! font_atts = Dict{String,Union{Dict{String,String},Nothing}}() for c in XML.children(current_font) if isnothing(XML.attributes(c)) || length(XML.attributes(c)) == 0 font_atts[XML.tag(c)] = nothing else - # @assert length(XML.attributes(c)) == 1 "Too many font attributes found for $(XML.tag(c)) Expected 1, found $(length(XML.attributes(c)))." for (k, v) in XML.attributes(c) font_atts[XML.tag(c)] = Dict(k => v) end @@ -688,8 +369,11 @@ end """ 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 `cr` in a worksheet or XLSXfile. +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 @@ -736,12 +420,12 @@ The `color` element can have the following attributes: `tint` can only be used in conjunction with the theme attribute to derive different shades of the theme color. For example: . -Only the `rgb` attribute can be used in `setBorder()` to define a border color. - # Examples: ```julia julia> getBorder(sh, "A1") +julia> getBorder(sh, 3, 6) + julia> getBorder(xf, "Sheet1!A1") ``` @@ -750,17 +434,22 @@ 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} - @assert get_xlsxfile(wb).use_cache_for_sheet_data "Cannot get border because cache is not enabled." + 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" xroot = styles_xmlroot(wb) - border_elements = find_all_nodes("/$SPREADSHEET_NAMESPACE_XPATH_ARG:styleSheet/$SPREADSHEET_NAMESPACE_XPATH_ARG:borders", xroot)[begin] - @assert parse(Int, border_elements["count"]) == length(XML.children(border_elements)) "Unexpected number of border definitions found : $(length(XML.children(border_elements))). Expected $(parse(Int, border_elements["count"]))" + border_elements = find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":borders", xroot)[begin] + if parse(Int, border_elements["count"]) != length(XML.children(border_elements)) + throw(XLSXError("Unexpected number of border definitions found : $(length(XML.children(border_elements))). Expected $(parse(Int, border_elements["count"]))")) + end current_border = XML.children(border_elements)[parse(Int, borderid)+1] # Zero based! diag_atts = XML.attributes(current_border) border_atts = Dict{String,Union{Dict{String,String},Nothing}}() @@ -768,10 +457,12 @@ function getBorder(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellBorder if isnothing(XML.attributes(side)) || length(XML.attributes(side)) == 0 border_atts[XML.tag(side)] = nothing else - @assert length(XML.attributes(side)) == 1 "Too many border attributes found for $(XML.tag(side)) Expected 1, found $(length(XML.attributes(side)))." + if length(XML.attributes(side)) != 1 + throw(XLSXError("Too many border attributes found for $(XML.tag(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[XML.tag(side)] = Dict(k => v) - if side == "diagonal" && !isnothing(diag_atts) + if XML.tag(side) == "diagonal" && !isnothing(diag_atts) if haskey(diag_atts, "diagonalUp") && haskey(diag_atts, "diagonalDown") border_atts[XML.tag(side)]["direction"] = "both" elseif haskey(diag_atts, "diagonalUp") @@ -779,7 +470,7 @@ function getBorder(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellBorder elseif haskey(diag_atts, "diagonalDown") border_atts[XML.tag(side)]["direction"] = "down" else - @assert false "No direction set for `diagonal` border" + throw(XLSXError("No direction set for `diagonal` border")) end end for subc in XML.children(side) # color is a child of a border element @@ -799,27 +490,38 @@ 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 -a named cell or named range in a worksheet or XLSXfile. +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]` - -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. +- `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 but it can be used together with `diagonal`. +keywords or with `outside` but it can be used together with `diagonal`. -The two attributes that can be set for each keyword are `style` and `rgb`. +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: @@ -838,8 +540,11 @@ Allowed values for `style` are: - `mediumDashDotDot` - `slantDashDot` -The `color` attribute is set by specifying an 8-digit hexadecimal value. -No other color attributes can be applied. +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 +([here](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 @@ -865,30 +570,126 @@ For cell ranges, column ranges and named ranges, the value returned is -1. ```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" => "FF765000"], + right = ["style" => "medium", "color" => "firebrick2"], top = ["style" => "thick", "color" => "FF230000"], - bottom = ["style" => "medium", "color" => "FF0000FF"], - diagonal = ["style" => "dotted", "color" => "FF00D4D4"] + bottom = ["style" => "medium", "color" => "goldenrod3"], + diagonal = ["style" => "dotted", "color" => "FF00D4D4", "direction" => "both"] ) ``` """ function setBorder end -setBorder(ws::Worksheet, rng::CellRange; kw...)::Int = process_cellranges(setBorder, ws, rng; kw...) -setBorder(ws::Worksheet, colrng::ColumnRange; kw...)::Int = process_columnranges(setBorder, ws, colrng; kw...) +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(xl::XLSXFile, sheetcell::String; kw...)::Int = process_sheetcell(setBorder, xl, sheetcell; 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::Union{Nothing,Vector{Pair{String,String}}}=nothing, + allsides::Union{Nothing,Vector{Pair{String,String}}}=nothing, + left::Union{Nothing,Vector{Pair{String,String}}}=nothing, + right::Union{Nothing,Vector{Pair{String,String}}}=nothing, + top::Union{Nothing,Vector{Pair{String,String}}}=nothing, + bottom::Union{Nothing,Vector{Pair{String,String}}}=nothing, + diagonal::Union{Nothing,Vector{Pair{String,String}}}=nothing +)::Int + if isnothing(outside) + return process_cellranges(setBorder, ws, rng; allsides, left, right, top, bottom, diagonal) + else + if !all(isnothing, [left, right, top, bottom, diagonal, allsides]) + throw(XLSXError("Keyword `outside` is incompatible with any other keywords.")) + end + return setOutsideBorder(ws, rng; outside) + end +end +function setBorder(ws::Worksheet, colrng::ColumnRange; + outside::Union{Nothing,Vector{Pair{String,String}}}=nothing, + allsides::Union{Nothing,Vector{Pair{String,String}}}=nothing, + left::Union{Nothing,Vector{Pair{String,String}}}=nothing, + right::Union{Nothing,Vector{Pair{String,String}}}=nothing, + top::Union{Nothing,Vector{Pair{String,String}}}=nothing, + bottom::Union{Nothing,Vector{Pair{String,String}}}=nothing, + diagonal::Union{Nothing,Vector{Pair{String,String}}}=nothing +)::Int + if isnothing(outside) + return process_columnranges(setBorder, ws, colrng; allsides, left, right, top, bottom, diagonal) + else + if !all(isnothing, [left, right, top, bottom, diagonal, allsides]) + throw(XLSXError("Keyword `outside` is incompatible with any other keywords")) + end + return process_columnranges(setOutsideBorder, ws, colrng; outside) + end +end +function setBorder(ws::Worksheet, rowrng::RowRange; + outside::Union{Nothing,Vector{Pair{String,String}}}=nothing, + allsides::Union{Nothing,Vector{Pair{String,String}}}=nothing, + left::Union{Nothing,Vector{Pair{String,String}}}=nothing, + right::Union{Nothing,Vector{Pair{String,String}}}=nothing, + top::Union{Nothing,Vector{Pair{String,String}}}=nothing, + bottom::Union{Nothing,Vector{Pair{String,String}}}=nothing, + diagonal::Union{Nothing,Vector{Pair{String,String}}}=nothing +)::Int + if isnothing(outside) + return process_rowranges(setBorder, ws, rowrng; allsides, left, right, top, bottom, diagonal) + else + if !all(isnothing, [left, right, top, bottom, diagonal, allsides]) + throw(XLSXError("Keyword `outside` is incompatible with any other keywords except `diagonal`.")) + end + return process_rowranges(setOutsideBorder, ws, rowrng; outside) + end +end +function setBorder(xl::XLSXFile, sheetcell::String; + outside::Union{Nothing,Vector{Pair{String,String}}}=nothing, + allsides::Union{Nothing,Vector{Pair{String,String}}}=nothing, + left::Union{Nothing,Vector{Pair{String,String}}}=nothing, + right::Union{Nothing,Vector{Pair{String,String}}}=nothing, + top::Union{Nothing,Vector{Pair{String,String}}}=nothing, + bottom::Union{Nothing,Vector{Pair{String,String}}}=nothing, + diagonal::Union{Nothing,Vector{Pair{String,String}}}=nothing +)::Int + if isnothing(outside) + return process_sheetcell(setBorder, xl, sheetcell; allsides, left, right, top, bottom, diagonal) + else + if !all(isnothing, [left, right, top, bottom, diagonal, allsides]) + throw(XLSXError("Keyword `outside` is incompatible with any other keywords except `diagonal`.")) + end + return process_sheetcell(setOutsideBorder, xl, sheetcell; outside) + end +end function setBorder(sh::Worksheet, cellref::CellRef; - allsides::Union{Nothing,Vector{Pair{String,String}}}=nothing, - left::Union{Nothing,Vector{Pair{String,String}}}=nothing, - right::Union{Nothing,Vector{Pair{String,String}}}=nothing, - top::Union{Nothing,Vector{Pair{String,String}}}=nothing, - bottom::Union{Nothing,Vector{Pair{String,String}}}=nothing, - diagonal::Union{Nothing,Vector{Pair{String,String}}}=nothing - )::Int + allsides::Union{Nothing,Vector{Pair{String,String}}}=nothing, + left::Union{Nothing,Vector{Pair{String,String}}}=nothing, + right::Union{Nothing,Vector{Pair{String,String}}}=nothing, + top::Union{Nothing,Vector{Pair{String,String}}}=nothing, + bottom::Union{Nothing,Vector{Pair{String,String}}}=nothing, + diagonal::Union{Nothing,Vector{Pair{String,String}}}=nothing +)::Int + + if !get_xlsxfile(sh).use_cache_for_sheet_data + throw(XLSXError("Cannot set borders because cache is not enabled.")) + end - @assert get_xlsxfile(sh).use_cache_for_sheet_data "Cannot set borders because cache is not enabled." + if !isnothing(allsides) + if !all(isnothing, [left, right, top, bottom]) + throw(XLSXError("Keyword `allsides` is incompatible with any other keywords except `diagonal`.")) + end + return setBorder(sh, cellref; left=allsides, right=allsides, top=allsides, bottom=allsides, diagonal=diagonal) + end kwdict = Dict{String,Union{Dict{String,String},Nothing}}() kwdict["allsides"] = isnothing(allsides) ? nothing : Dict{String,String}(p for p in allsides) @@ -898,21 +699,20 @@ function setBorder(sh::Worksheet, cellref::CellRef; kwdict["bottom"] = isnothing(bottom) ? nothing : Dict{String,String}(p for p in bottom) kwdict["diagonal"] = isnothing(diagonal) ? nothing : Dict{String,String}(p for p in diagonal) - if !isnothing(allsides) - @assert all(isnothing, [left, right, top, bottom]) "Keyword `allsides` is incompatible with any other keywords except `diagonal`." - return setBorder(sh, cellref; left=allsides, right=allsides, top=allsides, bottom=allsides, diagonal=diagonal) - end - wb = get_workbook(sh) cell = getcell(sh, cellref) - @assert !(cell isa EmptyCell) "Cannot set border for an `EmptyCell`: $(cellref.name). Set the value first." + if cell isa EmptyCell + throw(XLSXError("Cannot set border for an `EmptyCell`: $(cellref.name). Set the value first.")) + end + + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(wb)) if cell.style == "" - cell.style = string(get_num_style_index(sh, 0).id) + cell.style = string(get_num_style_index(sh, allXfNodes, 0).id) end - cell_style = styles_cell_xf(wb, parse(Int, cell.style)) + cell_style = styles_cell_xf(allXfNodes, parse(Int, cell.style)) new_border_atts = Dict{String,Union{Dict{String,String},Nothing}}() cell_borders = getBorder(wb, cell_style) @@ -921,38 +721,45 @@ function setBorder(sh::Worksheet, cellref::CellRef; for a in ["left", "right", "top", "bottom", "diagonal"] new_border_atts[a] = Dict{String,String}() - if isnothing(kwdict[a]) && haskey(old_border_atts, a) - new_border_atts[a] = old_border_atts[a] - elseif !isnothing(kwdict[a]) - if !haskey(kwdict[a], "style") && haskey(old_border_atts, a) && haskey(old_border_atts[a], "style") - new_border_atts[a]["style"] = old_border_atts[a]["style"] - elseif haskey(kwdict[a], "style") - @assert kwdict[a]["style"] ∈ ["none", "thin", "medium", "dashed", "dotted", "thick", "double", "hair", "mediumDashed", "dashDot", "mediumDashDot", "dashDotDot", "mediumDashDotDot", "slantDashDot"] "Invalid style: $v. Must be one of: `none`, `thin`, `medium`, `dashed`, `dotted`, `thick`, `double`, `hair`, `mediumDashed`, `dashDot`, `mediumDashDot`, `dashDotDot`, `mediumDashDotDot`, `slantDashDot`." - new_border_atts[a]["style"] = kwdict[a]["style"] - end - if a == "diagonal" - if !haskey(kwdict[a], "direction") - if haskey(old_border_atts, a) && !isnothing(old_border_atts[a]) && haskey(old_border_atts[a], "direction") - new_border_atts[a]["direction"] = old_border_atts[a]["direction"] - else - new_border_atts[a]["direction"] = "both" # default if direction not specified or inherited + if !isnothing(old_border_atts) # Need to merge new into old atts + if isnothing(kwdict[a]) && haskey(old_border_atts, a) + new_border_atts[a] = old_border_atts[a] + elseif !isnothing(kwdict[a]) + if !haskey(kwdict[a], "style") && haskey(old_border_atts, a) && !isnothing(old_border_atts[a]) && haskey(old_border_atts[a], "style") + new_border_atts[a]["style"] = old_border_atts[a]["style"] + elseif haskey(kwdict[a], "style") + if kwdict[a]["style"] ∉ ["none", "thin", "medium", "dashed", "dotted", "thick", "double", "hair", "mediumDashed", "dashDot", "mediumDashDot", "dashDotDot", "mediumDashDotDot", "slantDashDot"] + throw(XLSXError("Invalid style: $v. Must be one of: `none`, `thin`, `medium`, `dashed`, `dotted`, `thick`, `double`, `hair`, `mediumDashed`, `dashDot`, `mediumDashDot`, `dashDotDot`, `mediumDashDotDot`, `slantDashDot`.")) end - elseif haskey(kwdict[a], "direction") - @assert kwdict[a]["direction"] ∈ ["up", "down", "both"] "Invalid direction: $v. Must be one of: `up`, `down`, `both`." - new_border_atts[a]["direction"] = kwdict[a]["direction"] + new_border_atts[a]["style"] = kwdict[a]["style"] end - end - if !haskey(kwdict[a], "color") && haskey(old_border_atts, a) && !isnothing(old_border_atts[a]) - for (k, v) in old_border_atts[a] - if k != "style" - new_border_atts[a][k] = v + if a == "diagonal" + if !haskey(kwdict[a], "direction") + if haskey(old_border_atts, a) && !isnothing(old_border_atts[a]) && haskey(old_border_atts[a], "direction") + new_border_atts[a]["direction"] = old_border_atts[a]["direction"] + else + new_border_atts[a]["direction"] = "both" # default if direction not specified or inherited + end + elseif haskey(kwdict[a], "direction") + if kwdict[a]["direction"] ∉ ["up", "down", "both"] + throw(XLSXError("Invalid direction: $v. Must be one of: `up`, `down`, `both`.")) + end + new_border_atts[a]["direction"] = kwdict[a]["direction"] + end + end + if !haskey(kwdict[a], "color") && haskey(old_border_atts, a) && !isnothing(old_border_atts[a]) + for (k, v) in old_border_atts[a] + if k != "style" + new_border_atts[a][k] = v + end end + elseif haskey(kwdict[a], "color") + v = kwdict[a]["color"] + new_border_atts[a]["rgb"] = get_color(v) end - elseif haskey(kwdict[a], "color") - v = kwdict[a]["color"] - @assert occursin(r"^[0-9A-F]{8}$", v) "Invalid color value: $v. Must be an 8-digit hexadecimal RGB value." - new_border_atts[a]["rgb"] = v end + else + new_border_atts = kwdict end end @@ -960,7 +767,7 @@ function setBorder(sh::Worksheet, cellref::CellRef; new_borderid = styles_add_cell_attribute(wb, border_node, "borders") - newstyle = string(update_template_xf(sh, CellDataFormat(parse(Int, cell.style)), ["borderId", "applyBorder"], ["$new_borderid", "1"]).id) + newstyle = string(update_template_xf(sh, allXfNodes, CellDataFormat(parse(Int, cell.style)), ["borderId", "applyBorder"], [string(new_borderid), "1"]).id) cell.style = newstyle return new_borderid end @@ -969,8 +776,12 @@ end setUniformBorder(sh::Worksheet, cr::String; kw...) -> ::Int setUniformBorder(xf::XLSXFile, cr::String, kw...) -> ::Int -Set the border used by a cell range, a column range or a named range in a -worksheet or XLSXfile to be uniformly the same border. + 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 @@ -980,7 +791,7 @@ 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 +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. @@ -990,90 +801,127 @@ 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 -Set the border around the outside of a cell range, a column range or a named -range in a worksheet or XLSXfile. + 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 `:`. -Two key words can be defined: -- `style::String = nothing` : defines the style of the outside border -- `color::String = nothing` : defines the color of the outside border +There is one key word: +- `outside::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 diagonal will remain unchanged. Border +borders of internal edges and any diagonals will remain unchanged. Border settings for all internal cells in the range will remain unchanged. -The value returned is is -1. +Top and bottom borders for column ranges and left and right borders for +row ranges are taken from the worksheet `dimension`. -For keyword definitions see [`setBorder()`](@ref). +An outside border cannot be set for a non-contiguous range. + +The value returned is is -1. # Examples: ```julia -Julia> setOutsideBorder(sh, "B2:D6"; style = "thick") +Julia> setOutsideBorder(sh, "B2:D6"; outside = ["style" => "thick") -Julia> setOutsideBorder(xf, "Sheet1!A1:F20"; style = "dotted", color = "FF000FF0") - +Julia> setOutsideBorder(xf, "Sheet1!A1:F20"; outside = ["style" => "dotted", "color" => "FF000FF0"]) ``` +This function is equivalent to `setBorder()` called with the same arguments and keywords. + """ 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...) -function setOutsideBorder(ws::Worksheet, rng::CellRange; - style::Union{String, Nothing}=nothing, - color::Union{String, Nothing}=nothing - )::Int - - @assert get_xlsxfile(ws).use_cache_for_sheet_data "Cannot set borders because cache is not enabled." - - topLeft = CellRef(rng.start.row_number, rng.start.column_number) - topRight = CellRef(rng.start.row_number, rng.stop.column_number) - bottomLeft = CellRef(rng.stop.row_number, rng.start.column_number) - bottomRight = CellRef(rng.stop.row_number, rng.stop.column_number) - if !isnothing(style) && !isnothing(color) - setBorder(ws, CellRange(topLeft, topRight); top= ["style" => style, "color" => color]) - setBorder(ws, CellRange(topLeft, bottomLeft); left= ["style" => style, "color" => color]) - setBorder(ws, CellRange(topRight, bottomRight); right= ["style" => style, "color" => color]) - setBorder(ws, CellRange(bottomLeft, bottomRight); bottom= ["style" => style, "color" => color]) - elseif !isnothing(style) - setBorder(ws, CellRange(topLeft, topRight); top= ["style" => style]) - setBorder(ws, CellRange(topLeft, bottomLeft); left= ["style" => style]) - setBorder(ws, CellRange(topRight, bottomRight); right= ["style" => style]) - setBorder(ws, CellRange(bottomLeft, bottomRight); bottom= ["style" => style]) - elseif !isnothing(color) - setBorder(ws, CellRange(topLeft, topRight); top= ["color" => color]) - setBorder(ws, CellRange(topLeft, bottomLeft); left= ["color" => color]) - setBorder(ws, CellRange(topRight, bottomRight); right= ["color" => color]) - setBorder(ws, CellRange(bottomLeft, bottomRight); bottom= ["color" => color]) +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 + + if !get_xlsxfile(ws).use_cache_for_sheet_data + throw(XLSXError("Cannot set borders because cache is not enabled.")) end - + + # length(rng) <= 1 && throw(XLSXError("Cannot set outside border for a single cell.")) + + kwdict = Dict{String,Union{Dict{String,String},Nothing}}() + kwdict["outside"] = Dict{String,String}(p for p in outside) + + + topLeft = CellRef(rng.start.row_number, rng.start.column_number) + topRight = CellRef(rng.start.row_number, rng.stop.column_number) + bottomLeft = CellRef(rng.stop.row_number, rng.start.column_number) + bottomRight = CellRef(rng.stop.row_number, rng.stop.column_number) + + 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 @@ -1086,8 +934,11 @@ end """ 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 @@ -1152,6 +1003,8 @@ needed, they will simply be ignored by Excel, and the default appearance will be ```julia julia> getFill(sh, "A1") +julia> getFill(sh, 3, 4) + julia> getFill(xf, "Sheet1!A1") ``` @@ -1160,29 +1013,40 @@ 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} - @assert get_xlsxfile(wb).use_cache_for_sheet_data "Cannot get fill because cache is not enabled." + if !get_xlsxfile(wb).use_cache_for_sheet_data + throw(XLSXError("Cannot get fill because cache is not enabled.")) + end if haskey(cell_style, "fillId") fillid = cell_style["fillId"] applyfill = haskey(cell_style, "applyFill") ? cell_style["applyFill"] : "0" xroot = styles_xmlroot(wb) - fill_elements = find_all_nodes("/$SPREADSHEET_NAMESPACE_XPATH_ARG:styleSheet/$SPREADSHEET_NAMESPACE_XPATH_ARG:fills", xroot)[begin] - @assert parse(Int, fill_elements["count"]) == length(XML.children(fill_elements)) "Unexpected number of font definitions found : $(length(XML.children(fill_elements))). Expected $(parse(Int, fill_elements["count"]))" + fill_elements = find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":fills", xroot)[begin] + if parse(Int, fill_elements["count"]) != length(XML.children(fill_elements)) + throw(XLSXError("Unexpected number of font definitions found : $(length(XML.children(fill_elements))). Expected $(parse(Int, fill_elements["count"]))")) + end current_fill = XML.children(fill_elements)[parse(Int, fillid)+1] # Zero based! fill_atts = Dict{String,Union{Dict{String,String},Nothing}}() for pattern in XML.children(current_fill) if isnothing(XML.attributes(pattern)) || length(XML.attributes(pattern)) == 0 fill_atts[XML.tag(pattern)] = nothing else - @assert length(XML.attributes(pattern)) == 1 "Too many fill attributes found for $(XML.tag(pattern)) Expected 1, found $(length(XML.attributes(pattern)))." + if length(XML.attributes(pattern)) != 1 + throw(XLSXError("Too many fill attributes found for $(XML.tag(pattern)) Expected 1, found $(length(XML.attributes(pattern))).")) + end for (k, v) in XML.attributes(pattern) # patternType is the only possible attribute of a fill fill_atts[XML.tag(pattern)] = Dict(k => v) for subc in XML.children(pattern) # foreground and background colors are children of a patternFill element - @assert !isnothing(XML.children(subc)) && length(XML.attributes(subc)) > 0 "Too few children found for $(XML.tag(subc)) Expected 1, found 0." - @assert length(XML.children(subc)) < 3 "Too many children found for $(XML.tag(subc)) Expected < 3, found $(length(XML.attributes(subc)))." + if !isnothing(XML.children(subc)) && length(XML.attributes(subc)) <= 0 + throw(XLSXError("Too few children found for $(XML.tag(subc)) Expected 1, found 0.")) + end + if length(XML.children(subc)) > 2 + throw(XLSXError("Too many children found for $(XML.tag(subc)) Expected < 3, found $(length(XML.attributes(subc))).")) + end tag = first(XML.tag(subc), 2) for (k, v) in XML.attributes(subc) fill_atts[XML.tag(pattern)][tag*k] = v @@ -1200,9 +1064,13 @@ 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 -a named cell or named range in a worksheet or XLSXfile. +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. @@ -1230,8 +1098,10 @@ Here is a list of the available `pattern` values (thanks to Copilot!): - `gray125` - `gray0625` -The two colors are set by specifying an 8-digit hexadecimal value for the `fgColor` -and/or `bgColor` keywords. No other color attributes can be applied. +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 +([here](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. @@ -1249,34 +1119,59 @@ For cell ranges, column ranges and named ranges, the value returned is -1. ```julia Julia> setFill(sh, "B2"; pattern="gray125", bgColor = "FF000000") -Julia> setFill(xf, "Sheet1!A1:F20"; pattern="none", fgColor = "88FF8800") +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 + pattern::Union{Nothing,String}=nothing, + fgColor::Union{Nothing,String}=nothing, + bgColor::Union{Nothing,String}=nothing, +)::Int - @assert get_xlsxfile(sh).use_cache_for_sheet_data "Cannot set fill because cache is not enabled." + if !get_xlsxfile(sh).use_cache_for_sheet_data + throw(XLSXError("Cannot set fill because cache is not enabled.")) + end wb = get_workbook(sh) cell = getcell(sh, cellref) - @assert !(cell isa EmptyCell) "Cannot set fill for an `EmptyCell`: $(cellref.name). Set the value first." + if cell isa EmptyCell + throw(XLSXError("Cannot set fill for an `EmptyCell`: $(cellref.name). Set the value first.")) + end + + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(wb)) if cell.style == "" - cell.style = string(get_num_style_index(sh, 0).id) + cell.style = string(get_num_style_index(sh, allXfNodes, 0).id) end - cell_style = styles_cell_xf(wb, parse(Int, cell.style)) - + cell_style = styles_cell_xf(allXfNodes, parse(Int, cell.style)) + new_fill_atts = Dict{String,Union{Dict{String,String},Nothing}}() patternFill = Dict{String,String}() @@ -1289,6 +1184,9 @@ function setFill(sh::Worksheet, cellref::CellRef; if isnothing(pattern) && haskey(old_fill_atts, "patternType") patternFill["patternType"] = old_fill_atts["patternType"] elseif !isnothing(pattern) + if pattern ∉ ["none", "solid", "mediumGray", "darkGray", "lightGray", "darkHorizontal", "darkVertical", "darkDown", "darkUp", "darkGrid", "darkTrellis", "lightHorizontal", "lightVertical", "lightDown", "lightUp", "lightGrid", "lightTrellis", "gray125", "gray0625"] + throw(XLSXError("Invalid style: $pattern. Must be one of: `none`, `solid`, `mediumGray`, `darkGray`, `lightGray`, `darkHorizontal`, `darkVertical`, `darkDown`, `darkUp`, `darkGrid`, `darkTrellis`, `lightHorizontal`, `lightVertical`, `lightDown`, `lightUp`, `lightGrid`, `lightTrellis`, `gray125`, `gray0625`.")) + end patternFill["patternType"] = pattern end elseif a == "fg" @@ -1299,8 +1197,7 @@ function setFill(sh::Worksheet, cellref::CellRef; end end else - @assert occursin(r"^[0-9A-F]{8}$", fgColor) "Invalid color value: $fgColor. Must be an 8-digit hexadecimal RGB value." - patternFill["fgrgb"] = fgColor + patternFill["fgrgb"] = get_color(fgColor) end elseif a == "bg" if isnothing(bgColor) @@ -1310,8 +1207,7 @@ function setFill(sh::Worksheet, cellref::CellRef; end end else - @assert occursin(r"^[0-9A-F]{8}$", bgColor) "Invalid color value: $bgColor. Must be an 8-digit hexadecimal RGB value." - patternFill["bgrgb"] = bgColor + patternFill["bgrgb"] = get_color(bgColor) end end end @@ -1321,7 +1217,7 @@ function setFill(sh::Worksheet, cellref::CellRef; new_fillid = styles_add_cell_attribute(wb, fill_node, "fills") - newstyle = string(update_template_xf(sh, CellDataFormat(parse(Int, cell.style)), ["fillId", "applyFill"], ["$new_fillid", "1"]).id) + newstyle = string(update_template_xf(sh, allXfNodes, CellDataFormat(parse(Int, cell.style)), ["fillId", "applyFill"], [string(new_fillid), "1"]).id) cell.style = newstyle return new_fillid end @@ -1330,8 +1226,12 @@ end setUniformFill(sh::Worksheet, cr::String; kw...) -> ::Int setUniformFill(xf::XLSXFile, cr::String, kw...) -> ::Int -Set the fill used by a cell range, a column range or a named range in a -worksheet or XLSXfile to be uniformly the same fill. + 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 @@ -1351,6 +1251,9 @@ 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. @@ -1360,14 +1263,31 @@ For keyword definitions see [`setFill()`](@ref). ```julia Julia> setUniformFill(sh, "B2:D4"; pattern="gray125", bgColor = "FF000000") -Julia> setUniformFill(xf, "Sheet1!A1:F20"; pattern="none", fgColor = "88FF8800") +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...) # @@ -1377,8 +1297,11 @@ setUniformFill(ws::Worksheet, rng::CellRange; kw...)::Int = process_uniform_attr """ 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) @@ -1418,6 +1341,8 @@ Excel supports the following values for the vertical alignment: ```julia julia> getAlignment(sh, "A1") +julia> getAlignment(sh, 2, 5) # Cell E2 + julia> getAlignment(xf, "Sheet1!A1") ``` @@ -1426,19 +1351,24 @@ 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)) #getDefaultAlignment(ws::Worksheet) = getAlignment(get_workbook(ws), styles_cell_xf(get_workbook(ws), 0)) function getAlignment(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellAlignment} - @assert get_xlsxfile(wb).use_cache_for_sheet_data "Cannot get alignment because cache is not enabled." + if !get_xlsxfile(wb).use_cache_for_sheet_data + throw(XLSXError("Cannot get alignment because cache is not enabled.")) + end if length(XML.children(cell_style)) == 0 # `alignment` is a child node of the cell `xf`. return nothing end - @assert length(XML.children(cell_style)) == 1 "Expected cell `xf` to have 1 child node, found $(length(XML.children(cell_style)))" - @assert XML.tag(cell_style[1]) == "alignment" "Error cell alignment found but it has no attributes!" + if length(XML.children(cell_style)) != 1 + throw(XLSXError("Expected cell style to have 1 child node, found $(length(XML.children(cell_style)))")) + end + XML.tag(cell_style[1]) != "alignment" && throw(XLSXError("Cell style has a child node but it is not for alignment!")) atts = Dict{String,String}() for (k, v) in XML.attributes(cell_style[1]) - atts[k]=v + atts[k] = v end alignment_atts = Dict{String,Union{Dict{String,String},Nothing}}() alignment_atts["alignment"] = atts @@ -1449,9 +1379,14 @@ 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 -a named cell or named range in a worksheet or XLSXfile. +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. @@ -1493,35 +1428,60 @@ 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...) -function setAlignment(sh::Worksheet, cellref::CellRef; +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 +)::Int - @assert get_xlsxfile(sh).use_cache_for_sheet_data "Cannot set alignment because cache is not enabled." + if !get_xlsxfile(sh).use_cache_for_sheet_data + throw(XLSXError("Cannot set alignment because cache is not enabled.")) + end wb = get_workbook(sh) cell = getcell(sh, cellref) - @assert !(cell isa EmptyCell) "Cannot set fill for an `EmptyCell`: $(cellref.name). Set the value first." + if cell isa EmptyCell + throw(XLSXError("Cannot set alignment for an `EmptyCell`: $(cellref.name). Set the value first.")) + end + + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(wb)) if cell.style == "" - cell.style = string(get_num_style_index(sh, 0).id) + cell.style = string(get_num_style_index(sh, allXfNodes, 0).id) end - cell_style = styles_cell_xf(wb, parse(Int, cell.style)) + cell_style = styles_cell_xf(allXfNodes, parse(Int, cell.style)) atts = XML.OrderedDict{String,String}() cell_alignment = getAlignment(wb, cell_style) @@ -1531,12 +1491,12 @@ function setAlignment(sh::Worksheet, cellref::CellRef; old_applyAlignment = cell_alignment.applyAlignment end - @assert isnothing(horizontal) || horizontal ∈ ["left", "center", "right", "fill", "justify", "centerContinuous", "distributed"] "Invalid horizontal alignment: $horizontal. Must be one of: `left`, `center`, `right`, `fill`, `justify`, `centerContinuous`, `distributed`." - @assert isnothing(vertical) || vertical ∈ ["top", "center", "bottom", "justify", "distributed"] "Invalid vertical aligment: $vertical. Must be one of: `top`, `center`, `bottom`, `justify`, `distributed`." - @assert isnothing(wrapText) || wrapText ∈ [true, false] "Invalid wrap option: $wrapText. Must be one of: `true`, `false`." - @assert isnothing(shrink) || shrink ∈ [true, false] "Invalid shrink option: $shrink. Must be one of: `true`, `false`." - @assert isnothing(indent) || indent > 0 "Invalid indent value specified: $indent. Must be a postive integer." - @assert isnothing(rotation) || rotation ∈ -90:90 "Invalid rotation value specified: $rotation. Must be an integer between -90 and 90." + !isnothing(horizontal) && horizontal ∉ ["left", "center", "right", "fill", "justify", "centerContinuous", "distributed"] && throw(XLSXError("Invalid horizontal alignment: $horizontal. Must be one of: `left`, `center`, `right`, `fill`, `justify`, `centerContinuous`, `distributed`.")) + !isnothing(vertical) && vertical ∉ ["top", "center", "bottom", "justify", "distributed"] && throw(XLSXError("Invalid vertical aligment: $vertical. Must be one of: `top`, `center`, `bottom`, `justify`, `distributed`.")) + !isnothing(wrapText) && wrapText ∉ [true, false] && throw(XLSXError("Invalid wrap option: $wrapText. Must be one of: `true`, `false`.")) + !isnothing(shrink) && shrink ∉ [true, false] && throw(XLSXError("Invalid shrink option: $shrink. Must be one of: `true`, `false`.")) + !isnothing(indent) && indent < 0 && throw(XLSXError("Invalid indent value specified: $indent. Must be a postive integer.")) + !isnothing(rotation) && rotation ∉ -90:90 && throw(XLSXError("Invalid rotation value specified: $rotation. Must be an integer between -90 and 90.")) if isnothing(horizontal) && !isnothing(cell_alignment) && haskey(old_alignment_atts, "horizontal") atts["horizontal"] = old_alignment_atts["horizontal"] @@ -1571,7 +1531,7 @@ function setAlignment(sh::Worksheet, cellref::CellRef; alignment_node = XML.Node(XML.Element, "alignment", atts, nothing, nothing) - newstyle = string(update_template_xf(sh, CellDataFormat(parse(Int, cell.style)), alignment_node).id) + newstyle = string(update_template_xf(sh, allXfNodes, CellDataFormat(parse(Int, cell.style)), alignment_node).id) cell.style = newstyle return parse(Int, newstyle) @@ -1581,8 +1541,12 @@ end setUniformAlignment(sh::Worksheet, cr::String; kw...) -> ::Int setUniformAlignment(xf::XLSXFile, cr::String, kw...) -> ::Int -Set the alignment used by a cell range, a column range or a named range in a -worksheet or XLSXfile to be uniformly the same alignment. + 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 @@ -1602,6 +1566,9 @@ 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. @@ -1613,13 +1580,32 @@ For keyword definitions see [`setAlignment()`](@ref). 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...) # @@ -1629,8 +1615,11 @@ setUniformAlignment(ws::Worksheet, rng::CellRange; kw...)::Int = process_uniform """ 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 @@ -1649,6 +1638,8 @@ the format for built-in formats, too. julia> getFormat(sh, "A1") julia> getFormat(xf, "Sheet1!A1") + +julia> getFormat(sh, 1, 1) ``` """ @@ -1656,10 +1647,13 @@ 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)) #getDefaultFill(ws::Worksheet) = getFormat(get_workbook(ws), styles_cell_xf(get_workbook(ws), 0)) function getFormat(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellFormat} - @assert get_xlsxfile(wb).use_cache_for_sheet_data "Cannot get number formats because cache is not enabled." + if !get_xlsxfile(wb).use_cache_for_sheet_data + throw(XLSXError("Cannot get number formats because cache is not enabled.")) + end if haskey(cell_style, "numFmtId") numfmtid = cell_style["numFmtId"] @@ -1667,17 +1661,22 @@ function getFormat(wb::Workbook, cell_style::XML.Node)::Union{Nothing,CellFormat format_atts = Dict{String,Union{Dict{String,String},Nothing}}() if parse(Int, numfmtid) >= PREDEFINED_NUMFMT_COUNT xroot = styles_xmlroot(wb) - format_elements = find_all_nodes("/$SPREADSHEET_NAMESPACE_XPATH_ARG:styleSheet/$SPREADSHEET_NAMESPACE_XPATH_ARG:numFmts", xroot)[begin] - @assert parse(Int, format_elements["count"]) == length(XML.children(format_elements)) "Unexpected number of format definitions found : $(length(XML.children(format_elements))). Expected $(parse(Int, format_elements["count"]))" - current_format = XML.children(format_elements)[parse(Int, numfmtid)+1-PREDEFINED_NUMFMT_COUNT] # Zero based! - @assert length(XML.attributes(current_format)) == 2 "Wrong number of attributes found for $(XML.tag(current_format)) Expected 2, found $(length(XML.attributes(current_format)))." + format_elements = find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":numFmts", xroot)[begin] + if parse(Int, format_elements["count"]) != length(XML.children(format_elements)) + throw(XLSXError("Unexpected number of format definitions found : $(length(XML.children(format_elements))). Expected $(parse(Int, format_elements["count"]))")) + end + current_format = [x for x in XML.children(format_elements) if x["numFmtId"] == numfmtid][1] + if length(XML.attributes(current_format)) != 2 + throw(XLSXError("Wrong number of attributes found for $(XML.tag(current_format)) Expected 2, found $(length(XML.attributes(current_format))).")) + end for (k, v) in XML.attributes(current_format) format_atts[XML.tag(current_format)] = Dict(k => XML.unescape(v)) end else -# any(num in r for r in ranges) ranges = [0:22, 37:40, 45:49] - @assert any(parse(Int, numfmtid) == n for r ∈ ranges for n ∈ r) "Expected a built in format ID in the following ranges: 1:22, 37:40, 45:49. Got $numfmtid." + if !any(parse(Int, numfmtid) == n for r ∈ ranges for n ∈ r) + throw(XLSXError("Expected a built in format ID in the following ranges: 1:22, 37:40, 45:49. Got $numfmtid.")) + end if haskey(builtinFormats, numfmtid) format_atts["numFmt"] = Dict("numFmtId" => numfmtid, "formatCode" => builtinFormats[numfmtid]) end @@ -1691,9 +1690,13 @@ end """ setFormat(sh::Worksheet, cr::String; kw...) -> ::Int setFormat(xf::XLSXFile, cr::String; kw...) -> ::Int + + setFormat(sh::Worksheet, row, col; kw...) -> ::Int -Set the format used used by a single cell, a cell range, a column range or -a named cell or named range in a worksheet or XLSXfile. +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 @@ -1725,33 +1728,58 @@ 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_-;_-£* \\\"-\\\"??_-;_-@_-") ``` """ 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 + format::Union{Nothing,String}=nothing, +)::Int - @assert get_xlsxfile(sh).use_cache_for_sheet_data "Cannot set number formats because cache is not enabled." + if !get_xlsxfile(sh).use_cache_for_sheet_data + throw(XLSXError("Cannot set number formats because cache is not enabled.")) + end wb = get_workbook(sh) cell = getcell(sh, cellref) - @assert !(cell isa EmptyCell) "Cannot set format for an `EmptyCell`: $(cellref.name). Set the value first." + if cell isa EmptyCell + throw(XLSXError("Cannot set format for an `EmptyCell`: $(cellref.name). Set the value first.")) + end + + allXfNodes=find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", styles_xmlroot(wb)) if cell.style == "" - cell.style = string(get_num_style_index(sh, 0).id) + cell.style = string(get_num_style_index(sh, allXfNodes, 0).id) end - cell_style = styles_cell_xf(wb, parse(Int, cell.style)) - -# new_format_atts = Dict{String,Union{Dict{String,String},Nothing}}() + cell_style = styles_cell_xf(allXfNodes, parse(Int, cell.style)) + + # new_format_atts = Dict{String,Union{Dict{String,String},Nothing}}() new_format = XML.OrderedDict{String,String}() cell_format = getFormat(wb, cell_style) @@ -1759,43 +1787,21 @@ function setFormat(sh::Worksheet, cellref::CellRef; old_applyNumberFormat = cell_format.applyNumberFormat if isnothing(format) # User didn't specify any format so this is a no-op - return cell_format.formatId + return cell_format.numFmtId end - if haskey(builtinFormatNames, uppercasefirst(format)) # User specified a format by name - new_formatid = builtinFormatNames[uppercasefirst(format)] - else # user specified a format code - code = lowercase(format) - code = remove_formatting(code) - @assert occursin(floatformats, code) || any(map(x->occursin(x, code), DATETIME_CODES)) "Specified format is not a valid numFmt: $format" - - xroot = styles_xmlroot(wb) - i, j = get_idces(xroot, "styleSheet", "numFmts") - if isnothing(j) # There are no existing custom formats - new_formatid = styles_add_numFmt(wb, format) - else - existing_elements_count = length(XML.children(xroot[i][j])) - @assert parse(Int, xroot[i][j]["count"]) == existing_elements_count "Wrong number of font elements found: $existing_elements_count. Expected $(parse(Int, xroot[i][j]["count"]))." - - format_node = XML.Element("numFmt"; - numFmtId = string(existing_elements_count + PREDEFINED_NUMFMT_COUNT), - formatCode = xlsx_escape(format) - ) - - new_formatid = styles_add_cell_attribute(wb, format_node, "numFmts") + PREDEFINED_NUMFMT_COUNT - end - end + new_formatid = get_new_formatId(wb, format) if new_formatid == 0 atts = ["numFmtId"] - vals = ["$new_formatid"] + vals = [string(new_formatid)] else atts = ["numFmtId", "applyNumberFormat"] - vals = ["$new_formatid", "1"] + vals = [string(new_formatid), "1"] end - newstyle = string(update_template_xf(sh, CellDataFormat(parse(Int, cell.style)), atts, vals).id) + newstyle = string(update_template_xf(sh, allXfNodes, CellDataFormat(parse(Int, cell.style)), atts, vals).id) cell.style = newstyle - + return new_formatid end @@ -1803,8 +1809,12 @@ end setUniformFormat(sh::Worksheet, cr::String; kw...) -> ::Int setUniformFormat(xf::XLSXFile, cr::String, kw...) -> ::Int -Set the number format used by a cell range, a column range or a named range in a -worksheet or XLSXfile to be to be uniformly the same format. + 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 @@ -1815,6 +1825,9 @@ 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 very marginally 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. @@ -1825,14 +1838,31 @@ For keyword definitions see [`setFormat()`](@ref). 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, rng::CellRange; kw...)::Int = process_uniform_attribute(setFormat, ws, 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 @@ -1842,9 +1872,13 @@ setUniformFormat(ws::Worksheet, rng::CellRange; kw...)::Int = process_uniform_at setUniformStyle(sh::Worksheet, cr::String) -> ::Int setUniformStyle(xf::XLSXFile, cr::String) -> ::Int -Set the cell `style` used by a cell range, a column 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`. + 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`. @@ -1863,37 +1897,52 @@ If all cells in the range are `EmptyCells`, the returned value is -1. 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)::Int = process_columnranges(setUniformStyle, ws, colrng) +setUniformStyle(ws::Worksheet, rowrng::RowRange)::Int = process_rowranges(setUniformStyle, ws, rowrng) +setUniformStyle(ws::Worksheet, ncrng::NonContiguousRange)::Int = process_uniform_ncranges(ws, ncrng) setUniformStyle(xl::XLSXFile, sheetcell::AbstractString)::Int = process_sheetcell(setUniformStyle, xl, sheetcell) setUniformStyle(ws::Worksheet, ref_or_rng::AbstractString)::Int = process_ranges(setUniformStyle, ws, ref_or_rng) -function setUniformStyle(ws::Worksheet, rng::CellRange)::Union{Nothing, Int} +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} + + if !get_xlsxfile(ws).use_cache_for_sheet_data + throw(XLSXError("Cannot set styles because cache is not enabled.")) + end - @assert get_xlsxfile(ws).use_cache_for_sheet_data "Cannot set styles because cache is not enabled." + let newid::Union{Nothing,Int}, + newid = nothing - let newid::Union{Nothing, Int}, first = true + for cellref in rng - cell = getcell(ws, cellref) - if cell isa EmptyCell # Can't add a attribute to an empty cell. - continue - end - if first # Get the style of the first cell in the range. - newid = cell.style - first = false - else # Apply the same style to the rest of the cells in the range. - cell.style = newid - end + newid, first = process_uniform_core(ws, cellref, newid, first) end if first newid = -1 end - return isnothing(newID) ? nothing : newid + return isnothing(newid) ? nothing : newid end -end +end # # -- Get and set column width @@ -1903,11 +1952,16 @@ end setColumnWidth(sh::Worksheet, cr::String; kw...) -> ::Int setColumnWidth(xf::XLSXFile, cr::String, kw...) -> ::Int + setColumnWidth(sh::Worksheet, row, 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 function uses one keyword used to define a column width: - `width::Real = nothing` : Defines width in Excel's own (internal) units @@ -1925,7 +1979,8 @@ You can set a column width to 0. The function returns a value of 0. NOTE: Unlike the other `set` and `get` XLSX functions, working with `ColumnWidth` requires -a file to be open for writing as well as reading (`mode="rw"` or open as a template) +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 @@ -1938,33 +1993,53 @@ 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::Worksheet, 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...) +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...) +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 - - @assert get_xlsxfile(ws).is_writable "Cannot set column widths: `XLSXFile` is not writable." - # Because we are working on worksheet data directly, we need to update the xml file using the worksheet cache first. - update_worksheets_xml!(get_xlsxfile(ws)) + if !get_xlsxfile(ws).is_writable + throw(XLSXError("Cannot set column widths: `XLSXFile` is not writable.")) + end - left = rng.start.column_number + left = rng.start.column_number right = rng.stop.column_number padded_width = isnothing(width) ? -1 : width + 0.7109375 # Excel adds cell padding to a user specified width - @assert isnothing(width) || width >= 0 "Invalid value specified for width: $width. Width must be >= 0." + if !isnothing(width) && width < 0 + throw(XLSXError("Invalid value specified for width: $width. Width must be >= 0.")) + end if isnothing(width) # No-op return 0 end - sheetdoc = xmlroot(ws.package, "xl/worksheets/sheet$(ws.sheetId).xml") # find the block in the worksheet's xml file + sheetdoc = xmlroot(ws.package, "xl/worksheets/sheet" * string(ws.sheetId) * ".xml") # find the block in the worksheet's xml file i, j = get_idces(sheetdoc, "worksheet", "cols") if isnothing(j) # There are no existing column formats. Insert before the block and push everything else down one. k, l = get_idces(sheetdoc, "worksheet", "sheetData") len = length(sheetdoc[k]) - @assert i==k "Some problem here!" + i != k && throw(XLSXError("Some problem here!")) push!(sheetdoc[k], sheetdoc[k][end]) if l < len for pos = len-1:-1:l @@ -1972,7 +2047,7 @@ function setColumnWidth(ws::Worksheet, rng::CellRange; width::Union{Nothing,Real end end sheetdoc[k][l] = XML.Element("Cols") - j=l + j = l end child_list = Dict{String,Union{Dict{String,String},Nothing}}() @@ -1988,13 +2063,13 @@ function setColumnWidth(ws::Worksheet, rng::CellRange; width::Union{Nothing,Real end else if padded_width >= 0 # Add new definitions where there is not one extant - scol=string(col) + scol = string(col) push!(child_list, scol => Dict("max" => scol, "min" => scol, "width" => string(padded_width), "customWidth" => "1")) end end end - new_cols = unlink_cols(sheetdoc[i][j]) # Create the new Node + new_cols = unlink(sheetdoc[i][j], ("cols", "col")) # Create the new Node for atts in values(child_list) new_col = XML.Element("col") for (k, v) in atts @@ -2005,6 +2080,9 @@ function setColumnWidth(ws::Worksheet, rng::CellRange; width::Union{Nothing,Real sheetdoc[i][j] = new_cols # Update the worksheet with the new cols. + # Because we are working on worksheet data directly, we need to update the xml file using the worksheet cache. + update_worksheets_xml!(get_xlsxfile(ws)) + return 0 # meaningless return value. Int required to comply with reference decoding structure. end @@ -2012,7 +2090,10 @@ 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. @@ -2025,23 +2106,28 @@ does not have an explicitly defined width. 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? - @assert get_xlsxfile(ws).is_writable "Cannot get column width: `XLSXFile` is not writable." + if !get_xlsxfile(ws).is_writable + throw(XLSXError("Cannot get column width: `XLSXFile` is not writable.")) + end d = get_dimension(ws) - @assert cellref.row_number >= d.start.row_number && cellref.row_number <= d.stop.row_number "Cell specified is outside sheet dimension \"$d\"" - - # Because we are working on worksheet data directly, we need to update the xml file using the worksheet cache first. - update_worksheets_xml!(get_xlsxfile(ws)) + if cellref ∉ d + throw(XLSXError("Cell specified is outside sheet dimension `$d`")) + end - sheetdoc = xmlroot(ws.package, "xl/worksheets/sheet$(ws.sheetId).xml") # find the block in the worksheet's xml file + sheetdoc = xmlroot(ws.package, "xl/worksheets/sheet" * string(ws.sheetId) * ".xml") # find the block in the worksheet's xml file i, j = get_idces(sheetdoc, "worksheet", "cols") if isnothing(j) # There are no existing column formats defined. @@ -2069,11 +2155,15 @@ end 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. @@ -2098,50 +2188,78 @@ it returns a value of -1. ```julia julia> XLSX.setRowHeight(xf, "Sheet1!A2"; height = 50) -julia> XLSX.setRowHeight(sh, "F1:F5"; heighth = 0) +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 - @assert get_xlsxfile(ws).use_cache_for_sheet_data "Cannot set row heights because cache is not enabled." + if !get_xlsxfile(ws).use_cache_for_sheet_data + throw(XLSXError("Cannot set row heights because cache is not enabled.")) + end - top = rng.start.row_number + 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 - @assert isnothing(height) || height >= 0 "Invalid value specified for height: $height. Height must be >= 0." + 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 + first = true - for r in eachrow(ws) - if r.row in top:bottom - if haskey(ws.cache.row_ht, r.row) - ws.cache.row_ht[r.row] = padded_height - first = false - end + + for r in top:bottom + if haskey(ws.cache.row_ht, r) + ws.cache.row_ht[r] = padded_height + first=false end end - if first == true - return -1 # All rows were empty + if first == true + return -1 end - return 0 # meaningless return value. Int required to comply with reference decoding structure. + + 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. @@ -2156,27 +2274,350 @@ If the row is not found (an empty row), returns -1. 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} - @assert get_xlsxfile(ws).use_cache_for_sheet_data "Cannot get row height because cache is not enabled." + if !get_xlsxfile(ws).use_cache_for_sheet_data + throw(XLSXError("Cannot get row height because cache is not enabled.")) + end d = get_dimension(ws) - @assert cellref.row_number >= d.start.row_number && cellref.row_number <= d.stop.row_number "Cell specified is outside sheet dimension \"$d\"" + if cellref ∉ d + throw(XLSXError("Cell specified is outside sheet dimension `$d`")) + end + + if haskey(ws.cache.row_ht, cellref.row_number) + return ws.cache.row_ht[cellref.row_number] + end + + return -1 # Row specified not found (is empty) - for r in eachrow(ws) - if r.row == cellref.row_number - if haskey(ws.cache.row_ht, r.row) - return ws.cache.row_ht[r.row] +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\\tim\\Downloads\\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(ws.package, "xl/worksheets/sheet" * string(ws.sheetId) * ".xml") # 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. + return nothing + end + + c = XML.children(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. + +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 + + sheetdoc = xmlroot(ws.package, "xl/worksheets/sheet" * string(ws.sheetId) * ".xml") # 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 + push!(sheetdoc[k], sheetdoc[k][end]) + if l + 1 < len + for pos = len-1:-1:l+1 + sheetdoc[k][pos+1] = sheetdoc[k][pos] + end + end + sheetdoc[k][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.children(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 - return -1 # Row specified not found (is empty) + 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 diff --git a/src/cellref.jl b/src/cellref.jl index 07cac399..de632249 100644 --- a/src/cellref.jl +++ b/src/cellref.jl @@ -1,6 +1,6 @@ function CellRef(n::AbstractString) - @assert is_valid_cellname(n) "$n is not a valid CellRef." + !is_valid_cellname(n) && throw(XLSXError("$n is not a valid CellRef.")) column_name, row_number = split_cellname(n) return CellRef(n, row_number, decode_column_number(column_name)) end @@ -15,7 +15,7 @@ end function decode_column_number(column_name::AbstractString) :: Int local result::Int = 0 - @assert isascii(column_name) "$column_name is not a valid column name." + !isascii(column_name) && throw(XLSXError("$column_name is not a valid column name.")) num_characters = length(column_name) # this is safe, since `column_name` is encoded as ASCII iteration = 1 @@ -30,7 +30,9 @@ end # Converts column number to a column name. See also XLSX.decode_column_number. function encode_column_number(column_number::Int) :: String - @assert column_number > 0 && column_number <= EXCEL_MAX_COLS "Column number should be in the range from 1 to $EXCEL_MAX_COLS." + if column_number <= 0 && column_number > EXCEL_MAX_COLS + throw(XLSXError("Column number should be in the range from 1 to $EXCEL_MAX_COLS.")) + end third_letter_sequence = div(column_number - 26 - 1, 26*26) column_number = column_number - third_letter_sequence*(26*26) @@ -58,8 +60,10 @@ Base.string(c::CellRef) = c.name Base.show(io::IO, c::CellRef) = print(io, string(c)) Base.:(==)(c1::CellRef, c2::CellRef) = c1.name == c2.name Base.hash(c::CellRef) = hash(c.name) +Base.isless(c1::CellRef, c2::CellRef) = Base.isless(string(c1), string(c2)) const RGX_COLUMN_NAME = r"^[A-Z]?[A-Z]?[A-Z]$" +const RGX_ROW_NAME = r"^[1-9][0-9]*$" const RGX_CELLNAME = r"^[A-Z]+[0-9]+$" const RGX_CELLRANGE = r"^[A-Z]+[0-9]+:[A-Z]+[0-9]+$" @@ -75,28 +79,43 @@ function is_valid_column_name(n::AbstractString) :: Bool return true end +function is_valid_row_name(n::AbstractString) :: Bool + if !occursin(RGX_ROW_NAME, n) + return false + end + + row_number = parse(Int, n) + if row_number < 1 || row_number > EXCEL_MAX_ROWS + return false + end + + return true +end const RGX_CELLNAME_LEFT = r"^[A-Z]+" const RGX_CELLNAME_RIGHT = r"[0-9]+$" # Splits a string representing a cell name to its column name and row number. @inline function split_cellname(n::AbstractString) - @assert isascii(n) "$n is not a valid cell name." + !isascii(n) && throw(XLSXError("$n is not a valid cell name.")) for (i, c) in enumerate(n) if isdigit(c) # this block is safe since n is encoded as ASCII column_name = SubString(n, 1, i-1) row = parse(Int, SubString(n, i, length(n))) - return column_name, row end end - error("Couldn't split (column_name, row) for cellname $n.") + throw(XLSXError("Couldn't split (column_name, row) for cellname $n.")) end # Checks whether `n` is a valid name for a cell. function is_valid_cellname(n::AbstractString) :: Bool + if is_valid_non_contiguous_range(n) # Non-contiguous ranges are comma separated `CellRef-like` or `CellRange-like` strings + return false + end + if !occursin(RGX_CELLNAME, n) return false end @@ -131,11 +150,14 @@ julia> XLSX.split_cellrange("AB12:CD24") =# @inline function split_cellrange(n::AbstractString) s = split(n, ":") - @assert length(s) == 2 "$n is not a valid cell range." + length(s) != 2 && throw(XLSXError("$n is not a valid cell range.")) return s[1], s[2] end function is_valid_cellrange(n::AbstractString) :: Bool + if is_valid_non_contiguous_range(n) # Non-contiguous ranges are comma separated `CellRef-like` or `CellRange-like` strings + return false + end if !occursin(RGX_CELLRANGE, n) return false @@ -159,17 +181,18 @@ macro ref_str(ref) end function CellRange(r::AbstractString) - @assert occursin(RGX_CELLRANGE, r) "Invalid cell range: $r." + !occursin(RGX_CELLRANGE, r) && throw(XLSXError("Invalid cell range: $r.")) start_name, stop_name = split_cellrange(r) return CellRange(CellRef(start_name), CellRef(stop_name)) end CellRange(start_row::Integer, start_column::Integer, stop_row::Integer, stop_column::Integer) = CellRange(CellRef(start_row, start_column), CellRef(stop_row, stop_column)) -Base.string(cr::CellRange) = "$(string(cr.start)):$(string(cr.stop))" +Base.string(cr::CellRange) = string(cr.start)*":"*string(cr.stop) Base.show(io::IO, cr::CellRange) = print(io, string(cr)) Base.:(==)(cr1::CellRange, cr2::CellRange) = cr1.start == cr2.start && cr2.stop == cr2.stop Base.hash(cr::CellRange) = hash(cr.start) + hash(cr.stop) +Base.isless(cr1::CellRange, cr2::CellRange) = Base.isless(string(cr1), string(cr2)) # needed for tests macro range_str(cellrange) CellRange(cellrange) @@ -196,6 +219,43 @@ end # Checks whether `subrng` is a cell range contained in `rng`. Base.issubset(subrng::CellRange, rng::CellRange) :: Bool = in(subrng.start, rng) && in(subrng.stop, 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") + 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") + 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") + 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") + return true + end + if in(rng1.start, rng2) || in(rng1.stop, rng2) +# println("inside corner 1") + return true + end + if in(rng2.start, rng1) || in(rng2.stop, rng1) +# println("inside corner 2") + return true + end + if issubset(rng1, rng2) || issubset(rng2, rng1) +# println("subset") + return true + end + return false +end + function Base.size(rng::CellRange) top = row_number(rng.start) @@ -235,8 +295,9 @@ For example, for a range "B2:D4", we have: * "D4" relative position is (3, 3) =# + function relative_cell_position(ref::CellRef, rng::CellRange) - @assert ref ∈ rng "$ref is outside range $rng." + ref ∉ rng && throw(XLSXError("$ref is outside range $rng.")) top = row_number(rng.start) left = column_number(rng.start) @@ -247,17 +308,23 @@ function relative_cell_position(ref::CellRef, rng::CellRange) end # -# ColumnRange +# ColumnRange and RowRange # -Base.string(cr::ColumnRange) = "$(encode_column_number(cr.start)):$(encode_column_number(cr.stop))" +Base.string(cr::ColumnRange) = encode_column_number(cr.start)*":"*encode_column_number(cr.stop) Base.show(io::IO, cr::ColumnRange) = print(io, string(cr)) Base.:(==)(cr1::ColumnRange, cr2::ColumnRange) = cr1.start == cr2.start && cr2.stop == cr2.stop Base.hash(cr::ColumnRange) = hash(cr.start) + hash(cr.stop) Base.in(column_number::Integer, rng::ColumnRange) = rng.start <= column_number && column_number <= rng.stop +Base.string(cr::RowRange) = string(cr.start)*":"*string(cr.stop) +Base.show(io::IO, cr::RowRange) = print(io, string(cr)) +Base.:(==)(cr1::RowRange, cr2::RowRange) = cr1.start == cr2.start && cr2.stop == cr2.stop +Base.hash(cr::RowRange) = hash(cr.start) + hash(cr.stop) +Base.in(row_number::Integer, rng::RowRange) = rng.start <= row_number && row_number <= rng.stop + function relative_column_position(column_number::Integer, rng::ColumnRange) - @assert column_number ∈ rng "Column $column_number is outside range $rng." + column_number ∉ rng && throw(XLSXError("Column $column_number is outside range $rng.")) return column_number - rng.start + 1 end @@ -267,9 +334,14 @@ const RGX_COLUMN_RANGE = r"^[A-Z]?[A-Z]?[A-Z]:[A-Z]?[A-Z]?[A-Z]$" const RGX_COLUMN_RANGE_START = r"^[A-Z]+" const RGX_COLUMN_RANGE_STOP = r"[A-Z]+$" const RGX_SINGLE_COLUMN = r"^[A-Z]+$" +const RGX_ROW_RANGE = r"^[1-9][0-9]*:[1-9][0-9]*$" +const RGX_ROW_RANGE_START = r"^[1-9][0-9]*" +const RGX_ROW_RANGE_STOP = r"[1-9][0-9]*$" +const RGX_SINGLE_ROW = r"^[1-9][0-9]*$" # Returns tuple (column_name_start, column_name_stop). -@inline function split_column_range(n::AbstractString) +# Also works for row ranges (row_name_start, row_name_stop)! +@inline function split_sheet_range(n::AbstractString) if !occursin(":", n) return n, n else @@ -279,35 +351,56 @@ const RGX_SINGLE_COLUMN = r"^[A-Z]+$" end function is_valid_column_range(r::AbstractString) :: Bool - if occursin(RGX_SINGLE_COLUMN, r) return true end - if !occursin(RGX_COLUMN_RANGE, r) return false end - - start_name, stop_name = split_column_range(r) - + start_name, stop_name = split_sheet_range(r) if !is_valid_column_name(start_name) || !is_valid_column_name(stop_name) return false end - + return true +end +function is_valid_row_range(r::AbstractString) :: Bool + if occursin(RGX_SINGLE_ROW, r) + row_number = parse(Int, r) + if row_number <= 0 && row_number > EXCEL_MAX_ROWS + throw(XLSXError("Row number should be in the range from 1 to $EXCEL_MAX_ROWS.")) + end + return true + end + if !occursin(RGX_ROW_RANGE, r) + return false + end + start_name, stop_name = split_sheet_range(r) # Function works for row ranges too. + if !is_valid_row_name(start_name) || !is_valid_row_name(stop_name) + return false + end return true end +function RowRange(r::AbstractString) + !is_valid_row_range(r) && throw(XLSXError("Invalid row range: $r.")) + start_name, stop_name = split_sheet_range(r) + return RowRange(parse(Int, start_name), parse(Int, stop_name)) +end function ColumnRange(r::AbstractString) - @assert is_valid_column_range(r) "Invalid column range: $r." - start_name, stop_name = split_column_range(r) + !is_valid_column_range(r) && throw(XLSXError("Invalid column range: $r.")) + start_name, stop_name = split_sheet_range(r) return ColumnRange(decode_column_number(start_name), decode_column_number(stop_name)) end convert(::Type{ColumnRange}, str::AbstractString) = ColumnRange(str) convert(::Type{ColumnRange}, column_range::ColumnRange) = column_range +convert(::Type{RowRange}, str::AbstractString) = RowRange(str) +convert(::Type{RowRange}, row_range::RowRange) = row_range column_bounds(r::ColumnRange) = (r.start, r.stop) Base.length(r::ColumnRange) = r.stop - r.start + 1 +row_bounds(r::RowRange) = (r.start, r.stop) +Base.length(r::RowRange) = r.stop - r.start + 1 # ColumnRange iterator: element is a String with the column name, the state is the column number. function Base.iterate(itr::ColumnRange, state::Int=itr.start) @@ -318,6 +411,15 @@ function Base.iterate(itr::ColumnRange, state::Int=itr.start) return encode_column_number(state), state + 1 end +# RowRange iterator: element is a String with the row name (e.g. "1"). The state is the row number. +function Base.iterate(itr::RowRange, state::Int=itr.start) + if state > itr.stop + return nothing + end + + return string(state), state + 1 +end + # CellRange iterator: element is a CellRef, the state is a CellPosition. function Base.iterate(rng::CellRange, state::CellPosition=CellPosition(rng.start)) @@ -340,33 +442,109 @@ function Base.length(rng::CellRange) end # -# SheetCellRef, SheetCellRange, SheetColumnRange +# SheetCellRef, SheetCellRange, SheetColumnRange, SheetRowRange, NonContiguousRange # -Base.string(cr::SheetCellRef) = string(cr.sheet, "!", cr.cellref) +Base.string(cr::SheetCellRef) = string(quoteit(cr.sheet), "!", cr.cellref) Base.show(io::IO, cr::SheetCellRef) = print(io, string(cr)) Base.:(==)(cr1::SheetCellRef, cr2::SheetCellRef) = cr1.sheet == cr2.sheet && cr2.cellref == cr2.cellref Base.hash(cr::SheetCellRef) = hash(cr.sheet) + hash(cr.cellref) -Base.string(cr::SheetCellRange) = string(cr.sheet, "!", cr.rng) +Base.string(cr::SheetCellRange) = string(quoteit(cr.sheet), "!", cr.rng) Base.show(io::IO, cr::SheetCellRange) = print(io, string(cr)) Base.:(==)(cr1::SheetCellRange, cr2::SheetCellRange) = cr1.sheet == cr2.sheet && cr2.rng == cr2.rng Base.hash(cr::SheetCellRange) = hash(cr.sheet) + hash(cr.rng) -Base.string(cr::SheetColumnRange) = string(cr.sheet, "!", cr.colrng) +Base.string(cr::SheetColumnRange) = string(quoteit(cr.sheet), "!", cr.colrng) Base.show(io::IO, cr::SheetColumnRange) = print(io, string(cr)) Base.:(==)(cr1::SheetColumnRange, cr2::SheetColumnRange) = cr1.sheet == cr2.sheet && cr2.colrng == cr2.colrng Base.hash(cr::SheetColumnRange) = hash(cr.sheet) + hash(cr.colrng) +Base.string(cr::SheetRowRange) = string(quoteit(cr.sheet), "!", cr.rowrng) +Base.show(io::IO, cr::SheetRowRange) = print(io, string(cr)) +Base.:(==)(cr1::SheetRowRange, cr2::SheetRowRange) = cr1.sheet == cr2.sheet && cr2.rowrng == cr2.rowrng +Base.hash(cr::SheetRowRange) = hash(cr.sheet) + hash(cr.colrng) + +Base.string(cr::NonContiguousRange) = join([string(quoteit(cr.sheet), "!", x) for x in cr.rng],",") +Base.show(io::IO, cr::NonContiguousRange) = print(io, string(cr)) +Base.:(==)(cr1::NonContiguousRange, cr2::NonContiguousRange) = cr1.sheet == cr2.sheet && cr2.rng == cr2.rng +Base.hash(cr::NonContiguousRange) = hash(cr.sheet) + hash(cr.rng) + +function Base.in(ref::SheetCellRef, ncrng::NonContiguousRange) :: Bool + if ref.sheet != ncrng.sheet + return false + end + for r in ncrng.rng + if r isa CellRef + if ref.cellref == r + return true + end + else + if ref.cellref in r + return true + end + end + end + return false +end + +function nc_bounds(r::NonContiguousRange)::CellRange # Smallest rectangualar `CellRange` that contains all the elements in `r`. + top = EXCEL_MAX_ROWS + bottom = 0 + left = EXCEL_MAX_COLS + right = 0 + for rng in r.rng + if isa(rng, CellRef) + top = min(top, row_number(rng)) + bottom = max(bottom, row_number(rng)) + left = min(left, column_number(rng)) + right = max(right, column_number(rng)) + else + top = min(top, row_number(rng.start)) + bottom = max(bottom, row_number(rng.stop)) + left = min(left, column_number(rng.start)) + right = max(right, column_number(rng.stop)) + end + end + 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 + push!(allcells, rng.name) + else + for cell in rng + push!(allcells, cell.name) + end + end + end +# if rng isa CellRef +# s += 1 +# else +# s += length(rng) +# end +# end + return length(unique(allcells)) +end + const RGX_SHEET_CELLNAME = r"^.+![A-Z]+[0-9]+$" const RGX_SHEET_CELLRANGE = r"^.+![A-Z]+[0-9]+:[A-Z]+[0-9]+$" const RGX_SHEET_COLUMN_RANGE = r"^.+![A-Z]?[A-Z]?[A-Z]:[A-Z]?[A-Z]?[A-Z]$" +const RGX_SHEET_ROW_RANGE = r"^.+![1-9][0-9]*:[1-9][0-9]*$" const RGX_SHEET_CELLNAME_RIGHT = r"[A-Z]+[0-9]+$" const RGX_SHEET_CELLRANGE_RIGHT = r"[A-Z]+[0-9]+:[A-Z]+[0-9]+$" const RGX_SHEET_COLUMN_RANGE_RIGHT = r"[A-Z]?[A-Z]?[A-Z]:[A-Z]?[A-Z]?[A-Z]$" +const RGX_SHEET_ROW_RANGE_RIGHT = r"[1-9][0-9]*:[1-9][0-9]*$" function is_valid_sheet_cellname(n::AbstractString) :: Bool + + if is_valid_non_contiguous_range(n) # Non-contiguous ranges are comma separated `CellRef-like` or `CellRange-like` strings + return false + end + if !occursin(RGX_SHEET_CELLNAME, n) return false end @@ -380,6 +558,11 @@ function is_valid_sheet_cellname(n::AbstractString) :: Bool end function is_valid_sheet_cellrange(n::AbstractString) :: Bool + + if is_valid_non_contiguous_range(n) # Non-contiguous ranges are comma separated `CellRef-like` or `CellRange-like` strings + return false + end + if !occursin(RGX_SHEET_CELLRANGE, n) return false end @@ -404,13 +587,25 @@ function is_valid_sheet_column_range(n::AbstractString) :: Bool return true end +function is_valid_sheet_row_range(n::AbstractString) :: Bool + if !occursin(RGX_SHEET_ROW_RANGE, n) + return false + end + + row_range = match(RGX_SHEET_ROW_RANGE_RIGHT, n).match + if !is_valid_row_range(row_range) + return false + end + + return true +end const RGX_SHEET_PREFIX = r"^.+!" const RGX_CELLNAME_RIGHT_FIXED = r"\$[A-Z]+\$[0-9]+$" -const RGX_SHEET_CELNAME_RIGHT_FIXED = r"\$[A-Z]+\$[0-9]+:\$[A-Z]+\$[0-9]+$" +const RGX_SHEET_CELLNAME_RIGHT_FIXED = r"\$[A-Z]+\$[0-9]+:\$[A-Z]+\$[0-9]+$" function parse_sheetname_from_sheetcell_name(n::AbstractString) :: SubString - @assert occursin(RGX_SHEET_PREFIX, n) "$n is not a SheetCell reference." + !occursin(RGX_SHEET_PREFIX, n) && throw(XLSXError("$n is not a SheetCell reference.")) sheetname = match(RGX_SHEET_PREFIX, n).match sheetname = SubString(sheetname, firstindex(sheetname), prevind(sheetname, lastindex(sheetname))) return sheetname @@ -423,7 +618,7 @@ function SheetCellRef(n::AbstractString) fixed_cellname = match(RGX_CELLNAME_RIGHT_FIXED, n).match cellref = CellRef(replace(fixed_cellname, "\$" => "")) else - @assert is_valid_sheet_cellname(n) "$n is not a valid SheetCellRef." + !is_valid_sheet_cellname(n) && throw(XLSXError("$n is not a valid SheetCellRef.")) cellref = CellRef(match(RGX_SHEET_CELLNAME_RIGHT, n).match) end sheetname = parse_sheetname_from_sheetcell_name(n) @@ -434,10 +629,10 @@ function SheetCellRange(n::AbstractString) local cellrange::CellRange if is_valid_fixed_sheet_cellrange(n) - fixed_cellrange = match(RGX_SHEET_CELNAME_RIGHT_FIXED, n).match + fixed_cellrange = match(RGX_SHEET_CELLNAME_RIGHT_FIXED, n).match cellrange = CellRange(replace(fixed_cellrange, "\$" => "")) else - @assert is_valid_sheet_cellrange(n) "$n is not a valid SheetCellRange." + !is_valid_sheet_cellrange(n) && throw(XLSXError("$n is not a valid SheetCellRange.")) cellrange = CellRange(match(RGX_SHEET_CELLRANGE_RIGHT, n).match) end @@ -446,17 +641,111 @@ function SheetCellRange(n::AbstractString) end function SheetColumnRange(n::AbstractString) - @assert is_valid_sheet_column_range(n) "$n is not a valid SheetColumnRange." + !is_valid_sheet_column_range(n) && throw(XLSXError("$n is not a valid SheetColumnRange.")) column_range = match(RGX_SHEET_COLUMN_RANGE_RIGHT, n).match sheetname = parse_sheetname_from_sheetcell_name(n) return SheetColumnRange(sheetname, ColumnRange(column_range)) end +function SheetRowRange(n::AbstractString) + !is_valid_sheet_row_range(n) && throw(XLSXError("$n is not a valid SheetRowRange.")) + row_range = match(RGX_SHEET_ROW_RANGE_RIGHT, n).match + sheetname = parse_sheetname_from_sheetcell_name(n) + return SheetRowRange(sheetname, RowRange(row_range)) +end # Named ranges +const RGX_FIXED_CELLNAME = r"^\$[A-Z]+\$[0-9]+$" +const RGX_FIXED_CELLRANGE = r"^\$[A-Z]+\$[0-9]+:\$[A-Z]+\$[0-9]+$" const RGX_FIXED_SHEET_CELLNAME = r"^.+!\$[A-Z]+\$[0-9]+$" const RGX_FIXED_SHEET_CELLRANGE = r"^.+!\$[A-Z]+\$[0-9]+:\$[A-Z]+\$[0-9]+$" +is_valid_fixed_cellname(s::AbstractString) = occursin(RGX_FIXED_CELLNAME, s) +is_valid_fixed_cellrange(s::AbstractString) = occursin(RGX_FIXED_CELLRANGE, s) is_valid_fixed_sheet_cellname(s::AbstractString) = occursin(RGX_FIXED_SHEET_CELLNAME, s) is_valid_fixed_sheet_cellrange(s::AbstractString) = occursin(RGX_FIXED_SHEET_CELLRANGE, s) -is_non_contiguous_range(v) = occursin(",", string(v)) # Non-contiguous ranges are comma separated `SheetCellRef-like` or `SheetCellRange-like` strings +is_valid_non_contiguous_range(v::AbstractString) :: Bool = is_valid_non_contiguous_cellrange(v) || is_valid_non_contiguous_sheetcellrange(v) + +function is_valid_non_contiguous_sheetcellrange(v::AbstractString) :: Bool + + if !occursin(",", string(v)) # Non-contiguous ranges are comma separated `SheetCellRef-like` or `SheetCellRange-like` strings + return false + end + + ranges = split(v, ",") + for r in ranges + if !is_valid_sheet_cellname(r) && !is_valid_sheet_cellrange(r) && !is_valid_fixed_sheet_cellname(r) && !is_valid_fixed_sheet_cellrange(r) + return false + end + end + + firstsheet = parse_sheetname_from_sheetcell_name(ranges[begin]) + + if any(parse_sheetname_from_sheetcell_name(r) != firstsheet for r in ranges) # All `SheetCellRef`s and `SheetCellRange`s should have the same sheet name + return false + end + + return true +end + +function is_valid_non_contiguous_cellrange(v::AbstractString) :: Bool + + if !occursin(",", string(v)) # Non-contiguous ranges are comma separated `SheetCellRef-like` or `SheetCellRange-like` strings + + return false + end + + ranges = split(v, ",") + + for r in ranges + if !is_valid_cellname(r) && !is_valid_cellrange(r) &&!is_valid_fixed_cellname(r) && !is_valid_fixed_cellrange(r) + return false + end + end + + return true +end + +NonContiguousRange(s::Worksheet, v::AbstractString)::NonContiguousRange = nCR(s.name, string.(split(v, ","))) +function NonContiguousRange(v::AbstractString)::NonContiguousRange + + !is_valid_non_contiguous_range(v) && throw(XLSXError("$v is not a valid non-contiguous range.")) + + ranges = string.(split(v, ",")) + firstsheet = parse_sheetname_from_sheetcell_name(ranges[1]) + if !all(parse_sheetname_from_sheetcell_name(r) == firstsheet for r in ranges) + throw(XLSXError("All `CellRef`s and `CellRange`s should have the same sheet name.")) + end + + return nCR(unquoteit(firstsheet), ranges) +end + +function nCR(s::AbstractString, ranges::Vector{String}) :: NonContiguousRange + noncontig = Vector{Union{CellRef, CellRange}}() + + for n in ranges + if is_valid_fixed_sheet_cellname(n) + fixed_cellname = match(RGX_CELLNAME_RIGHT_FIXED, n).match + push!(noncontig, CellRef(replace(fixed_cellname, "\$" => ""))) + elseif is_valid_sheet_cellname(n) + push!(noncontig, CellRef(match(RGX_SHEET_CELLNAME_RIGHT, n).match)) + elseif is_valid_fixed_sheet_cellrange(n) + fixed_cellrange = match(RGX_SHEET_CELLNAME_RIGHT_FIXED, n).match + push!(noncontig, CellRange(replace(fixed_cellrange, "\$" => ""))) + elseif is_valid_sheet_cellrange(n) + push!(noncontig, CellRange(match(RGX_SHEET_CELLRANGE_RIGHT, n).match)) + elseif is_valid_fixed_cellname(n) + push!(noncontig, CellRef(replace(n, "\$" => ""))) + elseif is_valid_fixed_cellrange(n) + push!(noncontig, CellRange(replace(n, "\$" => ""))) + elseif is_valid_cellname(n) + push!(noncontig, CellRef(n)) + elseif is_valid_cellrange(n) + push!(noncontig, CellRange(n)) + else + throw(XLSXError("Invalid non-contiguous range: $n.")) + end + end + + return NonContiguousRange(s, noncontig) +end \ No newline at end of file diff --git a/src/conditional-format-helpers.jl b/src/conditional-format-helpers.jl new file mode 100644 index 00000000..ab4c6c29 --- /dev/null +++ b/src/conditional-format-helpers.jl @@ -0,0 +1,328 @@ +# +# ---- Some random helper functions +# +#function convertref(c) +# if !isnothing(c) +# if is_valid_cellname(c) +# c = abscell(CellRef(c)) +# elseif is_valid_sheet_cellname(c) +# c = mkabs(SheetCellRef(c)) +# end +# end +# return c +#end +function isValidKw(kw::String, val::Union{String, Nothing}, valid::Vector{String}) + if isnothing(val) || val ∈ valid + return true + else + throw(XLSXError("Invalid keyword $kw: $val. Valid values are $valid")) + end +end +function uppercase_unquoted(s::AbstractString) + result = IOBuffer() + i = firstindex(s) + inside_quote = false + while i <= lastindex(s) + c = s[i] + if c == '\\' && nextind(s, i) <= lastindex(s) + # Handle escaped character + next_i = nextind(s, i) + print(result, s[i:next_i]) + i = nextind(s, next_i) + elseif c == '"' + inside_quote = !inside_quote + print(result, c) + i = nextind(s, i) + else + if inside_quote + print(result, c) + else + print(result, uppercase(c)) + end + i = nextind(s, i) + end + end + return String(take!(result)) +end + +# +# --- Standard conditional formats +# +function allCfs(ws::Worksheet)::Vector{XML.Node} + sheetdoc = xmlroot(ws.package, "xl/worksheets/sheet" * string(ws.sheetId) * ".xml") # find all the blocks in the worksheet's xml file + return find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":worksheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":conditionalFormatting", sheetdoc) +end +function add_cf_to_XML(ws, new_cf) # Add a new conditional formatting to the worksheet XML. + sheetdoc = xmlroot(ws.package, "xl/worksheets/sheet" * string(ws.sheetId) * ".xml") # The blocks come after the + k, l = get_idces(sheetdoc, "worksheet", "sheetData") + len = length(sheetdoc[k]) + if l != len + push!(sheetdoc[k], sheetdoc[k][end]) + if l + 1 < len + for pos = len-1:-1:l+1 + sheetdoc[k][pos+1] = sheetdoc[k][pos] + end + end + sheetdoc[k][l+1] = new_cf + else + push!(sheetdoc[k], new_cf) + end +end +function update_worksheet_cfx!(allcfs, cfx, ws, rng) + matchcfs = filter(x -> x["sqref"] == string(rng), allcfs) # Match range with existing conditional formatting blocks. + l = length(matchcfs) + if l == 0 # No existing conditional formatting blocks for this range so create a new one. + new_cf = XML.Element("conditionalFormatting"; sqref=rng) + push!(new_cf, cfx) + add_cf_to_XML(ws, new_cf) # Add the new conditional formatting block to the worksheet XML. + elseif l == 1 # Existing conditional formatting block found for this range so add new rule to that block. + push!(matchcfs[1], cfx) + else + throw(XLSXError("Too many conditional formatting blocks for range `$rng`. Must be one or none, found `$l`.")) + end + update_worksheets_xml!(get_xlsxfile(ws)) +end + +# +# --- Conditional formats relying on Excel 2010 extensions +# +function allExtCfs(ws::Worksheet)::Vector{XML.Node} + sheetdoc = xmlroot(ws.package, "xl/worksheets/sheet" * string(ws.sheetId) * ".xml") + i, j = get_idces(sheetdoc, "worksheet", "extLst") + if isnothing(j) + return Vector{XML.Node}() + end + extlst = sheetdoc[i][j] + exts = XML.children(extlst) + let cfs = nothing + for ext in exts + for c in XML.children(ext) + if XML.tag(c)=="x14:conditionalFormattings" + cfs = c + break + end + end + end + return isnothing(cfs) ? Vector{XML.Node}() : XML.children(cfs) + end +end +function make_extLst!(s) + ext_list = XML.Element("extLst") + ext_element = XML.Element("ext") + ext_element["xmlns:x14"] = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" + ext_element["uri"] = "{78C0D931-6437-407d-A8EE-F0AAD7539E65}" + push!(ext_list, ext_element) + push!(s, ext_list) +end +function make_extCfsBlock() + extCf = XML.Element("x14:conditionalFormatting") + extCf["xmlns:xm"] = "http://schemas.microsoft.com/office/excel/2006/main" + return extCf +end +function update_worksheet_ext_cfx!(allcfs, cfx, ws, rng) + sheetdoc = xmlroot(ws.package, "xl/worksheets/sheet" * string(ws.sheetId) * ".xml") + i, j = get_idces(sheetdoc, "worksheet", "extLst") + if isnothing(j) + make_extLst!(sheetdoc[i]) + j = length(XML.children(sheetdoc[i])) + end + m, n = get_idces(sheetdoc[i], "extLst", "ext") + @assert m==j + if length(allcfs)==0 # No block. Need to create one. + extcfs=XML.Element("x14:conditionalFormattings") + push!(sheetdoc[i][j][n], extcfs) + end + matchcfs = filter(x -> XML.simple_value(x[end]) == string(rng), allcfs) # Match range with existing conditional formatting blocks. + o, p = get_idces(sheetdoc[i][j], "ext", "x14:conditionalFormattings") + @assert o==n + l = length(matchcfs) + if l == 0 # No existing conditional formatting blocks for this range so create a new one. + new_cf = make_extCfsBlock() + push!(new_cf, cfx) + push!(new_cf, XML.Element("xm:sqref", XML.Text(string(rng)))) + push!(sheetdoc[i][j][n][p], new_cf) # Add the new conditional formatting block to the worksheet XML. + elseif l == 1 # Existing conditional formatting block found for this range so add new rule to that block. + pushfirst!(matchcfs[1], cfx) + else + throw(XLSXError("Too many conditional formatting blocks for range `$rng`. Must be one or none, found `$l`.")) + end + update_worksheets_xml!(get_xlsxfile(ws)) +end +function get_x14_icon(x14set) + rule = XML.Element("x14:cfRule", type="iconSet", priority="1", id="XXXX-xxxx-XXXX") # replace id with UUID generated at time of use. + if x14set == "Custom" + icon = XML.Element("x14:iconSet", iconSet="3Arrows", custom="1") + else + icon = XML.Element("x14:iconSet", iconSet=x14set) + end + if x14set=="5Boxes" + vals=[0, 20, 40, 60, 80] + elseif x14set=="Custom" + vals=[0] + else + vals=[0, 33, 67] + end + for v in vals + cfvo = XML.Element("x14:cfvo", type="percent") + push!(cfvo, XML.Element("xm:f", XML.Text(v))) + push!(icon, cfvo) + end + push!(rule, icon) + return rule +end + +# +# ---- Formatting (styles) definitions for conditional formats +# +function Add_Cf_Dx(wb::Workbook, new_dx::XML.Node)::DxFormat + # Check if the workbook already has a dxfs element. If not, add one. + xroot = styles_xmlroot(wb) + i, j = get_idces(xroot, "styleSheet", "dxfs") + + if isnothing(j) # No existing conditional formats so need to add a block (is this even possible?). Push everything lower down one. + throw(XLSXError("No block found in the styles.xml file. Please submit an issue to report this and attach the Excel file you were working with.")) + #= I don't think this can ever happen, so I've commented it out to improve coverage. + k, l = get_idces(xroot, "styleSheet", "cellStyles") + l += 1 # The dxfs block comes after the cellXfs block. + len = length(xroot[k]) + i != k && throw(XLSXError("Some problem here!")) + push!(xroot[k], xroot[k][end]) # duplicate last element then move everything else down one + if l < len + for pos = len-1:-1:l + xroot[k][pos+1] = xroot[k][pos] + end + end + xroot[k][l] = XML.Element("dxsf", count="0") + j = l + println(XML.write(xroot[i][j])) + =# + else + existing_dxf_elements_count = length(XML.children(xroot[i][j])) + + if parse(Int, xroot[i][j]["count"]) != existing_dxf_elements_count + throw(XLSXError("Wrong number of xf elements found: $existing_cellxf_elements_count. Expected $(parse(Int, xroot[i][j]["count"])).")) + end + end + + # Don't reuse duplicates here. Always create new! + existingdx = XML.children(xroot[i][j]) + dxfs = unlink(xroot[i][j], ("dxfs", "dxf")) # Create the new Node + if length(existingdx) > 0 + for c in existingdx + push!(dxfs, c) # Copy each existing into the new Node + end + end + push!(dxfs, new_dx) + + xroot[i][j] = dxfs # Update the worksheet with the new cols. + + xroot[i][j]["count"] = string(existing_dxf_elements_count + 1) + + 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}} + if isnothing(dxStyle) + if all(isnothing.([border, fill, font, format])) + dx = highlights["redfilltext"] + else + dx = Dict{String,Dict{String,String}}() + for att in ["font" => font, "fill" => fill, "border" => border, "format" => format] + if !isnothing(last(att)) + dxx = Dict{String,String}() + for i in last(att) + push!(dxx, first(i) => last(i)) + end + push!(dx, first(att) => dxx) + end + end + end + elseif haskey(highlights, dxStyle) + dx = highlights[dxStyle] + else + throw(XLSXError("Invalid dxStyle: $dxStyle. Valid options are: $(keys(highlights)).")) + end + return dx +end +function get_new_dx(wb::Workbook, dx::Dict{String,Dict{String,String}})::XML.Node + new_dx = XML.Element("dxf") + for k in ["font", "format", "fill", "border"] # Order seems to be important to Excel. + if haskey(dx, k) + v = dx[k] + if k == "fill" + if !isnothing(v) + filldx = XML.Element("fill") + patterndx = XML.Element("patternFill") + for (y, z) in v + y in ["pattern", "bgColor", "fgColor"] || throw(XLSXError("Invalid fill attribute: $k. Valid options are: `pattern`, `bgColor`, `fgColor`.")) + if y in ["fgColor", "bgColor"] + push!(patterndx, XML.Element(y, rgb=get_color(z))) + elseif y == "pattern" && z != "none" + patterndx["patternType"] = z + end + end + push!(filldx, patterndx) + end + push!(new_dx, filldx) + elseif k == "font" + if !isnothing(v) + 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")) + elseif y == "strike" + z == "true" && push!(fontdx, XML.Element(y)) + end + end + end + push!(new_dx, fontdx) + elseif k == "border" + if !isnothing(v) + 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 + sdx = haskey(v, "style") ? v["style"] : nothing + leftdx = XML.Element("left") + rightdx = XML.Element("right") + topdx = XML.Element("top") + bottomdx = XML.Element("bottom") + if !isnothing(sdx) + leftdx["style"] = sdx + rightdx["style"] = sdx + topdx["style"] = sdx + bottomdx["style"] = sdx + end + if !isnothing(cdx) + push!(leftdx, cdx) + push!(rightdx, cdx) + push!(topdx, cdx) + push!(bottomdx, cdx) + end + end + push!(borderdx, leftdx) + push!(borderdx, rightdx) + push!(borderdx, topdx) + push!(borderdx, bottomdx) + push!(new_dx, borderdx) + elseif k == "format" + if !isnothing(v) + if haskey(v, "format") + fmtCode = v["format"] + new_formatId = get_new_formatId(wb, fmtCode) + new_fmtCode = styles_numFmt_formatCode(wb, new_formatId) + fmtdx = XML.Element("numFmt"; numFmtId=string(new_formatId), formatCode=new_fmtCode) + push!(new_dx, fmtdx) + end + end + end + end + end + return new_dx +end \ No newline at end of file diff --git a/src/conditional-formats.jl b/src/conditional-formats.jl new file mode 100644 index 00000000..94fb4151 --- /dev/null +++ b/src/conditional-formats.jl @@ -0,0 +1,2525 @@ +#const needsValue2::Vector{String} = ["between", "notBetween"] +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)))" +) + + +""" + 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(allCfs(ws)), getConditionalExtFormats(allExtCfs(ws))) +function getConditionalFormats(allcfnodes::Vector{XML.Node})::Vector{Pair{CellRange,NamedTuple{(:type, :priority),Tuple{String,Int64}}}} + allcfs = Vector{Pair{CellRange,NamedTuple{(:type, :priority),Tuple{String,Int64}}}}() + for cf in allcfnodes + for child in XML.children(cf) + if XML.tag(child) == "cfRule" + push!(allcfs, CellRange(cf["sqref"]) => (type=child["type"], priority=parse(Int, child["priority"]))) + end + end + end + return allcfs +end +function getConditionalExtFormats(allcfnodes::Vector{XML.Node})::Vector{Pair{CellRange,NamedTuple{(:type, :priority),Tuple{String,Int64}}}} + allcfs = Vector{Pair{CellRange,NamedTuple{(:type, :priority),Tuple{String,Int64}}}}() + for cf in allcfnodes + let t, p, r, rule = false, ref = false + @assert XML.tag(cf) == "x14:conditionalFormatting" "Something wrong here" + sqref = cf[end] + if XML.tag(sqref) == "xm:sqref" + r = XML.simple_value(sqref) + ref = true + end + for child in XML.children(cf) + if XML.tag(child) == "x14: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, CellRange(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 the conditional formats for this 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 the conditional formats 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 smae 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 + +``` +![image|320x500](../images/topN.png) + +# 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 the conditional formats 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 the conditional formats 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 + +``` +![image|320x500](../images/containsText.png) + +# 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 the conditional formats 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 + +``` +![image|320x500](../images/timePeriod-9thMay2025.png) + + +# 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 the conditional formats 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 + +``` +![image|320x500](../images/errorBlank.png) + +# 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 the conditional formats 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" + ) + +jjulia> 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 is 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) + 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) + 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...) +function setCfCellIs(ws::Worksheet, rng::CellRange; 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 + + !issubset(rng, get_dimension(ws)) && throw(XLSXError("Range `$rng` goes outside worksheet dimension.")) + + allcfs = allCfs(ws) # get all conditional format blocks + old_cf = getConditionalFormats(ws) # extract conditional format info + + !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) + value = all(ismissing.(ws[rng])) ? nothing : string(sum(skipmissing(ws[rng])) / count(!ismissing, ws[rng])) + end + cfx = XML.Element("cfRule"; type="cellIs", dxfId=Int(dxid.id)) + cfx["priority"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + if !isnothing(stopIfTrue) && stopIfTrue == "true" + cfx["stopIfTrue"] = "1" + end + cfx["operator"] = operator + + push!(cfx, XML.Element("formula", XML.Text(XML.escape(value)))) + if !isnothing(value2) && operator ∈ ["between", "notBetween"] + + push!(cfx, XML.Element("formula", XML.Text(XML.escape(value2)))) + end + + update_worksheet_cfx!(allcfs, 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...) +function setCfContainsText(ws::Worksheet, rng::CellRange; 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 + + !issubset(rng, get_dimension(ws)) && throw(XLSXError("Range `$rng` goes outside worksheet dimension.")) + + allcfs = allCfs(ws) # get all conditional format blocks + old_cf = getConditionalFormats(ws) # extract conditional format info + + 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(first(rng))) + + cfx = XML.Element("cfRule"; type=type, dxfId=Int(dxid.id)) + cfx["priority"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + if !isnothing(stopIfTrue) && stopIfTrue == "true" + cfx["stopIfTrue"] = "1" + end + cfx["operator"] = operator + cfx["text"] = value + push!(cfx, XML.Element("formula", XML.Text(XML.escape(formula)))) + + update_worksheet_cfx!(allcfs, 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...) +function setCfTop10(ws::Worksheet, rng::CellRange; 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 + + !issubset(rng, get_dimension(ws)) && throw(XLSXError("Range `$rng` goes outside worksheet dimension.")) + + allcfs = allCfs(ws) # get all conditional format blocks + old_cf = getConditionalFormats(ws) # extract conditional format info + + !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("cfRule"; type="top10", dxfId=Int(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"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + 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!(allcfs, 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...) +function setCfAboveAverage(ws::Worksheet, rng::CellRange; 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 + + !issubset(rng, get_dimension(ws)) && throw(XLSXError("Range `$rng` goes outside worksheet dimension.")) + + allcfs = allCfs(ws) # get all conditional format blocks + old_cf = getConditionalFormats(ws) # extract conditional format info + + 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("cfRule"; type=operator, dxfId=Int(dxid.id), priority="1") + elseif operator == "aboveEqAverage" + cfx = XML.Element("cfRule"; type="aboveAverage", dxfId=Int(dxid.id), priority="1", equalAverage="1") + elseif operator == "plus1StdDev" + cfx = XML.Element("cfRule"; type="aboveAverage", dxfId=Int(dxid.id), priority="1", stdDev="1") + elseif operator == "plus2StdDev" + cfx = XML.Element("cfRule"; type="aboveAverage", dxfId=Int(dxid.id), priority="1", stdDev="2") + elseif operator == "plus3StdDev" + cfx = XML.Element("cfRule"; type="aboveAverage", dxfId=Int(dxid.id), priority="1", stdDev="3") + elseif operator == "belowAverage" + cfx = XML.Element("cfRule"; type="aboveAverage", dxfId=Int(dxid.id), priority="1", aboveAverage="0") + elseif operator == "belowEqAverage" + cfx = XML.Element("cfRule"; type="aboveAverage", dxfId=Int(dxid.id), priority="1", aboveAverage="0", equalAverage="1") + elseif operator == "minus1StdDev" + cfx = XML.Element("cfRule"; type="aboveAverage", dxfId=Int(dxid.id), priority="1", aboveAverage="0", stdDev="1") + elseif operator == "minus2StdDev" + cfx = XML.Element("cfRule"; type="aboveAverage", dxfId=Int(dxid.id), priority="1", aboveAverage="0", stdDev="2") + elseif operator == "minus3StdDev" + cfx = XML.Element("cfRule"; type="aboveAverage", dxfId=Int(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"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + if !isnothing(stopIfTrue) && stopIfTrue == "true" + cfx["stopIfTrue"] = "1" + end + + update_worksheet_cfx!(allcfs, 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...) +function setCfTimePeriod(ws::Worksheet, rng::CellRange; 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 + + !issubset(rng, get_dimension(ws)) && throw(XLSXError("Range `$rng` goes outside worksheet dimension.")) + + allcfs = allCfs(ws) # get all conditional format blocks + old_cf = getConditionalFormats(ws) # extract conditional format info + + if operator == "yesterday" + formula = "FLOOR(__CR__,1)=TODAY()-1" + elseif operator == "today" + formula = "FLOOR(__CR__,1)=TODAY()" + elseif operator == "tomorrow" + formula = "FLOOR(__CR__,1)=TODAY()+1" + elseif operator == "last7Days" + formula = "AND(TODAY()-FLOOR(__CR__,1)<=6,FLOOR(__CR__,1)<=TODAY())" + elseif operator == "lastWeek" + formula = "AND(TODAY()-ROUNDDOWN(__CR__,0)>=(WEEKDAY(TODAY())),TODAY()-ROUNDDOWN(__CR__,0)<(WEEKDAY(TODAY())+7))" + elseif operator == "thisWeek" + formula = "AND(TODAY()-ROUNDDOWN(__CR__,0)<=WEEKDAY(TODAY())-1,ROUNDDOWN(__CR__,0)-TODAY()<=7-WEEKDAY(TODAY()))" + elseif operator == "nextWeek" + formula = "AND(ROUNDDOWN(__CR__,0)-TODAY()>(7-WEEKDAY(TODAY())),ROUNDDOWN(__CR__,0)-TODAY()<(15-WEEKDAY(TODAY())))" + elseif operator == "lastMonth" + formula = "AND(MONTH(__CR__)=MONTH(EDATE(TODAY(),0-1)),YEAR(__CR__)=YEAR(EDATE(TODAY(),0-1)))" + elseif operator == "thisMonth" + formula = "AND(MONTH(__CR__)=MONTH(TODAY()),YEAR(__CR__)=YEAR(TODAY()))" + elseif operator == "nextMonth" + formula = "AND(MONTH(__CR__)=MONTH(EDATE(TODAY(),0+1)),YEAR(__CR__)=YEAR(EDATE(TODAY(),0+1)))" + 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(first(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("cfRule"; type="timePeriod", dxfId=Int(dxid.id)) + cfx["priority"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + if !isnothing(stopIfTrue) && stopIfTrue == "true" + cfx["stopIfTrue"] = "1" + end + cfx["timePeriod"] = operator + + push!(cfx, XML.Element("formula", XML.Text(XML.escape(formula)))) + + update_worksheet_cfx!(allcfs, 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...) +function setCfContainsBlankErrorUniqDup(ws::Worksheet, rng::CellRange; 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 + + !issubset(rng, get_dimension(ws)) && throw(XLSXError("Range `$rng` goes outside worksheet dimension.")) + + allcfs = allCfs(ws) # get all conditional format blocks + old_cf = getConditionalFormats(ws) # extract conditional format info + 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(first(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("cfRule"; type=operator, dxfId=Int(dxid.id)) + cfx["priority"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + if !isnothing(stopIfTrue) && stopIfTrue == "true" + cfx["stopIfTrue"] = "1" + end + formula != "" && push!(cfx, XML.Element("formula", XML.Text(XML.escape(formula)))) + + update_worksheet_cfx!(allcfs, 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...) +function setCfFormula(ws::Worksheet, rng::CellRange; 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.")) + + !issubset(rng, get_dimension(ws)) && throw(XLSXError("Range `$rng` goes outside worksheet dimension.")) + + allcfs = allCfs(ws) # get all conditional format blocks + old_cf = getConditionalFormats(ws) # extract conditional format info + + 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("cfRule"; type="expression", dxfId=Int(dxid.id)) + cfx["priority"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + if !isnothing(stopIfTrue) && stopIfTrue == "true" + cfx["stopIfTrue"] = "1" + end + + push!(cfx, XML.Element("formula", XML.Text("(" * XML.escape(uppercase_unquoted(formula)) * ")"))) + + update_worksheet_cfx!(allcfs, 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...) +function setCfColorScale(ws::Worksheet, rng::CellRange; 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}=nothing + 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 + + !issubset(rng, get_dimension(ws)) && throw(XLSXError("Range `$rng` goes outside worksheet dimension ($(get_dimension(ws))).")) + + allcfs = allCfs(ws) # get all conditional format blocks + old_cf = getConditionalFormats(ws) # extract conditional format info + + let new_pr, new_cf + + new_pr = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + + 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.")) + + for val in [min_val, mid_val, max_val] + if !isnothing(val) + if is_valid_fixed_sheet_cellname(val) + do_sheet_names_match(ws, SheetCellRef(val)) + val = string(SheetCellRef(val).cellref) + end + val = XML.escape(uppercase_unquoted(val)) + end + end + + cfx = XML.h.cfRule(type="colorScale", priority=new_pr, + XML.h.colorScale( + isnothing(min_val) ? XML.h.cfvo(type=min_type) : XML.h.cfvo(type=min_type, val=min_val), + isnothing(mid_type) ? "" : XML.h.cfvo(type=mid_type, val=mid_val), + isnothing(max_val) ? XML.h.cfvo(type=max_type) : XML.h.cfvo(type=max_type, val=max_val), + XML.h.color(rgb=get_color(min_col)), + isnothing(mid_type) ? "" : XML.h.color(rgb=get_color(mid_col)), + XML.h.color(rgb=get_color(max_col)) + ) + ) + + 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!(allcfs, 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...) +function setCfIconSet(ws::Worksheet, rng::CellRange; 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 + + !issubset(rng, get_dimension(ws)) && throw(XLSXError("Range `$rng` goes outside worksheet dimension ($(get_dimension(ws))).")) + + allcfs = allCfs(ws) # get all conditional format blocks + old_cf = getConditionalFormats(ws) # extract conditional format info + allextcfs = allExtCfs(ws) # get all extended conditional format blocks + + let new_pr, new_cf + + new_pr = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + + 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.")) + + for val in [min_val, mid_val, mid2_val, max_val] + if !isnothing(val) + if is_valid_fixed_sheet_cellname(val) + do_sheet_names_match(ws, SheetCellRef(val)) + val = string(SheetCellRef(val).cellref) + end + val = XML.escape(uppercase_unquoted(val)) + end + end + 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"] = "{" * uppercase(string(UUIDs.uuid4())) * "}" + 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 isnothing(XML.children(cfx[1][i+1])) + cfx[1][i+1] = XML.Node(cfx[1][i+1], 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!(allextcfs, 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!(allcfs, 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...) +function setCfDataBar(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any}=())::Int + + databar::Union{Nothing,String}="bluegrad" + 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 == :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 + + !issubset(rng, get_dimension(ws)) && throw(XLSXError("Range `$rng` goes outside worksheet dimension ($(get_dimension(ws))).")) + + allcfs = allCfs(ws) # get all conditional format blocks + old_cf = getConditionalFormats(ws) # extract conditional format info + allextcfs = allExtCfs(ws) # get all extended conditional format blocks + + let new_pr, new_cf + + new_pr = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : "1" + 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 min_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.")) + + for val in [min_val, max_val] + if !isnothing(val) + if is_valid_fixed_sheet_cellname(val) + do_sheet_names_match(ws, SheetCellRef(val)) + val = string(SheetCellRef(val).cellref) + end + val = XML.escape(uppercase_unquoted(val)) + end + end + 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 = "{" * uppercase(string(UUIDs.uuid4())) * "}" + mnt = allkws["min_type"] ∈ ["automatic", "least"] ? "min" : allkws["min_type"] + mxt = allkws["max_type"] ∈ ["automatic", "highest"] ? "max" : allkws["max_type"] + cfx = XML.h.cfRule(type="dataBar", priority=new_pr, + XML.h.dataBar( + isnothing(allkws["min_val"]) ? XML.h.cfvo(type=mnt) : XML.h.cfvo(type=mnt, val=allkws["min_val"]), + isnothing(allkws["max_val"]) ? XML.h.cfvo(type=mxt) : XML.h.cfvo(type=mxt, val=allkws["max_val"]), + XML.h.color(rgb=get_color(allkws["fill_col"]))), + XML.h.extLst() + ) + if haskey(allkws, "showVal") && !isnothing(allkws["showVal"]) && allkws["showVal"] == "false" + cfx[1]["showValue"] = "0" + end + cfx_ext = XML.Element("ext") # This establishes link (via id) to the extension elements + cfx_ext["xmlns:x14"] = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" + cfx_ext["uri"] = "{B025F937-C7B1-47D3-B67F-A62EFF666E3E}" + push!(cfx_ext, XML.Element("x14:id", XML.Text(id))) + push!(cfx[end], cfx_ext) + + # 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!(allcfs, cfx, ws, rng) # Add basic elements to worksheet xml file + update_worksheet_ext_cfx!(allextcfs, ext_cfx, ws, rng) # Add extension elements to worksheet xml file + end + return 0 +end \ No newline at end of file diff --git a/src/read.jl b/src/read.jl index 51a77c07..15744738 100644 --- a/src/read.jl +++ b/src/read.jl @@ -17,20 +17,81 @@ function check_for_xlsx_file_format(source::IO, label::AbstractString="input") if header == ZIP_FILE_HEADER # valid Zip file header return elseif header == XLS_FILE_HEADER # old XLS file - error("$label looks like an old XLS file (not XLSX). This package does not support XLS file format.") + throw(XLSXError("$label looks like an old XLS file (not XLSX). This package does not support XLS file format.")) else - error("$label is not a valid XLSX file.") + throw(XLSXError("$label is not a valid XLSX file.")) end end function check_for_xlsx_file_format(filepath::AbstractString) - @assert isfile(filepath) "File $filepath not found." + !isfile(filepath) && throw(XLSXError("File $filepath not found.")) open(filepath, "r") do io check_for_xlsx_file_format(io, filepath) end end + +""" + opentemplate(source::Union{AbstractString, IO}) :: XLSXFile + +Read an existing Excel (`.xlsx`) file as a template and return as a writable `XLSXFile` for editing +and saving to another file with [XLSX.writexlsx](@ref). + +A convenience function equivalent to `openxlsx(source; mode="rw", enable_cache=true)` + +!!! note + XLSX.jl only works with `.xlsx` files and cannot work with Excel `.xltx` template files. + Reading as a template in this package merely means opening a `.xlsx` file to edit, update + and then write as an updated `.xlsx` file (e.g. using `XLSX.writexlsx()`). Doing so retains + the formatting and layout of the opened file, but this is not the same as using a `.xltx` file. + +# Examples +```julia +julia> xf = opentemplate("myExcelFile.xlsx") +``` + +""" +opentemplate(source::Union{AbstractString,IO})::XLSXFile = open_or_read_xlsx(source, true, true, true) + +@inline open_xlsx_template(source::Union{AbstractString,IO})::XLSXFile = open_or_read_xlsx(source, true, true, true) + +function _relocatable_data_path(; path::AbstractString=Artifacts.artifact"XLSX_relocatable_data") + return path +end + +""" + newxlsx() :: XLSXFile + +Return an empty, writable `XLSXFile` with 1 worksheet for editing and +subsequent saving to a file with [XLSX.writexlsx](@ref). +By default, the worksheet is `Sheet1`. Specify `sheetname` to give the worksheet a different name. + +# Examples +```julia +julia> xf = XLSX.newxlsx() +``` + +""" +newxlsx(sheetname::AbstractString=""; path::AbstractString=_relocatable_data_path())::XLSXFile = open_empty_template(sheetname; path) + +function open_empty_template( + sheetname::AbstractString=""; + path::AbstractString=_relocatable_data_path() +)::XLSXFile + + empty_excel_template = joinpath(path, "blank.xlsx") + !isfile(empty_excel_template) && throw(XLSXError("Couldn't find template file $empty_excel_template.")) + xf = open_xlsx_template(empty_excel_template) + xf[1].cache.is_empty = false + + if sheetname != "" + rename!(xf[1], sheetname) + end + xf.source="blank.xlsx" + return xf +end + """ readxlsx(source::Union{AbstractString, IO}) :: XLSXFile @@ -100,7 +161,7 @@ where `myfile.xlsx` is a spreadsheet that doesn't fit into memory. ```julia julia> XLSX.openxlsx("myfile.xlsx", enable_cache=false) do xf - for r in XLSX.eachrow(xf["mysheet"]) + for r in eachrow(xf["mysheet"]) # read something from row `r` end end @@ -128,24 +189,39 @@ See also [`XLSX.readxlsx`](@ref). """ function openxlsx(f::F, source::Union{AbstractString, IO}; mode::AbstractString="r", enable_cache::Bool=true) where {F<:Function} + xf = _openxlsx(f, source; mode, enable_cache) + if !enable_cache + GC.gc() # GC to clean-up after mmap + end + return xf +end + +function _openxlsx(f::F, source::Union{AbstractString, IO}; + mode::AbstractString="r", enable_cache::Bool=true) where {F<:Function} _read, _write = parse_file_mode(mode) if _read - @assert source isa IO || isfile(source) "File $source not found." - xf = open_or_read_xlsx(source, _write, enable_cache, _write) # Why _write, _write here??? + if !(source isa IO || isfile(source)) + throw(XLSXError("File $source not found.")) + end + xf = open_or_read_xlsx(source, _read, enable_cache, _write; use_stream=!enable_cache) else xf = open_empty_template() + xf.source = source end try f(xf) - finally + finally + if !isnothing(xf.stream) # close the mmap stream if it was opened + close(xf.stream) + xf.stream = nothing + end if _write writexlsx(source, xf, overwrite=true) end - end end @@ -153,8 +229,6 @@ end openxlsx(source::Union{AbstractString, IO}; mode="r", enable_cache=true) :: XLSXFile Supports opening a XLSX file without using do-syntax. -In this case, the user is responsible for closing the `XLSXFile` -using `close` or writing it to file using `XLSX.writexlsx`. See also [`XLSX.writexlsx`](@ref). """ @@ -165,10 +239,14 @@ function openxlsx(source::Union{AbstractString, IO}; _read, _write = parse_file_mode(mode) if _read - @assert source isa IO || isfile(source) "File $source not found." - return open_or_read_xlsx(source, _write, enable_cache, _write) # Why _write, _write here??? + if !(source isa IO || isfile(source)) + throw(XLSXError("File $source not found.")) + end + return open_or_read_xlsx(source, _read, enable_cache, _write) else - return open_empty_template() + xf = open_empty_template() + xf.source = source + return xf end end @@ -180,17 +258,17 @@ function parse_file_mode(mode::AbstractString) :: Tuple{Bool, Bool} elseif mode == "rw" || mode == "wr" return (true, true) else - error("Couldn't parse file mode $mode.") + throw(XLSXError("Couldn't parse file mode $mode.")) end end -function open_or_read_xlsx(source::Union{IO, AbstractString}, read_files::Bool, enable_cache::Bool, read_as_template::Bool) :: XLSXFile +function open_or_read_xlsx(source::Union{IO, AbstractString}, read_files::Bool, enable_cache::Bool, read_as_template::Bool; use_stream::Bool=false) :: XLSXFile # sanity check if read_as_template - @assert read_files && enable_cache + !(read_files && enable_cache) && throw(XLSXError("Cache must be enabled for files in `write` mode.")) end - - xf = XLSXFile(source, enable_cache, read_as_template) + xf = XLSXFile(source, enable_cache, read_as_template; use_stream) + for f in ZipArchives.zip_names(xf.io) @@ -225,17 +303,17 @@ function open_or_read_xlsx(source::Union{IO, AbstractString}, read_files::Bool, parse_workbook!(xf) # read data from Worksheet streams - if read_files + if enable_cache for sheet_name in sheetnames(xf) sheet = getsheet(xf, sheet_name) - # to read sheet content, we just need to iterate a SheetRowIterator and the data will be stored in cache - for r in eachrow(sheet) + # to read sheet content, we just need to iterate a SheetRowIterator and the data will be stored in cache + for _ in eachrow(sheet) nothing end + isnothing(sheet.dimension) && get_dimension(sheet) # Get sheet dimension from the cell cache if not specified in the `xlsx` file. end end - if read_as_template wb = get_workbook(xf) if has_sst(wb) @@ -273,7 +351,7 @@ function get_default_namespace(r::XML.Node) :: String end end - error("No default namespace found.") + throw(XLSXError("No default namespace found.")) end # See section 12.2 - Package Structure @@ -285,7 +363,23 @@ function check_minimum_requirements(xf::XLSXFile) ] for f in mandatory_files - @assert in(f, filenames(xf)) "Malformed XLSX File. Couldn't find file $f in the package." + !in(f, filenames(xf)) && throw(XLSXError("Malformed XLSX File. Couldn't find file $f in the package.")) + end + + # Further check if this is a valid `.xlsx` file. + f = "[Content_Types].xml" + if internal_xml_file_isread(xf, f) + content_types = XML.write(xf.data[f]) + else + content_types = ZipArchives.zip_readentry(xf.io, f, String) + end + + if occursin("spreadsheetml.sheet", content_types) + return nothing + elseif occursin("spreadsheetml.template", content_types) + throw(XLSXError("XLSX.jl does not support Excel template files (`.xltx` files).\nSave template as an `xlsx` file type first.")) + else + throw(XLSXError("Unknown Excel file type.")) end nothing @@ -300,7 +394,7 @@ function parse_relationships!(xf::XLSXFile) for el in XML.children(xroot) push!(xf.relationships, Relationship(el)) end - @assert !isempty(xf.relationships) "Relationships not found in _rels/.rels!" + isempty(xf.relationships) && throw(XLSXError("Relationships not found in _rels/.rels!")) # workbook level relationships wb = get_workbook(xf) @@ -308,7 +402,7 @@ function parse_relationships!(xf::XLSXFile) for el in XML.children(xroot) push!(wb.relationships, Relationship(el)) end - @assert !isempty(wb.relationships) "Relationships not found in xl/_rels/workbook.xml.rels" + isempty(wb.relationships) && throw(XLSXError("Relationships not found in xl/_rels/workbook.xml.rels")) nothing end @@ -317,7 +411,7 @@ end function parse_workbook!(xf::XLSXFile) xroot = xmlroot(xf, "xl/workbook.xml")[end] chn=XML.children(xroot) - @assert XML.tag(xroot) == "workbook" "Malformed xl/workbook.xml. Root node name should be 'workbook'. Got '$(XML.tag(xroot))'." + XML.tag(xroot) != "workbook" && throw(XLSXError("Malformed xl/workbook.xml. Root node name should be 'workbook'. Got '$(XML.tag(xroot))'.")) # workbook to be parsed workbook = get_workbook(xf) @@ -340,7 +434,7 @@ function parse_workbook!(xf::XLSXFile) elseif attribute_value_date1904 == "0" || attribute_value_date1904 == "false" workbook.date1904 = false else - error("Could not parse xl/workbook -> workbookPr -> date1904 = $(attribute_value_date1904).") + throw(XLSXError("Could not parse xl/workbook -> workbookPr -> date1904 = $(attribute_value_date1904).")) end end end @@ -356,7 +450,7 @@ function parse_workbook!(xf::XLSXFile) if XML.tag(node) == "sheets" for sheet_node in XML.children(node) - @assert XML.tag(sheet_node) == "sheet" "Unsupported node $(XML.tag(sheet_node)) in node $(XML.tag(node)) in 'xl/workbook.xml'." + XML.tag(sheet_node) != "sheet" && throw(XLSXError("Unsupported node $(XML.tag(sheet_node)) in node $(XML.tag(node)) in 'xl/workbook.xml'.")) worksheet = Worksheet(xf, sheet_node) push!(sheets, worksheet) end @@ -377,31 +471,52 @@ function parse_workbook!(xf::XLSXFile) name = XML.attributes(defined_name_node)["name"] local defined_value::DefinedNameValueTypes - - if is_valid_fixed_sheet_cellname(defined_value_string) || is_valid_sheet_cellname(defined_value_string) - defined_value = SheetCellRef(defined_value_string) - elseif is_valid_fixed_sheet_cellrange(defined_value_string) || is_valid_sheet_cellrange(defined_value_string) - defined_value = SheetCellRange(defined_value_string) + if is_valid_non_contiguous_range(defined_value_string) + defined_value = NonContiguousRange(unquoteit(defined_value_string)) + isabs=Vector{Bool}(undef,length(defined_value.rng)) + for (i, d) in enumerate(split(defined_value_string, ",")) + isabs[i]=is_valid_fixed_sheet_cellname(d) || is_valid_fixed_sheet_cellrange(d) + end + length(isabs) != length(defined_value.rng) && throw(XLSXError("Error parsing absolute references in non-contiguous range.")) + elseif is_valid_fixed_sheet_cellname(defined_value_string) + defined_value = SheetCellRef(unquoteit(defined_value_string)) + isabs=true + elseif is_valid_sheet_cellname(defined_value_string) + defined_value = SheetCellRef(unquoteit(defined_value_string)) + isabs=false + elseif is_valid_fixed_sheet_cellrange(defined_value_string) + defined_value = SheetCellRange(unquoteit(defined_value_string)) + isabs=true + elseif is_valid_sheet_cellrange(defined_value_string) + defined_value = SheetCellRange(unquoteit(defined_value_string)) + isabs=false elseif occursin(r"^\".*\"$", defined_value_string) # is enclosed by quotes defined_value = defined_value_string[2:end-1] # remove enclosing quotes if isempty(defined_value) defined_value = missing end + isabs=false elseif tryparse(Int, defined_value_string) !== nothing defined_value = parse(Int, defined_value_string) + isabs=false elseif tryparse(Float64, defined_value_string) !== nothing defined_value = parse(Float64, defined_value_string) + isabs=false elseif isempty(defined_value_string) defined_value = missing + isabs=false else # Couldn't parse definedName. Will silently ignore it, since this is not a critical feature. - continue + # Actually is just interpreted as a string anyway and added to the defined names (is this true?). + defined_value = string(defined_value_string) + isabs=false + #continue - # debug - #error("Could not parse value $(defined_value_string) for definedName $name.") + # debug - Now more important since we are writing updated defined names to back to output file. + # throw(XLSXError("Could not parse value $(defined_value_string) for definedName $name.")) end - a = XML.attributes(defined_name_node) + a = XML.attributes(defined_name_node) if haskey(a,"localSheetId") # is a Worksheet level name @@ -410,10 +525,10 @@ function parse_workbook!(xf::XLSXFile) # Which is the order of the elements under element in workbook.xml . localSheetId = parse(Int, a["localSheetId"])+1 sheetId = workbook.sheets[localSheetId].sheetId - workbook.worksheet_names[(sheetId, name)] = defined_value + workbook.worksheet_names[(sheetId, name)] = DefinedNameValue(defined_value, isabs) else # is a Workbook level name - workbook.workbook_names[name] = defined_value + workbook.workbook_names[name] = DefinedNameValue(defined_value, isabs) end end @@ -435,26 +550,40 @@ end @inline internal_xml_file_exists(xl::XLSXFile, filename::String) :: Bool = haskey(xl.files, filename) function internal_xml_file_add!(xl::XLSXFile, filename::String) - @assert endswith(filename, ".xml") || endswith(filename, ".rels") + !(endswith(filename, ".xml") || endswith(filename, ".rels")) && throw(XLSXError("Something wrong here!")) xl.files[filename] = false nothing end +function strip_bom_and_lf!(bytes::Vector{UInt8}) + # Issue 243 - Need to remove BOM characters that precede the XML declaration. + bom = UInt8[0xEF, 0xBB, 0xBF] + l=length(bytes) + if l >= 3 && bytes[1:3] == bom + if l > 3 && bytes[4] == 0x0A + deleteat!(bytes, 1:4) + else + deleteat!(bytes, 1:3) + end + end +end function internal_xml_file_read(xf::XLSXFile, filename::String) :: XML.Node - @assert internal_xml_file_exists(xf, filename) "Couldn't find $filename in $(xf.source)." + + !internal_xml_file_exists(xf, filename) && throw(XLSXError("Couldn't find $filename in $(xf.source).")) if !internal_xml_file_isread(xf, filename) try - xf.data[filename] = XML.Node(XML.Raw(ZipArchives.zip_readentry(xf.io, filename))) + bytes = ZipArchives.zip_readentry(xf.io, filename) + strip_bom_and_lf!(bytes) + xf.data[filename] = XML.Node(XML.Raw(bytes)) xf.files[filename] = true # set file as read catch err - @error("Failed to parse internal XML file `$filename`") - rethrow() - + throw(XLSXError("Failed to parse internal XML file `$filename`")) end end + return xf.data[filename] end @@ -473,7 +602,10 @@ end readdata(source, sheet, ref) readdata(source, sheetref) -Returns a scalar or matrix with values from a spreadsheet. +Return a scalar, vector or matrix with values from a spreadsheet file. +'ref' can be a defined name, a cell reference or a cell, column, row +or non-contiguous range. + See also [`XLSX.getdata`](@ref). @@ -500,11 +632,23 @@ julia> XLSX.readdata("myfile.xlsx", "mysheet!A2:B4") 2 "second" 3 "third" ``` + +Non-contiguous ranges return vectors. + +```julia +julia> XLSX.readdata("customXml.xlsx", "Mock-up", "Location") # `Location` is a `definedName` for a non-contiguous range +4-element Vector{Any}: + "Here" + missing + missing + missing +``` """ function readdata(source::Union{AbstractString, IO}, sheet::Union{AbstractString, Int}, ref) c = openxlsx(source, enable_cache=false) do xf getdata(getsheet(xf, sheet), ref) end +# GC.gc() return c end @@ -512,30 +656,35 @@ function readdata(source::Union{AbstractString, IO}, sheetref::AbstractString) c = openxlsx(source, enable_cache=false) do xf getdata(xf, sheetref) end +# GC.gc() return c end """ readtable( source, - sheet, - [columns]; + [sheet, + [columns]]; [first_row], [column_labels], [header], [infer_eltypes], [stop_in_empty_row], [stop_in_row_function], - [keep_empty_rows] + [keep_empty_rows], + [normalizenames] ) -> DataTable Returns tabular data from a spreadsheet as a struct `XLSX.DataTable`. Use this function to create a `DataFrame` from package `DataFrames.jl`. +If `sheet` is not given, the first sheet in the `XLSXFile` will be used. + 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. +of consecutive non-empty cells. A valid `sheet` must be specified +when specifying `columns`. Use `first_row` to indicate the first row from the table. `first_row=5` will look for a table starting at sheet row `5`. @@ -550,27 +699,36 @@ 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 `infer_eltypes=true` to get `data` as a `Vector{Any}` of typed vectors. -The default value is `infer_eltypes=false`. +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. +`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 ``` -`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. # Example @@ -582,16 +740,87 @@ julia> df = DataFrame(XLSX.readtable("myfile.xlsx", "mysheet")) See also: [`XLSX.gettable`](@ref). """ -function readtable(source::Union{AbstractString, IO}, sheet::Union{AbstractString, Int}; first_row::Union{Nothing, Int} = nothing, column_labels=nothing, header::Bool=true, infer_eltypes::Bool=false, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Nothing, Function}=nothing, enable_cache::Bool=false, keep_empty_rows::Bool=false) - c = openxlsx(source, enable_cache=enable_cache) do xf - gettable(getsheet(xf, sheet); first_row=first_row, column_labels=column_labels, header=header, infer_eltypes=infer_eltypes, stop_in_empty_row=stop_in_empty_row, stop_in_row_function=stop_in_row_function, keep_empty_rows=keep_empty_rows) +function readtable(source::Union{AbstractString, IO}; first_row::Union{Nothing, Int} = nothing, column_labels=nothing, header::Bool=true, infer_eltypes::Bool=true, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Nothing, Function}=nothing, enable_cache::Bool=false, keep_empty_rows::Bool=false, normalizenames::Bool=false) + c = openxlsx(source; enable_cache) do xf + gettable(getsheet(xf, 1); first_row, column_labels, header, infer_eltypes, stop_in_empty_row, stop_in_row_function, keep_empty_rows, normalizenames) + end + return c +end +function readtable(source::Union{AbstractString, IO}, sheet::Union{AbstractString, Int}; first_row::Union{Nothing, Int} = nothing, column_labels=nothing, header::Bool=true, infer_eltypes::Bool=true, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Nothing, Function}=nothing, enable_cache::Bool=false, keep_empty_rows::Bool=false, normalizenames::Bool=false) + c = openxlsx(source; enable_cache) do xf + gettable(getsheet(xf, sheet); first_row, column_labels, header, infer_eltypes, stop_in_empty_row, stop_in_row_function, keep_empty_rows, normalizenames) end return c end -function readtable(source::Union{AbstractString, IO}, sheet::Union{AbstractString, Int}, columns::Union{ColumnRange, AbstractString}; first_row::Union{Nothing, Int} = nothing, column_labels=nothing, header::Bool=true, infer_eltypes::Bool=false, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Nothing, Function}=nothing, enable_cache::Bool=false, keep_empty_rows::Bool=false) - c = openxlsx(source, enable_cache=enable_cache) do xf - gettable(getsheet(xf, sheet), columns; first_row=first_row, column_labels=column_labels, header=header, infer_eltypes=infer_eltypes, stop_in_empty_row=stop_in_empty_row, stop_in_row_function=stop_in_row_function, keep_empty_rows=keep_empty_rows) +function readtable(source::Union{AbstractString, IO}, sheet::Union{AbstractString, Int}, columns::ColumnRange; first_row::Union{Nothing, Int} = nothing, column_labels=nothing, header::Bool=true, infer_eltypes::Bool=true, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Nothing, Function}=nothing, enable_cache::Bool=false, keep_empty_rows::Bool=false, normalizenames::Bool=false) + c = openxlsx(source; enable_cache) do xf + gettable(getsheet(xf, sheet), columns; first_row, column_labels, header, infer_eltypes, stop_in_empty_row, stop_in_row_function, keep_empty_rows, normalizenames) end return c end + +function readtable(source::Union{AbstractString, IO}, sheet::Union{AbstractString, Int}, range::AbstractString; first_row::Union{Nothing, Int} = nothing, column_labels=nothing, header::Bool=true, infer_eltypes::Bool=true, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Nothing, Function}=nothing, enable_cache::Bool=false, keep_empty_rows::Bool=false, normalizenames::Bool=false) + if is_valid_column_range(range) + range = ColumnRange(range) + else + throw(XLSXError("The columns argument must be a valid column range.")) + end + return readtable(source, sheet, range; first_row, column_labels, header, infer_eltypes, stop_in_empty_row, stop_in_row_function, enable_cache, keep_empty_rows, normalizenames) +end + +""" + readto( + source, + [sheet, + [columns]], + sink; + [first_row], + [column_labels], + [header], + [infer_eltypes], + [stop_in_empty_row], + [stop_in_row_function], + [keep_empty_rows], + [normalizenames] + ) -> sink + +Read and parse an Excel worksheet, materializing directly using +the `sink` function (e.g. `DataFrame` or `StructArray`). + +Takes the same keyword arguments as [`XLSX.readtable`](@ref) + +# Example + +```julia +julia> using DataFrames, StructArrays, XLSX + +julia> df = XLSX.readto("myfile.xlsx", DataFrame) + +julia> df = XLSX.readto("myfile.xlsx", StructArray) + +julia> df = XLSX.readto("myfile.xlsx", "mysheet", DataFrame) + +julia> df = XLSX.readto("myfile.xlsx", "mysheet", "A:C", DataFrame) +``` + +See also: [`XLSX.gettable`](@ref). +""" +function readto(source::Union{AbstractString, IO}, sheet::Union{AbstractString, Int}, range::AbstractString, sink=nothing; kw...) + if sink === nothing + throw(XLSXError("provide a valid sink argument, like `using DataFrames; XLSX.readdf(source, sheet, columns, DataFrame)`")) + end + return Tables.CopiedColumns(readtable(source, sheet, range; kw...)) |> sink +end +function readto(source::Union{AbstractString, IO}, sheet::Union{AbstractString, Int}, sink=nothing; kw...) + if sink === nothing + throw(XLSXError("provide a valid sink argument, like `using DataFrames; XLSX.readdf(source, sheet, DataFrame)`")) + end + return Tables.CopiedColumns(readtable(source, sheet; kw...)) |> sink +end +function readto(source::Union{AbstractString, IO}, sink=nothing; kw...) + if sink === nothing + throw(XLSXError("provide a valid sink argument, like `using DataFrames; XLSX.readdf(source, sheet, DataFrame)`")) + end + return Tables.CopiedColumns(readtable(source; kw...)) |> sink +end diff --git a/src/relationship.jl b/src/relationship.jl index b65285d8..d232ddcd 100644 --- a/src/relationship.jl +++ b/src/relationship.jl @@ -1,6 +1,6 @@ -function Relationship(e::XML.Node) :: Relationship - @assert XML.tag(e) == "Relationship" "Unexpected XMLElement: $(XML.tag(e)). Expected: \"Relationship\"." +function Relationship(e::XML.Node)::Relationship + XML.tag(e) != "Relationship" && throw(XLSXError("Unexpected XMLElement: $(XML.tag(e)). Expected: \"Relationship\".")) a = XML.attributes(e) return Relationship( a["Id"], @@ -9,36 +9,35 @@ function Relationship(e::XML.Node) :: Relationship ) end -function parse_relationship_target(prefix::String, target::String) :: String - @assert !isempty(prefix) && !isempty(target) - +function parse_relationship_target(prefix::String, target::String)::String + isempty(prefix) || isempty(target) && throw(XLSXError("Something wrong here!")) if target[1] == '/' - @assert sizeof(target) > 1 "Incomplete target path $target." + sizeof(target) <= 1 && throw(XLSXError("Incomplete target path $target.")) return target[2:end] else return prefix * '/' * target end end -function get_relationship_target_by_id(prefix::String, wb::Workbook, Id::String) :: String +function get_relationship_target_by_id(prefix::String, wb::Workbook, Id::String)::String for r in wb.relationships if Id == r.Id return parse_relationship_target(prefix, r.Target) end end - error("Relationship Id=$(Id) not found") + throw(XLSXError("Relationship Id=$(Id) not found")) end -function get_relationship_target_by_type(prefix::String, wb::Workbook, _type_::String) :: String +function get_relationship_target_by_type(prefix::String, wb::Workbook, _type_::String)::String for r in wb.relationships if _type_ == r.Type return parse_relationship_target(prefix, r.Target) end end - error("Relationship Type=$(_type_) not found") + throw(XLSXError("Relationship Type=$(_type_) not found")) end -function has_relationship_by_type(wb::Workbook, _type_::String) :: Bool +function has_relationship_by_type(wb::Workbook, _type_::String)::Bool for r in wb.relationships if _type_ == r.Type return true @@ -47,25 +46,29 @@ function has_relationship_by_type(wb::Workbook, _type_::String) :: Bool false end -function get_package_relationship_root(xf::XLSXFile) :: XML.Node +function get_package_relationship_root(xf::XLSXFile)::XML.Node xroot = xmlroot(xf, "_rels/.rels")[end] - @assert XML.tag(xroot) == "Relationships" "Malformed XLSX file $(xf.source). _rels/.rels root node name should be `Relationships`. Found $(XML.tag(xroot))." - @assert (""=>"http://schemas.openxmlformats.org/package/2006/relationships") ∈ get_namespaces(xroot) "Unexpected namespace at workbook relationship file: `$(get_namespaces(xroot))`." + XML.tag(xroot) != "Relationships" && throw(XLSXError("Malformed XLSX file $(xf.source). _rels/.rels root node name should be `Relationships`. Found $(XML.tag(xroot)).")) + if ("" => "http://schemas.openxmlformats.org/package/2006/relationships") ∉ get_namespaces(xroot) + throw(XLSXError("Unexpected namespace at workbook relationship file: `$(get_namespaces(xroot))`.")) + end return xroot end -function get_workbook_relationship_root(xf::XLSXFile) :: XML.Node +function get_workbook_relationship_root(xf::XLSXFile)::XML.Node xroot = xmlroot(xf, "xl/_rels/workbook.xml.rels")[end] - @assert XML.tag(xroot) == "Relationships" "Malformed XLSX file $(xf.source). xl/_rels/workbook.xml.rels root node name should be `Relationships`. Found $(XML.tag(xroot))." - @assert (""=>"http://schemas.openxmlformats.org/package/2006/relationships") ∈ get_namespaces(xroot) "Unexpected namespace at workbook relationship file: `$(get_namespaces(xroot))`." + XML.tag(xroot) != "Relationships" && throw(XLSXError("Malformed XLSX file $(xf.source). xl/_rels/workbook.xml.rels root node name should be `Relationships`. Found $(XML.tag(xroot)).")) + if ("" => "http://schemas.openxmlformats.org/package/2006/relationships") ∉ get_namespaces(xroot) + throw(XLSXError("Unexpected namespace at workbook relationship file: `$(get_namespaces(xroot))`.")) + end return xroot end # Adds new relationship. Returns new generated rId. -function add_relationship!(wb::Workbook, target::String, _type::String) :: String +function add_relationship!(wb::Workbook, target::String, _type::String)::String xf = get_xlsxfile(wb) - @assert is_writable(xf) "XLSXFile instance is not writable." - local rId :: String + !is_writable(xf) && throws(XLSXError("XLSXFile instance is not writable.")) + local rId::String let got_unique_id = false @@ -90,7 +93,7 @@ function add_relationship!(wb::Workbook, target::String, _type::String) :: Strin # adds to XML tree xroot = get_workbook_relationship_root(xf) - el = XML.Element("Relationship"; Id = rId, Target = target, Type = _type) + el = XML.Element("Relationship"; Id=rId, Type=_type, Target=target) push!(xroot, el) return rId diff --git a/src/sst.jl b/src/sst.jl index 87ae973c..a5e62d6b 100644 --- a/src/sst.jl +++ b/src/sst.jl @@ -10,7 +10,7 @@ SharedStringTable() = SharedStringTable(Vector{String}(), Vector{String}(), Dict # Returns `nothing` if it's not in the shared string table. # Returns the index of the string in the shared string table. The index is 0-based. function get_shared_string_index(sst::SharedStringTable, str_formatted::AbstractString) :: Union{Nothing, Int} - @assert sst.is_loaded "Can't query shared string table because it's not loaded into memory." + !sst.is_loaded && throw(XLSXError("Can't query shared string table because it's not loaded into memory.")) #using a Dict is much more efficient than the findfirst approach especially on large datasets if haskey(sst.index, str_formatted) @@ -31,15 +31,17 @@ function add_shared_string!(sst::SharedStringTable, str_unformatted::AbstractStr push!(sst.formatted_strings, str_formatted) sst.index[str_formatted] = length(sst.formatted_strings) new_index = length(sst.formatted_strings) - 1 # 0-based - @assert new_index == get_shared_string_index(sst, str_formatted) "Inconsistent state after adding a string to the Shared String Table." + if new_index != get_shared_string_index(sst, str_formatted) + throw(XLSXError("Inconsistent state after adding a string to the Shared String Table.")) + end return new_index end end # Adds a string to shared string table. Returns the 0-based index of the shared string in the shared string table. function add_shared_string!(wb::Workbook, str_unformatted::AbstractString, str_formatted::AbstractString) :: Int - @assert is_writable(get_xlsxfile(wb)) "XLSXFile instance is not writable." - @assert !(isempty(str_unformatted) || isempty(str_formatted)) "Can't add empty string to Shared String Table." + !is_writable(get_xlsxfile(wb)) && throw(XLSXError("XLSXFile instance is not writable.")) + (isempty(str_unformatted) || isempty(str_formatted)) && throw(XLSXError("Can't add empty string to Shared String Table.")) sst = get_sst(wb) if !sst.is_loaded @@ -53,7 +55,7 @@ function add_shared_string!(wb::Workbook, str_unformatted::AbstractString, str_f # add Content Type ctype_root = xmlroot(get_xlsxfile(wb), "[Content_Types].xml")[end] - @assert XML.tag(ctype_root) == "Types" + XML.tag(ctype_root) != "Types" && throw(XLSXError("Something wrong here!")) override_node = XML.Element("Override"; ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml", PartName = "/xl/sharedStrings.xml" @@ -66,7 +68,11 @@ function add_shared_string!(wb::Workbook, str_unformatted::AbstractString, str_f end function add_shared_string!(wb::Workbook, str_unformatted::AbstractString) :: Int - str_formatted = string("", str_unformatted, "") + if startswith(str_unformatted, " ") || endswith(str_unformatted, " ") + str_formatted = string("", XML.escape(str_unformatted), "") + else + str_formatted = string("", XML.escape(str_unformatted), "") + end return add_shared_string!(wb, str_unformatted, str_formatted) end @@ -78,14 +84,13 @@ function sst_load!(workbook::Workbook) relationship_type = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" if has_relationship_by_type(workbook, relationship_type) sst_root = xmlroot(get_xlsxfile(workbook), get_relationship_target_by_type("xl", workbook, relationship_type))[end] - @assert XML.tag(sst_root) == "sst" + XML.tag(sst_root) != "sst" && throw(XLSXError("Something wrong here!")) for el in XML.children(sst_root) XML.nodetype(el) == XML.Text && continue - @assert XML.tag(el) == "si" "Unsupported node $(XML.tag(el)) in sst table." + XML.tag(el) != "si" && throw(XLSXError("Unsupported node $(XML.tag(el)) in sst table.")) push!(sst.unformatted_strings, unformatted_text(el)) push!(sst.formatted_strings, XML.write(el)) - end init_sst_index(sst) @@ -93,7 +98,7 @@ function sst_load!(workbook::Workbook) return end - error("Shared Strings Table not found for this workbook.") + throw(XLSXError("Shared Strings Table not found for this workbook.")) end end @@ -103,6 +108,13 @@ function has_sst(workbook::Workbook) :: Bool return has_relationship_by_type(workbook, relationship_type) end + +# work around issue 43 in XML.jl (https://github.com/JuliaComputing/XML.jl/issues/43) +# Can now write cells containing only whitespace characters or with leading or trailing whitespace. +# Cannot see these things in cells read in from existing Excel files - XML removes leading whitespace +# even if `xml:space="preserve"` is specified. +# Thus a cell containing " " will be read in as missing. A cell containing " hello" will become "hello". + # Helper function to gather unformatted text from Excel data files. # It looks at all children of `el` for tag name `t` and returns # a join of all the strings found. @@ -112,9 +124,12 @@ function unformatted_text(el::XML.Node) :: String if XML.tag(e) == "t" c = XML.children(e) if length(c) == 1 - push!(v, XML.value(c[1])) + push!(v, XML.is_simple(c[1]) ? XML.simple_value(c[1]) : XML.value(c[1])) + elseif length(c) == 0 + push!(v, isnothing(XML.value(e)) ? "" : XML.is_simple(e) ? XML.simple_value(e) : XML.value(e)) else - push!(v, XML.write(e)) +# println([i, " => ", XML.write(x) for (i, x) in enumerate(c)]) + throw(XLSXError("Unexpected number of children in node: $(length(c)). Expected 0 or 1.")) end end @@ -129,8 +144,10 @@ function unformatted_text(el::XML.Node) :: String v_string = Vector{String}() gather_strings!(v_string, el) -# println("sst131 : ",join(v_string)) - return join(v_string) + + gatheredstrings=XML.unescape(join(v_string)) + + return gatheredstrings end # Looks for a string inside the Shared Strings Table (sst). diff --git a/src/stream.jl b/src/stream.jl index 6c286291..b1f21c0f 100644 --- a/src/stream.jl +++ b/src/stream.jl @@ -20,11 +20,11 @@ end # About Iterators * `SheetRowIterator` is an abstract iterator that has `SheetRow` as its elements. `SheetRowStreamIterator` and `WorksheetCache` implements `SheetRowIterator` interface. -* `SheetRowStreamIterator` is a dumb iterator for row elements in sheetData XML tag of a worksheet. +* `SheetRowStreamIterator` is a dumb iterator for row elements in sheetData XML tag of a worksheet. Empty rows are not represented in the XML file so cannot be seen by the iterator. * `WorksheetCache` has a `SheetRowStreamIterator` and caches all values read from the stream. * `TableRowIterator` is a smart iterator that looks for tabular data, but uses a SheetRowIterator under the hood. -The implementation of `SheetRowIterator` will be chosen automatically by `XLSX.eachrow` method, +The implementation of `SheetRowIterator` will be chosen automatically by `eachrow` method, based on the `enable_cache` option used in `XLSX.openxlsx` method. =# @@ -48,9 +48,15 @@ Base.show(io::IO, state::SheetRowStreamIteratorState) = print(io, "SheetRowStrea # Opens a file for streaming. @inline function open_internal_file_stream(xf::XLSXFile, filename::String) :: XML.LazyNode - @assert internal_xml_file_exists(xf, filename) "Couldn't find $filename in $(xf.source)." - @assert xf.source isa IO || isfile(xf.source) "Can't open internal file $filename for streaming because the XLSX file $(xf.filepath) was not found." - XML.LazyNode(XML.Raw(ZipArchives.zip_readentry(xf.io, filename))) + + !internal_xml_file_exists(xf, filename) && throw(XLSXError("Couldn't find $filename in $(xf.source).")) + if !(xf.source isa IO || isfile(xf.source)) + throw(XLSXError("Can't open internal file $filename for streaming because the XLSX file $(xf.source) was not found.")) + end + + lznode=XML.LazyNode(XML.Raw(ZipArchives.zip_readentry(xf.io, filename))) + + return lznode end # Creates a reader for row elements in the Worksheet's XML. @@ -73,8 +79,8 @@ function Base.iterate(itr::SheetRowStreamIterator, state::Union{Nothing, SheetRo target_file = get_relationship_target_by_id("xl", get_workbook(ws), ws.relationship_id) reader = open_internal_file_stream(get_xlsxfile(ws), target_file) - @assert length(reader) > 0 "Couldn't open reader for Worksheet $(ws.name)." - @assert XML.tag(reader[end]) == "worksheet" "Expecting to find a worksheet node.: Found a $(XML.tag(reader[end]))." + length(reader) <= 0 && throw(XLSXError("Couldn't open reader for Worksheet $(ws.name).")) + XML.tag(reader[end]) != "worksheet" && throw(XLSXError("Expecting to find a worksheet node.: Found a $(XML.tag(reader[end])).")) ws_elements = XML.children(reader[end]) idx = findfirst(y -> y=="sheetData", [XML.tag(x) for x in ws_elements]) next_element= idx===nothing ? "" : (ws_elements[idx+1]) @@ -88,7 +94,9 @@ function Base.iterate(itr::SheetRowStreamIterator, state::Union{Nothing, SheetRo if nrows == 0 return nothing end - @assert XML.depth(lznode) == 2 "Malformed Worksheet \"$(ws.name)\": unexpected node depth for sheetData node: $(XML.depth(lznode))." + + XML.depth(lznode) != 2 && throw(XLSXError("Malformed Worksheet \"$(ws.name)\": unexpected node depth for sheetData node: $(XML.depth(lznode)).")) + break end @@ -117,7 +125,7 @@ function Base.iterate(itr::SheetRowStreamIterator, state::Union{Nothing, SheetRo end # given that the first iteration case is done in the code above, we shouldn't get it again in here - @assert state !== nothing "Error processing Worksheet $(ws.name): shouldn't get first iteration case again." + state === nothing && throw(XLSXError("Error processing Worksheet $(ws.name): shouldn't get first iteration case again.")) reader = state.itr lzstate = state.itr_state @@ -141,7 +149,9 @@ function Base.iterate(itr::SheetRowStreamIterator, state::Union{Nothing, SheetRo elseif XML.nodetype(lznode) == XML.Element && XML.tag(lznode) == "c" # This is a cell cell_no += 1 cell = Cell(lznode) - @assert row_number(cell) == current_row "Error processing Worksheet $(ws.name): Inconsistent state: expected row number $(current_row), but cell has row number $(row_number(cell))" + if row_number(cell) != current_row + throw(XLSXError("Error processing Worksheet $(ws.name): Inconsistent state: expected row number $(current_row), but cell has row number $(row_number(cell))")) + end rowcells[column_number(cell)] = cell if cell_no == nc # when all cells found @@ -162,8 +172,6 @@ function Base.iterate(itr::SheetRowStreamIterator, state::Union{Nothing, SheetRo state.ht = current_row_ht state.itr_state = lzstate - - return sheet_row, state end @@ -193,39 +201,56 @@ end # # WorksheetCache iterator # -# The state is the row number. The element is a SheetRow. +# The state is the row number and a flag for if the cache is full or being filled. The element is a SheetRow. # function WorksheetCache(ws::Worksheet) itr = SheetRowStreamIterator(ws) - return WorksheetCache(CellCache(), Vector{Int}(), Dict{Int, Union{Float64, Nothing}}(), Dict{Int, Int}(), itr, nothing, true) + return WorksheetCache(true, CellCache(), Vector{Int}(), Dict{Int, Union{Float64, Nothing}}(), Dict{Int, Int}(), itr, nothing, true) end @inline get_worksheet(r::SheetRow) = r.sheet @inline get_worksheet(itr::WorksheetCache) = get_worksheet(itr.stream_iterator) -# In the WorksheetCache iterator, the element is a SheetRow, the state is the row number -function Base.iterate(ws_cache::WorksheetCache, row_from_last_iteration::Int=0) +# In the WorksheetCache iterator, the element is a SheetRow, the state is the row number and a flag on whether the cache is already full or not +function Base.iterate(ws_cache::WorksheetCache, state::Union{Nothing, WorksheetCacheIteratorState}=nothing) + + # If first iteration, check if cache is full + if isnothing(state) + if is_cache_enabled(ws_cache.stream_iterator.sheet) && ws_cache.is_empty + state=WorksheetCacheIteratorState(0, false) + ws_cache.is_empty = false + else + state=WorksheetCacheIteratorState(0, true) + end + end - #the sorting operation is very costly when adding row and only needed if we use the row iterator + # 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 - - if row_from_last_iteration == 0 && !isempty(ws_cache.rows_in_cache) - # the next row is in cache, and it's the first one - current_row_number = ws_cache.rows_in_cache[1] - current_row_ht = ws_cache.row_ht[current_row_number] - sheet_row_cells = ws_cache.cells[current_row_number] - return SheetRow(get_worksheet(ws_cache), current_row_number, current_row_ht, sheet_row_cells), current_row_number - - elseif row_from_last_iteration != 0 && ws_cache.row_index[row_from_last_iteration] < length(ws_cache.rows_in_cache) - # the next row is in cache - current_row_number = ws_cache.rows_in_cache[ws_cache.row_index[row_from_last_iteration] + 1] - current_row_ht = ws_cache.row_ht[current_row_number] - sheet_row_cells = ws_cache.cells[current_row_number] - return SheetRow(get_worksheet(ws_cache), current_row_number, current_row_ht, sheet_row_cells), current_row_number + end + + + if state.full_cache + if state.row_from_last_iteration == 0 && !isempty(ws_cache.rows_in_cache) + # the next row is in cache, and it's the first one + current_row_number = ws_cache.rows_in_cache[1] + current_row_ht = ws_cache.row_ht[current_row_number] + sheet_row_cells = ws_cache.cells[current_row_number] + state.row_from_last_iteration=current_row_number + return SheetRow(get_worksheet(ws_cache), current_row_number, current_row_ht, sheet_row_cells), state + + elseif state.row_from_last_iteration != 0 && ws_cache.row_index[state.row_from_last_iteration] < length(ws_cache.rows_in_cache) + # the next row is in cache + current_row_number = ws_cache.rows_in_cache[ws_cache.row_index[state.row_from_last_iteration] + 1] + current_row_ht = ws_cache.row_ht[current_row_number] + sheet_row_cells = ws_cache.cells[current_row_number] + state.row_from_last_iteration=current_row_number + return SheetRow(get_worksheet(ws_cache), current_row_number, current_row_ht, sheet_row_cells), state + else + return nothing + end else next = iterate(ws_cache.stream_iterator, ws_cache.stream_state) @@ -241,7 +266,9 @@ function Base.iterate(ws_cache::WorksheetCache, row_from_last_iteration::Int=0) # update stream state ws_cache.stream_state = next_stream_state - return sheet_row, row_number(sheet_row) + state.row_from_last_iteration=row_number(sheet_row) + + return sheet_row, state end end @@ -251,7 +278,7 @@ function find_row(itr::SheetRowIterator, row::Int) :: SheetRow return r end end - error("Row $row not found.") + throw(XLSXError("Row $row not found.")) end @@ -276,10 +303,11 @@ function getcell(r::SheetRow, column_index::Int) :: AbstractCell end function getcell(r::SheetRow, column_name::AbstractString) - @assert is_valid_column_name(column_name) "$column_name is not a valid column name." + !is_valid_column_name(column_name) && throw(XLSXError("$column_name is not a valid column name.")) return getcell(r, decode_column_number(column_name)) end +getdata(r::SheetRow, column::Union{Vector{T}, UnitRange{T}}) where {T<:Integer} = [getdata(get_worksheet(r), getcell(r, x)) for x in column] getdata(r::SheetRow, column) = getdata(get_worksheet(r), getcell(r, column)) Base.getindex(r::SheetRow, x) = getdata(r, x) @@ -293,7 +321,7 @@ Example: Query all cells from columns 1 to 4. ```julia left = 1 # 1st column right = 4 # 4th column -for sheetrow in XLSX.eachrow(sheet) +for sheetrow in eachrow(sheet) for column in left:right cell = XLSX.getcell(sheetrow, column) @@ -301,8 +329,15 @@ for sheetrow in XLSX.eachrow(sheet) end end ``` + +Note: The `eachrow` row iterator will not return any row that +consists entirely of `EmptyCell`s. These are simply not seen +by the iterator. The `length(eachrow(sheet))` function therefore +defines the number of rows that are not entirely empty and will, +in any case, only succeed if the worksheet cache is in use. """ function eachrow(ws::Worksheet) :: SheetRowIterator + if is_cache_enabled(ws) if ws.cache === nothing ws.cache = WorksheetCache(ws) @@ -316,3 +351,5 @@ end function Base.isempty(sr::SheetRow) return isempty(sr.rowcells) end + +Base.length(r::XLSX.WorksheetCache)=length(r.cells) \ No newline at end of file diff --git a/src/styles.jl b/src/styles.jl index 89b42c72..1ca22338 100644 --- a/src/styles.jl +++ b/src/styles.jl @@ -42,13 +42,25 @@ default_cell_format(ws::Worksheet, ::Dates.DateTime) = get_num_style_index(ws, D # Attempts to get CellDataFormat associated with a numFmtId and sets a default style if it is not found # Use for ensuring default formats exist function get_num_style_index(ws::Worksheet, numformatid::Integer) - @assert numformatid >= 0 "Invalid number format id" + numformatid < 0 && throw(XLSXError("Invalid number format id")) wb = get_workbook(ws) style_index = styles_get_cellXf_with_numFmtId(wb, numformatid) if isempty(style_index) # adds default style - style_index = styles_add_cell_xf(wb, Dict("applyNumberFormat"=>"1", "borderId"=>"0", "fillId"=>"0", "fontId"=>"0", "numFmtId"=>string(numformatid), "xfId"=>"0")) + style_index = styles_add_cell_xf(wb, Dict("applyNumberFormat" => "1", "borderId" => "0", "fillId" => "0", "fontId" => "0", "numFmtId" => string(numformatid), "xfId" => "0")) + end + + return style_index +end +function get_num_style_index(ws::Worksheet, allXfNodes::Vector{XML.Node}, numformatid::Integer) + numformatid < 0 && throw(XLSXError("Invalid number format id")) + + wb = get_workbook(ws) + style_index = styles_get_cellXf_with_numFmtId(allXfNodes, numformatid) + if isempty(style_index) + # adds default style + style_index = styles_add_cell_xf(wb, Dict("applyNumberFormat" => "1", "borderId" => "0", "fillId" => "0", "fontId" => "0", "numFmtId" => string(numformatid), "xfId" => "0")) end return style_index @@ -63,53 +75,60 @@ function styles_xmlroot(workbook::Workbook) styles_root = xmlroot(get_xlsxfile(workbook), styles_target) # check root node name for styles.xml - @assert get_default_namespace(styles_root[end]) == SPREADSHEET_NAMESPACE_XPATH_ARG "Unsupported styles XML namespace $(get_default_namespace(styles_root[end]))." - @assert XML.tag(styles_root[end]) == "styleSheet" "Malformed package. Expected root node named `styleSheet` in `styles.xml`." + if get_default_namespace(styles_root[end]) != SPREADSHEET_NAMESPACE_XPATH_ARG + throw(XLSXError("Unsupported styles XML namespace $(get_default_namespace(styles_root[end])).")) + end + XML.tag(styles_root[end]) != "styleSheet" && throw(XLSXError("Malformed package. Expected root node named `styleSheet` in `styles.xml`.")) workbook.styles_xroot = styles_root else - error("Styles not found for this workbook.") + throw(XLSXError("Styles not found for this workbook.")) end end return workbook.styles_xroot end - + # Returns the xf XML node element for style `index`. # `index` is 0-based. -function styles_cell_xf(wb::Workbook, index::Int) :: XML.Node +function styles_cell_xf(wb::Workbook, index::Int)::XML.Node xroot = styles_xmlroot(wb) - xf_elements = find_all_nodes("/$SPREADSHEET_NAMESPACE_XPATH_ARG:styleSheet/$SPREADSHEET_NAMESPACE_XPATH_ARG:cellXfs/$SPREADSHEET_NAMESPACE_XPATH_ARG:xf", xroot) + xf_elements = find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", xroot) return xf_elements[index+1] end +function styles_cell_xf(allXfNodes::Vector{XML.Node}, index::Int)::XML.Node + return allXfNodes[index+1] +end # Queries numFmtId from cellXfs -> xf nodes." -function styles_cell_xf_numFmtId(wb::Workbook, index::Int) :: Int +function styles_cell_xf_numFmtId(wb::Workbook, index::Int)::Int el = styles_cell_xf(wb, index) if !haskey(el, "numFmtId") return 0 end return parse(Int, el["numFmtId"]) end +function styles_cell_xf_numFmtId(allXfNodes::Vector{XML.Node}, index::Int)::Int + el = styles_cell_xf(allXfNodes, index) + if !haskey(el, "numFmtId") + return 0 + end + return parse(Int, el["numFmtId"]) +end # Defines a custom number format to render numbers, dates or text. # Returns the index to be used as the `numFmtId` in a cellXf definition. -function styles_add_numFmt(wb::Workbook, format_code::AbstractString) :: Integer +function styles_add_numFmt(wb::Workbook, format_code::AbstractString)::Integer xroot = styles_xmlroot(wb) - numfmts = find_all_nodes("/$SPREADSHEET_NAMESPACE_XPATH_ARG:styleSheet/$SPREADSHEET_NAMESPACE_XPATH_ARG:numFmts", xroot) + numfmts = find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":numFmts", xroot) if isempty(numfmts) - stylesheet = find_all_nodes("/$SPREADSHEET_NAMESPACE_XPATH_ARG:styleSheet", xroot)[begin] # find first + stylesheet = find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet", xroot)[begin] # find first # We need to add the numFmts node directly after the styleSheet node # Move everything down one and then insert the new node at the top - nchildren = length(XML.children(stylesheet)) - numfmts = XML.Element("numFmts", count="1") - push!(stylesheet, stylesheet[end]) - for i in nchildren-1:-1:1 - stylesheet[i+1]=stylesheet[i] - end - stylesheet[1]=numfmts + numfmts = XML.Element("numFmts", count="1") + XML.pushfirst!(stylesheet, numfmts) else numfmts = numfmts[1] end @@ -117,21 +136,24 @@ function styles_add_numFmt(wb::Workbook, format_code::AbstractString) :: Integer existing_numFmt_elements_count = length(XML.children(numfmts)) fmt_code = existing_numFmt_elements_count + PREDEFINED_NUMFMT_COUNT new_fmt = XML.Element("numFmt"; - numFmtId = fmt_code, - formatCode = xlsx_escape(format_code) + numFmtId=fmt_code, + formatCode=XML.escape(format_code) ) push!(numfmts, new_fmt) return fmt_code end -const FontAttribute = Union{String, Pair{String, Pair{String, String}}} +const FontAttribute = Union{String,Pair{String,Pair{String,String}}} # Queries numFmt formatCode field by numFmtId. -function styles_numFmt_formatCode(wb::Workbook, numFmtId::AbstractString) :: String +function styles_numFmt_formatCode(wb::Workbook, numFmtId::AbstractString)::String + if haskey(builtinFormats, numFmtId) + return builtinFormats[numFmtId] + end xroot = styles_xmlroot(wb) - nodes_found = find_all_nodes("/$SPREADSHEET_NAMESPACE_XPATH_ARG:styleSheet/$SPREADSHEET_NAMESPACE_XPATH_ARG:numFmts/$SPREADSHEET_NAMESPACE_XPATH_ARG:numFmt", xroot) - elements_found = filter(x->XML.attributes(x)["numFmtId"] == numFmtId, nodes_found) - @assert length(elements_found) == 1 "numFmtId $numFmtId not found." + nodes_found = find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":numFmts/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":numFmt", xroot) + elements_found = filter(x -> XML.attributes(x)["numFmtId"] == numFmtId, nodes_found) + length(elements_found) != 1 && throw(XLSXError("numFmtId $numFmtId not found.")) return XML.attributes(elements_found[1])["formatCode"] end @@ -143,10 +165,10 @@ function remove_formatting(code::AbstractString) # this regex should cover all the formatting cases found here(colors/conditionals/quotes/spacing): # https://support.office.com/en-us/article/create-or-delete-a-custom-number-format-78f2a361-936b-4c03-8772-09fab54be7f4 ignoredformatting = r"""\[.{2,}?\]|".+?"|_.|\\.|\*."""x # Had to add ? to "".+"" to make it work. Don't understand what made this necessary! - replace(code, ignoredformatting => "") + replace(code, ignoredformatting => "") end -function styles_is_datetime(wb::Workbook, index::Int) :: Bool +function styles_is_datetime(wb::Workbook, index::Int)::Bool if !haskey(wb.buffer_styles_is_datetime, index) isdatetime = false @@ -157,7 +179,7 @@ function styles_is_datetime(wb::Workbook, index::Int) :: Bool elseif numFmtId > 81 code = lowercase(styles_numFmt_formatCode(wb, numFmtId)) code = remove_formatting(code) - if any(map(x->occursin(x, code), DATETIME_CODES)) + if any(map(x -> occursin(x, code), DATETIME_CODES)) isdatetime = true end end @@ -171,13 +193,13 @@ end styles_is_datetime(wb::Workbook, fmt::CellDataFormat) = styles_is_datetime(wb, Int(fmt.id)) function styles_is_datetime(wb::Workbook, index::AbstractString) - @assert !isempty(index) + isempty(index) && throw(XLSXError("Something wrong here!")) styles_is_datetime(wb, parse(Int, index)) end styles_is_datetime(ws::Worksheet, index) = styles_is_datetime(get_workbook(ws), index) -function styles_is_float(wb::Workbook, index::Int) :: Bool +function styles_is_float(wb::Workbook, index::Int)::Bool if !haskey(wb.buffer_styles_is_float, index) isfloat = false numFmtId = styles_cell_xf_numFmtId(wb, index) @@ -206,7 +228,7 @@ function styles_is_float(wb::Workbook, index::Int) :: Bool end function styles_is_float(wb::Workbook, index::AbstractString) - @assert !isempty(index) + isempty(index) && throw(XLSXError("Something wrong here!")) styles_is_float(wb, parse(Int, index)) end @@ -227,18 +249,20 @@ Returns -1 if not found. ``` =# -function styles_get_cellXf_with_numFmtId(wb::Workbook, numFmtId::Int) :: AbstractCellDataFormat +function styles_get_cellXf_with_numFmtId(wb::Workbook, numFmtId::Int)::AbstractCellDataFormat xroot = styles_xmlroot(wb) - elements_found = find_all_nodes("/$SPREADSHEET_NAMESPACE_XPATH_ARG:styleSheet/$SPREADSHEET_NAMESPACE_XPATH_ARG:cellXfs/$SPREADSHEET_NAMESPACE_XPATH_ARG:xf", xroot) - - if isempty(elements_found) + allXfNodes = find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":styleSheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":cellXfs/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":xf", xroot) + return styles_get_cellXf_with_numFmtId(allXfNodes, numFmtId) +end +function styles_get_cellXf_with_numFmtId(allXfNodes::Vector{XML.Node}, numFmtId::Int)::AbstractCellDataFormat + if isempty(allXfNodes) return EmptyCellDataFormat() else - for i in 1:length(elements_found) - el = XML.attributes(elements_found[i]) + for i in 1:length(allXfNodes) + el = XML.attributes(allXfNodes[i]) if !isnothing(el) && haskey(el, "numFmtId") if parse(Int, el["numFmtId"]) == numFmtId - return CellDataFormat(i-1) # CellDataFormat is zero-indexed + return CellDataFormat(i - 1) # CellDataFormat is zero-indexed end end end @@ -249,23 +273,23 @@ function styles_get_cellXf_with_numFmtId(wb::Workbook, numFmtId::Int) :: Abstrac end function styles_add_cell_xf(wb::Workbook, attributes::Dict{String,String})::CellDataFormat - new_xf = XML.Node(XML.Element, "xf", XML.OrderedDict{String, String}(), nothing, nothing) + new_xf = XML.Node(XML.Element, "xf", XML.OrderedDict{String,String}(), nothing, nothing) for k in keys(attributes) new_xf[k] = attributes[k] end return styles_add_cell_xf(wb, new_xf) end -function styles_add_cell_xf(wb::Workbook, new_xf::XML.Node) :: CellDataFormat +function styles_add_cell_xf(wb::Workbook, new_xf::XML.Node)::CellDataFormat xroot = styles_xmlroot(wb) i, j = get_idces(xroot, "styleSheet", "cellXfs") existing_cellxf_elements_count = length(XML.children(xroot[i][j])) - @assert parse(Int, xroot[i][j]["count"]) == existing_cellxf_elements_count "Wrong number of xf elements found: $existing_cellxf_elements_count. Expected $(parse(Int, xroot[i][j]["count"]))." + if parse(Int, xroot[i][j]["count"]) != existing_cellxf_elements_count + throw(XLSXError("Wrong number of xf elements found: $existing_cellxf_elements_count. Expected $(parse(Int, xroot[i][j]["count"])).")) + end # Check new_xf doesn't duplicate any existing xf. If yes, use that rather than create new. - # Need to work around XML.jl issue # 33 for (k, node) in enumerate(XML.children(xroot[i][j])) - #if XML.nodetype(node) == XML.nodetype(new_xf) && XML.parse(XML.Node, XML.write(node)) == XML.parse(XML.Node, XML.write(new_xf)) # XML.jl defines `Base.:(==)` - if XML.parse(XML.Node, XML.write(node))[1] == XML.parse(XML.Node, XML.write(new_xf))[1] # XML.jl defines `Base.:(==)` + if node == new_xf return CellDataFormat(k - 1) # CellDataFormat is zero-indexed end end diff --git a/src/table.jl b/src/table.jl index e0fbe436..8c776ce7 100644 --- a/src/table.jl +++ b/src/table.jl @@ -6,7 +6,7 @@ # Returns a tuple with the first and last index of the columns for a `SheetRow`. function column_bounds(sr::SheetRow) - @assert !isempty(sr) "Can't get column bounds from an empty row." + isempty(sr) && throw(XLSXError("Can't get column bounds from an empty row.")) local first_column_index::Int = first(keys(sr.rowcells)) local last_column_index::Int = first_column_index @@ -27,7 +27,7 @@ end # anchor_column will be the leftmost column of the column_bounds function last_column_index(sr::SheetRow, anchor_column::Int) :: Int - @assert !isempty(getcell(sr, anchor_column)) "Can't get column bounds based on an empty anchor cell." + isempty(getcell(sr, anchor_column)) && throw(XLSXError("Can't get column bounds based on an empty anchor cell.")) local first_column_index::Int = anchor_column local last_column_index::Int = first_column_index @@ -51,24 +51,24 @@ function last_column_index(sr::SheetRow, anchor_column::Int) :: Int return last_column_index end -function _colname_prefix_symbol(sheet::Worksheet, cell::Cell) +function _colname_prefix_string(sheet::Worksheet, cell::Cell) d = getdata(sheet, cell) if d isa String - return Symbol(XML.unescape(d)) + return XML.unescape(d) else - return Symbol(d) + return string(d) end end -_colname_prefix_symbol(sheet::Worksheet, ::EmptyCell) = Symbol("#Empty") +_colname_prefix_string(sheet::Worksheet, ::EmptyCell) = "#Empty" # helper function to manage problematic column labels # Empty cell -> "#Empty" # No_unique_label -> No_unique_label_2 -function push_unique!(vect::Vector{Symbol}, sheet::Worksheet, cell::AbstractCell, iter::Int=1) - name = _colname_prefix_symbol(sheet, cell) +function push_unique!(vect::Vector{String}, sheet::Worksheet, cell::AbstractCell, iter::Int=1) + name = _colname_prefix_string(sheet, cell) if iter > 1 - name = Symbol(name, '_', iter) + name = name*"_"*string(iter) end if name in vect @@ -80,6 +80,20 @@ function push_unique!(vect::Vector{Symbol}, sheet::Worksheet, cell::AbstractCell nothing end +# Issue 260 +const RESERVED = Set(["local", "global", "export", "let", + "for", "struct", "while", "const", "continue", "import", + "function", "if", "else", "try", "begin", "break", "catch", + "return", "using", "baremodule", "macro", "finally", + "module", "elseif", "end", "quote", "do"]) +normalizename(name::Symbol) = name +function normalizename(name::String)::Symbol + uname = strip(Unicode.normalize(name)) + id = Base.isidentifier(uname) ? uname : map(c->Base.is_id_char(c) ? c : '_', uname) + cleansed = string((isempty(id) || !Base.is_id_start_char(id[1]) || id in RESERVED) ? "_" : "", id) + return Symbol(replace(cleansed, r"(_)\1+"=>"_")) +end + """ eachtablerow(sheet, [columns]; [first_row], [column_labels], [header], [stop_in_empty_row], [stop_in_row_function], [keep_empty_rows]) @@ -134,37 +148,48 @@ function eachtablerow( stop_in_empty_row::Bool=true, stop_in_row_function::Union{Nothing, Function}=nothing, keep_empty_rows::Bool=false, + normalizenames::Bool=false ) :: TableRowIterator - if first_row === nothing - first_row = _find_first_row_with_data(sheet, convert(ColumnRange, cols).start) - end + #let col_lab + + if first_row === nothing + first_row = _find_first_row_with_data(sheet, convert(ColumnRange, cols).start) + end - itr = eachrow(sheet) - column_range = convert(ColumnRange, cols) - - if column_labels === nothing - column_labels = Vector{Symbol}() - if header - # will use getdata to get column names - for column_index in column_range.start:column_range.stop - sheet_row = find_row(itr, first_row) - cell = getcell(sheet_row, column_index) - push_unique!(column_labels, sheet, cell) + itr = eachrow(sheet) + column_range = convert(ColumnRange, cols) + col_lab = Vector{String}() + + if column_labels === nothing + if header + # will use getdata to get column names + for column_index in column_range.start:column_range.stop + sheet_row = find_row(itr, first_row) + cell = getcell(sheet_row, column_index) + push_unique!(col_lab, sheet, cell) + end + else + # generate column_labels if there's no header information anywhere + for c in column_range + push!(col_lab, string(c)) + end end else - # generate column_labels if there's no header information anywhere - for c in column_range - push!(column_labels, Symbol(c)) + # check consistency for column_range and column_labels + if length(column_labels) != length(column_range) + throw(XLSXError("`column_range` (length=$(length(column_range))) and `column_labels` (length=$(length(column_labels))) must have the same length.")) end end - else - # check consistency for column_range and column_labels - @assert length(column_labels) == length(column_range) "`column_range` (length=$(length(column_range))) and `column_labels` (length=$(length(column_labels))) must have the same length." - end - - first_data_row = header ? first_row + 1 : first_row - return TableRowIterator(sheet, Index(column_range, column_labels), first_data_row, stop_in_empty_row, stop_in_row_function, keep_empty_rows) + if normalizenames + column_labels = normalizename.(column_labels===nothing ? col_lab : column_labels) + else + column_labels = Symbol.(column_labels===nothing ? col_lab : column_labels) + end + + first_data_row = header ? first_row + 1 : first_row + return TableRowIterator(sheet, Index(column_range, column_labels), first_data_row, stop_in_empty_row, stop_in_row_function, keep_empty_rows) + # end end function TableRowIterator(sheet::Worksheet, index::Index, first_data_row::Int, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Nothing, Function}=nothing, keep_empty_rows::Bool=false) @@ -179,6 +204,7 @@ function eachtablerow( stop_in_empty_row::Bool=true, stop_in_row_function::Union{Nothing, Function}=nothing, keep_empty_rows::Bool=false, + normalizenames::Bool=false ) :: TableRowIterator if first_row === nothing @@ -224,12 +250,12 @@ function eachtablerow( # if got here, it's because all columns are non-empty column_range = ColumnRange(column_start, column_stop) - return eachtablerow(sheet, column_range; first_row=first_row, column_labels=column_labels, header=header, stop_in_empty_row=stop_in_empty_row, stop_in_row_function=stop_in_row_function, keep_empty_rows=keep_empty_rows) + return eachtablerow(sheet, column_range; first_row=first_row, column_labels=column_labels, header=header, stop_in_empty_row=stop_in_empty_row, stop_in_row_function=stop_in_row_function, keep_empty_rows=keep_empty_rows, normalizenames=normalizenames) end end end - error("Couldn't find a table in sheet $(sheet.name)") + throw(XLSXError("Couldn't find a table in sheet $(sheet.name)")) end function _find_first_row_with_data(sheet::Worksheet, column_number::Int) @@ -239,7 +265,7 @@ function _find_first_row_with_data(sheet::Worksheet, column_number::Int) return row_number(r) end end - error("Column $(encode_column_number(column_number)) has no data.") + throw(XLSXError("Column $(encode_column_number(column_number)) has no data.")) end @inline get_worksheet(tri::TableRowIterator) = get_worksheet(tri.itr) @@ -300,13 +326,14 @@ function TableRow(table_row::Int, index::Index, sheet_row::SheetRow) end getdata(r::TableRow, table_column_number::Int) = r.cell_values[table_column_number] +getdata(r::TableRow, table_column_numbers::Union{Vector{T}, UnitRange{T}}) where {T<:Integer} = [r.cell_values[x] for x in table_column_numbers] function getdata(r::TableRow, column_label::Symbol) index = r.index if haskey(index.lookup, column_label) return getdata(r, index.lookup[column_label]) else - error("Invalid column label: $column_label.") + throw(XLSXError("Invalid column label: $column_label.")) end end @@ -324,7 +351,7 @@ function Base.iterate(itr::TableRowIterator) table_row_index = 1 return TableRow(table_row_index, itr.index, sheet_row), TableRowIteratorState(table_row_index, row_number(sheet_row), sheet_row_iterator_state) else - next = iterate(itr.itr, sheet_row_iterator_state) + next = iterate(itr.itr, sheet_row_iterator_state) end end @@ -369,7 +396,7 @@ function Base.iterate(itr::TableRowIterator, state::TableRowIteratorState) end if is_empty_table_row(sheet_row) - if itr.stop_in_empty_row + if itr.stop_in_empty_row # user asked to stop fetching table rows if we find an empty row return nothing elseif !itr.keep_empty_rows @@ -391,10 +418,11 @@ function Base.iterate(itr::TableRowIterator, state::TableRowIteratorState) end # if the `is_empty_table_row` check above was successful, we can't get empty sheet_row here - @assert !is_empty_table_row(sheet_row) || itr.keep_empty_rows + @assert !is_empty_table_row(sheet_row) || itr.keep_empty_rows "Something wrong here!" + table_row = TableRow(table_row_index, itr.index, sheet_row) - # user asked to stop + # user asked to stop (or end of row range) if itr.stop_in_row_function !== nothing && itr.stop_in_row_function(table_row) return nothing end @@ -447,7 +475,9 @@ function check_table_data_dimension(data::Vector) # all columns should be vectors for (colindex, colvec) in enumerate(data) - @assert isa(colvec, Vector) "Data type at index $colindex is not a vector. Found: $(typeof(colvec))." + if !isa(colvec, Vector) + throw(XLSXError("Data type at index $colindex is not a vector. Found: $(typeof(colvec)).")) + end end # no need to check row count @@ -457,13 +487,15 @@ function check_table_data_dimension(data::Vector) col_count = length(data) row_count = length(data[1]) for colindex in 2:col_count - @assert length(data[colindex]) == row_count "Not all columns have the same number of rows. Check column $colindex." + if length(data[colindex]) != row_count + throw(XLSXError("Not all columns have the same number of rows. Check column $colindex.")) + end end nothing end -function gettable(itr::TableRowIterator; infer_eltypes::Bool=false) :: DataTable +function gettable(itr::TableRowIterator; infer_eltypes::Bool=true) :: DataTable column_labels = get_column_labels(itr) columns_count = table_columns_count(itr) data = Vector{Any}(undef, columns_count) @@ -516,7 +548,8 @@ end [infer_eltypes], [stop_in_empty_row], [stop_in_row_function], - [keep_empty_rows] + [keep_empty_rows], + [normalizenames] ) -> DataTable Returns tabular data from a spreadsheet as a struct `XLSX.DataTable`. @@ -540,8 +573,10 @@ 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 `normalizenames=true` to normalize column names to valid Julia identifiers. + Use `infer_eltypes=true` to get `data` as a `Vector{Any}` of typed vectors. -The default value is `infer_eltypes=false`. +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. @@ -571,15 +606,15 @@ julia> df = XLSX.openxlsx("myfile.xlsx") do xf DataFrame(XLSX.gettable(xf["mysheet"])) end ``` - + See also: [`XLSX.readtable`](@ref). """ -function gettable(sheet::Worksheet, cols::Union{ColumnRange, AbstractString}; first_row::Union{Nothing, Int}=nothing, column_labels=nothing, header::Bool=true, infer_eltypes::Bool=false, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Function, Nothing}=nothing, keep_empty_rows::Bool=false) - itr = eachtablerow(sheet, cols; first_row=first_row, column_labels=column_labels, header=header, stop_in_empty_row=stop_in_empty_row, stop_in_row_function=stop_in_row_function, keep_empty_rows=keep_empty_rows) - return gettable(itr; infer_eltypes=infer_eltypes) +function gettable(sheet::Worksheet, cols::Union{ColumnRange, AbstractString}; first_row::Union{Nothing, Int}=nothing, column_labels=nothing, header::Bool=true, infer_eltypes::Bool=true, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Function, Nothing}=nothing, keep_empty_rows::Bool=false, normalizenames::Bool=false) + itr = eachtablerow(sheet, cols; first_row, column_labels, header, stop_in_empty_row, stop_in_row_function, keep_empty_rows, normalizenames) + return gettable(itr; infer_eltypes) end -function gettable(sheet::Worksheet; first_row::Union{Nothing, Int}=nothing, column_labels=nothing, header::Bool=true, infer_eltypes::Bool=false, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Function, Nothing}=nothing, keep_empty_rows::Bool=false) - itr = eachtablerow(sheet; first_row=first_row, column_labels=column_labels, header=header, stop_in_empty_row=stop_in_empty_row, stop_in_row_function=stop_in_row_function, keep_empty_rows=keep_empty_rows) - return gettable(itr; infer_eltypes=infer_eltypes) +function gettable(sheet::Worksheet; first_row::Union{Nothing, Int}=nothing, column_labels=nothing, header::Bool=true, infer_eltypes::Bool=true, stop_in_empty_row::Bool=true, stop_in_row_function::Union{Function, Nothing}=nothing, keep_empty_rows::Bool=false, normalizenames::Bool=false) + itr = eachtablerow(sheet; first_row, column_labels, header, stop_in_empty_row, stop_in_row_function, keep_empty_rows, normalizenames) + return gettable(itr; infer_eltypes) end diff --git a/src/tables_interface.jl b/src/tables_interface.jl index b41ec25c..a38444bd 100644 --- a/src/tables_interface.jl +++ b/src/tables_interface.jl @@ -18,7 +18,7 @@ function _table_to_arrays(x) colnames = collect(Symbol, Tables.columnnames(x)) return columns, colnames else - error("$(typeof(x)) does not implement Tables.jl interface.") + throw(XLSXError("$(typeof(x)) does not implement Tables.jl interface.")) end end @@ -59,7 +59,7 @@ Tables.getcolumn(dt::DataTable, i::Int) = dt.data[i] function Tables.getcolumn(dt::DataTable, column_label::Symbol) if !haskey(dt.column_label_index, column_label) - error("Column `$column_label` not found.") + throw(XLSXError("Column `$column_label` not found.")) end column_index = dt.column_label_index[column_label] diff --git a/src/types.jl b/src/types.jl index ae9ba8c3..a8b21318 100644 --- a/src/types.jl +++ b/src/types.jl @@ -147,6 +147,11 @@ struct CellDataFormat <: AbstractCellDataFormat id::UInt end +# Keeps track of conditional formatting information. +struct DxFormat <: AbstractCellDataFormat + id::UInt +end + """ CellValueType @@ -175,7 +180,13 @@ As a convenience, `@range_str` macro is provided. cr = XLSX.range"A1:C4" ``` =# -struct CellRange + +abstract type AbstractCellRange end +abstract type ContiguousCellRange <: AbstractCellRange end +abstract type AbstractSheetCellRange <: AbstractCellRange end +abstract type ContiguousSheetCellRange <: AbstractSheetCellRange end + +struct CellRange <: ContiguousCellRange start::CellRef stop::CellRef @@ -186,18 +197,33 @@ struct CellRange left = column_number(a) right = column_number(b) - @assert left <= right && top <= bottom "Invalid CellRange. Start cell should be at the top left corner of the range." + if left > right || top > bottom + throw(XLSXError("Invalid CellRange. Start cell should be at the top left corner of the range.")) + end return new(a, b) end end -struct ColumnRange +struct ColumnRange <: ContiguousCellRange start::Int # column number stop::Int # column number function ColumnRange(a::Int, b::Int) - @assert a <= b "Invalid ColumnRange. Start column must be located before end column." + if a > b + throw(XLSXError("Invalid ColumnRange. Start column must be located before end column.")) + end + return new(a, b) + end +end +struct RowRange <: ContiguousCellRange + start::Int # row number + stop::Int # row number + + function RowRange(a::Int, b::Int) + if a > b + throw(XLSXError("Invalid RowRange. Start row must be located before end row.")) + end return new(a, b) end end @@ -207,15 +233,24 @@ struct SheetCellRef cellref::CellRef end -struct SheetCellRange +struct SheetCellRange <: ContiguousSheetCellRange sheet::String rng::CellRange end -struct SheetColumnRange +struct NonContiguousRange <: AbstractSheetCellRange + sheet::String + rng::Vector{Union{CellRef, CellRange}} +end + +struct SheetColumnRange <: ContiguousSheetCellRange sheet::String colrng::ColumnRange end +struct SheetRowRange <: ContiguousSheetCellRange + sheet::String + rowrng::RowRange +end abstract type MSOfficePackage end @@ -258,7 +293,13 @@ mutable struct SheetRowStreamIteratorState ht::Union{Float64, Nothing} # row height end +mutable struct WorksheetCacheIteratorState + row_from_last_iteration::Int + full_cache::Bool # is the cache full (true) or does it need filling (false) +end + mutable struct WorksheetCache{I<:SheetRowIterator} <: SheetRowIterator + is_empty::Bool # true before first read from file, then false cells::CellCache # SheetRowNumber -> Dict{column_number, Cell} rows_in_cache::Vector{Int} # ordered vector with row numbers that are stored in cache row_ht::Dict{Int, Union{Float64, Nothing}} # Maps a row number to a row height @@ -309,9 +350,18 @@ mutable struct SharedStringTable is_loaded::Bool # for lazy-loading of sst XML file (implies that this struct must be mutable) end -const DefinedNameValueTypes = Union{SheetCellRef, SheetCellRange, Int, Float64, String, Missing} +const DefinedNameValueTypes = Union{SheetCellRef, SheetCellRange, NonContiguousRange, Int, Float64, String, Missing} +const DefinedNameRangeTypes = Union{SheetCellRef, SheetCellRange, NonContiguousRange} + +struct DefinedNameValue + value::DefinedNameValueTypes + isabs::Union{Bool, Vector{Bool}} +end # Workbook is the result of parsing file `xl/workbook.xml`. +# 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. mutable struct Workbook package::MSOfficePackage # parent XLSXFile sheets::Vector{Worksheet} # workbook -> sheets -> . sheetId determines the index of the WorkSheet in this vector. @@ -320,15 +370,16 @@ mutable struct Workbook sst::SharedStringTable # shared string table buffer_styles_is_float::Dict{Int, Bool} # cell style -> true if is float buffer_styles_is_datetime::Dict{Int, Bool} # cell style -> true if is datetime - workbook_names::Dict{String, DefinedNameValueTypes} # definedName - worksheet_names::Dict{Tuple{Int, String}, DefinedNameValueTypes} # definedName. (sheetId, name) -> value. + workbook_names::Dict{String, DefinedNameValue} # definedName + worksheet_names::Dict{Tuple{Int, String}, DefinedNameValue} # definedName. (sheetId, name) -> value. styles_xroot::Union{XML.Node, Nothing} end """ `XLSXFile` represents a reference to an Excel file. -It is created by using [`XLSX.readxlsx`](@ref) or [`XLSX.openxlsx`](@ref). +It is created by using [`XLSX.readxlsx`](@ref) or [`XLSX.openxlsx`](@ref) +or [`XLSX.opentemplate`](@ref) or [`XLSX.newxlsx`](@ref). From a `XLSXFile` you can navigate to a `XLSX.Worksheet` reference as shown in the example below. @@ -343,6 +394,7 @@ sh = xf["mysheet"] # get a reference to a Worksheet mutable struct XLSXFile <: MSOfficePackage source::Union{AbstractString, IO} use_cache_for_sheet_data::Bool # indicates whether Worksheet.cache will be fed while reading worksheet cells. + stream::Union{Nothing, IOStream} # mmap stream for reading XLSX file io::ZipArchives.ZipReader files::Dict{String, Bool} # maps filename => isread bool data::Dict{String, XML.Node} # maps filename => XMLDocument @@ -351,10 +403,16 @@ mutable struct XLSXFile <: MSOfficePackage relationships::Vector{Relationship} # contains package level relationships is_writable::Bool # indicates whether this XLSX file can be edited - function XLSXFile(source::Union{AbstractString, IO}, use_cache::Bool, is_writable::Bool) + function XLSXFile(source::Union{AbstractString, IO}, use_cache::Bool, is_writable::Bool; use_stream::Bool = false) check_for_xlsx_file_format(source) - io = ZipArchives.ZipReader(read(source)) - xl = new(source, use_cache, io, Dict{String, Bool}(), Dict{String, XML.Node}(), Dict{String, Vector{UInt8}}(), EmptyWorkbook(), Vector{Relationship}(), is_writable) + if !use_stream && use_cache || (source isa IOBuffer) + stream = nothing + io = ZipArchives.ZipReader(read(source)) + else + stream = open(source, "r") + io = ZipArchives.ZipReader(Mmap.mmap(stream)) + end + xl = new(source, use_cache, stream, io, Dict{String, Bool}(), Dict{String, XML.Node}(), Dict{String, Vector{UInt8}}(), EmptyWorkbook(), Vector{Relationship}(), is_writable) xl.workbook.package = xl return xl end @@ -379,7 +437,9 @@ struct Index # based on DataFrames.jl function Index(column_range::Union{ColumnRange, AbstractString}, column_labels) column_labels_as_syms = [ Symbol(i) for i in column_labels ] column_range = convert(ColumnRange, column_range) - @assert length(unique(column_labels_as_syms)) == length(column_labels_as_syms) "Column labels must be unique." + if length(unique(column_labels_as_syms)) != length(column_labels_as_syms) + throw(XLSXError("Column labels must be unique.")) + end lookup = Dict{Symbol, Int}() for (i, n) in enumerate(column_labels_as_syms) @@ -425,11 +485,15 @@ struct DataTable column_labels::Vector{Symbol}, ) - @assert length(data) == length(column_labels) "data has $(length(data)) columns but $(length(column_labels)) column labels." + if length(data) != length(column_labels) + throw(XLSXError("Data has $(length(data)) columns but $(length(column_labels)) column labels.")) + end column_label_index = Dict{Symbol, Int}() for (i, sym) in enumerate(column_labels) - @assert !haskey(column_label_index, sym) "DataTable has repeated label for column `$sym`" + if haskey(column_label_index, sym) + throw(XLSXError("DataTable has repeated label for column `$sym`")) + end column_label_index[sym] = i end @@ -444,4 +508,9 @@ struct xpath function xpath(node::XML.Node, path::String) new(node, path) end -end \ No newline at end of file +end + +struct XLSXError <: Exception + msg::String +end +Base.showerror(io::IO, e::XLSXError) = print(io, "XLSXError: ",e.msg) \ No newline at end of file diff --git a/src/workbook.jl b/src/workbook.jl index db0375c2..9e44840a 100644 --- a/src/workbook.jl +++ b/src/workbook.jl @@ -1,7 +1,7 @@ EmptyWorkbook() = Workbook(EmptyMSOfficePackage(), Vector{Worksheet}(), false, - Vector{Relationship}(), SharedStringTable(), Dict{Int, Bool}(), Dict{Int, Bool}(), - Dict{String, DefinedNameValueTypes}(), Dict{Tuple{Int, String}, DefinedNameValueTypes}(), nothing) + Vector{Relationship}(), SharedStringTable(), Dict{Int,Bool}(), Dict{Int,Bool}(), + Dict{String,DefinedNameValueTypes}(), Dict{Tuple{Int,String},DefinedNameValueTypes}(), nothing) #= Indicates whether this XLSX file can be edited. @@ -14,20 +14,20 @@ is_writable(xl::XLSXFile) = xl.is_writable sheetnames(xl::XLSXFile) sheetnames(wb::Workbook) -Returns a vector with Worksheet names for this Workbook. +Return a vector with Worksheet names for this Workbook. """ -sheetnames(wb::Workbook) = [ s.name for s in wb.sheets ] +sheetnames(wb::Workbook) = [s.name for s in wb.sheets] @inline sheetnames(xl::XLSXFile) = sheetnames(xl.workbook) """ hassheet(wb::Workbook, sheetname::AbstractString) hassheet(xl::XLSXFile, sheetname::AbstractString) -Returns `true` if `wb` contains a sheet named `sheetname`. +Return `true` if `wb` contains a sheet named `sheetname`. """ -function hassheet(wb::Workbook, sheetname::AbstractString) :: Bool +function hassheet(wb::Workbook, sheetname::AbstractString)::Bool for s in wb.sheets - if s.name == sheetname + if s.name == unquoteit(sheetname) return true end end @@ -39,27 +39,27 @@ end """ sheetcount(xlsfile) :: Int -Counts the number of sheets in the Workbook. +Count the number of sheets in the Workbook. """ @inline sheetcount(wb::Workbook) = length(wb.sheets) @inline sheetcount(xl::XLSXFile) = sheetcount(xl.workbook) # Returns true if workbook follows date1904 convention. -@inline isdate1904(wb::Workbook) :: Bool = wb.date1904 -@inline isdate1904(xf::XLSXFile) :: Bool = isdate1904(get_workbook(xf)) +@inline isdate1904(wb::Workbook)::Bool = wb.date1904 +@inline isdate1904(xf::XLSXFile)::Bool = isdate1904(get_workbook(xf)) -function getsheet(wb::Workbook, sheetname::String) :: Worksheet +function getsheet(wb::Workbook, sheetname::String)::Worksheet for ws in wb.sheets - if ws.name == xlsx_escape(sheetname) + if ws.name == unquoteit(sheetname) return ws end end - error("$(get_xlsxfile(wb).source) does not have a Worksheet named $sheetname.") + 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] -@inline getsheet(xl::XLSXFile, sheetname::String) :: Worksheet = getsheet(xl.workbook, sheetname) -@inline getsheet(xl::XLSXFile, sheet_index::Int) :: Worksheet = getsheet(xl.workbook, sheet_index) +@inline getsheet(wb::Workbook, sheet_index::Int)::Worksheet = wb.sheets[sheet_index] +@inline getsheet(xl::XLSXFile, sheetname::String)::Worksheet = getsheet(xl.workbook, sheetname) +@inline getsheet(xl::XLSXFile, sheet_index::Int)::Worksheet = getsheet(xl.workbook, sheet_index) function Base.show(io::IO, xf::XLSXFile) @@ -74,13 +74,13 @@ function Base.show(io::IO, xf::XLSXFile) wb = xf.workbook print(io, "XLSXFile(\"$(xf.source)\") ", - "containing $(sheetcountstr(wb))\n") + "containing $(sheetcountstr(wb))\n") @printf(io, "%21s %-13s %-13s\n", "sheetname", "size", "range") - println(io, "-"^(21+1+13+1+13)) + println(io, "-"^(21 + 1 + 13 + 1 + 13)) for s in wb.sheets - sheetname = s.name - if textwidth(sheetname) > 20 + sheetname = s.name + if textwidth(sheetname) > 20 sheetname = sheetname[collect(eachindex(s.name))[1:20]] * "…" end @@ -105,81 +105,274 @@ function Base.getindex(xl::XLSXFile, s::AbstractString) end function getdata(xl::XLSXFile, ref::SheetCellRef) - @assert hassheet(xl, ref.sheet) "Sheet $(ref.sheet) not found." + !hassheet(xl, ref.sheet) && throw(XLSXError("Sheet `$(ref.sheet)` not found.")) return getdata(getsheet(xl, ref.sheet), ref.cellref) end function getdata(xl::XLSXFile, rng::SheetCellRange) - @assert hassheet(xl, rng.sheet) "Sheet $(rng.sheet) not found." + !hassheet(xl, rng.sheet) && throw(XLSXError("Sheet `$(rng.sheet)` not found.")) return getdata(getsheet(xl, rng.sheet), rng.rng) end function getdata(xl::XLSXFile, rng::SheetColumnRange) - @assert hassheet(xl, rng.sheet) "Sheet $(rng.sheet) not found." + !hassheet(xl, rng.sheet) && throw(XLSXError("Sheet `$(rng.sheet)` not found.")) return getdata(getsheet(xl, rng.sheet), rng.colrng) end +function getdata(xl::XLSXFile, rng::SheetRowRange) + !hassheet(xl, rng.sheet) && throw(XLSXError("Sheet `$(rng.sheet)` not found.")) + return getdata(getsheet(xl, rng.sheet), rng.rowrng) +end + +function getdata(xl::XLSXFile, rng::NonContiguousRange) + !hassheet(xl, rng.sheet) && throw(XLSXError("Sheet `$(rng.sheet)` not found.")) + return getdata(getsheet(xl, rng.sheet), rng) +end + function getdata(xl::XLSXFile, s::AbstractString) - if is_valid_sheet_cellname(s) - return getdata(xl, SheetCellRef(s)) - elseif is_valid_sheet_cellrange(s) - return getdata(xl, SheetCellRange(s)) - elseif is_valid_sheet_column_range(s) - return getdata(xl, SheetColumnRange(s)) - elseif is_workbook_defined_name(xl, s) + if is_workbook_defined_name(xl, s) v = get_defined_name_value(xl.workbook, s) if is_defined_name_value_a_constant(v) return v elseif is_defined_name_value_a_reference(v) return getdata(xl, v) else - error("Unexpected defined name value: $v.") + throw(XLSXError("Unexpected Workbook defined name value: $v.")) end + elseif is_valid_sheet_cellname(s) + return getdata(xl, SheetCellRef(s)) + elseif is_valid_sheet_cellrange(s) + return getdata(xl, SheetCellRange(s)) + elseif is_valid_sheet_column_range(s) + return getdata(xl, SheetColumnRange(s)) + elseif is_valid_sheet_row_range(s) + return getdata(xl, SheetRowRange(s)) + elseif is_valid_non_contiguous_sheetcellrange(s) + return getdata(xl, NonContiguousRange(s)) end - error("$s is not a valid sheetname or cell/range reference.") + throw(XLSXError("`$s` is not a valid sheetname, definedName or cell/range reference.")) end function getcell(xl::XLSXFile, ref::SheetCellRef) - @assert hassheet(xl, ref.sheet) "Sheet $(ref.sheet) not found." + !hassheet(xl, ref.sheet) && throw(XLSXError("Sheet `$(ref.sheet)` not found.")) return getcell(getsheet(xl, ref.sheet), ref.cellref) end -getcell(xl::XLSXFile, ref_str::AbstractString) = getcell(xl, SheetCellRef(ref_str)) +function getcell(xl::XLSXFile, ref_str::AbstractString) + if is_workbook_defined_name(xl, ref_str) + v = get_defined_name_value(xl.workbook, ref_str) + if is_defined_name_value_a_reference(v) + return isa(v, SheetCellRef) ? getcell(xl, v) : getcellrange(xl, v) + else + throw(XLSXError("`$ref_str` is not a valid Workbook definedName reference.")) + end + elseif is_valid_sheet_cellname(ref_str) + return getcell(xl, SheetCellRef(ref_str)) + elseif is_valid_sheet_cellrange(ref_str) + return getcellrange(xl, SheetCellRange(ref_str)) + elseif is_valid_sheet_column_range(ref_str) + return getcellrange(xl, SheetColumnRange(ref_str)) + elseif is_valid_sheet_row_range(ref_str) + return getcellrange(xl, SheetRowRange(ref_str)) + elseif is_valid_non_contiguous_range(ref_str) + return getcellrange(xl, NonContiguousRange(ref_str)) + end + throw(XLSXError("`$ref_str` is not a valid SheetCellRef.")) +end function getcellrange(xl::XLSXFile, rng::SheetCellRange) - @assert hassheet(xl, rng.sheet) "Sheet $(rng.sheet) not found." + !hassheet(xl, rng.sheet) && throw(XLSXError("Sheet `$(rng.sheet)` not found.")) return getcellrange(getsheet(xl, rng.sheet), rng.rng) end function getcellrange(xl::XLSXFile, rng::SheetColumnRange) - @assert hassheet(xl, rng.sheet) "Sheet $(rng.sheet) not found." + !hassheet(xl, rng.sheet) && throw(XLSXError("Sheet `$(rng.sheet)` not found.")) return getcellrange(getsheet(xl, rng.sheet), rng.colrng) end +function getcellrange(xl::XLSXFile, rng::SheetRowRange) + !hassheet(xl, rng.sheet) && throw(XLSXError("Sheet `$(rng.sheet)` not found.")) + return getcellrange(getsheet(xl, rng.sheet), rng.rowrng) +end + +function getcellrange(xl::XLSXFile, rng::NonContiguousRange) + !hassheet(xl, rng.sheet) && throw(XLSXError("Sheet `$(rng.sheet)` not found.")) + return getcellrange(getsheet(xl, rng.sheet), rng) +end + function getcellrange(xl::XLSXFile, rng_str::AbstractString) - if is_valid_sheet_cellrange(rng_str) + wb = get_workbook(xl) + if is_workbook_defined_name(wb, rng_str) + v = get_defined_name_value(wb, rng_str) + if is_defined_name_value_a_reference(v) + return getcellrange(xl, v) + else + throw(XLSXError("`$rng_str` is not a valid Workbook definedName reference.")) + end + elseif is_valid_sheet_cellrange(rng_str) return getcellrange(xl, SheetCellRange(rng_str)) elseif is_valid_sheet_column_range(rng_str) return getcellrange(xl, SheetColumnRange(rng_str)) + elseif is_valid_sheet_row_range(rng_str) + return getcellrange(xl, SheetRowRange(rng_str)) + elseif is_valid_non_contiguous_range(rng_str) + return getcellrange(xl, NonContiguousRange(rng_str)) end - - error("$rng_str is not a valid range reference.") + throw(XLSXError("`$rng_str` is not a valid SheetCellRange.")) end -@inline is_workbook_defined_name(wb::Workbook, name::AbstractString) :: Bool = haskey(wb.workbook_names, name) -@inline is_workbook_defined_name(xl::XLSXFile, name::AbstractString) :: Bool = is_workbook_defined_name(get_workbook(xl), name) -@inline is_worksheet_defined_name(ws::Worksheet, name::AbstractString) :: Bool = is_worksheet_defined_name(get_workbook(ws), ws.sheetId, name) -@inline is_worksheet_defined_name(wb::Workbook, sheetId::Int, name::AbstractString) :: Bool = haskey(wb.worksheet_names, (sheetId, name)) -@inline is_worksheet_defined_name(wb::Workbook, sheet_name::AbstractString, name::AbstractString) :: Bool = is_worksheet_defined_name(wb, getsheet(wb, sheet_name).sheetId, name) +# Defined names are case-insensitive in Excel. Need to check on this basis (haskey is insufficient). +@inline is_workbook_defined_name(wb::Workbook, name::AbstractString)::Bool = !isnothing(findfirst(x -> uppercase(x)==uppercase(name), collect(keys(wb.workbook_names)))) +@inline is_worksheet_defined_name(wb::Workbook, sheetId::Int, name::AbstractString)::Bool = !isnothing(findfirst(x -> uppercase(last(x))==uppercase(name) && first(x)==sheetId, collect(keys(wb.worksheet_names)))) +@inline is_workbook_defined_name(xl::XLSXFile, name::AbstractString)::Bool = is_workbook_defined_name(get_workbook(xl), name) +@inline is_worksheet_defined_name(ws::Worksheet, name::AbstractString)::Bool = is_worksheet_defined_name(get_workbook(ws), ws.sheetId, name) +@inline is_worksheet_defined_name(wb::Workbook, sheet_name::AbstractString, name::AbstractString)::Bool = is_worksheet_defined_name(wb, getsheet(wb, sheet_name).sheetId, name) -@inline get_defined_name_value(wb::Workbook, name::AbstractString) :: DefinedNameValueTypes = wb.workbook_names[name] +@inline get_defined_name_value(wb::Workbook, name::AbstractString)::DefinedNameValueTypes = wb.workbook_names[name].value -function get_defined_name_value(ws::Worksheet, name::AbstractString) :: DefinedNameValueTypes +function get_defined_name_value(ws::Worksheet, name::AbstractString)::DefinedNameValueTypes wb = get_workbook(ws) sheetId = ws.sheetId - return wb.worksheet_names[(sheetId, name)] + dn = wb.worksheet_names[(sheetId, name)] + return dn.value end -@inline is_defined_name_value_a_reference(v::DefinedNameValueTypes) = isa(v, SheetCellRef) || isa(v, SheetCellRange) +@inline is_defined_name_value_a_reference(v::DefinedNameValueTypes) = isa(v, SheetCellRef) || isa(v, SheetCellRange) || isa(v, NonContiguousRange) @inline is_defined_name_value_a_constant(v::DefinedNameValueTypes) = !is_defined_name_value_a_reference(v) + +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) + return false + end + if is_valid_sheet_cellname(name) || is_valid_sheet_cellrange(name) || is_valid_non_contiguous_sheetcellrange(name) + return false + end + if !isletter(name[1]) && name[1] != '_' + return false + end + for c in name + if !isletter(c) && !isdigit(c) && c != '_' && c != '\\' + return false + end + end + return true +end + +function addDefName(xf::XLSXFile, name::AbstractString, value::DefinedNameValueTypes; absolute=true) + if !is_valid_defined_name(name) + throw(XLSXError("Invalid defined name: `$name`.")) + end + 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 + xf.workbook.workbook_names[name] = DefinedNameValue(value, abs) +end +function addDefName(ws::Worksheet, name::AbstractString, value::DefinedNameValueTypes; absolute=true) + wb = get_workbook(ws) + if !is_valid_defined_name(name) + throw(XLSXError("Invalid defined name: `$name`.")) + end + if is_worksheet_defined_name(ws, name) + throw(XLSXError("Worksheet `$(ws.name)` already has a defined name called `$name`.")) + end + + 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 + wb.worksheet_names[(ws.sheetId, name)] = DefinedNameValue(value, abs) +end + +quoteit(x::AbstractString) = occursin(r"[^\w]|\s", x) ? "'$x'" : x +unquoteit(x::AbstractString) = replace(x, "'" => "") + +""" + addDefinedName(xf::XLSXFile, name::AbstractString, value::Union{Int, Float64, String}; absolute=true) + addDefinedName(xf::XLSXFile, name::AbstractString, value::AbstractString; absolute=true) + addDefinedName(sh::Worksheet, name::AbstractString, value::Union{Int, Float64, String}; absolute=true) + addDefinedName(sh::Worksheet, name::AbstractString, value::AbstractString; absolute=true) + +Add a defined name to the Workbook or Worksheet. If an `XLSXFile` is passed, the defined name +is added to the Workbook. If a `Worksheet` is passed, the defined name is added to the Worksheet. + +When adding defined name referring to a cell or range to a workbook, `value` must include the sheet +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 +`definedName` is not a cell reference or range. + +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 +written out again, the status of the `definedNames` is preserved. + +# Examples +```julia +julia> XLSX.addDefinedName(sh, "ID", "C21") + +julia> XLSX.addDefinedName(sh, "NEW", "A1:B2") + +julia> XLSX.addDefinedName(sh, "my_name", "A1,B2,C3") + +julia> XLSX.addDefinedName(xf, "New", "'Mock-up'!A1:B2") + +julia> XLSX.addDefinedName(xf, "Life_the_universe_and_everything", 42) + +julia> XLSX.addDefinedName(xf, "first_name", "Hello World") + +``` +""" +function addDefinedName end +addDefinedName(xf::XLSXFile, name::AbstractString, value::Union{Int,Float64}; absolute=true) = addDefName(xf, name, value; absolute) +addDefinedName(ws::Worksheet, name::AbstractString, value::Union{Int,Float64}; absolute=true) = addDefName(ws, name, value; absolute) +function addDefinedName(xf::XLSXFile, name::AbstractString, value::AbstractString; absolute=true) + if value == "" + throw(XLSXError("Defined name value cannot be an empty string.")) + end + if is_valid_cellname(value) || is_valid_cellrange(value) || is_valid_non_contiguous_cellrange(value) + throw(XLSXError("Workbook defined name reference `$value` incomplete. Must contain sheet name (e.g. `Sheet1!A1:B2`).")) + elseif is_valid_sheet_cellname(value) + return addDefName(xf, name, SheetCellRef(value); absolute) + elseif is_valid_sheet_cellrange(value) + return addDefName(xf, name, SheetCellRange(value); absolute) + elseif is_valid_non_contiguous_sheetcellrange(value) + return addDefName(xf, name, NonContiguousRange(value); absolute) + else + return addDefName(xf, name, value; absolute) + end +end +function addDefinedName(ws::Worksheet, name::AbstractString, value::AbstractString; absolute=true) + if value == "" + throw(XLSXError("Defined name value cannot be an empty string.")) + end + if is_valid_cellname(value) + return addDefName(ws, name, SheetCellRef(ws.name, CellRef(value)); absolute) + elseif is_valid_sheet_cellname(value) + return addDefName(ws, name, SheetCellRef(value); absolute) + elseif is_valid_cellrange(value) + return addDefName(ws, name, SheetCellRange(ws.name, CellRange(value)); absolute) + elseif is_valid_sheet_cellrange(value) + return addDefName(ws, name, SheetCellRange(value); absolute) + elseif is_valid_non_contiguous_cellrange(value) + return addDefName(ws, name, NonContiguousRange(ws, value); absolute) + elseif is_valid_non_contiguous_sheetcellrange(value) + return addDefName(ws, name, NonContiguousRange(value); absolute) + else + return addDefName(ws, name, value; absolute) + end +end diff --git a/src/worksheet.jl b/src/worksheet.jl index 0526f653..124321bd 100644 --- a/src/worksheet.jl +++ b/src/worksheet.jl @@ -1,10 +1,10 @@ function Worksheet(xf::XLSXFile, sheet_element::XML.Node) - @assert XML.tag(sheet_element) == "sheet" + XML.tag(sheet_element) != "sheet" && throw(XLSXError("Something wrong here!")) a = XML.attributes(sheet_element) sheetId = parse(Int, a["sheetId"]) relationship_id = a["r:id"] - name = a["name"] + name = XML.unescape(a["name"]) is_hidden = haskey(a, "state") && a["state"] in ["hidden", "veryHidden"] dim = read_worksheet_dimension(xf, relationship_id, name) @@ -13,9 +13,7 @@ end function Base.axes(ws::Worksheet, d) dim = get_dimension(ws) - if dim === nothing - throw(DimensionMismatch("Worksheet $ws has no dimension")) - elseif d == 1 + if d == 1 return dim.start.row_number:dim.stop.row_number elseif d == 2 return dim.start.column_number:dim.stop.column_number @@ -25,39 +23,78 @@ function Base.axes(ws::Worksheet, d) end # 18.3.1.35 - dimension (Worksheet Dimensions). This is optional, and not required. -function read_worksheet_dimension(xf::XLSXFile, relationship_id, name) :: Union{Nothing, CellRange} - local result::Union{Nothing, CellRange} = nothing +function read_worksheet_dimension(xf::XLSXFile, relationship_id, name)::Union{Nothing,CellRange} wb = get_workbook(xf) target_file = get_relationship_target_by_id("xl", wb, relationship_id) - zip_io, doc = open_internal_file_stream(xf, target_file) - - reader = iterate(doc) - # Now let's look for a row element, if it exists - while reader !== nothing # go next node - (sheet_row, state) = reader - if XML.nodetype(sheet_row) == XML.Element && XML.tag(sheet_row) == "dimension" - @assert XML.depth(sheet_row) == 2 "Malformed Worksheet \"$name\": unexpected node depth for dimension node: $(XML.depth(sheet_row))." - ref_str = XML.attributes(sheet_row)["ref"] - if is_valid_cellname(ref_str) - result = CellRange("$(ref_str):$(ref_str)") - else - result = CellRange(ref_str) - end - break + if xf.is_writable # read from cached file + !haskey(xf.files, target_file) && throw(XLSXError("Worksheet \"$name\" not found in the XLSX file.")) + i, j = get_idces(xf.data[target_file], "worksheet", "dimension") + if isnothing(i) || isnothing(j) + # No dimension element found, return nothing + return nothing + end + ref= xf.data[target_file][i][j]["ref"] + if is_valid_cellname(ref) + return CellRange(ref*":"*ref) + elseif is_valid_cellrange(ref) + return CellRange(ref) + else + throw(XLSXError("Malformed Worksheet \"$name\": unexpected dimension reference: $ref.")) end - reader = iterate(doc, state) - end - return result + else #read from xlsx file + local result::Union{Nothing,CellRange} = nothing + doc = open_internal_file_stream(xf, target_file) + reader = iterate(doc) + # Now let's look for a row element, if it exists + while reader !== nothing # go next node + (sheet_row, state) = reader + if XML.nodetype(sheet_row) == XML.Element && XML.tag(sheet_row) == "dimension" + + XML.depth(sheet_row) != 2 && throw(XLSXError("Malformed Worksheet \"$name\": unexpected node depth for dimension node: $(XML.depth(sheet_row)).")) + + ref_str = XML.attributes(sheet_row)["ref"] + if is_valid_cellname(ref_str) + result = CellRange("$(ref_str):$(ref_str)") + else + result = CellRange(ref_str) + end + + break + end + reader = iterate(doc, state) + end + + return result + end end @inline isdate1904(ws::Worksheet) = isdate1904(get_workbook(ws)) # Returns the dimension of this worksheet as a CellRange. -# Returns `nothing` if the dimension is unknown. -@inline get_dimension(ws::Worksheet) :: Union{Nothing, CellRange} = ws.dimension +# If the dimension is unknown, computes a dimension from cells in cache. +# If the cache is empty, set dimension to A1:A1. +# If cache is not being used, throw an error. +function get_dimension(ws::Worksheet)::Union{Nothing,CellRange} + !isnothing(ws.dimension) && return ws.dimension + # if (isnothing(ws.cache) || length(ws.cache.cells) < 1) + if isnothing(ws.cache) + throw(XLSXError("No worksheet dimension found for $(ws.name).")) + elseif length(ws.cache.cells) < 1 + set_dimension!(ws, CellRange(CellRef(1, 1), CellRef(1, 1))) + else + row_extr = extrema(keys(ws.cache.cells)) + row_min = first(row_extr) + row_max = last(row_extr) + col_extr = [extrema(y) for y in [keys(x) for x in values(ws.cache.cells)]] + col_min = minimum([x for x in first.(col_extr)]) + col_max = maximum([x for x in last.(col_extr)]) + set_dimension!(ws, CellRange(CellRef(row_min, col_min), CellRef(row_max, col_max))) + end + return ws.dimension +end function set_dimension!(ws::Worksheet, rng::CellRange) ws.dimension = rng @@ -68,8 +105,8 @@ end getdata(sheet, ref) getdata(sheet, row, column) -Returns a scalar or a matrix with values from a spreadsheet. -`ref` can be a cell reference or a range. +Returns a scalar, vector or a matrix with values from a spreadsheet. +`ref` can be a cell reference or a range or a valid defined name. Indexing in a `Worksheet` will dispatch to `getdata` method. @@ -78,39 +115,61 @@ Indexing in a `Worksheet` will dispatch to `getdata` method. ```julia julia> f = XLSX.readxlsx("myfile.xlsx") -julia> sheet = f["mysheet"] +julia> sheet = f["mysheet"] # Worksheet + +julia> matrix = sheet["A1:B4"] # CellRange + +julia> matrix = sheet["A:B"] # Column range -julia> matrix = sheet["A1:B4"] +julia> matrix = sheet["1:4"] # Row range -julia> single_value = sheet[2, 2] # B2 +julia> matrix = sheet["Contiguous"] # Named range + +julia> matrix = sheet[1:30, 1] # use unit ranges to define rows and/or columns + +julia> matrix = sheet[[1, 2, 3], 1] # vectors of integers to define rows and/or columns + +julia> vector = sheet["A1:A4,C1:C4,G5"] # Non-contiguous range + +julia> vector = sheet["Location"] # Non-contiguous named range + +julia> single_value = sheet[2, 2] # Cell "B2" ``` See also [`XLSX.readdata`](@ref). """ getdata(ws::Worksheet, single::CellRef) = getdata(ws, getcell(ws, single)) getdata(ws::Worksheet, row::Integer, col::Integer) = getdata(ws, CellRef(row, col)) +getdata(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}) = [getdata(ws, a, b) for a in row, b in col] +getdata(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = [getdata(ws, a, b) for a in row, b in col] +getdata(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}) = [getdata(ws, a, b) for a in row, b in col] getdata(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = getdata(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col)))) +getdata(ws::Worksheet, ::Colon, ::Colon) = getdata(ws) +function getdata(ws::Worksheet, ::Colon) + dim = get_dimension(ws) + getdata(ws, dim) +end function getdata(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon) dim = get_dimension(ws) - return if dim === nothing - @warn "No worksheet dimension found" - [] - else - getdata(ws, CellRange(CellRef(first(row), dim.start.column_number), CellRef(last(row), dim.stop.column_number))) - end + getdata(ws, CellRange(CellRef(first(row), dim.start.column_number), CellRef(last(row), dim.stop.column_number))) end function getdata(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}) dim = get_dimension(ws) - return if dim === nothing - @warn "No worksheet dimension found" - [] - else - getdata(ws, CellRange(CellRef(dim.start.row_number, first(col)), CellRef(dim.stop.row_number, last(col)))) - end + getdata(ws, CellRange(CellRef(dim.start.row_number, first(col)), CellRef(dim.stop.row_number, last(col)))) +end +function getdata(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon) + dim = get_dimension(ws) + col = dim.start.column_number:dim.stop.column_number + return getdata(ws, row, col) +end +function getdata(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}) + dim = get_dimension(ws) + row = dim.start.row_number:dim.stop.row_number + return getdata(ws, row, col) end -function getdata(ws::Worksheet, rng::CellRange) :: Array{Any,2} - result = Array{Any, 2}(undef, size(rng)) +function getdata(ws::Worksheet, rng::CellRange)::Array{Any,2} + result = Array{Any,2}(undef, size(rng)) fill!(result, missing) top = row_number(rng.start) @@ -118,66 +177,84 @@ function getdata(ws::Worksheet, rng::CellRange) :: Array{Any,2} left = column_number(rng.start) right = column_number(rng.stop) - for sheetrow in eachrow(ws) - if top <= sheetrow.row && sheetrow.row <= bottom - for column in left:right - cell = getcell(sheetrow, column) - if !isempty(cell) - (r, c) = relative_cell_position(cell, rng) - result[r, c] = getdata(ws, cell) + # if cache is in use, look-up data direct rather than iterating + if is_cache_enabled(ws) && ws.cache !== nothing && !ws.cache.is_empty + for row in top:bottom + if haskey(ws.cache.cells, row) + for column in left:right + if haskey(ws.cache.cells, row) && haskey(ws.cache.cells[row], column) + cell = ws.cache.cells[row][column] + (r, c) = relative_cell_position(cell, rng) + result[r, c] = getdata(ws, cell) + end end end end - # don't need to read new rows - if sheetrow.row > bottom - break + else # no cache, need to iterate rows + for sheetrow in eachrow(ws) + if top <= sheetrow.row && sheetrow.row <= bottom + for column in left:right + cell = getcell(sheetrow, column) + if !isempty(cell) + (r, c) = relative_cell_position(cell, rng) + result[r, c] = getdata(ws, cell) + end + end + end + + # don't need to read new rows + if sheetrow.row > bottom + break + end end end - return result end -function getdata(ws::Worksheet, rng::ColumnRange) :: Array{Any,2} - columns_count = length(rng) - columns = Vector{Vector{Any}}(undef, columns_count) - for i in 1:columns_count - columns[i] = Vector{Any}() - end - - left, right = column_bounds(rng) +function getdata(ws::Worksheet, rng::ColumnRange)::Array{Any,2} + dim = get_dimension(ws) + start = CellRef(dim.start.row_number, rng.start) + stop = CellRef(dim.stop.row_number, rng.stop) + return getdata(ws, CellRange(start, stop)) +end +function getdata(ws::Worksheet, rng::RowRange)::Array{Any,2} + dim = get_dimension(ws) + start = CellRef(rng.start, dim.start.column_number,) + stop = CellRef(rng.stop, dim.stop.column_number) + return getdata(ws, CellRange(start, stop)) +end - for sheetrow in eachrow(ws) - for column in left:right - cell = getcell(sheetrow, column) - c = relative_column_position(cell, rng) # r will be ignored - push!(columns[c], getdata(ws, cell)) +function getdata(ws::Worksheet, rng::NonContiguousRange)::Vector{Any} + do_sheet_names_match(ws, rng) + results = Vector{Any}() + for r in rng.rng + if r isa CellRef + push!(results, getdata(ws, r)) + else + for cell in r + push!(results, getdata(ws, cell)) + end end end - - rows = length(columns[1]) - for i in 1:columns_count - @assert length(columns[i]) == rows "Inconsistent state: Each column should have the same number of rows." - end - - return hcat(columns...) + return results end -function getdata(ws::Worksheet, ref::AbstractString) :: Union{Array{Any,2}, Any} - if is_valid_cellname(ref) - return getdata(ws, CellRef(ref)) - elseif is_valid_cellrange(ref) - return getdata(ws, CellRange(ref)) - elseif is_valid_column_range(ref) - return getdata(ws, ColumnRange(ref)) - elseif is_worksheet_defined_name(ws, ref) +# Needed for definedName references +getdata(ws::Worksheet, s::SheetCellRef) = do_sheet_names_match(ws, s) && getdata(ws, s.cellref) +getdata(ws::Worksheet, s::SheetCellRange) = do_sheet_names_match(ws, s) && getdata(ws, s.rng) +getdata(ws::Worksheet, s::SheetColumnRange) = do_sheet_names_match(ws, s) && getdata(ws, s.colrng) +getdata(ws::Worksheet, s::SheetRowRange) = do_sheet_names_match(ws, s) && getdata(ws, s.rowrng) + +function getdata(ws::Worksheet, ref::AbstractString) + if is_worksheet_defined_name(ws, ref) v = get_defined_name_value(ws, ref) if is_defined_name_value_a_constant(v) return v elseif is_defined_name_value_a_reference(v) return getdata(ws, v) else - error("Unexpected defined name value: $v.") + throw(XLSXError("Unexpected defined name value: $v.")) end elseif is_workbook_defined_name(get_workbook(ws), ref) wb = get_workbook(ws) @@ -187,22 +264,35 @@ function getdata(ws::Worksheet, ref::AbstractString) :: Union{Array{Any,2}, Any} elseif is_defined_name_value_a_reference(v) return getdata(get_xlsxfile(ws), v) else - error("Unexpected defined name value: $v.") + throw(XLSXError("Unexpected defined name value: $v.")) end + elseif is_valid_cellname(ref) + return getdata(ws, CellRef(ref)) + elseif is_valid_cellrange(ref) + return getdata(ws, CellRange(ref)) + elseif is_valid_column_range(ref) + return getdata(ws, ColumnRange(ref)) + elseif is_valid_row_range(ref) + return getdata(ws, RowRange(ref)) + elseif is_valid_sheet_cellname(ref) + return getdata(ws, SheetCellRef(ref)) + elseif is_valid_sheet_cellrange(ref) + return getdata(ws, SheetCellRange(ref)) + elseif is_valid_sheet_column_range(ref) + return getdata(ws, SheetColumnRange(ref)) + elseif is_valid_sheet_row_range(ref) + return getdata(ws, SheetRowRange(ref)) + elseif is_valid_non_contiguous_cellrange(ref) + return getdata(ws, NonContiguousRange(ws, ref)) + elseif is_valid_non_contiguous_sheetcellrange(ref) + nc = NonContiguousRange(ref) + return do_sheet_names_match(ws, nc) && getdata(ws, nc) else - error("$ref is not a valid cell or range reference.") + throw(XLSXError("`$ref` is not a valid cell or range reference.")) end end -getdata(ws::Worksheet, rng::SheetCellRange) = getdata(get_xlsxfile(ws), rng) - -function getdata(ws::Worksheet) - if ws.dimension !== nothing - return getdata(ws, get_dimension(ws)) - else - error("Worksheet dimension is unknown.") - end -end +getdata(ws::Worksheet) = getdata(ws, get_dimension(ws)) Base.getindex(ws::Worksheet, r) = getdata(ws, r) Base.getindex(ws::Worksheet, r, c) = getdata(ws, r, c) @@ -210,8 +300,8 @@ Base.getindex(ws::Worksheet, ::Colon) = getdata(ws) function Base.show(io::IO, ws::Worksheet) hidden_string = ws.is_hidden ? "(hidden)" : "" - if get_dimension(ws) !== nothing - rg = get_dimension(ws) + rg = get_dimension(ws) + if rg !== nothing nrow, ncol = size(rg) @printf(io, "%d×%d %s: [\"%s\"](%s) %s", nrow, ncol, typeof(ws), ws.name, rg, hidden_string) else @@ -221,8 +311,13 @@ end """ getcell(sheet, ref) + getcell(sheet, row, col) + +Return an `AbstractCell` that represents a cell in the spreadsheet. +Return a matrix with cells as `Array{AbstractCell, 2}` if called +with a reference tomore than one cell. -Returns an `AbstractCell` that represents a cell in the spreadsheet. +If `ref` is a range, `getcell` dispatches to `getcellrange`. Example: @@ -232,20 +327,24 @@ julia> xf = XLSX.readxlsx("myfile.xlsx") julia> sheet = xf["mysheet"] julia> cell = XLSX.getcell(sheet, "A1") + +julia> cell = XLSX.getcell(sheet, 1:3, [2,4,6]) + ``` + +Other examples are as [`getdata()`](@ref). + """ -function getcell(ws::Worksheet, single::CellRef) :: AbstractCell +function getcell(ws::Worksheet, single::CellRef)::AbstractCell # Access cache directly if it exists and if file `isread` - much faster! - if is_cache_enabled(ws) && ws.cache !== nothing - if haskey(get_xlsxfile(ws).files, "xl/worksheets/sheet$(ws.sheetId).xml") && get_xlsxfile(ws).files["xl/worksheets/sheet$(ws.sheetId).xml"] == true - if haskey(ws.cache.cells, single.row_number) - if haskey(ws.cache.cells[single.row_number], single.column_number) - return ws.cache.cells[single.row_number][single.column_number] - end + if is_cache_enabled(ws) && ws.cache !== nothing && !ws.cache.is_empty + if haskey(ws.cache.cells, single.row_number) + if haskey(ws.cache.cells[single.row_number], single.column_number) + return ws.cache.cells[single.row_number][single.column_number] end - return EmptyCell(single) end + return EmptyCell(single) end # If can't use cache then iterate sheetrows @@ -258,87 +357,162 @@ function getcell(ws::Worksheet, single::CellRef) :: AbstractCell return EmptyCell(single) end +getcell(ws::Worksheet, s::SheetCellRef) = do_sheet_names_match(ws, s) && getcell(ws, s.cellref) +getcell(ws::Worksheet, s::SheetCellRange) = do_sheet_names_match(ws, s) && getcellrange(ws, s.rng) +getcell(ws::Worksheet, s::SheetColumnRange) = do_sheet_names_match(ws, s) && getcellrange(ws, s.colrng) +getcell(ws::Worksheet, s::SheetRowRange) = do_sheet_names_match(ws, s) && getcellrange(ws, s.rowrng) +getcell(ws::Worksheet, s::CellRange) = getcellrange(ws, s) +getcell(ws::Worksheet, s::ColumnRange) = getcellrange(ws, s.colrng) +getcell(ws::Worksheet, s::RowRange) = getcellrange(ws, s.rowrng) + +getcell(ws::Worksheet, row::Integer, col::Integer) = getcell(ws, CellRef(row, col)) +getcell(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}) = getcellrange(ws, row, col) +getcell(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = getcellrange(ws, row, col) +getcell(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}) = getcellrange(ws, row, col) +getcell(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = getcellrange(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col)))) +function getcell(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon) + dim = get_dimension(ws) + getcellrange(ws, CellRange(CellRef(first(row), dim.start.column_number), CellRef(last(row), dim.stop.column_number))) +end +function getcell(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}) + dim = get_dimension(ws) + getcellrange(ws, CellRange(CellRef(dim.start.row_number, first(col)), CellRef(dim.stop.row_number, last(col)))) +end + function getcell(ws::Worksheet, ref::AbstractString) - if is_valid_cellname(ref) + if is_worksheet_defined_name(ws, ref) + v = get_defined_name_value(ws, ref) + if is_defined_name_value_a_reference(v) + return getcell(ws, v) + else + throw(XLSXError("`$ref` is not a valid cell or range reference.")) + end + elseif is_workbook_defined_name(get_workbook(ws), ref) + wb = get_workbook(ws) + v = get_defined_name_value(wb, ref) + if is_defined_name_value_a_reference(v) + return isa(v, SheetCellRef) ? getcell(get_xlsxfile(ws), v) : getcellrange(get_xlsxfile(ws), v) + else + throw(XLSXError("`$ref` is not a valid cell or range reference.")) + end + elseif is_valid_cellname(ref) return getcell(ws, CellRef(ref)) - else - error("$ref is not a valid cell reference.") + elseif is_valid_sheet_cellname(ref) + return getcell(ws, SheetCellRef(ref)) + elseif is_valid_cellrange(ref) + return getcellrange(ws, CellRange(ref)) + elseif is_valid_column_range(ref) + return getcellrange(ws, ColumnRange(ref)) + elseif is_valid_row_range(ref) + return getcellrange(ws, RowRange(ref)) + elseif is_valid_non_contiguous_range(ref) + return getcellrange(ws, NonContiguousRange(ws, ref)) + elseif is_valid_sheet_cellrange(ref) + return getcellrange(ws, SheetCellRange(ref)) + elseif is_valid_sheet_column_range(ref) + return getcellrange(ws, SheetColumnRange(ref)) + elseif is_valid_sheet_row_range(ref) + return getcellrange(ws, SheetRowRange(ref)) + elseif is_valid_non_contiguous_range(ref) + return getcellrange(ws, NonContiguousRange(ref)) end + throw(XLSXError("`$ref` is not a valid cell or range reference.")) end -getcell(ws::Worksheet, row::Integer, col::Integer) = getcell(ws, CellRef(row, col)) - """ getcellrange(sheet, rng) -Returns a matrix with cells as `Array{AbstractCell, 2}`. -`rng` must be a valid cell range, as in `"A1:B2"`. +Return a matrix with cells as `Array{AbstractCell, 2}`. +`rng` must be a valid cell range, column range or row range, +as in `"A1:B2"`, `"A:B"` or `"1:2"`, or a non-contiguous range. +For row and column ranges, the extent of the range in the other +dimension is determined by the worksheet's dimension. +A non-contiguous range (which is not rectangular) will return a vector. + +For example usage, see [`getdata()`](@ref). + """ -function getcellrange(ws::Worksheet, rng::CellRange) :: Array{AbstractCell,2} - result = Array{AbstractCell, 2}(undef, size(rng)) +function getcellrange(ws::Worksheet, rng::CellRange)::Array{AbstractCell,2} + result = Array{AbstractCell,2}(undef, size(rng)) for cellref in rng (r, c) = relative_cell_position(cellref, rng) - result[r, c] = EmptyCell(cellref) + cell = getcell(ws, cellref) + result[r, c] = isempty(cell) ? EmptyCell(cellref) : cell end - - top = row_number(rng.start) - bottom = row_number(rng.stop) - left = column_number(rng.start) - right = column_number(rng.stop) - - for sheetrow in eachrow(ws) - if top <= sheetrow.row && sheetrow.row <= bottom - for column in left:right - cell = getcell(sheetrow, column) - if !isempty(cell) - (r, c) = relative_cell_position(cell, rng) - result[r, c] = cell - end - end - end - - # don't need to read new rows - if sheetrow.row > bottom - break - end - end - return result end -function getcellrange(ws::Worksheet, rng::ColumnRange) :: Array{AbstractCell,2} - columns_count = length(rng) - columns = Vector{Vector{AbstractCell}}(undef, columns_count) - for i in 1:columns_count - columns[i] = Vector{AbstractCell}() - end +getcellrange(ws::Worksheet, s::SheetCellRange) = do_sheet_names_match(ws, s) && getcellrange(ws, s.rng) +getcellrange(ws::Worksheet, s::SheetColumnRange) = do_sheet_names_match(ws, s) && getcellrange(ws, s.colrng) +getcellrange(ws::Worksheet, s::SheetRowRange) = do_sheet_names_match(ws, s) && getcellrange(ws, s.rowrng) - let - left, right = column_bounds(rng) +getcellrange(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}) = [getcell(ws, a, b) for a in row, b in col] +getcellrange(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = [getcell(ws, a, b) for a in row, b in col] +getcellrange(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, col::Union{Vector{Int},StepRange{<:Integer}}) = [getcell(ws, a, b) for a in row, b in col] +getcellrange(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}) = getcell(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col)))) +getcellrange(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon) = getcell(ws, row, :) +getcellrange(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}) = getcell(ws, :, col) - for sheetrow in eachrow(ws) - for column in left:right - cell = getcell(sheetrow, column) - c = relative_column_position(cell, rng) # r will be ignored - push!(columns[c], cell) +function getcellrange(ws::Worksheet, rng::ColumnRange)::Array{AbstractCell,2} + dim = get_dimension(ws) + start = CellRef(dim.start.row_number, rng.start) + stop = CellRef(dim.stop.row_number, rng.stop) + return getcellrange(ws, CellRange(start, stop)) +end +function getcellrange(ws::Worksheet, rng::RowRange)::Array{AbstractCell,2} + dim = get_dimension(ws) + start = CellRef(rng.start, dim.start.column_number,) + stop = CellRef(rng.stop, dim.stop.column_number) + return getcellrange(ws, CellRange(start, stop)) +end + +function getcellrange(ws::Worksheet, rng::NonContiguousRange)::Vector{AbstractCell} + results = Vector{AbstractCell}() + for r in rng.rng + if r isa CellRef + push!(results, getcell(ws, r)) + else + for cell in r + push!(results, getcell(ws, cell)) end end end - - rows = length(columns[1]) - for i in 1:columns_count - @assert length(columns[i]) == rows "Inconsistent state: Each column should have the same number of rows." - end - - return hcat(columns...) + return results end function getcellrange(ws::Worksheet, rng::AbstractString) - if is_valid_cellrange(rng) + if is_worksheet_defined_name(ws, rng) + v = get_defined_name_value(ws, rng) + if is_defined_name_value_a_reference(v) + return getcellrange(ws, v) + else + throw(XLSXError("$rng is not a valid cell range.")) + end + elseif is_workbook_defined_name(get_workbook(ws), rng) + wb = get_workbook(ws) + v = get_defined_name_value(wb, rng) + if is_defined_name_value_a_reference(v) + isa(v, SheetCellRef) && throw(XLSXError("`$rng` is not a valid cell range.")) + return getcellrange(get_xlsxfile(ws), v) + else + throw(XLSXError("`$rng` is not a valid cell range.")) + end + elseif is_valid_cellrange(rng) return getcellrange(ws, CellRange(rng)) elseif is_valid_column_range(rng) return getcellrange(ws, ColumnRange(rng)) - else - error("$rng is not a valid cell range.") + elseif is_valid_row_range(rng) + return getcellrange(ws, RowRange(rng)) + elseif is_valid_non_contiguous_range(rng) + return getcellrange(ws, NonContiguousRange(ws, rng)) + elseif is_valid_sheet_cellrange(rng) + return getcellrange(s, SheetCellRange(rng)) + elseif is_valid_sheet_column_range(rng) + return getcellrange(s, SheetColumnRange(rng)) + elseif is_valid_sheet_row_range(rng) + return getcellrange(s, SheetRowRange(rng)) + elseif is_valid_non_contiguous_range(rng) + return getcellrange(s, NonContiguousRange(rng)) end + throw(XLSXError("`$rng` is not a valid cell range.")) end diff --git a/src/write.jl b/src/write.jl index 94613f39..964f747f 100644 --- a/src/write.jl +++ b/src/write.jl @@ -1,70 +1,46 @@ """ - opentemplate(source::Union{AbstractString, IO}) :: XLSXFile + savexlsx(f::XLSXFile) -Read an existing Excel file as a template and return as a writable `XLSXFile` for editing -and saving to another file with `XLSX.writexlsx`. +Save an `XLSXFile` instance back to the file from which it was opened, overwriting original content. -# Examples -```julia -julia> xf = opentemplate("myExcelFile") -``` +A new `XLSXFile` created with `XLSX.newxlsx` (or using `openxlsx` without specifying a filename) will +have `source` set to `"blank.xlsx"` and cannot be saved with this function. Use `writexlsx` instead to +specify a file name for the saved file. -""" -opentemplate(source::Union{AbstractString, IO}) :: XLSXFile = open_or_read_xlsx(source, true, true, true) - -@inline open_xlsx_template(source::Union{AbstractString, IO}) :: XLSXFile = open_or_read_xlsx(source, true, true, true) - -function _relocatable_data_path(; path::AbstractString=Artifacts.artifact"XLSX_relocatable_data") - return path -end - -""" - newxlsx() :: XLSXFile - -Return an empty, writable `XLSXFile` with 1 worksheet (`Sheet1`) for editing and -saving to a file with `XLSX.writexlsx`. - -# Examples -```julia -julia> xf = newxlsx() -``` +Returns the filepath of the written file if a filename is supplied, or `nothing` if writing to an `IO`. """ -newxlsx() = open_empty_template() - -function open_empty_template( - sheetname::AbstractString=""; - path::AbstractString=_relocatable_data_path() - ) :: XLSXFile - - empty_excel_template = joinpath(path, "blank.xlsx") - @assert isfile(empty_excel_template) "Couldn't find template file $empty_excel_template." - xf = open_xlsx_template(empty_excel_template) - - if sheetname != "" - rename!(xf[1], sheetname) - end - - return xf +function savexlsx(f::XLSXFile) + f.source == "blank.xlsx" && throw(XLSXError("Can't save a blank `XLSXFile` instance. Use `writexlsx` instead to specify a file name.")) + return writexlsx(f.source, f; overwrite=true) end + """ writexlsx(output_source, xlsx_file; [overwrite=false]) Write an Excel file given by `xlsx_file::XLSXFile` to IO or filepath `output_source`. +The source attribute of the `XLSXFile` will be updated to the `output_source` if it is a filepath. + +Returns the filepath of the written file if a filename is supplied, or `nothing` if writing to an `IO`. + If `overwrite=true`, `output_source` (when a filepath) will be overwritten if it exists. """ -function writexlsx(output_source::Union{AbstractString, IO}, xf::XLSXFile; overwrite::Bool=false) +function writexlsx(output_source::Union{AbstractString,IO}, xf::XLSXFile; overwrite::Bool=false) - @assert is_writable(xf) "XLSXFile instance is not writable." - @assert all(values(xf.files)) "Some internal files were not loaded into memory. Did you use `XLSX.open_xlsx_template` to open this file?" + !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable.")) + if !all(values(xf.files)) + throw(XLSXError("Some internal files were not loaded into memory. Did you use `XLSX.open_xlsx_template` to open this file?")) + end if output_source isa AbstractString && !overwrite - @assert !isfile(output_source) "Output file $output_source already exists." + isfile(output_source) && throw(XLSXError("Output file $output_source already exists.")) end - update_worksheets_xml!(xf) + update_worksheets_xml!(xf) + update_workbook_xml!(xf) + # update_relationships(xf) ZipArchives.ZipWriter(output_source) do xlsx # write XML files @@ -89,34 +65,43 @@ function writexlsx(output_source::Union{AbstractString, IO}, xf::XLSXFile; overw print(xlsx, generate_sst_xml_string(get_sst(xf))) end end - nothing + + if !(output_source isa IO) + (xf.source = output_source) # update source if output_source is a file path + return isabspath(xf.source) ? xf.source : abspath(xf.source) + else + return nothing + end + end get_worksheet_internal_file(ws::Worksheet) = get_relationship_target_by_id("xl", get_workbook(ws), ws.relationship_id) -get_worksheet_xml_document(ws::Worksheet) = get_xlsxfile(ws).data[ get_worksheet_internal_file(ws) ] +get_worksheet_xml_document(ws::Worksheet) = get_xlsxfile(ws).data[get_worksheet_internal_file(ws)] function set_worksheet_xml_document!(ws::Worksheet, xdoc::XML.Node) - @assert XML.nodetype(xdoc) == XML.Document "Expected an XML Document node, got $(XML.nodetype(xdoc))." + XML.nodetype(xdoc) != XML.Document && throw(XLSXError("Expected an XML Document node, got $(XML.nodetype(xdoc)).")) xf = get_xlsxfile(ws) filename = get_worksheet_internal_file(ws) - @assert haskey(xf.data, filename) "Internal file not found for $(ws.name)." + !haskey(xf.data, filename) && throw(XLSXError("Internal file not found for $(ws.name).")) xf.data[filename] = xdoc - end -function generate_sst_xml_string(sst::SharedStringTable) :: String - @assert sst.is_loaded "Can't generate XML string from a Shared String Table that is not loaded." +function generate_sst_xml_string(sst::SharedStringTable)::String + !sst.is_loaded && throw(XLSXError("Can't generate XML string from a Shared String Table that is not loaded.")) buff = IOBuffer() # TODO: -""") +""" + ) for s in sst.formatted_strings print(buff, s) end - + print(buff, "") return String(take!(buff)) end @@ -127,17 +112,17 @@ function add_node_formula!(node, f::Formula) end function add_node_formula!(node, f::FormulaReference) - f_node = XML.Element("f"; t = "shared", si = string(f.id)) + f_node = XML.Element("f"; t="shared", si=string(f.id)) push!(node, f_node) end function add_node_formula!(node, f::ReferencedFormula) - f_node = XML.Element("f", Text(f.formula); t = "shared", si = string(f.id), ref = f.ref) + f_node = XML.Element("f", Text(f.formula); t="shared", si=string(f.id), ref=f.ref) push!(node, f_node) end function find_all_nodes(givenpath::String, doc::XML.Node)::Vector{XML.Node} - @assert XML.nodetype(doc) == XML.Document + XML.nodetype(doc) != XML.Document && throw(XLSXError("Something wrong here!")) found_nodes = Vector{XML.Node}() for xp in get_node_paths(doc) if xp.path == givenpath @@ -147,7 +132,7 @@ function find_all_nodes(givenpath::String, doc::XML.Node)::Vector{XML.Node} return found_nodes end function get_node_paths(node::XML.Node) - @assert XML.nodetype(node) == XML.Document + XML.nodetype(node) != XML.Document && throw(XLSXError("Something wrong here!")) default_ns = get_default_namespace(node[end]) xpaths = Vector{xpath}() get_node_paths!(xpaths, node, default_ns, "") @@ -158,45 +143,47 @@ function get_node_paths!(xpaths::Vector{xpath}, node::XML.Node, default_ns, path for c in XML.children(node) if XML.nodetype(c) ∉ [XML.Declaration, XML.Comment, XML.Text] node_tag = XML.tag(c) - if !occursin(":", node_tag) + if !occursin(":", node_tag) node_tag = default_ns * ":" * node_tag end npath = path * "/" * node_tag push!(xpaths, xpath(c, npath)) - if length(XML.children(c))>0 + if length(XML.children(c)) > 0 get_node_paths!(xpaths, c, default_ns, npath) end end - end + end return nothing end -function unlink_rows(node::XML.Node) # removes all rows from a sheetData XML node. - new_worksheet = XML.Element("sheetData") + +# Remove all children with tag given by att[2] from a parent XML node with a tag given by att[1]. +function unlink(node::XML.Node, att::Tuple{String,String}) + new_node = XML.Element(first(att)) a = XML.attributes(node) if !isnothing(a) # Copy attributes across to new node for (k, v) in XML.attributes(node) - new_worksheet[k] = v + new_node[k] = v end end - for child in XML.children(node) # Copy any child nodes that are not rows across to new node - if XML.tag(child) != "row" - push!(new_worksheet, child) + for child in XML.children(node) # Copy any child nodes with tags that are not att[2] across to new node + if XML.tag(child) != last(att) + push!(new_node, child) end end - return new_worksheet + return new_node end function get_idces(doc, t, b) - i=1 - j=1 + i = 1 + j = 1 while XML.tag(doc[i]) != t - i+=1 + i += 1 if i > length(XML.children(doc)) return nothing, nothing end end while XML.tag(doc[i][j]) != b - j+=1 + j += 1 if j > length(XML.children(doc[i])) return i, nothing end @@ -213,13 +200,13 @@ function update_worksheets_xml!(xl::XLSXFile) xroot = doc[end] # check namespace and root node name - @assert get_default_namespace(xroot) == SPREADSHEET_NAMESPACE_XPATH_ARG "Unsupported Spreadsheet XML namespace $(get_default_namespace(xroot))." - @assert XML.tag(xroot) == "worksheet" "Malformed Excel file. Expected root node named `worksheet` in worksheet XML file." + get_default_namespace(xroot) != SPREADSHEET_NAMESPACE_XPATH_ARG && throw(XLSXError("Unsupported Spreadsheet XML namespace $(get_default_namespace(xroot)).")) + XML.tag(xroot) != "worksheet" && throw(XLSXError("Malformed Excel file. Expected root node named `worksheet` in worksheet XML file.")) # Since we do not at the moment track changes, we need to delete all data and re-write it, but this could entail losses. # |- Column formatting is preserved in the subtree. # |- Row formatting would get lost if we simply remove the children of storing the rows - unhandled_attributes = Dict{Int, Dict{String,String}}() # from row number to attribute and its value + unhandled_attributes = Dict{Int,Dict{String,String}}() # from row number to attribute and its value # The following attributes will be overwritten by us and need not be preserved handled_attributes = Set{String}([ @@ -229,7 +216,7 @@ function update_worksheets_xml!(xl::XLSXFile) ]) let - child_nodes = find_all_nodes("/$SPREADSHEET_NAMESPACE_XPATH_ARG:worksheet/$SPREADSHEET_NAMESPACE_XPATH_ARG:sheetData/$SPREADSHEET_NAMESPACE_XPATH_ARG:row", doc) + child_nodes = find_all_nodes("/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":worksheet/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":sheetData/" * SPREADSHEET_NAMESPACE_XPATH_ARG * ":row", doc) i, j = get_idces(doc, "worksheet", "sheetData") parent = doc[i][j] @@ -241,17 +228,17 @@ function update_worksheets_xml!(xl::XLSXFile) attributes = XML.attributes(c) if !isnothing(attributes) unhandled_attributes_ = filter(attribute -> !in(first(attribute), handled_attributes), attributes) - if length(unhandled_attributes_)>0 + if length(unhandled_attributes_) > 0 row_nr = parse(Int, c["r"]) unhandled_attributes[row_nr] = unhandled_attributes_ end end else @warn("Unexpected node under sheetData: $(XML.tag(c))") - end + end end - doc[i][j] = unlink_rows(parent) + doc[i][j] = unlink(parent, ("sheetData", "row")) end # updates sheetData @@ -270,16 +257,15 @@ function update_worksheets_xml!(xl::XLSXFile) local spans_str::String = "" # Every row has the `spans=1:` property. Set it to the whole range of columns by default - if !isnothing(get_dimension(sheet)) - spans_str = string(column_number(get_dimension(sheet).start), ":", column_number(get_dimension(sheet).stop)) - end + d = get_dimension(sheet) + spans_str = string(column_number(d.start), ":", column_number(d.stop)) # iterates over WorksheetCache cells and write the XML for r in eachrow(sheet) row_nr = row_number(r) ordered_column_indexes = sort(collect(keys(r.rowcells))) - row_node = XML.Element("row"; r = string(row_nr)) + row_node = XML.Element("row"; r=string(row_nr)) if spans_str != "" row_node["spans"] = spans_str end @@ -297,7 +283,7 @@ function update_worksheets_xml!(xl::XLSXFile) # add cells to row for c in ordered_column_indexes cell = getcell(r, c) - c_element = XML.Element("c"; r = cell.ref.name) + c_element = XML.Element("c"; r=cell.ref.name) if cell.datatype != "" c_element["t"] = cell.datatype @@ -317,16 +303,16 @@ function update_worksheets_xml!(xl::XLSXFile) end push!(row_node, c_element) end - push!(sheetData_node, row_node) end - doc[i][j]=sheetData_node + doc[i][j] = sheetData_node # updates worksheet dimension - if get_dimension(sheet) !== nothing - i, j = get_idces(doc, "worksheet", "dimension") + + i, j = get_idces(doc, "worksheet", "dimension") + if !isnothing(j) dimension_node = doc[i][j] - dimension_node["ref"] = string(get_dimension(sheet)) + dimension_node["ref"] = string(d) doc[i][j] = dimension_node end @@ -336,15 +322,92 @@ function update_worksheets_xml!(xl::XLSXFile) nothing end +function abscell(c::CellRef) + col, row = split_cellname(c.name) + return "\$" * col * "\$" * string(row) +end + +mkabs(c::SheetCellRef) = abscell(c.cellref) +mkabs(c::SheetCellRange) = abscell(c.rng.start) * ":" * abscell(c.rng.stop) +function make_absolute(dn::DefinedNameValue) + if dn.value isa NonContiguousRange + v = "" + for (i, r) in enumerate(dn.value.rng) + cr = r isa CellRange ? SheetCellRange(dn.value.sheet, r) : SheetCellRef(dn.value.sheet, r) # need to separate and handle separately + if dn.isabs[i] + v *= quoteit(cr.sheet) * "!" * mkabs(cr) * "," + else + v *= string(cr) * "," + end + end + return v[1:end-1] + else + return dn.isabs ? quoteit(dn.value.sheet) * "!" * mkabs(dn.value) : string(dn.value) + end +end + +function update_workbook_xml!(xl::XLSXFile) # Need to update and . + wb = get_workbook(xl) + + #update defined names + if length(wb.workbook_names) > 0 || length(wb.worksheet_names) > 0 # skip if no defined names present + wbdoc = xmlroot(xl, "xl/workbook.xml") # find the block in the workbook's xml file + i, j = get_idces(wbdoc, "workbook", "definedNames") + if isnothing(j) + # there is no block in the workbook's xml file, so we'll need to create one + # The block goes after the block. Need to move everything down one to make room. + m, n = get_idces(wbdoc, "workbook", "sheets") + nchildren = length(XML.children(wbdoc[m])) + push!(wbdoc[m], wbdoc[m][end]) + for c in nchildren-1:-1:n+1 + wbdoc[m][c+1] = wbdoc[m][c] + end + definedNames = XML.Element("definedNames") + j = n + 1 + else + definedNames = unlink(wbdoc[i][j], ("definedNames", "definedName")) # Remove old defined names + end + for (k, v) in wb.workbook_names + if typeof(v.value) <: DefinedNameRangeTypes + v = make_absolute(v) + else + v = string(v.value) + end + dn_node = XML.Element("definedName", name=k, XML.Text(v)) + push!(definedNames, dn_node) + end + for (k, v) in wb.worksheet_names + if typeof(v.value) <: DefinedNameRangeTypes + v = make_absolute(v) + else + v = string(v.value) + end + dn_node = XML.Element("definedName", name=last(k), localSheetId=first(k) - 1, XML.Text(v)) + push!(definedNames, dn_node) + end + wbdoc[i][j] = definedNames # Add the new definedNames block to the workbook's xml file + end + + #update sheets + doc = xmlroot(xl, "xl/workbook.xml") + i, j = get_idces(doc, "workbook", "sheets") + unlink(doc[i][j], ("sheets", "sheet")) + sheets_element = XML.Element("sheets") + for s in wb.sheets + sheet_element = XML.Element("sheet"; name=XML.escape(s.name)) + sheet_element["sheetId"] = s.sheetId + sheet_element["r:id"] = s.relationship_id + push!(sheets_element, sheet_element) + end + doc[i][j] = sheets_element + + return nothing +end + function add_cell_to_worksheet_dimension!(ws::Worksheet, cell::Cell) # update worksheet dimension ws_dimension = get_dimension(ws) - if ws_dimension === nothing - set_dimension!(ws, CellRange(cell.ref, cell.ref)) - return - end - top = row_number(ws_dimension.start) left = column_number(ws_dimension.start) @@ -368,8 +431,8 @@ function add_cell_to_worksheet_dimension!(ws::Worksheet, cell::Cell) end function setdata!(ws::Worksheet, cell::Cell) - @assert is_writable(get_xlsxfile(ws)) "XLSXFile instance is not writable." - @assert ws.cache !== nothing "Can't write data to a Worksheet with empty cache." + !is_writable(get_xlsxfile(ws)) && throw(XLSXError("XLSXFile instance is not writable.")) + ws.cache === nothing && throw(XLSXError("Can't write data to a Worksheet with empty cache.")) cache = ws.cache r = row_number(cell) @@ -377,7 +440,7 @@ function setdata!(ws::Worksheet, cell::Cell) if !haskey(cache.cells, r) push!(cache.rows_in_cache, r) - cache.cells[r] = Dict{Int, Cell}() + cache.cells[r] = Dict{Int,Cell}() cache.row_ht[r] = nothing cache.dirty = true end @@ -412,33 +475,47 @@ function strip_illegal_chars(x::String) return result end -const ESCAPE_CHARS = ('&' => "&", '<' => "<", '>' => ">", "'" => "'", '"' => """) -function xlsx_escape(x::String)# Adaped from XML.escape() - result = replace(x, r"&(?!amp;|quot;|apos;|gt;|lt;)" => "&") # This is a change from the XML.escape function, which uses r"&(?=\s)" - for (pat, r) in ESCAPE_CHARS[2:end] - result = replace(result, pat => r) - end - return result -end - # Returns the datatype and value for `val` to be inserted into `ws`. function xlsx_encode(ws::Worksheet, val::AbstractString) if isempty(val) return ("", "") end - sst_ind = add_shared_string!(get_workbook(ws), strip_illegal_chars(xlsx_escape(val))) + sst_ind = add_shared_string!(get_workbook(ws), strip_illegal_chars(val)) return ("s", string(sst_ind)) end xlsx_encode(::Worksheet, val::Missing) = ("", "") xlsx_encode(::Worksheet, val::Bool) = ("b", val ? "1" : "0") -xlsx_encode(::Worksheet, val::Union{Int, Float64}) = ("", string(val)) +xlsx_encode(::Worksheet, val::Union{Int,Float64}) = ("", string(val)) xlsx_encode(ws::Worksheet, val::Dates.Date) = ("", string(date_to_excel_value(val, isdate1904(get_xlsxfile(ws))))) xlsx_encode(ws::Worksheet, val::Dates.DateTime) = ("", string(datetime_to_excel_value(val, isdate1904(get_xlsxfile(ws))))) xlsx_encode(::Worksheet, val::Dates.Time) = ("", string(time_to_excel_value(val))) +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) +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}}) + for a in row, b in col + setdata!(ws, CellRef(a, b), v) + end +end + function setdata!(ws::Worksheet, ref::CellRef, val::CellValue) t, v = xlsx_encode(ws, val.value) cell = Cell(ref, t, id(val.styleid), v, Formula("")) @@ -455,11 +532,7 @@ setdata!(ws::Worksheet, ref::CellRef, ::Nothing) = setdata!(ws, ref, CellValue(w setdata!(ws::Worksheet, row::Integer, col::Integer, val::CellValue) = setdata!(ws, CellRef(row, col), val) -Base.setindex!(ws::Worksheet, v, ref) = setdata!(ws, ref, v) -Base.setindex!(ws::Worksheet, v, r, c) = setdata!(ws, r, c, v) - -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) +setdata!(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, col::Union{Integer,UnitRange{<:Integer}}, v) = setdata!(ws, CellRange(CellRef(first(row), first(col)), CellRef(last(row), last(col))), v) function setdata!(ws::Worksheet, ref::CellRef, val::CellValueType) # use existing cell format if it exists @@ -471,49 +544,152 @@ function setdata!(ws::Worksheet, ref::CellRef, val::CellValueType) # use existin isa_dt = styles_is_datetime(ws.package.workbook, existing_style) if val isa Dates.Date if isa_dt == false - c.style = string(update_template_xf(ws, existing_style, "numFmtId", DEFAULT_DATE_numFmtId).id) + c.style = string(update_template_xf(ws, existing_style, ["numFmtId", "applyNumberFormat"], [string(DEFAULT_DATE_numFmtId), "1"]).id) end elseif val isa Dates.Time if isa_dt == false - c.style = string(update_template_xf(ws, existing_style, "numFmtId", DEFAULT_TIME_numFmtId).id) + c.style = string(update_template_xf(ws, existing_style, ["numFmtId", "applyNumberFormat"], [string(DEFAULT_TIME_numFmtId), "1"]).id) end elseif val isa Dates.DateTime if isa_dt == false - c.style = string(update_template_xf(ws, existing_style, "numFmtId", DEFAULT_DATETIME_numFmtId).id) + c.style = string(update_template_xf(ws, existing_style, ["numFmtId", "applyNumberFormat"], [string(DEFAULT_DATETIME_numFmtId), "1"]).id) end elseif val isa Float64 || val isa Int if styles_is_float(ws.package.workbook, existing_style) == false && Int(existing_style.id) ∉ [0, 1] - c.style = string(update_template_xf(ws, existing_style, "numFmtId", DEFAULT_NUMBER_numFmtId).id) + c.style = string(update_template_xf(ws, existing_style, ["numFmtId"], [string(DEFAULT_NUMBER_numFmtId)]).id) end elseif val isa Bool # Now rerouted here rather than assigning an EmptyCellDataFormat. - # Change any style to General (0) and retiain other formatting. - c.style = string(update_template_xf(ws, existing_style, "numFmtId", DEFAULT_BOOL_numFmtId).id) + # Change any style to General (0) and retain other formatting. + c.style = string(update_template_xf(ws, existing_style, ["numFmtId"], [string(DEFAULT_BOOL_numFmtId)]).id) end return setdata!(ws, ref, CellValue(val, CellDataFormat(parse(Int, c.style)))) end end -# setdata!(ws::Worksheet, ref::CellRef, val::CellValueType) = setdata!(ws, ref, CellValue(ws, val)) -setdata!(ws::Worksheet, ref_str::AbstractString, value) = setdata!(ws, CellRef(ref_str), value) +function setdata!(ws::Worksheet, ref::AbstractString, value) + if is_worksheet_defined_name(ws, ref) + v = get_defined_name_value(ws, ref) + if is_defined_name_value_a_reference(v) + return setdata!(ws, v, value) + else + throw(XLSXError("`$ref` is not a valid cell or range reference.")) + end + elseif is_workbook_defined_name(get_workbook(ws), ref) + wb = get_workbook(ws) + v = get_defined_name_value(wb, ref) + if is_defined_name_value_a_reference(v) + return setdata!(ws, v, value) + else + throw(XLSXError("`$ref` is not a valid cell or range reference.")) + end + elseif is_valid_cellname(ref) + return setdata!(ws, CellRef(ref), value) + elseif is_valid_sheet_cellname(ref) + return setdata!(ws, SheetCellRef(ref), value) + elseif is_valid_cellrange(ref) + return setdata!(ws, CellRange(ref), value) + elseif is_valid_column_range(ref) + return setdata!(ws, ColumnRange(ref), value) + elseif is_valid_row_range(ref) + return setdata!(ws, RowRange(ref), value) + elseif is_valid_non_contiguous_range(ref) + return setdata!(ws, NonContiguousRange(ws, ref), value) + elseif is_valid_sheet_cellrange(ref) + return setdata!(ws, SheetCellRange(ref), value) + elseif is_valid_sheet_column_range(ref) + return setdata!(ws, SheetColumnRange(ref), value) + elseif is_valid_sheet_row_range(ref) + return setdata!(ws, SheetRowRange(ref), value) + elseif is_valid_non_contiguous_cellrange(ref) + return setdata!(ws, NonContiguousRange(ws, ref), value) + elseif is_valid_non_contiguous_sheetcellrange(ref) + nc = NonContiguousRange(ref) + return do_sheet_names_match(ws, nc) && setdata!(ws, nc, value) + end + throw(XLSXError("`$ref` is not a valid cell or range reference.")) +end +function setdata!(ws::Worksheet, rng::CellRange, value) + for row in rng.start.row_number:rng.stop.row_number + for col in rng.start.column_number:rng.stop.column_number + setdata!(ws, row, col, value) + end + end +end +function setdata!(ws::Worksheet, rng::RowRange, value) + dim = get_dimension(ws) + start = CellRef(rng.start, dim.start.column_number,) + stop = CellRef(rng.stop, dim.stop.column_number) + setdata!(ws, CellRange(start, stop), value) +end +function setdata!(ws::Worksheet, rng::ColumnRange, value) + dim = get_dimension(ws) + start = CellRef(dim.start.row_number, rng.start) + stop = CellRef(dim.stop.row_number, rng.stop) + setdata!(ws, CellRange(start, stop), value) +end +function setdata!(ws::Worksheet, rng::NonContiguousRange, value) + for r in rng.rng + if r isa CellRef + setdata!(ws, r, value) + else + for cell in r + setdata!(ws, cell, value) + end + end + end +end +setdata!(ws::Worksheet, ::Colon, ::Colon, v) = setdata!(ws::Worksheet, :, v) +function setdata!(ws::Worksheet, ::Colon, v) + dim = get_dimension(ws) + setdata!(ws, dim, v) +end +function setdata!(ws::Worksheet, row::Union{Integer,UnitRange{<:Integer}}, ::Colon, v) + dim = get_dimension(ws) + setdata!(ws, CellRange(CellRef(first(row), dim.start.column_number), CellRef(last(row), dim.stop.column_number)), v) +end +function setdata!(ws::Worksheet, ::Colon, col::Union{Integer,UnitRange{<:Integer}}, v) + dim = get_dimension(ws) + setdata!(ws, CellRange(CellRef(dim.start.row_number, first(col)), CellRef(dim.stop.row_number, last(col))), v) +end +function setdata!(ws::Worksheet, row::Union{Vector{Int},StepRange{<:Integer}}, ::Colon, v) + dim = get_dimension(ws) + for a in row + for b in dim.start.column_number:dim.stop.column_number + setdata!(ws, CellRef(a, b), v) + end + end +end +function setdata!(ws::Worksheet, ::Colon, col::Union{Vector{Int},StepRange{<:Integer}}, v) + dim = get_dimension(ws) + for b in col + for a in dim.start.row_number:dim.stop.row_number + setdata!(ws, CellRef(a, b), v) + end + end +end +setdata!(ws::Worksheet, ref::SheetCellRef, value) = setdata!(ws, ref.cellref, value) +setdata!(ws::Worksheet, rng::SheetCellRange, value) = setdata!(ws, rng.rng, value) +setdata!(ws::Worksheet, rng::SheetColumnRange, value) = setdata!(ws, rng.colrng, value) +setdata!(ws::Worksheet, rng::SheetRowRange, value) = setdata!(ws, rng.rowrng, value) setdata!(ws::Worksheet, ref_str::AbstractString, value::Vector, dim::Integer) = setdata!(ws, CellRef(ref_str), value, dim) setdata!(ws::Worksheet, row::Integer, col::Integer, data) = setdata!(ws, CellRef(row, col), data) -setdata!(ws::Worksheet, ref::CellRef, value) = error("Unsupported datatype $(typeof(value)) for writing data to Excel file. Supported data types are $(CellValueType) or $(CellValue).") +setdata!(ws::Worksheet, ref::CellRef, value) = throw(XLSXError("Unsupported datatype $(typeof(value)) for writing data to Excel file. Supported data types are $(CellValueType) or $(CellValue).")) setdata!(ws::Worksheet, row::Integer, col::Integer, data::AbstractVector, dim::Integer) = setdata!(ws, CellRef(row, col), data, dim) function setdata!(sheet::Worksheet, ref::CellRef, data::AbstractVector, dim::Integer) for (i, val) in enumerate(data) - target_cell_ref = target_cell_ref_from_offset(ref, i-1, dim) + target_cell_ref = target_cell_ref_from_offset(ref, i - 1, dim) setdata!(sheet, target_cell_ref, val) 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) +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) function setdata!(sheet::Worksheet, row::Integer, cols::UnitRange{T}, data::AbstractVector) where {T<:Integer} - @assert length(data) == length(cols) "Column count mismatch between `data` ($(length(data)) columns) and column range $cols ($(length(cols)) columns)." + length(data) != length(cols) && throw(XLSXError("Column count mismatch between `data` ($(length(data)) columns) and column range $cols ($(length(cols)) columns).")) anchor_cell_ref = CellRef(row, first(cols)) # since cols is the unit range, this is a column-based operation @@ -521,24 +697,24 @@ function setdata!(sheet::Worksheet, row::Integer, cols::UnitRange{T}, data::Abst end function setdata!(sheet::Worksheet, rows::UnitRange{T}, col::Integer, data::AbstractVector) where {T<:Integer} - @assert length(data) == length(rows) "Row count mismatch between `data` ($(length(data)) rows) and row range $rows ($(length(rows)) rows)." + length(data) != length(rows) && throw(XLSXError("Row count mismatch between `data` ($(length(data)) rows) and row range $rows ($(length(rows)) rows).")) anchor_cell_ref = CellRef(first(rows), col) # since rows is the unit range, this is a row-based operation setdata!(sheet, anchor_cell_ref, data, 1) end -function setdata!(sheet::Worksheet, ref_or_rng::AbstractString, matrix::Array{T, 2}) where {T} +function setdata!(sheet::Worksheet, ref_or_rng::AbstractString, matrix::Array{T,2}) where {T} if is_valid_cellrange(ref_or_rng) setdata!(sheet, CellRange(ref_or_rng), matrix) elseif is_valid_cellname(ref_or_rng) setdata!(sheet, CellRef(ref_or_rng), matrix) else - error("Invalid cell reference or range: $ref_or_rng") + throw(XLSXError("Invalid cell reference or range: $ref_or_rng")) end end -function setdata!(sheet::Worksheet, ref::CellRef, matrix::Array{T, 2}) where {T} +function setdata!(sheet::Worksheet, ref::CellRef, matrix::Array{T,2}) where {T} rows, cols = size(matrix) anchor_row = row_number(ref) anchor_col = column_number(ref) @@ -548,8 +724,8 @@ function setdata!(sheet::Worksheet, ref::CellRef, matrix::Array{T, 2}) where {T} end end -function setdata!(sheet::Worksheet, rng::CellRange, matrix::Array{T, 2}) where {T} - @assert size(rng) == size(matrix) "Target range $rng size ($(size(rng))) must be equal to the input matrix size ($(size(matrix))) " +function setdata!(sheet::Worksheet, rng::CellRange, matrix::Array{T,2}) where {T} + size(rng) != size(matrix) && throw(XLSXError("Target range $rng size ($(size(rng))) must be equal to the input matrix size ($(size(matrix)))")) setdata!(sheet, rng.start, matrix) end @@ -557,20 +733,34 @@ end # Returns a CellRef at: # - (anchor_row + offset, anchol_col) if dim = 1 (operates on rows) # - (anchor_row, anchor_col + offset) if dim = 2 (operates on cols) -function target_cell_ref_from_offset(anchor_row::Integer, anchor_col::Integer, offset::Integer, dim::Integer) :: CellRef +function target_cell_ref_from_offset(anchor_row::Integer, anchor_col::Integer, offset::Integer, dim::Integer)::CellRef if dim == 1 return CellRef(anchor_row + offset, anchor_col) elseif dim == 2 return CellRef(anchor_row, anchor_col + offset) else - error("Invalid dimension: $dim.") + throw(XLSXError("Invalid dimension: $dim.")) end end -function target_cell_ref_from_offset(anchor_cell::CellRef, offset::Integer, dim::Integer) :: CellRef +function target_cell_ref_from_offset(anchor_cell::CellRef, offset::Integer, dim::Integer)::CellRef return target_cell_ref_from_offset(row_number(anchor_cell), column_number(anchor_cell), offset, dim) end +const ALLOWED_TYPES = Union{Number,String,Bool,Dates.Date,Dates.Time,Dates.DateTime,Missing,Nothing} +function process_vector(col) # Convert any disallowed types to strings. #239. + if eltype(col) <: ALLOWED_TYPES + # Case 1: All elements are of allowed types + return col + elseif eltype(col) <: Any && all(x -> !(typeof(x) <: ALLOWED_TYPES), col) + # Case 2: All elements are of disallowed types + return map(x -> string(x), col) + else + # Case 3: Mixed types, process each element + return [typeof(x) <: ALLOWED_TYPES ? x : string(x) for x in col] + end +end + """ writetable!( sheet::Worksheet, @@ -586,47 +776,56 @@ starting at `anchor_cell`. `data` must be a vector of columns. `columnnames` must be a vector of column labels. +Column labels that are not of type `String` will be converted +to strings before writing. Any data columns that are not of +type `String`, `Float64`, `Int64`, `Bool`, `Date`, `Time`, +`DateTime`, `Missing`, or `Nothing` will be converted to strings +before writing. + + See also: [`XLSX.writetable`](@ref). """ function writetable!( - sheet::Worksheet, - data, - columnnames; - anchor_cell::CellRef=CellRef("A1"), - write_columnnames::Bool=true, - ) + sheet::Worksheet, + data, + columnnames; + anchor_cell::CellRef=CellRef("A1"), + write_columnnames::Bool=true, +) # read dimensions col_count = length(data) - @assert col_count == length(columnnames) "Column count mismatch between `data` ($col_count columns) and `columnnames` ($(length(columnnames)) columns)." - @assert col_count > 0 "Can't write table with no columns." - @assert col_count <= EXCEL_MAX_COLS "`data` contains $col_count columns, but Excel only supports up to $EXCEL_MAX_COLS; must reduce `data` size" + col_count != length(columnnames) && throw(XLSXError("Column count mismatch between `data` ($col_count columns) and `columnnames` ($(length(columnnames)) columns).")) + col_count <= 0 && throw(XLSXError("Can't write table with no columns.")) + col_count > EXCEL_MAX_COLS && throw(XLSXError("`data` contains $col_count columns, but Excel only supports up to $EXCEL_MAX_COLS; must reduce `data` size")) row_count = length(data[1]) - @assert row_count <= EXCEL_MAX_ROWS-1 "`data` contains $row_count rows, but Excel only supports up to $(EXCEL_MAX_ROWS-1); must reduce `data` size" + row_count > EXCEL_MAX_ROWS - 1 && throw(XLSXError("`data` contains $row_count rows, but Excel only supports up to $(EXCEL_MAX_ROWS-1); must reduce `data` size")) if col_count > 1 for c in 2:col_count - @assert length(data[c]) == row_count "Row count mismatch between column 1 ($row_count rows) and column $c ($(length(data[c])) rows)." + length(data[c]) != row_count && throw(XLSXError("Row count mismatch between column 1 ($row_count rows) and column $c ($(length(data[c])) rows).")) end end anchor_row = row_number(anchor_cell) anchor_col = column_number(anchor_cell) start_from_anchor = 1 + # write table header if write_columnnames for c in 1:col_count target_cell_ref = CellRef(anchor_row, c + anchor_col - 1) - sheet[target_cell_ref] = strip_illegal_chars(xlsx_escape(string(columnnames[c]))) + sheet[target_cell_ref] = strip_illegal_chars(XML.escape(string(columnnames[c]))) end start_from_anchor = 0 end # write table data + data = [process_vector(col) for col in data] # Address issue #239 for c in 1:col_count for r in 1:row_count target_cell_ref = CellRef(r + anchor_row - start_from_anchor, c + anchor_col - 1) v = data[c][r] - sheet[target_cell_ref] = v isa String ? strip_illegal_chars(xlsx_escape(v)) : v + sheet[target_cell_ref] = v isa String ? strip_illegal_chars(XML.escape(v)) : v end end end @@ -645,8 +844,8 @@ function rename!(ws::Worksheet, name::AbstractString) end xf = get_xlsxfile(ws) - @assert is_writable(xf) "XLSXFile instance is not writable." - @assert name ∉ sheetnames(xf) "Sheetname $name is already in use." + !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable.")) + name ∈ sheetnames(xf) && throw(XLSXError("Sheetname $name is already in use.")) # updates XML xroot = xmlroot(xf, "xl/workbook.xml")[end] @@ -670,67 +869,188 @@ function rename!(ws::Worksheet, name::AbstractString) nothing end -addsheet!(xl::XLSXFile, name::AbstractString="") :: Worksheet = addsheet!(get_workbook(xl), name) - """ - addsheet!(workbook, [name]) :: Worksheet + addsheet!(wb::Workbook, [name::AbstractString=""]) --> ::Worksheet + addsheet!(xf::XLSXFile, [name::AbstractString=""]) --> ::Worksheet Create a new worksheet named `name`. If `name` is not provided, a unique name is created. -""" -function addsheet!(wb::Workbook, name::AbstractString=""; relocatable_data_path::String = _relocatable_data_path()) :: Worksheet - xf = get_xlsxfile(wb) - @assert is_writable(xf) "XLSXFile instance is not writable." +See also [copysheet!](@ref), [deletesheet!](@ref) +""" +addsheet!(xl::XLSXFile, name::AbstractString="")::Worksheet = addsheet!(get_workbook(xl), name) ::Worksheet +function addsheet!(wb::Workbook, name::AbstractString=""; relocatable_data_path::String=_relocatable_data_path())::Worksheet file_sheet_template = joinpath(relocatable_data_path, "sheet_template.xml") - @assert isfile(file_sheet_template) "Couldn't find template file $file_sheet_template." + !isfile(file_sheet_template) && throw(XLSXError("Couldn't find template file $file_sheet_template.")) + xdoc = XML.read(file_sheet_template, XML.Node) + new_ws = insertsheet!(wb, xdoc, name) + new_ws.cache = XLSX.WorksheetCache( + false, + Dict{Int64,Dict{Int64,XLSX.Cell}}(), + Int64[], + Dict{Int,Union{Float64,Nothing}}(), + Dict{Int64,Int64}(), + SheetRowStreamIterator(new_ws), + nothing, + false + ) + return new_ws +end + +""" + copysheet!(ws::Worksheet, [name::AbstractString=""]) --> ::Worksheet + +Create a copy of the worksheet `ws` and add it to the end of the workbook with the +specified worksheet name. +Return the new worksheet object. +If `name` is not provided, a new name is generated by appending " (copy)" to the original +worksheet name, with a further numerical suffix to guarantee uniqueness if necessary. +To copy worksheets, the `XLSXFile` must be writable (opened with `mode="rw"` or as a template). +See also [`XLSX.openxlsx`](@ref) and [XLSX.opentemplate](@ref). + +!!! warning "Experimental" + This function is experimental is not guaranteed to work with all XLSX files, + especially those with complex features. However, cell formats, conditional formats + and worksheet defined names should all copy OK. + +See also [addsheet!](@ref), [deletesheet!](@ref) + +# Examples +```julia +julia> f=XLSX.openxlsx("general.xlsx", mode="rw") +XLSXFile("C:\\...\\general.xlsx") containing 13 Worksheets + sheetname size range +------------------------------------------------- + general 10x6 A1:F10 + table3 5x6 A2:F6 + table4 4x3 E12:G15 + table 12x8 A2:H13 + table2 5x3 A1:C5 + empty 1x1 A1:A1 + table5 6x1 C3:C8 + table6 8x2 B1:C8 + table7 7x2 B2:C8 + lookup 4x9 B2:J5 + header_error 3x4 B2:E4 + named_ranges_2 4x5 A1:E4 + named_ranges 14x6 A2:F15 + +julia> XLSX.copysheet!(f[4]) +12×8 XLSX.Worksheet: ["table (copy)"](A2:H13) + +julia> f +XLSXFile("C:\\...\\general.xlsx") containing 14 Worksheets + sheetname size range +------------------------------------------------- + general 10x6 A1:F10 + table3 5x6 A2:F6 + table4 4x3 E12:G15 + table 12x8 A2:H13 + table2 5x3 A1:C5 + empty 1x1 A1:A1 + table5 6x1 C3:C8 + table6 8x2 B1:C8 + table7 7x2 B2:C8 + lookup 4x9 B2:J5 + header_error 3x4 B2:E4 + named_ranges_2 4x5 A1:E4 + named_ranges 14x6 A2:F15 + table (copy) 12x8 A2:H13 + +``` + +""" +function copysheet!(ws::Worksheet, name::AbstractString="")::Worksheet + wb = get_workbook(ws) + xl=get_xlsxfile(ws) + !is_writable(get_xlsxfile(ws)) && throw(XLSXError("XLSXFile instance is not writable.")) + dim=get_dimension(ws) + + # make sure cache and XML are consistent + update_worksheets_xml!(xl) + + # create a copy of the XML document + xdoc = copynode(get_worksheet_xml_document(ws)) + + # generate a new name for the copied sheet + name = name=="" ? ws.name * " (copy)" : name + + # insert the copied sheet into the workbook + new_ws = insertsheet!(wb, xdoc, name, dim=dim) + + # copy the original worksheet cache to the new worksheet + new_ws.cache = XLSX.WorksheetCache( + false, + ws.cache.cells, + ws.cache.rows_in_cache, + ws.cache.row_ht, + ws.cache.row_index, + SheetRowStreamIterator(new_ws), + nothing, + ws.cache.dirty, + ) + + # copy defined names from the original worksheet to the new worksheet + 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) + end + + return new_ws +end + +function insertsheet!(wb::Workbook, xdoc::XML.Node, name::AbstractString=""; dim=CellRange("A1:A1"))::Worksheet + xf = get_xlsxfile(wb) + !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable.")) if name == "" - # name was not provided. Will find a unique name. - i = 1 - current_sheet_names = sheetnames(wb) - while true - name = "Sheet$i" - if !in(name, current_sheet_names) - # found a unique name - break - end - i += 1 - end - else + new_name="" + else + new_name = name + end + # ensure name is unique. + i = 1 + current_sheet_names = sheetnames(wb) + while new_name ∈ current_sheet_names || new_name == "" + new_name = (name=="" ? "Sheet" : name * " ") * string(i) + i += 1 end - @assert name != "" + new_name == "" && throw(XLSXError("Something wrong here!")) # checks if name is a unique sheet name - @assert name ∉ sheetnames(wb) "A sheet named `$name` already exists in this workbook." - function check_valid_sheetname(n::AbstractString) max_length = 31 - @assert(length(n) <= max_length, - "Invalid sheetname $n: must have at most $max_length characters. Found $(length(n))" - ) + if length(n) > max_length + throw(XLSXError("Invalid sheetname $n: must have at most $max_length characters. Found $(length(n))")) + end - @assert(!occursin(r"[:\\/\?\*\[\]]+", n), - "Sheetname cannot contain characters: ':', '\\', '/', '?', '*', '[', ']'." - ) + if occursin(r"[:\\/\?\*\[\]]+", n) + throw(XLSXError("Sheetname cannot contain characters: ':', '\\', '/', '?', '*', '[', ']'.")) + end end - check_valid_sheetname(name) + check_valid_sheetname(new_name) # generate sheetId - current_sheet_ids = [ ws.sheetId for ws in wb.sheets ] + current_sheet_ids = [ws.sheetId for ws in wb.sheets] sheetId = max(current_sheet_ids...) + 1 - xdoc = XML.read(file_sheet_template, XML.Node) + # generate a unique ID for the new sheet + xdoc[2]["xr:uid"] = "{" * string(UUIDs.uuid4()) * "}" # generate a unique name for the XML local xml_filename::String i = 1 while true - xml_filename = "xl/worksheets/sheet$i.xml" - if !in(xml_filename, keys(xf.files)) + xml_filename = "xl/worksheets/sheet" * string(i) * ".xml" +# if !in(xml_filename, keys(xf.files)) + if !haskey(xf.files, xml_filename) break end i += 1 @@ -739,55 +1059,199 @@ function addsheet!(wb::Workbook, name::AbstractString=""; relocatable_data_path: # adds doc do XLSXFile xf.files[xml_filename] = true # is read xf.data[xml_filename] = xdoc - # adds workbook-level relationship # rId = add_relationship!(wb, xml_filename[4:end], "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet") # creates Worksheet instance - ws = Worksheet(xf, sheetId, rId, name, CellRange("A1:A1"), false) - # creates a mock WorksheetCache - # because we can't write to sheet with empty cache (see setdata!(ws::Worksheet, cell::Cell)) - # and the stream should be closed - # to indicate that no more rows will be fetched from SheetRowStreamIterator in Base.iterate(ws_cache::WorksheetCache, row_from_last_iteration::Int) - reader = open_internal_file_stream(xf, "xl/worksheets/sheet1.xml") # could be any file - state = SheetRowStreamIteratorState(reader, nothing, 0, nothing) - ws.cache = XLSX.WorksheetCache( - Dict{Int64, Dict{Int64, XLSX.Cell}}(), - Int64[], - Dict{Int, Union{Float64, Nothing}}(), - Dict{Int64, Int64}(), - SheetRowStreamIterator(ws), - state, - false - ) + ws = Worksheet(xf, sheetId, rId, new_name, dim, false) # adds the new sheet to the list of sheets in the workbook push!(wb.sheets, ws) # update [Content_Types].xml (fix for issue #275) ctype_root = xmlroot(get_xlsxfile(wb), "[Content_Types].xml")[end] - @assert XML.tag(ctype_root) == "Types" + XML.tag(ctype_root) != "Types" && throw(XLSXError("Something wrong here!")) override_node = XML.Element("Override"; - ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", - PartName = "/xl/worksheets/sheet$sheetId.xml" + PartName="/xl/worksheets/sheet" * string(sheetId) * ".xml", + ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" ) push!(ctype_root, override_node) - # updates workbook xml - xroot = xmlroot(xf, "xl/workbook.xml")[end] - for node in XML.children(xroot) - if XML.tag(node) == "sheets" - sheet_element = XML.Element("sheet"; name = name) - sheet_element["r:id"] = rId - sheet_element["sheetId"] = string(sheetId) - push!(node, sheet_element) - break + update_workbook_xml!(xf) + + return ws +end + +""" + deletesheet!(ws::Worksheet) -> ::XLSXFile + deletesheet!(wb::Workbook, name::AbstractString) -> ::XLSXFile + deletesheet!(xf::XLSXFile, name::AbstractString) -> ::XLSXFile + deletesheet!(xf::XLSXFile, sheetId::Integer) -> ::XLSXFile + +Delete the given worksheet, the worksheet with the given name or the worksheet with the given `sheetId` from its `XLSXFile` +(`sheetId` is a 1-based integer representing the order in which worksheet tabs are displayed in Excel). + +See also [addsheet!](@ref), [copysheet!](@ref) + +# Examples + +```julia +julia> f = XLSX.opentemplate("general.xlsx") +XLSXFile("C:\\...\\general.xlsx") containing 13 Worksheets + sheetname size range +------------------------------------------------- + general 10x6 A1:F10 + table3 5x6 A2:F6 + table4 4x3 E12:G15 + table 12x8 A2:H13 + table2 5x3 A1:C5 + empty 1x1 A1:A1 + table5 6x1 C3:C8 + table6 8x2 B1:C8 + table7 7x2 B2:C8 + lookup 4x9 B2:J5 + header_error 3x4 B2:E4 + named_ranges_2 4x5 A1:E4 + named_ranges 14x6 A2:F15 + + +julia> XLSX.deletesheet!(f[4]) +XLSXFile("C:\\...\\general.xlsx") containing 12 Worksheets + sheetname size range +------------------------------------------------- + general 10x6 A1:F10 + table3 5x6 A2:F6 + table4 4x3 E12:G15 + table2 5x3 A1:C5 + empty 1x1 A1:A1 + table5 6x1 C3:C8 + table6 8x2 B1:C8 + table7 7x2 B2:C8 + lookup 4x9 B2:J5 + header_error 3x4 B2:E4 + named_ranges_2 4x5 A1:E4 + named_ranges 14x6 A2:F15 + + +julia> XLSX.deletesheet!(f, "table5") +XLSXFile("C:\\...\\general.xlsx") containing 11 Worksheets + sheetname size range +------------------------------------------------- + general 10x6 A1:F10 + table3 5x6 A2:F6 + table4 4x3 E12:G15 + table2 5x3 A1:C5 + empty 1x1 A1:A1 + table6 8x2 B1:C8 + table7 7x2 B2:C8 + lookup 4x9 B2:J5 + header_error 3x4 B2:E4 + named_ranges_2 4x5 A1:E4 + named_ranges 14x6 A2:F15 + + +julia> XLSX.deletesheet!(f, 1) +XLSXFile("C:\\...\\general.xlsx") containing 10 Worksheets + sheetname size range +------------------------------------------------- + table3 5x6 A2:F6 + table4 4x3 E12:G15 + table2 5x3 A1:C5 + empty 1x1 A1:A1 + table6 8x2 B1:C8 + table7 7x2 B2:C8 + lookup 4x9 B2:J5 + header_error 3x4 B2:E4 + named_ranges_2 4x5 A1:E4 + named_ranges 14x6 A2:F15 +``` + +""" +deletesheet!(ws::Worksheet) = deletesheet!(get_workbook(ws), ws.name) +deletesheet!(xl::XLSXFile, sheetId::Integer) = deletesheet!(get_workbook(xl), xl[sheetId].name) +deletesheet!(xl::XLSXFile, name::AbstractString) = deletesheet!(get_workbook(xl), name) +function deletesheet!(wb::Workbook, name::AbstractString)::XLSXFile + hassheet(wb, name) || throw(XLSXError("Worksheet `$name` not found in workbook.")) + sheetcount(wb) > 1 || throw(XLSXError("`$name` is this workbook's only sheet. Cannot delete the only sheet!")) + + # Worksheets and relationships + s = (findfirst(s -> s.name == name, wb.sheets)) + sId = wb.sheets[s].sheetId + rId = wb.sheets[s].relationship_id + r = findfirst(y -> occursin("worksheet", y.Type) && y.Id == rId, wb.relationships) + deleteat!(wb.sheets, s) + + # Defined Names + found_wbnames = Vector{String}() + for (k, v) in wb.workbook_names + wbn = v.value + if typeof(wbn) <: DefinedNameRangeTypes + if wbn.sheet == name + push!(found_wbnames, k) + end end end + found_wsnames = Vector{Tuple{Int64,String}}() + for (k, v) in wb.worksheet_names + wbn = v.value + if first(k) == sId + push!(found_wsnames, k) + end + end + for key in found_wbnames + delete!(wb.workbook_names, key) + end + for key in found_wsnames + delete!(wb.worksheet_names, key) + end + renumber_keys = Vector{Pair{Tuple{Int64,String},Tuple{Int64,String}}}() + for (k, v) in wb.worksheet_names + wbn = v.value + first(k) == sId && throw(XLSXError("Something wrong here!")) + if first(k) > sId + push!(renumber_keys, k => (sId, last(k))) + end + end + for (oldkey, newkey) in renumber_keys + wb.worksheet_names[newkey] = wb.worksheet_names[oldkey] + delete!(wb.worksheet_names, oldkey) + end - return ws + xf = get_xlsxfile(wb) + + # Files + xml_filename = "xl/worksheets/sheet" * rId[4:end] * ".xml" + if in(xml_filename, keys(xf.files)) + delete!(xf.files, xml_filename) + end + if in(xml_filename, keys(xf.data)) + delete!(xf.data, xml_filename) + end + if in(xml_filename, keys(xf.binary_data)) + delete!(xf.binary_data, xml_filename) + end + + # update [Content_Types].xml + ctype_root = xmlroot(get_xlsxfile(wb), "[Content_Types].xml")[end] + XML.tag(ctype_root) != "Types" && throw(XLSXError("Something wrong here!")) + cont = XML.children(ctype_root) + let idx = 0 + for (i, c) in enumerate(cont) + if haskey(c, "PartName") && c["PartName"] == "/xl/worksheets/sheet" * rId[4:end] * ".xml" + idx = i + break + end + end + if idx > 0 + deleteat!(cont, idx) + end + end + + update_workbook_xml!(xf) + + return xf end # @@ -802,6 +1266,8 @@ end - `overwrite` is a `Bool` to control if `filename` should be overwritten if already exists. - `sheetname` is the name for the worksheet. +Returns the filepath of the written file if a filename is supplied, or `nothing` if writing to an `IO`. + # Example ```julia @@ -813,10 +1279,10 @@ XLSX.writetable("table.xlsx", columns, colnames) See also: [`XLSX.writetable!`](@ref). """ -function writetable(filename::Union{AbstractString, IO}, data, columnnames; overwrite::Bool=false, sheetname::AbstractString="", anchor_cell::Union{String, CellRef}=CellRef("A1")) +function writetable(filename::Union{AbstractString,IO}, data, columnnames; overwrite::Bool=false, sheetname::AbstractString="", anchor_cell::Union{String,CellRef}=CellRef("A1")) if filename isa AbstractString && !overwrite - @assert !isfile(filename) "$filename already exists." + isfile(filename) && throw(XLSXError("$filename already exists.")) end xf = open_empty_template(sheetname) @@ -830,7 +1296,6 @@ function writetable(filename::Union{AbstractString, IO}, data, columnnames; over # write output file writexlsx(filename, xf, overwrite=overwrite) - nothing end """ @@ -842,6 +1307,8 @@ Write multiple tables. `kw` is a variable keyword argument list. Each element should be in this format: `sheetname=( data, column_names )`, where `data` is a vector of columns and `column_names` is a vector of column labels. +Returns the filepath of the written file if a filename is supplied, or `nothing` if writing to an `IO`. + Example: ```julia @@ -854,10 +1321,10 @@ julia> df2 = DataFrames.DataFrame(AA=["aa", "bb"], AB=[10.1, 10.2]) julia> XLSX.writetable("report.xlsx", "REPORT_A" => df1, "REPORT_B" => df2) ``` """ -function writetable(filename::Union{AbstractString, IO}; overwrite::Bool=false, kw...) +function writetable(filename::Union{AbstractString,IO}; overwrite::Bool=false, kw...) if filename isa AbstractString && !overwrite - @assert !isfile(filename) "$filename already exists." + isfile(filename) && throw(XLSXError("$filename already exists.")) end xf = open_empty_template() @@ -879,13 +1346,13 @@ function writetable(filename::Union{AbstractString, IO}; overwrite::Bool=false, # write output file writexlsx(filename, xf, overwrite=overwrite) - nothing + end -function writetable(filename::Union{AbstractString, IO}, tables::Vector{Tuple{String, S, Vector{T}}}; overwrite::Bool=false) where {S<:Vector{U} where U, T<:Union{String, Symbol}} +function writetable(filename::Union{AbstractString,IO}, tables::Vector{Tuple{String,S,Vector{T}}}; overwrite::Bool=false) where {S<:Vector{U} where {U},T<:Union{String,Symbol}} if filename isa AbstractString && !overwrite - @assert !isfile(filename) "$filename already exists." + isfile(filename) && throw(XLSXError("$filename already exists.")) end xf = open_empty_template() diff --git a/test/runtests.jl b/test/runtests.jl index 11b81d40..e55f03d8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,7 +2,8 @@ import XLSX import Tables using Test, Dates, XML -import DataFrames +import DataFrames, Random +import Distributions as Dist const SPREADSHEET_NAMESPACE_XPATH_ARG = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" struct xpath @@ -123,24 +124,60 @@ data_directory = joinpath(dirname(pathof(XLSX)), "..", "data") @test XLSX.isdate1904(ef_Book1["Sheet1"]) == false @testset "Read XLS file error" begin - @test_throws ErrorException XLSX.readxlsx(joinpath(data_directory, "old.xls")) + @test_throws XLSX.XLSXError XLSX.readxlsx(joinpath(data_directory, "old.xls")) try XLSX.readxlsx(joinpath(data_directory, "old.xls")) @test false # didn't throw exception catch e @test occursin("This package does not support XLS file format", "$e") end + end @testset "Read invalid XLSX error" begin - @test_throws ErrorException XLSX.readxlsx(joinpath(data_directory, "sheet_template.xml")) + @test_throws XLSX.XLSXError XLSX.readxlsx(joinpath(data_directory, "sheet_template.xml")) try XLSX.readxlsx(joinpath(data_directory, "sheet_template.xml")) @test false # didn't throw exception catch e @test occursin("is not a valid XLSX file", "$e") end + @test_throws XLSX.XLSXError XLSX.readxlsx(joinpath(data_directory, "Template File.xltx")) + try + XLSX.readxlsx(joinpath(data_directory, "Template File.xltx")) + @test false # didn't throw exception + catch e + @test occursin("does not support Excel template files", "$e") + 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 + ef = XLSX.readxlsx("mytest.xlsx") + @test ef["Sheet1"]["A1"] == 1 + f=XLSX.openxlsx("mytest.xlsx", mode="w") + @test f.source == "mytest.xlsx" + f[1]["A1"]=1 + XLSX.writexlsx("mytest.xlsx", f, overwrite=true) + ef = XLSX.readxlsx("mytest.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) + ef = XLSX.readxlsx("mytest.xlsx") + @test ef["Sheet1"]["A1"] == 1 + isfile("mytest.xlsx") && rm("mytest.xlsx") + end + end @testset "Cell names" begin @@ -158,6 +195,12 @@ end @test !XLSX.is_valid_column_name("AAAZ") @test !XLSX.is_valid_column_name(":") @test !XLSX.is_valid_column_name("É") + @test XLSX.is_valid_row_name("1") + @test XLSX.is_valid_row_name("12") + @test XLSX.is_valid_row_name("123") + @test !XLSX.is_valid_row_name("012") + @test !XLSX.is_valid_row_name(":") + @test !XLSX.is_valid_row_name("A") @test XLSX.is_valid_sheet_cellname("Sheet1!A2") @test !XLSX.is_valid_sheet_cellname("Sheet1!A2:B3") @@ -177,13 +220,29 @@ end @test !XLSX.is_valid_sheet_cellrange("Sheet1!") @test !XLSX.is_valid_sheet_cellrange("Sheet1") @test !XLSX.is_valid_sheet_cellrange("mysheet!A1") - @test XLSX.is_valid_sheet_cellrange("mysheet!A1:A4") @test XLSX.is_valid_sheet_column_range("Sheet1!A:B") @test XLSX.is_valid_sheet_column_range("Sheet1!AB:BC") @test !XLSX.is_valid_sheet_column_range("A:B") @test !XLSX.is_valid_sheet_column_range("Sheet1!") @test !XLSX.is_valid_sheet_column_range("Sheet1") + @test XLSX.is_valid_sheet_row_range("Sheet1!1:2") + @test XLSX.is_valid_sheet_row_range("Sheet1!12:23") + @test !XLSX.is_valid_sheet_row_range("1:2") + @test !XLSX.is_valid_sheet_row_range("Sheet1!") + @test !XLSX.is_valid_sheet_row_range("Sheet1") + + @test XLSX.is_valid_non_contiguous_range("Sheet1!B1,Sheet1!B3") + @test XLSX.is_valid_non_contiguous_range("Sheet1!B1,Sheet1!GZ75:HB127") + @test XLSX.is_valid_non_contiguous_range("B2,B5") + @test XLSX.is_valid_non_contiguous_range("C3:C5,D6,G7:G8") + @test !XLSX.is_valid_non_contiguous_range("Sheet1!C3,Sheet2!C3") + @test !XLSX.is_valid_non_contiguous_range("Sheet1!B3") + @test !XLSX.is_valid_non_contiguous_range("Sheet1!B3:C6") + + @test in(XLSX.SheetCellRef("Sheet1!A1"), XLSX.NonContiguousRange("Sheet1!A1,Sheet1!B2")) == true + @test in(XLSX.SheetCellRef("Sheet1!B2"), XLSX.NonContiguousRange("Sheet1!A1,Sheet1!B2")) == true + @test in(XLSX.SheetCellRef("Sheet1!A2"), XLSX.NonContiguousRange("Sheet1!A1,Sheet1!B2")) == false cn = XLSX.CellRef("A1") @test string(cn) == "A1" @@ -215,7 +274,7 @@ end @assert length(v_column_names) == length(v_column_numbers) "Test script is wrong." - for i in 1:length(v_column_names) + 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 @@ -276,8 +335,8 @@ end @test XLSX.is_valid_cellrange("B2:C8") @test !XLSX.is_valid_cellrange("A:B") - @test_throws AssertionError XLSX.CellRange("Z10:A1") - @test_throws AssertionError XLSX.CellRange("Z1:A1") + @test_throws XLSX.XLSXError XLSX.CellRange("Z10:A1") + @test_throws XLSX.XLSXError XLSX.CellRange("Z1:A1") # hashing and equality @test XLSX.CellRef("AMI1") == XLSX.CellRef("AMI1") @@ -305,7 +364,7 @@ end ref = XLSX.SheetCellRange("Sheet1!A1:B4") @test ref.sheet == "Sheet1" @test ref.rng == XLSX.CellRange("A1:B4") - @test_throws AssertionError XLSX.SheetCellRange("Sheet1!B4:A1") + @test_throws XLSX.XLSXError XLSX.SheetCellRange("Sheet1!B4:A1") @test XLSX.SheetCellRange("Sheet1!A1:B4") == XLSX.SheetCellRange("Sheet1!A1:B4") @test hash(XLSX.SheetCellRange("Sheet1!A1:B4")) == hash(XLSX.SheetCellRange("Sheet1!A1:B4")) show(IOBuffer(), ref) @@ -355,7 +414,12 @@ end @test XLSX.getcell(sheet1, "B2") == XLSX.Cell(XLSX.CellRef("B2"), "s", "", "0", "") XLSX.getcellrange(sheet1, "B2:C3") XLSX.getcellrange(f, "Sheet1!B2:C3") - @test_throws ErrorException XLSX.getcellrange(f, "B2:C3") + XLSX.getcellrange(sheet1, 2, 2) + XLSX.getcellrange(sheet1, 2, :) + XLSX.getcellrange(sheet1, :, 3) + XLSX.getcellrange(sheet1, 3, :) + XLSX.getcellrange(sheet1, "B2:C3") + @test_throws XLSX.XLSXError XLSX.getcellrange(f, "B2:C3") # a cell can be put in a dict c = XLSX.getcell(sheet1, "B2") @@ -372,6 +436,136 @@ end @test sheet2_data == sheet2["A1:C3"] @test sheet2_data == sheet2[:] @test sheet2[:] == XLSX.getdata(sheet2) + @test sheet2[:] == XLSX.getdata(sheet2, :) + @test XLSX.getdata(sheet2, :, [1, 2]) == sheet2["A1:B3"] +end + +@testset "setindex" begin + f = XLSX.newxlsx() + s = f[1] + s["A1:A3"] = "Hello world" + s[2, 1:3] = 42 + s[[1, 3], 2:3] = true + @test s[1:3, [1, 2, 3]] == Any["Hello world" true true; 42 42 42; "Hello world" true true] + s[2, :] = 44 + @test s[[1, 2, 3], 1:3] == Any["Hello world" true true; 44 44 44; "Hello world" true true] + @test s["Sheet1!A1:C3"] == Any["Hello world" true true; 44 44 44; "Hello world" true true] + @test s["Sheet1!A:C"] == Any["Hello world" true true; 44 44 44; "Hello world" true true] + @test s["Sheet1!1:3"] == Any["Hello world" true true; 44 44 44; "Hello world" true true] + s[:, :] = 0 + @test s[:, :] == Any[0 0 0; 0 0 0; 0 0 0] + s[:] = 1 + @test s[:, 1:3] == Any[1 1 1; 1 1 1; 1 1 1] + @test s[1:3, :] == Any[1 1 1; 1 1 1; 1 1 1] + @test s[1:2:3, :] == Any[1 1 1; 1 1 1] + @test s[1:2:3, 1] == Any[1, 1] + s["A1,B2,C3"] = "non-contiguous" + @test s["Sheet1!A1,Sheet1!B2,Sheet1!C3"] == Any["non-contiguous", "non-contiguous", "non-contiguous"] + + f = XLSX.newxlsx() + s = f[1] + s[[1, 2, 3], :] = "Hello world" + s[:, [1, 2, 3, 4]] = 42 + s[:, 1:3] = true + @test s["Sheet1!1:3"] == Any[true true true 42; true true true 42; true true true 42] + s["Sheet1!A1"] = "Goodbye world" + @test s["Sheet1!A1"] == "Goodbye world" + s["Sheet1!A1:A3"] = "Goodbye cruel world" + @test s["Sheet1!A1:A3"] == ["Goodbye cruel world"; "Goodbye cruel world"; "Goodbye cruel world";;] + s["Sheet1!1:2"] = "Bright Lights" + @test s["A1,B2,C3"] == ["Bright Lights", "Bright Lights", true] + s["Sheet1!C:D"] = "Beat my Retreat" + @test s["B1,C2,D3"] == ["Bright Lights", "Beat my Retreat", "Beat my Retreat"] + s["Sheet1!B1,Sheet1!C2,Sheet1!D3"] = "Night Comes In" + @test s["Sheet1!B1,Sheet1!C2,Sheet1!D3"] == ["Night Comes In", "Night Comes In", "Night Comes In"] + + f = XLSX.newxlsx() + s = f[1] + s[[1, 2, 3], :] = "Hello world" + s[:, [1, 2, 3, 4]] = 42 + s[:, 1:3] = true + @test f["Sheet1!1:3"] == Any[true true true 42; true true true 42; true true true 42] + s["Sheet1!A1"] = "Goodbye world" + @test f["Sheet1!A1"] == "Goodbye world" + s["Sheet1!A1:A3"] = "Goodbye cruel world" + @test s["Sheet1!A1:A3"] == ["Goodbye cruel world"; "Goodbye cruel world"; "Goodbye cruel world";;] + s["Sheet1!1:2"] = "Bright Lights" + @test s["A1,B2,C3"] == ["Bright Lights", "Bright Lights", true] + s["Sheet1!C:D"] = "Beat my Retreat" + @test s["B1,C2,D3"] == ["Bright Lights", "Beat my Retreat", "Beat my Retreat"] + s["Sheet1!B1,Sheet1!C2,Sheet1!D3"] = "Night Comes In" + @test s["Sheet1!B1,Sheet1!C2,Sheet1!D3"] == ["Night Comes In", "Night Comes In", "Night Comes In"] + @test_throws XLSX.XLSXError s["Sheet1!garbage"] = 1 + @test_throws XLSX.XLSXError s["garbage"] = 1 + @test_throws XLSX.XLSXError s["garbage1:garbage2"] = 1 + + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5 + for j in 1:5 + s[i, j] = i + j + end + end + @test s[1:5, 1:5] == [2 3 4 5 6; 3 4 5 6 7; 4 5 6 7 8; 5 6 7 8 9; 6 7 8 9 10] + s[1:3, 1:2:5] = 99 + @test s[1:5, 1:5] == [99 3 99 5 99; 99 4 99 6 99; 99 5 99 7 99; 5 6 7 8 9; 6 7 8 9 10] + s[1:2:5, 4:5] = -99 + @test s[1:5, 1:5] == [99 3 99 -99 -99; 99 4 99 6 99; 99 5 99 -99 -99; 5 6 7 8 9; 6 7 8 -99 -99] + s[[2, 4], [3, 5]] = 0 + @test s[1:5, 1:5] == [99 3 99 -99 -99; 99 4 0 6 0; 99 5 99 -99 -99; 5 6 0 8 0; 6 7 8 -99 -99] + @test s[[2, 4], [3, 5]] == [0 0; 0 0] + +end + +@testset "getcell" begin + f = XLSX.newxlsx() + s = f[1] + for i in 1:3 + for j in 1:3 + s[i, j] = i + j + end + end + @test XLSX.getcell(s, "A1") == XLSX.Cell(XLSX.CellRef("A1"), "", "", "2", "") + @test XLSX.getcell(s, "Sheet1!A1") == XLSX.Cell(XLSX.CellRef("A1"), "", "", "2", "") + @test XLSX.getcell(f, "Sheet1!A1") == XLSX.Cell(XLSX.CellRef("A1"), "", "", "2", "") + @test XLSX.getcell(s, XLSX.SheetCellRef("Sheet1!A1")) == XLSX.Cell(XLSX.CellRef("A1"), "", "", "2", "") + @test XLSX.getcell(f, XLSX.SheetCellRef("Sheet1!A1")) == XLSX.Cell(XLSX.CellRef("A1"), "", "", "2", "") + @test XLSX.getcell(s, "B1:B3") == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""); XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", ""); XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "");;] + @test XLSX.getcell(s, "Sheet1!B1:B3") == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""); XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", ""); XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "");;] + @test XLSX.getcell(f, "Sheet1!B1:B3") == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""); XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", ""); XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "");;] + @test XLSX.getcell(s, XLSX.SheetCellRange("Sheet1!B1:B3")) == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""); XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", ""); XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "");;] + @test XLSX.getcell(s, "B1,B3") == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""), XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "")] + @test XLSX.getcell(s, "Sheet1!B1,Sheet1!B3") == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""), XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "")] + @test XLSX.getcell(f, "Sheet1!B1,Sheet1!B3") == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""), XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "")] + @test XLSX.getcell(s, "B:B") == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""); XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", ""); XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "");;] + @test XLSX.getcell(s, "Sheet1!B:B") == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""); XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", ""); XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "");;] + @test XLSX.getcell(f, "Sheet1!B:B") == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""); XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", ""); XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "");;] + @test XLSX.getcell(s, XLSX.SheetColumnRange("Sheet1!B:B")) == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""); XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", ""); XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "");;] + @test XLSX.getcell(s, "Sheet1!2:2") == [XLSX.Cell(XLSX.CellRef("A2"), "", "", "3", "") XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", "") XLSX.Cell(XLSX.CellRef("C2"), "", "", "5", "")] + @test XLSX.getcell(f, "Sheet1!2:2") == [XLSX.Cell(XLSX.CellRef("A2"), "", "", "3", "") XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", "") XLSX.Cell(XLSX.CellRef("C2"), "", "", "5", "")] + @test XLSX.getcell(s, XLSX.SheetRowRange("Sheet1!2:2")) == [XLSX.Cell(XLSX.CellRef("A2"), "", "", "3", "") XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", "") XLSX.Cell(XLSX.CellRef("C2"), "", "", "5", "")] + @test XLSX.getcell(s, "2:2") == [XLSX.Cell(XLSX.CellRef("A2"), "", "", "3", "") XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", "") XLSX.Cell(XLSX.CellRef("C2"), "", "", "5", "")] + @test XLSX.getcell(s, :, 2) == [XLSX.Cell(XLSX.CellRef("B1"), "", "", "3", ""); XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", ""); XLSX.Cell(XLSX.CellRef("B3"), "", "", "5", "");;] + @test XLSX.getcell(s, 2, :) == [XLSX.Cell(XLSX.CellRef("A2"), "", "", "3", "") XLSX.Cell(XLSX.CellRef("B2"), "", "", "4", "") XLSX.Cell(XLSX.CellRef("C2"), "", "", "5", "")] + @test XLSX.getcell(s, 2, 1:2:3) == [XLSX.Cell(XLSX.CellRef("A2"), "", "", "3", ""), XLSX.Cell(XLSX.CellRef("C2"), "", "", "5", "")] + @test XLSX.getcell(s, 2, [1, 3]) == [XLSX.Cell(XLSX.CellRef("A2"), "", "", "3", ""), XLSX.Cell(XLSX.CellRef("C2"), "", "", "5", "")] + @test XLSX.getcell(s, [2], 1) == [XLSX.Cell(XLSX.CellRef("A2"), "", "", "3", "")] + @test XLSX.getcell(s, [2], [1, 3]) == [XLSX.Cell(XLSX.CellRef("A2"), "", "", "3", "") XLSX.Cell(XLSX.CellRef("C2"), "", "", "5", "")] + @test_throws XLSX.XLSXError XLSX.getcell(f, "Sheet1!garbage") + @test_throws XLSX.XLSXError XLSX.getcell(s, "Sheet1!garbage") + @test_throws XLSX.XLSXError XLSX.getcell(s, "garbage") + @test_throws XLSX.XLSXError XLSX.getcell(s, "garbage1:garbage2") + + XLSX.addDefinedName(f, "MyName1", "Sheet1!A1") + XLSX.addDefinedName(s, "MyName2", "Sheet1!A2:A3") + s["MyName1"] = 12.9 + @test s["MyName1"] == 12.9 + s["MyName2"] = 42 + @test s["MyName2"] == [42; 42;;] + @test XLSX.getcell(s, "MyName1") == XLSX.Cell(XLSX.CellRef("A1"), "", "", "12.9", "") + @test XLSX.getcell(s, "MyName2") == [XLSX.Cell(XLSX.CellRef("A2"), "", "", "42", ""); XLSX.Cell(XLSX.CellRef("A3"), "", "", "42", "");;] + end @testset "Time and DateTime" begin @@ -408,7 +602,7 @@ end end end -@testset "Defined Names" begin +@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) @@ -416,28 +610,98 @@ end @test !XLSX.is_defined_name_value_a_reference("Hey") @test !XLSX.is_defined_name_value_a_reference(missing) - XLSX.openxlsx(joinpath(data_directory, "general.xlsx")) do f - @test f["SINGLE_CELL"] == "single cell A2" - @test f["RANGE_B4C5"] == Any["range B4:C5" "range B4:C5"; "range B4:C5" "range B4:C5"] - @test f["CONST_DATE"] == 43383 - @test isapprox(f["CONST_FLOAT"], 10.2) - @test f["CONST_INT"] == 100 - @test f["LOCAL_INT"] == 2000 - @test f["named_ranges_2"]["LOCAL_INT"] == 2000 - @test f["named_ranges"]["LOCAL_INT"] == 1000 - @test f["named_ranges"]["LOCAL_NAME"] == "Hey You" - @test f["named_ranges_2"]["LOCAL_NAME"] == "out there in the cold" - @test f["named_ranges"]["SINGLE_CELL"] == "single cell A2" - - @test_throws ErrorException f["header_error"]["LOCAL_REF"] - @test f["named_ranges"]["LOCAL_REF"][1] == 10 - @test f["named_ranges"]["LOCAL_REF"][2] == 20 - @test f["named_ranges_2"]["LOCAL_REF"][1] == "local" - @test f["named_ranges_2"]["LOCAL_REF"][2] == "reference" - end + f = XLSX.opentemplate(joinpath(data_directory, "general.xlsx")) + @test f["SINGLE_CELL"] == "single cell A2" + @test f["RANGE_B4C5"] == Any["range B4:C5" "range B4:C5"; "range B4:C5" "range B4:C5"] + @test f["CONST_DATE"] == 43383 + @test isapprox(f["CONST_FLOAT"], 10.2) + @test f["CONST_INT"] == 100 + @test f["LOCAL_INT"] == 2000 + @test f["named_ranges_2"]["LOCAL_INT"] == 2000 + @test f["named_ranges"]["LOCAL_INT"] == 1000 + @test f["named_ranges"]["LOCAL_NAME"] == "Hey You" + @test f["named_ranges_2"]["LOCAL_NAME"] == "out there in the cold" + @test f["named_ranges"]["SINGLE_CELL"] == "single cell A2" + + @test_throws XLSX.XLSXError f["header_error"]["LOCAL_REF"] + @test f["named_ranges"]["LOCAL_REF"][1] == 10 + @test f["named_ranges"]["LOCAL_REF"][2] == 20 + @test f["named_ranges_2"]["LOCAL_REF"][1] == "local" + @test f["named_ranges_2"]["LOCAL_REF"][2] == "reference" + + XLSX.addDefinedName(f["lookup"], "Life_the_Universe_and_Everything", 42) + XLSX.addDefinedName(f["lookup"], "FirstName", "Hello World") + XLSX.addDefinedName(f["lookup"], "single", "C2"; absolute=true) + XLSX.addDefinedName(f["lookup"], "range", "C3:C5"; absolute=true) + XLSX.addDefinedName(f["lookup"], "NonContig", "C3:C5,D3:D5"; absolute=true) + @test f["lookup"]["Life_the_Universe_and_Everything"] == 42 + @test f["lookup"]["FirstName"] == "Hello World" + @test f["lookup"]["single"] == "NAME" + @test f["lookup"]["range"] == Any["name1"; "name2"; "name3";;] # A 2D Array, size (3, 1) + @test f["lookup"]["NonContig"] == Any["name1", "name2", "name3", 100, 200, 300] # NonContiguousRanges return a vector + + XLSX.addDefinedName(f, "Life_the_Universe_and_Everything", 42) + XLSX.addDefinedName(f, "FirstName", "Hello World") + XLSX.addDefinedName(f, "single", "lookup!C2"; absolute=true) + XLSX.addDefinedName(f, "range", "lookup!C3:C5"; absolute=true) + XLSX.addDefinedName(f, "NonContig", "lookup!C3:C5,lookup!D3:D5"; absolute=true) + @test f["Life_the_Universe_and_Everything"] == 42 + @test f["FirstName"] == "Hello World" + @test f["single"] == "NAME" + @test f["range"] == Any["name1"; "name2"; "name3";;] # A 2D Array, size (3, 1) + @test f["NonContig"] == Any["name1", "name2", "name3", 100, 200, 300] # NonContiguousRanges return a vector + + XLSX.setFont(f["lookup"], "NonContig"; name="Arial", size=12, color="FF0000FF", bold=true, italic=true, under="single", strike=true) + @test XLSX.getFont(f["lookup"], "C3").font == Dict("i" => nothing, "b" => nothing, "u" => nothing, "strike" => nothing, "sz" => Dict("val" => "12"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + @test XLSX.getFont(f["lookup"], "C4").font == Dict("i" => nothing, "b" => nothing, "u" => nothing, "strike" => nothing, "sz" => Dict("val" => "12"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + @test XLSX.getFont(f["lookup"], "C5").font == Dict("i" => nothing, "b" => nothing, "u" => nothing, "strike" => nothing, "sz" => Dict("val" => "12"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + @test XLSX.getFont(f["lookup"], "D3").font == Dict("i" => nothing, "b" => nothing, "u" => nothing, "strike" => nothing, "sz" => Dict("val" => "12"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + @test XLSX.getFont(f["lookup"], "D4").font == Dict("i" => nothing, "b" => nothing, "u" => nothing, "strike" => nothing, "sz" => Dict("val" => "12"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + @test XLSX.getFont(f["lookup"], "D5").font == Dict("i" => nothing, "b" => nothing, "u" => nothing, "strike" => nothing, "sz" => Dict("val" => "12"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + XLSX.setFont(f, "single"; name="Arial", size=12, color="FF0000FF", bold=true, italic=true, under="double", strike=true) + @test XLSX.getFont(f["lookup"], "C2").font == Dict("i" => nothing, "b" => nothing, "u" => Dict("val" => "double"), "strike" => nothing, "sz" => Dict("val" => "12"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + + XLSX.writexlsx("mytest.xlsx", f, overwrite=true) + + f = XLSX.readxlsx("mytest.xlsx") + @test f["Life_the_Universe_and_Everything"] == 42 + @test f["FirstName"] == "Hello World" + @test f["single"] == "NAME" + @test f["range"] == Any["name1"; "name2"; "name3";;] # A 2D Array, size (3, 1) + @test f["NonContig"] == Any["name1", "name2", "name3", 100, 200, 300] # NonContiguousRanges return a vector + isfile("mytest.xlsx") && rm("mytest.xlsx") @test XLSX.readdata(joinpath(data_directory, "general.xlsx"), "SINGLE_CELL") == "single cell A2" @test XLSX.readdata(joinpath(data_directory, "general.xlsx"), "RANGE_B4C5") == Any["range B4:C5" "range B4:C5"; "range B4:C5" "range B4:C5"] + + f = XLSX.newxlsx() + s = f[1] + s["A1:B3"] = "Hello world" + XLSX.addDefinedName(f, "Life_the_Universe_and_Everything", 42) + XLSX.addDefinedName(f[1], "FirstName", "Hello World") + XLSX.addDefinedName(f, "MyCell", "Sheet1!A1") + XLSX.addDefinedName(f[1], "YourCells", "Sheet1!A2:B3") + @test_throws XLSX.XLSXError XLSX.addDefinedName(s, "yourcells", "Sheet1!A2:B3") # not unique (case insensitive) + @test_throws XLSX.XLSXError XLSX.addDefinedName(s, "firstname", "NewText") # not unique (case insensitive) + @test_throws XLSX.XLSXError s["FirstName"] = 32 + s["MyCell"] = true + @test s["MyCell"] == true + s["YourCells"] = false + @test s["YourCells"] == Any[false false; false false] + + XLSX.writexlsx("mytest.xlsx", f, overwrite=true) + f = XLSX.readxlsx("mytest.xlsx") + @test s["MyCell"] == true + @test s["YourCells"] == Any[false false; false false] + isfile("mytest.xlsx") && rm("mytest.xlsx") + + @test_throws XLSX.XLSXError XLSX.addDefinedName(f, "A1", "Sheet1!B1") + @test_throws XLSX.XLSXError XLSX.addDefinedName(f, "A1:A3", "Sheet1!B2:B3") + @test_throws XLSX.XLSXError XLSX.addDefinedName(f, "A1,A3", 42) + @test_throws XLSX.XLSXError XLSX.addDefinedName(s, "Sheet1!A1", "Sheet1!B1") + @test_throws XLSX.XLSXError XLSX.addDefinedName(s, "Sheet1!A1:A3", "Sheet1!B2:B3") + @test_throws XLSX.XLSXError XLSX.addDefinedName(s, "Sheet1!A1,Sheet!A3", 42) + end @testset "Book1.xlsx" begin @@ -530,19 +794,90 @@ end @test XLSX.column_number(emptycell) == 2 end +@testset "No Dimension" begin + noDim = XLSX.readxlsx(joinpath(data_directory, "NoDim.xlsx")) # These two files are the same except the `NoDim` file has the dimension nodes removed. + 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")) + +end + @testset "Column Range" begin cr = XLSX.ColumnRange("B:D") @test string(cr) == "B:D" @test cr.start == 2 @test cr.stop == 4 @test length(cr) == 3 - @test_throws AssertionError XLSX.ColumnRange("B1:D3") - @test_throws AssertionError XLSX.ColumnRange("D:A") + @test_throws XLSX.XLSXError XLSX.ColumnRange("B1:D3") + @test_throws XLSX.XLSXError XLSX.ColumnRange("D:A") @test collect(cr) == ["B", "C", "D"] @test XLSX.ColumnRange("B:D") == XLSX.ColumnRange("B:D") @test hash(XLSX.ColumnRange("B:D")) == hash(XLSX.ColumnRange("B:D")) end +@testset "Row Range" begin # Issue #150 + cr = XLSX.RowRange("2:5") + @test string(cr) == "2:5" + @test cr.start == 2 + @test cr.stop == 5 + @test length(cr) == 4 + @test collect(cr) == ["2", "3", "4", "5"] + + cr = XLSX.RowRange("2") + @test string(cr) == "2:2" + @test cr.start == 2 + @test cr.stop == 2 + @test length(cr) == 1 + @test collect(cr) == ["2"] + + @test_throws XLSX.XLSXError XLSX.RowRange("B1:D3") + @test_throws XLSX.XLSXError XLSX.RowRange("5:2") + @test XLSX.RowRange("2:5") == XLSX.RowRange("2:5") + @test hash(XLSX.RowRange("2:5")) == hash(XLSX.RowRange("2:5")) +end + +@testset "Non-contiguous Range" begin + cr = XLSX.NonContiguousRange("Sheet1!D1:D3,Sheet1!B1:B3") + @test string(cr) == "Sheet1!D1:D3,Sheet1!B1:B3" + @test cr.sheet == "Sheet1" + @test cr.rng == [XLSX.CellRange("D1:D3"), XLSX.CellRange("B1:B3")] + @test length(cr) == 6 + @test length(XLSX.NonContiguousRange("Sheet1!B1:B1,Sheet1!B1")) == 1 + @test collect(cr.rng) == [XLSX.CellRange("D1:D3"), XLSX.CellRange("B1:B3")] + @test XLSX.NonContiguousRange("Sheet1!D1:D3,Sheet1!B1:B3") == XLSX.NonContiguousRange("Sheet1!D1:D3,Sheet1!B1:B3") + @test hash(XLSX.NonContiguousRange("Sheet1!D1:D3,Sheet1!B1:B3")) == hash(XLSX.NonContiguousRange("Sheet1!D1:D3,Sheet1!B1:B3")) + + f = XLSX.newxlsx("Sheet 1") + s = f["Sheet 1"] + for cell in XLSX.CellRange("A1:D6") + s[cell] = "" + end + cr = XLSX.NonContiguousRange(s, "D1:D3,A2,B1:B3") + @test string(cr) == "'Sheet 1'!D1:D3,'Sheet 1'!A2,'Sheet 1'!B1:B3" + @test cr.sheet == "Sheet 1" + @test cr.rng == [XLSX.CellRange("D1:D3"), XLSX.CellRef("A2"), XLSX.CellRange("B1:B3")] + @test length(cr) == 7 + @test length(XLSX.NonContiguousRange(s, "B1:B1,B1")) == 1 + @test collect(cr.rng) == [XLSX.CellRange("D1:D3"), XLSX.CellRef("A2"), XLSX.CellRange("B1:B3")] + @test XLSX.NonContiguousRange(s, "D1:D3,A2,B1:B3") == XLSX.NonContiguousRange(s, "D1:D3,A2,B1:B3") + @test hash(XLSX.NonContiguousRange(s, "D1:D3,A2,B1:B3")) == hash(XLSX.NonContiguousRange(s, "D1:D3,A2,B1:B3")) + + @test_throws XLSX.XLSXError XLSX.NonContiguousRange("Sheet1!D1:D3,B1:B3") + @test_throws XLSX.XLSXError XLSX.NonContiguousRange("Sheet1!D1:D3,Sheet2!B1:B3") + @test_throws XLSX.XLSXError XLSX.NonContiguousRange("B1:D3") + @test_throws XLSX.XLSXError XLSX.NonContiguousRange("2:5") +end + @testset "CellRange iterator" begin 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")] @@ -618,7 +953,7 @@ end @test XLSX.last_column_index(r, 2) == 4 @test XLSX.last_column_index(r, 3) == 4 @test XLSX.last_column_index(r, 4) == 4 - @test_throws AssertionError XLSX.last_column_index(r, 5) + @test_throws XLSX.XLSXError XLSX.last_column_index(r, 5) elseif XLSX.row_number(r) == 9 @test XLSX.last_column_index(r, 2) == 3 @test XLSX.last_column_index(r, 3) == 3 @@ -731,35 +1066,35 @@ end # queries based on ColumnRange x = XLSX.getcellrange(s, XLSX.ColumnRange("B:D")) - @test size(x) == (11, 3) + @test size(x) == (12, 3) y = XLSX.getcellrange(s, "B:D") - @test size(y) == (11, 3) + @test size(y) == (12, 3) @test x == y - @test_throws AssertionError XLSX.getcellrange(s, "D:B") - @test_throws ErrorException XLSX.getcellrange(s, "A:C1") + @test_throws XLSX.XLSXError XLSX.getcellrange(s, "D:B") + @test_throws XLSX.XLSXError XLSX.getcellrange(s, "A:C1") d = XLSX.getdata(s, "B:D") - @test size(d) == (11, 3) - @test_throws ErrorException XLSX.getdata(s, "A:C1") + @test size(d) == (12, 3) + @test_throws XLSX.XLSXError XLSX.getdata(s, "A:C1") @test d[1, 1] == "Column B" @test d[1, 2] == "Column C" @test d[1, 3] == "Column D" @test d[9, 1] == 8 @test d[9, 2] == "Str2" @test d[9, 3] == Date(2018, 4, 28) - @test d[10, 1] == "trash" - @test ismissing(d[10, 2]) - @test d[10, 3] == "trash" - @test ismissing(d[11, 1]) + @test d[11, 1] == "trash" @test ismissing(d[11, 2]) - @test ismissing(d[11, 3]) + @test d[11, 3] == "trash" + @test ismissing(d[12, 1]) + @test ismissing(d[12, 2]) + @test ismissing(d[12, 3]) d2 = f["table!B:D"] @test size(d) == size(d2) @test all(d .=== d2) - @test_throws ErrorException f["table!B1:D"] - @test_throws AssertionError f["table!D:B"] + @test_throws XLSX.XLSXError f["table!B1:D"] + @test_throws XLSX.XLSXError f["table!D:B"] s = f["table2"] test_data = Vector{Any}(undef, 3) @@ -787,7 +1122,7 @@ end @test XLSX.get_column_label(rowdata, 2) == :HB @test XLSX.get_column_label(rowdata, 3) == :HC - @test_throws ErrorException XLSX.getdata(rowdata, :INVALID_COLUMN) + @test_throws XLSX.XLSXError XLSX.getdata(rowdata, :INVALID_COLUMN) end override_col_names_strs = ["ColumnA", "ColumnB", "ColumnC"] @@ -844,7 +1179,7 @@ end data, col_names = dtable.data, dtable.column_labels @test col_names == [:H1, :H2, :H3] check_test_data(data, test_data) - @test_throws ErrorException XLSX.find_row(XLSX.eachrow(s), 20) + @test_throws XLSX.XLSXError XLSX.find_row(XLSX.eachrow(s), 20) for r in XLSX.eachrow(s) @test isempty(XLSX.getcell(r, "A")) @@ -855,7 +1190,7 @@ end end @test XLSX._find_first_row_with_data(s, 5) == 5 - @test_throws ErrorException XLSX._find_first_row_with_data(s, 7) + @test_throws XLSX.XLSXError XLSX._find_first_row_with_data(s, 7) s = f["table4"] dtable = XLSX.gettable(s) @@ -866,10 +1201,10 @@ end @testset "empty/invalid" begin XLSX.openxlsx(joinpath(data_directory, "general.xlsx")) do xf empty_sheet = XLSX.getsheet(xf, "empty") - @test_throws ErrorException XLSX.gettable(empty_sheet) + @test_throws XLSX.XLSXError XLSX.gettable(empty_sheet) itr = XLSX.eachrow(empty_sheet) - @test_throws ErrorException XLSX.find_row(itr, 1) - @test_throws ErrorException XLSX.getsheet(xf, "invalid_sheet") + @test_throws XLSX.XLSXError XLSX.find_row(itr, 1) + @test_throws XLSX.XLSXError XLSX.getsheet(xf, "invalid_sheet") end end @@ -970,6 +1305,53 @@ end test_data = Any[Any["C3", missing], Any[missing, "D4"]] check_test_data(data, test_data) end + + @testset "Read DataFrame" begin + + df = XLSX.readto(joinpath(data_directory, "general.xlsx"), "table4", "F:G", DataFrames.DataFrame) + @test names(df) == ["H2", "H3"] + @test size(df) == (2, 2) + @test df[1, :H2] == "C3" + @test df[2, :H3] == "D4" + @test ismissing(df[1, 2]) + @test ismissing(df[2, 1]) + + df = XLSX.readto(joinpath(data_directory, "general.xlsx"), "table4", DataFrames.DataFrame) + @test names(df) == ["H1", "H2", "H3"] + @test size(df) == (3, 3) + @test df[1, :H2] == "C3" + @test df[2, :H3] == "D4" + @test ismissing(df[1, :H1]) + @test ismissing(df[2, :H2]) + + df = XLSX.readto(joinpath(data_directory, "general.xlsx"), DataFrames.DataFrame) + @test names(df) == ["text", "regular text"] + @test size(df) == (9, 2) + @test df[1, "text"] == "integer" + @test df[2, "regular text"] == 102.2 + @test df[3, 2] == Dates.Date(1983, 04, 16) + @test df[5, 2] == Dates.DateTime(2018, 04, 16, 19, 19, 51) + + @test_throws XLSX.XLSXError df = XLSX.readto(joinpath(data_directory, "general.xlsx")) # No sink + @test_throws XLSX.XLSXError df = XLSX.readto(joinpath(data_directory, "general.xlsx"), 3) # No sink + @test_throws XLSX.XLSXError df = XLSX.readto(joinpath(data_directory, "general.xlsx"), 3, "F:G") # No sink + + end + + @testset "normalizenames" begin # Issue #260 + + data = Vector{Any}() + push!(data, [:sym1, :sym2, :sym3]) + push!(data, [1.0, 2.0, 3.0]) + push!(data, ["abc", "DeF", "gHi"]) + push!(data, [true, true, false]) + cols = ["1 col", "col \$2", "local", "col:4"] + + XLSX.writetable("mytest.xlsx", data, cols; overwrite=true) + df = DataFrames.DataFrame(XLSX.readtable("mytest.xlsx", "Sheet1", normalizenames=true)) + @test DataFrames.names(df) == Any["_1_col", "col_2", "_local", "col_4"] + + end end @testset "Write" begin @@ -998,6 +1380,25 @@ end isfile(filename_copy) && rm(filename_copy) end +@testset "Save" begin + f=XLSX.openxlsx("saveable.xlsx", mode="w") + XLSX.rename!(f["Sheet1"], "new_name") + s=f[1] + s[1:10, 1:10] = "hello world" + @test XLSX.savexlsx(f) == abspath("saveable.xlsx") + f2 = XLSX.openxlsx("saveable.xlsx", mode="rw") + @test XLSX.hassheet(f2, "new_name") + @test f2["new_name"][1, 1] == "hello world" + @test f2["new_name"][10, 10] == "hello world" + f2["new_name"][1:5, 1:5] = "goodbye world" + XLSX.savexlsx(f2) + f3 = XLSX.openxlsx("saveable.xlsx", mode="r") + @test f3["new_name"][1, 1] == "goodbye world" + @test f3["new_name"][5, 5] == "goodbye world" + @test f3["new_name"][10, 10] == "hello world" + isfile("saveable.xlsx") && rm("saveable.xlsx") +end + @testset "CustomXml" begin # issue #210 # None of the example .xlsx files in the test suite include custoimXml internal files @@ -1011,7 +1412,7 @@ end sheet["Q3"] = "this" sheet["Q4"] = "template" end - @test XLSX.writexlsx(filename_copy, template, overwrite=true) === nothing # This is where the bug will throw if custoimXml internal files present. + @test XLSX.writexlsx(filename_copy, template, overwrite=true) == abspath(filename_copy) # This is where the bug will throw if customXml internal files present. @test isfile(filename_copy) f_copy = XLSX.readxlsx(filename_copy) # Don't really think this second part is necessary. test_Xmlread = [["Cant", "write", "this", "template"]] @@ -1038,15 +1439,33 @@ end rm(new_filename) end -@testset "addsheet!" begin - new_filename = "template_with_new_sheet.xlsx" - f = XLSX.open_empty_template() - s = XLSX.addsheet!(f, "new_sheet") - s["A1"] = 10 +@testset "add/copy sheet!" begin + + @testset "addsheet!" 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) + - @testset "check invalid sheet names" begin + big_sheetname = "aaaaaaaaaabbbbbbbbbbccccccccccd" + s2 = XLSX.addsheet!(f, big_sheetname) + + XLSX.writexlsx(new_filename, f, overwrite=true) + fx = XLSX.opentemplate(new_filename) + @test XLSX.sheetnames(f) == ["Sheet1", "new_sheet", big_sheetname] + + end + + @testset "invalid sheet names" begin + + f = XLSX.open_empty_template() + s = XLSX.addsheet!(f, "new_sheet") + s["A1"] = 10 invalid_names = [ - "new_sheet", "aaaaaaaaaabbbbbbbbbbccccccccccd1", "abc:def", "abcdef/", @@ -1057,28 +1476,122 @@ end ] for invalid_name in invalid_names - @test_throws AssertionError XLSX.addsheet!(f, invalid_name) + @test_throws XLSX.XLSXError XLSX.addsheet!(f, invalid_name) end + end - big_sheetname = "aaaaaaaaaabbbbbbbbbbccccccccccd" - s2 = XLSX.addsheet!(f, big_sheetname) + @testset "copysheet!" begin - XLSX.writexlsx(new_filename, f, overwrite=true) + f=XLSX.newxlsx() + XLSX.rename!(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) + 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 - # @test !XLSX.isopen(f) + end + isfile("copied_sheets.xlsx") && rm("copied_sheets.xlsx") + + @testset "deletesheet!" begin + + 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) + 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) + + 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) + 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) + 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") + + end + + isfile("template_with_new_sheet.xlsx") && rm("template_with_new_sheet.xlsx") - f = XLSX.readxlsx(new_filename) - @test XLSX.sheetnames(f) == ["Sheet1", "new_sheet", big_sheetname] - rm(new_filename) end @testset "Edit" begin f = XLSX.open_xlsx_template(joinpath(data_directory, "general.xlsx")) s = f["general"] - @test_throws ErrorException s["A1"] = :sym + @test_throws XLSX.XLSXError s["A1"] = :sym XLSX.rename!(s, "general") # no-op - @test_throws AssertionError XLSX.rename!(s, "table") # name is taken + @test_throws XLSX.XLSXError XLSX.rename!(s, "table") # name is taken XLSX.rename!(s, "renamed_sheet") @test s.name == "renamed_sheet" s["A1"] = "Hey You!" @@ -1139,7 +1652,7 @@ end dtable = XLSX.readtable("output_table.xlsx", "report") read_data, read_column_names = dtable.data, dtable.column_labels @test length(read_column_names) == length(col_names) - for c in 1:length(col_names) + for c in axes(col_names, 1) @test Symbol(col_names[c]) == read_column_names[c] end check_test_data(read_data, data) @@ -1170,7 +1683,6 @@ end @test labels[1] == :COLUMN_A @test labels[2] == :COLUMN_B check_test_data(data, report_2_data) - XLSX.writetable("output_tables.xlsx", [("REPORT_A", report_1_data, report_1_column_names), ("REPORT_B", report_2_data, report_2_column_names)], overwrite=true) dtable = XLSX.readtable("output_tables.xlsx", "REPORT_A") @@ -1187,7 +1699,6 @@ end report_1_column_names = [:HEADER_A, :HEADER_B] report_2_column_names = [:COLUMN_A, :COLUMN_B] - XLSX.writetable("output_tables.xlsx", [("REPORT_A", report_1_data, report_1_column_names), ("REPORT_B", report_2_data, report_2_column_names)], overwrite=true) dtable = XLSX.readtable("output_tables.xlsx", "REPORT_A") @@ -1209,7 +1720,6 @@ end report_2_data = Vector{Any}(undef, 2) report_2_data[1] = [Date(2017, 2, 1), Date(2018, 2, 1)] report_2_data[2] = [10.2, 10.3] - XLSX.writetable("output_tables.xlsx", [("REPORT_A", report_1_data, report_1_column_names), ("REPORT_B", report_2_data, report_2_column_names)], overwrite=true) dtable = XLSX.readtable("output_tables.xlsx", "REPORT_A") @@ -1236,14 +1746,39 @@ end @test dt_read.column_label_index == dt.column_label_index end + @testset "extended types" begin # Issue #239 + @enum enums begin + enum1 + enum2 + enum3 + end + + data = Vector{Any}() + push!(data, [:sym1, :sym2, :sym3]) + push!(data, [1.0, 2.0, 3.0]) + push!(data, ["abc", "DeF", "gHi"]) + push!(data, [true, true, false]) + push!(data, [XLSX.CellRef("A1"), XLSX.CellRef("B2"), XLSX.CellRef("CCC34000")]) + push!(data, collect(instances(enums))) + cols = [string(eltype(x)) for x in data] + + XLSX.writetable("mytest.xlsx", data, cols; overwrite=true) + + f = XLSX.readxlsx("mytest.xlsx") + @test f[1]["A1"] == "Symbol" + @test f[1]["A1:A4"] == Any["Symbol"; "sym1"; "sym2"; "sym3";;] # A 2D Array, size (4, 1) + @test f[1]["A1"] == "Symbol" + @test f[1]["E1:E4"] == Any["XLSX.CellRef"; "A1"; "B2"; "CCC34000";;] + end + # delete files created by this testset - delete_files = ["output_table.xlsx", "output_tables.xlsx"] + delete_files = ["output_table.xlsx", "output_tables.xlsx", "mytest.xlsx"] for f in delete_files isfile(f) && rm(f) end end -@testset "Styles" verbose = true begin +@testset "Styles" begin @testset "Original" begin using XLSX: CellValue, id, getcell, setdata!, CellRef @@ -1462,6 +1997,49 @@ end end @testset "setFont" begin + + f = XLSX.newxlsx() + s = f[1] + s["A1:B2,D1:E2"] = "" + + XLSX.setFont(s, "A1:A2"; bold=true, italic=true, size=24, name="Arial") + XLSX.setFont(s, "B1:B2"; bold=true, italic=false, size=14, name="Aptos") + XLSX.setFont(s, "D1:D2"; bold=false, italic=true, size=34, name="Berlin Sans FB Demi") + XLSX.setFont(s, "E1:E2"; bold=false, italic=false, size=4, name="Times New Roman") + XLSX.setUniformFont(s, "A1:B2,D1:E2"; color="blue") # `setUniformAttribute()` on a non-contiguous range + @test XLSX.getFont(s, "A1").font == Dict("b" => nothing, "i" => nothing, "sz" => Dict("val" => "24"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + @test XLSX.getFont(s, "B2").font == Dict("b" => nothing, "i" => nothing, "sz" => Dict("val" => "24"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + @test XLSX.getFont(s, "D1").font == Dict("b" => nothing, "i" => nothing, "sz" => Dict("val" => "24"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + @test XLSX.getFont(s, "E2").font == Dict("b" => nothing, "i" => nothing, "sz" => Dict("val" => "24"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + + XLSX.setFont(s, "Sheet1!A1:A2"; bold=true, italic=true, size=24, name="Arial", color="blue") + @test XLSX.getFont(s, "A1").font == Dict("b" => nothing, "i" => nothing, "sz" => Dict("val" => "24"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF0000FF")) + XLSX.setFont(s, "Sheet1!Y:Z"; bold=true, italic=false, size=14, name="Aptos", color="blue") + @test XLSX.getFont(s, "Y20").font == Dict("b" => nothing, "sz" => Dict("val" => "14"), "name" => Dict("val" => "Aptos"), "color" => Dict("rgb" => "FF0000FF")) + XLSX.setFont(s, "Sheet1!2:3"; bold=false, italic=true, size=34, name="Berlin Sans FB Demi", color="blue") + @test XLSX.getFont(s, "M3").font == Dict("i" => nothing, "sz" => Dict("val" => "34"), "name" => Dict("val" => "Berlin Sans FB Demi"), "color" => Dict("rgb" => "FF0000FF")) + XLSX.setFont(f, "Sheet1!A1:A2"; bold=false, italic=false, size=14, name="Aptos", color="green") + @test XLSX.getFont(s, "A1").font == Dict("sz" => Dict("val" => "14"), "name" => Dict("val" => "Aptos"), "color" => Dict("rgb" => "FF008000")) + XLSX.setFont(f, "Sheet1!Y:Z"; bold=false, italic=true, size=24, name="Arial", color="green") + @test XLSX.getFont(s, "Y20").font == Dict("i" => nothing, "sz" => Dict("val" => "24"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF008000")) + XLSX.setFont(f, "Sheet1!2:3"; bold=true, italic=false, size=24, name="Times New Roman", color="green") + @test XLSX.getFont(s, "M3").font == Dict("b" => nothing, "sz" => Dict("val" => "24"), "name" => Dict("val" => "Times New Roman"), "color" => Dict("rgb" => "FF008000")) + XLSX.setFont(s, "E1,E2,G2:G4"; bold=false, italic=false, size=4, name="Times New Roman", color="blue") + @test XLSX.getFont(s, "G3").font == Dict("sz" => Dict("val" => "4"), "name" => Dict("val" => "Times New Roman"), "color" => Dict("rgb" => "FF0000FF")) + XLSX.setFont(s, :, 15:16; bold=true, italic=false, size=38, name="Wingdings", color="red") + @test XLSX.getFont(s, "P10").font == Dict("b" => nothing, "sz" => Dict("val" => "38"), "name" => Dict("val" => "Wingdings"), "color" => Dict("rgb" => "FFFF0000")) + XLSX.setFont(s, 15:16, :; bold=false, italic=true, size=8, name="Wingdings", color="red") + @test XLSX.getFont(f, "Sheet1!T16").font == Dict("i" => nothing, "sz" => Dict("val" => "8"), "name" => Dict("val" => "Wingdings"), "color" => Dict("rgb" => "FFFF0000")) + XLSX.setFont(s, [20, 22, 24], :; bold=false, italic=true, size=48, name="Aptos", color="red") + @test XLSX.getFont(f, "Sheet1!H22").font == Dict("i" => nothing, "sz" => Dict("val" => "48"), "name" => Dict("val" => "Aptos"), "color" => Dict("rgb" => "FFFF0000")) + XLSX.setUniformFont(s, [15, 16, 20, 22, 24], :; bold=false, italic=true, size=28, name="Aptos", color="red") + @test XLSX.getFont(f, "Sheet1!H15").font == Dict("i" => nothing, "sz" => Dict("val" => "28"), "name" => Dict("val" => "Aptos"), "color" => Dict("rgb" => "FFFF0000")) + @test XLSX.getFont(f, "Sheet1!H22").font == Dict("i" => nothing, "sz" => Dict("val" => "28"), "name" => Dict("val" => "Aptos"), "color" => Dict("rgb" => "FFFF0000")) + xfile = XLSX.open_empty_template() wb = XLSX.get_workbook(xfile) sheet = xfile["Sheet1"] @@ -1491,6 +2069,9 @@ end dcolorkey = collect(keys(default_font["color"]))[1] dcolorval = collect(values(default_font["color"]))[1] + # Sheet mismatch + @test_throws XLSX.XLSXError XLSX.setFont(sheet, "S2!A1"; bold=true, size=24, name="Arial") + XLSX.setFont(sheet, "A1"; bold=true, size=24, name="Arial") @test XLSX.getFont(sheet, "A1").font == Dict("b" => nothing, "sz" => Dict("val" => "24"), "name" => Dict("val" => "Arial"), "color" => Dict(dcolorkey => dcolorval)) XLSX.setFont(sheet, "A1"; size=18) @@ -1559,12 +2140,74 @@ end @test XLSX.getFont(xfile, "Sheet1!B4").font == Dict("sz" => Dict("val" => "12"), "name" => Dict("val" => "Times New Roman"), "color" => Dict("rgb" => "FF040404")) isfile("output.xlsx") && rm("output.xlsx") + + f = XLSX.newxlsx() + sheet = f[1] + sheet["A1:E5"] = "" + XLSX.setFont(sheet, :, [1, 2, 3, 4, 5]; size=18, name="Arial", color="FF040404") + XLSX.setFont(sheet, 1:3, [1, 3]; size=12, name="Aptos", color="FF040408") + XLSX.setFont(sheet, [4, 5], [2, 4]; size=6, name="Courier New", color="FF040400") + @test XLSX.getFont(sheet, "A4").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF040404")) + @test XLSX.getFont(sheet, "A3").font == Dict("sz" => Dict("val" => "12"), "name" => Dict("val" => "Aptos"), "color" => Dict("rgb" => "FF040408")) + @test XLSX.getFont(f, "Sheet1!D5").font == Dict("sz" => Dict("val" => "6"), "name" => Dict("val" => "Courier New"), "color" => Dict("rgb" => "FF040400")) + @test_throws XLSX.XLSXError XLSX.setFont(sheet, "1:10"; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setFont(sheet, "A:K"; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setFont(f, "Sheet1!garbage"; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setFont(sheet, "Sheet1!garbage"; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setFont(sheet, "garbage"; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setFont(sheet, "garbage1:garbage2"; size=18, name="Arial", color="FF040404") + + f = XLSX.newxlsx() + sheet = f[1] + sheet["A1:E5"] = "" + XLSX.setUniformFont(sheet, "Sheet1!A1:E1"; size=18, name="Arial", color="FF040404") + @test XLSX.getFont(sheet, "D1").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF040404")) + XLSX.setUniformFont(sheet, "Sheet1!2:3"; size=18, name="Arial", color="FF040408") + @test XLSX.getFont(sheet, "A3").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF040408")) + XLSX.setUniformFont(sheet, "Sheet1!D:E"; size=18, name="Arial", color="FF040400") + @test XLSX.getFont(sheet, "E5").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF040400")) + XLSX.setUniformFont(sheet, "A1:E1"; size=18, name="Arial", color="FF040304") + @test XLSX.getFont(sheet, "D1").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF040304")) + XLSX.setUniformFont(sheet, "2:3"; size=18, name="Arial", color="FF040308") + @test XLSX.getFont(sheet, "A3").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF040308")) + XLSX.setUniformFont(sheet, "D:E"; size=18, name="Arial", color="FF040300") + @test XLSX.getFont(sheet, "E5").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF040300")) + + f = XLSX.newxlsx() + sheet = f[1] + sheet["A1:E5"] = "" + XLSX.setUniformFont(sheet, :, 1; size=18, name="Arial", color="FF040404") + @test XLSX.getFont(sheet, "A3").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF040404")) + XLSX.setUniformFont(sheet, :, [2, 3]; size=18, name="Arial", color="FF040400") + @test XLSX.getFont(sheet, "C4").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF040400")) + XLSX.setUniformFont(sheet, [1, 3, 4], 5; size=18, name="Arial", color="FF040300") + @test XLSX.getFont(sheet, "E1").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF040300")) + XLSX.setUniformFont(sheet, 5, [3, 4]; size=18, name="Arial", color="FF030300") + @test XLSX.getFont(sheet, "D5").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF030300")) + XLSX.setUniformFont(sheet, [2, 3, 4], [3, 4]; size=18, name="Arial", color="FF030308") + @test XLSX.getFont(sheet, "C3").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF030308")) + XLSX.setUniformFont(sheet, 4:5, 4; size=18, name="Arial", color="FF030408") + @test XLSX.getFont(sheet, "D4").font == Dict("sz" => Dict("val" => "18"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF030408")) + XLSX.setUniformFont(sheet, :; size=8, name="Arial", color="FF030408") + @test XLSX.getFont(sheet, "D4").font == Dict("sz" => Dict("val" => "8"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF030408")) + XLSX.setUniformFont(sheet, :, :; size=28, name="Arial", color="FF030408") + @test XLSX.getFont(sheet, "D4").font == Dict("sz" => Dict("val" => "28"), "name" => Dict("val" => "Arial"), "color" => Dict("rgb" => "FF030408")) + @test_throws XLSX.XLSXError XLSX.setUniformFont(sheet, :, [1, 3, 10, 15]; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setUniformFont(sheet, [1, 3, 10, 15], :; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setUniformFont(sheet, 1, [1, 3, 10, 15]; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setUniformFont(sheet, [1, 3, 10, 15], 2:3; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setUniformFont(f, "Sheet1!garbage"; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setUniformFont(sheet, "Sheet1!garbage"; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setUniformFont(sheet, "garbage"; size=18, name="Arial", color="FF040404") + @test_throws XLSX.XLSXError XLSX.setUniformFont(sheet, "garbage1:garbage2"; size=18, name="Arial", color="FF040404") end @testset "setBorder" begin f = XLSX.open_xlsx_template(joinpath(data_directory, "Borders.xlsx")) s = f["Sheet1"] + @test XLSX.getDefaultBorders(s).border == Dict("left" => nothing, "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test isnothing(XLSX.getBorder(s, "A1")) @test XLSX.getBorder(s, "B2").border == Dict("left" => Dict("auto" => "1", "style" => "medium"), "bottom" => Dict("auto" => "1", "style" => "medium"), "right" => Dict("auto" => "1", "style" => "medium"), "top" => Dict("auto" => "1", "style" => "medium"), "diagonal" => nothing) @@ -1573,34 +2216,96 @@ end @test XLSX.getBorder(f, "Sheet1!D6").border == Dict("left" => Dict("auto" => "1", "style" => "thick"), "bottom" => Dict("auto" => "1", "style" => "thick"), "right" => Dict("auto" => "1", "style" => "thick"), "top" => Dict("auto" => "1", "style" => "thick"), "diagonal" => nothing) XLSX.setBorder(f, "Sheet1!D6"; left=["style" => "dotted", "color" => "FF000FF0"], right=["style" => "medium", "color" => "FF765000"], top=["style" => "thick", "color" => "FF230000"], bottom=["style" => "medium", "color" => "FF0000FF"], diagonal=["style" => "dotted", "color" => "FF00D4D4"]) - @test XLSX.getBorder(s, "D6").border == Dict("left" => Dict("rgb" => "FF000FF0", "style" => "dotted"), "bottom" => Dict("rgb" => "FF0000FF", "style" => "medium"), "right" => Dict("rgb" => "FF765000", "style" => "medium"), "top" => Dict("rgb" => "FF230000", "style" => "thick"), "diagonal" => Dict("rgb" => "FF00D4D4", "style" => "dotted")) + @test XLSX.getBorder(s, "D6").border == Dict("left" => Dict("rgb" => "FF000FF0", "style" => "dotted"), "bottom" => Dict("rgb" => "FF0000FF", "style" => "medium"), "right" => Dict("rgb" => "FF765000", "style" => "medium"), "top" => Dict("rgb" => "FF230000", "style" => "thick"), "diagonal" => Dict("rgb" => "FF00D4D4", "style" => "dotted", "direction" => "both")) XLSX.setBorder(f, "Sheet1!B2:D4"; left=["style" => "hair"], right=["color" => "FF111111"], top=["style" => "hair"], bottom=["color" => "FF111111"], diagonal=["style" => "hair"]) - @test XLSX.getBorder(s, "B2").border == Dict("left" => Dict("auto" => "1", "style" => "hair"), "bottom" => Dict("rgb" => "FF111111", "style" => "medium"), "right" => Dict("rgb" => "FF111111", "style" => "medium"), "top" => Dict("auto" => "1", "style" => "hair"), "diagonal" => Dict("style" => "hair")) - @test XLSX.getBorder(f, "Sheet1!D4").border == Dict("left" => Dict("theme" => "3", "style" => "hair", "tint" => "0.24994659260841701"), "bottom" => Dict("rgb" => "FF111111", "style" => "dashed"), "right" => Dict("rgb" => "FF111111", "style" => "dashed"), "top" => Dict("theme" => "3", "style" => "hair", "tint" => "0.24994659260841701"), "diagonal" => Dict("style" => "hair")) - - XLSX.setBorder(f, "Sheet1!A1:D11"; left=["style" => "hair", "color" => "FF111111"], right=["style" => "hair", "color" => "FF111111"], top=["style" => "hair", "color" => "FF111111"], bottom=["style" => "hair", "color" => "FF111111"], diagonal=["style" => "hair", "color" => "FF111111"]) - @test XLSX.getBorder(s, "B4").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")) - @test XLSX.getBorder(s, "B6").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")) - @test XLSX.getBorder(s, "D4").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")) - @test XLSX.getBorder(s, "D8").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")) - @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")) - @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")) + @test XLSX.getBorder(s, "B2").border == Dict("left" => Dict("auto" => "1", "style" => "hair"), "bottom" => Dict("rgb" => "FF111111", "style" => "medium"), "right" => Dict("rgb" => "FF111111", "style" => "medium"), "top" => Dict("auto" => "1", "style" => "hair"), "diagonal" => Dict("style" => "hair", "direction" => "both")) + @test XLSX.getBorder(f, "Sheet1!D4").border == Dict("left" => Dict("theme" => "3", "style" => "hair", "tint" => "0.24994659260841701"), "bottom" => Dict("rgb" => "FF111111", "style" => "dashed"), "right" => Dict("rgb" => "FF111111", "style" => "dashed"), "top" => Dict("theme" => "3", "style" => "hair", "tint" => "0.24994659260841701"), "diagonal" => Dict("style" => "hair", "direction" => "both")) + + XLSX.setBorder(f, "Sheet1!A1:D10"; left=["style" => "hair", "color" => "FF111111"], right=["style" => "hair", "color" => "FF111111"], top=["style" => "hair", "color" => "FF111111"], bottom=["style" => "hair", "color" => "FF111111"], diagonal=["style" => "hair", "color" => "FF111111"]) + @test XLSX.getBorder(s, "B4").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, "B6").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, "D4").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, "D8").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" => "up")) + @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 isnothing(XLSX.getBorder(s, "D11")) # Cannot set a border in an EmptyCell (outside sheet dimension). + @test_throws XLSX.XLSXError XLSX.getBorder(s, "D11") # Cannot get a border outside sheet dimension. + + f = XLSX.newxlsx() + s = f[1] + s[1:6, 1:6] = "" + XLSX.setBorder(s, "B2:E5"; outside=["color" => "FFFF0000", "style" => "thick"]) + @test XLSX.getBorder(s, "B2").border == Dict("left" => Dict("rgb" => "FFFF0000", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => Dict("rgb" => "FFFF0000", "style" => "thick"), "diagonal" => nothing) + @test XLSX.getBorder(s, "B3").border == Dict("left" => Dict("rgb" => "FFFF0000", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "B4").border == Dict("left" => Dict("rgb" => "FFFF0000", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "B5").border == Dict("left" => Dict("rgb" => "FFFF0000", "style" => "thick"), "bottom" => Dict("rgb" => "FFFF0000", "style" => "thick"), "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "C2").border == Dict("left" => nothing, "bottom" => nothing, "right" => nothing, "top" => Dict("rgb" => "FFFF0000", "style" => "thick"), "diagonal" => nothing) + @test XLSX.getBorder(s, "C3") === nothing + @test XLSX.getBorder(s, "C4") === nothing + @test XLSX.getBorder(s, "C5").border == Dict("left" => nothing, "bottom" => Dict("rgb" => "FFFF0000", "style" => "thick"), "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "D2").border == Dict("left" => nothing, "bottom" => nothing, "right" => nothing, "top" => Dict("rgb" => "FFFF0000", "style" => "thick"), "diagonal" => nothing) + @test XLSX.getBorder(s, "D3") === nothing + @test XLSX.getBorder(s, "D4") === nothing + @test XLSX.getBorder(s, "D5").border == Dict("left" => nothing, "bottom" => Dict("rgb" => "FFFF0000", "style" => "thick"), "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "E2").border == Dict("left" => nothing, "bottom" => nothing, "right" => Dict("rgb" => "FFFF0000", "style" => "thick"), "top" => Dict("rgb" => "FFFF0000", "style" => "thick"), "diagonal" => nothing) + @test XLSX.getBorder(s, "E3").border == Dict("left" => nothing, "bottom" => nothing, "right" => Dict("rgb" => "FFFF0000", "style" => "thick"), "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "E4").border == Dict("left" => nothing, "bottom" => nothing, "right" => Dict("rgb" => "FFFF0000", "style" => "thick"), "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "E5").border == Dict("left" => nothing, "bottom" => Dict("rgb" => "FFFF0000", "style" => "thick"), "right" => Dict("rgb" => "FFFF0000", "style" => "thick"), "top" => nothing, "diagonal" => nothing) + + XLSX.setBorder(s, "B2:E5"; outside=["color" => "dodgerblue4"]) + @test XLSX.getBorder(s, "B2").border == Dict("left" => Dict("rgb" => "FF104E8B", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => Dict("rgb" => "FF104E8B", "style" => "thick"), "diagonal" => nothing) + @test XLSX.getBorder(s, "B3").border == Dict("left" => Dict("rgb" => "FF104E8B", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "B4").border == Dict("left" => Dict("rgb" => "FF104E8B", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "B5").border == Dict("left" => Dict("rgb" => "FF104E8B", "style" => "thick"), "bottom" => Dict("rgb" => "FF104E8B", "style" => "thick"), "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "C2").border == Dict("left" => nothing, "bottom" => nothing, "right" => nothing, "top" => Dict("rgb" => "FF104E8B", "style" => "thick"), "diagonal" => nothing) + @test XLSX.getBorder(s, "C3") === nothing + @test XLSX.getBorder(s, "C4") === nothing + + f = XLSX.newxlsx() + s = f[1] + s[1:6, 1:6] = "" + XLSX.setBorder(s, "Sheet1!A1"; allsides=["color" => "FFFF00FF", "style" => "thick"]) + @test XLSX.getBorder(s, "A1").border == Dict("left" => Dict("rgb" => "FFFF00FF", "style" => "thick"), "bottom" => Dict("rgb" => "FFFF00FF", "style" => "thick"), "right" => Dict("rgb" => "FFFF00FF", "style" => "thick"), "top" => Dict("rgb" => "FFFF00FF", "style" => "thick"), "diagonal" => nothing) + XLSX.setBorder(s, "Sheet1!A1:E1"; allsides=["color" => "FFFF0000", "style" => "thick"]) + @test XLSX.getBorder(s, "B1").border == Dict("left" => Dict("rgb" => "FFFF0000", "style" => "thick"), "bottom" => Dict("rgb" => "FFFF0000", "style" => "thick"), "right" => Dict("rgb" => "FFFF0000", "style" => "thick"), "top" => Dict("rgb" => "FFFF0000", "style" => "thick"), "diagonal" => nothing) + XLSX.setBorder(s, "Sheet1!A:E"; left=["color" => "FFFF0001", "style" => "thick"]) + @test XLSX.getBorder(s, "B2").border == Dict("left" => Dict("rgb" => "FFFF0001", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + XLSX.setBorder(s, "Sheet1!3:4"; left=["color" => "FFFF0002", "style" => "thick"]) + @test XLSX.getBorder(s, "B4").border == Dict("left" => Dict("rgb" => "FFFF0002", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + XLSX.setBorder(s, "B2,B4"; left=["color" => "FFFF0004", "style" => "thick"]) + @test XLSX.getBorder(s, "B2").border == Dict("left" => Dict("rgb" => "FFFF0004", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "B4").border == Dict("left" => Dict("rgb" => "FFFF0004", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + @test XLSX.getBorder(s, "B3").border == Dict("left" => Dict("rgb" => "FFFF0002", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + + f = XLSX.newxlsx() + s = f[1] + s[1:6, 1:6] = "" + XLSX.setBorder(s, 1, :; left=["color" => "FFFF0001", "style" => "thick"]) + @test XLSX.getBorder(s, "B1").border == Dict("left" => Dict("rgb" => "FFFF0001", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + XLSX.setBorder(s, [2, 3], :; left=["color" => "FFFF0002", "style" => "thick"]) + @test XLSX.getBorder(s, "D3").border == Dict("left" => Dict("rgb" => "FFFF0002", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + XLSX.setBorder(s, :, [2, 3]; left=["color" => "FFFF0003", "style" => "thick"]) + @test XLSX.getBorder(s, "C4").border == Dict("left" => Dict("rgb" => "FFFF0003", "style" => "thick"), "bottom" => nothing, "right" => nothing, "top" => nothing, "diagonal" => nothing) + XLSX.setBorder(s, 4, [2, 3]; left=["color" => "FFFF0004", "style" => "thick"]) + @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"]) f = XLSX.open_xlsx_template(joinpath(data_directory, "Borders.xlsx")) s = f["Sheet1"] - XLSX.setUniformBorder(f, "Sheet1!A1:D4"; left=["style" => "dotted", "color" => "FF000FF0"], + XLSX.setUniformBorder(f, "Sheet1!A1:D4"; left=["style" => "dotted", "color" => "darkseagreen3"], right=["style" => "medium", "color" => "FF765000"], top=["style" => "thick", "color" => "FF230000"], bottom=["style" => "medium", "color" => "FF0000FF"], diagonal=["style" => "none"] ) - @test XLSX.getBorder(s, "A1").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF000FF0"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) - @test XLSX.getBorder(f, "Sheet1!B2").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF000FF0"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) - @test XLSX.getBorder(f, "Sheet1!D4").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF000FF0"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + @test XLSX.getBorder(s, "A1").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) + @test XLSX.getBorder(f, "Sheet1!B2").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) + @test XLSX.getBorder(f, "Sheet1!D4").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) @test XLSX.getcell(s, "C3") isa XLSX.EmptyCell @test isnothing(XLSX.getBorder(s, "C3")) @@ -1608,48 +2313,246 @@ end f = XLSX.open_xlsx_template(joinpath(data_directory, "customXml.xlsx")) s = f["Mock-up"] - XLSX.setBorder(s, "ID"; left=["style" => "dotted", "color" => "FF000FF0"], bottom=["style" => "medium", "color" => "FF0000FF"], right=["style" => "medium", "color" => "FF765000"], top=["style" => "thick", "color" => "FF230000"], diagonal=nothing) - @test XLSX.getBorder(s, "ID").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF000FF0"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + # Sheet mismatch + @test_throws XLSX.XLSXError XLSX.setUniformBorder(s, "Document History!A1:D4"; left=["style" => "dotted", "color" => "darkseagreen3"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + + @test XLSX.setUniformBorder(s, "Mock-up!A1:B4,Mock-up!D4:E6"; left=["style" => "dotted", "color" => "darkseagreen3"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"]) == 28 + + XLSX.setBorder(s, "ID"; left=["style" => "dotted", "color" => "grey36"], bottom=["style" => "medium", "color" => "FF0000FF"], right=["style" => "medium", "color" => "FF765000"], top=["style" => "thick", "color" => "FF230000"], diagonal=nothing) + @test XLSX.getBorder(s, "ID").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF5C5C5C"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) # Location is a non-contiguous range - XLSX.setBorder(s, "Location"; left=["style" => "hair", "color" => "FF111111"], right=["style" => "hair", "color" => "FF111111"], top=["style" => "hair", "color" => "FF111111"], bottom=["style" => "hair", "color" => "FF111111"], diagonal=["style" => "hair", "color" => "FF111111"]) - @test XLSX.getBorder(s, "D18").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")) - @test XLSX.getBorder(s, "D20").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")) - @test XLSX.getBorder(s, "J18").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")) - @test XLSX.getBorder(s, "J18").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")) + XLSX.setBorder(s, "Location"; left=["style" => "hair", "color" => "chocolate4"], right=["style" => "hair", "color" => "chocolate4"], top=["style" => "hair", "color" => "chocolate4"], bottom=["style" => "hair", "color" => "chocolate4"], diagonal=["style" => "hair", "color" => "chocolate4"]) + @test XLSX.getBorder(s, "D18").border == Dict("left" => Dict("rgb" => "FF8B4513", "style" => "hair"), "bottom" => Dict("rgb" => "FF8B4513", "style" => "hair"), "right" => Dict("rgb" => "FF8B4513", "style" => "hair"), "top" => Dict("rgb" => "FF8B4513", "style" => "hair"), "diagonal" => Dict("rgb" => "FF8B4513", "style" => "hair", "direction" => "both")) + @test XLSX.getBorder(s, "D20").border == Dict("left" => Dict("rgb" => "FF8B4513", "style" => "hair"), "bottom" => Dict("rgb" => "FF8B4513", "style" => "hair"), "right" => Dict("rgb" => "FF8B4513", "style" => "hair"), "top" => Dict("rgb" => "FF8B4513", "style" => "hair"), "diagonal" => Dict("rgb" => "FF8B4513", "style" => "hair", "direction" => "both")) + @test XLSX.getBorder(s, "J18").border == Dict("left" => Dict("rgb" => "FF8B4513", "style" => "hair"), "bottom" => Dict("rgb" => "FF8B4513", "style" => "hair"), "right" => Dict("rgb" => "FF8B4513", "style" => "hair"), "top" => Dict("rgb" => "FF8B4513", "style" => "hair"), "diagonal" => Dict("rgb" => "FF8B4513", "style" => "hair", "direction" => "both")) + @test XLSX.getBorder(s, "J20").border == Dict("left" => Dict("rgb" => "FF8B4513", "style" => "hair"), "bottom" => Dict("rgb" => "FF8B4513", "style" => "hair"), "right" => Dict("rgb" => "FF8B4513", "style" => "hair"), "top" => Dict("rgb" => "FF8B4513", "style" => "hair"), "diagonal" => Dict("rgb" => "FF8B4513", "style" => "hair", "direction" => "both")) - # Cant get attributes on a range. - @test_throws AssertionError XLSX.getBorder(s, "Contiguous") + # Can't get attributes on a range. + @test_throws XLSX.XLSXError XLSX.getBorder(s, "Contiguous") f = XLSX.open_empty_template() s = f["Sheet1"] - - # All these cells are `EmptyCells` - @test XLSX.setUniformFont(s, "A1:B4"; size=12, name="Times New Roman", color="FF040404") == -1 - @test XLSX.setUniformBorder(f, "Sheet1!A1:D4"; left=["style" => "dotted", "color" => "FF000FF0"], + 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" => "FF0000FF"], + bottom=["style" => "medium", "color" => "chocolate4"], diagonal=["style" => "none"] ) == -1 @test XLSX.setUniformFill(s, "B2:D4"; pattern="gray125", bgColor="FF000000") == -1 - @test XLSX.setFont(s, "A1:B2"; size=18, name="Arial") == -1 - @test XLSX.setBorder(f, "Sheet1!B2:D4"; left=["style" => "hair"], right=["color" => "FF111111"], top=["style" => "hair"], bottom=["color" => "FF111111"], diagonal=["style" => "hair"]) == -1 - @test XLSX.setFill(f, "Sheet1!A1:F20"; pattern="none", fgColor="88FF8800") == -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") + + 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") 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"] ) - @test_throws MethodError XLSX.setUniformFill(s, "ID"; pattern="darkTrellis", fgColor="FF222222", bgColor="FFDDDDDD") + + f = XLSX.newxlsx() + s = f[1] + s[1:6, 1:6] = "" + XLSX.setUniformBorder(s, "Sheet1!A:B"; + left=["style" => "dotted", "color" => "darkseagreen3"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "A1").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) + XLSX.setUniformBorder(s, "Sheet1!2:4"; + left=["style" => "dotted", "color" => "FF9BCD9C"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "C2").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF9BCD9C"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, "A:B"; + left=["style" => "dotted", "color" => "FF9BCD9E"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "B3").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF9BCD9E"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, "2:4"; + left=["style" => "dotted", "color" => "FF9BCD9D"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "C3").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF9BCD9D"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, 5, :; + left=["style" => "dotted", "color" => "FF9BCD8D"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "F5").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF9BCD8D"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, :, 5; + left=["style" => "dotted", "color" => "FF9BBD8D"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "E2").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF9BBD8D"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, :, :; + left=["style" => "dotted", "color" => "FF9BCD7D"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "F5").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF9BCD7D"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, :; + left=["style" => "dotted", "color" => "FF9BCD6D"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "D3").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF9BCD6D"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, [2, 3], :; + left=["style" => "dotted", "color" => "FF9BCE6D"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "B2").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF9BCE6D"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, :, [2, 3]; + left=["style" => "dotted", "color" => "FF9BCB6D"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "C6").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF9BCB6D"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, 1, [2, 3]; + left=["style" => "dotted", "color" => "FF8BCB6D"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "C1").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF8BCB6D"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, [1, 2], [4, 5, 6]; + left=["style" => "dotted", "color" => "FF6BCB6D"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "E2").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF6BCB6D"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + XLSX.setUniformBorder(s, 4, 4; + left=["style" => "dotted", "color" => "FF7BCB6D"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, "D4").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF7BCB6D"), "bottom" => Dict("style" => "medium", "rgb" => "FF0000FF"), "right" => Dict("style" => "medium", "rgb" => "FF765000"), "top" => Dict("style" => "thick", "rgb" => "FF230000"), "diagonal" => nothing) + + f = XLSX.newxlsx() + s = f[1] + s[1:6, 1:6] = "" + XLSX.setOutsideBorder(s, "Sheet1!A1:A2"; outside=["style" => "dotted", "color" => "FF003FF0"]) + @test XLSX.getBorder(s, "A1").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF003FF0"), "bottom" => nothing, "right" => Dict("style" => "dotted", "rgb" => "FF003FF0"), "top" => Dict("style" => "dotted", "rgb" => "FF003FF0"), "diagonal" => nothing) + @test XLSX.getBorder(s, "A2").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF003FF0"), "bottom" => Dict("style" => "dotted", "rgb" => "FF003FF0"), "right" => Dict("style" => "dotted", "rgb" => "FF003FF0"), "top" => nothing, "diagonal" => nothing) + XLSX.setOutsideBorder(s, "Sheet1!C:E"; outside=["style" => "dotted", "color" => "FF000FF0"]) + @test XLSX.getBorder(s, "C1").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF000FF0"), "bottom" => nothing, "right" => nothing, "top" => Dict("style" => "dotted", "rgb" => "FF000FF0"), "diagonal" => nothing) + @test XLSX.getBorder(s, "E6").border == Dict("left" => nothing, "bottom" => Dict("style" => "dotted", "rgb" => "FF000FF0"), "right" => Dict("style" => "dotted", "rgb" => "FF000FF0"), "top" => nothing, "diagonal" => nothing) + XLSX.setOutsideBorder(s, "Sheet1!3:5"; outside=["style" => "dotted", "color" => "FF000FFF"]) + @test XLSX.getBorder(s, "A3").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF000FFF"), "bottom" => nothing, "right" => nothing, "top" => Dict("style" => "dotted", "rgb" => "FF000FFF"), "diagonal" => nothing) + @test XLSX.getBorder(s, "F5").border == Dict("left" => nothing, "bottom" => Dict("style" => "dotted", "rgb" => "FF000FFF"), "right" => Dict("style" => "dotted", "rgb" => "FF000FFF"), "top" => nothing, "diagonal" => nothing) + XLSX.setOutsideBorder(s, "C:E"; outside=["style" => "dotted", "color" => "FFFF0FF0"]) + @test XLSX.getBorder(s, "C1").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FFFF0FF0"), "bottom" => nothing, "right" => nothing, "top" => Dict("style" => "dotted", "rgb" => "FFFF0FF0"), "diagonal" => nothing) + @test XLSX.getBorder(s, "E6").border == Dict("left" => nothing, "bottom" => Dict("style" => "dotted", "rgb" => "FFFF0FF0"), "right" => Dict("style" => "dotted", "rgb" => "FFFF0FF0"), "top" => nothing, "diagonal" => nothing) + XLSX.setOutsideBorder(s, "3:5"; outside=["style" => "dotted", "color" => "FFF50FFF"]) + @test XLSX.getBorder(s, "A3").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FFF50FFF"), "bottom" => nothing, "right" => nothing, "top" => Dict("style" => "dotted", "rgb" => "FFF50FFF"), "diagonal" => nothing) + @test XLSX.getBorder(s, "F5").border == Dict("left" => nothing, "bottom" => Dict("style" => "dotted", "rgb" => "FFF50FFF"), "right" => Dict("style" => "dotted", "rgb" => "FFF50FFF"), "top" => nothing, "diagonal" => nothing) + + f = XLSX.newxlsx() + s = f[1] + s[1:6, 1:6] = "" + XLSX.setOutsideBorder(s, 1, :; outside=["style" => "dotted", "color" => "FF002FF0"]) + @test XLSX.getBorder(s, "A1").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF002FF0"), "bottom" => Dict("style" => "dotted", "rgb" => "FF002FF0"), "right" => nothing, "top" => Dict("style" => "dotted", "rgb" => "FF002FF0"), "diagonal" => nothing) + @test XLSX.getBorder(s, "F1").border == Dict("left" => nothing, "bottom" => Dict("style" => "dotted", "rgb" => "FF002FF0"), "right" => Dict("style" => "dotted", "rgb" => "FF002FF0"), "top" => Dict("style" => "dotted", "rgb" => "FF002FF0"), "diagonal" => nothing) + XLSX.setOutsideBorder(s, :, 1; outside=["style" => "dotted", "color" => "FF003FF0"]) + @test XLSX.getBorder(s, "A1").border == Dict("left" => Dict("rgb" => "FF003FF0", "style" => "dotted"), "bottom" => Dict("rgb" => "FF002FF0", "style" => "dotted"), "right" => Dict("rgb" => "FF003FF0", "style" => "dotted"), "top" => Dict("rgb" => "FF003FF0", "style" => "dotted"), "diagonal" => nothing) + @test XLSX.getBorder(s, "A6").border == Dict("left" => Dict("style" => "dotted", "rgb" => "FF003FF0"), "bottom" => Dict("style" => "dotted", "rgb" => "FF003FF0"), "right" => Dict("style" => "dotted", "rgb" => "FF003FF0"), "top" => nothing, "diagonal" => nothing) + XLSX.setOutsideBorder(s, :, :; outside=["style" => "dotted", "color" => "FF000FF0"]) + @test XLSX.getBorder(s, "A1").border == Dict("left" => Dict("rgb" => "FF000FF0", "style" => "dotted"), "bottom" => Dict("rgb" => "FF002FF0", "style" => "dotted"), "right" => Dict("rgb" => "FF003FF0", "style" => "dotted"), "top" => Dict("rgb" => "FF000FF0", "style" => "dotted"), "diagonal" => nothing) + @test XLSX.getBorder(s, "F6").border == Dict("left" => nothing, "bottom" => Dict("style" => "dotted", "rgb" => "FF000FF0"), "right" => Dict("style" => "dotted", "rgb" => "FF000FF0"), "top" => nothing, "diagonal" => nothing) + XLSX.setOutsideBorder(s, :; outside=["style" => "dotted", "color" => "FF000FFF"]) + @test XLSX.getBorder(s, "A1").border == Dict("left" => Dict("rgb" => "FF000FFF", "style" => "dotted"), "bottom" => Dict("rgb" => "FF002FF0", "style" => "dotted"), "right" => Dict("rgb" => "FF003FF0", "style" => "dotted"), "top" => Dict("rgb" => "FF000FFF", "style" => "dotted"), "diagonal" => nothing) + @test XLSX.getBorder(s, "F6").border == Dict("left" => nothing, "bottom" => Dict("style" => "dotted", "rgb" => "FF000FFF"), "right" => Dict("style" => "dotted", "rgb" => "FF000FFF"), "top" => nothing, "diagonal" => nothing) + XLSX.setOutsideBorder(s, 1:2, 1; outside=["style" => "dotted", "color" => "FFFFFFF0"]) + @test XLSX.getBorder(s, "A1").border == Dict("left" => Dict("rgb" => "FFFFFFF0", "style" => "dotted"), "bottom" => Dict("rgb" => "FF002FF0", "style" => "dotted"), "right" => Dict("rgb" => "FFFFFFF0", "style" => "dotted"), "top" => Dict("rgb" => "FFFFFFF0", "style" => "dotted"), "diagonal" => nothing) + @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) end @@ -1658,6 +2561,8 @@ end f = XLSX.open_xlsx_template(joinpath(data_directory, "customXml.xlsx")) s = f["Mock-up"] + @test XLSX.getDefaultFill(s).fill == Dict("patternFill" => Dict("patternType" => "none")) + @test XLSX.getFill(s, "D17").fill == Dict("patternFill" => Dict("bgindexed" => "64", "patternType" => "solid", "fgtint" => "-9.9978637043366805E-2", "fgtheme" => "2")) @test XLSX.getFill(f, "Mock-up!D18").fill == Dict("patternFill" => Dict("bgindexed" => "64", "patternType" => "solid", "fgtint" => "-0.499984740745262", "fgtheme" => "2")) @@ -1666,15 +2571,19 @@ end XLSX.setFill(s, "ID"; pattern="darkTrellis", fgColor="FF222222", bgColor="FFDDDDDD") @test XLSX.getFill(s, "ID").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDDD", "patternType" => "darkTrellis", "fgrgb" => "FF222222")) + @test_throws XLSX.XLSXError XLSX.setFill(s, "ID"; pattern="darkTrellis", fgColor="notAcolor", bgColor="FFDDDDDD") + @test_throws XLSX.XLSXError XLSX.setFill(s, "ID"; pattern="notApattern", fgColor="FF222222", bgColor="FFDDDDDD") + @test_throws XLSX.XLSXError XLSX.setFill(s, "ID"; pattern="darkTrellis", fgColor="FF222222", bgColor="FFDDDDDDFF") + @test_throws XLSX.XLSXError XLSX.setFill(s, "ID"; fgColor="FF222222", bgColor="FFDDDDDDFF") # Location is a non-contiguous range - XLSX.setFill(s, "Location"; pattern="lightVertical") + XLSX.setFill(s, "Location"; pattern="lightVertical") # Default colors unchanged @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")) - XLSX.setFill(s, "Contiguous"; pattern="lightVertical") + XLSX.setFill(s, "Contiguous"; pattern="lightVertical") # Default colors unchanged @test XLSX.getFill(s, "D23").fill == Dict("patternFill" => Dict("patternType" => "lightVertical", "bgindexed" => "64", "fgtheme" => "0")) @test XLSX.getFill(s, "D24").fill == Dict("patternFill" => Dict("patternType" => "lightVertical", "bgindexed" => "64", "fgtheme" => "0")) @test XLSX.getFill(s, "D25").fill == Dict("patternFill" => Dict("patternType" => "lightVertical", "bgindexed" => "64", "fgtheme" => "0")) @@ -1682,7 +2591,7 @@ end @test XLSX.getFill(s, "D27").fill == Dict("patternFill" => Dict("patternType" => "lightVertical", "bgindexed" => "64", "fgtheme" => "0")) # Can't get attributes on a range. - @test_throws AssertionError XLSX.getFill(s, "Contiguous") + @test_throws XLSX.XLSXError XLSX.getFill(s, "Contiguous") XLSX.setUniformFill(s, "B3:D5"; pattern="lightGrid", fgColor="FF0000FF", bgColor="FF00FF00") @test XLSX.getFill(s, "B3").fill == Dict("patternFill" => Dict("bgrgb" => "FF00FF00", "patternType" => "lightGrid", "fgrgb" => "FF0000FF")) @@ -1707,7 +2616,7 @@ end @test XLSX.getFill(s, "D27").fill == Dict("patternFill" => Dict("patternType" => "lightVertical", "bgindexed" => "64", "fgtheme" => "0")) # Can't get attributes on a range. - @test_throws AssertionError XLSX.getFill(s, "Contiguous") + @test_throws XLSX.XLSXError XLSX.getFill(s, "Contiguous") XLSX.writexlsx("output.xlsx", f, overwrite=true) @test isfile("output.xlsx") @@ -1723,10 +2632,112 @@ end isfile("output.xlsx") && rm("output.xlsx") + f = XLSX.newxlsx() + s = f[1] + s[1:6, 1:6] = "" + XLSX.setFill(s, "Sheet1!A1"; pattern="darkTrellis", fgColor="FF222222", bgColor="FFDDDDDD") + @test XLSX.getFill(s, "A1").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDDD", "patternType" => "darkTrellis", "fgrgb" => "FF222222")) + XLSX.setFill(s, "Sheet1!A2:F2"; pattern="darkTrellis", fgColor="FF222224", bgColor="FFDDDDD4") + @test XLSX.getFill(s, "A2").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDD4", "patternType" => "darkTrellis", "fgrgb" => "FF222224")) + XLSX.setFill(s, "Sheet1!C:D"; pattern="darkTrellis", fgColor="FF222228", bgColor="FFDDDDD8") + @test XLSX.getFill(s, "D4").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDD8", "patternType" => "darkTrellis", "fgrgb" => "FF222228")) + XLSX.setFill(s, "Sheet1!5:6"; pattern="darkTrellis", fgColor="FF222220", bgColor="FFDDDDD0") + @test XLSX.getFill(s, "F5").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF222220")) + XLSX.setFill(s, "Sheet1!E4:E6,Sheet1!A4"; pattern="darkTrellis", fgColor="FF422220", bgColor="FF4DDDD0") + @test XLSX.getFill(s, "E4").fill == Dict("patternFill" => Dict("bgrgb" => "FF4DDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF422220")) + @test XLSX.getFill(s, "E5").fill == Dict("patternFill" => Dict("bgrgb" => "FF4DDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF422220")) + @test XLSX.getFill(s, "E6").fill == Dict("patternFill" => Dict("bgrgb" => "FF4DDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF422220")) + @test XLSX.getFill(s, "A4").fill == Dict("patternFill" => Dict("bgrgb" => "FF4DDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF422220")) + XLSX.setFill(s, :, 2; pattern="darkTrellis", fgColor="FF622220", bgColor="FF6DDDD0") + @test XLSX.getFill(s, "B4").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF622220")) + XLSX.setFill(s, [2, 6], :; pattern="darkTrellis", fgColor="FF622222", bgColor="FF6DDDD2") + @test XLSX.getFill(s, "C2").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD2", "patternType" => "darkTrellis", "fgrgb" => "FF622222")) + @test XLSX.getFill(s, "F6").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD2", "patternType" => "darkTrellis", "fgrgb" => "FF622222")) + XLSX.setFill(s, :, [2, 5]; pattern="darkTrellis", fgColor="FF622224", bgColor="FF6DDDD4") + @test XLSX.getFill(s, "B2").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD4", "patternType" => "darkTrellis", "fgrgb" => "FF622224")) + @test XLSX.getFill(s, "E3").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD4", "patternType" => "darkTrellis", "fgrgb" => "FF622224")) + XLSX.setFill(s, 2, [3, 6]; pattern="darkTrellis", fgColor="FF622226", bgColor="FF6DDDD6") + @test XLSX.getFill(s, "C2").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD6", "patternType" => "darkTrellis", "fgrgb" => "FF622226")) + XLSX.setFill(s, 2:2:6, [4, 5]; pattern="darkTrellis", fgColor="FF622228", bgColor="FF6DDDD8") + @test XLSX.getFill(s, "E4").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD8", "patternType" => "darkTrellis", "fgrgb" => "FF622228")) + + f = XLSX.newxlsx() + s = f[1] + s[1:6, 1:6] = "" + XLSX.setUniformFill(s, "Sheet1!A2:F2"; pattern="darkTrellis", fgColor="FF222224", bgColor="FFDDDDD4") + @test XLSX.getFill(s, "A2").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDD4", "patternType" => "darkTrellis", "fgrgb" => "FF222224")) + XLSX.setUniformFill(s, "Sheet1!C:D"; pattern="darkTrellis", fgColor="FF222228", bgColor="FFDDDDD8") + @test XLSX.getFill(s, "D4").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDD8", "patternType" => "darkTrellis", "fgrgb" => "FF222228")) + XLSX.setUniformFill(s, "Sheet1!5:6"; pattern="darkTrellis", fgColor="FF222220", bgColor="FFDDDDD0") + @test XLSX.getFill(s, "F5").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF222220")) + XLSX.setUniformFill(s, "A2:F2"; pattern="darkTrellis", fgColor="FF222224", bgColor="FFDDDDD4") + @test XLSX.getFill(s, "A2").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDD4", "patternType" => "darkTrellis", "fgrgb" => "FF222224")) + XLSX.setUniformFill(s, "C:D"; pattern="darkTrellis", fgColor="FF222228", bgColor="FFDDDDD8") + @test XLSX.getFill(s, "D4").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDD8", "patternType" => "darkTrellis", "fgrgb" => "FF222228")) + XLSX.setUniformFill(s, "5:6"; pattern="darkTrellis", fgColor="FF222220", bgColor="FFDDDDD0") + @test XLSX.getFill(s, "F5").fill == Dict("patternFill" => Dict("bgrgb" => "FFDDDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF222220")) + XLSX.setUniformFill(s, "E4:E6,A4"; pattern="darkTrellis", fgColor="FF422220", bgColor="FF4DDDD0") + @test XLSX.getFill(s, "E4").fill == Dict("patternFill" => Dict("bgrgb" => "FF4DDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF422220")) + @test XLSX.getFill(s, "E5").fill == Dict("patternFill" => Dict("bgrgb" => "FF4DDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF422220")) + @test XLSX.getFill(s, "E6").fill == Dict("patternFill" => Dict("bgrgb" => "FF4DDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF422220")) + @test XLSX.getFill(s, "A4").fill == Dict("patternFill" => Dict("bgrgb" => "FF4DDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF422220")) + XLSX.setUniformFill(s, :, 2; pattern="darkTrellis", fgColor="FF622220", bgColor="FF6DDDD0") + @test XLSX.getFill(s, "B4").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD0", "patternType" => "darkTrellis", "fgrgb" => "FF622220")) + XLSX.setUniformFill(s, [2, 6], :; pattern="darkTrellis", fgColor="FF622222", bgColor="FF6DDDD2") + @test XLSX.getFill(s, "C2").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD2", "patternType" => "darkTrellis", "fgrgb" => "FF622222")) + @test XLSX.getFill(s, "F6").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD2", "patternType" => "darkTrellis", "fgrgb" => "FF622222")) + XLSX.setUniformFill(s, :, [2, 5]; pattern="darkTrellis", fgColor="FF622224", bgColor="FF6DDDD4") + @test XLSX.getFill(s, "B2").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD4", "patternType" => "darkTrellis", "fgrgb" => "FF622224")) + @test XLSX.getFill(s, "E3").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD4", "patternType" => "darkTrellis", "fgrgb" => "FF622224")) + XLSX.setUniformFill(s, 2, [3, 6]; pattern="darkTrellis", fgColor="FF622226", bgColor="FF6DDDD6") + @test XLSX.getFill(s, "C2").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD6", "patternType" => "darkTrellis", "fgrgb" => "FF622226")) + XLSX.setUniformFill(s, [2, 3], 5:6; pattern="darkTrellis", fgColor="FF642226", bgColor="FF64DDD6") + @test XLSX.getFill(s, "F2").fill == Dict("patternFill" => Dict("bgrgb" => "FF64DDD6", "patternType" => "darkTrellis", "fgrgb" => "FF642226")) + XLSX.setUniformFill(s, 2:2:6, [4, 5]; pattern="darkTrellis", fgColor="FF622228", bgColor="FF6DDDD8") + @test XLSX.getFill(s, "E4").fill == Dict("patternFill" => Dict("bgrgb" => "FF6DDDD8", "patternType" => "darkTrellis", "fgrgb" => "FF622228")) + XLSX.setUniformFill(s, :, :; pattern="darkTrellis", fgColor="FF822228", bgColor="FF8DDDD8") + @test XLSX.getFill(s, "E4").fill == Dict("patternFill" => Dict("bgrgb" => "FF8DDDD8", "patternType" => "darkTrellis", "fgrgb" => "FF822228")) + XLSX.setUniformFill(s, :; pattern="darkTrellis", fgColor="FF822288", bgColor="FF8DDD88") + @test XLSX.getFill(s, "E4").fill == Dict("patternFill" => Dict("bgrgb" => "FF8DDD88", "patternType" => "darkTrellis", "fgrgb" => "FF822288")) + XLSX.setUniformFill(s, :; pattern="darkTrellis", fgColor="FF822288", bgColor="FF8DDD88") + @test XLSX.getFill(s, "E4").fill == Dict("patternFill" => Dict("bgrgb" => "FF8DDD88", "patternType" => "darkTrellis", "fgrgb" => "FF822288")) + XLSX.setUniformFill(s, 1, 1:2; pattern="darkTrellis", fgColor="FF822268", bgColor="FF8DDD68") + @test XLSX.getFill(s, "B1").fill == Dict("patternFill" => Dict("bgrgb" => "FF8DDD68", "patternType" => "darkTrellis", "fgrgb" => "FF822268")) + end @testset "setAlignment" begin + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setAlignment(s, "Sheet1!A1"; horizontal="right", vertical="justify", wrapText=true) + @test XLSX.getAlignment(s, "A1").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + XLSX.setAlignment(s, "Sheet1!A2:C4"; horizontal="right", vertical="justify", wrapText=true) + @test XLSX.getAlignment(s, "B3").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + XLSX.setAlignment(s, "Sheet1!D:E"; horizontal="right", vertical="justify", wrapText=true) + @test XLSX.getAlignment(s, "D26").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + XLSX.setAlignment(s, "Sheet1!25:26"; horizontal="left", vertical="top", wrapText=false) + @test XLSX.getAlignment(s, "D26").alignment == Dict("alignment" => Dict("horizontal" => "left", "vertical" => "top", "wrapText" => "0")) + XLSX.setAlignment(s, "G8,H10,J15:M18"; horizontal="left", vertical="bottom", wrapText=true) + @test XLSX.getAlignment(s, "G8").alignment == Dict("alignment" => Dict("horizontal" => "left", "vertical" => "bottom", "wrapText" => "1")) + @test XLSX.getAlignment(s, "H10").alignment == Dict("alignment" => Dict("horizontal" => "left", "vertical" => "bottom", "wrapText" => "1")) + @test XLSX.getAlignment(s, "L16").alignment == Dict("alignment" => Dict("horizontal" => "left", "vertical" => "bottom", "wrapText" => "1")) + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setAlignment(s, :, 1:3; horizontal="right", vertical="justify", wrapText=true) + @test XLSX.getAlignment(s, "B25").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + XLSX.setAlignment(s, 8:2:16, :; horizontal="right", vertical="justify", wrapText=true) + @test XLSX.getAlignment(s, "C12").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + XLSX.setAlignment(s, :, [8, 10, 12, 14, 16]; horizontal="right", vertical="top", wrapText=true) + @test XLSX.getAlignment(s, "L22").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "top", "wrapText" => "1")) + XLSX.setAlignment(s, 18, 20:3:26; horizontal="left", vertical="top", wrapText=true) + @test XLSX.getAlignment(s, "W18").alignment == Dict("alignment" => Dict("horizontal" => "left", "vertical" => "top", "wrapText" => "1")) + XLSX.setAlignment(s, 18:2:22, 20:3:26; horizontal="left", vertical="bottom", wrapText=true) + @test XLSX.getAlignment(s, "Z20").alignment == Dict("alignment" => Dict("horizontal" => "left", "vertical" => "bottom", "wrapText" => "1")) + f = XLSX.open_xlsx_template(joinpath(data_directory, "customXml.xlsx")) s = f["Mock-up"] @@ -1755,6 +2766,105 @@ end isfile("output.xlsx") && rm("output.xlsx") + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setUniformAlignment(s, "Sheet1!E5:E6"; horizontal="right", vertical="justify", wrapText=true) + @test XLSX.getAlignment(s, "E5").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + XLSX.setUniformAlignment(s, "Sheet1!A:A"; horizontal="right", vertical="top", wrapText=true) + @test XLSX.getAlignment(s, "A23").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "top", "wrapText" => "1")) + XLSX.setUniformAlignment(s, "Sheet1!15:24"; horizontal="left", vertical="top", wrapText=true) + @test XLSX.getAlignment(s, "Q15").alignment == Dict("alignment" => Dict("horizontal" => "left", "vertical" => "top", "wrapText" => "1")) + @test XLSX.getAlignment(s, "A23").alignment == Dict("alignment" => Dict("horizontal" => "left", "vertical" => "top", "wrapText" => "1")) + XLSX.setUniformAlignment(s, "A:A"; horizontal="right", vertical="bottom", wrapText=true) + @test XLSX.getAlignment(s, "A15").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "bottom", "wrapText" => "1")) + XLSX.setUniformAlignment(s, "10:12"; horizontal="right", vertical="bottom", wrapText=true) + @test XLSX.getAlignment(s, "Q11").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "bottom", "wrapText" => "1")) + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setUniformAlignment(s, 2, :; horizontal="right", vertical="justify", wrapText=true) + @test XLSX.getAlignment(s, "E2").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + XLSX.setUniformAlignment(s, :, 4:5; horizontal="right", vertical="top", wrapText=true) + @test XLSX.getAlignment(s, "D23").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "top", "wrapText" => "1")) + XLSX.setUniformAlignment(s, :, :; horizontal="left", vertical="top", wrapText=true) + @test XLSX.getAlignment(s, "Q15").alignment == Dict("alignment" => Dict("horizontal" => "left", "vertical" => "top", "wrapText" => "1")) + @test XLSX.getAlignment(s, "A23").alignment == Dict("alignment" => Dict("horizontal" => "left", "vertical" => "top", "wrapText" => "1")) + XLSX.setUniformAlignment(s, :; horizontal="right", vertical="bottom", wrapText=true) + @test XLSX.getAlignment(s, "A15").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "bottom", "wrapText" => "1")) + XLSX.setUniformAlignment(s, :, [8, 12, 14]; horizontal="right", vertical="bottom", wrapText=false) + @test XLSX.getAlignment(s, "L12").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "bottom", "wrapText" => "0")) + XLSX.setUniformAlignment(s, 8:12:20, 3; horizontal="right", vertical="top", wrapText=false) + @test XLSX.getAlignment(s, "C20").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "top", "wrapText" => "0")) + XLSX.setUniformAlignment(s, 8:12:20, [3, 4]; horizontal="justify", vertical="justify", wrapText=false) + @test XLSX.getAlignment(s, "D8").alignment == Dict("alignment" => Dict("horizontal" => "justify", "vertical" => "justify", "wrapText" => "0")) + XLSX.setUniformAlignment(s, 8:20, 8; horizontal="justify", vertical="justify", wrapText=true) + @test XLSX.getAlignment(s, "H15").alignment == Dict("alignment" => Dict("horizontal" => "justify", "vertical" => "justify", "wrapText" => "1")) + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setAlignment(s, "A1"; horizontal="right", vertical="justify", wrapText=true) + XLSX.setUniformAlignment(f, "Sheet1!A1,Sheet1!C3,Sheet1!E5:E6") + @test XLSX.getAlignment(s, "A1").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, "C3").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, "E5").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, "E6").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, 2, [1, 3, 10, 15, 28]; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, [1, 3, 10, 15, 28], 2:3; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, :, [1, 3, 10, 15, 28]; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, [1, 3, 10, 15, 28], :; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, "Z100"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, "Sheet1!Z100"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, "Sheet1!E1:F5,Sheet1!Z100"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(f, "Sheet1!garbage"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, "Sheet1!garbage"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, "garbage"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, "garbage1:garbage2"; horizontal="right", vertical="justify", wrapText=true) + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setAlignment(s, "A1"; horizontal="right", vertical="justify", wrapText=true) + XLSX.setUniformAlignment(s, 1, 1:2:25) + @test XLSX.getAlignment(s, 1, 1).alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, 1, 9).alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, 1, 19).alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, 1, 25).alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, 1, 8) === nothing + @test XLSX.getAlignment(s, 1, 16) === nothing + @test XLSX.getAlignment(s, 1, 22) === nothing + @test XLSX.getAlignment(s, 1, 24) === nothing + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setAlignment(s, "A2"; horizontal="right", vertical="justify", wrapText=true) + XLSX.setUniformAlignment(s, 2:2:26, :) + @test XLSX.getAlignment(s, "A2").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, "C4").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, "K6").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, "Y24").alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1")) + @test XLSX.getAlignment(s, "A3") === nothing + @test XLSX.getAlignment(s, "C5") === nothing + @test XLSX.getAlignment(s, "K7") === nothing + @test XLSX.getAlignment(s, "Y25") === nothing + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, 2, [1, 3, 10, 15, 28]; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, [1, 3, 10, 15, 28], 2:3; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, :, [1, 3, 10, 15, 28]; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, [1, 3, 10, 15, 28], :; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "Z100:Z101"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "Sheet1!Z100:Z101"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "Sheet1!E1:F5,Sheet1!Z100"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "Z100:Z100"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "Sheet1!Z100:Z100"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "Sheet1!Z100,Sheet1!Z100"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(f, "Sheet1!garbage"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "Sheet1!garbage"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "garbage"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(s, "garbage1:garbage2"; horizontal="right", vertical="justify", wrapText=true) + end @testset "setFormat" begin @@ -1810,6 +2920,10 @@ end @test XLSX.setFormat(s, "F1:F5"; format="#,##0.000") == -1 @test XLSX.getFormat(s, "E2").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) @test XLSX.getFormat(f, "Sheet1!F2").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) + @test_throws XLSX.XLSXError XLSX.setFormat(s, "Z100"; format="Currency") + @test_throws XLSX.XLSXError XLSX.setFormat(s, "Sheet1!Z100"; format="Currency") + @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) @@ -1824,6 +2938,174 @@ end isfile("test.xlsx") && rm("test.xlsx") + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setFormat(s, "Sheet1!E5"; format="Currency") + @test XLSX.getFormat(f, "Sheet1!E5").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setFormat(s, "Sheet1!W5:X8"; format="Currency") + @test XLSX.getFormat(f, "Sheet1!X7").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setFormat(s, "Sheet1!F:G"; format="Currency") + @test XLSX.getFormat(s, "F3").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setFormat(s, "Sheet1!4:8"; format="Currency") + @test XLSX.getFormat(s, "Q7").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setFormat(s, "N4,M8:M15,Z25:Z26"; format="#,##0.000") + @test XLSX.getFormat(s, "Z26").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setFormat(s, :, 2:4; format="Currency") + @test XLSX.getFormat(f, "Sheet1!B23").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setFormat(s, 4:3:10, :; format="Currency") + @test XLSX.getFormat(s, "Q7").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setFormat(s, :, [8, 23, 4]; format="Currency") + @test XLSX.getFormat(s, "H1").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setFormat(s, 25:26, 20:26; format="#,##0.000") + @test XLSX.getFormat(s, "Z26").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) + XLSX.setFormat(s, 25:26, 15; format="#,##0.0000") + @test XLSX.getFormat(s, "O26").format == Dict("numFmt" => Dict("formatCode" => "#,##0.0000")) + XLSX.setFormat(s, 21:2:25, [15, 16]; format="#,##0.0") + @test XLSX.getFormat(s, "P25").format == Dict("numFmt" => Dict("formatCode" => "#,##0.0")) + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setUniformFormat(s, "Sheet1!W5:X8"; format="Currency") + @test XLSX.getFormat(f, "Sheet1!X7").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setUniformFormat(s, "Sheet1!F:G"; format="Currency") + @test XLSX.getFormat(s, "F3").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setUniformFormat(s, "Sheet1!4:8"; format="Currency") + @test XLSX.getFormat(s, "Q7").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setUniformFormat(s, "N4,M8:M15,Z25:Z26"; format="#,##0.000") + @test XLSX.getFormat(s, "Z26").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) + @test_throws XLSX.XLSXError XLSX.setUniformFormat(s, "Z100:Z101"; format="Currency") + @test_throws XLSX.XLSXError XLSX.setUniformFormat(s, "Sheet1!Z100:Z101"; format="Currency") + @test_throws XLSX.XLSXError XLSX.setUniformFormat(s, "Sheet1!E1:F5,Sheet1!Z100"; format="Currency") + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setUniformFormat(s, :, 2:4; format="Currency") + @test XLSX.getFormat(f, "Sheet1!B23").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setUniformFormat(s, 4:3:10, :; format="Currency") + @test XLSX.getFormat(s, "Q7").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setUniformFormat(s, :, [8, 23, 4]; format="Currency") + @test XLSX.getFormat(s, "H1").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setUniformFormat(s, 25:26, 20:26; format="#,##0.000") + @test XLSX.getFormat(s, "Z26").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) + XLSX.setUniformFormat(s, 25:26, 15; format="#,##0.0000") + @test XLSX.getFormat(s, "O26").format == Dict("numFmt" => Dict("formatCode" => "#,##0.0000")) + XLSX.setUniformFormat(s, 21:2:25, [15, 16]; format="#,##0.0") + @test XLSX.getFormat(s, "P25").format == Dict("numFmt" => Dict("formatCode" => "#,##0.0")) + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setUniformFormat(s, :, :; format="Currency") + @test XLSX.getFormat(f, "Sheet1!B23").format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setUniformFormat(s, 4:10, :; format="#,##0.000") + @test XLSX.getFormat(s, "Q7").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) + XLSX.setUniformFormat(s, [8, 23, 4], 8; format="#,##0.0") + @test XLSX.getFormat(s, "H8").format == Dict("numFmt" => Dict("formatCode" => "#,##0.0")) + + end + + @testset "UniformStyle" begin + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + + XLSX.setFont(s, "A1:F5"; size=18, name="Arial") + cell_style = parse(Int, XLSX.getcell(s, "A1").style) + @test XLSX.setUniformStyle(s, "A1:F5") == cell_style + @test parse(Int, XLSX.getcell(s, "F5").style) == cell_style + + XLSX.setFont(s, "A6:F10"; size=10, name="Aptos") + cell_style = parse(Int, XLSX.getcell(s, "E6").style) + @test XLSX.setUniformStyle(s, [6, 7, 8, 9, 10], 5) == cell_style + @test parse(Int, XLSX.getcell(s, "E8").style) == cell_style + + XLSX.setFont(s, "A11:F15"; size=10, name="Times New Roman") + cell_style = parse(Int, XLSX.getcell(s, "E6").style) + @test XLSX.setUniformStyle(s, [6, 7, 8, 9, 10], :) == cell_style + @test parse(Int, XLSX.getcell(s, "Z8").style) == cell_style + + XLSX.setFont(s, "A16"; size=80, name="Ariel") + cell_style = parse(Int, XLSX.getcell(s, "A16").style) + @test XLSX.setUniformStyle(s, "A16,A15,D20:E25,F25") == cell_style + @test parse(Int, XLSX.getcell(s, "A15").style) == cell_style + @test parse(Int, XLSX.getcell(s, "D20").style) == cell_style + @test parse(Int, XLSX.getcell(s, "F25").style) == cell_style + + XLSX.setFont(s, "A1"; size=8, name="Aptos") + cell_style = parse(Int, XLSX.getcell(s, "A1").style) + @test XLSX.setUniformStyle(s, :) == cell_style + @test parse(Int, XLSX.getcell(s, "A1").style) == cell_style + @test parse(Int, XLSX.getcell(s, "M13").style) == cell_style + @test parse(Int, XLSX.getcell(s, "Z26").style) == cell_style + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setFont(s, "A1"; size=8, name="Aptos") + cell_style = parse(Int, XLSX.getcell(s, "A1").style) + @test XLSX.setUniformStyle(s, "Sheet1!A1:A26") == cell_style + @test parse(Int, XLSX.getcell(s, "A2").style) == cell_style + @test parse(Int, XLSX.getcell(s, "A13").style) == cell_style + @test parse(Int, XLSX.getcell(s, "A26").style) == cell_style + @test XLSX.setUniformStyle(s, "Sheet1!1:2") == cell_style + @test parse(Int, XLSX.getcell(s, "B1").style) == cell_style + @test parse(Int, XLSX.getcell(s, "M2").style) == cell_style + @test parse(Int, XLSX.getcell(s, "Z1").style) == cell_style + @test XLSX.setUniformStyle(s, "Sheet1!B:C") == cell_style + @test parse(Int, XLSX.getcell(s, "C3").style) == cell_style + @test parse(Int, XLSX.getcell(s, "B13").style) == cell_style + @test parse(Int, XLSX.getcell(s, "C26").style) == cell_style + + XLSX.setFont(s, "A1"; size=8, name="Arial") + cell_style = parse(Int, XLSX.getcell(s, "A1").style) + @test XLSX.setUniformStyle(s, "A1:A26") == cell_style + @test parse(Int, XLSX.getcell(s, "A2").style) == cell_style + @test parse(Int, XLSX.getcell(s, "A13").style) == cell_style + @test parse(Int, XLSX.getcell(s, "A26").style) == cell_style + @test XLSX.setUniformStyle(s, "1:2") == cell_style + @test parse(Int, XLSX.getcell(s, "B1").style) == cell_style + @test parse(Int, XLSX.getcell(s, "M2").style) == cell_style + @test parse(Int, XLSX.getcell(s, "Z1").style) == cell_style + @test XLSX.setUniformStyle(s, "B:C") == cell_style + @test parse(Int, XLSX.getcell(s, "C3").style) == cell_style + @test parse(Int, XLSX.getcell(s, "B13").style) == cell_style + @test parse(Int, XLSX.getcell(s, "C26").style) == cell_style + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setFont(s, "A1"; size=8, name="Aptos") + cell_style = parse(Int, XLSX.getcell(s, "A1").style) + @test XLSX.setUniformStyle(s, 1, :) == cell_style + @test parse(Int, XLSX.getcell(s, "B1").style) == cell_style + @test XLSX.setUniformStyle(s, :, 2) == cell_style + @test parse(Int, XLSX.getcell(s, "B13").style) == cell_style + @test XLSX.setUniformStyle(s, :, 5:2:15) == cell_style + @test parse(Int, XLSX.getcell(s, "E25").style) == cell_style + @test XLSX.setUniformStyle(s, 5:10, [15, 16, 17]) == cell_style + @test parse(Int, XLSX.getcell(s, "P10").style) == cell_style + @test XLSX.setUniformStyle(s, 5:10, 17:19) == cell_style + @test parse(Int, XLSX.getcell(s, "S10").style) == cell_style + @test XLSX.setUniformStyle(s, [10, 12, 26], [19, 24, 26]) == cell_style + @test parse(Int, XLSX.getcell(s, "Z26").style) == cell_style + @test XLSX.setUniformStyle(s, :, :) == cell_style + @test parse(Int, XLSX.getcell(s, "Y4").style) == cell_style + @test_throws XLSX.XLSXError XLSX.setUniformStyle(s, :, [1, 3, 10, 15, 28]) + @test_throws XLSX.XLSXError XLSX.setUniformStyle(s, [1, 3, 10, 15, 28], :) + @test_throws XLSX.XLSXError XLSX.setUniformStyle(s, 1, [1, 3, 10, 15, 28]) + @test_throws XLSX.XLSXError XLSX.setUniformStyle(s, [1, 3, 10, 15, 28], 2:3) + @test_throws XLSX.XLSXError XLSX.setUniformStyle(f, "Sheet1!garbage") + @test_throws XLSX.XLSXError XLSX.setUniformStyle(s, "Sheet1!garbage") + @test_throws XLSX.XLSXError XLSX.setUniformStyle(s, "garbage") + @test_throws XLSX.XLSXError XLSX.setUniformStyle(s, "garbage1:garbage2") + end @testset "Width and height" begin @@ -1873,44 +3155,2360 @@ end f = XLSX.open_xlsx_template(joinpath(data_directory, "customXml.xlsx")) s = f["Mock-up"] - XLSX.setColumnWidth(s, "Location"; width = 60) - XLSX.setRowHeight(s, "Location"; height = 50) + XLSX.setColumnWidth(s, "Location"; width=60) + XLSX.setRowHeight(s, "Location"; height=50) @test XLSX.getRowHeight(s, "D18") ≈ 50.2109375 @test XLSX.getColumnWidth(s, "D18") ≈ 60.7109375 @test XLSX.getRowHeight(f, "Mock-up!J20") ≈ 50.2109375 @test XLSX.getColumnWidth(f, "Mock-up!J20") ≈ 60.7109375 + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setColumnWidth(s, "Sheet1!A1"; width=60) + @test XLSX.getColumnWidth(s, "A1") ≈ 60.7109375 + XLSX.setColumnWidth(s, "Sheet1!A1:Z1"; width=60) + @test XLSX.getColumnWidth(s, "R1") ≈ 60.7109375 + XLSX.setColumnWidth(s, "Sheet1!A:B"; width=60) + @test XLSX.getColumnWidth(s, "B26") ≈ 60.7109375 + XLSX.setColumnWidth(s, "Sheet1!2:3"; width=60) + @test XLSX.getColumnWidth(s, "R26") ≈ 60.7109375 + XLSX.setColumnWidth(s, "A:B"; width=30.5) + @test XLSX.getColumnWidth(s, "B26") ≈ 31.2109375 + XLSX.setColumnWidth(s, "2:3"; width=30.5) + @test XLSX.getColumnWidth(s, "R3") ≈ 31.2109375 + XLSX.setColumnWidth(s, "Sheet1!C5:C7,Sheet1!F5:F7,Sheet1!H7"; width=10.1) + @test XLSX.getColumnWidth(s, "F26") ≈ 10.8109375 + XLSX.setColumnWidth(s, 5, :; width=10.0) + @test XLSX.getColumnWidth(s, "Q5") ≈ 10.7109375 + XLSX.setColumnWidth(s, 5:7; width=10.2) + @test XLSX.getColumnWidth(s, "G22") ≈ 10.9109375 + XLSX.setColumnWidth(s, :, 5:7; width=10.3) + @test XLSX.getColumnWidth(s, "G22") ≈ 11.0109375 + XLSX.setColumnWidth(s, :, :; width=10.4) + @test XLSX.getColumnWidth(s, "G22") ≈ 11.1109375 + XLSX.setColumnWidth(s, :; width=10.5) + @test XLSX.getColumnWidth(s, "G22") ≈ 11.2109375 + XLSX.setColumnWidth(s, 2:3:11, :; width=10.6) + @test XLSX.getColumnWidth(s, "Z26") ≈ 11.3109375 + XLSX.setColumnWidth(s, 2:3:11; width=10.7) + @test XLSX.getColumnWidth(s, "E26") ≈ 11.4109375 + XLSX.setColumnWidth(s, :, [2, 3, 11]; width=10.8) + @test XLSX.getColumnWidth(s, "K15") ≈ 11.5109375 + XLSX.setColumnWidth(s, 3:6, [2, 3, 11]; width=10.9) + @test XLSX.getColumnWidth(s, "K15") ≈ 11.6109375 + XLSX.setColumnWidth(s, 3:3:6, [2, 3, 11]; width=11.0) + @test XLSX.getColumnWidth(s, "K15") ≈ 11.7109375 + XLSX.setColumnWidth(s, 11, 7:13; width=11.1) + @test XLSX.getColumnWidth(s, "K15") ≈ 11.8109375 + + f = XLSX.newxlsx() + s = f[1] + s["A1:Z26"] = "" + XLSX.setRowHeight(s, "Sheet1!A1"; height=10.1) + @test XLSX.getRowHeight(s, "A1") ≈ 10.3109375 + XLSX.setRowHeight(s, "Sheet1!A1:A26"; height=10.2) + @test XLSX.getRowHeight(s, "R20") ≈ 10.4109375 + XLSX.setRowHeight(s, "Sheet1!A:B"; height=10.3) + @test XLSX.getRowHeight(s, "B26") ≈ 10.5109375 + XLSX.setRowHeight(s, "Sheet1!2:3"; height=10.4) + @test XLSX.getRowHeight(s, "R3") ≈ 10.6109375 + XLSX.setRowHeight(s, "A:B"; height=10.5) + @test XLSX.getRowHeight(s, "B26") ≈ 10.7109375 + XLSX.setRowHeight(s, "2:3"; height=10.6) + @test XLSX.getRowHeight(s, "R3") ≈ 10.8109375 + XLSX.setRowHeight(s, "Sheet1!C5:C7,Sheet1!F5:F7,Sheet1!H7"; height=10.7) + @test XLSX.getRowHeight(s, "F6") ≈ 10.9109375 + XLSX.setRowHeight(s, 5, :; height=10.8) + @test XLSX.getRowHeight(s, "Q5") ≈ 11.0109375 + XLSX.setRowHeight(s, 5:7; height=10.9) + @test XLSX.getRowHeight(s, "P6") ≈ 11.1109375 + XLSX.setRowHeight(s, :, 5:7; height=11.0) + @test XLSX.getRowHeight(s, "G22") ≈ 11.2109375 + XLSX.setRowHeight(s, :, :; height=11.1) + @test XLSX.getRowHeight(s, "G22") ≈ 11.3109375 + XLSX.setRowHeight(s, :; height=11.2) + @test XLSX.getRowHeight(s, "G22") ≈ 11.4109375 + XLSX.setRowHeight(s, 2:3:11, :; height=11.3) + @test XLSX.getRowHeight(s, "J8") ≈ 11.5109375 + XLSX.setRowHeight(s, 2:3:11; height=11.4) + @test XLSX.getRowHeight(s, "J8") ≈ 11.6109375 + XLSX.setRowHeight(s, :, [2, 3, 11]; height=11.5) + @test XLSX.getRowHeight(s, "K15") ≈ 11.7109375 + XLSX.setRowHeight(s, 3:6, [2, 3, 11]; height=11.6) + @test XLSX.getRowHeight(s, "K5") ≈ 11.8109375 + XLSX.setRowHeight(s, 3:3:6, [2, 3, 11]; height=11.7) + @test XLSX.getRowHeight(s, "K6") ≈ 11.9109375 + XLSX.setRowHeight(s, 11, 7:13; height=11.8) + @test XLSX.getRowHeight(s, "K11") ≈ 12.0109375 + end @testset "No cache" begin XLSX.openxlsx(joinpath(data_directory, "customXml.xlsx"); mode="r", enable_cache=true) do f @test XLSX.getRowHeight(f, "Mock-up!B2") ≈ 23.25 - @test_throws AssertionError XLSX.getColumnWidth(f, "Mock-up!B2") + @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 - @test_throws AssertionError XLSX.getRowHeight(f, "Mock-up!B2") - @test_throws AssertionError XLSX.getColumnWidth(f, "Mock-up!B2") - @test_throws AssertionError XLSX.getFont(f, "Mock-up!B2") - @test_throws AssertionError XLSX.getFill(f, "Mock-up!B2") - @test_throws AssertionError XLSX.getBorder(f, "Mock-up!B2") - @test_throws AssertionError XLSX.getFormat(f, "Mock-up!B2") - @test_throws AssertionError XLSX.getAlignment(f, "Mock-up!B2") - @test_throws AssertionError XLSX.setRowHeight(f, "Mock-up!B2") - @test_throws AssertionError XLSX.setColumnWidth(f, "Mock-up!B2") - @test_throws AssertionError XLSX.setFont(f, "Mock-up!B2") - @test_throws AssertionError XLSX.setFill(f, "Mock-up!B2") - @test_throws AssertionError XLSX.setBorder(f, "Mock-up!B2") - @test_throws AssertionError XLSX.setFormat(f, "Mock-up!B2") - @test_throws AssertionError XLSX.setAlignment(f, "Mock-up!B2") - @test_throws AssertionError XLSX.setUniformFont(f, "Mock-up!B2:C4") - @test_throws AssertionError XLSX.setUniformFill(f, "Mock-up!B2:C4") - @test_throws AssertionError XLSX.setUniformBorder(f, "Mock-up!B2:C4") - @test_throws AssertionError XLSX.setUniformFormat(f, "Mock-up!B2:C4") - @test_throws AssertionError XLSX.setUniformAlignment(f, "Mock-up!B2:C4") - @test_throws AssertionError XLSX.setOutsideBorder(f, "Mock-up!B2:C4") + @test_throws XLSX.XLSXError XLSX.getRowHeight(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.getFont(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.getFill(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.getBorder(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.getFormat(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.getAlignment(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.setRowHeight(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.setColumnWidth(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.setFont(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.setFill(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.setBorder(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.setFormat(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.setAlignment(f, "Mock-up!B2") + @test_throws XLSX.XLSXError XLSX.setUniformFont(f, "Mock-up!B2:C4") + @test_throws XLSX.XLSXError XLSX.setUniformFill(f, "Mock-up!B2:C4") + @test_throws XLSX.XLSXError XLSX.setUniformBorder(f, "Mock-up!B2:C4") + @test_throws XLSX.XLSXError XLSX.setUniformFormat(f, "Mock-up!B2:C4") + @test_throws XLSX.XLSXError XLSX.setUniformAlignment(f, "Mock-up!B2:C4") + @test_throws XLSX.XLSXError XLSX.setOutsideBorder(f, "Mock-up!B2:C4") end end + @testset "indexing setAttribute" begin + f = XLSX.newxlsx() # Empty XLSXFile + s = f[1] #1×1 XLSX.Worksheet: ["Sheet1"](A1:A1) + + #Can't write to single, empty cells + @test_throws XLSX.XLSXError XLSX.setFont(s, "A1"; color="grey42") + @test_throws XLSX.XLSXError XLSX.setFont(s, "A1:A1"; color="grey42") + @test_throws XLSX.XLSXError XLSX.setFont(s, "A:A"; color="grey42") + @test_throws XLSX.XLSXError XLSX.setFont(s, "1"; color="grey42") + @test_throws XLSX.XLSXError XLSX.setFont(s, 1, 1; color="grey42") + @test_throws XLSX.XLSXError XLSX.setFont(s, [1], 1; color="grey42") + @test_throws XLSX.XLSXError XLSX.setFont(s, 1, 1:1; color="grey42") + @test_throws XLSX.XLSXError XLSX.setFont(s, 1, :; color="grey42") + @test_throws XLSX.XLSXError XLSX.setFont(s, :; color="grey42") + @test_throws XLSX.XLSXError XLSX.setFont(s, :, :; color="grey42") + + s[2, 1] = "" + s[3, 3] = "" + # Skip empty cells silently in ranges + @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) + + s[1:3, 1:3] = "" + default_font = XLSX.getDefaultFont(s).font + dname = default_font["name"]["val"] + dsize = default_font["sz"]["val"] + XLSX.setFont(s, "A1"; color="grey42") + @test XLSX.getFont(s, "A1").font == Dict("name" => Dict("val" => dname), "sz" => Dict("val" => dsize), "color" => Dict("rgb" => "FF6B6B6B")) + XLSX.setFont(s, 2, 2; color="grey43", name="Ariel") + @test XLSX.getFont(s, 2, 2).font == Dict("name" => Dict("val" => "Ariel"), "sz" => Dict("val" => dsize), "color" => Dict("rgb" => "FF6E6E6E")) + XLSX.setFont(s, [2, 3], 1:3; color="grey44", name="Courier New") + @test XLSX.getFont(s, 3, 1).font == Dict("name" => Dict("val" => "Courier New"), "sz" => Dict("val" => dsize), "color" => Dict("rgb" => "FF707070")) + @test XLSX.getFont(s, 2, 2).font == Dict("name" => Dict("val" => "Courier New"), "sz" => Dict("val" => dsize), "color" => Dict("rgb" => "FF707070")) + @test XLSX.getFont(s, 3, 3).font == Dict("name" => Dict("val" => "Courier New"), "sz" => Dict("val" => dsize), "color" => Dict("rgb" => "FF707070")) + + f = XLSX.newxlsx() + s = f[1] + @test_throws XLSX.XLSXError XLSX.setBorder(s, "A1"; allsides=["color" => "grey42", "style" => "thick"]) + @test_throws XLSX.XLSXError XLSX.setBorder(s, "A1:A1"; allsides=["color" => "grey42", "style" => "thick"]) + @test_throws XLSX.XLSXError XLSX.setBorder(s, "A"; allsides=["color" => "grey42", "style" => "thick"]) + @test_throws XLSX.XLSXError XLSX.setBorder(s, "1"; allsides=["color" => "grey42", "style" => "thick"]) + @test_throws XLSX.XLSXError XLSX.setBorder(s, 1, 1; allsides=["color" => "grey42", "style" => "thick"]) + @test_throws XLSX.XLSXError XLSX.setBorder(s, [1], 1; allsides=["color" => "grey42", "style" => "thick"]) + @test_throws XLSX.XLSXError XLSX.setBorder(s, 1, 1:1; allsides=["color" => "grey42", "style" => "thick"]) + @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) + 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) + XLSX.setBorder(s, 2, 2; allsides=["color" => "grey43", "style" => "thin"]) + @test XLSX.getBorder(s, 2, 2).border == Dict("left" => Dict("rgb" => "FF6E6E6E", "style" => "thin"), "bottom" => Dict("rgb" => "FF6E6E6E", "style" => "thin"), "right" => Dict("rgb" => "FF6E6E6E", "style" => "thin"), "top" => Dict("rgb" => "FF6E6E6E", "style" => "thin"), "diagonal" => nothing) + XLSX.setBorder(s, [2, 3], 1:3; allsides=["color" => "grey44", "style" => "hair"], diagonal=["color" => "grey44", "style" => "thin", "direction" => "down"]) + @test XLSX.getBorder(s, 3, 1).border == Dict("left" => Dict("rgb" => "FF707070", "style" => "hair"), "bottom" => Dict("rgb" => "FF707070", "style" => "hair"), "right" => Dict("rgb" => "FF707070", "style" => "hair"), "top" => Dict("rgb" => "FF707070", "style" => "hair"), "diagonal" => Dict("rgb" => "FF707070", "style" => "thin", "direction" => "down")) + @test XLSX.getBorder(s, 2, 2).border == Dict("left" => Dict("rgb" => "FF707070", "style" => "hair"), "bottom" => Dict("rgb" => "FF707070", "style" => "hair"), "right" => Dict("rgb" => "FF707070", "style" => "hair"), "top" => Dict("rgb" => "FF707070", "style" => "hair"), "diagonal" => Dict("rgb" => "FF707070", "style" => "thin", "direction" => "down")) + @test XLSX.getBorder(s, 3, 3).border == Dict("left" => Dict("rgb" => "FF707070", "style" => "hair"), "bottom" => Dict("rgb" => "FF707070", "style" => "hair"), "right" => Dict("rgb" => "FF707070", "style" => "hair"), "top" => Dict("rgb" => "FF707070", "style" => "hair"), "diagonal" => Dict("rgb" => "FF707070", "style" => "thin", "direction" => "down")) + + f = XLSX.newxlsx() + s = f[1] + @test_throws XLSX.XLSXError XLSX.setFill(s, "A1"; pattern="lightVertical", fgColor="Red", bgColor="blue") + @test_throws XLSX.XLSXError XLSX.setFill(s, "A1:A1"; pattern="lightVertical", fgColor="Red", bgColor="blue") + @test_throws XLSX.XLSXError XLSX.setFill(s, "A"; pattern="lightVertical", fgColor="Red", bgColor="blue") + @test_throws XLSX.XLSXError XLSX.setFill(s, "1"; pattern="lightVertical", fgColor="Red", bgColor="blue") + @test_throws XLSX.XLSXError XLSX.setFill(s, 1, 1; pattern="lightVertical", fgColor="Red", bgColor="blue") + @test_throws XLSX.XLSXError XLSX.setFill(s, [1], 1; pattern="lightVertical", fgColor="Red", bgColor="blue") + @test_throws XLSX.XLSXError XLSX.setFill(s, 1, 1:1; pattern="lightVertical", fgColor="Red", bgColor="blue") + @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) + 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")) + XLSX.setFill(s, 2, 2; pattern="lightGrid", fgColor="Red", bgColor="blue") + @test XLSX.getFill(s, 2, 2).fill == Dict("patternFill" => Dict("bgrgb" => "FF0000FF", "patternType" => "lightGrid", "fgrgb" => "FFFF0000")) + XLSX.setFill(s, [2, 3], 1:3; pattern="lightGrid", fgColor="Red", bgColor="blue") + @test XLSX.getFill(s, 3, 1).fill == Dict("patternFill" => Dict("bgrgb" => "FF0000FF", "patternType" => "lightGrid", "fgrgb" => "FFFF0000")) + @test XLSX.getFill(s, 2, 2).fill == Dict("patternFill" => Dict("bgrgb" => "FF0000FF", "patternType" => "lightGrid", "fgrgb" => "FFFF0000")) + @test XLSX.getFill(s, 3, 3).fill == Dict("patternFill" => Dict("bgrgb" => "FF0000FF", "patternType" => "lightGrid", "fgrgb" => "FFFF0000")) + + f = XLSX.newxlsx() + s = f[1] + @test_throws XLSX.XLSXError XLSX.setAlignment(s, "A1"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, "A1:A1"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, "A"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, "1"; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, 1, 1; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, [1], 1; horizontal="right", vertical="justify", wrapText=true) + @test_throws XLSX.XLSXError XLSX.setAlignment(s, 1, 1:1; horizontal="right", vertical="justify", wrapText=true) + @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) + 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")) + XLSX.setAlignment(s, 2, 2; horizontal="right", vertical="justify", wrapText=true, rotation=90) + @test XLSX.getAlignment(s, 2, 2).alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1", "textRotation" => "90")) + XLSX.setAlignment(s, [2, 3], 1:3; horizontal="right", vertical="justify", shrink=true, rotation=90) + @test XLSX.getAlignment(s, 3, 1).alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "shrinkToFit" => "1", "textRotation" => "90")) + @test XLSX.getAlignment(s, 2, 2).alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "wrapText" => "1", "shrinkToFit" => "1", "textRotation" => "90")) + @test XLSX.getAlignment(s, 3, 3).alignment == Dict("alignment" => Dict("horizontal" => "right", "vertical" => "justify", "shrinkToFit" => "1", "textRotation" => "90")) + + f = XLSX.newxlsx() + s = f[1] + @test_throws XLSX.XLSXError XLSX.setFormat(s, "A1"; format="Percentage") + @test_throws XLSX.XLSXError XLSX.setFormat(s, "A1:A1"; format="Percentage") + @test_throws XLSX.XLSXError XLSX.setFormat(s, "A"; format="Percentage") + @test_throws XLSX.XLSXError XLSX.setFormat(s, "1"; format="Percentage") + @test_throws XLSX.XLSXError XLSX.setFormat(s, 1, 1; format="Percentage") + @test_throws XLSX.XLSXError XLSX.setFormat(s, [1], 1; format="Percentage") + @test_throws XLSX.XLSXError XLSX.setFormat(s, 1, 1:1; format="Percentage") + @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) + s[1:3, 1:3] = "" + XLSX.setFormat(s, "A1"; format="#,##0.000;(#,##0.000)") + @test XLSX.getFormat(s, "A1").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000;(#,##0.000)")) + XLSX.setFormat(s, 2, 2; format="Currency") + @test XLSX.getFormat(s, 2, 2).format == Dict("numFmt" => Dict("numFmtId" => "7", "formatCode" => "\$#,##0.00_);(\$#,##0.00)")) + XLSX.setFormat(s, [2, 3], 1:3; format="LongDate") + @test XLSX.getFormat(s, 3, 1).format == Dict("numFmt" => Dict("numFmtId" => "15", "formatCode" => "d-mmm-yy")) + @test XLSX.getFormat(s, 2, 2).format == Dict("numFmt" => Dict("numFmtId" => "15", "formatCode" => "d-mmm-yy")) + @test XLSX.getFormat(s, 3, 3).format == Dict("numFmt" => Dict("numFmtId" => "15", "formatCode" => "d-mmm-yy")) + + f = XLSX.newxlsx() + s = f[1] + @test_throws XLSX.XLSXError XLSX.getColumnWidth(s, "B2") # Cell outside sheet dimension + s[1:3, 1:3] = "" + XLSX.setColumnWidth(s, "A1"; width=30) + @test XLSX.getColumnWidth(s, "A1") ≈ 30.7109375 + XLSX.setColumnWidth(s, 2, 2; width=40) + @test XLSX.getColumnWidth(s, 2, 2) ≈ 40.7109375 + XLSX.setColumnWidth(s, [2, 3], 1:3; width=50) + @test XLSX.getColumnWidth(s, 3, 1) ≈ 50.7109375 + @test XLSX.getColumnWidth(s, 2, 2) ≈ 50.7109375 + @test XLSX.getColumnWidth(s, 3, 3) ≈ 50.7109375 + + f = XLSX.newxlsx() + s = f[1] + @test_throws XLSX.XLSXError XLSX.getRowHeight(s, "B2") # Cell outside sheet dimension + s[1:3, 1:3] = "" + XLSX.setRowHeight(s, "A1"; height=30) + @test XLSX.getRowHeight(s, "A1") ≈ 30.2109375 + XLSX.setRowHeight(s, 2, 2; height=40) + @test XLSX.getRowHeight(s, 2, 2) ≈ 40.2109375 + XLSX.setRowHeight(s, [2, 3], 1:3; height=50) + @test XLSX.getRowHeight(s, 3, 1) ≈ 50.2109375 + @test XLSX.getRowHeight(s, 2, 2) ≈ 50.2109375 + @test XLSX.getRowHeight(s, 3, 3) ≈ 50.2109375 + + f = XLSX.newxlsx() + s = f[1] + s[1:30, 1:26] = "" + XLSX.setUniformFont(s, 1:4, :; size=12, name="Times New Roman", color="FF040404") + @test XLSX.getFont(f, "Sheet1!A1").font == Dict("sz" => Dict("val" => "12"), "name" => Dict("val" => "Times New Roman"), "color" => Dict("rgb" => "FF040404")) + @test XLSX.getFont(f, "Sheet1!G2").font == Dict("sz" => Dict("val" => "12"), "name" => Dict("val" => "Times New Roman"), "color" => Dict("rgb" => "FF040404")) + @test XLSX.getFont(f, "Sheet1!N3").font == Dict("sz" => Dict("val" => "12"), "name" => Dict("val" => "Times New Roman"), "color" => Dict("rgb" => "FF040404")) + @test XLSX.getFont(f, "Sheet1!Y4").font == Dict("sz" => Dict("val" => "12"), "name" => Dict("val" => "Times New Roman"), "color" => Dict("rgb" => "FF040404")) + + XLSX.setUniformFill(s, :, 2:8; pattern="lightGrid", fgColor="FF0000FF", bgColor="FF00FF00") + @test XLSX.getFill(s, "B10").fill == Dict("patternFill" => Dict("bgrgb" => "FF00FF00", "patternType" => "lightGrid", "fgrgb" => "FF0000FF")) + @test XLSX.getFill(s, "D20").fill == Dict("patternFill" => Dict("bgrgb" => "FF00FF00", "patternType" => "lightGrid", "fgrgb" => "FF0000FF")) + @test XLSX.getFill(s, "F30").fill == Dict("patternFill" => Dict("bgrgb" => "FF00FF00", "patternType" => "lightGrid", "fgrgb" => "FF0000FF")) + + XLSX.setUniformFormat(s, :; format="#,##0.000") + @test XLSX.getFormat(s, "A1").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) + @test XLSX.getFormat(s, "G10").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) + @test XLSX.getFormat(s, "M20").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) + @test XLSX.getFormat(s, "X30").format == Dict("numFmt" => Dict("formatCode" => "#,##0.000")) + + f = XLSX.open_xlsx_template(joinpath(data_directory, "Borders.xlsx")) + s = f["Sheet1"] + XLSX.setUniformBorder(s, [1, 2, 3, 4], 1:4; left=["style" => "dotted", "color" => "darkseagreen3"], + right=["style" => "medium", "color" => "FF765000"], + top=["style" => "thick", "color" => "FF230000"], + bottom=["style" => "medium", "color" => "FF0000FF"], + diagonal=["style" => "none"] + ) + @test XLSX.getBorder(s, 1, 1).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) + @test XLSX.getBorder(s, 2, 2).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) + @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) + + 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) + end +end + +@testset "Conditional Formats" begin + + @testset "DataBar" begin + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + + @test_throws MethodError XLSX.setConditionalFormat(s, "A1,A3", :dataBar) # Non-contiguous ranges not allowed + @test_throws MethodError XLSX.setConditionalFormat(s, [1], 1, :dataBar) # Vectors may be non-contiguous + @test_throws MethodError XLSX.setConditionalFormat(s, 1, 1:3:7, :dataBar) # StepRange is non-contiguous + @test XLSX.setConditionalFormat(s, "1:1", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, 2, :, :dataBar; databar="greengrad") == 0 + @test XLSX.setConditionalFormat(s, 3, 1:5, :dataBar; + min_type="least", + min_val="green", #should be ignored because type=least + max_type="percentile", + max_val="50", + ) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A4:E4", :dataBar; + min_type="automatic", + max_type="automatic", + ) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A5:E5", :dataBar; + min_type="num", + min_val="\$A\$1", + max_type="formula", + max_val="\$A\$2" + ) == 0 + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("A5:E5") => (type="dataBar", priority=5), XLSX.CellRange("A4:E4") => (type="dataBar", priority=4), XLSX.CellRange("A3:E3") => (type="dataBar", priority=3), XLSX.CellRange("A2:E2") => (type="dataBar", priority=2), XLSX.CellRange("A1:E1") => (type="dataBar", priority=1)] + @test XLSX.setConditionalFormat(s, "A1", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, "A1:C3", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1:A2", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!1:2", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :dataBar) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!1:2", :dataBar) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A:C", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, :, 1:3, :dataBar) == 0 + @test XLSX.setConditionalFormat(s, 1:3, :, :dataBar) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, :, :dataBar) == 0 + @test XLSX.setConditionalFormat(s, :, :, :dataBar) == 0 + @test length(XLSX.getConditionalFormats(s)) == 22 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:E5") => (type="dataBar", priority=21), + XLSX.CellRange("A1:E5") => (type="dataBar", priority=22), + XLSX.CellRange("A1:E3") => (type="dataBar", priority=17), + XLSX.CellRange("A1:C5") => (type="dataBar", priority=12), + XLSX.CellRange("A1:C5") => (type="dataBar", priority=13), + XLSX.CellRange("A1:C5") => (type="dataBar", priority=15), + XLSX.CellRange("A1:C5") => (type="dataBar", priority=16), + XLSX.CellRange("A1:C5") => (type="dataBar", priority=19), + XLSX.CellRange("A1:C5") => (type="dataBar", priority=20), + XLSX.CellRange("A2:E4") => (type="dataBar", priority=11), + XLSX.CellRange("A2:E4") => (type="dataBar", priority=18), + XLSX.CellRange("A1:E2") => (type="dataBar", priority=10), + XLSX.CellRange("A1:E2") => (type="dataBar", priority=14), + XLSX.CellRange("A1:A2") => (type="dataBar", priority=9), + XLSX.CellRange("A1:C3") => (type="dataBar", priority=7), + XLSX.CellRange("A1:A1") => (type="dataBar", priority=6), + XLSX.CellRange("A1:A1") => (type="dataBar", priority=8), + XLSX.CellRange("A5:E5") => (type="dataBar", priority=5), + XLSX.CellRange("A4:E4") => (type="dataBar", priority=4), + XLSX.CellRange("A3:E3") => (type="dataBar", priority=3), + XLSX.CellRange("A2:E2") => (type="dataBar", priority=2), + XLSX.CellRange("A1:E1") => (type="dataBar", priority=1) + ] + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + @test XLSX.setConditionalFormat(s, "A1:A5", :dataBar) == 0 + @test XLSX.setConditionalFormat(s, :, 2, :dataBar; databar="orange") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!E:E", :dataBar; databar = "purplegrad") == 0 + @test XLSX.setConditionalFormat(s, 1:5, 3:4, :dataBar; + borders = "false", + min_type="percentile", + min_val="25", + max_type="percentile", + max_val="75" + ) == 0 + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("C1:D5") => (type="dataBar", priority=4), XLSX.CellRange("E1:E5") => (type="dataBar", priority=3), XLSX.CellRange("B1:B5") => (type="dataBar", priority=2), XLSX.CellRange("A1:A5") => (type="dataBar", priority=1)] + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + + @test XLSX.setConditionalFormat(s, :, 1:4, :dataBar; + databar="red", + borders="true", + fill_col="blue", + border_col="yellow", + neg_fill_col="magenta", + neg_border_col="green", + axis_col="cyan" + ) == 0 + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + XLSX.addDefinedName(s, "myRange", "A1:B5") + @test XLSX.setConditionalFormat(s, "myRange", :dataBar; + showVal = "false", + direction="leftToRight", + borders="true", + sameNegBorders="false" + ) == 0 + XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5") + @test_throws MethodError XLSX.setConditionalFormat(s, "myNCRange", :dataBar; # Non-contiguous ranges not allowed + showVal = "false", + direction="leftToRight", + borders="true", + sameNegBorders="false" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:A2", :dataBar; + databar="rainbow" + ) + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:12 + s[i, j] = i + j + end + s[1, 13]=5 + + @test XLSX.setConditionalFormat(s, :, 1, :dataBar; + databar="orange", + sameNegFill="true", + sameNegBorders="true" + )==0 + @test XLSX.setConditionalFormat(s, :, 2, :dataBar; + databar="orange", + axis_pos="none" + )==0 + @test XLSX.setConditionalFormat(s, :, 3, :dataBar; + databar="orange", + axis_pos="middle" + )==0 + @test XLSX.setConditionalFormat(s, :, 4, :dataBar; + databar="orange", + min_type="num", + min_val="Sheet1!\$M\$1" + )==0 + @test XLSX.setConditionalFormat(s, :, 5, :dataBar; + databar="orange", + showVal = "false", + direction="rightToLeft", + borders="true", + sameNegBorders="false", + sameNegFill="false" + ) == 0 + + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, :, 4, :dataBar; + axis_pos="nonsense", + databar="orange", + min_type="num", + min_val="Sheet1!\$M\$1" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, :, 4, :dataBar; + borders="nonsense", + databar="orange", + min_type="num", + min_val="Sheet1!\$M\$1" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, :, 4, :dataBar; + fill_col="nonsense", + databar="orange", + min_type="num", + min_val="Sheet1!\$M\$1" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, :, 4, :dataBar; + sameNegFill="nonsense", + databar="orange", + min_type="num", + min_val="Sheet1!\$M\$1" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, :, 4, :dataBar; + databar="orange", + min_type="num", + min_val="Sheet2!\$M\$1" + ) + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:12 + s[i, j] = i + j + end + for (j, k) in enumerate(keys(XLSX.databars)) + @test XLSX.setConditionalFormat(s, :, j, :dataBar; databar=k)==0 + end + end + + @testset "colorScale" begin + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1,A3", :wrongOne) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1, 2, :wrongOne) + + @test_throws MethodError XLSX.setConditionalFormat(s, "A1,A3", :colorScale) # Non-contiguous ranges not allowed + @test_throws MethodError XLSX.setConditionalFormat(s, [1], 1, :colorScale) # Vectors may be non-contiguous + @test_throws MethodError XLSX.setConditionalFormat(s, 1, 1:3:7, :colorScale) # StepRange is non-contiguous + @test XLSX.setConditionalFormat(s, "1:1", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, 2, :, :colorScale; colorscale="redwhiteblue") == 0 + @test XLSX.setConditionalFormat(s, 3, 1:5, :colorScale; + min_type="min", + min_col="green", + mid_type="percentile", + mid_val="50", + mid_col="red", + max_type="max", + max_col="blue" + ) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A4:E4", :colorScale; + min_type="min", + min_col="tomato", + max_type="max", + max_col="gold4" + ) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A5:E5", :colorScale; + min_type="min", + min_col="yellow", + max_type="max", + max_col="darkgreen" + ) == 0 + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("A5:E5") => (type="colorScale", priority=5), XLSX.CellRange("A4:E4") => (type="colorScale", priority=4), XLSX.CellRange("A3:E3") => (type="colorScale", priority=3), XLSX.CellRange("A2:E2") => (type="colorScale", priority=2), XLSX.CellRange("A1:E1") => (type="colorScale", priority=1)] + @test XLSX.setConditionalFormat(s, "A1", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, "A1:C3", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1:A2", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!1:2", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :colorScale) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!1:2", :colorScale) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A:C", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, :, 1:3, :colorScale) == 0 + @test XLSX.setConditionalFormat(s, 1:3, :, :colorScale) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, :, :colorScale) == 0 + @test XLSX.setConditionalFormat(s, :, :, :colorScale) == 0 + @test length(XLSX.getConditionalFormats(s)) == 22 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:E5") => (type="colorScale", priority=21), + XLSX.CellRange("A1:E5") => (type="colorScale", priority=22), + XLSX.CellRange("A1:E3") => (type="colorScale", priority=17), + XLSX.CellRange("A1:C5") => (type="colorScale", priority=12), + XLSX.CellRange("A1:C5") => (type="colorScale", priority=13), + XLSX.CellRange("A1:C5") => (type="colorScale", priority=15), + XLSX.CellRange("A1:C5") => (type="colorScale", priority=16), + XLSX.CellRange("A1:C5") => (type="colorScale", priority=19), + XLSX.CellRange("A1:C5") => (type="colorScale", priority=20), + XLSX.CellRange("A2:E4") => (type="colorScale", priority=11), + XLSX.CellRange("A2:E4") => (type="colorScale", priority=18), + XLSX.CellRange("A1:E2") => (type="colorScale", priority=10), + XLSX.CellRange("A1:E2") => (type="colorScale", priority=14), + XLSX.CellRange("A1:A2") => (type="colorScale", priority=9), + XLSX.CellRange("A1:C3") => (type="colorScale", priority=7), + XLSX.CellRange("A1:A1") => (type="colorScale", priority=6), + XLSX.CellRange("A1:A1") => (type="colorScale", priority=8), + XLSX.CellRange("A5:E5") => (type="colorScale", priority=5), + XLSX.CellRange("A4:E4") => (type="colorScale", priority=4), + XLSX.CellRange("A3:E3") => (type="colorScale", priority=3), + XLSX.CellRange("A2:E2") => (type="colorScale", priority=2), + XLSX.CellRange("A1:E1") => (type="colorScale", priority=1) + ] + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + @test XLSX.setConditionalFormat(s, "A1:A5", :colorScale) == 0 + @test XLSX.setConditionalFormat(s, :, 2, :colorScale; colorscale="redwhiteblue") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!E:E", :colorScale; colorscale="greenwhitered") == 0 + @test XLSX.setConditionalFormat(s, 1:5, 3:4, :colorScale; + min_type="min", + min_col="green", + mid_type="percentile", + mid_val="50", + mid_col="red", + max_type="max", + max_col="blue" + ) == 0 + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("C1:D5") => (type="colorScale", priority=4), XLSX.CellRange("E1:E5") => (type="colorScale", priority=3), XLSX.CellRange("B1:B5") => (type="colorScale", priority=2), XLSX.CellRange("A1:A5") => (type="colorScale", priority=1)] + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + + @test XLSX.setConditionalFormat(s, :, 1:4, :colorScale; + min_type="min", + min_col="green", + mid_type="percentile", + mid_val="\$E\$4", + mid_col="red", + max_type="max", + max_col="blue" + ) == 0 + @test XLSX.setConditionalFormat(s, :, 5, :colorScale; + min_type="min", + min_col="green", + mid_type="percentile", + mid_val="Sheet1!\$E\$4", + mid_col="red", + max_type="max", + max_col="blue" + ) == 0 + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, :, 5, :colorScale; + min_type="min", + min_col="green", + mid_type="percentile", + mid_val="Sheet2!\$E\$4", + mid_col="red", + max_type="max", + max_col="blue" + ) + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + XLSX.addDefinedName(s, "myRange", "A1:B5") + @test XLSX.setConditionalFormat(s, "myRange", :colorScale; + min_type="min", + min_col="green", + mid_type="percentile", + mid_val="50", + mid_col="red", + max_type="max", + max_col="blue" + ) == 0 + XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5") + @test_throws MethodError XLSX.setConditionalFormat(s, "myNCRange", :colorScale; # Non-contiguous ranges not allowed + min_type="min", + min_col="green", + mid_type="percentile", + mid_val="50", + mid_col="red", + max_type="max", + max_col="blue" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:A2", :colorScale; + colorscale="rainbow" + ) + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:12 + s[i, j] = i + j + end + for (j, k) in enumerate(keys(XLSX.colorscales)) + @test XLSX.setConditionalFormat(s, :, j, :colorScale; colorscale=k)==0 + end + end + + @testset "iconSet" begin + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + @test_throws MethodError XLSX.setConditionalFormat(s, "A1,A3", :iconSet) # Non-contiguous ranges not allowed + @test_throws MethodError XLSX.setConditionalFormat(s, [1], 1, :iconSet) # Vectors may be non-contiguous + @test_throws MethodError XLSX.setConditionalFormat(s, 1, 1:3:7, :iconSet) # StepRange is non-contiguous + @test XLSX.setConditionalFormat(s, "1:1", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, 2, :, :iconSet; iconset="3Arrows") == 0 + @test XLSX.setConditionalFormat(s, 3, 1:5, :iconSet; + min_type="percent", + min_val="20", + max_type="num", + max_val="4" + ) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A4:E4", :iconSet; + min_type="percentile", + min_val="10", + max_type="num", + max_val="\$C\$4" + ) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A5:E5", :iconSet; + min_type="percentile", + min_val="\$D\$5", + max_type="percent", + max_val="95" + ) == 0 + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("A5:E5") => (type = "iconSet", priority = 5),XLSX.CellRange("A4:E4") => (type = "iconSet", priority = 4), XLSX.CellRange("A3:E3") => (type = "iconSet", priority = 3), XLSX.CellRange("A2:E2") => (type = "iconSet", priority = 2), XLSX.CellRange("A1:E1") => (type = "iconSet", priority = 1)] + @test XLSX.setConditionalFormat(s, "A1", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, "A1:C3", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1:A2", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!1:2", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :iconSet) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!1:2", :iconSet) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A:C", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, :, 1:3, :iconSet) == 0 + @test XLSX.setConditionalFormat(s, 1:3, :, :iconSet) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :iconSet) == 0 + @test XLSX.setConditionalFormat(s, :, :iconSet) == 0 + @test XLSX.setConditionalFormat(s, :, :, :iconSet) == 0 + @test length(XLSX.getConditionalFormats(s)) == 22 + + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:E5") => (type = "iconSet", priority = 21), + XLSX.CellRange("A1:E5") => (type = "iconSet", priority = 22), + XLSX.CellRange("A1:E3") => (type = "iconSet", priority = 17), + XLSX.CellRange("A1:C5") => (type = "iconSet", priority = 12), + XLSX.CellRange("A1:C5") => (type = "iconSet", priority = 13), + XLSX.CellRange("A1:C5") => (type = "iconSet", priority = 15), + XLSX.CellRange("A1:C5") => (type = "iconSet", priority = 16), + XLSX.CellRange("A1:C5") => (type = "iconSet", priority = 19), + XLSX.CellRange("A1:C5") => (type = "iconSet", priority = 20), + XLSX.CellRange("A2:E4") => (type = "iconSet", priority = 11), + XLSX.CellRange("A2:E4") => (type = "iconSet", priority = 18), + XLSX.CellRange("A1:E2") => (type = "iconSet", priority = 10), + XLSX.CellRange("A1:E2") => (type = "iconSet", priority = 14), + XLSX.CellRange("A1:A2") => (type = "iconSet", priority = 9), + XLSX.CellRange("A1:C3") => (type = "iconSet", priority = 7), + XLSX.CellRange("A1:A1") => (type = "iconSet", priority = 6), + XLSX.CellRange("A1:A1") => (type = "iconSet", priority = 8), + XLSX.CellRange("A5:E5") => (type = "iconSet", priority = 5), + XLSX.CellRange("A4:E4") => (type = "iconSet", priority = 4), + XLSX.CellRange("A3:E3") => (type = "iconSet", priority = 3), + XLSX.CellRange("A2:E2") => (type = "iconSet", priority = 2), + XLSX.CellRange("A1:E1") => (type = "iconSet", priority = 1) + ] + + f=XLSX.newxlsx() + s=f[1] + + XLSX.writetable!(s, [collect(1:10), collect(1:10), collect(1:10), collect(1:10), collect(1:10), collect(1:10)], + ["normal", "showVal=\"false\"", "reverse=\"true\"", "min_gte=\"false\"", "extra1", "extra2"]) + s["G1"]=3 + s["G4"]="y" + + @test XLSX.setConditionalFormat(s, "A2:A11", :iconSet; + min_type="num", max_type="formula", + min_val="3", max_val="if(\$G\$4=\"y\", \$G\$1+5, 10)") == 0 + + @test XLSX.setConditionalFormat(s, "A2:A11", :iconSet; + min_type="num", max_type="num", + min_val="3", max_val="8") == 0 + + @test XLSX.setConditionalFormat(s, "B2:B11", :iconSet; iconset = "4TrafficLights", + min_type="num", mid_type="percent", max_type="num", + min_val="3", mid_val="50", max_val="8", + showVal="false") == 0 + + @test XLSX.setConditionalFormat(s, "C2:C11", :iconSet; iconset = "3Symbols2", + min_type="num", mid_type="percentile", max_type="num", + min_val="3", mid_val="50", max_val="8", + reverse="true") == 0 + + @test XLSX.setConditionalFormat(s, "D2:D11", :iconSet; iconset = "5Arrows", + 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", max_gte="false") == 0 + + @test XLSX.setConditionalFormat(s, "E2:E11", :iconSet; iconset = "3Stars", + 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", max_gte="false") == 0 + + @test XLSX.setConditionalFormat(s, "F2:F11", :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") == 0 + + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("D2:D11") => (type = "iconSet", priority = 5), + XLSX.CellRange("C2:C11") => (type = "iconSet", priority = 4), + XLSX.CellRange("B2:B11") => (type = "iconSet", priority = 3), + XLSX.CellRange("A2:A11") => (type = "iconSet", priority = 1), + XLSX.CellRange("A2:A11") => (type = "iconSet", priority = 2), + XLSX.CellRange("E2:E11") => (type = "iconSet", priority = 6), + XLSX.CellRange("F2:F11") => (type = "iconSet", priority = 7) + ] + + f=XLSX.newxlsx() + s=f[1] + for i = 0:3 + for j=1:13 + s[i+1,j]=i*13+j + end + end + for j=1:13 + @test XLSX.setConditionalFormat(s, 1:4, j, :iconSet; # Create a custom 4-icon set in each column. + iconset="Custom", + icon_list=[j, 13+j, 26+j, 39+j], + min_type="percent", mid_type="percent", max_type="percent", + min_val="25", mid_val="50", max_val="75" + ) == 0 + end + + @test XLSX.setConditionalFormat(s, 1:4, 1, :iconSet; + iconset="Custom", + icon_list = [1,2,3,4,5], + min_type="percent", max_type="percent", + min_val="25", max_val="75", + min_gte="false", max_gte="false" + ) == 0 + @test XLSX.setConditionalFormat(s, 1:4, 1, :iconSet; + iconset="Custom", + showVal = "false", + icon_list = [1,2,3,4,5], + min_type="percent", mid_type="percent", max_type="percent", + min_val="25", mid_val="50", max_val="75" + ) == 0 + @test XLSX.setConditionalFormat(s, 1:4, 1, :iconSet; + iconset="Custom", + reverse="true", + icon_list = [1,2,3,4,5], + min_type="percent", mid_type="percent", mid2_type="percentile", max_type="percent", + min_val="25", mid_val="50", mid2_val="60", max_val="75" + ) == 0 + + @test XLSX.setConditionalFormat(s, "A2:M2", :iconSet; + iconset = "Custom", + icon_list = [31,24,11], + min_type="num", max_type="formula", + min_val="3", max_val="if(\$G\$4=\"y\", \$G\$1+5, 10)") == 0 + + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:4, 1, :iconSet; + iconset="Custom", + icon_list = [1,2,3,4,5], + min_type="percent", mid_type="madeUp", mid2_type="percentile", max_type="num", + min_val="25", mid_val="50", mid2_val="60", max_val="75" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:4, 1, :iconSet; + iconset="Custom", + icon_list = [99,2,3,4,5], + min_type="percent", mid_type="percent", mid2_type="percentile", max_type="num", + min_val="25", mid_val="50", mid2_val="60", max_val="75" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:4, 1, :iconSet; + iconset="Custom", + min_type="percent", mid_type="percent", max_type="percent", + min_val="25", mid_val="50", max_val="75" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:4, 1, :iconSet; + iconset="Custom", + icon_list=[], + min_type="percent", mid_type="percent", max_type="percent", + min_val="25", mid_val="50", max_val="75" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:4, 1, :iconSet; + iconset="Custom", + icon_list=[1, 13, 26], + min_type="percent", mid_type="percent", max_type="percent", + min_val="25", mid_val="50", max_val="75" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:4, 1, :iconSet; + iconset="Custom", + 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 XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:A4") => (type = "iconSet", priority = 16), + XLSX.CellRange("A1:A4") => (type = "iconSet", priority = 15), + XLSX.CellRange("A1:A4") => (type = "iconSet", priority = 14), + XLSX.CellRange("A1:A4") => (type = "iconSet", priority = 1), + XLSX.CellRange("B1:B4") => (type = "iconSet", priority = 2), + XLSX.CellRange("C1:C4") => (type = "iconSet", priority = 3), + XLSX.CellRange("D1:D4") => (type = "iconSet", priority = 4), + XLSX.CellRange("E1:E4") => (type = "iconSet", priority = 5), + XLSX.CellRange("F1:F4") => (type = "iconSet", priority = 6), + XLSX.CellRange("G1:G4") => (type = "iconSet", priority = 7), + XLSX.CellRange("H1:H4") => (type = "iconSet", priority = 8), + XLSX.CellRange("I1:I4") => (type = "iconSet", priority = 9), + XLSX.CellRange("J1:J4") => (type = "iconSet", priority = 10), + XLSX.CellRange("K1:K4") => (type = "iconSet", priority = 11), + XLSX.CellRange("L1:L4") => (type = "iconSet", priority = 12), + XLSX.CellRange("M1:M4") => (type = "iconSet", priority = 13), + XLSX.CellRange("A2:M2") => (type = "iconSet", priority = 17) + ] + + XLSX.addDefinedName(s, "myRange", "A1:B2") + XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5") + @test XLSX.setConditionalFormat(s, "myRange", :iconSet) == 0 + @test_throws MethodError XLSX.setConditionalFormat(s, "myNCRange", :iconSet) + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:21 + s[i, j] = i + j + end + for (j, k) in enumerate(keys(XLSX.iconsets)) + if k=="Custom" + @test XLSX.setConditionalFormat(s, :, j, :iconSet; + iconset=k, + icon_list=[1,2,3,4,5], + min_type="num", mid_type="num", mid2_type="num", max_type="num", + min_val="8", mid_val="12", mid2_val="15", max_val="18", + )==0 + else + @test XLSX.setConditionalFormat(s, :, j, :iconSet; iconset=k)==0 + end + end + f = XLSX.newxlsx() + s = f[1] + for i in 1:3, j in 1:21 + s[i, j] = i + j + end + @test XLSX.setConditionalFormat(s, "Sheet1!A1:E1", :iconSet; + min_type="percentile", + min_val="10", + max_type="num", + max_val="Sheet1!\$C\$4" + ) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A2:E2", :iconSet; + min_type="percentile", + min_val="Sheet1!\$D\$5", + max_type="percent", + max_val="95" + ) == 0 + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "Sheet1!A1:E1", :iconSet; + min_type="percentile", + min_val="10", + max_type="num", + max_val="Sheet2!\$C\$4" + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(f, "Sheet1!A5:E5", :iconSet; + min_type="percentile", + min_val="Sheet2!\$D\$5", + max_type="percent", + max_val="95" + ) + @test XML.tag(XLSX.get_x14_icon("3Triangles")) == "x14:cfRule" + @test XML.attributes(XLSX.get_x14_icon("3Stars")) == XML.OrderedDict("type" => "iconSet", "priority" => "1", "id" => "XXXX-xxxx-XXXX") + @test length(XML.children(XLSX.get_x14_icon("5Boxes"))) == 1 + @test typeof(XLSX.get_x14_icon("Custom")) == XML.Node + end + + @testset "cellIs" begin + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + @test_throws MethodError XLSX.setConditionalFormat(s, "A1,A3", :cellIs) # Non-contiguous ranges not allowed + @test_throws MethodError XLSX.setConditionalFormat(s, [1], 1, :cellIs) # Vectors may be non-contiguous + @test_throws MethodError XLSX.setConditionalFormat(s, 1, 1:3:7, :cellIs) # StepRange is non-contiguous + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "A1:A3", :cellIs; dxStyle="madeUp") # dxStyle invalid + @test XLSX.setConditionalFormat(s, "1:1", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, 2, :, :cellIs; dxStyle="greenfilltext") == 0 + @test XLSX.setConditionalFormat(s, 3, 1:5, :cellIs; + operator="between", + value="2", + value2="3", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "FFFFC7CE"], + format=["format" => "0.00%"], + font=["color" => "blue", "bold" => "true"] + ) == 0 + + @test XLSX.setConditionalFormat(s, "Sheet1!A4:E4", :cellIs; + operator="greaterThan", + value="4", + fill=["pattern" => "none", "bgColor" => "green"], + format=["format" => "0.0"], + font=["color" => "red", "italic" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A5:E5", :cellIs; + operator="lessThan", + value="2", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "thick", "color" => "coral"] + ) == 0 + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("A5:E5") => (type="cellIs", priority=5), XLSX.CellRange("A4:E4") => (type="cellIs", priority=4), XLSX.CellRange("A3:E3") => (type="cellIs", priority=3), XLSX.CellRange("A2:E2") => (type="cellIs", priority=2), XLSX.CellRange("A1:E1") => (type="cellIs", priority=1)] + @test XLSX.setConditionalFormat(s, "A1", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, "A1:C3", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1:A2", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!1:2", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :cellIs) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!1:2", :cellIs) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A:C", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, :, 1:3, :cellIs) == 0 + @test XLSX.setConditionalFormat(s, 1:3, :, :cellIs) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :cellIs) == 0 + @test XLSX.setConditionalFormat(s, :, :cellIs) == 0 + @test XLSX.setConditionalFormat(s, :, :, :cellIs) == 0 + @test length(XLSX.getConditionalFormats(s)) == 22 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:E5") => (type="cellIs", priority=21), + XLSX.CellRange("A1:E5") => (type="cellIs", priority=22), + XLSX.CellRange("A1:E3") => (type="cellIs", priority=17), + XLSX.CellRange("A1:C5") => (type="cellIs", priority=12), + XLSX.CellRange("A1:C5") => (type="cellIs", priority=13), + XLSX.CellRange("A1:C5") => (type="cellIs", priority=15), + XLSX.CellRange("A1:C5") => (type="cellIs", priority=16), + XLSX.CellRange("A1:C5") => (type="cellIs", priority=19), + XLSX.CellRange("A1:C5") => (type="cellIs", priority=20), + XLSX.CellRange("A2:E4") => (type="cellIs", priority=11), + XLSX.CellRange("A2:E4") => (type="cellIs", priority=18), + XLSX.CellRange("A1:E2") => (type="cellIs", priority=10), + XLSX.CellRange("A1:E2") => (type="cellIs", priority=14), + XLSX.CellRange("A1:A2") => (type="cellIs", priority=9), + XLSX.CellRange("A1:C3") => (type="cellIs", priority=7), + XLSX.CellRange("A1:A1") => (type="cellIs", priority=6), + XLSX.CellRange("A1:A1") => (type="cellIs", priority=8), + XLSX.CellRange("A5:E5") => (type="cellIs", priority=5), + XLSX.CellRange("A4:E4") => (type="cellIs", priority=4), + XLSX.CellRange("A3:E3") => (type="cellIs", priority=3), + XLSX.CellRange("A2:E2") => (type="cellIs", priority=2), + XLSX.CellRange("A1:E1") => (type="cellIs", priority=1) + ] + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "Sheet1!A4:E4", :cellIs; + operator="madeUp", + value="4", + fill=["pattern" => "none", "bgColor" => "green"], + format=["format" => "0.0"], + font=["color" => "red", "italic" => "true"] + ) + + 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: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; + operator="between", + value="2", + value2="4", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "thick", "color" => "coral"] + ) == 0 + + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("C1:D5") => (type="cellIs", priority=4), XLSX.CellRange("E1:E5") => (type="cellIs", priority=3), XLSX.CellRange("B1:B5") => (type="cellIs", priority=2), XLSX.CellRange("A1:A5") => (type="cellIs", priority=1)] + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + + @test XLSX.setConditionalFormat(s, :, 1:4, :cellIs; + operator="lessThan", + value="\$E\$4", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green", "under" => "double"], + border=["style" => "thin", "color" => "coral"] + ) == 0 + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:5 + s[i, j] = i + j + end + XLSX.addDefinedName(s, "myRange", "A1:B5") + @test XLSX.setConditionalFormat(s, "myRange", :cellIs; + operator="lessThan", + value="2", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) == 0 + XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5") + @test_throws MethodError XLSX.setConditionalFormat(s, "myNCRange", :cellIs; # Non-contiguous ranges not allowed + operator="lessThan", + value="2", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) + + f = XLSX.newxlsx() + s = f[1] + for i in 1:5, j in 1:6 + s[i, j] = i + j + end + for (j, k) in enumerate(keys(XLSX.highlights)) + @test XLSX.setConditionalFormat(s, :, j, :cellIs; dxStyle=k)==0 + end + end + + + @testset "containsText" begin + f = XLSX.newxlsx() + s = f[1] + s["A1:E1"] = "Hello World" + s["A2:E2"] = "Life the universe and everything" + s["A3:E3"] = "Once upon a time" + s["A4:E4"] = "In America" + s["A5:E5"] = "a" + @test_throws MethodError XLSX.setConditionalFormat(s, "A1,A3", :containsText; value="a") # Non-contiguous ranges not allowed + @test_throws MethodError XLSX.setConditionalFormat(s, [1], 1, :containsText; value="a") # Vectors may be non-contiguous + @test_throws MethodError XLSX.setConditionalFormat(s, 1, 1:3:7, :containsText; value="a") # StepRange is non-contiguous + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "1:1", :containsText) # value must be defined + @test XLSX.setConditionalFormat(s, "1:1", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, 2, :, :containsText; value="a", dxStyle="greenfilltext") == 0 + @test XLSX.setConditionalFormat(s, 3, 1:5, :containsText; + operator="notContainsText", + value="a", + fill=["pattern" => "none", "bgColor" => "FFFFC7CE"], + format=["format" => "0.00%"], + font=["color" => "blue", "bold" => "true"] + ) == 0 + + @test XLSX.setConditionalFormat(s, "Sheet1!A4:E4", :containsText; + operator="notContainsText", + value="a", + fill=["pattern" => "none", "bgColor" => "green"], + format=["format" => "0.0"], + font=["color" => "red", "italic" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A5:E5", :containsText; + operator="beginsWith", + value="a", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "thick", "color" => "coral"] + ) == 0 + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("A5:E5") => (type="beginsWith", priority=5), XLSX.CellRange("A4:E4") => (type="notContainsText", priority=4), XLSX.CellRange("A3:E3") => (type="notContainsText", priority=3), XLSX.CellRange("A2:E2") => (type="containsText", priority=2), XLSX.CellRange("A1:E1") => (type="containsText", priority=1)] + # @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("A5:E5") => (type = "containsText", priority = 5), XLSX.CellRange("A4:E4") => (type = "containsText", priority = 4), XLSX.CellRange("A3:E3") => (type = "containsText", priority = 3), XLSX.CellRange("A2:E2") => (type = "containsText", priority = 2), XLSX.CellRange("A1:E1") => (type = "containsText", priority = 1)] + @test XLSX.setConditionalFormat(s, "A1", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, "A1:C3", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1:A2", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!1:2", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, "2:4", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, "A:C", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!1:2", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A:C", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, :, 1:3, :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, 1:3, :, :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, "2:4", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, "A:C", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, :, :containsText; value="a") == 0 + @test XLSX.setConditionalFormat(s, :, :, :containsText; value="a") == 0 + @test length(XLSX.getConditionalFormats(s)) == 22 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:E5") => (type="containsText", priority=21), + XLSX.CellRange("A1:E5") => (type="containsText", priority=22), + XLSX.CellRange("A1:E3") => (type="containsText", priority=17), + XLSX.CellRange("A1:C5") => (type="containsText", priority=12), + XLSX.CellRange("A1:C5") => (type="containsText", priority=13), + XLSX.CellRange("A1:C5") => (type="containsText", priority=15), + XLSX.CellRange("A1:C5") => (type="containsText", priority=16), + XLSX.CellRange("A1:C5") => (type="containsText", priority=19), + XLSX.CellRange("A1:C5") => (type="containsText", priority=20), + XLSX.CellRange("A2:E4") => (type="containsText", priority=11), + XLSX.CellRange("A2:E4") => (type="containsText", priority=18), + XLSX.CellRange("A1:E2") => (type="containsText", priority=10), + XLSX.CellRange("A1:E2") => (type="containsText", priority=14), + XLSX.CellRange("A1:A2") => (type="containsText", priority=9), + XLSX.CellRange("A1:C3") => (type="containsText", priority=7), + XLSX.CellRange("A1:A1") => (type="containsText", priority=6), + XLSX.CellRange("A1:A1") => (type="containsText", priority=8), + XLSX.CellRange("A5:E5") => (type="beginsWith", priority=5), + XLSX.CellRange("A4:E4") => (type="notContainsText", priority=4), + XLSX.CellRange("A3:E3") => (type="notContainsText", priority=3), + XLSX.CellRange("A2:E2") => (type="containsText", priority=2), + XLSX.CellRange("A1:E1") => (type="containsText", priority=1) + ] + + f = XLSX.newxlsx() + s = f[1] + s["A1:E1"] = "Hello World" + s["A2:E2"] = "Life the universe and everything" + s["A3:E3"] = "Once upon a time" + s["A4:E4"] = "In America" + s["A5:E5"] = "a" + 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; + operator="endsWith", + value="a", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "thick", "color" => "coral"] + ) == 0 + + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("C1:D5") => (type="endsWith", priority=4), XLSX.CellRange("E1:E5") => (type="containsText", priority=3), XLSX.CellRange("B1:B5") => (type="containsText", priority=2), XLSX.CellRange("A1:A5") => (type="containsText", priority=1)] + + f = XLSX.newxlsx() + s = f[1] + s["A1:E1"] = "Hello World" + s["A2:E2"] = "Life the universe and everything" + s["A3:E3"] = "Once upon a time" + s["A4:E4"] = "In America" + s["A5:E5"] = "a" + + @test XLSX.setConditionalFormat(s, :, 1:4, :containsText; + operator="containsText", + value="Sheet1!\$E\$5", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green", "under" => "double"], + border=["style" => "thin", "color" => "coral"] + ) == 0 + + f = XLSX.newxlsx() + s = f[1] + s["A1:E1"] = "Hello World" + s["A2:E2"] = "Life the universe and everything" + s["A3:E3"] = "Once upon a time" + s["A4:E4"] = "In America" + s["A5:E5"] = "a" + XLSX.addDefinedName(s, "myRange", "A1:B5") + @test XLSX.setConditionalFormat(s, "myRange", :containsText; + operator="notContainsText", + value="a", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) == 0 + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "myRange", :containsText; + operator="madeUp", + value="a", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) == 0 + XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5") + @test_throws MethodError XLSX.setConditionalFormat(s, "myNCRange", :containsText; # Non-contiguous ranges not allowed + operator="beginsWith", + value="a", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) + + end + + @testset "top10" begin + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + @test_throws MethodError XLSX.setConditionalFormat(s, "A1,A3", :top10) # Non-contiguous ranges not allowed + @test_throws MethodError XLSX.setConditionalFormat(s, [1], 1, :top10) # Vectors may be non-contiguous + @test_throws MethodError XLSX.setConditionalFormat(s, 1, 1:3:7, :top10) # StepRange is non-contiguous + @test XLSX.setConditionalFormat(s, "1:1", :top10) == 0 + @test XLSX.setConditionalFormat(s, 2, :, :top10; dxStyle="greenfilltext") == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:10, :top10; + operator="topN", + value="5", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "green"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:10, :top10; + operator="bottomN", + value="5", + stopIfTrue="true", + fill=["pattern" => "lightVertical", "fgColor" => "grey", "bgColor" => "FFFFC7CE"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:10, :top10; + operator="topN%", + value="20", + 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:10, :top10; + operator="bottomN%", + value="30", + fill=["pattern" => "none", "bgColor" => "pink"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "italic" => "true"] + ) == 0 + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("A1:J10") => (type="top10", priority=3), XLSX.CellRange("A1:J10") => (type="top10", priority=4), XLSX.CellRange("A1:J10") => (type="top10", priority=5), XLSX.CellRange("A1:J10") => (type="top10", priority=6), XLSX.CellRange("A2:J2") => (type="top10", priority=2), XLSX.CellRange("A1:J1") => (type="top10", priority=1)] + + @test XLSX.setConditionalFormat(s, "A1", :top10) == 0 + @test XLSX.setConditionalFormat(s, "A1:C3", :top10) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1", :top10) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1:A2", :top10) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!1:2", :top10) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :top10) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :top10) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :top10) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!1:2", :top10) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A:C", :top10) == 0 + @test XLSX.setConditionalFormat(s, :, 1:3, :top10) == 0 + @test XLSX.setConditionalFormat(s, 1:3, :, :top10) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :top10) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :top10) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :top10) == 0 + @test XLSX.setConditionalFormat(s, :, :top10) == 0 + @test XLSX.setConditionalFormat(s, :, :, :top10) == 0 + @test length(XLSX.getConditionalFormats(s)) == 23 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:J3") => (type="top10", priority=18), + XLSX.CellRange("A1:C10") => (type="top10", priority=13), + XLSX.CellRange("A1:C10") => (type="top10", priority=14), + XLSX.CellRange("A1:C10") => (type="top10", priority=16), + XLSX.CellRange("A1:C10") => (type="top10", priority=17), + XLSX.CellRange("A1:C10") => (type="top10", priority=20), + XLSX.CellRange("A1:C10") => (type="top10", priority=21), + XLSX.CellRange("A2:J4") => (type="top10", priority=12), + XLSX.CellRange("A2:J4") => (type="top10", priority=19), + XLSX.CellRange("A1:J2") => (type="top10", priority=11), + XLSX.CellRange("A1:J2") => (type="top10", priority=15), + XLSX.CellRange("A1:A2") => (type="top10", priority=10), + XLSX.CellRange("A1:C3") => (type="top10", priority=8), + XLSX.CellRange("A1:A1") => (type="top10", priority=7), + XLSX.CellRange("A1:A1") => (type="top10", priority=9), + XLSX.CellRange("A1:J10") => (type="top10", priority=3), + XLSX.CellRange("A1:J10") => (type="top10", priority=4), + XLSX.CellRange("A1:J10") => (type="top10", priority=5), + XLSX.CellRange("A1:J10") => (type="top10", priority=6), + XLSX.CellRange("A1:J10") => (type="top10", priority=22), + XLSX.CellRange("A1:J10") => (type="top10", priority=23), + XLSX.CellRange("A2:J2") => (type="top10", priority=2), + XLSX.CellRange("A1:J1") => (type="top10", priority=1) + ] + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + 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; + operator="topN%", + value="20", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "thick", "color" => "coral"] + ) == 0 + + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("C1:D5") => (type="top10", priority=4), XLSX.CellRange("E1:E5") => (type="top10", priority=3), XLSX.CellRange("B1:B5") => (type="top10", priority=2), XLSX.CellRange("A1:A5") => (type="top10", priority=1)] + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + + @test XLSX.setConditionalFormat(s, :, 1:4, :top10; + operator="bottomN", + value="\$E\$4", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green", "under" => "double"], + border=["style" => "thin", "color" => "coral"] + ) == 0 + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + XLSX.addDefinedName(s, "myRange", "A1:E5") + @test XLSX.setConditionalFormat(s, "myRange", :top10; + operator="topN%", + value="2", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "medium", "color" => "cyan"] + ) == 0 + XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5") + @test_throws MethodError XLSX.setConditionalFormat(s, "myNCRange", :top10; # Non-contiguous ranges not allowed + operator="bottomN%", + value="2", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, "myRange", :top10; + operator="madeUp", + value="2", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) + + end + + @testset "aboveAverage" begin + f = XLSX.newxlsx() + s = f[1] + d = Dist.Normal() + columns = [rand(d, 1000), rand(d, 1000), rand(d, 1000)] + XLSX.writetable!(s, columns, ["normal1", "normal2", "normal3"]) + @test_throws MethodError XLSX.setConditionalFormat(s, "A2:A1001,C1:C1000", :aboveAverage) # Non-contiguous ranges not allowed + @test_throws MethodError XLSX.setConditionalFormat(s, [2, 3, 19], 1:3, :aboveAverage) # Vectors may be non-contiguous + @test_throws MethodError XLSX.setConditionalFormat(s, 2, 1:3:7, :aboveAverage) # StepRange is non-contiguous + @test XLSX.setConditionalFormat(s, "2:2", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, 2, :, :aboveAverage; dxStyle="greenfilltext") == 0 + @test XLSX.setConditionalFormat(s, 2:10, 1:3, :aboveAverage; + operator="plus3StdDev", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "FFFFC7CE"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 2:1001, 1:3, :aboveAverage; + operator="minus3StdDev", + stopIfTrue="true", + fill=["pattern" => "lightVertical", "fgColor" => "grey", "bgColor" => "FFFFC7CE"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 2:1001, 1:3, :aboveAverage; + operator="plus2StdDev", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "FFFFC7CE"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 2:1001, 1:3, :aboveAverage; + operator="minus2StdDev", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "pink"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "italic" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 2:1001, 1:3, :aboveAverage; + operator="plus1StdDev", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "FFFFCFCE"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "yellow", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:1001, 1:3, :aboveAverage; + operator="minus1StdDev", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "yellow"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "green", "bold" => "true", "italic" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:1001, 1:3, :aboveAverage; + operator="aboveAverage", + fill=["pattern" => "none", "bgColor" => "FFFFCFCE"], + border=["style" => "thick", "color" => "gray"], + font=["color" => "yellow", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:1001, 1:3, :aboveAverage; + operator="belowAverage", + fill=["pattern" => "none", "bgColor" => "yellow"], + border=["style" => "thick", "color" => "green"], + font=["color" => "green", "bold" => "true", "italic" => "true"] + ) == 0 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=8), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=9), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=10), + XLSX.CellRange("A2:C1001") => (type="aboveAverage", priority=4), + XLSX.CellRange("A2:C1001") => (type="aboveAverage", priority=5), + XLSX.CellRange("A2:C1001") => (type="aboveAverage", priority=6), + XLSX.CellRange("A2:C1001") => (type="aboveAverage", priority=7), + XLSX.CellRange("A2:C10") => (type="aboveAverage", priority=3), + XLSX.CellRange("A2:C2") => (type="aboveAverage", priority=1), + XLSX.CellRange("A2:C2") => (type="aboveAverage", priority=2) + ] + + @test XLSX.setConditionalFormat(s, "A1", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, "A1:C3", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1:A2", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!1:2", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!1:2", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A:C", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, :, 1:3, :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, 1:3, :, :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, :, :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, :, :, :aboveAverage) == 0 + @test length(XLSX.getConditionalFormats(s)) == 27 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A2:C4") => (type="aboveAverage", priority=16), + XLSX.CellRange("A2:C4") => (type="aboveAverage", priority=23), + XLSX.CellRange("A1:C2") => (type="aboveAverage", priority=15), + XLSX.CellRange("A1:C2") => (type="aboveAverage", priority=19), + XLSX.CellRange("A1:A2") => (type="aboveAverage", priority=14), + XLSX.CellRange("A1:C3") => (type="aboveAverage", priority=12), + XLSX.CellRange("A1:C3") => (type="aboveAverage", priority=22), + XLSX.CellRange("A1:A1") => (type="aboveAverage", priority=11), + XLSX.CellRange("A1:A1") => (type="aboveAverage", priority=13), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=8), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=9), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=10), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=17), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=18), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=20), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=21), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=24), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=25), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=26), + XLSX.CellRange("A1:C1001") => (type="aboveAverage", priority=27), + XLSX.CellRange("A2:C1001") => (type="aboveAverage", priority=4), + XLSX.CellRange("A2:C1001") => (type="aboveAverage", priority=5), + XLSX.CellRange("A2:C1001") => (type="aboveAverage", priority=6), + XLSX.CellRange("A2:C1001") => (type="aboveAverage", priority=7), + XLSX.CellRange("A2:C10") => (type="aboveAverage", priority=3), + XLSX.CellRange("A2:C2") => (type="aboveAverage", priority=1), + XLSX.CellRange("A2:C2") => (type="aboveAverage", priority=2) + ] + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + @test XLSX.setConditionalFormat(s, "A1:A5", :aboveAverage) == 0 + @test XLSX.setConditionalFormat(s, :, 2, :aboveAverage; dxStyle="redborder") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!E:E", :aboveAverage; dxStyle="redfilltext") == 0 + @test XLSX.setConditionalFormat(s, 1:5, 3:4, :aboveAverage; + operator="aboveEqAverage", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "thick", "color" => "coral"] + ) == 0 + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:5, 3:4, :aboveAverage; + operator="madeup", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "thick", "color" => "coral"] + ) == 0 + + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("C1:D5") => (type="aboveAverage", priority=4), XLSX.CellRange("E1:E5") => (type="aboveAverage", priority=3), XLSX.CellRange("B1:B5") => (type="aboveAverage", priority=2), XLSX.CellRange("A1:A5") => (type="aboveAverage", priority=1)] + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + + @test XLSX.setConditionalFormat(s, :, 1:4, :aboveAverage; + operator="belowEqAverage", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green", "under" => "double"], + border=["style" => "thin", "color" => "coral"] + ) == 0 + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + XLSX.addDefinedName(s, "myRange", "A1:E5") + @test XLSX.setConditionalFormat(s, "myRange", :aboveAverage; + operator="aboveEqAverage", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "medium", "color" => "cyan"] + ) == 0 + XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5") + @test_throws MethodError XLSX.setConditionalFormat(s, "myNCRange", :aboveAverage; # Non-contiguous ranges not allowed + operator="belowEqAverage", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) + + end + + @testset "timePeriod" begin + f = XLSX.newxlsx() + s = f[1] + todaynow = Dates.today() + s[1, 1:10] = todaynow - Dates.Year(1) + s[2, 1:10] = todaynow - Dates.Month(1) + s[3, 1:10] = todaynow - Dates.Day(14) + s[4, 1:10] = todaynow - Dates.Day(5) + s[5, 1:10] = todaynow - Dates.Day(1) + s[6, 1:10] = todaynow + s[7, 1:10] = todaynow + Dates.Day(1) + s[8, 1:10] = todaynow + Dates.Day(14) + s[9, 1:10] = todaynow + Dates.Month(1) + s[10, 1:10] = todaynow + Dates.Year(1) + + @test_throws MethodError XLSX.setConditionalFormat(s, "A1:A5,C1:C5", :timePeriod) # Non-contiguous ranges not allowed + @test_throws MethodError XLSX.setConditionalFormat(s, [2, 3, 8], 1:3, :timePeriod) # Vectors may be non-contiguous + @test_throws MethodError XLSX.setConditionalFormat(s, 2, 1:3:7, :timePeriod) # StepRange is non-contiguous + @test XLSX.setConditionalFormat(s, "2:2", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, 2, :, :timePeriod; dxStyle="greenfilltext") == 0 + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:10, 1:3, :timePeriod; + operator="madeUp", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "FFFFC7CE"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "strike" => "true"] + ) + @test XLSX.setConditionalFormat(s, 1:10, 1:3, :timePeriod; + operator="today", + stopIfTrue="true", + 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, :timePeriod; + operator="yesterday", + stopIfTrue="true", + fill=["pattern" => "lightVertical", "fgColor" => "grey", "bgColor" => "FFFFC7CE"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:3, :timePeriod; + operator="tomorrow", + stopIfTrue="true", + 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, :timePeriod; + operator="lastMonth", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "pink"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "italic" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:3, :timePeriod; + operator="thisMonth", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "FFCC4411"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "yellow", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:3, :timePeriod; + operator="nextMonth", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "FFFFCFCE"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "yellow", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:3, :timePeriod; + operator="last7Days", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "yellow"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "green", "bold" => "true", "italic" => "true"] + ) == 0 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=3), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=4), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=5), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=6), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=7), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=8), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=9), + XLSX.CellRange("A2:J2") => (type="timePeriod", priority=1), + XLSX.CellRange("A2:J2") => (type="timePeriod", priority=2) + ] + + @test XLSX.setConditionalFormat(s, "A1", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, "A1:C3", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1:A2", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!1:2", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :timePeriod) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!1:2", :timePeriod) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A:C", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, :, 1:3, :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, 1:3, :, :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, :, :timePeriod) == 0 + @test XLSX.setConditionalFormat(s, :, :, :timePeriod) == 0 + @test length(XLSX.getConditionalFormats(s)) == 26 + + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:J10") => (type="timePeriod", priority=25), + XLSX.CellRange("A1:J10") => (type="timePeriod", priority=26), + XLSX.CellRange("A1:J3") => (type="timePeriod", priority=21), + XLSX.CellRange("A2:J4") => (type="timePeriod", priority=15), + XLSX.CellRange("A2:J4") => (type="timePeriod", priority=22), + XLSX.CellRange("A1:J2") => (type="timePeriod", priority=14), + XLSX.CellRange("A1:J2") => (type="timePeriod", priority=18), + XLSX.CellRange("A1:A2") => (type="timePeriod", priority=13), + XLSX.CellRange("A1:C3") => (type="timePeriod", priority=11), + XLSX.CellRange("A1:A1") => (type="timePeriod", priority=10), + XLSX.CellRange("A1:A1") => (type="timePeriod", priority=12), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=3), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=4), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=5), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=6), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=7), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=8), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=9), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=16), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=17), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=19), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=20), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=23), + XLSX.CellRange("A1:C10") => (type="timePeriod", priority=24), + XLSX.CellRange("A2:J2") => (type="timePeriod", priority=1), + XLSX.CellRange("A2:J2") => (type="timePeriod", priority=2) + ] + + f = XLSX.newxlsx() + s = f[1] + s[1, 1:10] = todaynow - Dates.Year(1) + s[2, 1:10] = todaynow - Dates.Month(1) + s[3, 1:10] = todaynow - Dates.Day(14) + s[4, 1:10] = todaynow - Dates.Day(5) + s[5, 1:10] = todaynow - Dates.Day(1) + s[6, 1:10] = todaynow + s[7, 1:10] = todaynow + Dates.Day(1) + s[8, 1:10] = todaynow + Dates.Day(14) + s[9, 1:10] = todaynow + Dates.Month(1) + s[10, 1:10] = todaynow + Dates.Year(1) + 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; + operator="lastWeek", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "thick", "color" => "coral"] + ) == 0 + + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("C1:D5") => (type="timePeriod", priority=4), XLSX.CellRange("E1:E10") => (type="timePeriod", priority=3), XLSX.CellRange("B1:B10") => (type="timePeriod", priority=2), XLSX.CellRange("A1:A5") => (type="timePeriod", priority=1)] + + f = XLSX.newxlsx() + s = f[1] + s[1, 1:10] = todaynow - Dates.Year(1) + s[2, 1:10] = todaynow - Dates.Month(1) + s[3, 1:10] = todaynow - Dates.Day(14) + s[4, 1:10] = todaynow - Dates.Day(5) + s[5, 1:10] = todaynow - Dates.Day(1) + s[6, 1:10] = todaynow + s[7, 1:10] = todaynow + Dates.Day(1) + s[8, 1:10] = todaynow + Dates.Day(14) + s[9, 1:10] = todaynow + Dates.Month(1) + s[10, 1:10] = todaynow + Dates.Year(1) + + @test XLSX.setConditionalFormat(s, :, 1:4, :timePeriod; + operator="thisWeek", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green", "under" => "double"], + border=["style" => "thin", "color" => "coral"] + ) == 0 + + f = XLSX.newxlsx() + s = f[1] + s[1, 1:10] = todaynow - Dates.Year(1) + s[2, 1:10] = todaynow - Dates.Month(1) + s[3, 1:10] = todaynow - Dates.Day(14) + s[4, 1:10] = todaynow - Dates.Day(5) + s[5, 1:10] = todaynow - Dates.Day(1) + s[6, 1:10] = todaynow + s[7, 1:10] = todaynow + Dates.Day(1) + s[8, 1:10] = todaynow + Dates.Day(14) + s[9, 1:10] = todaynow + Dates.Month(1) + s[10, 1:10] = todaynow + Dates.Year(1) + XLSX.addDefinedName(s, "myRange", "A1:E5") + @test XLSX.setConditionalFormat(s, "myRange", :timePeriod; + operator="nextWeek", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "medium", "color" => "cyan"] + ) == 0 + XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5") + @test_throws MethodError XLSX.setConditionalFormat(s, "myNCRange", :timePeriod; # Non-contiguous ranges not allowed + operator="lastWeek", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) + + end + + @testset "expression" begin + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + + @test_throws MethodError XLSX.setConditionalFormat(s, "A1:A5,C1:C5", :expression; formula = "A1>3") # Non-contiguous ranges not allowed + @test_throws MethodError XLSX.setConditionalFormat(s, [2, 3, 8], 1:3, :expression; formula = "A1 > 11") # Vectors may be non-contiguous + @test_throws MethodError XLSX.setConditionalFormat(s, 2, 1:3:7, :expression; formula = "A1 < 7") # StepRange is non-contiguous + @test XLSX.setConditionalFormat(s, "2:2", :expression; formula = "A1 = 16") == 0 + @test XLSX.setConditionalFormat(s, 2, :, :expression; formula = "A1 < 16", dxStyle="greenfilltext") == 0 + @test_throws XLSX.XLSXError XLSX.setConditionalFormat(s, 1:10, 1:3, :expression; + stopIfTrue="true", + 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", + 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 = "iseven(A1)", + stopIfTrue="true", + fill=["pattern" => "lightVertical", "fgColor" => "grey", "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 < 10", + stopIfTrue="true", + 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 < 5", + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "pink"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "italic" => "true"] + ) == 0 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:C10") => (type="expression", priority=3), + XLSX.CellRange("A1:C10") => (type="expression", priority=4), + XLSX.CellRange("A1:C10") => (type="expression", priority=5), + XLSX.CellRange("A1:C10") => (type="expression", priority=6), + XLSX.CellRange("A2:J2") => (type="expression", priority=1), + XLSX.CellRange("A2:J2") => (type="expression", priority=2), + ] + + @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 + @test XLSX.setConditionalFormat(s, "Sheet1!A1:A2", :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!1:2", :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, "2:4", :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, "A:C", :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!1:2", :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A:C", :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, :, 1:3, :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, 1:3, :, :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, "2:4", :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, "A:C", :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, :, :expression; formula = "iseven(A1)") == 0 + @test XLSX.setConditionalFormat(s, :, :, :expression; formula = "iseven(A1)") == 0 + @test length(XLSX.getConditionalFormats(s)) == 23 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:J10") => (type = "expression", priority = 22), + XLSX.CellRange("A1:J10") => (type = "expression", priority = 23), + XLSX.CellRange("A1:J3") => (type = "expression", priority = 18), + XLSX.CellRange("A2:J4") => (type = "expression", priority = 12), + XLSX.CellRange("A2:J4") => (type = "expression", priority = 19), + XLSX.CellRange("A1:J2") => (type = "expression", priority = 11), + XLSX.CellRange("A1:J2") => (type = "expression", priority = 15), + XLSX.CellRange("A1:A2") => (type = "expression", priority = 10), + XLSX.CellRange("A1:C3") => (type = "expression", priority = 8), + XLSX.CellRange("A1:A1") => (type = "expression", priority = 7), + XLSX.CellRange("A1:A1") => (type = "expression", priority = 9), + XLSX.CellRange("A1:C10") => (type = "expression", priority = 3), + XLSX.CellRange("A1:C10") => (type = "expression", priority = 4), + XLSX.CellRange("A1:C10") => (type = "expression", priority = 5), + XLSX.CellRange("A1:C10") => (type = "expression", priority = 6), + XLSX.CellRange("A1:C10") => (type = "expression", priority = 13), + XLSX.CellRange("A1:C10") => (type = "expression", priority = 14), + XLSX.CellRange("A1:C10") => (type = "expression", priority = 16), + XLSX.CellRange("A1:C10") => (type = "expression", priority = 17), + XLSX.CellRange("A1:C10") => (type = "expression", priority = 20), + XLSX.CellRange("A1:C10") => (type = "expression", priority = 21), + XLSX.CellRange("A2:J2") => (type = "expression", priority = 1), + XLSX.CellRange("A2:J2") => (type = "expression", priority = 2) + ] + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + 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; + formula="A1=1", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "thick", "color" => "coral"] + ) == 0 + + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("C1:D5") => (type="expression", priority=4), XLSX.CellRange("E1:E10") => (type="expression", priority=3), XLSX.CellRange("B1:B10") => (type="expression", priority=2), XLSX.CellRange("A1:A5") => (type="expression", priority=1)] + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + @test XLSX.setConditionalFormat(s, :, 1:4, :expression; + formula = "A1 > \$E\$3", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green", "under" => "double"], + border=["style" => "thin", "color" => "coral"] + ) == 0 + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + XLSX.addDefinedName(f, "myTest", "Sheet1!L11") + s["L11"] = 70 + XLSX.addDefinedName(s, "myRange", "F6:J10") + + @test XLSX.setConditionalFormat(s, "myRange", :expression; + formula="E5 > myTest", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "medium", "color" => "cyan"] + ) == 0 + XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5") + @test_throws MethodError XLSX.setConditionalFormat(s, "myNCRange", :expression; # Non-contiguous ranges not allowed + formula = "C4 < myTest", + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) + + end + + @testset "containsErrors" begin + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + @test_throws MethodError XLSX.setConditionalFormat(s, "A1:A5,C1:C5", :containsErrors) # Non-contiguous ranges not allowed + @test_throws MethodError XLSX.setConditionalFormat(s, [2, 3, 8], 1:3, :containsErrors) # Vectors may be non-contiguous + @test_throws MethodError XLSX.setConditionalFormat(s, 2, 1:3:7, :containsErrors) # StepRange is non-contiguous + @test XLSX.setConditionalFormat(s, "2:2", :containsErrors) == 0 + @test XLSX.setConditionalFormat(s, 2, :, :containsErrors; dxStyle="greenfilltext") == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:3, :containsErrors; + stopIfTrue="true", + 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, :notContainsErrors; + stopIfTrue="true", + fill=["pattern" => "lightVertical", "fgColor" => "grey", "bgColor" => "FFFFC7CE"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:3, :containsBlanks; + stopIfTrue="true", + 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, :notContainsBlanks; + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "pink"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "blue", "bold" => "true", "italic" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:3, :uniqueValues; + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "FFFFCFCE"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "yellow", "bold" => "true", "strike" => "true"] + ) == 0 + @test XLSX.setConditionalFormat(s, 1:10, 1:3, :duplicateValues; + stopIfTrue="true", + fill=["pattern" => "none", "bgColor" => "yellow"], + border=["style" => "thick", "color" => "coral"], + font=["color" => "green", "bold" => "true", "italic" => "true"] + ) == 0 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:C10") => (type="containsErrors", priority=3), + XLSX.CellRange("A1:C10") => (type="notContainsErrors", priority=4), + XLSX.CellRange("A1:C10") => (type="containsBlanks", priority=5), + XLSX.CellRange("A1:C10") => (type="notContainsBlanks", priority=6), + XLSX.CellRange("A1:C10") => (type="uniqueValues", priority=7), + XLSX.CellRange("A1:C10") => (type="duplicateValues", priority=8), + XLSX.CellRange("A2:J2") => (type="containsErrors", priority=1), + XLSX.CellRange("A2:J2") => (type="containsErrors", priority=2) + ] + + @test XLSX.setConditionalFormat(s, "A1", :containsErrors) == 0 + @test XLSX.setConditionalFormat(s, "A1:C3", :notContainsErrors) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1", :containsBlanks) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A1:A2", :notContainsBlanks) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!1:2", :uniqueValues) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :duplicateValues) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :containsErrors) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :containsErrors) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!1:2", :containsErrors) == 0 + @test XLSX.setConditionalFormat(f, "Sheet1!A:C", :containsErrors) == 0 + @test XLSX.setConditionalFormat(s, :, 1:3, :containsErrors) == 0 + @test XLSX.setConditionalFormat(s, 1:3, :, :notContainsErrors) == 0 + @test XLSX.setConditionalFormat(s, "2:4", :containsBlanks) == 0 + @test XLSX.setConditionalFormat(s, "A:C", :notContainsBlanks) == 0 + @test XLSX.setConditionalFormat(s, "Sheet1!A:C", :containsErrors) == 0 + @test XLSX.setConditionalFormat(s, :, :uniqueValues) == 0 + @test XLSX.setConditionalFormat(s, :, :, :duplicateValues) == 0 + @test length(XLSX.getConditionalFormats(s)) == 25 + @test XLSX.getConditionalFormats(s) == [ + XLSX.CellRange("A1:J10") => (type="uniqueValues", priority=24), + XLSX.CellRange("A1:J10") => (type="duplicateValues", priority=25), + XLSX.CellRange("A1:J3") => (type="notContainsErrors", priority=20), + XLSX.CellRange("A2:J4") => (type="duplicateValues", priority=14), + XLSX.CellRange("A2:J4") => (type="containsBlanks", priority=21), + XLSX.CellRange("A1:J2") => (type="uniqueValues", priority=13), + XLSX.CellRange("A1:J2") => (type="containsErrors", priority=17), + XLSX.CellRange("A1:A2") => (type="notContainsBlanks", priority=12), + XLSX.CellRange("A1:C3") => (type="notContainsErrors", priority=10), + XLSX.CellRange("A1:A1") => (type="containsErrors", priority=9), + XLSX.CellRange("A1:A1") => (type="containsBlanks", priority=11), + XLSX.CellRange("A1:C10") => (type="containsErrors", priority=3), + XLSX.CellRange("A1:C10") => (type="notContainsErrors", priority=4), + XLSX.CellRange("A1:C10") => (type="containsBlanks", priority=5), + XLSX.CellRange("A1:C10") => (type="notContainsBlanks", priority=6), + XLSX.CellRange("A1:C10") => (type="uniqueValues", priority=7), + XLSX.CellRange("A1:C10") => (type="duplicateValues", priority=8), + XLSX.CellRange("A1:C10") => (type="containsErrors", priority=15), + XLSX.CellRange("A1:C10") => (type="containsErrors", priority=16), + XLSX.CellRange("A1:C10") => (type="containsErrors", priority=18), + XLSX.CellRange("A1:C10") => (type="containsErrors", priority=19), + XLSX.CellRange("A1:C10") => (type="notContainsBlanks", priority=22), + XLSX.CellRange("A1:C10") => (type="containsErrors", priority=23), + XLSX.CellRange("A2:J2") => (type="containsErrors", priority=1), + XLSX.CellRange("A2:J2") => (type="containsErrors", priority=2) + ] + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + 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; + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "thick", "color" => "coral"] + ) == 0 + + @test XLSX.getConditionalFormats(s) == [XLSX.CellRange("C1:D5") => (type="uniqueValues", priority=4), XLSX.CellRange("E1:E10") => (type="containsBlanks", priority=3), XLSX.CellRange("B1:B10") => (type="notContainsErrors", priority=2), XLSX.CellRange("A1:A5") => (type="containsErrors", priority=1)] + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + + @test XLSX.setConditionalFormat(s, :, 1:4, :containsErrors; + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green", "under" => "double"], + border=["style" => "thin", "color" => "coral"] + ) == 0 + + f = XLSX.newxlsx() + s = f[1] + for i = 1:10 + for j = 1:10 + s[i, j] = i * j + end + end + XLSX.addDefinedName(s, "myRange", "A1:E5") + @test XLSX.setConditionalFormat(s, "myRange", :containsErrors; + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "medium", "color" => "cyan"] + ) == 0 + XLSX.addDefinedName(s, "myNCRange", "C1:C5,D1:D5") + @test_throws MethodError XLSX.setConditionalFormat(s, "myNCRange", :containsErrors; # Non-contiguous ranges not allowed + fill=["pattern" => "none", "bgColor" => "yellow"], + format=["format" => "0.0"], + font=["color" => "green"], + border=["style" => "hair", "color" => "cyan"] + ) + + end + +end + +@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 + XLSX.openxlsx(joinpath(data_directory, "customXml.xlsx"); mode="rw") do f + 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 + + @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"])) + end + + 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) + + 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 + 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) + + 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) + + 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) + + 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) + + 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) + + + end @testset "filemodes" begin @@ -1928,11 +5526,11 @@ end ] # can't read or edit a file that does not exist - @test_throws AssertionError XLSX.openxlsx(filename, mode="r") do xf + @test_throws XLSX.XLSXError XLSX.openxlsx(filename, mode="r") do xf error("This should fail.") end - @test_throws AssertionError XLSX.openxlsx(filename, mode="rw") do xf + @test_throws XLSX.XLSXError XLSX.openxlsx(filename, mode="rw") do xf error("This should fail.") end @@ -1992,9 +5590,11 @@ end # test writing throws error if flag not set XLSX.openxlsx(filename) do xf sheet = xf[1] - @test_throws AssertionError sheet[1, 1] = "failure" + @test_throws XLSX.XLSXError sheet[1, 1] = "failure" end + @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) @@ -2052,7 +5652,7 @@ end test_data = [1 2 3; 4 5 6; 7 8 9] XLSX.openxlsx(filename, mode="w") do xf sheet = xf[1] - @test_throws AssertionError sheet["A7:C10"] = test_data + @test_throws XLSX.XLSXError sheet["A7:C10"] = test_data end end @@ -2132,28 +5732,28 @@ end # These tests are not sufficient. It may be possible using these tests (or similar) to create XLSX files # that are not valid Excel files and will not successfully open. I do not now how to test this here but # have successfully tested `output_table_escape_test.xlsx` and `escape.xlsx` manually. - @test XLSX.xlsx_escape("hello&world<'") == "hello&world<'" + @test XML.escape("hello&world<'") == "hello&world<'" @test XML.unescape("hello&world<'") == "hello&world<'" esc_filename = "output_table_escape_test.xlsx" isfile(esc_filename) && rm(esc_filename) esc_col_names = ["&' & \" < > '", "I❤Julia", "\"<'&O-O&'>\"", "<&>"] - esc_sheetname = XLSX.xlsx_escape("& & \" > < ") + esc_sheetname = "& & \" > < " esc_data = Vector{Any}(undef, 4) esc_data[1] = ["11&&", "12\"&", "13<&", "14>&", "15'&"] esc_data[2] = ["21&&&&", "22&\"&&", "23&<&&", "24&>&&", "25&'&&"] esc_data[3] = ["31&&&&&&", "32&&\"&&&", "33&&<&&&", "34&&>&&&", "35&&'&&&"] esc_data[4] = ["41& &; &&", "42\" \"; \"\"", "43< <; <<", "44> >; >>", "45' '; ''"] - XLSX.writetable(esc_filename, esc_data, XLSX.xlsx_escape.(esc_col_names), overwrite=true, sheetname=esc_sheetname) + XLSX.writetable(esc_filename, esc_data, esc_col_names, overwrite=true, sheetname=esc_sheetname) dtable = XLSX.readtable(esc_filename, esc_sheetname) r1_data, r1_col_names = dtable.data, dtable.column_labels check_test_data(r1_data, esc_data) - @test XML.unescape(string(r1_col_names[4])) == esc_col_names[4] - @test XML.unescape(string(r1_col_names[3])) == esc_col_names[3] - @test XML.unescape(string(r1_col_names[2])) == esc_col_names[2] - @test XML.unescape(string(r1_col_names[1])) == esc_col_names[1] + @test r1_col_names[4] == Symbol(esc_col_names[4]) + @test r1_col_names[3] == Symbol(esc_col_names[3]) + @test r1_col_names[2] == Symbol(esc_col_names[2]) + @test r1_col_names[1] == Symbol(esc_col_names[1]) rm(esc_filename) # compare to the backup version: escape.xlsx @@ -2161,10 +5761,10 @@ end r2_data, r2_col_names = [[x isa String ? XML.unescape(x) : x for x in y] for y in dtable.data], dtable.column_labels check_test_data(r2_data, esc_data) check_test_data(r2_data, r1_data) - @test XML.unescape(string(r2_col_names[4])) == esc_col_names[4] - @test XML.unescape(string(r2_col_names[3])) == esc_col_names[3] - @test XML.unescape(string(r2_col_names[2])) == esc_col_names[2] - @test XML.unescape(string(r2_col_names[1])) == esc_col_names[1] + @test string(r2_col_names[4]) == esc_col_names[4] + @test string(r2_col_names[3]) == esc_col_names[3] + @test string(r2_col_names[2]) == esc_col_names[2] + @test string(r2_col_names[1]) == esc_col_names[1] end # issue #67 @@ -2257,6 +5857,13 @@ end @test ismissing(sheet["J2"]) end +# issue #299 +@testset "empty_v" begin + xf = XLSX.readxlsx(joinpath(data_directory, "empty_v.xlsx")) + sheet1 = xf["Sheet1"] + @test XLSX.getcell(sheet1, "A1") == XLSX.Cell(XLSX.CellRef("A1"), "str", "", "", XLSX.Formula("\"\"")) +end + @testset "Tables.jl integration" begin f = XLSX.readxlsx(joinpath(data_directory, "general.xlsx")) s = f["table"]