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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +63 -0
- data/Cargo.lock +341 -0
- data/Cargo.toml +7 -0
- data/LICENSE +21 -0
- data/README.md +220 -0
- data/ext/roo_oxide/Cargo.toml +19 -0
- data/ext/roo_oxide/extconf.rb +14 -0
- data/ext/roo_oxide/src/archive.rs +546 -0
- data/ext/roo_oxide/src/convert/cells.rs +516 -0
- data/ext/roo_oxide/src/convert/csv.rs +209 -0
- data/ext/roo_oxide/src/convert.rs +687 -0
- data/ext/roo_oxide/src/lib.rs +306 -0
- data/ext/roo_oxide/src/numeric.rs +174 -0
- data/ext/roo_oxide/src/reader/attributes.rs +199 -0
- data/ext/roo_oxide/src/reader/tests.rs +475 -0
- data/ext/roo_oxide/src/reader.rs +944 -0
- data/ext/roo_oxide/src/shared_strings.rs +684 -0
- data/ext/roo_oxide/src/sheet_parts.rs +312 -0
- data/ext/roo_oxide/src/store/formatters.rs +177 -0
- data/ext/roo_oxide/src/store.rs +894 -0
- data/lib/roo/calamine/version.rb +5 -0
- data/lib/roo/calamine.rb +3 -0
- data/lib/roo/oxide/context.rb +74 -0
- data/lib/roo/oxide/csv.rb +157 -0
- data/lib/roo/oxide/deferred_sheets.rb +284 -0
- data/lib/roo/oxide/excelx.rb +156 -0
- data/lib/roo/oxide/formatters.rb +114 -0
- data/lib/roo/oxide/row_index.rb +93 -0
- data/lib/roo/oxide/row_iteration.rb +33 -0
- data/lib/roo/oxide/shared_strings.rb +46 -0
- data/lib/roo/oxide/sheet.rb +99 -0
- data/lib/roo/oxide/sheet_doc.rb +138 -0
- data/lib/roo/oxide/sheet_parts.rb +78 -0
- data/lib/roo/oxide/version.rb +7 -0
- data/lib/roo/oxide.rb +100 -0
- metadata +110 -0
data/lib/roo/calamine.rb
ADDED
|
@@ -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
|