xlsxrb 0.1.12 → 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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -42
  3. data/README.md +28 -22
  4. data/lib/xlsxrb/colors.rb +118 -0
  5. data/lib/xlsxrb/dsl_helpers.rb +59 -0
  6. data/lib/xlsxrb/elements/cell.rb +205 -16
  7. data/lib/xlsxrb/elements/column.rb +6 -4
  8. data/lib/xlsxrb/elements/coordinate_access.rb +32 -0
  9. data/lib/xlsxrb/elements/image.rb +128 -0
  10. data/lib/xlsxrb/elements/row.rb +206 -9
  11. data/lib/xlsxrb/elements/styles.rb +157 -0
  12. data/lib/xlsxrb/elements/types.rb +95 -1
  13. data/lib/xlsxrb/elements/workbook.rb +96 -4
  14. data/lib/xlsxrb/elements/worksheet.rb +424 -29
  15. data/lib/xlsxrb/elements.rb +2 -0
  16. data/lib/xlsxrb/number_formatter.rb +429 -0
  17. data/lib/xlsxrb/ooxml/cfb.rb +1 -1
  18. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +7 -5
  19. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +11 -4
  20. data/lib/xlsxrb/ooxml/reader.rb +12 -14
  21. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +10 -6
  22. data/lib/xlsxrb/ooxml/styles_parser.rb +39 -9
  23. data/lib/xlsxrb/ooxml/utils.rb +34 -18
  24. data/lib/xlsxrb/ooxml/workbook_parser.rb +53 -15
  25. data/lib/xlsxrb/ooxml/workbook_writer.rb +70 -21
  26. data/lib/xlsxrb/ooxml/worksheet_parser.rb +466 -63
  27. data/lib/xlsxrb/ooxml/worksheet_writer.rb +206 -61
  28. data/lib/xlsxrb/ooxml/writer/features_xml.rb +4 -2
  29. data/lib/xlsxrb/ooxml/writer.rb +16 -1
  30. data/lib/xlsxrb/ooxml/xml_builder.rb +20 -0
  31. data/lib/xlsxrb/ooxml/zip_reader.rb +452 -82
  32. data/lib/xlsxrb/ooxml/zip_writer.rb +32 -5
  33. data/lib/xlsxrb/stream_row.rb +243 -16
  34. data/lib/xlsxrb/stream_sheet.rb +504 -17
  35. data/lib/xlsxrb/stream_writer.rb +246 -83
  36. data/lib/xlsxrb/style_builder.rb +308 -22
  37. data/lib/xlsxrb/utils.rb +158 -0
  38. data/lib/xlsxrb/version.rb +1 -1
  39. data/lib/xlsxrb/workbook_builder.rb +74 -4
  40. data/lib/xlsxrb/worksheet_builder.rb +230 -47
  41. data/lib/xlsxrb.rb +1005 -229
  42. data/sig/generated/xlsxrb/colors.rbs +40 -0
  43. data/sig/generated/xlsxrb/dsl_helpers.rbs +32 -0
  44. data/sig/generated/xlsxrb/elements/cell.rbs +113 -8
  45. data/sig/generated/xlsxrb/elements/column.rbs +3 -2
  46. data/sig/generated/xlsxrb/elements/coordinate_access.rbs +18 -0
  47. data/sig/generated/xlsxrb/elements/image.rbs +82 -0
  48. data/sig/generated/xlsxrb/elements/row.rbs +83 -4
  49. data/sig/generated/xlsxrb/elements/styles.rbs +79 -0
  50. data/sig/generated/xlsxrb/elements/types.rbs +43 -0
  51. data/sig/generated/xlsxrb/elements/workbook.rbs +58 -2
  52. data/sig/generated/xlsxrb/elements/worksheet.rbs +231 -18
  53. data/sig/generated/xlsxrb/number_formatter.rbs +132 -0
  54. data/sig/generated/xlsxrb/ooxml/reader.rbs +6 -0
  55. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +2 -2
  56. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -2
  57. data/sig/generated/xlsxrb/ooxml/utils.rbs +17 -8
  58. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +9 -3
  59. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +31 -8
  60. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +3 -1
  61. data/sig/generated/xlsxrb/ooxml/writer.rbs +2 -0
  62. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +11 -0
  63. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +106 -8
  64. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +5 -1
  65. data/sig/generated/xlsxrb/stream_row.rbs +100 -6
  66. data/sig/generated/xlsxrb/stream_sheet.rbs +288 -14
  67. data/sig/generated/xlsxrb/stream_writer.rbs +120 -14
  68. data/sig/generated/xlsxrb/style_builder.rbs +55 -18
  69. data/sig/generated/xlsxrb/utils.rbs +119 -0
  70. data/sig/generated/xlsxrb/workbook_builder.rbs +26 -0
  71. data/sig/generated/xlsxrb/worksheet_builder.rbs +107 -11
  72. data/sig/generated/xlsxrb.rbs +202 -36
  73. metadata +16 -146
  74. data/.devcontainer/Dockerfile +0 -65
  75. data/.devcontainer/devcontainer.json +0 -17
  76. data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +0 -9
  77. data/.gem_rbs_collection/ast/2.4/ast.rbs +0 -73
  78. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +0 -9
  79. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +0 -4
  80. data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +0 -16
  81. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +0 -96
  82. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +0 -4
  83. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +0 -68
  84. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +0 -249
  85. data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +0 -4
  86. data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +0 -47
  87. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +0 -5
  88. data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +0 -9
  89. data/.gem_rbs_collection/csv/3.3/csv.rbs +0 -3871
  90. data/.gem_rbs_collection/csv/3.3/manifest.yaml +0 -3
  91. data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +0 -9
  92. data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +0 -48
  93. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +0 -9
  94. data/.gem_rbs_collection/listen/3.9/listen.rbs +0 -25
  95. data/.gem_rbs_collection/listen/3.9/listener.rbs +0 -24
  96. data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +0 -9
  97. data/.gem_rbs_collection/logger/1.7/formatter.rbs +0 -45
  98. data/.gem_rbs_collection/logger/1.7/log_device.rbs +0 -100
  99. data/.gem_rbs_collection/logger/1.7/logger.rbs +0 -796
  100. data/.gem_rbs_collection/logger/1.7/manifest.yaml +0 -2
  101. data/.gem_rbs_collection/logger/1.7/period.rbs +0 -17
  102. data/.gem_rbs_collection/logger/1.7/severity.rbs +0 -34
  103. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +0 -9
  104. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +0 -2332
  105. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +0 -4
  106. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +0 -9
  107. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +0 -86
  108. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +0 -9
  109. data/.gem_rbs_collection/parser/3.2/manifest.yaml +0 -7
  110. data/.gem_rbs_collection/parser/3.2/parser.rbs +0 -194
  111. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +0 -4
  112. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +0 -9
  113. data/.gem_rbs_collection/rainbow/3.0/global.rbs +0 -7
  114. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +0 -209
  115. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +0 -5
  116. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +0 -9
  117. data/.gem_rbs_collection/rake/13.0/manifest.yaml +0 -2
  118. data/.gem_rbs_collection/rake/13.0/rake.rbs +0 -39
  119. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +0 -9
  120. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +0 -17
  121. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +0 -9
  122. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +0 -208
  123. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +0 -9
  124. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +0 -903
  125. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +0 -9
  126. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +0 -8
  127. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +0 -42
  128. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +0 -5
  129. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +0 -47
  130. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +0 -30
  131. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +0 -9
  132. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +0 -7
  133. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +0 -19
  134. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +0 -31
  135. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +0 -18
  136. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +0 -12
  137. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +0 -11
  138. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +0 -13
  139. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +0 -95
  140. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +0 -31
  141. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +0 -58
  142. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +0 -24
  143. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +0 -17
  144. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +0 -23
  145. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +0 -22
  146. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +0 -30
  147. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +0 -20
  148. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +0 -15
  149. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +0 -26
  150. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +0 -21
  151. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +0 -131
  152. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +0 -14
  153. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +0 -33
  154. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +0 -21
  155. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +0 -63
  156. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +0 -55
  157. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +0 -35
  158. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +0 -7
  159. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +0 -10
  160. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +0 -22
  161. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +0 -29
  162. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +0 -17
  163. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +0 -13
  164. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +0 -10
  165. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +0 -8
  166. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +0 -6
  167. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +0 -30
  168. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +0 -10
  169. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +0 -10
  170. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +0 -5
  171. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +0 -15
  172. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +0 -3
  173. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +0 -40
  174. data/.mutant.yml +0 -8
  175. data/Rakefile +0 -633
  176. data/Steepfile +0 -18
  177. data/benchmark.rb +0 -406
  178. data/docs/ARCHITECTURE.md +0 -514
  179. data/docs/DEVELOPMENT.md +0 -137
  180. data/docs/MUTATION_TESTING.md +0 -52
  181. data/docs/PEER_LIBRARIES.md +0 -121
  182. data/docs/QUALITY_ASSURANCE.md +0 -26
  183. data/docs/SPEC_SOURCES.md +0 -48
  184. data/docs/assets/benchmark_results.svg +0 -144
  185. data/docs/assets/lsp_autocompletion.png +0 -0
  186. data/docs/assets/playground_preview.png +0 -0
  187. data/docs/coi-serviceworker.js +0 -82
  188. data/docs/office_thread.js +0 -77
  189. data/docs/preview.html +0 -719
  190. data/docs/visual/VisualGallery.md +0 -4683
  191. data/docs/wasm/ruby.wasm +0 -0
  192. data/docs/wasm/wasm_doc_helper.css +0 -351
  193. data/docs/wasm/wasm_doc_helper.js +0 -439
  194. data/docs/zeta.js +0 -1107
  195. data/rbs_collection.lock.yaml +0 -252
  196. data/rbs_collection.yaml +0 -19
  197. data/vendor/sdk_runner/Program.cs +0 -93
  198. data/vendor/sdk_runner/sdk_runner.csproj +0 -13
