roo-oxide 0.0.2

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.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../oxide/version"
4
+
5
+ Roo::Calamine = Roo::Oxide unless Roo.const_defined?(:Calamine, false)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "oxide"
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ # The answers the native reader needs from roo while it builds cells. Each
6
+ # one comes from the roo object or method that SheetDoc#cell_from_xml uses,
7
+ # so formats, shared strings, and errors are roo's own.
8
+ class Context
9
+ # roo versions whose cell constructors the native reader reproduces:
10
+ # under them it sets each cell's instance variables itself, and under
11
+ # any other version it calls the constructors.
12
+ CELL_LAYOUT_ROO_VERSIONS = %w[4.0.0].freeze
13
+
14
+ attr_reader :hyperlinks, :empty_cell
15
+
16
+ def initialize(shared, hyperlinks, empty_cell)
17
+ @shared = shared
18
+ @hyperlinks = hyperlinks
19
+ @empty_cell = empty_cell
20
+ end
21
+
22
+ def base_date
23
+ @shared.base_date
24
+ end
25
+
26
+ def base_timestamp
27
+ @shared.base_timestamp
28
+ end
29
+
30
+ def build_cells?
31
+ CELL_LAYOUT_ROO_VERSIONS.include?(Roo::VERSION)
32
+ end
33
+
34
+ # The format string, its type, and whether Cell::Number#create_numeric
35
+ # always calls Float under it (nil when matching it raises).
36
+ def style(index)
37
+ format = @shared.styles.style_format(index)
38
+ [format.to_s, Roo::Excelx::Format.to_type(format), float_format(format.to_s)]
39
+ end
40
+
41
+ def style_index(text)
42
+ text.to_i
43
+ end
44
+
45
+ def coordinate(reference)
46
+ Roo::Utils.extract_coordinate(reference)
47
+ end
48
+
49
+ # Roo's plain strings and deferred or public HTML table, nil when disabled.
50
+ def shared_strings
51
+ strings = @shared.shared_strings
52
+ html_disabled = strings.instance_variable_get(:@options)[:disable_html_wrapper]
53
+ plain = strings.to_a
54
+ [plain, (strings.oxide_html unless html_disabled)]
55
+ end
56
+
57
+ def shared_string(text)
58
+ shared_string_at(@shared.shared_strings, text.to_i)
59
+ end
60
+
61
+ private
62
+
63
+ def float_format(format)
64
+ format.match?(/%|\.0/)
65
+ rescue ArgumentError, EncodingError
66
+ nil
67
+ end
68
+
69
+ def shared_string_at(strings, index)
70
+ strings.use_html?(index) ? strings.to_html[index] : strings[index]
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+
5
+ module Roo
6
+ module Oxide
7
+ # Prepended to Roo::Excelx. roo's #to_csv writes every slot from the first
8
+ # row and column to the last through #empty?, #cell and #celltype, and each
9
+ # of them looks up the sheet and the cell again. This writes the same text
10
+ # from one pass over the sheet's cells: natively from the lazy cells while
11
+ # the sheet has them, else a line at a time, asking each cell what roo's
12
+ # Roo::Formatters::CSV#cell_to_csv asks it, in the same order.
13
+ module CSV
14
+ # roo versions whose Roo::Formatters::CSV this reproduces. Under any other
15
+ # version roo writes the CSV itself.
16
+ ROO_VERSIONS = %w[4.0.0].freeze
17
+ private_constant :ROO_VERSIONS
18
+
19
+ private
20
+
21
+ def write_csv_content(file = nil, sheet = nil, separator = ",")
22
+ return super unless csv_lines?(separator.to_s)
23
+
24
+ file ||= STDOUT # rubocop:disable Style/GlobalStdStream
25
+ return unless first_row(sheet)
26
+
27
+ target = sheet_for(sheet)
28
+ area = [last_row(sheet), last_column(sheet), target.first_row, target.last_row, target.first_column,
29
+ target.last_column]
30
+ store = target.lazy_cells
31
+ if store && area.all?(Integer)
32
+ store.write_csv(file, self, separator.to_s, area)
33
+ else
34
+ write_csv_lines(file, target, separator.to_s, *area.first(2))
35
+ end
36
+ end
37
+
38
+ # roo prints each field on its own; a line joins them, so the separator
39
+ # has to join UTF-8 text, and IO#print must add no $\ between fields.
40
+ def csv_lines?(separator)
41
+ Oxide.enabled? && ROO_VERSIONS.include?(Roo::VERSION) && $OUTPUT_RECORD_SEPARATOR.nil? &&
42
+ (separator.ascii_only? || separator.encoding == Encoding::UTF_8)
43
+ end
44
+
45
+ # roo writes rows 1..last_row and columns 1..last_column, and prints the
46
+ # separator before every column but the first, then the field, so after
47
+ # a field raises the file holds the line up to it.
48
+ def write_csv_lines(file, sheet, separator, rows, width) # rubocop:disable Metrics
49
+ first_row = sheet.first_row
50
+ last_row = sheet.last_row
51
+ first_column = sheet.first_column
52
+ last_column = sheet.last_column
53
+ separators = Hash.new { |runs, count| runs[count] = separator * count }
54
+ blank_line = "#{separators[[width - 1, 0].max]}\n"
55
+ line = +""
56
+ line_row = written = 0
57
+ each_csv_cell(sheet.cells, rows, width) do |row, column, cell|
58
+ if row != line_row
59
+ file.print(line << separators[width - 1 - written] << "\n") if line_row.positive?
60
+ (row - line_row - 1).times { file.print(blank_line) }
61
+ line.clear
62
+ line_row = row
63
+ written = 0
64
+ end
65
+ line << separators[column - 1 - written]
66
+ written = column - 1
67
+ begin
68
+ next if !cell || cell.empty? || row < first_row || row > last_row ||
69
+ column < first_column || column > last_column
70
+
71
+ line << csv_field(cell)
72
+ rescue StandardError
73
+ file.print(line)
74
+ raise
75
+ end
76
+ end
77
+ file.print(line << separators[width - 1 - written] << "\n") if line_row.positive?
78
+ (line_row + 1).upto(rows) { file.print(blank_line) }
79
+ end
80
+
81
+ # Yields the cells roo visits, by row and then column: those with an
82
+ # Integer row in 1..rows and an Integer column in 1..columns.
83
+ def each_csv_cell(cells, rows, columns)
84
+ if csv_keys_sorted?(cells)
85
+ cells.each_pair do |(row, column), cell|
86
+ break if row > rows
87
+
88
+ yield row, column, cell if row.positive? && column.between?(1, columns)
89
+ end
90
+ else
91
+ sorted_csv_keys(cells, rows, columns).each { |key| yield key[0], key[1], cells[key] }
92
+ end
93
+ end
94
+
95
+ def sorted_csv_keys(cells, rows, columns)
96
+ keys = cells.keys.select do |key|
97
+ key.is_a?(Array) && key.size == 2 && key.all?(Integer) &&
98
+ key[0].between?(1, rows) && key[1].between?(1, columns)
99
+ end
100
+ keys.sort!
101
+ end
102
+
103
+ # Whether every key is a [row, column] of Integers, in increasing order
104
+ # from row 0.
105
+ def csv_keys_sorted?(cells) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
106
+ previous_row = previous_column = 0
107
+ cells.each_key do |key|
108
+ return false unless key.is_a?(Array) && key.size == 2
109
+
110
+ row, column = key
111
+ return false unless row.is_a?(Integer) && column.is_a?(Integer)
112
+
113
+ if row == previous_row
114
+ return false if column < previous_column
115
+ elsif row < previous_row
116
+ return false
117
+ end
118
+ previous_row = row
119
+ previous_column = column
120
+ end
121
+ true
122
+ end
123
+
124
+ # Roo::Formatters::CSV#cell_to_csv after its #empty? check.
125
+ def csv_field(cell) # rubocop:disable Metrics/CyclomaticComplexity
126
+ value = cell.value
127
+ case (type = cell.type)
128
+ when :string then csv_quote(value) unless value.empty?
129
+ when :boolean then %("#{cell.formatted_value.gsub('"', '""').downcase}")
130
+ when :float, :percentage then csv_number(value)
131
+ when :formula then csv_formula_value(value)
132
+ when :date, :datetime then value.to_s
133
+ when :time then integer_to_timestring(value)
134
+ when :link then csv_quote(value.url)
135
+ else raise "unhandled celltype #{type}"
136
+ end || ""
137
+ end
138
+
139
+ def csv_formula_value(value)
140
+ case value
141
+ when String then csv_quote(value) unless value.empty?
142
+ when Integer, Date, DateTime, TrueClass, FalseClass then value.to_s
143
+ when Float then csv_number(value)
144
+ else raise "unhandled onecell-class #{value.class}"
145
+ end
146
+ end
147
+
148
+ def csv_quote(text)
149
+ text.include?('"') ? %("#{text.gsub('"', '""')}") : %("#{text}")
150
+ end
151
+
152
+ def csv_number(value)
153
+ value == value.to_i ? value.to_i.to_s : value.to_s
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ # Prepended to Roo::Excelx. roo extracts every worksheet to its temporary
6
+ # directory when it opens a workbook. With the native reader on, a
7
+ # workbook leaves its worksheets in the zip instead:
8
+ # the native reader inflates the one it reads, and roo's own code finds
9
+ # the file extracted the first time it reads it (DeferredSheetDoc). roo
10
+ # reads the workbook's file only while it opens it, so it opens a snapshot
11
+ # in memory for small ZIPs or in its temporary directory for large ones.
12
+ # The workbook reads the same after its file
13
+ # is changed, moved, or deleted. The native extension also writes the
14
+ # files roo does extract, such as sharedStrings.xml: rubyzip's inflating
15
+ # leaves several times the file's size in short-lived Strings, which
16
+ # Ruby's GC frees only after they have grown the process.
17
+ module DeferredSheets
18
+ # roo versions whose workbook extraction these methods reproduce.
19
+ ROO_VERSIONS = %w[4.0.0].freeze
20
+ COMPRESSION_METHODS = [::Zip::Entry::STORED, ::Zip::Entry::DEFLATED].freeze
21
+ METADATA_FILES = %w[roo_workbook.xml roo_workbook.xml.rels roo_styles.xml].freeze
22
+ # Nokogiri's memory parser passes the byte length as a signed C int.
23
+ MAX_METADATA_SIZE = (2**31) - 1
24
+ MEMORY_ZIP_SIZE = 256 * 1024
25
+ private_constant :ROO_VERSIONS, :COMPRESSION_METHODS, :METADATA_FILES, :MAX_METADATA_SIZE, :MEMORY_ZIP_SIZE
26
+
27
+ # `path` as the native extension takes it. On Windows, Ruby opens a
28
+ # binary path's bytes as UTF-8, where magnus refuses a binary string.
29
+ # The extension raises EncodingError for a path it cannot take.
30
+ def self.native_path(path)
31
+ path = ::File.path(path)
32
+ return path unless Gem.win_platform? && path.encoding == Encoding::BINARY
33
+
34
+ path.dup.force_encoding(Encoding::UTF_8)
35
+ end
36
+
37
+ # Extended onto each Zip::Entry of the copy that the native extension
38
+ # can read: `oxide_entry` is its Native::ZipEntry, which writes the
39
+ # file when roo extracts the entry. When the native extraction fails,
40
+ # or cannot take the path, rubyzip extracts it as roo does, and raises
41
+ # or warns as roo does.
42
+ module NativeEntry
43
+ attr_accessor :oxide_entry, :oxide_metadata
44
+
45
+ def extract(entry_path, destination_directory:)
46
+ path = ::File.join(destination_directory, entry_path)
47
+ if oxide_metadata && METADATA_FILES.include?(entry_path) &&
48
+ size <= MAX_METADATA_SIZE && !::File.exist?(path)
49
+ oxide_metadata[entry_path] = Native.metadata_bytes(oxide_entry)
50
+ return self
51
+ end
52
+ oxide_entry.extract(DeferredSheets.native_path(path))
53
+ self
54
+ rescue IOError, EncodingError
55
+ super
56
+ end
57
+ end
58
+
59
+ # A worksheet left in the zip: `zip_entry` is rubyzip's Zip::Entry for
60
+ # it, a NativeEntry.
61
+ class Sheet
62
+ def initialize(zip_entry)
63
+ @zip_entry = zip_entry
64
+ @lock = Mutex.new
65
+ @extracted = false
66
+ end
67
+
68
+ # Its Native::ZipEntry.
69
+ def entry
70
+ @zip_entry.oxide_entry
71
+ end
72
+
73
+ # Extracts the worksheet where roo would have, once; another thread
74
+ # waits until the file is complete.
75
+ def extract(path)
76
+ @lock.synchronize do
77
+ next if @extracted
78
+
79
+ @zip_entry.extract(::File.basename(path), destination_directory: ::File.dirname(path))
80
+ @extracted = true
81
+ end
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def process_zipfile(zipfilename_or_stream)
88
+ @oxide_copy = oxide_copy(zipfilename_or_stream)
89
+ @oxide_copy ? super(@oxide_copy) : super
90
+ end
91
+
92
+ # The snapshot roo opens: a stream, a copy in roo's temporary
93
+ # directory, or the file roo wrote there itself for a URL or
94
+ # `packed: :zip`. An empty file is left to roo: rubyzip's error names
95
+ # the file it opened.
96
+ def oxide_copy(source)
97
+ return unless Oxide.enabled? && ROO_VERSIONS.include?(Roo::VERSION)
98
+
99
+ if is_stream?(source)
100
+ source.seek(0)
101
+ elsif ::File.dirname(source) == @tmpdir
102
+ return source
103
+ end
104
+
105
+ write_oxide_copy(source)
106
+ end
107
+
108
+ def memory_copy(source)
109
+ size = is_stream?(source) ? (source.size if source.respond_to?(:size)) : ::File.size(source)
110
+ ::StringIO.new(is_stream?(source) ? source.read : ::File.binread(source)) if size&.between?(1, MEMORY_ZIP_SIZE)
111
+ end
112
+
113
+ def write_oxide_copy(source)
114
+ copy = memory_copy(source)
115
+ return copy if copy
116
+
117
+ path = "#{@tmpdir}/roo_oxide.xlsx"
118
+ # StringIO#read shares its full String; IO.copy_stream's partial reads copy its buffer.
119
+ if source.is_a?(::StringIO)
120
+ ::File.binwrite(path, source.read)
121
+ else
122
+ IO.copy_stream(source, path)
123
+ end
124
+ path if ::File.size?(path)
125
+ end
126
+
127
+ def process_zipfile_entries(entries)
128
+ if @oxide_copy
129
+ @shared.extend(MetadataShared)
130
+ @shared.oxide_metadata = {}
131
+ copy = @oxide_copy
132
+ zip = copy.is_a?(::StringIO) ? Native::ZipBuffer.new(copy.string) : DeferredSheets.native_path(copy)
133
+ native_entries(entries, zip)
134
+ end
135
+ super
136
+ end
137
+
138
+ # Makes each entry the native extension can read a NativeEntry. roo
139
+ # raises or warns while extracting an entry that is not a file,
140
+ # encrypted, or compressed by another method; rubyzip still extracts it.
141
+ def native_entries(entries, zip)
142
+ metadata = metadata_entries(entries)
143
+ entries.each do |entry|
144
+ next unless entry.file? && !entry.encrypted? && COMPRESSION_METHODS.include?(entry.compression_method)
145
+
146
+ entry.extend(NativeEntry).oxide_entry =
147
+ Native::ZipEntry.new(zip, entry.local_header_offset, entry.compressed_size, entry.size,
148
+ entry.compression_method)
149
+ entry.oxide_metadata = @shared.oxide_metadata if metadata.include?(entry)
150
+ end
151
+ rescue EncodingError
152
+ nil
153
+ end
154
+
155
+ def metadata_entries(entries)
156
+ metadata = [entries.find { |entry| entry.name[/workbook.xml$/] },
157
+ entries.find { |entry| entry.name[/workbook.xml.rels$/] }]
158
+ styles = entries.select { |entry| entry.name.downcase[/styles.xml$/] && !entry.name.downcase[/richdata/] }
159
+ metadata + (styles.one? ? styles : [])
160
+ end
161
+
162
+ def extract_worksheet_ids(entries, path)
163
+ return super unless @oxide_copy
164
+
165
+ entry = entries.find { |item| item.name[/workbook.xml$/] }
166
+ return super unless entry.is_a?(NativeEntry)
167
+
168
+ entry.extract(::File.basename(path), destination_directory: ::File.dirname(path))
169
+ workbook.sheets.map { |sheet| sheet["id"] }
170
+ end
171
+
172
+ def extract_worksheet_rels(entries, path)
173
+ return super unless @oxide_copy
174
+
175
+ entry = entries.find { |item| item.name[/workbook.xml.rels$/] }
176
+ return super unless entry
177
+
178
+ entry.extract(::File.basename(path), destination_directory: ::File.dirname(path))
179
+ bytes = @shared.oxide_metadata.delete(::File.basename(path))
180
+ document = (bytes ? ::Nokogiri::XML(bytes, path) : Roo::Utils.load_xml(path)).remove_namespaces!
181
+ document.xpath("//Relationship").each_with_object({}) do |relationship, sheets|
182
+ sheets[relationship["Id"]] = relationship["Target"] if worksheet_types.include?(relationship["Type"])
183
+ end
184
+ end
185
+
186
+ # Roo::Excelx#extract_sheets_in_order, without the extraction. roo
187
+ # raises or warns reading a worksheet that is missing or that the
188
+ # native extension cannot read; it still does.
189
+ def extract_sheets_in_order(entries, sheet_ids, sheets, tmpdir)
190
+ found = (sheet_ids & sheets.keys).map { |id| sheet_entry(entries, sheets[id]) }
191
+ return super unless found.all?(NativeEntry)
192
+
193
+ deferred = found.each_with_index.to_h { |entry, i| ["#{tmpdir}/roo_sheet#{i + 1}", Sheet.new(entry)] }
194
+ sheet_files.concat(deferred.keys)
195
+ @sheet_files.concat(deferred.keys)
196
+ @shared.instance_variable_set(:@oxide_deferred_sheets, deferred)
197
+ end
198
+
199
+ def sheet_entry(entries, name)
200
+ name && entries.find { |e| "/#{e.name}".end_with?(name) }
201
+ end
202
+ end
203
+
204
+ # Roo constructs these extractors after extraction. Pass retained bytes
205
+ # alongside their original paths, including when native reading is off.
206
+ module MetadataShared
207
+ attr_accessor :oxide_metadata
208
+
209
+ def workbook
210
+ @workbook ||= Roo::Excelx::Workbook.new(::File.join(@dir, "roo_workbook.xml"),
211
+ oxide_xml: oxide_metadata["roo_workbook.xml"])
212
+ end
213
+
214
+ def styles
215
+ @styles ||= Roo::Excelx::Styles.new(::File.join(@dir, "roo_styles.xml"),
216
+ oxide_xml: oxide_metadata["roo_styles.xml"])
217
+ end
218
+ end
219
+
220
+ # Roo's document cache is weak: keep the source bytes so metadata can be
221
+ # parsed again after GC, without keeping a large Nokogiri tree alive.
222
+ module MetadataDocument
223
+ def initialize(*, oxide_xml: nil, **)
224
+ @oxide_xml = oxide_xml
225
+ super(*, **)
226
+ end
227
+
228
+ private
229
+
230
+ def doc
231
+ return super unless @oxide_xml
232
+
233
+ instance_cache(:@doc) do
234
+ raise Roo::FileNotFound, "#{@path} file not found" unless doc_exists?
235
+
236
+ ::Nokogiri::XML(@oxide_xml, @path).remove_namespaces!
237
+ end
238
+ end
239
+
240
+ def doc_exists?
241
+ @oxide_xml ? ::File.directory?(::File.dirname(@path)) : super
242
+ end
243
+ end
244
+
245
+ # Prepended to Roo::Excelx::SheetDoc, below Roo::Oxide::SheetDoc. roo
246
+ # reads a worksheet's file in #doc, #dimensions, and #each_row_streaming;
247
+ # each one extracts a deferred worksheet first.
248
+ module DeferredSheetDoc
249
+ def dimensions
250
+ extract_deferred_sheet
251
+ super
252
+ end
253
+
254
+ def each_row_streaming(&)
255
+ extract_deferred_sheet
256
+ super
257
+ end
258
+
259
+ private
260
+
261
+ def doc
262
+ extract_deferred_sheet
263
+ super
264
+ end
265
+
266
+ # What the native reader reads: the worksheet's Native::ZipEntry, or
267
+ # its file when it was not left in the zip.
268
+ def sheet_source
269
+ deferred_sheet&.entry || @path
270
+ end
271
+
272
+ def extract_deferred_sheet
273
+ deferred_sheet&.extract(@path)
274
+ end
275
+
276
+ # nil once the workbook is closed or collected: its temporary directory
277
+ # is gone, and roo raises reading the worksheet's file.
278
+ def deferred_sheet
279
+ sheet = @shared.instance_variable_get(:@oxide_deferred_sheets)&.[](@path)
280
+ sheet if sheet && ::File.directory?(::File.dirname(@path))
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ # Roo exposes a mutable sheet list, so names must be checked on each lookup.
6
+ module SheetSelection
7
+ private
8
+
9
+ def validate_sheet!(sheet)
10
+ case sheet
11
+ when String then super unless sheets.include?(sheet)
12
+ else
13
+ super
14
+ end
15
+ end
16
+ end
17
+
18
+ # Prepended to Roo::Excelx. With options, Roo::Base#each reads every value
19
+ # through #cell, which resolves the sheet and looks up an Array key in its
20
+ # cells hash, one cell at a time. These keep roo's header handling (its own
21
+ # private methods) and read the cells with less work per cell.
22
+ #
23
+ # Value and type lookups read the store; other single-cell lookups read
24
+ # Sheet#cell_at instead of Sheet#cells, and
25
+ # rows read Sheet#row, so they build only the cells their values need.
26
+ # #each without options reads the native store sequentially, or uses a
27
+ # RowIndex once every cell is built.
28
+ module Excelx
29
+ def each(options = {}, &)
30
+ return super unless block_given?
31
+
32
+ if options.empty?
33
+ build_cells
34
+ return native_rows? ? each_without_options(&) : super
35
+ end
36
+ return super unless Oxide.enabled?
37
+
38
+ each_with_headers(header_columns(options), &)
39
+ end
40
+
41
+ def parse(options = {})
42
+ return super if block_given? || options.empty? || !Oxide.enabled?
43
+
44
+ rows = rows_for(header_columns(options))
45
+ options[:headers] == true ? rows : rows.drop(1)
46
+ end
47
+
48
+ def cell(row, col, sheet = nil)
49
+ row, col = normalize(row, col) if row.is_a?(String) || col.is_a?(String)
50
+ sheet = sheet_for(sheet)
51
+ lazy = sheet.lazy_cells
52
+ return lazy.value_at(row, col) if lazy
53
+
54
+ cell = sheet.cell_at(row, col)
55
+ cell.value if cell.respond_to?(:value)
56
+ end
57
+
58
+ def formula(row, col, sheet = nil)
59
+ row, col = normalize(row, col) if row.is_a?(String) || col.is_a?(String)
60
+ cell = sheet_for(sheet).cell_at(row, col)
61
+ cell.formula if cell.respond_to?(:formula)
62
+ end
63
+
64
+ def font(row, col, sheet = nil)
65
+ row, col = normalize(row, col) if row.is_a?(String) || col.is_a?(String)
66
+ cell = sheet_for(sheet).cell_at(row, col)
67
+ definition_index = cell.style if cell.respond_to?(:style)
68
+ styles.definitions[definition_index] if definition_index
69
+ end
70
+
71
+ def celltype(row, col, sheet = nil)
72
+ row, col = normalize(row, col) if row.is_a?(String) || col.is_a?(String)
73
+ sheet = sheet_for(sheet)
74
+ lazy = sheet.lazy_cells
75
+ return lazy.type_at(row, col) if lazy
76
+
77
+ cell = sheet.cell_at(row, col)
78
+ cell.type if cell.respond_to?(:type)
79
+ end
80
+
81
+ def excelx_type(row, col, sheet = nil)
82
+ row, col = normalize(row, col) if row.is_a?(String) || col.is_a?(String)
83
+ cell = sheet_for(sheet).cell_at(row, col)
84
+ cell.cell_type if cell.respond_to?(:cell_type)
85
+ end
86
+
87
+ def excelx_value(row, col, sheet = nil)
88
+ row, col = normalize(row, col) if row.is_a?(String) || col.is_a?(String)
89
+ cell = sheet_for(sheet).cell_at(row, col)
90
+ cell.cell_value if cell.respond_to?(:cell_value)
91
+ end
92
+
93
+ def empty?(row, col, sheet = nil)
94
+ sheet = sheet_for(sheet)
95
+ key = normalize(row, col)
96
+ cell = (sheet.lazy_cells || sheet.cells)[key]
97
+ !cell || cell.empty? ||
98
+ (row < sheet.first_row || row > sheet.last_row || col < sheet.first_column || col > sheet.last_column)
99
+ end
100
+
101
+ def set(...)
102
+ @oxide_set_calls = (@oxide_set_calls || 0) + 1
103
+ super
104
+ end
105
+
106
+ private
107
+
108
+ def clean_sheet(name)
109
+ store = sheet_for(name).lazy_cells if Oxide.enabled?
110
+ return super unless store
111
+
112
+ store.clean(self)
113
+ @cleaned[name] = true
114
+ end
115
+
116
+ def each_with_headers(headers)
117
+ first, last = headers.map(&:last).minmax
118
+ slots = headers.map { |key, column| [key, column - first] }
119
+ @header_line.upto(last_row) do |line|
120
+ lazy = sheet_for(nil).lazy_cells unless headers.empty?
121
+ yield(lazy ? lazy.hash_row(line, first, last, slots) : cells_header_row(line, headers))
122
+ end
123
+ end
124
+
125
+ def cells_header_row(line, headers)
126
+ cells = nil
127
+ headers.each_with_object({}) do |(key, column), row|
128
+ cells ||= sheet_for(nil).cells
129
+ row[key] = safe_send(cells[normalize(line, column)], :value)
130
+ end
131
+ end
132
+
133
+ # Roo::Base#each, up to its loop over the rows.
134
+ def header_columns(options)
135
+ clean_sheet_if_need(options)
136
+ build_cells
137
+ search_or_set_header(options)
138
+ @headers || (first_column..last_column).to_h { |column| [cell(@header_line, column), column] }
139
+ end
140
+
141
+ # Builds every cell before roo's next step unless the lazy cells answer it. When that raises,
142
+ # roo's own read raises the same error where roo raises it.
143
+ def build_cells
144
+ sheet = sheet_for(nil)
145
+ sheet.cells unless sheet.lazy_cells
146
+ rescue StandardError
147
+ nil
148
+ end
149
+
150
+ def rows_for(headers)
151
+ fields = headers.map { |key, column| [key, normalize(0, column).last] }
152
+ sheet_for(nil).parse_rows(@header_line.upto(last_row), fields)
153
+ end
154
+ end
155
+ end
156
+ end