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
@@ -13,49 +13,71 @@ module Xlsxrb
13
13
  class WorksheetParser
14
14
  EMPTY_ARRAY = [].freeze
15
15
  EMPTY_HASH = {}.freeze
16
+ TAG_DELIM_BYTES = [32, 62, 9, 10, 13, 47].freeze
17
+ ROW_ATTR_START_BYTES = [104, 99, 111, 115].freeze
16
18
 
17
19
  # Parses all rows from a worksheet XML string.
18
20
  # Returns an Array of raw row hashes:
19
21
  # { index:, cells: [{ ref:, type:, style_index:, value:, formula: }], attrs:, unmapped: }
20
- def self.parse(xml_string, shared_strings: [])
22
+ def self.parse(xml_string, shared_strings: [], styles: nil)
21
23
  return [] if xml_string.nil? || xml_string.empty?
22
24
 
23
25
  rows = []
24
- each_row(xml_string, shared_strings: shared_strings) { |row| rows << row }
26
+ each_row(xml_string, shared_strings: shared_strings, styles: styles) { |row| rows << row }
25
27
  rows
26
28
  end
27
29
 
28
30
  # Streaming parse: yields one raw row hash at a time.
29
- def self.each_row(xml_source, shared_strings: [], part_name: "xl/worksheets/sheet1.xml", &block)
30
- return enum_for(:each_row, xml_source, shared_strings: shared_strings, part_name: part_name) unless block
31
+ def self.each_row(xml_source, shared_strings: [], part_name: "xl/worksheets/sheet1.xml", styles: nil, date1904: false, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &block)
32
+ return enum_for(:each_row, xml_source, shared_strings: shared_strings, part_name: part_name, styles: styles, date1904: date1904, trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells) unless block
31
33
  return if xml_source.nil? || (xml_source.respond_to?(:empty?) && xml_source.empty?)
32
34
 
35
+ row_consumer = if trim_empty_rows
36
+ pending_empty = []
37
+ lambda do |row|
38
+ if row.empty?
39
+ pending_empty << row
40
+ else
41
+ pending_empty.each(&block)
42
+ pending_empty.clear
43
+ block.call(row)
44
+ end
45
+ end
46
+ else
47
+ block
48
+ end
49
+
50
+ if pad_empty_rows
51
+ current_expected_idx = 0
52
+ inner_consumer = row_consumer
53
+ row_consumer = lambda do |row|
54
+ while current_expected_idx < row.index
55
+ empty_row = StreamRow.fast_create(
56
+ current_expected_idx, "".b, 0, 0, shared_strings, "", nil, false, false, nil, nil, styles, date1904, false, pad_empty_cells
57
+ )
58
+ inner_consumer.call(empty_row)
59
+ current_expected_idx += 1
60
+ end
61
+ inner_consumer.call(row)
62
+ current_expected_idx = row.index + 1
63
+ end
64
+ end
65
+
33
66
  if xml_source.is_a?(String)
34
- fast_scan_rows_direct(xml_source, shared_strings, part_name, &block)
67
+ fast_scan_rows_direct(xml_source, shared_strings, part_name, styles, date1904, pad_empty_cells, &row_consumer)
35
68
  else
36
- scan_rows_stream(xml_source, shared_strings, part_name, &block)
69
+ scan_rows_stream(xml_source, shared_strings, part_name, styles, date1904, pad_empty_cells, &row_consumer)
37
70
  end
38
71
  end
39
72
 
40
73
  # Parses column definitions (<cols>) from a worksheet.
41
- def self.parse_columns(xml_string, part_name: "xl/worksheets/sheet1.xml")
74
+ def self.parse_columns(xml_string, _part_name = "xl/worksheets/sheet1.xml")
42
75
  return [] if xml_string.nil? || xml_string.empty?
76
+ return [] unless xml_string.include?("<cols")
43
77
 
44
- columns = []
45
- each_event(xml_string, part_name: part_name) do |event|
46
- next unless event.type == :column
47
-
48
- min, max, width, hidden, custom_width, outline_level = event.args
49
- columns << {
50
- min: min,
51
- max: max,
52
- width: width,
53
- hidden: hidden,
54
- custom_width: custom_width,
55
- outline_level: outline_level
56
- }
57
- end
58
- columns
78
+ listener = ColumnsListener.new
79
+ XmlParser.parse(xml_string, listener)
80
+ listener.columns
59
81
  end