@@ -3,6 +3,7 @@
3
3
  # rbs_inline: enabled
4
4
 
5
5
  require_relative "xml_parser"
6
+ require_relative "xml_builder"
6
7
 
7
8
  module Xlsxrb
8
9
  module Ooxml
@@ -12,45 +13,71 @@ module Xlsxrb
12
13
  class WorksheetParser
13
14
  EMPTY_ARRAY = [].freeze
14
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
15
18
 
16
19
  # Parses all rows from a worksheet XML string.
17
20
  # Returns an Array of raw row hashes:
18
21
  # { index:, cells: [{ ref:, type:, style_index:, value:, formula: }], attrs:, unmapped: }
19
- def self.parse(xml_string, shared_strings: [])
22
+ def self.parse(xml_string, shared_strings: [], styles: nil)
20
23
  return [] if xml_string.nil? || xml_string.empty?
21
24
 
22
25
  rows = []
23
- 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 }
24
27
  rows
25
28
  end
26
29
 
27
30
  # Streaming parse: yields one raw row hash at a time.
28
- def self.each_row(xml_string, shared_strings: [], part_name: "xl/worksheets/sheet1.xml", &block)
29
- return enum_for(:each_row, xml_string, shared_strings: shared_strings, part_name: part_name) unless block
30
- return if xml_string.nil? || xml_string.empty?
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
33
+ return if xml_source.nil? || (xml_source.respond_to?(:empty?) && xml_source.empty?)
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
31
65
 
