xlsxrb 0.1.13 → 0.1.14

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/README.md +2 -0
  4. data/lib/xlsxrb/colors.rb +118 -0
  5. data/lib/xlsxrb/elements/cell.rb +205 -16
  6. data/lib/xlsxrb/elements/column.rb +6 -4
  7. data/lib/xlsxrb/elements/coordinate_access.rb +32 -0
  8. data/lib/xlsxrb/elements/image.rb +128 -0
  9. data/lib/xlsxrb/elements/row.rb +206 -9
  10. data/lib/xlsxrb/elements/styles.rb +157 -0
  11. data/lib/xlsxrb/elements/types.rb +95 -1
  12. data/lib/xlsxrb/elements/workbook.rb +85 -4
  13. data/lib/xlsxrb/elements/worksheet.rb +424 -29
  14. data/lib/xlsxrb/elements.rb +2 -0
  15. data/lib/xlsxrb/number_formatter.rb +429 -0
  16. data/lib/xlsxrb/ooxml/cfb.rb +1 -1
  17. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +7 -5
  18. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +11 -4
  19. data/lib/xlsxrb/ooxml/reader.rb +12 -14
  20. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +7 -1
  21. data/lib/xlsxrb/ooxml/styles_parser.rb +39 -9
  22. data/lib/xlsxrb/ooxml/utils.rb +34 -18
  23. data/lib/xlsxrb/ooxml/workbook_parser.rb +53 -15
  24. data/lib/xlsxrb/ooxml/workbook_writer.rb +65 -14
  25. data/lib/xlsxrb/ooxml/worksheet_parser.rb +319 -79
  26. data/lib/xlsxrb/ooxml/worksheet_writer.rb +206 -61
  27. data/lib/xlsxrb/ooxml/writer/features_xml.rb +4 -2
  28. data/lib/xlsxrb/ooxml/writer.rb +14 -0
  29. data/lib/xlsxrb/ooxml/zip_reader.rb +109 -25
  30. data/lib/xlsxrb/stream_row.rb +243 -16
  31. data/lib/xlsxrb/stream_sheet.rb +414 -15
  32. data/lib/xlsxrb/stream_writer.rb +213 -17
  33. data/lib/xlsxrb/style_builder.rb +308 -22
  34. data/lib/xlsxrb/utils.rb +158 -0
  35. data/lib/xlsxrb/version.rb +1 -1
  36. data/lib/xlsxrb/workbook_builder.rb +73 -3
  37. data/lib/xlsxrb/worksheet_builder.rb +227 -16
  38. data/lib/xlsxrb.rb +578 -79
  39. data/sig/generated/xlsxrb/colors.rbs +40 -0
  40. data/sig/generated/xlsxrb/elements/cell.rbs +113 -8
  41. data/sig/generated/xlsxrb/elements/column.rbs +3 -2
  42. data/sig/generated/xlsxrb/elements/coordinate_access.rbs +18 -0
  43. data/sig/generated/xlsxrb/elements/image.rbs +82 -0
  44. data/sig/generated/xlsxrb/elements/row.rbs +83 -4
  45. data/sig/generated/xlsxrb/elements/styles.rbs +79 -0
  46. data/sig/generated/xlsxrb/elements/types.rbs +43 -0
  47. data/sig/generated/xlsxrb/elements/workbook.rbs +51 -2
  48. data/sig/generated/xlsxrb/elements/worksheet.rbs +231 -18
  49. data/sig/generated/xlsxrb/number_formatter.rbs +132 -0
  50. data/sig/generated/xlsxrb/ooxml/reader.rbs +6 -0
  51. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +2 -0
  52. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -2
  53. data/sig/generated/xlsxrb/ooxml/utils.rbs +17 -8
  54. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +9 -3
  55. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +28 -6
  56. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +3 -1
  57. data/sig/generated/xlsxrb/ooxml/writer.rbs +2 -0
  58. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +10 -0
  59. data/sig/generated/xlsxrb/stream_row.rbs +100 -6
  60. data/sig/generated/xlsxrb/stream_sheet.rbs +247 -13
  61. data/sig/generated/xlsxrb/stream_writer.rbs +120 -14
  62. data/sig/generated/xlsxrb/style_builder.rbs +55 -18
  63. data/sig/generated/xlsxrb/utils.rbs +119 -0
  64. data/sig/generated/xlsxrb/workbook_builder.rbs +26 -0
  65. data/sig/generated/xlsxrb/worksheet_builder.rbs +107 -11
  66. data/sig/generated/xlsxrb.rbs +186 -27
  67. metadata +11 -1