60
82
 
61
83
  # Yields Event objects for columns, rows, cells, and hyperlinks.
@@ -80,19 +102,37 @@ module Xlsxrb
80
102
  fast_scan_events(xml_string, shared_strings, part_name, &block)
81
103
 
82
104
  # 3. Parse and yield hyperlink events
83
- return unless xml_string.include?("hyperlink")
105
+ parse_hyperlinks(xml_string).each do |hl|
106
+ block.call(Event.new(
107
+ type: :hyperlink,
108
+ args: [hl[:ref], hl[:rid], hl[:display], hl[:tooltip], hl[:location]],
109
+ source: { part: part_name, cell: hl[:ref] }
110
+ ))
111
+ end
112
+ end
113
+
114
+ # Parses all hyperlink elements from worksheet XML.
115
+ #
116
+ # @param xml_string [String, nil]
117
+ # @return [Array<Hash[Symbol, untyped]>]
118
+ # @api public
119
+ #: (String?) -> Array[Hash[Symbol, untyped]]
120
+ def self.parse_hyperlinks(xml_string)
121
+ return [] if xml_string.nil? || xml_string.empty?
122
+ return [] unless xml_string.include?("hyperlink")
84
123
 
85
124
  xml = xml_string.b
86
125
  hpos_term = xml.index("hyperlinks")
87
- return unless hpos_term
126
+ return [] unless hpos_term
88
127
 
89
128
  hpos = xml.rindex("<", hpos_term)
90
- return unless hpos
129
+ return [] unless hpos
91
130
 
92
131
  h_end_term = xml.index("/hyperlinks", hpos)
93
132
  h_end = h_end_term ? xml.index(">", h_end_term) : xml.size
94
133
  h_end ||= xml.size
95
134
 
135
+ links = []
96
136
  pos = hpos
97
137
  while pos < h_end
98
138
  hl_start_term = xml.index("hyperlink", pos)
@@ -106,20 +146,22 @@ module Xlsxrb
106
146
 
107
147
  hl_tag = xml.byteslice(hl_start, hl_tag_end - hl_start)
108
148
  ref = tag_attr(hl_tag, ' ref="')
109
- rid = tag_attr(hl_tag, ' r:id="') || tag_attr(hl_tag, ' id="')
149
+ rid = tag_attr(hl_tag, ' r:id="') || tag_attr(hl_tag, ' id="') || hl_tag[/(?:[a-zA-Z0-9_]+:)?id="([^"]+)"/, 1]
110
150
  display = tag_attr(hl_tag, ' display="')
111
151
  tooltip = tag_attr(hl_tag, ' tooltip="')
112
152
  location = tag_attr(hl_tag, ' location="')
113
153
 
114
154
  if ref
115
- block.call(Event.new(
116
- type: :hyperlink,
117
- args: [ref, rid, display, tooltip, location],
118
- source: { part: part_name, cell: ref }
119
- ))
155
+ entry = { ref: ref }
156
+ entry[:rid] = rid if rid
157
+ entry[:display] = display if display
158
+ entry[:tooltip] = tooltip if tooltip
159
+ entry[:location] = location if location
160
+ links << entry
120
161
  end
121
162
  pos = hl_tag_end + 1
122
163
  end
164
+ links
123
165
  end
124
166
 
125
167
  # ---- Fast string-scanning event parser (byte-level) ----
@@ -157,13 +199,14 @@ module Xlsxrb
157
199
  row_end_len = row_end_tag.bytesize
158
200
 
159
201
  pos = sd_open_end + 1
202
+ last_row_index = -1
160
203
 
161
204
  while pos < sd_end
162
205
  row_start = xml.index(row_start_pattern, pos)
163
206
  break unless row_start && row_start < sd_end
164
207
 
165
208
  nb = xml.getbyte(row_start + row_start_len)
166
- unless [32, 62, 9, 10, 13, 47].include?(nb)
209
+ unless TAG_DELIM_BYTES.include?(nb)
167
210
  pos = row_start + row_start_len
168
211
  next
169
212
  end
@@ -178,9 +221,9 @@ module Xlsxrb
178
221
 
179
222
  # Row index and attrs from tag substring (bounded search)
180
223
  row_tag = xml.byteslice(row_start, tag_end - row_start)
181
- row_index = 0
182
224
  r_val = tag_attr(row_tag, ' r="')
