xlsxrb 0.1.16 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +2 -2
- data/lib/xlsxrb/chart_builder.rb +41 -2
- data/lib/xlsxrb/dsl_helpers.rb +522 -6
- data/lib/xlsxrb/elements/cell.rb +34 -9
- data/lib/xlsxrb/elements/column.rb +2 -2
- data/lib/xlsxrb/elements/types.rb +1 -0
- data/lib/xlsxrb/elements/workbook.rb +48 -0
- data/lib/xlsxrb/elements/worksheet.rb +158 -11
- data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +22 -1
- data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +187 -24
- data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +108 -1
- data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +16 -11
- data/lib/xlsxrb/ooxml/reader.rb +88 -0
- data/lib/xlsxrb/ooxml/styles_parser.rb +19 -11
- data/lib/xlsxrb/ooxml/workbook_parser.rb +18 -3
- data/lib/xlsxrb/ooxml/workbook_writer.rb +266 -46
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +18 -4
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +289 -88
- data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +579 -441
- data/lib/xlsxrb/ooxml/writer/features_xml.rb +26 -9
- data/lib/xlsxrb/ooxml/writer/styles_xml.rb +23 -9
- data/lib/xlsxrb/ooxml/writer.rb +268 -31
- data/lib/xlsxrb/ooxml/zip_writer.rb +15 -5
- data/lib/xlsxrb/stream_sheet.rb +157 -3
- data/lib/xlsxrb/stream_writer.rb +946 -74
- data/lib/xlsxrb/style_builder.rb +59 -6
- data/lib/xlsxrb/utils.rb +65 -0
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb/workbook_builder.rb +197 -14
- data/lib/xlsxrb/worksheet_builder.rb +1137 -44
- data/lib/xlsxrb.rb +302 -47
- data/sig/generated/xlsxrb/chart_builder.rbs +31 -3
- data/sig/generated/xlsxrb/dsl_helpers.rbs +119 -4
- data/sig/generated/xlsxrb/elements/cell.rbs +20 -3
- data/sig/generated/xlsxrb/elements/column.rbs +3 -3
- data/sig/generated/xlsxrb/elements/types.rbs +2 -0
- data/sig/generated/xlsxrb/elements/workbook.rbs +37 -0
- data/sig/generated/xlsxrb/elements/worksheet.rbs +82 -2
- data/sig/generated/xlsxrb/ooxml/reader/listeners/core_listeners.rbs +2 -0
- data/sig/generated/xlsxrb/ooxml/reader/listeners/drawing_listeners.rbs +74 -2
- data/sig/generated/xlsxrb/ooxml/reader/listeners/feature_listeners.rbs +36 -0
- data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +3 -0
- data/sig/generated/xlsxrb/ooxml/reader.rbs +17 -0
- data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +3 -0
- data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +8 -0
- data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +8 -4
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +1 -1
- data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +12 -1
- data/sig/generated/xlsxrb/ooxml/writer/drawing_xml.rbs +16 -0
- data/sig/generated/xlsxrb/ooxml/writer.rbs +42 -8
- data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +8 -2
- data/sig/generated/xlsxrb/stream_sheet.rbs +101 -1
- data/sig/generated/xlsxrb/stream_writer.rbs +592 -23
- data/sig/generated/xlsxrb/style_builder.rbs +24 -6
- data/sig/generated/xlsxrb/utils.rbs +20 -0
- data/sig/generated/xlsxrb/workbook_builder.rbs +87 -5
- data/sig/generated/xlsxrb/worksheet_builder.rbs +554 -10
- data/sig/generated/xlsxrb.rbs +77 -16
- metadata +1 -1
data/lib/xlsxrb/dsl_helpers.rb
CHANGED
|
@@ -94,21 +94,73 @@ module Xlsxrb
|
|
|
94
94
|
{ left: left, right: right, top: top, bottom: bottom, header: header, footer: footer }.compact
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
TOTALS_ROW_FUNCTIONS = {
|
|
98
|
+
sum: "sum",
|
|
99
|
+
average: "average",
|
|
100
|
+
count: "count",
|
|
101
|
+
count_nums: "countNums",
|
|
102
|
+
countnums: "countNums",
|
|
103
|
+
max: "max",
|
|
104
|
+
min: "min",
|
|
105
|
+
std_dev: "stdDev",
|
|
106
|
+
stddev: "stdDev",
|
|
107
|
+
var: "var",
|
|
108
|
+
custom: "custom"
|
|
109
|
+
}.freeze
|
|
110
|
+
|
|
97
111
|
# Normalizes table options hash.
|
|
98
112
|
#
|
|
99
113
|
# @param ref [String]
|
|
100
|
-
# @param columns [Array<String>, Array<Hash>]
|
|
114
|
+
# @param columns [Array<String>, Array<Hash>, Array<Symbol>]
|
|
101
115
|
# @param name [String, nil]
|
|
102
116
|
# @param display_name [String, nil]
|
|
103
|
-
# @param style [String, nil]
|
|
117
|
+
# @param style [String, Hash, nil]
|
|
104
118
|
# @param opts [Hash]
|
|
105
119
|
# @return [Hash]
|
|
106
|
-
#: (String ref, columns: untyped, ?name: String?, ?display_name: String?, ?style: String?, **untyped opts) -> Hash[Symbol, untyped]
|
|
120
|
+
#: (String ref, columns: untyped, ?name: String?, ?display_name: String?, ?style: (String | Hash[Symbol, untyped])?, **untyped opts) -> Hash[Symbol, untyped]
|
|
107
121
|
def self.normalize_table_options(ref, columns:, name: nil, display_name: nil, style: nil, **opts)
|
|
108
|
-
tbl = { ref: ref
|
|
109
|
-
|
|
110
|
-
tbl[:
|
|
122
|
+
tbl = { ref: ref }
|
|
123
|
+
cols = columns.is_a?(Array) ? columns : [columns]
|
|
124
|
+
tbl[:columns] = cols.map do |c|
|
|
125
|
+
if c.is_a?(Hash)
|
|
126
|
+
col = c.dup
|
|
127
|
+
col[:name] = col[:name].to_s if col.key?(:name)
|
|
128
|
+
calc_f = col[:calculated_column_formula] || col[:data_formula] || col[:formula]
|
|
129
|
+
col[:calculated_column_formula] = calc_f.to_s if calc_f
|
|
130
|
+
tot_f = col[:totals_row_formula] || col[:total_row_formula] || col[:total_formula]
|
|
131
|
+
col[:totals_row_formula] = tot_f.to_s if tot_f
|
|
132
|
+
if col.key?(:totals_row_function)
|
|
133
|
+
fn = col[:totals_row_function]
|
|
134
|
+
col[:totals_row_function] = TOTALS_ROW_FUNCTIONS[fn.to_sym] || fn.to_s
|
|
135
|
+
elsif tot_f
|
|
136
|
+
col[:totals_row_function] = "custom"
|
|
137
|
+
end
|
|
138
|
+
col
|
|
139
|
+
else
|
|
140
|
+
c.to_s
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
tbl[:name] = name.to_s if name
|
|
144
|
+
tbl[:display_name] = display_name.to_s if display_name
|
|
111
145
|
tbl[:style] = style if style
|
|
146
|
+
|
|
147
|
+
# Normalize total row options
|
|
148
|
+
if opts.key?(:total_row) || opts.key?(:totals_row)
|
|
149
|
+
tr = opts.delete(:total_row)
|
|
150
|
+
tr = opts.delete(:totals_row) if tr.nil?
|
|
151
|
+
tbl[:totals_row_count] = if tr == true
|
|
152
|
+
1
|
|
153
|
+
elsif tr == false
|
|
154
|
+
0
|
|
155
|
+
else
|
|
156
|
+
tr.to_i
|
|
157
|
+
end
|
|
158
|
+
elsif opts.key?(:total_row_count)
|
|
159
|
+
tbl[:totals_row_count] = opts.delete(:total_row_count).to_i
|
|
160
|
+
elsif opts.key?(:totals_row_count)
|
|
161
|
+
tbl[:totals_row_count] = opts.delete(:totals_row_count).to_i
|
|
162
|
+
end
|
|
163
|
+
|
|
112
164
|
tbl.merge!(opts)
|
|
113
165
|
tbl
|
|
114
166
|
end
|
|
@@ -171,5 +223,469 @@ module Xlsxrb
|
|
|
171
223
|
raise ArgumentError, "Row index #{row_index} exceeds Excel limit of 1,048,576 rows" if row_index >= 1_048_576
|
|
172
224
|
raise ArgumentError, "Row height #{height} must be between 0 and 409 points (Excel limitation)" if height && (height.negative? || height > 409)
|
|
173
225
|
end
|
|
226
|
+
|
|
227
|
+
# Compresses a sorted or unsorted collection of integers and/or ranges into minimal disjoint [start, finish] runs.
|
|
228
|
+
# Operates on intervals directly without expanding ranges into individual integer arrays.
|
|
229
|
+
#
|
|
230
|
+
# @param values [Integer, Range, Enumerable, nil]
|
|
231
|
+
# @return [Array<Array(Integer, Integer)>]
|
|
232
|
+
#: (untyped values) -> Array[[Integer, Integer]]
|
|
233
|
+
def self.compress_runs(values)
|
|
234
|
+
intervals = []
|
|
235
|
+
items = values.is_a?(Array) ? values : [values]
|
|
236
|
+
items.each do |item|
|
|
237
|
+
case item
|
|
238
|
+
when Range
|
|
239
|
+
first = item.begin
|
|
240
|
+
last = item.exclude_end? ? item.end - 1 : item.end
|
|
241
|
+
intervals << [first, last] if first <= last
|
|
242
|
+
when Integer
|
|
243
|
+
intervals << [item, item]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
merged = []
|
|
248
|
+
intervals.sort.each do |s, e|
|
|
249
|
+
last = merged.last
|
|
250
|
+
if !last || s > last.last + 1
|
|
251
|
+
merged << [s, e]
|
|
252
|
+
else
|
|
253
|
+
last[1] = [last.last, e].max
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
merged
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Returns minimal disjoint runs within min..max that are NOT present in excluded.
|
|
260
|
+
#
|
|
261
|
+
# @param excluded [Integer, Range, Enumerable, nil]
|
|
262
|
+
# @param min [Integer]
|
|
263
|
+
# @param max [Integer]
|
|
264
|
+
# @return [Array<Array(Integer, Integer)>]
|
|
265
|
+
#: (untyped excluded, Integer min, Integer max) -> Array[[Integer, Integer]]
|
|
266
|
+
def self.complement_runs(excluded, min, max)
|
|
267
|
+
runs = []
|
|
268
|
+
start_pt = min
|
|
269
|
+
compress_runs(excluded).each do |e_start, e_end|
|
|
270
|
+
if e_end >= start_pt && e_start <= max
|
|
271
|
+
runs << [start_pt, e_start - 1] if e_start > start_pt
|
|
272
|
+
start_pt = e_end + 1
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
runs << [start_pt, max] if start_pt <= max
|
|
276
|
+
runs
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Builds an A1-style reference from a column interval [col_start, col_end] (0-based)
|
|
280
|
+
# and a row interval [row_start, row_end] (1-based Excel row number).
|
|
281
|
+
#
|
|
282
|
+
# @param col_run [Array(Integer, Integer)]
|
|
283
|
+
# @param row_run [Array(Integer, Integer)]
|
|
284
|
+
# @return [String]
|
|
285
|
+
#: (Array[Integer] col_run, Array[Integer] row_run) -> String
|
|
286
|
+
def self.area_ref(col_run, row_run)
|
|
287
|
+
c1, c2 = col_run
|
|
288
|
+
r1, r2 = row_run
|
|
289
|
+
ref1 = "#{Elements::Cell.column_letter(c1)}#{r1}"
|
|
290
|
+
c1 == c2 && r1 == r2 ? ref1 : "#{ref1}:#{Elements::Cell.column_letter(c2)}#{r2}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Combines column intervals and row intervals into chunked space-delimited sqref strings
|
|
294
|
+
# respecting the maximum reference limit per dataValidation tag (default 32,767).
|
|
295
|
+
#
|
|
296
|
+
# @param col_runs [Array<Array(Integer, Integer)>]
|
|
297
|
+
# @param row_runs [Array<Array(Integer, Integer)>]
|
|
298
|
+
# @param max_per_validation [Integer]
|
|
299
|
+
# @return [Array<String>]
|
|
300
|
+
#: (Array[Array[Integer]] col_runs, Array[Array[Integer]] row_runs, ?Integer max_per_validation) -> Array[String]
|
|
301
|
+
def self.chunked_sqrefs(col_runs, row_runs, max_per_validation = MAX_REFS_PER_VALIDATION)
|
|
302
|
+
refs = col_runs.flat_map { |col_run| row_runs.map { |row_run| area_ref(col_run, row_run) } }
|
|
303
|
+
refs.each_slice(max_per_validation).map { |chunk| chunk.join(" ") }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Quotes a sheet name for use in formula and defined name references, escaping single quotes with double single quotes.
|
|
307
|
+
#
|
|
308
|
+
# @param sheet_name [String, Object]
|
|
309
|
+
# @return [String]
|
|
310
|
+
#: (untyped sheet_name) -> String
|
|
311
|
+
def self.quote_sheet_name(sheet_name)
|
|
312
|
+
"'#{sheet_name.to_s.gsub("'", "''")}'"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Converts a cell reference, rectangular cell range, column range, or row range to absolute $ form.
|
|
316
|
+
# Returns nil if the input is not a coordinate or range.
|
|
317
|
+
#
|
|
318
|
+
# @param ref [String]
|
|
319
|
+
# @return [String, nil]
|
|
320
|
+
#: (String ref) -> String?
|
|
321
|
+
def self.format_absolute_reference(ref)
|
|
322
|
+
u = ref.upcase
|
|
323
|
+
case u
|
|
324
|
+
when /\A\$?[A-Z]{1,3}\$?\d+(?::\$?[A-Z]{1,3}\$?\d+)?\z/
|
|
325
|
+
absolute_range(u)
|
|
326
|
+
when /\A\$?([A-Z]{1,3}):\$?([A-Z]{1,3})\z/, /\A\$?(\d+):\$?(\d+)\z/
|
|
327
|
+
"$#{Regexp.last_match(1)}:$#{Regexp.last_match(2)}"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Normalizes defined name value, auto-formatting bare coordinates and ranges with sheet quotes and absolute dollar references.
|
|
332
|
+
# Leaves constants, formulas, and already-qualified references untouched.
|
|
333
|
+
#
|
|
334
|
+
# @param value [Object, nil]
|
|
335
|
+
# @param sheet [String, nil]
|
|
336
|
+
# @param col [Integer, String, nil]
|
|
337
|
+
# @param rows [Integer, Range, nil]
|
|
338
|
+
# @param range [String, nil]
|
|
339
|
+
# @return [String]
|
|
340
|
+
#: (?untyped value, ?sheet: untyped, ?col: untyped, ?rows: untyped, ?range: untyped) -> String
|
|
341
|
+
def self.normalize_defined_name_value(value = nil, sheet: nil, col: nil, rows: nil, range: nil)
|
|
342
|
+
if range
|
|
343
|
+
value = range
|
|
344
|
+
elsif col || rows
|
|
345
|
+
c_letter = Elements::Cell.column_letter(col ? Elements::Cell.column_index(col) : 0)
|
|
346
|
+
value = if rows.is_a?(Range)
|
|
347
|
+
r2 = rows.exclude_end? ? rows.end - 1 : rows.end
|
|
348
|
+
"$#{c_letter}$#{rows.begin}:$#{c_letter}$#{r2}"
|
|
349
|
+
elsif rows
|
|
350
|
+
"$#{c_letter}$#{rows}"
|
|
351
|
+
else
|
|
352
|
+
"$#{c_letter}:$#{c_letter}"
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
str = value.to_s
|
|
357
|
+
abs = format_absolute_reference(str)
|
|
358
|
+
if abs
|
|
359
|
+
sheet ? "#{quote_sheet_name(sheet)}!#{abs}" : abs
|
|
360
|
+
else
|
|
361
|
+
str
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Normalizes data validation arguments (sqref, coordinate targeting, list arrays, input/error message inference).
|
|
366
|
+
#
|
|
367
|
+
# @param sqref [String, nil]
|
|
368
|
+
# @param opts [Hash]
|
|
369
|
+
# @yield [DataValidationBuilder]
|
|
370
|
+
# @return [Array<Hash>]
|
|
371
|
+
#: (?String? sqref, **untyped opts) ?{ (DataValidationBuilder) -> void } -> Array[Hash[Symbol, untyped]]
|
|
372
|
+
def self.normalize_data_validation(sqref = nil, **opts)
|
|
373
|
+
if block_given?
|
|
374
|
+
builder = DataValidationBuilder.new(sqref, **opts)
|
|
375
|
+
yield builder
|
|
376
|
+
opts = builder.to_h
|
|
377
|
+
else
|
|
378
|
+
opts = opts.dup
|
|
379
|
+
end
|
|
380
|
+
sqref = opts.delete(:sqref) || sqref
|
|
381
|
+
|
|
382
|
+
if opts[:in].is_a?(Array)
|
|
383
|
+
opts[:type] ||= "list"
|
|
384
|
+
opts[:formula1] ||= %("#{opts.delete(:in).join(",")}")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# ST_Xstring conversion for newlines in prompt and error messages
|
|
388
|
+
opts[:prompt] = opts[:prompt].to_s.gsub(/\r\n|\r|\n/, "_x000a_") if opts[:prompt]
|
|
389
|
+
opts[:error] = opts[:error].to_s.gsub(/\r\n|\r|\n/, "_x000a_") if opts[:error]
|
|
390
|
+
opts[:prompt_title] = opts[:prompt_title].to_s.gsub(/\r\n|\r|\n/, "_x000a_") if opts[:prompt_title]
|
|
391
|
+
opts[:error_title] = opts[:error_title].to_s.gsub(/\r\n|\r|\n/, "_x000a_") if opts[:error_title]
|
|
392
|
+
|
|
393
|
+
# Auto-infer show_input_message / show_error_message
|
|
394
|
+
opts[:show_input_message] = true if !opts.key?(:show_input_message) && (!opts[:prompt].nil? || !opts[:prompt_title].nil?)
|
|
395
|
+
opts[:show_error_message] = true if !opts.key?(:show_error_message) && (!opts[:error].nil? || !opts[:error_title].nil?)
|
|
396
|
+
|
|
397
|
+
# Determine sqrefs
|
|
398
|
+
sqrefs = []
|
|
399
|
+
has_coords = opts.key?(:columns) || opts.key?(:col) || opts.key?(:rows) || opts.key?(:row)
|
|
400
|
+
if sqref.nil? && has_coords
|
|
401
|
+
cols_val = opts.delete(:columns)
|
|
402
|
+
col_val = opts.delete(:col)
|
|
403
|
+
raw_cols = cols_val || col_val || 0
|
|
404
|
+
|
|
405
|
+
rows_val = opts.delete(:rows)
|
|
406
|
+
row_val = opts.delete(:row)
|
|
407
|
+
raw_rows = rows_val || row_val || (1..Xlsxrb::MAX_ROW)
|
|
408
|
+
|
|
409
|
+
except_rows = opts.delete(:except_rows)
|
|
410
|
+
|
|
411
|
+
col_indices = normalize_column_indices(raw_cols)
|
|
412
|
+
col_runs = compress_runs(col_indices)
|
|
413
|
+
row_runs = compress_runs(raw_rows)
|
|
414
|
+
row_runs = row_runs.flat_map { |s, e| complement_runs(except_rows, s, e) } if except_rows
|
|
415
|
+
sqrefs = chunked_sqrefs(col_runs, row_runs)
|
|
416
|
+
elsif sqref
|
|
417
|
+
opts.delete(:columns)
|
|
418
|
+
opts.delete(:col)
|
|
419
|
+
opts.delete(:rows)
|
|
420
|
+
opts.delete(:row)
|
|
421
|
+
opts.delete(:except_rows)
|
|
422
|
+
str_sqref = sqref.to_s
|
|
423
|
+
refs = str_sqref.split
|
|
424
|
+
sqrefs = if refs.size > Xlsxrb::MAX_REFS_PER_VALIDATION
|
|
425
|
+
refs.each_slice(Xlsxrb::MAX_REFS_PER_VALIDATION).map { |chunk| chunk.join(" ") }
|
|
426
|
+
else
|
|
427
|
+
[str_sqref]
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
raise ArgumentError, "data validation sqref or coordinates (columns/rows) must be provided" if sqrefs.empty?
|
|
432
|
+
|
|
433
|
+
sqrefs.map { |ref| opts.merge(sqref: ref) }
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
CF_OPERATORS = {
|
|
437
|
+
greater_than: "greaterThan",
|
|
438
|
+
less_than: "lessThan",
|
|
439
|
+
equal: "equal",
|
|
440
|
+
not_equal: "notEqual",
|
|
441
|
+
greater_than_or_equal: "greaterThanOrEqual",
|
|
442
|
+
less_than_or_equal: "lessThanOrEqual",
|
|
443
|
+
between: "between",
|
|
444
|
+
not_between: "notBetween",
|
|
445
|
+
contains_text: "containsText",
|
|
446
|
+
not_contains: "notContains",
|
|
447
|
+
begins_with: "beginsWith",
|
|
448
|
+
ends_with: "endsWith"
|
|
449
|
+
}.freeze
|
|
450
|
+
|
|
451
|
+
CF_TYPES = {
|
|
452
|
+
cell_is: "cellIs",
|
|
453
|
+
expression: "expression",
|
|
454
|
+
color_scale: "colorScale",
|
|
455
|
+
data_bar: "dataBar",
|
|
456
|
+
icon_set: "iconSet",
|
|
457
|
+
above_average: "aboveAverage",
|
|
458
|
+
top10: "top10",
|
|
459
|
+
duplicate_values: "duplicateValues",
|
|
460
|
+
unique_values: "uniqueValues",
|
|
461
|
+
contains_text: "containsText",
|
|
462
|
+
not_contains_text: "notContainsText",
|
|
463
|
+
begins_with: "beginsWith",
|
|
464
|
+
ends_with: "endsWith",
|
|
465
|
+
contains_blanks: "containsBlanks",
|
|
466
|
+
not_contains_blanks: "notContainsBlanks",
|
|
467
|
+
contains_errors: "containsErrors",
|
|
468
|
+
not_contains_errors: "notContainsErrors",
|
|
469
|
+
time_period: "timePeriod"
|
|
470
|
+
}.freeze
|
|
471
|
+
|
|
472
|
+
CF_TIME_PERIODS = {
|
|
473
|
+
today: "today",
|
|
474
|
+
yesterday: "yesterday",
|
|
475
|
+
tomorrow: "tomorrow",
|
|
476
|
+
last7_days: "last7Days",
|
|
477
|
+
last_7_days: "last7Days",
|
|
478
|
+
this_month: "thisMonth",
|
|
479
|
+
last_month: "lastMonth",
|
|
480
|
+
next_month: "nextMonth",
|
|
481
|
+
this_week: "thisWeek",
|
|
482
|
+
last_week: "lastWeek",
|
|
483
|
+
next_week: "nextWeek"
|
|
484
|
+
}.freeze
|
|
485
|
+
|
|
486
|
+
CF_ICON_SETS = {
|
|
487
|
+
three_arrows: "3Arrows",
|
|
488
|
+
three_arrows_gray: "3ArrowsGray",
|
|
489
|
+
three_flags: "3Flags",
|
|
490
|
+
three_traffic_lights1: "3TrafficLights1",
|
|
491
|
+
three_traffic_lights2: "3TrafficLights2",
|
|
492
|
+
three_signs: "3Signs",
|
|
493
|
+
three_symbols: "3Symbols",
|
|
494
|
+
three_symbols2: "3Symbols2",
|
|
495
|
+
four_arrows: "4Arrows",
|
|
496
|
+
four_arrows_gray: "4ArrowsGray",
|
|
497
|
+
four_red_to_black: "4RedToBlack",
|
|
498
|
+
four_rating: "4Rating",
|
|
499
|
+
four_traffic_lights: "4TrafficLights",
|
|
500
|
+
five_arrows: "5Arrows",
|
|
501
|
+
five_arrows_gray: "5ArrowsGray",
|
|
502
|
+
five_rating: "5Rating",
|
|
503
|
+
five_quarters: "5Quarters"
|
|
504
|
+
}.freeze
|
|
505
|
+
|
|
506
|
+
# Extracts a differential format (dxf) hash from a conditional formatting rule hash.
|
|
507
|
+
# Mutates the rule hash by deleting extracted font and fill keys.
|
|
508
|
+
#
|
|
509
|
+
# @param rule [Hash]
|
|
510
|
+
# @return [Hash, nil]
|
|
511
|
+
#: (Hash[Symbol, untyped] rule) -> Hash[Symbol, untyped]?
|
|
512
|
+
def self.extract_dxf_from_rule!(rule)
|
|
513
|
+
font = (rule.delete(:font) || {}).dup
|
|
514
|
+
font[:color] = rule.delete(:font_color) if rule.key?(:font_color)
|
|
515
|
+
font[:bold] = rule.delete(:bold) if rule.key?(:bold)
|
|
516
|
+
font[:italic] = rule.delete(:italic) if rule.key?(:italic)
|
|
517
|
+
font[:underline] = rule.delete(:underline) if rule.key?(:underline)
|
|
518
|
+
font[:size] = rule.delete(:size) if rule.key?(:size)
|
|
519
|
+
font[:sz] = rule.delete(:sz) if rule.key?(:sz)
|
|
520
|
+
|
|
521
|
+
fill = (rule.delete(:fill) || {}).dup
|
|
522
|
+
if rule.key?(:fill_color)
|
|
523
|
+
fill[:pattern] = "solid"
|
|
524
|
+
fill[:fg_color] = rule.delete(:fill_color)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
dxf = {}
|
|
528
|
+
dxf[:font] = font unless font.empty?
|
|
529
|
+
dxf[:fill] = fill unless fill.empty?
|
|
530
|
+
dxf.empty? ? nil : dxf
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Normalizes conditional format arguments (sqref, coordinate targeting, types, operators, color scales, icon sets).
|
|
534
|
+
#
|
|
535
|
+
# @param sqref [String, nil]
|
|
536
|
+
# @param opts [Hash]
|
|
537
|
+
# @yield [ConditionalFormatBuilder]
|
|
538
|
+
# @return [Array<Hash>]
|
|
539
|
+
#: (?String? sqref, **untyped opts) ?{ (ConditionalFormatBuilder) -> void } -> Array[Hash[Symbol, untyped]]
|
|
540
|
+
def self.normalize_conditional_format(sqref = nil, **opts)
|
|
541
|
+
if block_given?
|
|
542
|
+
builder = ConditionalFormatBuilder.new(sqref, **opts)
|
|
543
|
+
yield builder
|
|
544
|
+
opts = builder.to_h
|
|
545
|
+
else
|
|
546
|
+
opts = opts.dup
|
|
547
|
+
end
|
|
548
|
+
sqref = opts.delete(:sqref) || sqref
|
|
549
|
+
|
|
550
|
+
# Determine sqrefs
|
|
551
|
+
sqrefs = []
|
|
552
|
+
has_coords = opts.key?(:columns) || opts.key?(:col) || opts.key?(:rows) || opts.key?(:row)
|
|
553
|
+
if sqref.nil? && has_coords
|
|
554
|
+
cols_val = opts.delete(:columns)
|
|
555
|
+
col_val = opts.delete(:col)
|
|
556
|
+
raw_cols = cols_val || col_val || 0
|
|
557
|
+
|
|
558
|
+
rows_val = opts.delete(:rows)
|
|
559
|
+
row_val = opts.delete(:row)
|
|
560
|
+
raw_rows = rows_val || row_val || (1..Xlsxrb::MAX_ROW)
|
|
561
|
+
|
|
562
|
+
except_rows = opts.delete(:except_rows)
|
|
563
|
+
|
|
564
|
+
col_indices = normalize_column_indices(raw_cols)
|
|
565
|
+
col_runs = compress_runs(col_indices)
|
|
566
|
+
row_runs = compress_runs(raw_rows)
|
|
567
|
+
row_runs = row_runs.flat_map { |s, e| complement_runs(except_rows, s, e) } if except_rows
|
|
568
|
+
sqrefs = chunked_sqrefs(col_runs, row_runs)
|
|
569
|
+
elsif sqref
|
|
570
|
+
opts.delete(:columns)
|
|
571
|
+
opts.delete(:col)
|
|
572
|
+
opts.delete(:rows)
|
|
573
|
+
opts.delete(:row)
|
|
574
|
+
opts.delete(:except_rows)
|
|
575
|
+
str_sqref = sqref.to_s
|
|
576
|
+
refs = str_sqref.split
|
|
577
|
+
sqrefs = if refs.size > Xlsxrb::MAX_REFS_PER_VALIDATION
|
|
578
|
+
refs.each_slice(Xlsxrb::MAX_REFS_PER_VALIDATION).map { |chunk| chunk.join(" ") }
|
|
579
|
+
else
|
|
580
|
+
[str_sqref]
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
raise ArgumentError, "conditional format sqref or coordinates (columns/rows) must be provided" if sqrefs.empty?
|
|
585
|
+
|
|
586
|
+
# Normalize time_period if provided
|
|
587
|
+
if opts.key?(:time_period) && opts[:time_period]
|
|
588
|
+
tp = opts[:time_period]
|
|
589
|
+
opts[:time_period] = CF_TIME_PERIODS[tp.to_sym] || tp.to_s
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Infer rule type if not explicitly provided
|
|
593
|
+
if opts[:type].nil?
|
|
594
|
+
if opts[:time_period]
|
|
595
|
+
opts[:type] = :time_period
|
|
596
|
+
elsif opts[:rank]
|
|
597
|
+
opts[:type] = :top10
|
|
598
|
+
elsif !opts[:above_average].nil? || !opts[:equal_average].nil? || opts[:std_dev]
|
|
599
|
+
opts[:type] = :above_average
|
|
600
|
+
elsif opts[:text]
|
|
601
|
+
opts[:type] = :contains_text
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
type_str = opts[:type].to_s
|
|
606
|
+
# Normalize color_scale
|
|
607
|
+
if opts.key?(:color_scale) || type_str == "colorScale" || type_str == "color_scale"
|
|
608
|
+
cs = (opts[:color_scale] || {}).dup
|
|
609
|
+
colors = cs[:colors] || opts.delete(:colors)
|
|
610
|
+
if colors.is_a?(Array)
|
|
611
|
+
colors = colors.map do |c|
|
|
612
|
+
c.is_a?(String) && c =~ /\A[0-9A-Fa-f]{6}\z/ ? "FF#{c.upcase}" : c
|
|
613
|
+
end
|
|
614
|
+
cs[:colors] = colors
|
|
615
|
+
if !cs.key?(:cfvo) || cs[:cfvo].nil?
|
|
616
|
+
cs[:cfvo] = if colors.size == 2
|
|
617
|
+
[{ type: "min" }, { type: "max" }]
|
|
618
|
+
else
|
|
619
|
+
[{ type: "min" }, { type: "percent", val: "50" }, { type: "max" }]
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
opts[:color_scale] = cs unless cs.empty?
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Normalize icon_set
|
|
627
|
+
if opts.key?(:icon_set) || type_str == "iconSet" || type_str == "icon_set"
|
|
628
|
+
is = (opts[:icon_set] || {}).dup
|
|
629
|
+
style_name = is[:icon_set] || opts.delete(:icon_style) || is[:icon_style]
|
|
630
|
+
if style_name
|
|
631
|
+
mapped_style = CF_ICON_SETS[style_name.to_sym] || style_name.to_s
|
|
632
|
+
is[:icon_set] = mapped_style
|
|
633
|
+
if !is.key?(:cfvo) || is[:cfvo].nil?
|
|
634
|
+
is[:cfvo] = if mapped_style.start_with?("4")
|
|
635
|
+
[
|
|
636
|
+
{ type: "percent", val: "0" },
|
|
637
|
+
{ type: "percent", val: "25" },
|
|
638
|
+
{ type: "percent", val: "50" },
|
|
639
|
+
{ type: "percent", val: "75" }
|
|
640
|
+
]
|
|
641
|
+
elsif mapped_style.start_with?("5")
|
|
642
|
+
[
|
|
643
|
+
{ type: "percent", val: "0" },
|
|
644
|
+
{ type: "percent", val: "20" },
|
|
645
|
+
{ type: "percent", val: "40" },
|
|
646
|
+
{ type: "percent", val: "60" },
|
|
647
|
+
{ type: "percent", val: "80" }
|
|
648
|
+
]
|
|
649
|
+
else
|
|
650
|
+
[
|
|
651
|
+
{ type: "percent", val: "0" },
|
|
652
|
+
{ type: "percent", val: "33" },
|
|
653
|
+
{ type: "percent", val: "67" }
|
|
654
|
+
]
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
opts[:icon_set] = is unless is.empty?
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
sqrefs.map { |ref| opts.merge(sqref: ref) }
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
FORBIDDEN_SHEET_CHARS = %r{[\\/?*\[\]:]|[\x00-\x08\x0B\x0C\x0E-\x1F]}
|
|
665
|
+
MAX_SHEET_NAME_LENGTH = 31
|
|
666
|
+
|
|
667
|
+
# Sanitizes a worksheet name to ensure it conforms to Excel constraints within 31 characters.
|
|
668
|
+
# Replaces forbidden characters (\ / ? * [ ] : and control characters),
|
|
669
|
+
# replaces leading/trailing apostrophes, and replaces reserved 'History' with 'History_'.
|
|
670
|
+
#
|
|
671
|
+
# @param name [Object] Desired worksheet name.
|
|
672
|
+
# @param replace_char [String] Replacement character for invalid characters.
|
|
673
|
+
# @return [String] Excel-compliant worksheet name.
|
|
674
|
+
#: (untyped name, ?replace_char: String) -> String
|
|
675
|
+
def self.sanitize_sheet_name(name, replace_char: "_")
|
|
676
|
+
raw = name.to_s.strip
|
|
677
|
+
str = raw.gsub(FORBIDDEN_SHEET_CHARS, replace_char)
|
|
678
|
+
str = str.sub(/\A'+/) { replace_char * Regexp.last_match(0).length }
|
|
679
|
+
str = "Sheet" if str.empty?
|
|
680
|
+
|
|
681
|
+
base = str[0, MAX_SHEET_NAME_LENGTH].sub(/'+\z/) { replace_char * Regexp.last_match(0).length }
|
|
682
|
+
return "#{base}#{replace_char}" if base.casecmp?("History")
|
|
683
|
+
|
|
684
|
+
base
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
class << self
|
|
688
|
+
alias sheet_name sanitize_sheet_name
|
|
689
|
+
end
|
|
174
690
|
end
|
|
175
691
|
end
|
data/lib/xlsxrb/elements/cell.rb
CHANGED
|
@@ -16,13 +16,13 @@ module Xlsxrb
|
|
|
16
16
|
#
|
|
17
17
|
# @api public
|
|
18
18
|
class Cell
|
|
19
|
-
attr_reader :row_index, :column_index, :value, :formula, :style_index, :unmapped_data, :errors, :hyperlink, :comment, :raw_value, :format_code, :date1904
|
|
19
|
+
attr_reader :row_index, :column_index, :value, :formula, :style_index, :unmapped_data, :errors, :hyperlink, :comment, :raw_value, :format_code, :date1904, :threaded_comments
|
|
20
20
|
|
|
21
21
|
# @param row_index [Integer] 0-based row index.
|
|
22
22
|
# @param column_index [Integer] 0-based column index.
|
|
23
23
|
# @param value [Object, nil] The cell's value.
|
|
24
24
|
# @param formula [Elements::Formula, nil] Optional formula.
|
|
25
|
-
# @param style_index [Integer, String, nil] Style identifier.
|
|
25
|
+
# @param style_index [Integer, String, Symbol, Hash, nil] Style identifier or inline style.
|
|
26
26
|
# @param unmapped_data [Hash] Additional metadata.
|
|
27
27
|
# @param errors [Array<String>, nil] Validation errors.
|
|
28
28
|
# @param hyperlink [Hash, String, Elements::Hyperlink, nil] Optional hyperlink metadata or URL.
|
|
@@ -30,8 +30,9 @@ module Xlsxrb
|
|
|
30
30
|
# @param raw_value [String, nil] Optional unparsed raw string value.
|
|
31
31
|
# @param format_code [String, nil] Optional OpenXML number/date format code.
|
|
32
32
|
# @param date1904 [Boolean] Whether the cell belongs to a 1904 date system workbook.
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
# @param threaded_comments [Array<Hash>, nil] Optional threaded comments.
|
|
34
|
+
#: (row_index: untyped, column_index: untyped, ?value: untyped, ?formula: (Elements::Formula | String)?, ?style_index: (Integer | String | Symbol | Hash[Symbol, untyped])?, ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?, ?hyperlink: (Hash[Symbol, untyped] | String | Elements::Hyperlink)?, ?comment: (Hash[Symbol, untyped] | String)?, ?raw_value: String?, ?format_code: String?, ?date1904: bool, ?threaded_comments: Array[Hash[Symbol, untyped]]?) -> void
|
|
35
|
+
def initialize(row_index:, column_index:, value: nil, formula: nil, style_index: nil, unmapped_data: EMPTY_HASH, errors: nil, hyperlink: nil, comment: nil, raw_value: nil, format_code: nil, date1904: false, threaded_comments: nil)
|
|
35
36
|
@row_index = row_index
|
|
36
37
|
@column_index = column_index
|
|
37
38
|
@value = value
|
|
@@ -45,6 +46,7 @@ module Xlsxrb
|
|
|
45
46
|
@raw_value = raw_value
|
|
46
47
|
@format_code = format_code
|
|
47
48
|
@date1904 = date1904 ? true : false
|
|
49
|
+
@threaded_comments = (threaded_comments || EMPTY_ARRAY).freeze
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
# rubocop:disable Style/OptionalBooleanParameter
|
|
@@ -62,6 +64,7 @@ module Xlsxrb
|
|
|
62
64
|
inst.instance_variable_set(:@raw_value, raw_value)
|
|
63
65
|
inst.instance_variable_set(:@format_code, format_code)
|
|
64
66
|
inst.instance_variable_set(:@date1904, date1904 ? true : false)
|
|
67
|
+
inst.instance_variable_set(:@threaded_comments, EMPTY_ARRAY)
|
|
65
68
|
inst
|
|
66
69
|
end
|
|
67
70
|
# rubocop:enable Style/OptionalBooleanParameter
|
|
@@ -78,22 +81,23 @@ module Xlsxrb
|
|
|
78
81
|
hyperlink == other.hyperlink &&
|
|
79
82
|
comment == other.comment &&
|
|
80
83
|
raw_value == other.raw_value &&
|
|
81
|
-
format_code == other.format_code
|
|
84
|
+
format_code == other.format_code &&
|
|
85
|
+
threaded_comments == other.threaded_comments
|
|
82
86
|
end
|
|
83
87
|
alias eql? ==
|
|
84
88
|
|
|
85
89
|
def hash
|
|
86
|
-
[row_index, column_index, value, formula, style_index, unmapped_data, errors, hyperlink, comment, raw_value, format_code].hash
|
|
90
|
+
[row_index, column_index, value, formula, style_index, unmapped_data, errors, hyperlink, comment, raw_value, format_code, threaded_comments].hash
|
|
87
91
|
end
|
|
88
92
|
|
|
89
93
|
def deconstruct
|
|
90
|
-
[row_index, column_index, value, formula, style_index, unmapped_data, errors, hyperlink, comment, raw_value, format_code]
|
|
94
|
+
[row_index, column_index, value, formula, style_index, unmapped_data, errors, hyperlink, comment, raw_value, format_code, threaded_comments]
|
|
91
95
|
end
|
|
92
96
|
|
|
93
97
|
def deconstruct_keys(keys)
|
|
94
98
|
h = { row_index: row_index, column_index: column_index, value: value, formula: formula,
|
|
95
99
|
style_index: style_index, unmapped_data: unmapped_data, errors: errors, hyperlink: hyperlink,
|
|
96
|
-
comment: comment, raw_value: raw_value, format_code: format_code }
|
|
100
|
+
comment: comment, raw_value: raw_value, format_code: format_code, threaded_comments: threaded_comments }
|
|
97
101
|
keys ? h.slice(*keys) : h
|
|
98
102
|
end
|
|
99
103
|
|
|
@@ -110,7 +114,8 @@ module Xlsxrb
|
|
|
110
114
|
comment: changes.fetch(:comment, comment),
|
|
111
115
|
raw_value: changes.fetch(:raw_value, raw_value),
|
|
112
116
|
format_code: changes.fetch(:format_code, format_code),
|
|
113
|
-
date1904: changes.fetch(:date1904, @date1904 || false)
|
|
117
|
+
date1904: changes.fetch(:date1904, @date1904 || false),
|
|
118
|
+
threaded_comments: changes.fetch(:threaded_comments, threaded_comments)
|
|
114
119
|
)
|
|
115
120
|
end
|
|
116
121
|
|
|
@@ -230,6 +235,24 @@ module Xlsxrb
|
|
|
230
235
|
end
|
|
231
236
|
end
|
|
232
237
|
|
|
238
|
+
# Returns whether the cell contains any threaded comments.
|
|
239
|
+
#
|
|
240
|
+
# @return [Boolean]
|
|
241
|
+
# @api public
|
|
242
|
+
#: () -> bool
|
|
243
|
+
def threaded_comments?
|
|
244
|
+
!@threaded_comments.empty?
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Returns the root threaded comment for this cell, or nil.
|
|
248
|
+
#
|
|
249
|
+
# @return [Hash[Symbol, untyped], nil]
|
|
250
|
+
# @api public
|
|
251
|
+
#: () -> Hash[Symbol, untyped]?
|
|
252
|
+
def threaded_comment
|
|
253
|
+
@threaded_comments.first
|
|
254
|
+
end
|
|
255
|
+
|
|
233
256
|
# Returns the formatted string representation of the cell value according to its format code.
|
|
234
257
|
#
|
|
235
258
|
# @param custom_format_code [String, nil] Optional format code override.
|
|
@@ -261,6 +284,8 @@ module Xlsxrb
|
|
|
261
284
|
when :url then url
|
|
262
285
|
when :tooltip then tooltip
|
|
263
286
|
when :comment then comment
|
|
287
|
+
when :threaded_comments then threaded_comments
|
|
288
|
+
when :threaded_comment then threaded_comment
|
|
264
289
|
when :raw_value then raw_value
|
|
265
290
|
when :format_code then format_code
|
|
266
291
|
when :formatted_value then formatted_value
|
|
@@ -19,10 +19,10 @@ module Xlsxrb
|
|
|
19
19
|
# @param hidden [Boolean] Whether the column is hidden.
|
|
20
20
|
# @param custom_width [Boolean] Whether custom width flag is set.
|
|
21
21
|
# @param outline_level [Integer, nil] Grouping/outline level.
|
|
22
|
-
# @param style_index [Integer, String, nil] Optional style identifier.
|
|
22
|
+
# @param style_index [Integer, String, Symbol, Hash, nil] Optional style identifier or inline style.
|
|
23
23
|
# @param unmapped_data [Hash] Additional metadata.
|
|
24
24
|
# @param errors [Array<String>, nil] Validation errors.
|
|
25
|
-
#: (index: Integer, ?width: Float | Integer | nil, ?hidden: bool, ?custom_width: bool, ?outline_level: Integer | nil, ?style_index: (Integer | String)?, ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
|
|
25
|
+
#: (index: Integer, ?width: Float | Integer | nil, ?hidden: bool, ?custom_width: bool, ?outline_level: Integer | nil, ?style_index: (Integer | String | Symbol | Hash[Symbol, untyped])?, ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
|
|
26
26
|
def initialize(index:, width: nil, hidden: false, custom_width: false, outline_level: nil,
|
|
27
27
|
style_index: nil, unmapped_data: {}, errors: nil)
|
|
28
28
|
style_idx = style_index || unmapped_data[:style_index] || unmapped_data[:style]
|