@@ -49,6 +49,7 @@ module Xlsxrb
49
49
  @total_uncompressed = 0
50
50
  @eof = false
51
51
  @closed = false
52
+ @bom_checked = false
52
53
  @inflater = @compression_method == 8 ? Zlib::Inflate.new(-Zlib::MAX_WBITS) : nil
53
54
  end
54
55
 
@@ -56,6 +57,8 @@ module Xlsxrb
56
57
  def read(length = nil)
57
58
  raise IOError, "closed stream" if @closed
58
59
 
60
+ check_bom_encoding!
61
+
59
62
  if length.nil?
60
63
  result = @buffer.dup
61
64
  @buffer.clear
@@ -96,6 +99,11 @@ module Xlsxrb
96
99
  return if @closed
97
100
 
98
101
  @closed = true
102
+ begin
103
+ @inflater&.reset
104
+ rescue StandardError
105
+ nil
106
+ end
99
107
  @inflater&.close
100
108
  @inflater = nil
101
109
  @buffer.clear
@@ -176,18 +184,48 @@ module Xlsxrb
176
184
  nil
177
185
  end
178
186
  end
187
+
188
+ def check_bom_encoding!
189
+ return if @bom_checked
190
+
191
+ @bom_checked = true
192
+ peek = fetch_chunk(DEFAULT_CHUNK_SIZE)
193
+ return unless peek
194
+
195
+ raw_b = peek.b
196
+ if raw_b.start_with?("\xFF\xFE".b)
197
+ rest = +""
198
+ while (chunk = fetch_chunk(DEFAULT_CHUNK_SIZE))
199
+ rest << chunk
200
+ end
201
+ full = (peek + rest).force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8).delete_prefix("\uFEFF")
202
+ @buffer = full
203
+ @eof = true
204
+ elsif raw_b.start_with?("\xFE\xFF".b)
205
+ rest = +""
206
+ while (chunk = fetch_chunk(DEFAULT_CHUNK_SIZE))
207
+ rest << chunk
208
+ end
209
+ full = (peek + rest).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8).delete_prefix("\uFEFF")
210
+ @buffer = full
211
+ @eof = true
212
+ elsif raw_b.start_with?("\xEF\xBB\xBF".b)
213
+ @buffer << peek.b.delete_prefix("\xEF\xBB\xBF".b).force_encoding(Encoding::UTF_8)
214
+ else
215
+ @buffer << peek
216
+ end
217
+ end
179
218
  end
180
219
 
181
220
  # Opens a ZIP from a file path or IO and yields the reader.
182
221
  #: (untyped source, ?max_uncompressed_size: Integer) ?{ (ZipReader) -> untyped } -> (ZipReader | untyped)
183
222
  def self.open(source, max_uncompressed_size: MAX_UNCOMPRESSED_SIZE)
184
- should_close = source.is_a?(String) && File.file?(source)
185
- io = if source.is_a?(String)
186
- if source.start_with?(LOCAL_HEADER_SIG) || source.include?("\x00")
187
- StringIO.new(source.b)
188
- else
189
- File.open(source, "rb")
190
- end
223
+ is_binary = source.is_a?(String) && (source.start_with?(LOCAL_HEADER_SIG) || source.include?("\x00"))
224
+ should_close = source.is_a?(String) && !is_binary && File.file?(source)
225
+ io = if should_close
226
+ File.open(source, "rb")
227
+ elsif source.is_a?(String)
228
+ StringIO.new(source.b)
191
229
  else