183
- row_index = r_val.to_i - 1 if r_val
225
+ row_index = r_val ? r_val.to_i - 1 : last_row_index + 1
226
+ last_row_index = row_index
184
227
 
185
228
  attrs = extract_row_attrs(row_tag)
186
229
 
@@ -217,22 +260,33 @@ module Xlsxrb
217
260
  attrs[:height] = ht_val.to_f
218
261
  end
219
262
 
220
- if row_tag.include?('hidden="1"')
263
+ if row_tag.include?('hidden="1"') || row_tag.include?('hidden="true"')
221
264
  attrs = {} if attrs.equal?(EMPTY_HASH)
222
265
  attrs[:hidden] = true
223
266
  end
224
267
 
225
- if row_tag.include?('customHeight="1"')
268
+ if row_tag.include?('customHeight="1"') || row_tag.include?('customHeight="true"')
226
269
  attrs = {} if attrs.equal?(EMPTY_HASH)
227
270
  attrs[:custom_height] = true
228
271
  end
229
272
 
273
+ if row_tag.include?('collapsed="1"') || row_tag.include?('collapsed="true"')
274
+ attrs = {} if attrs.equal?(EMPTY_HASH)
275
+ attrs[:collapsed] = true
276
+ end
277
+
230
278
  ol_val = tag_attr(row_tag, ' outlineLevel="')
231
279
  if ol_val
232
280
  attrs = {} if attrs.equal?(EMPTY_HASH)
233
281
  attrs[:outline_level] = ol_val.to_i
234
282
  end
235
283
 
284
+ s_val = tag_attr(row_tag, ' s="')
285
+ if s_val
286
+ attrs = {} if attrs.equal?(EMPTY_HASH)
287
+ attrs[:style_index] = s_val.to_i
288
+ end
289
+
236
290
  attrs
237
291
  end
238
292
 
@@ -278,7 +332,7 @@ module Xlsxrb
278
332
  break unless c_start && c_start < to
279
333
 
280
334
  nb = xml.getbyte(c_start + c_start_len)
281
- unless [32, 62, 9, 10, 13, 47].include?(nb)
335
+ unless TAG_DELIM_BYTES.include?(nb)
282
336
  pos = c_start + c_start_len
283
337
  next
284
338
  end
@@ -401,8 +455,7 @@ module Xlsxrb
401
455
  break unless t_end && t_end <= to
402
456
 
403
457
  text_segment = xml.byteslice(t_tag_end + 1, t_close - t_tag_end - 1).force_encoding("UTF-8")
404
- text_segment = decode_xml_entities(text_segment) if text_segment.include?("&")
405
- result << text_segment
458
+ result << normalize_text(text_segment)
406
459
 
407
460
  pos = t_end + 1
408
461
  end
@@ -418,8 +471,7 @@ module Xlsxrb
418
471
  when "b"
419
472
  raw == "1"
420
473
  when "e", "str", "inlineStr"
421
- val = raw.force_encoding("UTF-8")
422
- val.include?("&") ? decode_xml_entities(val) : val
474
+ normalize_text(raw.force_encoding("UTF-8"))
423
475
  else
424
476
  return nil if raw.empty?
425
477
 
@@ -441,6 +493,12 @@ module Xlsxrb
441
493
  XmlBuilder.unescape(str)
442
494
  end
443
495
 
496
+ #: (String str) -> String
497
+ def self.normalize_text(str)
498
+ s = decode_xml_entities(str)
499
+ s.include?("\r") ? s.gsub("\r\n", "\n").tr("\r", "\n") : s
500
+ end
501
+
444
502
  # Parses <cols> section for column definitions.
445
503
  class ColumnsListener
446
504
  include REXML::SAX2Listener
@@ -465,7 +523,8 @@ module Xlsxrb
465
523
  width: attrs["width"]&.to_f,
466
524
  hidden: attrs["hidden"] == "1",
467
525
  custom_width: attrs["customWidth"] == "1",
468
- outline_level: attrs["outlineLevel"]&.to_i
526
+ outline_level: attrs["outlineLevel"]&.to_i,
527
+ style_index: attrs["style"]&.to_i
469
528
  }
470
529
  @columns << col
471
530
  end
@@ -478,7 +537,9 @@ module Xlsxrb
478
537
  def characters(_text); end
479
538
  end
480
539
 