32
- fast_scan_rows_direct(xml_string, shared_strings, part_name, &block)
66
+ if xml_source.is_a?(String)
67
+ fast_scan_rows_direct(xml_source, shared_strings, part_name, styles, date1904, pad_empty_cells, &row_consumer)
68
+ else
69
+ scan_rows_stream(xml_source, shared_strings, part_name, styles, date1904, pad_empty_cells, &row_consumer)
70
+ end
33
71
  end
34
72
 
35
73
  # Parses column definitions (<cols>) from a worksheet.
36
- 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")
37
75
  return [] if xml_string.nil? || xml_string.empty?
76
+ return [] unless xml_string.include?("<cols")
38
77
 
39
- columns = []
40
- each_event(xml_string, part_name: part_name) do |event|
41
- next unless event.type == :column
42
-
43
- min, max, width, hidden, custom_width, outline_level = event.args
44
- columns << {
45
- min: min,
46
- max: max,
47
- width: width,
48
- hidden: hidden,
49
- custom_width: custom_width,
50
- outline_level: outline_level
51
- }
52
- end
53
- columns
78
+ listener = ColumnsListener.new
79
+ XmlParser.parse(xml_string, listener)
80
+ listener.columns
54
81
  end
55
82
 
56
83
  # Yields Event objects for columns, rows, cells, and hyperlinks.