192
230
  source
193
231
  end
@@ -211,6 +249,7 @@ module Xlsxrb
211
249
  @tempfile = nil
212
250
  @io = nil
213
251
  @catalog = nil
252
+ @ci_catalog = nil
214
253
  @cached_entries = nil
215
254
  end
216
255
 
@@ -227,11 +266,15 @@ module Xlsxrb
227
266
  # Returns raw bytes for a single entry, or nil if not found.
228
267
  #: (String name) -> String?
229
268
  def read_entry(name)
230
- return @cached_entries[name] if @cached_entries&.key?(name)
269
+ norm_name = normalize_entry_name(name)
270
+ cached = @cached_entries[norm_name] if @cached_entries&.key?(norm_name)
231
271
 
232
- entry = catalog[name]
272
+ entry = lookup_entry(norm_name)
233
273
  return nil unless entry
234
274
 
275
+ cached ||= @cached_entries[entry.name] if @cached_entries&.key?(entry.name)
276
+ return normalize_text_encoding(cached) if cached
277
+
235
278
  io.seek(entry.data_offset, IO::SEEK_SET)
236
279
  raw = io.read(entry.compressed_size)
237
280
  decompress(raw, entry.method)
@@ -250,7 +293,7 @@ module Xlsxrb
250
293
  # Opens an EntryStreamIO for an entry by name.
251
294
  #: (String entry_name) -> EntryStreamIO
252
295
  def open_entry_io(entry_name)
253
- entry = catalog[entry_name]
296
+ entry = lookup_entry(entry_name)
254
297
  raise ArgumentError, "Entry not found in archive: #{entry_name}" unless entry
255
298
 
256
299
  EntryStreamIO.new(io, entry.data_offset, entry.compressed_size, entry.method, max_uncompressed_size: @max_uncompressed_size)
@@ -272,7 +315,7 @@ module Xlsxrb
272
315
  # Returns true if the archive contains the specified entry.
273
316
  #: (String name) -> bool
274
317
  def entry?(name)
275
- catalog.key?(name)
318
+ !lookup_entry(name).nil?
276
319
  end
277
320
 
278
321
  # Returns entry metadata catalog.
@@ -290,7 +333,7 @@ module Xlsxrb
290
333
  # Copies the specified entry to a ZipWriter directly without decompression.
291
334
  #: (String entry_name, ZipWriter writer, ?target_path: String?) -> void
292
335
  def copy_to_writer(entry_name, writer, target_path: nil)
293
- entry = catalog[entry_name]
336
+ entry = lookup_entry(entry_name)
294
337
  raise ArgumentError, "Entry not found in archive: #{entry_name}" unless entry
295
338
 
296
339
  if entry.compressed_size.zero? && entry.uncompressed_size.positive?
@@ -396,7 +439,8 @@ module Xlsxrb
396
439
  entry_name = io.read(nlen).force_encoding("UTF-8")
397
440
  io.read(elen + clen)
398
441
 
399
- next if entry_name.end_with?("/")
442
+ norm_name = normalize_entry_name(entry_name)
443
+ next if norm_name.end_with?("/")
400
444
 
401
445
  current_cd_pos = io.pos
402
446
 
@@ -411,8 +455,8 @@ module Xlsxrb
411
455
  local_elen = local_header[24, 2].unpack1("v")
412
456
  data_offset = offset + 30 + local_nlen + local_elen
413
457
 