481
- def self.fast_scan_rows_direct(xml_src, shared_strings, _part_name, &block)
540
+ # rubocop:disable Style/OptionalBooleanParameter
541
+ def self.fast_scan_rows_direct(xml_src, shared_strings, _part_name, styles = nil, date1904 = false, pad_empty_cells = false, &block)
542
+ # rubocop:enable Style/OptionalBooleanParameter
482
543
  xml = xml_src.b # force ASCII-8BIT for O(1) byte indexing
483
544
 
484
545
  sd_term = xml.index("sheetData")
@@ -503,13 +564,14 @@ module Xlsxrb
503
564
  row_end_len = row_end_tag.bytesize
504
565
 
505
566
  pos = sd_open_end + 1
567
+ last_row_index = -1
506
568
 
507
569
  while pos < sd_end
508
570
  row_start = xml.index(row_start_pattern, pos)
509
571
  break unless row_start && row_start < sd_end
510
572
 
511
573
  nb = xml.getbyte(row_start + row_start_len)
512
- unless [32, 62, 9, 10, 13, 47].include?(nb)
574
+ unless TAG_DELIM_BYTES.include?(nb)
513
575
  pos = row_start + row_start_len
514
576
  next
515
577
  end
@@ -522,23 +584,23 @@ module Xlsxrb
522
584
  next
523
585
  end
524
586
 
525
- row_index = 0
587
+ row_index = nil
526
588
  has_custom_attrs = false
527
589
  ri = row_start + row_start_len
528
590
  while ri < tag_end
529
591
  rb = xml.getbyte(ri)
530
592
  if rb == 114 && xml.getbyte(ri + 1) == 61 && xml.getbyte(ri + 2) == 34 # r="
531
593
  ri += 3
594
+ parsed_r = 0
532
595
  while ri < tag_end
533
596
  cb = xml.getbyte(ri)
534
597
  break unless cb.between?(48, 57)
535
598
 
536
- row_index = (row_index * 10) + (cb - 48)
599
+ parsed_r = (parsed_r * 10) + (cb - 48)
537
600
  ri += 1
538
-
539
601
  end
540
- row_index -= 1
541
- elsif [104, 99, 111].include?(rb) # 'h', 'c', 'o' for ht, customHeight, hidden, outlineLevel
602
+ row_index = parsed_r - 1
603
+ elsif ROW_ATTR_START_BYTES.include?(rb) # 'h', 'c', 'o', 's' for ht, customHeight, hidden, outlineLevel, s
542
604
  has_custom_attrs = true
543
605
  ri += 1
544
606
  else
@@ -546,6 +608,9 @@ module Xlsxrb
546
608
  end
547
609
  end
548
610
 
611
+ row_index ||= last_row_index + 1
612
+ last_row_index = row_index
613
+
549
614
  attrs = if has_custom_attrs
550
615
  row_tag = xml.byteslice(row_start, tag_end - row_start)
551
616
  extract_row_attrs(row_tag)
@@ -566,7 +631,12 @@ module Xlsxrb
566
631
  attrs[:height],
567
632
  attrs[:hidden] || false,
568
633
  attrs[:custom_height] || false,
569
- attrs[:outline_level]
634
+ attrs[:outline_level],
635
+ attrs[:style_index],
636
+ styles,
637
+ date1904,
638
+ attrs[:collapsed] || false,
639
+ pad_empty_cells
570
640
  )
571
641
  block.call(row_obj)
572
642
 
@@ -576,7 +646,9 @@ module Xlsxrb
576
646
 
577
647
  private_class_method :fast_scan_rows_direct
578
648
 
579
- def self.scan_rows_stream(source, shared_strings, _part_name, &block)
649
+ # rubocop:disable Style/OptionalBooleanParameter
650
+ def self.scan_rows_stream(source, shared_strings, _part_name, styles = nil, date1904 = false, pad_empty_cells = false, &block)
651
+ # rubocop:enable Style/OptionalBooleanParameter
580
652
  buffer = +""
581
653
  buffer.force_encoding(Encoding::BINARY)
582
654
  in_sheet_data = false
@@ -586,6 +658,7 @@ module Xlsxrb
586
658
  row_end_tag = "</row>"
587
659
  row_end_len = 6
588
660
  sd_end_tag = "</sheetData>"
661
+ last_row_index = -1
589
662
 
590
663
  push_chunk = lambda do |chunk|
591
664
  return if in_sheet_data == :closed