@@ -75,19 +102,37 @@ module Xlsxrb
75
102
  fast_scan_events(xml_string, shared_strings, part_name, &block)
76
103
 
77
104
  # 3. Parse and yield hyperlink events
78
- 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")
79
123
 
80
124
  xml = xml_string.b
81
125
  hpos_term = xml.index("hyperlinks")
82
- return unless hpos_term
126
+ return [] unless hpos_term
83
127
 
84
128
  hpos = xml.rindex("<", hpos_term)
85
- return unless hpos
129
+ return [] unless hpos
86
130
 
87
131
  h_end_term = xml.index("/hyperlinks", hpos)
88
132
  h_end = h_end_term ? xml.index(">", h_end_term) : xml.size
89
133
  h_end ||= xml.size
90
134
 
135
+ links = []
91
136
  pos = hpos
92
137
  while pos < h_end
93
138
  hl_start_term = xml.index("hyperlink", pos)
@@ -101,20 +146,22 @@ module Xlsxrb
101
146
 
102
147
  hl_tag = xml.byteslice(hl_start, hl_tag_end - hl_start)
103
148
  ref = tag_attr(hl_tag, ' ref="')
104
- 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]
105
150
  display = tag_attr(hl_tag, ' display="')
106
151
  tooltip = tag_attr(hl_tag, ' tooltip="')
107
152
  location = tag_attr(hl_tag, ' location="')
108
153
 
109
154
  if ref
110
- block.call(Event.new(
111
- type: :hyperlink,
112
- args: [ref, rid, display, tooltip, location],
113
- source: { part: part_name, cell: ref }
114
- ))
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
115
161
  end
116
162
  pos = hl_tag_end + 1
117
163
  end
164
+ links
118
165
  end
119
166
 
120
167
  # ---- Fast string-scanning event parser (byte-level) ----
@@ -152,13 +199,14 @@ module Xlsxrb
152
199
  row_end_len = row_end_tag.bytesize
153
200
 
154
201
  pos = sd_open_end + 1
202
+ last_row_index = -1
155
203
 
156
204
  while pos < sd_end
157
205
  row_start = xml.index(row_start_pattern, pos)
158
206
  break unless row_start && row_start < sd_end
159
207
 
160
208
  nb = xml.getbyte(row_start + row_start_len)
161
- unless [32, 62, 9, 10, 13, 47].include?(nb)
209
+ unless TAG_DELIM_BYTES.include?(nb)
162
210
  pos = row_start + row_start_len
163
211
  next
164
212
  end
@@ -173,9 +221,9 @@ module Xlsxrb
173
221
 
174
222
  # Row index and attrs from tag substring (bounded search)
175
223
  row_tag = xml.byteslice(row_start, tag_end - row_start)
