xlsxrb 0.1.5 → 0.1.7

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +2332 -0
  4. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +4 -0
  5. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +9 -0
  6. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +8 -0
  7. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +42 -0
  8. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +5 -0
  9. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +47 -0
  10. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +30 -0
  11. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +9 -0
  12. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +7 -0
  13. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +19 -0
  14. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +31 -0
  15. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +18 -0
  16. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +12 -0
  17. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +11 -0
  18. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +13 -0
  19. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +95 -0
  20. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +31 -0
  21. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +58 -0
  22. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +24 -0
  23. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +17 -0
  24. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +23 -0
  25. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +22 -0
  26. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +30 -0
  27. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +20 -0
  28. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +15 -0
  29. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +26 -0
  30. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +21 -0
  31. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +131 -0
  32. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +14 -0
  33. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +33 -0
  34. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +21 -0
  35. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +63 -0
  36. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +55 -0
  37. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +35 -0
  38. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +7 -0
  39. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +10 -0
  40. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +22 -0
  41. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +29 -0
  42. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +17 -0
  43. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +13 -0
  44. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +10 -0
  45. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +8 -0
  46. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +6 -0
  47. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +30 -0
  48. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +10 -0
  49. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +10 -0
  50. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +5 -0
  51. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +15 -0
  52. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +3 -0
  53. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +40 -0
  54. data/CHANGELOG.md +28 -0
  55. data/README.md +66 -44
  56. data/Rakefile +2 -1
  57. data/Steepfile +1 -2
  58. data/benchmark.rb +376 -0
  59. data/docs/ARCHITECTURE.md +12 -5
  60. data/docs/visual/VisualGallery.md +8 -0
  61. data/docs/wasm/ruby.wasm +0 -0
  62. data/lib/ruby_lsp/xlsxrb/addon.rb +43 -0
  63. data/lib/ruby_lsp/xlsxrb/completion_listener.rb +769 -0
  64. data/lib/xlsxrb/elements/cell.rb +137 -11
  65. data/lib/xlsxrb/elements/column.rb +22 -0
  66. data/lib/xlsxrb/elements/row.rb +98 -5
  67. data/lib/xlsxrb/elements/types.rb +40 -5
  68. data/lib/xlsxrb/elements/workbook.rb +73 -5
  69. data/lib/xlsxrb/elements/worksheet.rb +110 -1
  70. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +104 -40
  71. data/lib/xlsxrb/ooxml/worksheet_parser.rb +144 -23
  72. data/lib/xlsxrb/ooxml/worksheet_writer.rb +160 -145
  73. data/lib/xlsxrb/ooxml/xml_builder.rb +16 -10
  74. data/lib/xlsxrb/ooxml/zip_reader.rb +104 -18
  75. data/lib/xlsxrb/ooxml/zip_writer.rb +63 -25
  76. data/lib/xlsxrb/style_builder.rb +158 -30
  77. data/lib/xlsxrb/version.rb +1 -1
  78. data/lib/xlsxrb.rb +892 -336
  79. data/rbs_collection.lock.yaml +28 -0
  80. data/sig/generated/xlsxrb/elements/cell.rbs +39 -0
  81. data/sig/generated/xlsxrb/elements/column.rbs +35 -0
  82. data/sig/generated/xlsxrb/elements/row.rbs +39 -0
  83. data/sig/generated/xlsxrb/elements/types.rbs +82 -0
  84. data/sig/generated/xlsxrb/elements/workbook.rbs +32 -0
  85. data/sig/generated/xlsxrb/elements/worksheet.rbs +34 -0
  86. data/sig/generated/xlsxrb/ooxml/reader.rbs +1412 -0
  87. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +21 -0
  88. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +44 -0
  89. data/sig/generated/xlsxrb/ooxml/utils.rbs +40 -0
  90. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +46 -0
  91. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +66 -0
  92. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +67 -0
  93. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +92 -0
  94. data/sig/generated/xlsxrb/ooxml/writer.rbs +880 -0
  95. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +45 -0
  96. data/sig/generated/xlsxrb/ooxml/xml_parser.rbs +30 -0
  97. data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +38 -0
  98. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +45 -0
  99. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +45 -0
  100. data/sig/generated/xlsxrb/style_builder.rbs +212 -50
  101. data/sig/generated/xlsxrb.rbs +1646 -0
  102. data/sig/rexml.rbs +4 -0
  103. metadata +78 -1
