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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ # Roo 4.0.0's output formatting, using the native records while the
6
+ # sheet's public cells hash has not been materialized.
7
+ module Formatters
8
+ def formatted_value(row, col, sheet = nil)
9
+ row, col = normalize(row, col) if row.is_a?(String) || col.is_a?(String)
10
+ cell = sheet_for(sheet).cell_at(row, col)
11
+ return formatter_temporal_value(cell) if native_formatters? && cell.is_a?(Roo::Excelx::Cell::DateTime)
12
+
13
+ cell.formatted_value if cell.respond_to?(:formatted_value)
14
+ end
15
+
16
+ def to_yaml(prefix = {}, from_row = nil, from_column = nil, to_row = nil, to_column = nil, sheet = default_sheet) # rubocop:disable Metrics/ParameterLists
17
+ return super unless native_formatters?
18
+ return "" unless first_row
19
+
20
+ area = formatter_area(sheet, [from_row, from_column, to_row, to_column])
21
+ store = sheet_for(sheet).lazy_cells
22
+ return super unless store && area.all?(Integer)
23
+
24
+ store.to_yaml(self, prefix, area)
25
+ end
26
+
27
+ def to_matrix(from_row = nil, from_column = nil, to_row = nil, to_column = nil, sheet = default_sheet) # rubocop:disable Metrics/ParameterLists
28
+ return super unless native_formatters?
29
+
30
+ require "matrix"
31
+ return ::Matrix.empty unless first_row
32
+
33
+ area = formatter_rectangle(sheet, [from_row, from_column, to_row, to_column])
34
+ store = sheet_for(sheet).lazy_cells
35
+ return super unless store && area.all?(Integer)
36
+
37
+ ::Matrix.rows(store.matrix_rows(area))
38
+ end
39
+
40
+ def to_xml
41
+ return super unless native_formatters?
42
+ return super if sheets.empty?
43
+
44
+ targets = sheets.map { |name| sheet_for(name) }
45
+ return super unless targets.all?(&:lazy_cells)
46
+
47
+ result = +"<?xml version=\"1.0\"?>\n<spreadsheet>\n"
48
+ document = Nokogiri::XML::Document.new
49
+ sheets.zip(targets).each do |name, target|
50
+ self.default_sheet = name
51
+ result << formatter_xml_sheet(document, name, target)
52
+ end
53
+ result << "</spreadsheet>\n"
54
+ end
55
+
56
+ private
57
+
58
+ def formatter_temporal_value(cell)
59
+ if cell.is_a?(Roo::Excelx::Cell::Time)
60
+ @oxide_time_formats ||= Hash.new { |formats, encoding| formats[encoding] = {} }
61
+ formatter = @oxide_time_formats[cell.format.encoding][cell.format] ||=
62
+ cell.format.gsub(/#{Roo::Excelx::Cell::DateTime::TIME_FORMATS.keys.join("|")}/,
63
+ Roo::Excelx::Cell::DateTime::TIME_FORMATS)
64
+ return cell.instance_variable_get(:@datetime).strftime(formatter)
65
+ end
66
+
67
+ @oxide_datetime_formats ||= Hash.new { |formats, encoding| formats[encoding] = {} }
68
+ formatter = @oxide_datetime_formats[cell.format.encoding][cell.format] ||= formatter_datetime(cell)
69
+ formatter ? cell.value.strftime(formatter) : cell.formatted_value
70
+ end
71
+
72
+ def formatter_datetime(cell)
73
+ parts = cell.format.downcase.split(" ", 0).map do |part|
74
+ parsed = cell.send(:parse_date_or_time_format, part)
75
+ return nil unless parsed
76
+
77
+ parsed
78
+ end
79
+ parts.join(" ")
80
+ end
81
+
82
+ def native_formatters?
83
+ Oxide.enabled? && Roo::VERSION == "4.0.0"
84
+ end
85
+
86
+ def formatter_area(sheet, rectangle)
87
+ target = sheet_for(sheet)
88
+ formatter_rectangle(sheet, rectangle) +
89
+ [target.first_row, target.first_column, target.last_row, target.last_column]
90
+ end
91
+
92
+ def formatter_rectangle(sheet, rectangle)
93
+ bounds = %i[first_row first_column last_row last_column]
94
+ rectangle.zip(bounds).map { |requested, bound| requested || public_send(bound, sheet) }
95
+ end
96
+
97
+ def formatter_xml_sheet(document, name, target)
98
+ element = document.create_element("sheet", name:)
99
+ cells = first_row ? target.lazy_cells.xml_cells(self, formatter_area(name, [nil] * 4)) : ""
100
+ " #{element.to_xml.delete_suffix("/>")}#{cells.empty? ? "/>\n" : ">\n#{cells} </sheet>\n"}"
101
+ end
102
+
103
+ def formatter_yaml_prefix(prefix)
104
+ text = +""
105
+ prefix.each { |key, value| text << " #{key}: #{value} \n" }
106
+ text
107
+ end
108
+
109
+ def formatter_xml_cell(value, row, column, type)
110
+ Nokogiri::XML::Document.new.create_element("cell", value, row:, column:, type:).to_xml
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ # Answers Excelx#row for the default sheet from its cells in
6
+ # first_row..last_row and first_column..last_column, laid out in one pass
7
+ # over Sheet#cells: in one flat Array with a slot per column of each row
8
+ # (an Array per row costs a major GC on a large sheet), or, on a sheet
9
+ # with fewer than one cell per MAX_SLOTS_PER_CELL slots, in a Hash of an
10
+ # Array per row of its column offsets and cells. roo answers the first ROO_ROWS
11
+ # requests, so a caller that stops early never pays for that pass, and
12
+ # any other row, a sheet too short to repay the pass (fewer than
13
+ # 3 * ROO_ROWS rows), an empty sheet, and every request once the default
14
+ # sheet changes or Excelx#set writes to the workbook.
15
+ class RowIndex
16
+ ROO_ROWS = 1_000
17
+ MAX_SLOTS_PER_CELL = 4
18
+
19
+ def initialize(workbook)
20
+ @workbook = workbook
21
+ @requests = 0
22
+ end
23
+
24
+ def row(line, set_calls)
25
+ index = index(line) if current?(set_calls)
26
+ return @workbook.row(line) unless index
27
+
28
+ @lines ? sparse_row(@lines[index]) : @cells[index * @width, @width].map! { |cell| cell&.value }
29
+ end
30
+
31
+ private
32
+
33
+ def index(line)
34
+ line - @first_row if line.is_a?(Integer) && line.between?(@first_row, @last_row)
35
+ end
36
+
37
+ def current?(set_calls)
38
+ return false if (@requests += 1) <= ROO_ROWS
39
+
40
+ sheet = @workbook.sheet_for(nil)
41
+ build(sheet, set_calls) unless @sheet
42
+ (@cells || @lines) && @sheet.equal?(sheet) && @set_calls == set_calls
43
+ end
44
+
45
+ def sparse_row(entries)
46
+ values = Array.new(@width)
47
+ return values unless entries
48
+
49
+ i = 0
50
+ while i < entries.size
51
+ values[entries[i]] = entries[i + 1]&.value
52
+ i += 2
53
+ end
54
+ values
55
+ end
56
+
57
+ def build(sheet, set_calls)
58
+ @sheet = sheet
59
+ @set_calls = set_calls
60
+ return unless sheet.first_row && sheet.last_row >= 3 * ROO_ROWS
61
+
62
+ @first_row = sheet.first_row
63
+ @last_row = sheet.last_row
64
+ @first = sheet.first_column
65
+ @last = sheet.last_column
66
+ @width = @last - @first + 1
67
+ lay_out(sheet.cells, @last_row - @first_row + 1)
68
+ end
69
+
70
+ def lay_out(cells, rows)
71
+ if rows * @width > MAX_SLOTS_PER_CELL * cells.size
72
+ @lines = {}
73
+ each_cell(cells) { |index, column, cell| (@lines[index] ||= []).push(column, cell) }
74
+ else
75
+ @cells = Array.new(rows * @width)
76
+ each_cell(cells) { |index, column, cell| @cells[(index * @width) + column] = cell }
77
+ end
78
+ end
79
+
80
+ def each_cell(cells) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
81
+ cells.each_pair do |key, cell|
82
+ next unless key.is_a?(Array) && key.size == 2
83
+
84
+ line, column = key
85
+ next unless line.is_a?(Integer) && column.is_a?(Integer) &&
86
+ line >= @first_row && line <= @last_row && column >= @first && column <= @last
87
+
88
+ yield line - @first_row, column - @first, cell
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ # Roo::Base#each reads the current sheet's row after every block call.
6
+ module Excelx
7
+ private
8
+
9
+ def native_rows?
10
+ Oxide.enabled? && method(:row).owner.equal?(::Roo::Excelx)
11
+ end
12
+
13
+ def each_without_options(&)
14
+ sheet = sheet_for(nil)
15
+ lazy = sheet.lazy_cells
16
+ return each_lazy_row(sheet, lazy, &) if lazy && sheet.first_column
17
+
18
+ index = RowIndex.new(self)
19
+ 1.upto(last_row) { |line| yield index.row(line, @oxide_set_calls) }
20
+ end
21
+
22
+ def each_lazy_row(sheet, lazy)
23
+ last = last_row
24
+ line = lazy.each_row(last, sheet.first_column, sheet.last_column) do |values|
25
+ yield values
26
+ sheet_for(nil).equal?(sheet) && sheet.lazy_cells.equal?(lazy)
27
+ end
28
+ line.upto(last) { |number| yield row(number) }
29
+ 1
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ # Prepended to Roo::Excelx::SharedStrings: the native extension builds the
6
+ # arrays #to_a and #to_html memoize, deferring HTML objects until used.
7
+ # Roo's own code and the native reader share them. A file the extension
8
+ # does not read exactly the way roo does is left to roo.
9
+ module SharedStrings
10
+ # roo versions whose shared-string extraction the extension reproduces.
11
+ ROO_VERSIONS = %w[4.0.0].freeze
12
+
13
+ def to_a
14
+ oxide_read(html: !@options[:disable_html_wrapper] && !(@html || @oxide_html), plain: true) unless @array
15
+ super
16
+ end
17
+
18
+ def to_html
19
+ unless @html
20
+ oxide_read(html: true, plain: false) unless @oxide_html
21
+ @html = @oxide_html.to_a if @oxide_html && (@oxide_html_used || doc_exists?)
22
+ end
23
+ super
24
+ end
25
+
26
+ def oxide_html
27
+ return to_html unless @oxide_html && (@oxide_html_used || doc_exists?)
28
+
29
+ @oxide_html_used = true
30
+ @html || @oxide_html
31
+ end
32
+
33
+ private
34
+
35
+ def oxide_read(html:, plain:)
36
+ return unless Oxide.enabled? && ROO_VERSIONS.include?(Roo::VERSION) && doc_exists?
37
+
38
+ plain, html = Native.shared_strings(@path, html, plain)
39
+ @array ||= plain
40
+ @oxide_html ||= html
41
+ rescue UnsupportedError
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ # Prepended to Roo::Excelx::Sheet: single-cell lookups, rows, columns, and
6
+ # the sheet's bounds read the lazy cells of SheetDoc#lazy_cells while
7
+ # #cells has not been built, so they build only the cells they read. Rows
8
+ # and columns build only the cells whose value needs one.
9
+ module Sheet
10
+ NO_CELLS = [].freeze
11
+ private_constant :NO_CELLS
12
+
13
+ # cells[[row, column]]
14
+ def cell_at(row, column)
15
+ (lazy = lazy_cells) ? lazy.cell_at(row, column) : cells[[row, column]]
16
+ end
17
+
18
+ def row(row_number)
19
+ lazy = lazy_cells
20
+ return lazy.row(row_number, first_column, last_column) if lazy && first_column
21
+
22
+ first_column.upto(last_column).map do |col|
23
+ cell_at(row_number, col)&.value
24
+ end
25
+ end
26
+
27
+ def column(col_number)
28
+ lazy = lazy_cells
29
+ return lazy.column(col_number, first_row, last_row) if lazy && first_row
30
+
31
+ first_row.upto(last_row).map do |row|
32
+ cell_at(row, col_number)&.value
33
+ end
34
+ end
35
+
36
+ def excelx_format(key)
37
+ cell = (lazy = lazy_cells) ? lazy[key] : cells[key]
38
+ styles.style_format(cell.style).to_s if cell
39
+ end
40
+
41
+ # For Excelx#parse: a Hash per line of `lines`, holding the value of the
42
+ # cell at [line, column] under `key` for each [key, column] of `fields`.
43
+ def parse_rows(lines, fields)
44
+ lazy = lazy_cells
45
+ if lazy && fields.all? { |_, column| column.is_a?(Integer) }
46
+ lazy_parse_rows(lazy, lines, fields)
47
+ else
48
+ cells_parse_rows(lines, fields)
49
+ end
50
+ end
51
+
52
+ # The Native::Store behind the lazy cells while #cells is not built.
53
+ def lazy_cells
54
+ @sheet.lazy_cells(@rels) unless @cells
55
+ end
56
+
57
+ private
58
+
59
+ def lazy_parse_rows(lazy, lines, fields)
60
+ first, last = fields.map(&:last).minmax
61
+ slots = fields.map { |key, column| [key, column - first] }.freeze
62
+ lines.map { |line| lazy.hash_row(line, first || 1, last || 0, slots) }
63
+ end
64
+
65
+ def cells_parse_rows(lines, fields)
66
+ slots = fields.map(&:last).uniq.each_with_index.to_h
67
+ by_line = cells_by_line(slots)
68
+ fields = fields.map { |key, column| [key, slots.fetch(column)] }
69
+ lines.map do |line|
70
+ line_cells = by_line.fetch(line, NO_CELLS)
71
+ fields.each_with_object({}) do |(key, slot), row|
72
+ cell = line_cells[slot]
73
+ row[key] = (cell.value if cell.respond_to?(:value)) # Roo::Excelx#safe_send, inline
74
+ end
75
+ end
76
+ end
77
+
78
+ def cells_by_line(slots)
79
+ by_line = {}
80
+ cells.each do |(line, column), cell|
81
+ slot = slots[column] or next
82
+ (by_line[line] ||= Array.new(slots.size))[slot] = cell
83
+ end
84
+ by_line
85
+ end
86
+
87
+ # Custom padding stays in roo's loop over #each_cell.
88
+ def cells_for_row_element(row_element, options = {})
89
+ return super if options[:pad_cells] && !method(:pad_cells).owner.equal?(::Roo::Excelx::Sheet)
90
+
91
+ @sheet.native_row_cells(row_element, pad_cells: options[:pad_cells]) || super
92
+ end
93
+
94
+ def first_last_row_col
95
+ @first_last_row_col ||= lazy_cells&.bounds || super
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ # Prepended to Roo::Excelx::SheetDoc, where every worksheet read in roo
6
+ # starts: Sheet#cells (and with it #row, #each, #parse), the lazy cells
7
+ # behind Excelx#cell and the other single-cell lookups, and Sheet#each_row
8
+ # (#each_row_streaming). Offsets and row limits stay roo's code.
9
+ module SheetDoc
10
+ # Cells the native reader parses ahead of Ruby, per batch.
11
+ BATCH_CELLS = 16_384
12
+
13
+ # A sheet already read into a Native::Store is built from it even when
14
+ # the native reader has been turned off since, so the cells handed out
15
+ # stay the ones #cells holds.
16
+ def cells(relationships)
17
+ return super if @cells || !(native? || @oxide_store)
18
+
19
+ store = @oxide_store || nil
20
+ @oxide_store = nil
21
+ @cells = store ? store.cells : read_cells(relationships)
22
+ rescue UnsupportedError
23
+ super
24
+ end
25
+
26
+ # The cells of #cells, each built when it is first read. nil when they
27
+ # have to be built all at once: when #cells already exists, when merged
28
+ # ranges are expanded, and when building some cell would raise, because
29
+ # roo raises for the whole sheet then.
30
+ def lazy_cells(relationships)
31
+ if @oxide_store.nil? && !@cells && !@options[:expand_merged_ranges] && native?
32
+ @oxide_store = read_store(relationships)
33
+ end
34
+ @oxide_store || nil
35
+ end
36
+
37
+ # Yields Native::Row objects; #each_cell turns one into cells when roo
38
+ # asks for it, so rows roo skips are never converted, as in roo.
39
+ def each_row_streaming(&)
40
+ return super unless native?
41
+
42
+ yielded = 0
43
+ reader = Native::Reader.new(sheet_source, true, BATCH_CELLS)
44
+ while (row = reader.next_row)
45
+ yield row
46
+ yielded += 1
47
+ end
48
+ nil
49
+ rescue UnsupportedError
50
+ # roo reads the sheet again; skip the rows already yielded.
51
+ skip = yielded
52
+ super { |row| skip.zero? ? yield(row) : skip -= 1 }
53
+ ensure
54
+ # Also when the caller stops early, as roo does for max_rows.
55
+ reader&.close
56
+ end
57
+
58
+ def each_cell(row_xml, &)
59
+ return super unless row_xml.is_a?(Native::Row)
60
+
61
+ oxide_stream_converter.row_cells(row_xml, false).each(&)
62
+ end
63
+
64
+ # The cells #each_cell yields for a Native::Row, as a new Array; nil for
65
+ # rows roo reads itself, custom #each_cell, or roo-only padding.
66
+ def native_row_cells(row, pad_cells: false)
67
+ return unless row.is_a?(Native::Row) && method(:each_cell).owner.equal?(SheetDoc)
68
+
69
+ oxide_stream_converter.row_cells(row, pad_cells)
70
+ end
71
+
72
+ # Roo::Oxide.each_row_values. `sheet` is the Roo::Excelx::Sheet that
73
+ # owns this document; it turns rows that roo reads itself into cells.
74
+ def each_row_values(sheet, &)
75
+ return each_roo_row_values(sheet, 0, &) unless native?
76
+
77
+ yielded = 0
78
+ reader = Native::Reader.new(sheet_source, true, BATCH_CELLS)
79
+ while (values = reader.next_values(oxide_stream_converter))
80
+ yield values
81
+ yielded += 1
82
+ end
83
+ nil
84
+ rescue UnsupportedError
85
+ each_roo_row_values(sheet, yielded, &)
86
+ ensure
87
+ reader&.close
88
+ end
89
+
90
+ private
91
+
92
+ # The values of the rows roo's own #each_row_streaming yields, after the
93
+ # first `skip`.
94
+ def each_roo_row_values(sheet, skip)
95
+ method(:each_row_streaming).super_method.call do |row|
96
+ next skip -= 1 unless skip.zero?
97
+
98
+ yield sheet.send(:cells_for_row_element, row, pad_cells: true).map { |cell| cell&.value }
99
+ end
100
+ nil
101
+ end
102
+
103
+ def oxide_stream_converter
104
+ @oxide_stream_converter ||= Native::Converter.new(Context.new(@shared, hyperlinks(@relationships), true),
105
+ true)
106
+ end
107
+
108
+ def native?
109
+ @path && Oxide.enabled?
110
+ end
111
+
112
+ # Every cell, in roo's order.
113
+ def read_cells(relationships)
114
+ cells = {}
115
+ reader = Native::Reader.new(sheet_source, false, BATCH_CELLS)
116
+ converter = Native::Converter.new(context(relationships), false)
117
+ nil while reader.fill_cells(cells, converter)
118
+ expand_merged_ranges(cells) if @options[:expand_merged_ranges]
119
+ cells
120
+ ensure
121
+ reader&.close
122
+ end
123
+
124
+ # A Native::Store, or false when the cells have to be built all at once.
125
+ # roo applies the hyperlinks to every cell on the first read, so later
126
+ # changes to the hash it hands out do not reach the cells.
127
+ def read_store(relationships)
128
+ Native::Store.read(sheet_source, context(relationships, hyperlinks(relationships).dup)) || false
129
+ rescue UnsupportedError
130
+ false
131
+ end
132
+
133
+ def context(relationships, links = hyperlinks(relationships))
134
+ Context.new(@shared, links, @options[:empty_cell])
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ # Prepended to Roo::Excelx::SheetDoc. roo finds a sheet's hyperlinks
6
+ # (#hyperlinks, behind every read of a sheet with a hyperlink
7
+ # relationship) and its merged ranges with XPath on the whole sheet.
8
+ # Native.sheet_parts resolves links without a Nokogiri document.
9
+ module SheetParts
10
+ # roo versions whose hyperlink and merge rules these methods reproduce.
11
+ ROO_VERSIONS = %w[4.0.0].freeze
12
+
13
+ def hyperlinks(relationships)
14
+ return super unless native? && ROO_VERSIONS.include?(Roo::VERSION)
15
+ return @hyperlinks if @hyperlinks
16
+ return @hyperlinks = {} if @options[:no_hyperlinks]
17
+
18
+ targets = relationships.oxide_hyperlink_targets
19
+ return @hyperlinks = {} unless targets
20
+ return super unless (parts = oxide_parts(targets))
21
+
22
+ @hyperlinks = parts.first
23
+ end
24
+
25
+ private
26
+
27
+ def expand_merged_ranges(cells)
28
+ return super unless (parts = oxide_parts)
29
+
30
+ merges = {}
31
+ parts.last.each { |ref| merge_range(merges, ref, cells) }
32
+ merges.each { |dst, src| cells[dst] = cells[src] }
33
+ end
34
+
35
+ def merge_range(merges, ref, cells)
36
+ src, dst = ref.split(":").map { |key| ::Roo::Utils.ref_to_key(key) }
37
+ return unless cells[src]
38
+
39
+ (src[0]..dst[0]).each do |row|
40
+ (src[1]..dst[1]).each do |col|
41
+ merges[[row, col]] = src unless row == src[0] && col == src[1]
42
+ end
43
+ end
44
+ end
45
+
46
+ # [hyperlinks, merge_cells] from Native.sheet_parts, or nil when roo
47
+ # reads them from the whole document.
48
+ def oxide_parts(targets = {})
49
+ return unless native? && ROO_VERSIONS.include?(Roo::VERSION)
50
+
51
+ @oxide_parts = Native.sheet_parts(sheet_source, targets) if @oxide_parts.nil?
52
+ @oxide_parts || nil
53
+ rescue UnsupportedError
54
+ @oxide_parts = false
55
+ nil
56
+ end
57
+ end
58
+
59
+ # roo's relationship nodes stay lazy; hyperlink reads need only targets.
60
+ module HyperlinkRelationships
61
+ def oxide_hyperlink_targets
62
+ return targets_from_roo if @relationships || !doc_exists?
63
+
64
+ Native.hyperlink_targets(@path)
65
+ rescue UnsupportedError
66
+ targets_from_roo
67
+ end
68
+
69
+ private
70
+
71
+ def targets_from_roo
72
+ return unless include_type?("hyperlink")
73
+
74
+ to_a.transform_values { |relationship| relationship["Target"] }
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roo
4
+ module Oxide
5
+ VERSION = "0.0.2"
6
+ end
7
+ end