xlsxrb 0.1.6 → 0.1.8

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.
@@ -4,91 +4,110 @@
4
4
 
5
5
  module Xlsxrb
6
6
  module Elements
7
- # Represents a single worksheet in a workbook.
8
- Worksheet = Data.define(:name, :rows, :columns, :charts, :unmapped_data, :errors) do
9
- include Enumerable
10
-
7
+ # Represents a single fully parsed, in-memory worksheet in a workbook.
8
+ # Provides coordinate random access (sheet["A1"]), row lookups (row_at),
9
+ # and immutable cell updates (update_cell).
10
+ #
11
+ # @example Access cells and rows
12
+ # sheet = workbook.sheet(0).load
13
+ # cell = sheet["A1"]
14
+ # row = sheet.row_at(0)
15
+ #
16
+ # @api public
17
+ class Worksheet
18
+ [Enumerable, CoordinateAccess].each { |m| include m }
19
+
20
+ attr_reader :name, :rows, :columns, :charts, :unmapped_data, :errors
21
+
22
+ # @param name [String] The worksheet name (max 31 characters).
23
+ # @param rows [Array<Elements::Row>] Rows in the sheet.
24
+ # @param columns [Array<Elements::Column>] Column definitions.
25
+ # @param charts [Array<Hash>] Charts in the sheet.
26
+ # @param unmapped_data [Hash] Additional metadata for round-tripping.
27
+ # @param errors [Array<String>, nil] Validation errors.
28
+ #: (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
29
  def initialize(name:, rows: [], columns: [], charts: [], unmapped_data: {}, errors: nil)
12
- computed_errors = errors || self.class.validate(name, rows)
13
- super(name: name, rows: rows.freeze, columns: columns.freeze, charts: charts.freeze,
14
- unmapped_data: unmapped_data, errors: computed_errors.freeze)
15
- end
16
-
17
- def cells_hash
18
- h = {}
19
- rows.each do |r|
20
- r.cells.each do |c|
21
- ref = "#{Cell.column_letter(c.column_index)}#{c.row_index + 1}"
22
- h[ref] = c
23
- end
24
- end
25
- h
26
- end
27
-
28
- def cells
29
- # Ensure ordered traversal
30
- cells_hash.values.sort_by { |c| [c.row_index, c.column_index] }
31
- end
32
-
33
- def [](ref)
34
- cells_hash[ref.to_s.upcase]
30
+ @name = name
31
+ @rows = (rows || []).freeze
32
+ @columns = (columns || []).freeze
33
+ @charts = (charts || []).freeze
34
+ @unmapped_data = (unmapped_data || {}).freeze
35
+ computed_errors = errors || self.class.validate(@name, @rows)
36
+ @errors = computed_errors.freeze
35
37
  end
36
38
 
39
+ # Iterate over rows in the worksheet.
40
+ #
41
+ # @example
42
+ # sheet.each do |row|
43
+ # puts row.to_a.inspect
44
+ # end
45
+ #
46
+ # @yield [row]
47
+ # @yieldparam row [Elements::Row]
48
+ # @return [Enumerator, void]
49
+ # @api public
50
+ #: () { (Elements::Row) -> void } -> void
51
+ #: | () -> Enumerator[Elements::Row, void]
37
52
  def each(&)
38
53
  return to_enum(:each) unless block_given?
39
54
 
40
- cells.each(&)
41
- end
42
-
43
- def each_cell(&)
44
- return to_enum(:each_cell) unless block_given?
45
-
46
- cells.each(&)
55
+ rows.each(&)
47
56
  end
48
57
 
58
+ # Iterate over rows in the worksheet.
59
+ #
60
+ # @example
61
+ # sheet.each_row do |row|
62
+ # puts "Row #{row.index}: #{row.to_a.inspect}"
63
+ # end
64
+ #
65
+ # @yield [row]
66
+ # @yieldparam row [Elements::Row]
67
+ # @return [Enumerator, void]
68
+ # @api public
69
+ #: () { (Elements::Row) -> void } -> void
70
+ #: | () -> Enumerator[Elements::Row, void]
49
71
  def each_row(&)