@@ -5,15 +5,33 @@
5
5
  module Xlsxrb
6
6
  module Elements
7
7
  # Represents a single worksheet in a workbook.
8
+ #
9
+ # @example Access cells and rows
10
+ # sheet = workbook.sheet(0)
11
+ # cell = sheet["A1"]
12
+ # sheet.each_row { |row| puts row.to_a.inspect }
13
+ #
14
+ # @api public
8
15
  Worksheet = Data.define(:name, :rows, :columns, :charts, :unmapped_data, :errors) do
9
16
  include Enumerable
10
17
 
18
+ # @param name [String] The worksheet name (max 31 characters).
19
+ # @param rows [Array<Elements::Row>] Rows in the sheet.
20
+ # @param columns [Array<Elements::Column>] Column definitions.
21
+ # @param charts [Array<Hash>] Charts in the sheet.
22
+ # @param unmapped_data [Hash] Additional metadata for round-tripping.
23
+ # @param errors [Array<String>, nil] Validation errors.
24
+ #: (name: String, ?rows: Array[Elements::Row], ?columns: Array[Elements::Column], ?charts: Array[Hash[Symbol, untyped]], ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
11
25
  def initialize(name:, rows: [], columns: [], charts: [], unmapped_data: {}, errors: nil)
12
26
  computed_errors = errors || self.class.validate(name, rows)
13
27
  super(name: name, rows: rows.freeze, columns: columns.freeze, charts: charts.freeze,
14
28
  unmapped_data: unmapped_data, errors: computed_errors.freeze)
15
29
  end
16
30
 
31
+ # Returns a Hash mapping Excel cell references (e.g. "A1") to Cell objects.
32
+ #
33
+ # @return [Hash<String, Elements::Cell>]
34
+ #: () -> Hash[String, Elements::Cell]
17
35
  def cells_hash
18
36
  h = {}
19
37
  rows.each do |r|
@@ -25,51 +43,131 @@ module Xlsxrb
25
43
  h
26
44
  end
27
45
 
46
+ # Returns all cells ordered by row and column index.
47
+ #
48
+ # @return [Array<Elements::Cell>]
49
+ # @api public
50
+ #: () -> Array[Elements::Cell]
28
51
  def cells
29
52
  # Ensure ordered traversal
30
53
  cells_hash.values.sort_by { |c| [c.row_index, c.column_index] }
31
54
  end
32
55
 
56
+ # Access a cell by its Excel-style reference (e.g. "A1").
57
+ #
58
+ # @example
59
+ # sheet["A1"] #=> #<Elements::Cell value="Hello">
60
+ #
61
+ # @param ref [String, Symbol] Cell reference (e.g. "A1" or :A1).
62
+ # @return [Elements::Cell, nil]
63
+ # @api public
64
+ #: (String | Symbol ref) -> Elements::Cell?
33
65
  def [](ref)
34
66
  cells_hash[ref.to_s.upcase]
35
67
  end
36
68
 
69
+ # Iterate over cells in the worksheet.
70
+ #
71
+ # @example
72
+ # sheet.each do |cell|
73
+ # puts cell.value
74
+ # end
75
+ #
76
+ # @yield [cell]
77
+ # @yieldparam cell [Elements::Cell]
78
+ # @return [Enumerator, void]
79
+ # @api public
80
+ #: () { (Elements::Cell) -> void } -> void
81
+ #: | () -> Enumerator[Elements::Cell, void]
37
82
  def each(&)
38
83
  return to_enum(:each) unless block_given?
39
84
 
40
85
  cells.each(&)
41
86
  end