414
- result[entry_name] = Entry.new(
415
- name: entry_name,
458
+ entry = Entry.new(
459
+ name: norm_name,
416
460
  method: method,
417
461
  compressed_size: csize,
418
462
  uncompressed_size: usize,
@@ -420,6 +464,8 @@ module Xlsxrb
420
464
  local_header_offset: offset,
421
465
  data_offset: data_offset
422
466
  )
467
+ result[norm_name] = entry
468
+ result[entry_name] = entry if entry_name != norm_name
423
469
 
424
470
  io.seek(current_cd_pos, IO::SEEK_SET)
425
471
  end
@@ -453,16 +499,18 @@ module Xlsxrb
453
499
  io.read(extra_len)
454
500
  data_offset = io.pos
455
501
 
456
- next if entry_name.end_with?("/")
502
+ norm_name = normalize_entry_name(entry_name)
503
+ next if norm_name.end_with?("/")
457
504
 
458
505
  has_data_descriptor = gp_flag.anybits?(0x08)
459
506
 
460
507
  if has_data_descriptor && compressed_size.zero?
461
508
  entry_data, = find_data_descriptor_stream(io, method)
462
509
  @cached_entries ||= {}
463
- @cached_entries[entry_name] = entry_data
464
- result[entry_name] = Entry.new(
465
- name: entry_name,
510
+ @cached_entries[norm_name] = entry_data
511
+ @cached_entries[entry_name] = entry_data if entry_name != norm_name
512
+ entry = Entry.new(
513
+ name: norm_name,
466
514
  method: method,
467
515
  compressed_size: entry_data.bytesize,
468
516
  uncompressed_size: entry_data.bytesize,
@@ -470,9 +518,11 @@ module Xlsxrb
470
518
  local_header_offset: header_pos,
471
519
  data_offset: data_offset
472
520
  )
521
+ result[norm_name] = entry
522
+ result[entry_name] = entry if entry_name != norm_name
473
523
  else
474
- result[entry_name] = Entry.new(
475
- name: entry_name,
524
+ entry = Entry.new(
525
+ name: norm_name,
476
526
  method: method,
477
527
  compressed_size: compressed_size,
478
528
  uncompressed_size: uncompressed_size,
@@ -480,6 +530,8 @@ module Xlsxrb
480
530
  local_header_offset: header_pos,
481
531
  data_offset: data_offset
482
532
  )
533
+ result[norm_name] = entry
534
+ result[entry_name] = entry if entry_name != norm_name
483
535
 
484
536
  io.seek(compressed_size, IO::SEEK_CUR) if io.respond_to?(:seek)
485
537
  end
@@ -527,11 +579,15 @@ module Xlsxrb
527
579
  end
528
580
 
529
581
  def decompress(raw, method)
530
- return raw&.dup&.force_encoding("UTF-8") || "" if method.zero?
531
-
532
- safe_inflate(raw || "", -Zlib::MAX_WBITS)
582
+ data = if method.zero?
583
+ raw&.dup || ""
584
+ else
585
+ safe_inflate(raw || "", -Zlib::MAX_WBITS)
586
+ end
587
+ normalize_text_encoding(data)
533
588
  rescue Zlib::DataError
534
- safe_inflate(raw || "", -Zlib::MAX_WBITS)
589
+ data = safe_inflate(raw || "", -Zlib::MAX_WBITS)
590
+ normalize_text_encoding(data)
535
591
  end
536
592
 
537
593
  def safe_inflate(raw, wbits)
@@ -561,6 +617,34 @@ module Xlsxrb
561
617
 
562
618
  result.force_encoding("UTF-8")
563
619
  end
620
+
621
+ def normalize_entry_name(name)
622
+ name.include?("\\") ? name.tr("\\", "/") : name
623
+ end
624
+
625
+ def lookup_entry(name)
626
+ norm_name = normalize_entry_name(name)
627
+ catalog[norm_name] || ci_catalog[norm_name.downcase]
628
+ end
629
+
630
+ def ci_catalog
631
+ @ci_catalog ||= catalog.transform_keys(&:downcase)
632
+ end
633
+
634
+ def normalize_text_encoding(data)
635
+ return data if data.nil? || data.empty?
636
+
637
+ raw_b = data.b
638
+ if raw_b.start_with?("\xFF\xFE".b)
639
+ data.force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8).delete_prefix("\uFEFF")
640
+ elsif raw_b.start_with?("\xFE\xFF".b)
641
+ data.force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8).delete_prefix("\uFEFF")
642
+ elsif raw_b.start_with?("\xEF\xBB\xBF".b)
643
+ data.b.delete_prefix("\xEF\xBB\xBF".b).force_encoding(Encoding::UTF_8)
644
+ else
645
+ data.force_encoding(Encoding::UTF_8)
646
+ end
647
+ end
564
648
  end
565
649
  end
566
650
  end
@@ -19,7 +19,7 @@ module Xlsxrb
19
19
  class StreamRow
20
20
  [Enumerable].each { |m| include m }
21
21
 
22
- attr_reader :index, :height, :hidden, :custom_height, :outline_level
22
+ attr_reader :index, :height, :hidden, :custom_height, :collapsed, :outline_level, :style_index, :pad_empty_cells
23
23
 
24
24
  # @param index [Integer] 0-based row index.
25
25
  # @param xml_bytes [String] Raw ASCII-8BIT XML bytes.
@@ -30,10 +30,14 @@ module Xlsxrb
30
30
  # @param height [Float, Integer, nil] Row height in points.
31
31
  # @param hidden [Boolean] Whether the row is hidden.
32
32
  # @param custom_height [Boolean] Whether custom height is set.
33
+ # @param collapsed [Boolean] Whether the row is collapsed.
33
34
  # @param outline_level [Integer, nil] Grouping/outline level.
34
- #: (index: Integer, xml_bytes: String, from: Integer, to: Integer, shared_strings: Array[String], ?prefix: String, ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?outline_level: Integer | nil) -> void
35
+ # @param style_index [Integer, nil] Style index.
36
+ # @param date1904 [Boolean] Whether the 1904 date system is active.
37
+ # @param pad_empty_cells [Boolean] Whether missing cells in this row are padded.
38
+ #: (index: Integer, xml_bytes: String, from: Integer, to: Integer, shared_strings: Array[String], ?prefix: String, ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?collapsed: bool, ?outline_level: Integer | nil, ?style_index: Integer | nil, ?styles: Hash[untyped, untyped]?, ?date1904: bool, ?pad_empty_cells: bool) -> void
35
39
  def initialize(index:, xml_bytes:, from:, to:, shared_strings:, prefix: "", height: nil, hidden: false,
36
- custom_height: false, outline_level: nil)
40
+ custom_height: false, collapsed: false, outline_level: nil, style_index: nil, styles: nil, date1904: false, pad_empty_cells: false)
37
41
  @index = index