176
- row_index = 0
177
224
  r_val = tag_attr(row_tag, ' r="')
178
- 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
179
227
 
180
228
  attrs = extract_row_attrs(row_tag)
181
229
 
@@ -212,22 +260,33 @@ module Xlsxrb
212
260
  attrs[:height] = ht_val.to_f
213
261
  end
214
262
 
215
- if row_tag.include?('hidden="1"')
263
+ if row_tag.include?('hidden="1"') || row_tag.include?('hidden="true"')
216
264
  attrs = {} if attrs.equal?(EMPTY_HASH)
217
265
  attrs[:hidden] = true
218
266
  end
219
267
 
220
- if row_tag.include?('customHeight="1"')
268
+ if row_tag.include?('customHeight="1"') || row_tag.include?('customHeight="true"')
221
269
  attrs = {} if attrs.equal?(EMPTY_HASH)
222
270
  attrs[:custom_height] = true
223
271
  end
224
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
+
225
278
  ol_val = tag_attr(row_tag, ' outlineLevel="')
226
279
  if ol_val
227
280
  attrs = {} if attrs.equal?(EMPTY_HASH)
228
281
  attrs[:outline_level] = ol_val.to_i
229
282
  end
230
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
+
231
290
  attrs
232
291
  end
233
292
 
@@ -273,7 +332,7 @@ module Xlsxrb
273
332
  break unless c_start && c_start < to
274
333
 
275
334
  nb = xml.getbyte(c_start + c_start_len)
276
- unless [32, 62, 9, 10, 13, 47].include?(nb)
335
+ unless TAG_DELIM_BYTES.include?(nb)
277
336
  pos = c_start + c_start_len
278
337
  next
279
338
  end
@@ -396,8 +455,7 @@ module Xlsxrb
396
455
  break unless t_end && t_end <= to
397
456
 
398
457
  text_segment = xml.byteslice(t_tag_end + 1, t_close - t_tag_end - 1).force_encoding("UTF-8")
399
- text_segment = decode_xml_entities(text_segment) if text_segment.include?("&")
400
- result << text_segment
458
+ result << normalize_text(text_segment)
401
459
 
402
460
  pos = t_end + 1
403
461
  end
@@ -413,8 +471,7 @@ module Xlsxrb
413
471
  when "b"
414
472
  raw == "1"
415
473
  when "e", "str", "inlineStr"
416
- val = raw.force_encoding("UTF-8")
417
- val.include?("&") ? decode_xml_entities(val) : val
474
+ normalize_text(raw.force_encoding("UTF-8"))
418
475
  else
419
476
  return nil if raw.empty?
420
477
 
@@ -428,16 +485,19 @@ module Xlsxrb
428
485
 
429
486
  private_class_method :resolve_fast_value
430
487
 
431
- XML_ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"', "&apos;" => "'" }.freeze
432
-
433
488
  # SECURITY NOTE: Decodes only standard predefined XML entities in a single pass.
434
489
  # This completely prevents "Billion Laughs" attacks (exponential entity expansion)
435
490
  # because it avoids recursive expansion and ignores custom entities entirely.
491
+ #: (String str) -> String
436
492
  def self.decode_xml_entities(str)
437
- str.gsub(/&(?:amp|lt|gt|quot|apos);/, XML_ENTITIES)
493
+ XmlBuilder.unescape(str)
438
494
  end
439
495
 
440
- private_class_method :decode_xml_entities
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
441
501
 
442
502
  # Parses <cols> section for column definitions.
443
503
  class ColumnsListener
@@ -463,7 +523,8 @@ module Xlsxrb
463
523
  width: attrs["width"]&.to_f,
464
524
  hidden: attrs["hidden"] == "1",
465
525
  custom_width: attrs["customWidth"] == "1",
466
- outline_level: attrs["outlineLevel"]&.to_i
526
+ outline_level: attrs["outlineLevel"]&.to_i,
527
+ style_index: attrs["style"]&.to_i
467
528
  }