42
87
 
88
+ # Iterate over cells in the worksheet.
89
+ #
90
+ # @example
91
+ # sheet.each_cell do |cell|
92
+ # puts "#{cell.ref}: #{cell.value}"
93
+ # end
94
+ #
95
+ # @yield [cell]
96
+ # @yieldparam cell [Elements::Cell]
97
+ # @return [Enumerator, void]
98
+ # @api public
99
+ #: () { (Elements::Cell) -> void } -> void
100
+ #: | () -> Enumerator[Elements::Cell, void]
43
101
  def each_cell(&)
44
102
  return to_enum(:each_cell) unless block_given?
45
103
 
46
104
  cells.each(&)
47
105
  end
48
106
 
107
+ # Iterate over rows in the worksheet.
108
+ #
109
+ # @example
110
+ # sheet.each_row do |row|
111
+ # puts "Row #{row.index}: #{row.to_a.inspect}"
112
+ # end
113
+ #
114
+ # @yield [row]
115
+ # @yieldparam row [Elements::Row]
116
+ # @return [Enumerator, void]
117
+ # @api public
118
+ #: () { (Elements::Row) -> void } -> void
119
+ #: | () -> Enumerator[Elements::Row, void]
49
120
  def each_row(&)
50
121
  return to_enum(:each_row) unless block_given?
51
122
 
52
123
  rows.each(&)
53
124
  end
54
125
 
126
+ # Returns whether the worksheet is valid according to OOXML specifications.
127
+ #
128
+ # @return [Boolean]
129
+ #: () -> bool
55
130
  def valid?
56
131
  errors.empty?
57
132
  end
58
133
 
59
134
  # Returns the row at the given 0-based index, or nil.
135
+ #
136
+ # @param index [Integer] 0-based row index.
137
+ # @return [Elements::Row, nil]
138
+ # @api public
139
+ #: (Integer index) -> Elements::Row?
60
140
  def row_at(index)
61
141
  rows.find { |r| r.index == index }
62
142
  end
63
143
 
144
+ # Returns the first row in the sheet, or nil.
145
+ #
146
+ # @return [Elements::Row, nil]
147
+ # @api public
148
+ #: () -> Elements::Row?
64
149
  def first_row
65
150
  rows.min_by(&:index)
66
151
  end
67
152
 
153
+ # Returns the last row in the sheet, or nil.
154
+ #
155
+ # @return [Elements::Row, nil]
156
+ # @api public
157
+ #: () -> Elements::Row?
68
158
  def last_row
69
159
  rows.max_by(&:index)
70
160
  end
71
161
 
72
- # Returns cell value at Excel-style reference (e.g. "A1").
162
+ # Returns the raw cell value at the given Excel-style reference (e.g. "A1").
163
+ #
164
+ # @example
165
+ # sheet.cell_value("A1") #=> "Sales Report"
166
+ #
167
+ # @param ref [String] Cell reference (e.g. "A1").
168
+ # @return [Object, nil]
169
+ # @api public
170
+ #: (String ref) -> untyped
73
171
  def cell_value(ref)
74
172
  parsed = Cell.parse_ref(ref)
75
173
  return nil unless parsed
@@ -84,11 +182,16 @@ module Xlsxrb
84
182
 
85
183
  # Returns a new Worksheet with the specified cell updated.
86
184
  #
185
+ # @example
186
+ # new_sheet = sheet.update_cell("B1", value: "Updated")
187
+ #
87
188
  # @param ref [String] The cell reference (e.g. "B1").
88
189
  # @param value [Object] The new cell value.
89
190
  # @param style_index [Integer, String, nil] Optional new style index.
90
191
  # @param formula [Elements::Formula, nil] Optional new formula.
91
192
  # @return [Worksheet] A new Worksheet instance.
193
+ # @api public
194
+ #: (String ref, ?value: untyped, ?style_index: Integer | String | nil, ?formula: Elements::Formula?) -> Elements::Worksheet
92
195
  def update_cell(ref, value: nil, style_index: nil, formula: nil)