@@ -628,9 +701,9 @@ module Xlsxrb
628
701
 
629
702
  pos = 0
630
703
  last_consumed_pos = 0
704
+ sd_end_pos = buffer.index(sd_end_tag)
631
705
 
632
706
  loop do
633
- sd_end_pos = buffer.index(sd_end_tag, pos)
634
707
  row_start = buffer.index(row_start_pattern, pos)
635
708
 
636
709
  if sd_end_pos && (row_start.nil? || sd_end_pos < row_start)
@@ -645,7 +718,7 @@ module Xlsxrb
645
718
  break if next_pos >= buffer.bytesize
646
719
 
647
720
  nb = buffer.getbyte(next_pos)
648
- unless [32, 62, 9, 10, 13, 47].include?(nb)
721
+ unless TAG_DELIM_BYTES.include?(nb)
649
722
  pos = next_pos
650
723
  next
651
724
  end
@@ -666,22 +739,23 @@ module Xlsxrb
666
739
  break
667
740
  end
668
741
 
669
- row_index = 0
742
+ row_index = nil
670
743
  has_custom_attrs = false
671
744
  ri = row_start + row_start_len
672
745
  while ri < tag_end
673
746
  rb = buffer.getbyte(ri)
674
747
  if rb == 114 && buffer.getbyte(ri + 1) == 61 && buffer.getbyte(ri + 2) == 34 # r="
675
748
  ri += 3
749
+ parsed_r = 0
676
750
  while ri < tag_end
677
751
  cb = buffer.getbyte(ri)
678
752
  break unless cb.between?(48, 57)
679
753
 
680
- row_index = (row_index * 10) + (cb - 48)
754
+ parsed_r = (parsed_r * 10) + (cb - 48)
681
755
  ri += 1
682
756
  end
683
- row_index -= 1
684
- elsif [104, 99, 111].include?(rb)
757
+ row_index = parsed_r - 1
758
+ elsif ROW_ATTR_START_BYTES.include?(rb)
685
759
  has_custom_attrs = true
686
760
  ri += 1
687
761
  else
@@ -689,6 +763,9 @@ module Xlsxrb
689
763
  end
690
764
  end
691
765
 
766
+ row_index ||= last_row_index + 1
767
+ last_row_index = row_index
768
+
692
769
  attrs = if has_custom_attrs
693
770
  row_tag = buffer.byteslice(row_start, tag_end - row_start)
694
771
  extract_row_attrs(row_tag)
@@ -707,7 +784,12 @@ module Xlsxrb
707
784
  attrs[:height],
708
785
  attrs[:hidden] || false,
709
786
  attrs[:custom_height] || false,
710
- attrs[:outline_level]
787
+ attrs[:outline_level],
788
+ attrs[:style_index],
789
+ styles,
790
+ date1904,
791
+ attrs[:collapsed] || false,
792
+ pad_empty_cells
711
793
  )
712
794
  block.call(row_obj)
713
795
 
@@ -718,20 +800,32 @@ module Xlsxrb
718
800
  buffer.slice!(0...last_consumed_pos) if last_consumed_pos.positive?
719
801
  end
720
802
 
721
- if source.respond_to?(:each_chunk)
722
- source.each_chunk(65_536, &push_chunk)
723
- elsif source.respond_to?(:read)
724
- while (chunk = source.read(65_536))
725
- break if chunk.empty?
803
+ catch(:done) do
804
+ if source.respond_to?(:each_chunk)
805
+ source.each_chunk(65_536) do |chunk|
806
+ push_chunk.call(chunk)
807
+ throw :done if in_sheet_data == :closed
808
+ end
809
+ elsif source.respond_to?(:read)
810
+ while (chunk = source.read(65_536))
811
+ break if chunk.empty?
726
812
 
727
- push_chunk.call(chunk)
813
+ push_chunk.call(chunk)
814
+ break if in_sheet_data == :closed
815
+ end
816
+ elsif source.respond_to?(:call)
817
+ source.call do |chunk|
818
+ push_chunk.call(chunk)
819
+ throw :done if in_sheet_data == :closed
820
+ end
821
+ elsif source.respond_to?(:each)
822
+ source.each do |chunk|
823
+ push_chunk.call(chunk)
824
+ break if in_sheet_data == :closed
825
+ end
826
+ else
827
+ raise ArgumentError, "Unsupported stream source: #{source.class}"
728
828
  end