468
529
  @columns << col
469
530
  end
@@ -476,7 +537,9 @@ module Xlsxrb
476
537
  def characters(_text); end
477
538
  end
478
539
 
479
- 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
480
543
  xml = xml_src.b # force ASCII-8BIT for O(1) byte indexing
481
544
 
482
545
  sd_term = xml.index("sheetData")
@@ -501,13 +564,14 @@ module Xlsxrb
501
564
  row_end_len = row_end_tag.bytesize
502
565
 
503
566
  pos = sd_open_end + 1
567
+ last_row_index = -1
504
568
 
505
569
  while pos < sd_end
506
570
  row_start = xml.index(row_start_pattern, pos)
507
571
  break unless row_start && row_start < sd_end
508
572
 
509
573
  nb = xml.getbyte(row_start + row_start_len)
510
- unless [32, 62, 9, 10, 13, 47].include?(nb)
574
+ unless TAG_DELIM_BYTES.include?(nb)
511
575
  pos = row_start + row_start_len
512
576
  next
513
577
  end
@@ -520,23 +584,23 @@ module Xlsxrb
520
584
  next
521
585
  end
522
586
 
523
- row_index = 0
587
+ row_index = nil
524
588
  has_custom_attrs = false
525
589
  ri = row_start + row_start_len
526
590
  while ri < tag_end
527
591
  rb = xml.getbyte(ri)
528
592
  if rb == 114 && xml.getbyte(ri + 1) == 61 && xml.getbyte(ri + 2) == 34 # r="
529
593
  ri += 3
594
+ parsed_r = 0
530
595
  while ri < tag_end
531
596
  cb = xml.getbyte(ri)
532
597
  break unless cb.between?(48, 57)
533
598
 
534
- row_index = (row_index * 10) + (cb - 48)
599
+ parsed_r = (parsed_r * 10) + (cb - 48)
535
600
  ri += 1
536
-
537
601
  end
538
- row_index -= 1
539
- 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
540
604
  has_custom_attrs = true
541
605
  ri += 1
542
606
  else
@@ -544,6 +608,9 @@ module Xlsxrb
544
608
  end
545
609
  end
546
610
 
611
+ row_index ||= last_row_index + 1
612
+ last_row_index = row_index
613
+
547
614
  attrs = if has_custom_attrs
548
615
  row_tag = xml.byteslice(row_start, tag_end - row_start)
549
616
  extract_row_attrs(row_tag)
@@ -564,7 +631,12 @@ module Xlsxrb
564
631
  attrs[:height],
565
632
  attrs[:hidden] || false,
566
633
  attrs[:custom_height] || false,
567
- attrs[:outline_level]
634
+ attrs[:outline_level],
635
+ attrs[:style_index],
636
+ styles,
637
+ date1904,
638
+ attrs[:collapsed] || false,
639
+ pad_empty_cells
568
640
  )
569
641
  block.call(row_obj)
570
642
 
@@ -574,6 +646,191 @@ module Xlsxrb
574
646
 
575
647
  private_class_method :fast_scan_rows_direct