93
196
  parsed = Cell.parse_ref(ref)
94
197
  raise ArgumentError, "invalid cell reference: #{ref}" unless parsed
@@ -124,6 +227,12 @@ module Xlsxrb
124
227
  with(rows: new_rows)
125
228
  end
126
229
 
230
+ # Validates worksheet name and rows against OOXML limits.
231
+ #
232
+ # @param name [String]
233
+ # @param rows [Array<Elements::Row>]
234
+ # @return [Array<String>] List of errors.
235
+ #: (untyped name, untyped rows) -> Array[String]
127
236
  def self.validate(name, rows)
128
237
  errs = []
129
238
  if name.nil? || !name.is_a?(String) || name.empty?
@@ -9,13 +9,20 @@ module Xlsxrb
9
9
  # SAX-based parser for xl/sharedStrings.xml.
10
10
  # Returns an Array of strings (index = SST index).
11
11
  class SharedStringsParser
12
+ XML_ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"', "&apos;" => "'" }.freeze
13
+
12
14
  # Parses all shared strings and returns an Array of strings.
13
- def self.parse(xml_string, part_name: "xl/sharedStrings.xml")
15
+ def self.parse(xml_string, _part_name: "xl/sharedStrings.xml")
14
16
  return [] if xml_string.nil? || xml_string.empty?
15
17
 
18
+ if xml_string.include?("<!DOCTYPE") || xml_string.include?("<!ENTITY")
19
+ listener = Class.new { include REXML::SAX2Listener }.new
20
+ XmlParser.parse(xml_string, listener)
21
+ end
22
+
16
23
  strings = []
17
- each_event(xml_string, part_name: part_name) do |event|
18
- strings << event.args[0] if event.type == :sst_item
24
+ fast_scan(xml_string) do |str|
25
+ strings << str
19
26
  end
20
27
  strings
21
28
  end
@@ -25,53 +32,110 @@ module Xlsxrb
25
32
  return enum_for(:each_event, xml_string, part_name: part_name) unless block
26
33
  return if xml_string.nil? || xml_string.empty?
27
34
 
28
- listener = EventListener.new(part_name, &block)
29
- XmlParser.parse(xml_string, listener)
35
+ index = 0
36
+ fast_scan(xml_string) do |str|
37
+ block.call(Event.new(
38
+ type: :sst_item,
39
+ args: [str],
40
+ source: { part: part_name, index: index }
41
+ ))
42
+ index += 1
43
+ end
30
44
  end
31
45
 
32
- # SAX listener for generating events from shared string table.
33
- class EventListener
34
- include REXML::SAX2Listener
35
-
36
- def initialize(part_name, &block)
37
- @part_name = part_name
38
- @block = block
39
- @in_si = false
40
- @in_t = false
41
- @current_text = +""
42
- @index = 0
43
- end
46
+ def self.fast_scan(xml_src)
47
+ xml = xml_src.b
48
+ sst_start = xml.index("<sst")
49
+ return unless sst_start
44
50
 
45
- def start_element(_uri, localname, _qname, _attrs)
46
- case localname
47
- when "si"
48
- @in_si = true
49
- @current_text = +""
50
- when "t"
51
- @in_t = true
51
+ sst_open_end = xml.index(">", sst_start)
52
+ return unless sst_open_end
53
+ return if xml.getbyte(sst_open_end - 1) == 47 # self-closing <sst/>
54
+
55
+ sst_end = xml.index("</sst>", sst_open_end)
56
+ sst_end ||= xml.bytesize
57
+
58
+ pos = sst_open_end + 1
59
+ while pos < sst_end
60
+ si_start = xml.index("<si", pos)
61
+ break unless si_start && si_start < sst_end
62
+
63
+ si_open_end = xml.index(">", si_start + 3)
64
+ break unless si_open_end
65
+
66
+ # Self-closing <si/>
67
+ if xml.getbyte(si_open_end - 1) == 47
68
+ yield ""
69
+ pos = si_open_end + 1
70
+ next
52
71
  end