50
72
  return to_enum(:each_row) unless block_given?
51
73
 
52
74
  rows.each(&)
53
75
  end
54
76
 
55
- def valid?
56
- errors.empty?
57
- end
58
-
59
- # Returns the row at the given 0-based index, or nil.
60
- def row_at(index)
61
- rows.find { |r| r.index == index }
62
- end
63
-
64
- def first_row
65
- rows.min_by(&:index)
66
- end
77
+ # Iterate over all cells across rows.
78
+ #
79
+ # @yield [cell]
80
+ # @yieldparam cell [Elements::Cell]
81
+ # @return [Enumerator, void]
82
+ # @api public
83
+ #: () { (Elements::Cell) -> void } -> void
84
+ #: | () -> Enumerator[Elements::Cell, void]
85
+ def each_cell(&)
86
+ return to_enum(:each_cell) unless block_given?
67
87
 
68
- def last_row
69
- rows.max_by(&:index)
88
+ cells.each(&)
70
89
  end
71
90
 
72
- # Returns cell value at Excel-style reference (e.g. "A1").
73
- def cell_value(ref)
74
- parsed = Cell.parse_ref(ref)
75
- return nil unless parsed
76
-
77
- row_idx, col_idx = parsed
78
- row = row_at(row_idx)
79
- return nil unless row
80
-
81
- cell = row.cell_at(col_idx)
82
- cell&.value
91
+ # Returns whether the worksheet is valid according to OOXML specifications.
92
+ #
93
+ # @return [Boolean]
94
+ #: () -> bool
95
+ def valid?
96
+ errors.empty?
83
97
  end
84
98
 
85
99
  # Returns a new Worksheet with the specified cell updated.
86
100
  #
101
+ # @example
102
+ # new_sheet = sheet.update_cell("B1", value: "Updated")
103
+ #
87
104
  # @param ref [String] The cell reference (e.g. "B1").
88
105
  # @param value [Object] The new cell value.
89
106
  # @param style_index [Integer, String, nil] Optional new style index.
90
107
  # @param formula [Elements::Formula, nil] Optional new formula.
91
108
  # @return [Worksheet] A new Worksheet instance.
109
+ # @api public
110
+ #: (String ref, ?value: untyped, ?style_index: Integer | String | nil, ?formula: Elements::Formula?) -> Elements::Worksheet
92
111
  def update_cell(ref, value: nil, style_index: nil, formula: nil)
93
112
  parsed = Cell.parse_ref(ref)
94
113
  raise ArgumentError, "invalid cell reference: #{ref}" unless parsed
@@ -124,6 +143,66 @@ module Xlsxrb
124
143
  with(rows: new_rows)
125
144
  end
126
145
 
146
+ # Returns a new Worksheet with attributes replaced (Data-like behavior).
147
+ #
148
+ # @param changes [Hash]
149
+ # @return [Worksheet]
150
+ # @api public
151
+ #: (**untyped) -> Elements::Worksheet
152
+ def with(**changes)
153
+ new_name = changes.key?(:name) ? changes[:name] : name
154
+ new_rows = changes.key?(:rows) ? changes[:rows] : rows
155
+ new_cols = changes.key?(:columns) ? changes[:columns] : columns
156
+ new_charts = changes.key?(:charts) ? changes[:charts] : charts
157
+ new_unmapped = changes.key?(:unmapped_data) ? changes[:unmapped_data] : unmapped_data
158
+ new_errors = changes.key?(:errors) ? changes[:errors] : errors
159
+
160
+ self.class.new(
161
+ name: new_name,
162
+ rows: new_rows,
163
+ columns: new_cols,
164
+ charts: new_charts,
165
+ unmapped_data: new_unmapped,
166
+ errors: new_errors
167
+ )
168
+ end
169
+
170
+ # Support pattern matching.
171
+ #: (Array[Symbol]?) -> Hash[Symbol, untyped]
172
+ def deconstruct_keys(_keys)
173
+ { name: name, rows: rows, columns: columns, charts: charts, unmapped_data: unmapped_data, errors: errors }
174
+ end
175
+
176
+ # Compare worksheets for equality.
177
+ #: (untyped other) -> bool
178
+ def ==(other)
179
+ return false unless other.is_a?(Worksheet)
180
+
181
+ name == other.name && rows == other.rows && columns == other.columns && charts == other.charts
182
+ end
183
+ alias eql? ==
184
+
185
+ #: () -> Integer
186
+ def hash
187
+ [self.class, name, rows, columns, charts].hash
188
+ end
189
+
190
+ # Returns self when load is called on an already in-memory Worksheet.
191
+ #
192
+ # @return [Elements::Worksheet]
193
+ # @api public
194
+ #: () -> Elements::Worksheet
195
+ def load
196
+ self
197
+ end
198
+ alias to_worksheet load
199
+
200
+ # Validates worksheet name and rows against OOXML limits.
201
+ #
202
+ # @param name [String]
203
+ # @param rows [Array<Elements::Row>]
204
+ # @return [Array<String>] List of errors.
205
+ #: (untyped name, untyped rows) -> Array[String]
127
206
  def self.validate(name, rows)