729
- elsif source.respond_to?(:call)
730
- source.call(&push_chunk)
731
- elsif source.respond_to?(:each)
732
- source.each(&push_chunk)
733
- else
734
- raise ArgumentError, "Unsupported stream source: #{source.class}"
735
829
  end
736
830
  end
737
831
 
@@ -752,8 +846,8 @@ module Xlsxrb
752
846
  [COL_LETTERS[idx], idx]
753
847
  end.freeze
754
848
 
755
- CELL_FAST_RE = %r{<(?:[a-zA-Z0-9_]+:)?c\s+r="([A-Za-z0-9]+)"(?:[^>]*?s="(\d+)")?(?:[^>]*?t="([a-zA-Z]+)")?[^>]*>(?:<(?:[a-zA-Z0-9_]+:)?f[^>]*>([^<]*)</(?:[a-zA-Z0-9_]+:)?f>)?(?:<(?:[a-zA-Z0-9_]+:)?v>([^<]*)</(?:[a-zA-Z0-9_]+:)?v>)?(?:<(?:[a-zA-Z0-9_]+:)?is>(.*?)</(?:[a-zA-Z0-9_]+:)?is>)?</(?:[a-zA-Z0-9_]+:)?c>|<(?:[a-zA-Z0-9_]+:)?c\s+r="([A-Za-z0-9]+)"(?:[^>]*?s="(\d+)")?[^>]*/>}
756
- CELL_GENERIC_RE = %r{<(?:[a-zA-Z0-9_]+:)?c\b([^>]*?)(?:>(?:<(?:[a-zA-Z0-9_]+:)?f[^>]*>([^<]*)</(?:[a-zA-Z0-9_]+:)?f>)?(?:<(?:[a-zA-Z0-9_]+:)?v>([^<]*)</(?:[a-zA-Z0-9_]+:)?v>)?(?:<(?:[a-zA-Z0-9_]+:)?is>(.*?)</(?:[a-zA-Z0-9_]+:)?is>)?</(?:[a-zA-Z0-9_]+:)?c>|/>)}m
849
+ CELL_FAST_RE = %r{<(?:[a-zA-Z0-9_]+:)?c\s+r="([A-Za-z0-9]+)"(?:[^>]*?s="(\d+)")?(?:[^>]*?t="([a-zA-Z]+)")?[^>]*>\s*(?:<(?:[a-zA-Z0-9_]+:)?f\b[^>]*?(?:/>|>([^<]*)</(?:[a-zA-Z0-9_]+:)?f>)\s*)?(?:<(?:[a-zA-Z0-9_]+:)?v>([^<]*)</(?:[a-zA-Z0-9_]+:)?v>\s*)?(?:<(?:[a-zA-Z0-9_]+:)?is>(.*?)</(?:[a-zA-Z0-9_]+:)?is>\s*)?.*?</(?:[a-zA-Z0-9_]+:)?c>|<(?:[a-zA-Z0-9_]+:)?c\s+r="([A-Za-z0-9]+)"(?:[^>]*?s="(\d+)")?[^>]*/>}m
850
+ CELL_GENERIC_RE = %r{<(?:[a-zA-Z0-9_]+:)?c\b([^>]*?)(?:>\s*(?:<(?:[a-zA-Z0-9_]+:)?f\b[^>]*?(?:/>|>([^<]*)</(?:[a-zA-Z0-9_]+:)?f>)\s*)?(?:<(?:[a-zA-Z0-9_]+:)?v>([^<]*)</(?:[a-zA-Z0-9_]+:)?v>\s*)?(?:<(?:[a-zA-Z0-9_]+:)?is>(.*?)</(?:[a-zA-Z0-9_]+:)?is>\s*)?.*?</(?:[a-zA-Z0-9_]+:)?c>|/>)}m
757
851
  ATTR_R = /r="([A-Za-z0-9]+)"/
758
852
  ATTR_T = /t="([a-zA-Z]+)"/
759
853
  ATTR_S = /s="(\d+)"/
@@ -764,7 +858,9 @@ module Xlsxrb
764
858
  cells
765
859
  end
766
860
 
767
- def self.fast_scan_cells_direct(xml, from, to, shared_strings, row_source, _prefix = "", &block)
861
+ # rubocop:disable Style/OptionalBooleanParameter
862
+ def self.fast_scan_cells_direct(xml, from, to, shared_strings, row_source, _prefix = "", styles = nil, date1904 = false, &block)
863
+ # rubocop:enable Style/OptionalBooleanParameter
768
864
  chunk = xml.byteslice(from, to - from)