53
- end
54
72
 
55
- def end_element(_uri, localname, _qname)
56
- case localname
57
- when "si"
58
- @in_si = false
59
- frozen_str = @current_text.freeze
60
- @block.call(Event.new(
61
- type: :sst_item,
62
- args: [frozen_str],
63
- source: { part: @part_name, index: @index }
64
- ))
65
- @index += 1
66
- when "t"
67
- @in_t = false
73
+ si_end = xml.index("</si>", si_open_end + 1)
74
+ break unless si_end
75
+
76
+ # Fast path: check for simple single <t>...</t> inside <si>
77
+ first_t = xml.index("<t", si_open_end + 1)
78
+ if first_t && first_t < si_end
79
+ t_open_end = xml.index(">", first_t + 2)
80
+ if t_open_end && t_open_end < si_end
81
+ if xml.getbyte(t_open_end - 1) == 47 # <t/>
82
+ str = ""
83
+ else
84
+ t_end = xml.index("</t>", t_open_end + 1)
85
+ if t_end && t_end <= si_end
86
+ next_t = xml.index("<t", t_end + 4)
87
+ if next_t && next_t < si_end
88
+ # Multiple <t> tags (rich text runs)
89
+ str = extract_multi_t(xml, si_open_end + 1, si_end)
90
+ else
91
+ raw_str = xml.byteslice(t_open_end + 1, t_end - t_open_end - 1).force_encoding("UTF-8")
92
+ str = raw_str.include?("&") ? raw_str.gsub(/&(?:amp|lt|gt|quot|apos);/, XML_ENTITIES) : raw_str
93
+ end
94
+ else
95
+ str = ""
96
+ end
97
+ end
98
+ else
99
+ str = ""
100
+ end
101
+ else
102
+ str = ""
68
103
  end
104
+
105
+ yield str.freeze
106
+ pos = si_end + 5
69
107
  end
108
+ end
109
+
110
+ private_class_method :fast_scan
111
+
112
+ def self.extract_multi_t(xml, from, to)
113
+ buf = +""
114
+ pos = from
115
+ while pos < to
116
+ t_start = xml.index("<t", pos)
117
+ break unless t_start && t_start < to
70
118
 
71
- def characters(text)
72
- @current_text << text if @in_si && @in_t
119
+ t_open_end = xml.index(">", t_start + 2)
120
+ break unless t_open_end
121
+
122
+ if xml.getbyte(t_open_end - 1) == 47
123
+ pos = t_open_end + 1
124
+ next
125
+ end
126
+
127
+ t_end = xml.index("</t>", t_open_end + 1)
128
+ break unless t_end && t_end <= to
129
+
130
+ raw_chunk = xml.byteslice(t_open_end + 1, t_end - t_open_end - 1).force_encoding("UTF-8")
131
+ raw_chunk = raw_chunk.gsub(/&(?:amp|lt|gt|quot|apos);/, XML_ENTITIES) if raw_chunk.include?("&")
132
+ buf << raw_chunk
133
+ pos = t_end + 4
73
134
  end
135
+ buf
74
136
  end
137
+
138
+ private_class_method :extract_multi_t
75
139
  end
76
140
  end
77
141
  end
@@ -393,8 +393,7 @@ module Xlsxrb
393
393
  if raw.include?(".")
394
394
  raw.to_f
395
395
  else
396
- int_val = raw.to_i
397
- int_val.to_s == raw ? int_val : raw.to_f
396
+ raw.to_i
398
397
  end
399
398
  end
400
399
  end
@@ -482,19 +481,53 @@ module Xlsxrb
482
481
  next
483
482
  end
484
483
 
485
- row_tag = xml.byteslice(row_start, tag_end - row_start)
486
484
  row_index = 0
