diff --git a/CHANGELOG.md b/CHANGELOG.md index eab80dd5..d728bf82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] -## [v0.12.0](https://github.com/JuliaData/XLSX.jl/tree/v0.12.0) - 2026-07-06 +## [v0.12.0](https://github.com/JuliaData/XLSX.jl/tree/v0.12.0) - 2026-07-12 - Adopt XML.jl v0.4 - add a package extension to support [FileIO.jl](https://github.com/JuliaIO/FileIO.jl) +- Fix issues #425, #426, #427 and #428 - update copyright notice end-date - modularize tests - Julia floor raised to 1.10 (LTS) diff --git a/src/conditional-format-helpers.jl b/src/conditional-format-helpers.jl index 2445a131..acb94c6f 100644 --- a/src/conditional-format-helpers.jl +++ b/src/conditional-format-helpers.jl @@ -43,14 +43,10 @@ function allCfs(ws::Worksheet)::Vector{XML.Node} xf = get_xlsxfile(ws) target_file = get_relationship_target_by_id("xl", wb, ws.relationship_id) v = xf.data[target_file] - # Parse ephemerally when the raw string is still needed intact — promoting - # it to a parsed XML.Node via xmlroot/get_xml_data here would break - # eachrow's first_cache_fill!, which requires the raw string the first - # time this sheet is read (issue #425). sheetdoc = v isa String ? parse(v, XML.Node) : xmlroot(wb, ws.relationship_id) 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. +function add_cf_to_XML(ws, new_cf) wb = get_workbook(ws) sheetdoc = xmlroot(get_workbook(ws), ws.relationship_id) l = insert_index(sheetdoc[end], "conditionalFormatting", WORKSHEET_ORDER) @@ -61,6 +57,20 @@ function add_cf_to_XML(ws, new_cf) # Add a new conditional formatting to the wor push!(sheetdoc[end], new_cf) end end +function next_cf_priority!(ws::Worksheet)::Int + if ws.next_cf_priority === nothing + # One-time O(n) scan over whatever rules already exist (e.g. in a + # file opened with existing conditional formatting). Every + # subsequent call is O(1). + allcfs = allCfs(ws) + allextcfs = allExtCfs(ws) + old_cf = append!(getConditionalFormats(ws, allcfs), getConditionalExtFormats(ws, allextcfs)) + ws.next_cf_priority = isempty(old_cf) ? 1 : maximum(last(x).priority for x in old_cf) + 1 + end + pr = ws.next_cf_priority + ws.next_cf_priority += 1 + return pr +end function update_worksheet_cfx!(allcfs, cfx, ws, rng) pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" @@ -106,6 +116,24 @@ function allExtCfs(ws::Worksheet)::Vector{XML.Node} return isnothing(cfs) ? Vector{XML.Node}() : xml_elements(cfs) end end + +#= +function _extcfs_from_doc(sheetdoc::XML.Node)::Vector{XML.Node} + i, j = get_idces(sheetdoc, "worksheet", "extLst") + isnothing(j) && return Vector{XML.Node}() + extlst = sheetdoc[i][j] + cfs = nothing + for ext in XML.children(extlst) + for c in XML.children(ext) + if localname(c) == "conditionalFormattings" + cfs = c + break + end + end + end + return isnothing(cfs) ? Vector{XML.Node}() : xml_elements(cfs) +end +=# function make_extLst!(s) ext_list = XML.Element("extLst") ext_element = XML.Element("ext") diff --git a/src/conditional-formats.jl b/src/conditional-formats.jl index efc7c9e6..9f39c22f 100644 --- a/src/conditional-formats.jl +++ b/src/conditional-formats.jl @@ -1445,8 +1445,7 @@ function setCfCellIs(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any}=()) pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" - allcfs = allCfs(ws) # get all conditional format blocks - old_cf = getConditionalFormats(ws) # extract conditional format info + allcfs = allCfs(ws) !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.")) @@ -1460,7 +1459,7 @@ function setCfCellIs(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any}=()) value = all(ismissing.(ws[rng])) ? nothing : string(sum(skipmissing(ws[rng])) / count(!ismissing, ws[rng])) end cfx = XML.Element("$(pfx)cfRule"; type="cellIs", dxfId=string(dxid.id)) - cfx["priority"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + cfx["priority"] = next_cf_priority!(ws) if !isnothing(stopIfTrue) && stopIfTrue == "true" cfx["stopIfTrue"] = "1" end @@ -1527,8 +1526,7 @@ function setCfContainsText(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,An pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" - allcfs = allCfs(ws) # get all conditional format blocks - old_cf = getConditionalFormats(ws) # extract conditional format info + allcfs = allCfs(ws) isnothing(value) && throw(XLSXError("Invalid `value`: $value. Must contain text or a CellRef.")) @@ -1555,7 +1553,7 @@ function setCfContainsText(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,An formula = replace(formula, "__txt__" => value, "__CR__" => string(first(rng))) cfx = XML.Element("$(pfx)cfRule"; type=type, dxfId=string(dxid.id)) - cfx["priority"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + cfx["priority"] = next_cf_priority!(ws) if !isnothing(stopIfTrue) && stopIfTrue == "true" cfx["stopIfTrue"] = "1" end @@ -1619,8 +1617,7 @@ function setCfTop10(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any}=()): pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" - allcfs = allCfs(ws) # get all conditional format blocks - old_cf = getConditionalFormats(ws) # extract conditional format info + allcfs = allCfs(ws) !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.")) @@ -1644,7 +1641,7 @@ function setCfTop10(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any}=()): 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 + cfx["priority"] = next_cf_priority!(ws) if !isnothing(stopIfTrue) && stopIfTrue == "true" cfx["stopIfTrue"] = "1" end @@ -1709,8 +1706,7 @@ function setCfAboveAverage(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,An pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" - allcfs = allCfs(ws) # get all conditional format blocks - old_cf = getConditionalFormats(ws) # extract conditional format info + allcfs = allCfs(ws) wb = get_workbook(ws) dx = get_dx(dxStyle, format, font, border, fill) @@ -1741,7 +1737,7 @@ function setCfAboveAverage(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,An 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 + cfx["priority"] = next_cf_priority!(ws) if !isnothing(stopIfTrue) && stopIfTrue == "true" cfx["stopIfTrue"] = "1" end @@ -1799,8 +1795,7 @@ function setCfTimePeriod(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any} pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" - allcfs = allCfs(ws) # get all conditional format blocks - old_cf = getConditionalFormats(ws) # extract conditional format info + allcfs = allCfs(ws) if haskey(timeperiods, operator) formula = timeperiods[operator] @@ -1816,7 +1811,7 @@ function setCfTimePeriod(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any} dxid = Add_Cf_Dx(wb, new_dx) cfx = XML.Element("$(pfx)cfRule"; type="timePeriod", dxfId=string(dxid.id)) - cfx["priority"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + cfx["priority"] = next_cf_priority!(ws) if !isnothing(stopIfTrue) && stopIfTrue == "true" cfx["stopIfTrue"] = "1" end @@ -1877,8 +1872,8 @@ function setCfContainsBlankErrorUniqDup(ws::Worksheet, rng::CellRange; allkws::D pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" - allcfs = allCfs(ws) # get all conditional format blocks - old_cf = getConditionalFormats(ws) # extract conditional format info + allcfs = allCfs(ws) + if operator == "containsBlanks" formula = "LEN(TRIM(__CR__))=0" elseif operator == "notContainsBlanks" @@ -1900,7 +1895,7 @@ function setCfContainsBlankErrorUniqDup(ws::Worksheet, rng::CellRange; allkws::D dxid = Add_Cf_Dx(wb, new_dx) cfx = XML.Element("$(pfx)cfRule"; type=operator, dxfId=string(dxid.id)) - cfx["priority"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + cfx["priority"] = next_cf_priority!(ws) if !isnothing(stopIfTrue) && stopIfTrue == "true" cfx["stopIfTrue"] = "1" end @@ -1960,8 +1955,7 @@ function setCfFormula(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any}=() pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" - allcfs = allCfs(ws) # get all conditional format blocks - old_cf = getConditionalFormats(ws) # extract conditional format info + allcfs = allCfs(ws) wb = get_workbook(ws) dx = get_dx(dxStyle, format, font, border, fill) @@ -1969,7 +1963,7 @@ function setCfFormula(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any}=() dxid = Add_Cf_Dx(wb, new_dx) cfx = XML.Element("$(pfx)cfRule"; type="expression", dxfId=string(dxid.id)) - cfx["priority"] = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : 1 + cfx["priority"] = next_cf_priority!(ws) if !isnothing(stopIfTrue) && stopIfTrue == "true" cfx["stopIfTrue"] = "1" end @@ -2038,12 +2032,11 @@ function setCfColorScale(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any} pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" - allcfs = allCfs(ws) # get all conditional format blocks - old_cf = getConditionalFormats(ws) # extract conditional format info + allcfs = allCfs(ws) let new_pr, new_cf - new_pr = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : "1" + new_pr = string(next_cf_priority!(ws)) if isnothing(colorscale) @@ -2091,17 +2084,6 @@ function setCfColorScale(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any} push!(csc, XML.Element("$(pfx)color"; rgb=get_color(min_col))) !isnothing(mid_type) && push!(csc, XML.Element("$(pfx)color"; rgb=get_color(mid_col))) push!(csc, XML.Element("$(pfx)color"; rgb=get_color(max_col))) - -# 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)) -# ) -# ) push!(cfx, csc) else if !haskey(colorscales, colorscale) @@ -2194,13 +2176,12 @@ function setCfIconSet(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any}=() pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" - 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 + allcfs = allCfs(ws) + allextcfs = allExtCfs(ws) let new_pr, new_cf - new_pr = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : "1" + new_pr = string(next_cf_priority!(ws)) isnothing(min_type) || min_type in ["percentile", "percent", "num", "formula"] || throw(XLSXError("Invalid min_type: $min_type. Valid options are: percentile, percent, num, formula.")) (!isnothing(min_type) && min_type == "formula") || isnothing(min_val) || is_valid_fixed_cellname(min_val) || is_valid_fixed_sheet_cellname(min_val) || !isnothing(tryparse(Float64, min_val)) || throw(XLSXError("Invalid min_val: `$min_val`. Valid options (unless min_type is `formula`) are a CellRef (e.g. `\$A\$1`) or a number.")) @@ -2416,13 +2397,13 @@ function setCfDataBar(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any}=() pfx = get_prefix(ws) pfx = pfx == "" ? pfx : pfx * ":" - 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 + allcfs = allCfs(ws) + allextcfs = allExtCfs(ws) let new_pr, new_cf - new_pr = length(old_cf) > 0 ? string(maximum([last(x).priority for x in values(old_cf)]) + 1) : "1" + new_pr = string(next_cf_priority!(ws)) + isnothing(min_type) || min_type in ["least", "percentile", "percent", "num", "formula", "automatic"] || throw(XLSXError("Invalid min_type: $min_type. Valid options are: least, percentile, percent, num, formula.")) if min_type in ["least", "automatic"] min_val = nothing @@ -2498,14 +2479,6 @@ function setCfDataBar(ws::Worksheet, rng::CellRange; allkws::Dict{Symbol,Any}=() push!(cdb, XML.Element("$(pfx)color"; rgb=get_color(allkws["fill_col"]))) -# 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() -# ) - push!(cfx, cdb) if haskey(allkws, "showVal") && !isnothing(allkws["showVal"]) && allkws["showVal"] == "false" diff --git a/src/sst.jl b/src/sst.jl index a084aa05..ad7f58e7 100644 --- a/src/sst.jl +++ b/src/sst.jl @@ -183,8 +183,9 @@ end # Looks for a string inside the Shared Strings Table (sst). # `index` starts at 0. @inline function sst_unformatted_string(wb::Workbook, index::Int64)::String - sst_load!(wb) - return get_sst(wb).unformatted[index+1] + sst = get_sst(wb) + sst.is_loaded || sst_load!(wb) + return sst.unformatted[index+1] end @inline sst_unformatted_string(xl::XLSXFile, index::Int64) :: String = sst_unformatted_string(get_workbook(xl), index) diff --git a/src/types.jl b/src/types.jl index 8ca6fca1..4b81d099 100644 --- a/src/types.jl +++ b/src/types.jl @@ -356,12 +356,6 @@ mutable struct SheetRowStreamIteratorState{I} local_formulas::Dict{SheetCellRef,AbstractFormula} rows_since_merge::Int end -#= -struct SheetRowStreamIteratorState{I} - row_iter::I - rowcells::Dict{Int,Cell} -end -=# mutable struct WorksheetCacheIteratorState row_from_last_iteration::Int @@ -397,19 +391,20 @@ println( sh[:] ) # all data inside worksheet's dimension ``` """ mutable struct Worksheet - package::MSOfficePackage # parent XLSXFile + package::MSOfficePackage sheetId::Int - relationship_id::String # r:id="rId1" + relationship_id::String name::String dimension::Union{Nothing, CellRange} is_hidden::Bool cache::Union{WorksheetCache, Nothing} next_formula_id::Int - unhandled_attributes::Union{Nothing,Dict{Int,Dict{String,String}}} # row => attributes(name=>value) - sst_count::Int # number of cells containing a shared string + unhandled_attributes::Union{Nothing,Dict{Int,Dict{String,String}}} + sst_count::Int + next_cf_priority::Union{Int, Nothing} # next priority to assign; nothing until first computed function Worksheet(package::MSOfficePackage, sheetId::Int, relationship_id::String, name::String, dimension::Union{Nothing, CellRange}, is_hidden::Bool) - return new(package, sheetId, relationship_id, name, dimension, is_hidden, nothing, 0, nothing, 0) + return new(package, sheetId, relationship_id, name, dimension, is_hidden, nothing, 0, nothing, 0, nothing) end end diff --git a/src/worksheet.jl b/src/worksheet.jl index 85d92e79..e7752b97 100644 --- a/src/worksheet.jl +++ b/src/worksheet.jl @@ -330,28 +330,48 @@ function getdata(ws::Worksheet, rng::CellRange)::Array{Any,2} bottom = row_number(rng.stop) left = column_number(rng.start) right = column_number(rng.stop) + width = right - left + 1 - # Fast path: cache already covers the whole sheet, so we can index - # straight into ws.cache.cells for just the rows we need instead of - # walking every row from 1 up to `bottom`. if !isnothing(ws.cache) && is_cache_enabled(ws) && ws.cache.is_full cells = ws.cache.cells - for r in top:bottom - row_dict = get(cells, r, nothing) - isnothing(row_dict) && continue - for c in left:right - cell = get(row_dict, c, nothing) - isnothing(cell) && continue - if !isempty(cell) - result[r - top + 1, c - left + 1] = getdata(ws, cell) + rows = ws.cache.rows_in_cache # sorted Vector{Int} of populated row numbers + + # Binary-search the populated-row range instead of hash-probing every + # integer in top:bottom — cheap for dense sheets (same row count, just + # a plain index walk instead of hashing) and skips gaps outright for + # sparse ones. + lo = searchsortedfirst(rows, top) + hi = searchsortedlast(rows, bottom) + + @inbounds for k in lo:hi + r = rows[k] + row_dict = cells[r] # r came from rows_in_cache, so this key must exist + + if length(row_dict) <= width + # Fewer entries than the span — cheaper to walk the row's own + # entries (no hashing) and filter to range. + for (c, cell) in row_dict + (left <= c <= right) || continue + if !isempty(cell) + result[r - top + 1, c - left + 1] = getdata(ws, cell) + end + end + else + # At least as full as the span — cheaper to probe just the + # columns actually requested than to walk past irrelevant ones. + for c in left:right + cell = get(row_dict, c, nothing) + isnothing(cell) && continue + if !isempty(cell) + result[r - top + 1, c - left + 1] = getdata(ws, cell) + end end end end return result end - # Fallback: cache isn't fully populated (or is disabled), so we still - # need to stream rows in order to fill it / read them. + # Fallback: cache isn't fully populated (or is disabled) — unchanged. for sheetrow in eachrow(ws) if top <= sheetrow.row && sheetrow.row <= bottom for column in left:right @@ -367,7 +387,6 @@ function getdata(ws::Worksheet, rng::CellRange)::Array{Any,2} return result end - function getdata(ws::Worksheet, rng::ColumnRange)::Array{Any,2} dim = get_dimension(ws) start = CellRef(dim.start.row_number, rng.start)