xlsxrb 0.1.15 → 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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -0
  3. data/README.md +24 -5
  4. data/benchmark/micro_ips.rb +70 -0
  5. data/lib/xlsxrb/chart_builder.rb +41 -2
  6. data/lib/xlsxrb/dsl_helpers.rb +522 -6
  7. data/lib/xlsxrb/elements/cell.rb +34 -9
  8. data/lib/xlsxrb/elements/column.rb +2 -2
  9. data/lib/xlsxrb/elements/styles.rb +427 -15
  10. data/lib/xlsxrb/elements/types.rb +1 -0
  11. data/lib/xlsxrb/elements/workbook.rb +79 -4
  12. data/lib/xlsxrb/elements/worksheet.rb +173 -11
  13. data/lib/xlsxrb/ooxml/cfb.rb +10 -0
  14. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +77 -38
  15. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +187 -24
  16. data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +115 -2
  17. data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +28 -18
  18. data/lib/xlsxrb/ooxml/reader.rb +253 -136
  19. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +83 -28
  20. data/lib/xlsxrb/ooxml/styles_parser.rb +76 -25
  21. data/lib/xlsxrb/ooxml/utils.rb +59 -2
  22. data/lib/xlsxrb/ooxml/workbook_parser.rb +57 -3
  23. data/lib/xlsxrb/ooxml/workbook_writer.rb +471 -86
  24. data/lib/xlsxrb/ooxml/worksheet_parser.rb +210 -127
  25. data/lib/xlsxrb/ooxml/worksheet_writer.rb +515 -184
  26. data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +595 -453
  27. data/lib/xlsxrb/ooxml/writer/features_xml.rb +26 -9
  28. data/lib/xlsxrb/ooxml/writer/styles_xml.rb +87 -24
  29. data/lib/xlsxrb/ooxml/writer.rb +299 -59
  30. data/lib/xlsxrb/ooxml/xml_builder.rb +12 -4
  31. data/lib/xlsxrb/ooxml/xml_parser.rb +45 -0
  32. data/lib/xlsxrb/ooxml/zip_reader.rb +48 -29
  33. data/lib/xlsxrb/ooxml/zip_writer.rb +15 -5
  34. data/lib/xlsxrb/stream_row.rb +4 -3
  35. data/lib/xlsxrb/stream_sheet.rb +182 -8
  36. data/lib/xlsxrb/stream_writer.rb +977 -84
  37. data/lib/xlsxrb/style_builder.rb +59 -6
  38. data/lib/xlsxrb/utils.rb +80 -0
  39. data/lib/xlsxrb/version.rb +1 -1
  40. data/lib/xlsxrb/workbook_builder.rb +307 -24
  41. data/lib/xlsxrb/worksheet_builder.rb +1150 -49
  42. data/lib/xlsxrb.rb +705 -142
  43. data/sig/generated/xlsxrb/chart_builder.rbs +31 -3
  44. data/sig/generated/xlsxrb/dsl_helpers.rbs +119 -4
  45. data/sig/generated/xlsxrb/elements/cell.rbs +20 -3
  46. data/sig/generated/xlsxrb/elements/column.rbs +3 -3
  47. data/sig/generated/xlsxrb/elements/styles.rbs +271 -5
  48. data/sig/generated/xlsxrb/elements/types.rbs +2 -0
  49. data/sig/generated/xlsxrb/elements/workbook.rbs +63 -3
  50. data/sig/generated/xlsxrb/elements/worksheet.rbs +92 -2
  51. data/sig/generated/xlsxrb/ooxml/reader/listeners/core_listeners.rbs +2 -0
  52. data/sig/generated/xlsxrb/ooxml/reader/listeners/drawing_listeners.rbs +74 -2
  53. data/sig/generated/xlsxrb/ooxml/reader/listeners/feature_listeners.rbs +36 -0
  54. data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +5 -0
  55. data/sig/generated/xlsxrb/ooxml/reader.rbs +29 -0
  56. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +3 -1
  57. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -0
  58. data/sig/generated/xlsxrb/ooxml/utils.rbs +22 -0
  59. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +15 -0
  60. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +20 -4
  61. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +19 -3
  62. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +15 -2
  63. data/sig/generated/xlsxrb/ooxml/writer/drawing_xml.rbs +16 -0
  64. data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +5 -2
  65. data/sig/generated/xlsxrb/ooxml/writer.rbs +42 -8
  66. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +4 -1
  67. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +6 -6
  68. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +8 -2
  69. data/sig/generated/xlsxrb/stream_row.rbs +3 -2
  70. data/sig/generated/xlsxrb/stream_sheet.rbs +122 -6
  71. data/sig/generated/xlsxrb/stream_writer.rbs +606 -30
  72. data/sig/generated/xlsxrb/style_builder.rbs +24 -6
  73. data/sig/generated/xlsxrb/utils.rbs +33 -0
  74. data/sig/generated/xlsxrb/workbook_builder.rbs +87 -5
  75. data/sig/generated/xlsxrb/worksheet_builder.rbs +557 -12
  76. data/sig/generated/xlsxrb.rbs +118 -20
  77. metadata +2 -1
@@ -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, columns: columns }
109
- tbl[:name] = name if name
110
- tbl[:display_name] = display_name if display_name
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
@@ -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
- #: (row_index: untyped, column_index: untyped, ?value: untyped, ?formula: (Elements::Formula | String)?, ?style_index: (Integer | String)?, ?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) -> void
34
- 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)
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]