487
- r_val = tag_attr(row_tag, ' r="')
488
- row_index = r_val.to_i - 1 if r_val
485
+ has_custom_attrs = false
486
+ ri = row_start + 4
487
+ while ri < tag_end
488
+ rb = xml.getbyte(ri)
489
+ if rb == 114 && xml.getbyte(ri + 1) == 61 && xml.getbyte(ri + 2) == 34 # r="
490
+ ri += 3
491
+ while ri < tag_end
492
+ cb = xml.getbyte(ri)
493
+ break unless cb.between?(48, 57)
494
+
495
+ row_index = (row_index * 10) + (cb - 48)
496
+ ri += 1
497
+
498
+ end
499
+ row_index -= 1
500
+ elsif [104, 99, 111].include?(rb) # 'h', 'c', 'o' for ht, customHeight, hidden, outlineLevel
501
+ has_custom_attrs = true
502
+ ri += 1
503
+ else
504
+ ri += 1
505
+ end
506
+ end
507
+
508
+ attrs = if has_custom_attrs
509
+ row_tag = xml.byteslice(row_start, tag_end - row_start)
510
+ extract_row_attrs(row_tag)
511
+ else
512
+ EMPTY_HASH
513
+ end
489
514
 
490
- attrs = extract_row_attrs(row_tag)
491
515
  row_end = xml.index("</row>", tag_end + 1)
492
516
  break unless row_end
493
517
 
494
518
  row_source = { part: part_name, row: row_index }
495
519
  cells = fast_parse_cells_direct(xml, tag_end + 1, row_end, shared_strings, row_source)
496
520
 
497
- block.call({ index: row_index, cells: cells, attrs: attrs, unmapped: EMPTY_ARRAY, source: row_source })
521
+ row_obj = Elements::Row.new(
522
+ index: row_index,
523
+ cells: cells,
524
+ height: attrs[:height],
525
+ hidden: attrs[:hidden] || false,
526
+ custom_height: attrs[:custom_height] || false,
527
+ outline_level: attrs[:outline_level],
528
+ errors: Elements::EMPTY_ERRORS
529
+ )
530
+ block.call(row_obj)
498
531
 
499
532
  pos = row_end + 6
500
533
  end
@@ -519,18 +552,90 @@ module Xlsxrb
519
552
  c_tag_end = xml.index(">", c_start + 2)
520
553
  break unless c_tag_end
521
554
 
522
- # Extract tag substring for bounded attribute search
523
- c_tag = xml.byteslice(c_start, c_tag_end - c_start)
524
- ref = tag_attr(c_tag, ' r="')
525
- type = tag_attr(c_tag, ' t="')
526
- style_str = tag_attr(c_tag, ' s="')
527
- style_index = style_str&.to_i
555
+ # Zero-allocation attribute scan directly within the tag
556
+ type = nil
557
+ style_index = nil
558
+ col_idx = nil
559
+ row_idx = nil
560
+
561
+ ai = c_start + 2
562
+ while ai < c_tag_end
563
+ b = xml.getbyte(ai)
564
+ if [32, 9, 10, 13].include?(b)
565
+ ai += 1
566
+ next
567
+ end
568
+
569
+ if b == 114 && xml.getbyte(ai + 1) == 61 && xml.getbyte(ai + 2) == 34 # r="
570
+ ai += 3
571
+ col_idx = 0
572
+ while ai < c_tag_end
573
+ cb = xml.getbyte(ai)
574
+ if cb.between?(65, 90)
575
+ col_idx = (col_idx * 26) + (cb - 64)
576
+ ai += 1
577
+ elsif cb.between?(97, 122)
578
+ col_idx = (col_idx * 26) + (cb - 96)
579
+ ai += 1
580
+ else
581
+ break
582
+ end
583
+ end
584
+ col_idx -= 1
585
+ row_idx = 0
586
+ while ai < c_tag_end
587
+ cb = xml.getbyte(ai)
588
+ break unless cb.between?(48, 57)
589
+
590
+ row_idx = (row_idx * 10) + (cb - 48)
591
+ ai += 1
592
+
593
+ end
594
+ row_idx -= 1
595
+ ai += 1 if xml.getbyte(ai) == 34
596
+ elsif b == 116 && xml.getbyte(ai + 1) == 61 && xml.getbyte(ai + 2) == 34 # t="
597
+ ai += 3
598
+ type_b = xml.getbyte(ai)
599
+ if type_b == 115 && xml.getbyte(ai + 1) == 34 # "s"
600
+ type = "s"
601
+ ai += 2
602
+ elsif type_b == 98 && xml.getbyte(ai + 1) == 34 # "b"
603
+ type = "b"
604
+ ai += 2
605
+ elsif type_b == 101 && xml.getbyte(ai + 1) == 34 # "e"
606
+ type = "e"
607
+ ai += 2
608
+ else
609
+ t_end = xml.index('"', ai)
610
+ type = xml.byteslice(ai, t_end - ai) if t_end
611
+ ai = t_end ? t_end + 1 : c_tag_end
612
+ end
613
+ elsif b == 115 && xml.getbyte(ai + 1) == 61 && xml.getbyte(ai + 2) == 34 # s="
614
+ ai += 3
615
+ style_index = 0
616
+ while ai < c_tag_end
617
+ cb = xml.getbyte(ai)
618
+ break unless cb.between?(48, 57)
619
+
620
+ style_index = (style_index * 10) + (cb - 48)
621
+ ai += 1
622
+
623
+ end
624
+ ai += 1 if xml.getbyte(ai) == 34
625
+ else
626
+ ai += 1
627
+ end
628
+ end
528
629
 