576
648
 
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
652
+ buffer = +""
653
+ buffer.force_encoding(Encoding::BINARY)
654
+ in_sheet_data = false
655
+ prefix = ""
656
+ row_start_pattern = "<row"
657
+ row_start_len = 4
658
+ row_end_tag = "</row>"
659
+ row_end_len = 6
660
+ sd_end_tag = "</sheetData>"
661
+ last_row_index = -1
662
+
663
+ push_chunk = lambda do |chunk|
664
+ return if in_sheet_data == :closed
665
+ return if chunk.nil? || chunk.empty?
666
+
667
+ buffer << chunk.b
668
+
669
+ unless in_sheet_data
670
+ sd_term = buffer.index("sheetData")
671
+ if sd_term.nil?
672
+ buffer.slice!(0...(buffer.bytesize - 32)) if buffer.bytesize > 64
673
+ return
674
+ end
675
+
676
+ sd_start = buffer.rindex("<", sd_term)
677
+ if sd_start.nil?
678
+ buffer.slice!(0...sd_term)
679
+ return
680
+ end
681
+
682
+ sd_open_end = buffer.index(">", sd_term)
683
+ return if sd_open_end.nil?
684
+
685
+ if buffer.getbyte(sd_open_end - 1) == 47
686
+ in_sheet_data = :closed
687
+ buffer.clear
688
+ return
689
+ end
690
+
691
+ prefix = buffer.byteslice(sd_start + 1, sd_term - (sd_start + 1))
692
+ row_start_pattern = "<#{prefix}row"
693
+ row_start_len = row_start_pattern.bytesize
694
+ row_end_tag = "</#{prefix}row>"
695
+ row_end_len = row_end_tag.bytesize
696
+ sd_end_tag = "</#{prefix}sheetData>"
697
+
698
+ buffer.slice!(0..sd_open_end)
699
+ in_sheet_data = true
700
+ end
701
+
702
+ pos = 0
703
+ last_consumed_pos = 0
704
+ sd_end_pos = buffer.index(sd_end_tag)
705
+
706
+ loop do
707
+ row_start = buffer.index(row_start_pattern, pos)
708
+
709
+ if sd_end_pos && (row_start.nil? || sd_end_pos < row_start)
710
+ in_sheet_data = :closed
711
+ buffer.clear
712
+ return
713
+ end
714
+
715
+ break if row_start.nil?
716
+
717
+ next_pos = row_start + row_start_len
718
+ break if next_pos >= buffer.bytesize
719
+
720
+ nb = buffer.getbyte(next_pos)
721
+ unless TAG_DELIM_BYTES.include?(nb)
722
+ pos = next_pos
723
+ next
724
+ end
725
+
726
+ tag_end = buffer.index(">", next_pos)
727
+ break if tag_end.nil?
728
+
729
+ if buffer.getbyte(tag_end - 1) == 47
730
+ pos = tag_end + 1
731
+ last_consumed_pos = pos
732
+ next
733
+ end
734
+
735
+ row_end = buffer.index(row_end_tag, tag_end + 1)
736
+ if row_end.nil?
737
+ raise ArgumentError, "Malformed XML: row size exceeds maximum buffer limit" if buffer.bytesize > 50 * 1024 * 1024
738
+
739
+ break
740
+ end
741
+
742
+ row_index = nil
743
+ has_custom_attrs = false
744
+ ri = row_start + row_start_len
745
+ while ri < tag_end
746
+ rb = buffer.getbyte(ri)
747
+ if rb == 114 && buffer.getbyte(ri + 1) == 61 && buffer.getbyte(ri + 2) == 34 # r="
748
+ ri += 3
749
+ parsed_r = 0
750
+ while ri < tag_end
751
+ cb = buffer.getbyte(ri)
752
+ break unless cb.between?(48, 57)
753
+
754
+ parsed_r = (parsed_r * 10) + (cb - 48)
755
+ ri += 1
756
+ end
757
+ row_index = parsed_r - 1
758
+ elsif ROW_ATTR_START_BYTES.include?(rb)
759
+ has_custom_attrs = true
760
+ ri += 1
761
+ else
762
+ ri += 1
763
+ end
764
+ end
765
+
766
+ row_index ||= last_row_index + 1
767
+ last_row_index = row_index
768
+
769
+ attrs = if has_custom_attrs
770
+ row_tag = buffer.byteslice(row_start, tag_end - row_start)
771
+ extract_row_attrs(row_tag)
772
+ else
773
+ EMPTY_HASH
774
+ end
775
+
776
+ cells_xml = buffer.byteslice(tag_end + 1, row_end - (tag_end + 1))
777
+ row_obj = StreamRow.fast_create(
778
+ row_index,
779
+ cells_xml,
780
+ 0,
781
+ cells_xml.bytesize,
782
+ shared_strings,
783
+ prefix,
784
+ attrs[:height],
785
+ attrs[:hidden] || false,
786
+ attrs[:custom_height] || false,
787
+ attrs[:outline_level],
788
+ attrs[:style_index],
789
+ styles,
790
+ date1904,
791
+ attrs[:collapsed] || false,
792
+ pad_empty_cells
793
+ )
794
+ block.call(row_obj)
795
+
796
+ pos = row_end + row_end_len
797
+ last_consumed_pos = pos
798
+ end
799
+
800
+ buffer.slice!(0...last_consumed_pos) if last_consumed_pos.positive?
801
+ end
802
+
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?
812
+
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}"
828
+ end
829
+ end
830
+ end
831
+
832
+ private_class_method :scan_rows_stream
833
+
577
834
  COL_LETTERS = (0...16_384).map do |index|