38
42
  @xml = xml_bytes
39
43
  @from = from
@@ -43,12 +47,17 @@ module Xlsxrb
43
47
  @height = height
44
48
  @hidden = hidden
45
49
  @custom_height = custom_height
50
+ @collapsed = collapsed
46
51
  @outline_level = outline_level
52
+ @style_index = style_index
53
+ @styles = styles
54
+ @date1904 = date1904 ? true : false
55
+ @pad_empty_cells = pad_empty_cells ? true : false
47
56
  @cells = nil
48
57
  end
49
58
 
50
59
  # rubocop:disable Style/OptionalBooleanParameter
51
- def self.fast_create(index, xml_bytes, from, to, shared_strings, prefix = "", height = nil, hidden = false, custom_height = false, outline_level = nil)
60
+ def self.fast_create(index, xml_bytes, from, to, shared_strings, prefix = "", height = nil, hidden = false, custom_height = false, outline_level = nil, style_index = nil, styles = nil, date1904 = false, collapsed = false, pad_empty_cells = false)
52
61
  inst = allocate
53
62
  inst.instance_variable_set(:@index, index)
54
63
  inst.instance_variable_set(:@xml, xml_bytes)
@@ -59,12 +68,80 @@ module Xlsxrb
59
68
  inst.instance_variable_set(:@height, height)
60
69
  inst.instance_variable_set(:@hidden, hidden)
61
70
  inst.instance_variable_set(:@custom_height, custom_height)
71
+ inst.instance_variable_set(:@collapsed, collapsed)
62
72
  inst.instance_variable_set(:@outline_level, outline_level)