529
630
  # Self-closing <c ... />
530
631
  if xml.getbyte(c_tag_end - 1) == 47
531
- cell_source = row_source.dup
532
- cell_source[:cell] = ref
533
- cells << { ref: ref, type: type, style_index: style_index, value: nil, source: cell_source }
632
+ cells << Elements::Cell.new(
633
+ row_index: row_idx || row_source[:row],
634
+ column_index: col_idx || cells.size,
635
+ value: nil,
636
+ style_index: style_index,
637
+ errors: Elements::EMPTY_ERRORS
638
+ )
534
639
  pos = c_tag_end + 1
535
640
  next
536
641
  end
@@ -554,8 +659,21 @@ module Xlsxrb
554
659
  v_val_start = tag_pos + 3
555
660
  v_end = xml.index("</v>", v_val_start)
556
661
  if v_end
557
- raw_value = xml.byteslice(v_val_start, v_end - v_val_start)
558
- value = resolve_fast_value(raw_value, type, shared_strings)
662
+ if type == "s"
663
+ # Zero-allocation integer parse for SST index
664
+ s_idx = 0
665
+ v_i = v_val_start
666
+ while v_i < v_end
667
+ s_idx = (s_idx * 10) + (xml.getbyte(v_i) - 48)
668
+ v_i += 1
669
+ end
670
+ value = shared_strings[s_idx] || ""
671
+ elsif type == "b"
672
+ value = xml.getbyte(v_val_start) == 49 # '1'
673
+ else
674
+ raw_value = xml.byteslice(v_val_start, v_end - v_val_start)
675
+ value = resolve_fast_value(raw_value, type, shared_strings)
676
+ end
559
677
  cpos = v_end + 4
560
678
  else
561
679
  cpos = tag_pos + 3
@@ -603,11 +721,14 @@ module Xlsxrb
603
721
  end
604
722
 
605
723
  val_to_use = inline_str || value
606
- cell_source = row_source.dup
607
- cell_source[:cell] = ref
608
- cell = { ref: ref, type: type, style_index: style_index, value: val_to_use, source: cell_source }
609
- cell[:formula] = formula if formula
610
- cells << cell
724
+ cells << Elements::Cell.new(
725
+ row_index: row_idx || row_source[:row],
726
+ column_index: col_idx || cells.size,
727
+ value: val_to_use,
728
+ formula: formula,
729
+ style_index: style_index,
730
+ errors: Elements::EMPTY_ERRORS
731
+ )
611
732
 
612
733
  pos = c_end + 4
613
734
  end