769
865
  row_idx = row_source.is_a?(Hash) ? row_source[:row] : row_source
770
866
  col_idx = 0
@@ -789,6 +885,8 @@ module Xlsxrb
789
885
  col_idx
790
886
  end
791
887
  col_idx = c_idx + 1
888
+ v = v.strip if v
889
+ f = f.strip if f
792
890
 
793
891
  val = if t == "s"
794
892
  shared_strings[v.to_i] || ""
@@ -798,7 +896,7 @@ module Xlsxrb
798
896
  if is
799
897
  extract_inline_text(is, 0, is.bytesize)
800
898
  elsif v
801
- v.include?("&") ? decode_xml_entities(v) : v
899
+ normalize_text(v)
802
900
  else
803
901
  ""
804
902
  end
@@ -806,8 +904,16 @@ module Xlsxrb
806
904
  v.include?(".") ? v.to_f : v.to_i
807
905
  end
808
906
 
809
- formula_expr = f&.include?("&") ? decode_xml_entities(f) : f
810
- cell = Elements::Cell.fast_create(row_idx, c_idx, val, style_idx, formula_expr)
907
+ raw_val = if is
908
+ extract_inline_text(is, 0, is.bytesize)
909
+ elsif v
910
+ normalize_text(v)
911
+ end
912
+
913
+ formula_expr = f ? normalize_text(f) : nil
914
+ formula_expr = nil if formula_expr && formula_expr.empty?
915
+ fmt_code = NumberFormatter.format_code_for(style_idx, styles) if styles && style_idx
916
+ cell = Elements::Cell.fast_create(row_idx, c_idx, val, style_idx, formula_expr, raw_val, fmt_code, date1904)
811
917
  block.call(cell)
812
918
  end
813
919
 
@@ -833,6 +939,8 @@ module Xlsxrb
833
939
  col_idx
834
940
  end
835
941
  col_idx = c_idx + 1
942
+ v = v.strip if v
943
+ f = f.strip if f
836
944
 
837
945
  val = if t == "s"
838
946
  shared_strings[v.to_i] || ""
@@ -842,7 +950,7 @@ module Xlsxrb
842
950
  if is
843
951
  extract_inline_text(is, 0, is.bytesize)
844
952
  elsif v
845
- v.include?("&") ? decode_xml_entities(v) : v
953
+ normalize_text(v)
846
954
  else
847
955
  ""
848
956
  end
@@ -850,13 +958,145 @@ module Xlsxrb
850
958
  v.include?(".") ? v.to_f : v.to_i
851
959
  end
852
960
 
961
+ raw_val = if is
962
+ extract_inline_text(is, 0, is.bytesize)
963
+ elsif v
964
+ normalize_text(v)
965
+ end
966
+
853
967
  style_idx = s&.to_i
854
- formula_expr = f&.include?("&") ? decode_xml_entities(f) : f
855
- cell = Elements::Cell.fast_create(row_idx, c_idx, val, style_idx, formula_expr)
968
+ formula_expr = f ? normalize_text(f) : nil
969
+ formula_expr = nil if formula_expr && formula_expr.empty?
970
+ fmt_code = NumberFormatter.format_code_for(style_idx, styles) if styles && style_idx
971
+ cell = Elements::Cell.fast_create(row_idx, c_idx, val, style_idx, formula_expr, raw_val, fmt_code, date1904)
856
972
  block.call(cell)
857
973
  end
858
974
  end
859
975
 