73
+ inst.instance_variable_set(:@style_index, style_index)
74
+ inst.instance_variable_set(:@styles, styles)
75
+ inst.instance_variable_set(:@date1904, date1904 ? true : false)
76
+ inst.instance_variable_set(:@pad_empty_cells, pad_empty_cells ? true : false)
63
77
  inst.instance_variable_set(:@cells, nil)
64
78
  inst
65
79
  end
66
80
  # rubocop:enable Style/OptionalBooleanParameter
67
81
 
82
+ # Returns whether the row is hidden.
83
+ #
84
+ # @return [Boolean]
85
+ # @api public
86
+ #: () -> bool
87
+ def hidden?
88
+ @hidden == true
89
+ end
90
+
91
+ # Returns whether the row is collapsed.
92
+ #
93
+ # @return [Boolean]
94
+ # @api public
95
+ #: () -> bool
96
+ def collapsed?
97
+ @collapsed == true
98
+ end
99
+
100
+ # Returns whether custom height is set.
101
+ #
102
+ # @return [Boolean]
103
+ # @api public
104
+ #: () -> bool
105
+ def custom_height?
106
+ @custom_height == true
107
+ end
108
+
109
+ # Returns a frozen Hash of row attributes.
110
+ #
111
+ # @return [Hash{Symbol => Object}]
112
+ # @api public
113
+ #: () -> Hash[Symbol, untyped]
114
+ def attributes
115
+ h = {
116
+ index: @index,
117
+ height: @height,
118
+ hidden: @hidden,
119
+ custom_height: @custom_height,
120
+ collapsed: @collapsed,
121
+ outline_level: @outline_level
122
+ }
123
+ h[:style_index] = @style_index if @style_index
124
+ h.freeze
125
+ end
126
+
127
+ # Returns whether the row uses the 1904 date system.
128
+ #
129
+ # @return [Boolean]
130
+ # @api public
131
+ #: () -> bool
132
+ def date1904?
133
+ @date1904 ? true : false
134
+ end
135
+
136
+ # Returns whether missing cells in this row are padded.
137
+ #
138
+ # @return [Boolean]
139
+ # @api public
140
+ #: () -> bool
141
+ def pad_empty_cells?
142
+ @pad_empty_cells ? true : false
143
+ end
144
+
68
145
  # Iterate over cells in this streaming row one by one.
69
146
  #
70
147
  # @yield [cell]
@@ -76,10 +153,10 @@ module Xlsxrb
76
153
  def each_cell(&block)
77
154
  return enum_for(:each_cell) unless block
78
155
 
79
- if @cells
80
- @cells.each(&block)
156
+ if @cells || @pad_empty_cells
157
+ cells.each(&block)
81
158
  else
82
- Ooxml::WorksheetParser.fast_scan_cells_direct(@xml, @from, @to, @shared_strings, @index, @prefix, &block)
159
+ Ooxml::WorksheetParser.fast_scan_cells_direct(@xml, @from, @to, @shared_strings, @index, @prefix, @styles, @date1904, &block)
83
160
  end
84
161
  end
85
162
 
@@ -103,9 +180,21 @@ module Xlsxrb
103
180
  def cells
104
181
  @cells ||= begin
105
182
  arr = []
106
- Ooxml::WorksheetParser.fast_scan_cells_direct(@xml, @from, @to, @shared_strings, @index, @prefix) do |c|
183
+ Ooxml::WorksheetParser.fast_scan_cells_direct(@xml, @from, @to, @shared_strings, @index, @prefix, @styles, @date1904) do |c|
107
184
  arr << c
108
185
  end
186
+ if @pad_empty_cells && !arr.empty?
187
+ max_col = arr.map(&:column_index).max || 0
188
+ if arr.size < max_col + 1
189
+ cell_map = {}
190
+ arr.each { |c| cell_map[c.column_index] = c }
191
+ padded = Array.new(max_col + 1)
192
+ (0..max_col).each do |c_idx|
193
+ padded[c_idx] = cell_map[c_idx] || Elements::Cell.fast_create(@index, c_idx, nil, nil, nil, nil, nil, @date1904)
194
+ end
195
+ arr = padded
196
+ end
197
+ end
109
198
  arr.freeze