578
835
  result = +""
579
836
  i = index
@@ -589,8 +846,8 @@ module Xlsxrb
589
846
  [COL_LETTERS[idx], idx]
590
847
  end.freeze
591
848
 
592
- 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+)")?[^>]*/>}
593
- 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
594
851
  ATTR_R = /r="([A-Za-z0-9]+)"/
595
852
  ATTR_T = /t="([a-zA-Z]+)"/
596
853
  ATTR_S = /s="(\d+)"/
@@ -601,7 +858,9 @@ module Xlsxrb
601
858
  cells
602
859
  end
603
860
 
604
- 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
605
864
  chunk = xml.byteslice(from, to - from)
606
865
  row_idx = row_source.is_a?(Hash) ? row_source[:row] : row_source
607
866
  col_idx = 0
@@ -626,6 +885,8 @@ module Xlsxrb
626
885
  col_idx
627
886
  end
628
887
  col_idx = c_idx + 1
888
+ v = v.strip if v
889
+ f = f.strip if f
629
890
 
630
891
  val = if t == "s"
631
892
  shared_strings[v.to_i] || ""
@@ -635,7 +896,7 @@ module Xlsxrb
635
896
  if is
636
897
  extract_inline_text(is, 0, is.bytesize)
637
898
  elsif v
638
- v.include?("&") ? decode_xml_entities(v) : v
899
+ normalize_text(v)
639
900
  else
640
901
  ""
641
902
  end
@@ -643,8 +904,16 @@ module Xlsxrb
643
904
  v.include?(".") ? v.to_f : v.to_i
644
905
  end
645
906
 
646
- formula_expr = f&.include?("&") ? decode_xml_entities(f) : f
647
- 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)
648
917
  block.call(cell)
649
918
  end
650
919
 
@@ -670,6 +939,8 @@ module Xlsxrb
670
939
  col_idx
671
940
  end
672
941
  col_idx = c_idx + 1
942
+ v = v.strip if v
943
+ f = f.strip if f
673
944
 
674
945
  val = if t == "s"
675
946
  shared_strings[v.to_i] || ""
@@ -679,7 +950,7 @@ module Xlsxrb
679
950
  if is
680
951
  extract_inline_text(is, 0, is.bytesize)
681
952
  elsif v
682
- v.include?("&") ? decode_xml_entities(v) : v
953
+ normalize_text(v)
683
954
  else
684
955
  ""
685
956
  end
@@ -687,13 +958,145 @@ module Xlsxrb
687
958
  v.include?(".") ? v.to_f : v.to_i
688
959
  end
689
960
 
961
+ raw_val = if is
962
+ extract_inline_text(is, 0, is.bytesize)
963
+ elsif v
964
+ normalize_text(v)
965
+ end
966
+
690
967
  style_idx = s&.to_i
691
- formula_expr = f&.include?("&") ? decode_xml_entities(f) : f
692
- 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)
693
972
  block.call(cell)
694
973
  end
695
974
  end
696
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
+
697
1100
  private_class_method :fast_parse_cells_direct
698
1101
  end
699
1102
  end