976
+ # rubocop:disable Style/OptionalBooleanParameter
977
+ #: (String xml, Integer from, Integer to, Array[String] shared_strings, ?untyped styles, ?bool date1904, ?type_cast: bool) -> Array[untyped]
978
+ def self.fast_scan_row_values(xml, from, to, shared_strings, styles = nil, date1904 = false, type_cast: false)
979
+ # rubocop:enable Style/OptionalBooleanParameter
980
+ chunk = xml.byteslice(from, to - from)
981
+ return [] if chunk.nil? || chunk.empty?
982
+
983
+ col_idx = 0
984
+ matched_any = false
985
+ row_values = []
986
+
987
+ chunk.scan(CELL_FAST_RE) do |r, s, t, _f, v, is, self_r, self_s|
988
+ matched_any = true
989
+ ref = r || self_r
990
+ s_val = s || self_s
991
+ c_idx = if ref
992
+ expected_letter = COL_LETTERS[col_idx]
993
+ if expected_letter && ref.start_with?(expected_letter)
994
+ col_idx
995
+ else
996
+ c_len = 0
997
+ c_len += 1 while (b = ref.getbyte(c_len)) && (b.between?(65, 90) || b.between?(97, 122))
998
+ col_letters = ref.byteslice(0, c_len).upcase
999
+ COL_MAP[col_letters] || col_idx
1000
+ end
1001
+ else
1002
+ col_idx
1003
+ end
1004
+ col_idx = c_idx + 1
1005
+ v = v.strip if v
1006
+
1007
+ val = if t == "s"
1008
+ shared_strings[v.to_i] || ""
1009
+ elsif t == "b"
1010
+ v == "1"
1011
+ elsif %w[inlineStr str e].include?(t)
1012
+ if is
1013
+ extract_inline_text(is, 0, is.bytesize)
1014
+ elsif v
1015
+ normalize_text(v)
1016
+ else
1017
+ ""
1018
+ end
1019
+ elsif v
1020
+ v.include?(".") ? v.to_f : v.to_i
1021
+ end
1022
+
1023
+ if type_cast && val.is_a?(Numeric) && s_val && styles
1024
+ fmt_type = NumberFormatter.format_type_for(s_val.to_i, styles)
1025
+ begin
1026
+ case fmt_type
1027
+ when :date
1028
+ val = Utils.serial_to_date(val, date1904: date1904)
1029
+ when :datetime, :time
1030
+ val = Utils.serial_to_datetime(val, date1904: date1904)
1031
+ end
1032
+ rescue StandardError
1033
+ # Keep numeric value on conversion failure
1034
+ end
1035
+ end
1036
+
1037
+ row_values[c_idx] = val
1038
+ end
1039
+
1040
+ return row_values if matched_any || (!chunk.include?("<c") && !chunk.include?(":c"))
1041
+
1042
+ # Fallback for non-standard attribute ordering
1043
+ chunk.scan(CELL_GENERIC_RE) do |attrs, _f, v, is|
1044
+ r = attrs[ATTR_R, 1]
1045
+ t = attrs[ATTR_T, 1]
1046
+ s = attrs[ATTR_S, 1]
1047
+
1048
+ c_idx = if r
1049
+ expected_letter = COL_LETTERS[col_idx]
1050
+ if expected_letter && r.start_with?(expected_letter)
1051
+ col_idx
1052
+ else
1053
+ c_len = 0
1054
+ c_len += 1 while (b = r.getbyte(c_len)) && (b.between?(65, 90) || b.between?(97, 122))
1055
+ col_letters = r.byteslice(0, c_len).upcase
1056
+ COL_MAP[col_letters] || col_idx
1057
+ end
1058
+ else
1059
+ col_idx
1060
+ end
1061
+ col_idx = c_idx + 1
1062
+ v = v.strip if v
1063
+
1064
+ val = if t == "s"
1065
+ shared_strings[v.to_i] || ""
1066
+ elsif t == "b"
1067
+ v == "1"
1068
+ elsif %w[inlineStr str e].include?(t)
1069
+ if is
1070
+ extract_inline_text(is, 0, is.bytesize)
1071
+ elsif v
1072
+ normalize_text(v)
1073
+ else
1074
+ ""
1075
+ end
1076
+ elsif v
1077
+ v.include?(".") ? v.to_f : v.to_i
1078
+ end
1079
+
1080
+ if type_cast && val.is_a?(Numeric) && s && styles
1081
+ fmt_type = NumberFormatter.format_type_for(s.to_i, styles)
1082
+ begin
1083
+ case fmt_type
1084
+ when :date
1085
+ val = Utils.serial_to_date(val, date1904: date1904)
1086
+ when :datetime, :time
1087
+ val = Utils.serial_to_datetime(val, date1904: date1904)
1088
+ end
1089
+ rescue StandardError
1090
+ # Keep numeric value on conversion failure
1091
+ end
1092
+ end
1093
+
1094
+ row_values[c_idx] = val
1095
+ end
1096
+
1097
+ row_values
1098
+ end
1099
+
860
1100
  private_class_method :fast_parse_cells_direct
861
1101
  end
862
1102
  end