110
199
  end
111
200
  end
@@ -121,12 +210,28 @@ module Xlsxrb
121
210
  when Symbol
122
211
  case col_index
123
212
  when :cells then cells
213
+ when :values then values
214
+ when :raw_values then raw_values
215
+ when :formatted_values then formatted_values
216
+ when :formulas then formulas
124
217
  when :index then index
125
218
  when :height then height
126
219
  when :hidden then hidden
127
220
  when :custom_height then custom_height
221
+ when :collapsed then collapsed
128
222
  when :outline_level then outline_level
129
- when :attrs then { height: height, hidden: hidden, custom_height: custom_height, outline_level: outline_level }
223
+ when :style_index then style_index
224
+ when :date1904 then date1904?
225
+ when :hidden? then hidden?
226
+ when :collapsed? then collapsed?
227
+ when :custom_height? then custom_height?
228
+ when :pad_empty_cells then @pad_empty_cells
229
+ when :pad_empty_cells? then pad_empty_cells?
230
+ when :attributes then attributes
231
+ when :attrs
232
+ h = { height: height, hidden: hidden, custom_height: custom_height, collapsed: collapsed, outline_level: outline_level }
233
+ h[:style_index] = style_index if style_index
234
+ h
130
235
  end
131
236
  else
132
237
  cells[col_index]
@@ -143,29 +248,107 @@ module Xlsxrb
143
248
  cells.find { |c| c.column_index == col_index }
144
249
  end
145
250
 
251
+ # Convert row cells to a coordinate-keyed Hash (e.g. {"A1" => "value", "B1" => 123}).
252
+ #
253
+ # @param type_cast [Boolean] Whether to coerce date/time serial numbers into Date/Time instances.
254
+ # @return [Hash{String => Object}]
255
+ # @api public
256
+ #: (?type_cast: bool) -> Hash[String, untyped]
257
+ def to_h(type_cast: false)
258
+ result = {}
259
+ each_cell do |c|
260
+ val = c.value
261
+ if type_cast && val.is_a?(Numeric)
262
+ fmt_type = if c.style_index && @styles
263
+ NumberFormatter.format_type_for(c.style_index, @styles)
264
+ elsif c.format_code
265
+ NumberFormatter.format_type(c.format_code)
266
+ end
267
+ begin
268
+ case fmt_type
269
+ when :date
270
+ val = Ooxml::Utils.serial_to_date(val, date1904: @date1904)
271
+ when :datetime, :time
272
+ val = Ooxml::Utils.serial_to_datetime(val, date1904: @date1904)
273
+ end
274
+ rescue StandardError
275
+ # Keep val numeric on failure
276
+ end
277
+ end
278
+ result[c.ref] = val
279
+ end
280
+ result
281
+ end
282
+
146
283
  # Convert row cells to an Array of raw values (sparse columns get nil).
147
284
  #
148
285
  # @return [Array<Object>]
149
286
  # @api public
150
287
  #: () -> Array[untyped]
151
288
  def to_a
289
+ values
290
+ end
291
+
292
+ # Returns cell values as an Array (sparse columns get nil).
293
+ #
294
+ # @param type_cast [Boolean] Whether to coerce date/time serial numbers into Date/Time instances.
295
+ # @return [Array<Object>]
296
+ # @api public
297
+ #: (?type_cast: bool) -> Array[untyped]
298
+ def values(type_cast: false)
299
+ if @cells
300
+ values_from_cells(type_cast)
301
+ else
302
+ Ooxml::WorksheetParser.fast_scan_row_values(@xml, @from, @to, @shared_strings, @styles, @date1904, type_cast: type_cast)
303
+ end
304
+ end
305
+
306
+ # Convert row cells to an Array of raw string values (sparse columns get nil).
307
+ #
308
+ # @return [Array<String, nil>]
309
+ # @api public
310
+ #: () -> Array[String?]
311
+ def raw_values
152
312
  return [] if cells.empty?
153
313
 