128
207
  errs = []
129
208
  if name.nil? || !name.is_a?(String) || name.empty?
@@ -6,6 +6,7 @@ require_relative "elements/types"
6
6
  require_relative "elements/cell"
7
7
  require_relative "elements/row"
8
8
  require_relative "elements/column"
9
+ require_relative "elements/coordinate_access"
9
10
  require_relative "elements/worksheet"
10
11
  require_relative "elements/workbook"
11
12
 
@@ -515,17 +515,17 @@ module Xlsxrb
515
515
  row_end = xml.index("</row>", tag_end + 1)
516
516
  break unless row_end
517
517
 
518
- row_source = { part: part_name, row: row_index }
519
- cells = fast_parse_cells_direct(xml, tag_end + 1, row_end, shared_strings, row_source)
520
-
521
- row_obj = Elements::Row.new(
518
+ { part: part_name, row: row_index }
519
+ row_obj = StreamRow.new(
522
520
  index: row_index,
523
- cells: cells,
521
+ xml_bytes: xml,
522
+ from: tag_end + 1,
523
+ to: row_end,
524
+ shared_strings: shared_strings,
524
525
  height: attrs[:height],
525
526
  hidden: attrs[:hidden] || false,
526
527
  custom_height: attrs[:custom_height] || false,
527
- outline_level: attrs[:outline_level],
528
- errors: Elements::EMPTY_ERRORS
528
+ outline_level: attrs[:outline_level]
529
529
  )
530
530
  block.call(row_obj)
531
531
 
@@ -537,7 +537,13 @@ module Xlsxrb
537
537
 
538
538
  def self.fast_parse_cells_direct(xml, from, to, shared_strings, row_source)
539
539
  cells = []
540
+ fast_scan_cells_direct(xml, from, to, shared_strings, row_source) { |c| cells << c }
541
+ cells
542
+ end
543
+
544
+ def self.fast_scan_cells_direct(xml, from, to, shared_strings, row_source, &block)
540
545
  pos = from
546
+ cell_count = 0
541
547
 
542
548
  while pos < to
543
549
  c_start = xml.index("<c", pos)
@@ -589,7 +595,6 @@ module Xlsxrb
589
595
 
590
596
  row_idx = (row_idx * 10) + (cb - 48)
591
597
  ai += 1
592
-
593
598
  end
594
599
  row_idx -= 1
595
600
  ai += 1 if xml.getbyte(ai) == 34
@@ -619,7 +624,6 @@ module Xlsxrb
619
624
 
620
625
  style_index = (style_index * 10) + (cb - 48)
621
626
  ai += 1
622
-
623
627
  end
624
628
  ai += 1 if xml.getbyte(ai) == 34
625
629
  else
@@ -629,13 +633,15 @@ module Xlsxrb
629
633
 
630
634
  # Self-closing <c ... />
631
635
  if xml.getbyte(c_tag_end - 1) == 47
632
- cells << Elements::Cell.new(
636
+ cell_obj = Elements::Cell.new(
633
637
  row_index: row_idx || row_source[:row],
634
- column_index: col_idx || cells.size,
638
+ column_index: col_idx || cell_count,
635
639
  value: nil,
636
640
  style_index: style_index,
637
641
  errors: Elements::EMPTY_ERRORS
638
642
  )
643
+ cell_count += 1
644
+ block.call(cell_obj)
639
645
  pos = c_tag_end + 1
640
646
  next
641
647
  end
@@ -721,19 +727,19 @@ module Xlsxrb
721
727
  end
722
728
 
723
729
  val_to_use = inline_str || value
724
- cells << Elements::Cell.new(
730
+ cell_obj = Elements::Cell.new(
725
731
  row_index: row_idx || row_source[:row],
726
- column_index: col_idx || cells.size,
732
+ column_index: col_idx || cell_count,
727
733
  value: val_to_use,
728
734
  formula: formula,
729
735
  style_index: style_index,
730
736
  errors: Elements::EMPTY_ERRORS
731
737
  )
738
+ cell_count += 1
739
+ block.call(cell_obj)
732
740
 
733
741
  pos = c_end + 4
734
742
  end
735
-
736
- cells
737
743
  end
738
744
 
739
745
  private_class_method :fast_parse_cells_direct
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module Xlsxrb
6
+ # Streaming row implementation that parses cells on-demand / lazily.
7
+ # Provides O(1) memory consumption even for rows with tens of thousands of columns.
8
+ #
9
+ # @example Streaming cells one-by-one (O(1) memory)
10
+ # row.each_cell do |cell|
11
+ # puts "#{cell.ref}: #{cell.value}"
12
+ # end
13
+ #
14
+ # @example Random access or array conversion (cached on-demand)
15
+ # cell = row[0]
16
+ # values = row.to_a
17
+ #
18
+ # @api public
19
+ class StreamRow
20
+ [Enumerable].each { |m| include m }
21
+
22
+ attr_reader :index, :height, :hidden, :custom_height, :outline_level
23
+
24
+ # @param index [Integer] 0-based row index.
25
+ # @param xml_bytes [String] Raw ASCII-8BIT XML bytes.
26
+ # @param from [Integer] Byte offset where cells start.
27
+ # @param to [Integer] Byte offset where cells end.
28
+ # @param shared_strings [Array<String>] Shared strings table.
29
+ # @param height [Float, Integer, nil] Row height in points.
30
+ # @param hidden [Boolean] Whether the row is hidden.
31
+ # @param custom_height [Boolean] Whether custom height is set.
32
+ # @param outline_level [Integer, nil] Grouping/outline level.
33
+ #: (index: Integer, xml_bytes: String, from: Integer, to: Integer, shared_strings: Array[String], ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?outline_level: Integer | nil) -> void
34
+ def initialize(index:, xml_bytes:, from:, to:, shared_strings:, height: nil, hidden: false,
35
+ custom_height: false, outline_level: nil)
36
+ @index = index
37
+ @xml = xml_bytes
38
+ @from = from
39
+ @to = to
40
+ @shared_strings = shared_strings
41
+ @height = height
42
+ @hidden = hidden
43
+ @custom_height = custom_height
44
+ @outline_level = outline_level
45
+ @cells_cache = nil
46
+ end
47
+
48
+ # Iterate over cells in this streaming row one by one.
49
+ #
50
+ # @yield [cell]
51
+ # @yieldparam cell [Elements::Cell]
52
+ # @return [Enumerator, void]
53
+ # @api public
54
+ #: () { (Elements::Cell) -> void } -> void
55
+ #: | () -> Enumerator[Elements::Cell, void]
56
+ def each_cell(&)
57
+ return enum_for(:each_cell) unless block_given?
58
+
59
+ if @cells
60
+ @cells.each(&)
61
+ else
62
+ Ooxml::WorksheetParser.fast_scan_cells_direct(@xml, @from, @to, @shared_strings, { row: @index }, &)
63
+ end
64
+ end
65
+
66
+ # Iterate over cells in this streaming row.
67
+ #
68
+ # @yield [cell]
69
+ # @yieldparam cell [Elements::Cell]
70
+ # @return [Enumerator, void]
71
+ # @api public
72
+ #: () { (Elements::Cell) -> void } -> void
73
+ #: | () -> Enumerator[Elements::Cell, void]
74
+ def each(&)
75
+ each_cell(&)
76
+ end
77
+
78
+ # Returns all cells as an Array. Cached on first access.
79
+ #
80
+ # @return [Array<Elements::Cell>]
81
+ # @api public
82
+ #: () -> Array[Elements::Cell]
83
+ def cells
84
+ @cells ||= each_cell.to_a.freeze
85
+ end
86
+
87
+ # Access a cell by 0-based column index, or access row attributes via Symbol.
88
+ #
89
+ # @param col_index [Integer, Symbol] Column index or attribute symbol.
90
+ # @return [Elements::Cell, Object, nil]
91
+ # @api public
92
+ #: (Integer | Symbol col_index) -> untyped
93
+ def [](col_index)
94
+ case col_index
95
+ when Symbol
96
+ case col_index
97
+ when :cells then cells
98
+ when :index then index
99
+ when :height then height
100
+ when :hidden then hidden
101
+ when :custom_height then custom_height
102
+ when :outline_level then outline_level
103
+ when :attrs then { height: height, hidden: hidden, custom_height: custom_height, outline_level: outline_level }
104
+ end
105
+ else
106
+ cells[col_index]
107
+ end
108
+ end
109
+
110
+ # Access a cell by 0-based column index.
111
+ #
112
+ # @param col_index [Integer] 0-based column index.
113
+ # @return [Elements::Cell, nil]
114
+ # @api public
115
+ #: (Integer col_index) -> Elements::Cell?
116
+ def cell_at(col_index)
117
+ cells.find { |c| c.column_index == col_index }
118
+ end
119
+
120
+ # Convert row cells to an Array of raw values (sparse columns get nil).
121
+ #
122
+ # @return [Array<Object>]
123
+ # @api public
124
+ #: () -> Array[untyped]
125
+ def to_a
126
+ return [] if cells.empty?
127
+
128
+ max_col = cells.map(&:column_index).max || 0
129
+ arr = Array.new(max_col + 1)
130
+ cells.each do |cell|
131
+ arr[cell.column_index] = cell.value
132
+ end
133
+ arr
134
+ end
135
+
136
+ # Returns cell values as an Array.
137
+ #
138
+ # @return [Array<Object>]
139
+ # @api public
140
+ #: () -> Array[untyped]
141
+ def values
142
+ to_a
143
+ end
144
+
145
+ # Returns whether the row is valid according to OOXML specifications.
146
+ #
147
+ # @return [Boolean]
148
+ # @api public
149
+ #: () -> bool
150
+ def valid?
151
+ true
152
+ end
153
+
154
+ # Unmapped metadata for compatibility with Elements::Row.
155
+ #
156
+ # @return [Hash]
157
+ # @api public
158
+ #: () -> Hash[untyped, untyped]
159
+ def unmapped_data
160
+ Elements::EMPTY_HASH
161
+ end
162
+
163
+ # Validation errors for compatibility with Elements::Row.
164
+ #
165
+ # @return [Array<String>]
166
+ # @api public
167
+ #: () -> Array[String]
168
+ def errors
169
+ Elements::EMPTY_ERRORS
170
+ end
171
+
172
+ # Human-readable representation.
173
+ #
174
+ # @return [String]
175
+ # @api public
176
+ #: () -> String
177
+ def inspect
178
+ "#<#{self.class.name} index=#{index} height=#{height.inspect} hidden=#{hidden}>"
179
+ end
180
+ end
181
+ end
@@ -3,5 +3,5 @@
3
3
  # rbs_inline: enabled
4
4
 
5
5
  module Xlsxrb
6
- VERSION = "0.1.6"
6
+ VERSION = "0.1.8"
7
7
  end