154
314
  max_col = cells.map(&:column_index).max || 0
155
315
  arr = Array.new(max_col + 1)
156
316
  cells.each do |cell|
157
- arr[cell.column_index] = cell.value
317
+ arr[cell.column_index] = cell.raw_value
158
318
  end
159
319
  arr
160
320
  end
161
321
 
162
- # Returns cell values as an Array.
322
+ # Convert row cells to an Array of formatted string representations (sparse columns get nil).
163
323
  #
164
- # @return [Array<Object>]
324
+ # @return [Array<String, nil>]
165
325
  # @api public
166
- #: () -> Array[untyped]
167
- def values
168
- to_a
326
+ #: () -> Array[String?]
327
+ def formatted_values
328
+ return [] if cells.empty?
329
+
330
+ max_col = cells.map(&:column_index).max || 0
331
+ arr = Array.new(max_col + 1)
332
+ cells.each do |cell|
333
+ arr[cell.column_index] = cell.formatted_value
334
+ end
335
+ arr
336
+ end
337
+
338
+ # Convert row cells to an Array of formula expressions without leading '=' (sparse columns get nil).
339
+ #
340
+ # @return [Array<String, nil>]
341
+ # @api public
342
+ #: () -> Array[String?]
343
+ def formulas
344
+ return [] if cells.empty?
345
+
346
+ max_col = cells.map(&:column_index).max || 0
347
+ arr = Array.new(max_col + 1)
348
+ cells.each do |cell|
349
+ arr[cell.column_index] = cell.formula_expression
350
+ end
351
+ arr
169
352
  end
170
353
 
171
354
  # Returns whether the row is valid according to OOXML specifications.
@@ -177,13 +360,25 @@ module Xlsxrb
177
360
  true
178
361
  end
179
362
 
363
+ # Returns whether the row contains no cell values (or all cell values are nil or empty strings).
364
+ #
365
+ # @return [Boolean]
366
+ # @api public
367
+ #: () -> bool
368
+ def empty?
369
+ vals = values
370
+ return true if vals.empty?
371
+
372
+ vals.all? { |v| v.nil? || (v.is_a?(String) && v.empty?) }
373
+ end
374
+
180
375
  # Unmapped metadata for compatibility with Elements::Row.
181
376
  #
182
377
  # @return [Hash]
183
378
  # @api public
184
379
  #: () -> Hash[untyped, untyped]
185
380
  def unmapped_data
186
- Elements::EMPTY_HASH
381
+ @style_index ? { style_index: @style_index } : Elements::EMPTY_HASH
187
382
  end
188
383
 
189
384
  # Validation errors for compatibility with Elements::Row.
@@ -203,5 +398,37 @@ module Xlsxrb
203
398
  def inspect
204
399
  "#<#{self.class.name} index=#{index} height=#{height.inspect} hidden=#{hidden}>"
205
400
  end
401
+
402
+ private
403
+
404
+ #: (bool) -> Array[untyped]
405
+ def values_from_cells(type_cast)
406
+ return [] if @cells.nil? || @cells.empty?
407
+
408
+ max_col = @cells.map(&:column_index).max || 0
409
+ arr = Array.new(max_col + 1)
410
+ @cells.each do |c|
411
+ val = c.value
412
+ if type_cast && val.is_a?(Numeric)
413
+ fmt_type = if c.style_index && @styles
414
+ NumberFormatter.format_type_for(c.style_index, @styles)
415
+ elsif c.format_code
416
+ NumberFormatter.format_type(c.format_code)
417
+ end
418
+ begin
419
+ case fmt_type
420
+ when :date
421
+ val = Ooxml::Utils.serial_to_date(val, date1904: @date1904)
422
+ when :datetime, :time
423
+ val = Ooxml::Utils.serial_to_datetime(val, date1904: @date1904)
424
+ end
425
+ rescue StandardError
426
+ # Keep val numeric on failure
427
+ end
428
+ end
429
+ arr[c.column_index] = val
430
+ end
431
+ arr
432
+ end
206
433
  end
207
434
  end