calcsheet-native 0.1.0

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,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ module Native
5
+ # The native XLSX backend: calcsheet's Ruby reader (the zip archive and its limits, the workbook, styles and
6
+ # shared strings) with the XML of each sheet read in Rust instead of by Ox. The Rust side reads a plain subset of
7
+ # XML and the values calcsheet accepts; on anything else (a comment, CDATA, a reference in an attribute, a value
8
+ # calcsheet refuses, an error of the zip stream...) the sheet is read again by calcsheet's own parser, which
9
+ # gives its exact rows or error. The rows already yielded are not yielded again, and the bytes the first attempt
10
+ # inflated are not charged twice against +max_uncompressed_size+.
11
+ #
12
+ # A read from a later row (the rows after the header, +start_at:+) parses the rows before it too, where calcsheet
13
+ # passes over them with RowSkipper: what the subset reads, RowSkipper passes over without an error, and anything
14
+ # else is again left to calcsheet.
15
+ #
16
+ # It depends on the private methods of Calcsheet::Readers::XlsxReader and the internals of ZipContainer of the
17
+ # calcsheet minor version it supports.
18
+ class XlsxReader < Calcsheet::Readers::XlsxReader
19
+ # calcsheet's SharedStrings over a StringTable read in Rust, which the sheet readers share: one copy of the
20
+ # strings.
21
+ class SharedStrings < Readers::Xlsx::SharedStrings
22
+ # @return [StringTable]
23
+ attr_reader :native
24
+
25
+ # @param table [StringTable]
26
+ def initialize(table) # rubocop:disable Lint/MissingSuper -- the buffer and offsets of the superclass are in Rust
27
+ @native = table
28
+ @size = table.size
29
+ @bytesize = table.bytesize
30
+ end
31
+
32
+ # @see Readers::Xlsx::SharedStrings#unchecked
33
+ def unchecked(index)
34
+ @native.unchecked(index)
35
+ end
36
+ end
37
+
38
+ # Thrown when calcsheet's parser must read the sheet.
39
+ FALLBACK = :calcsheet_native_fallback
40
+ # Rows per call into Rust, and bytes read from the zip stream at a time.
41
+ ROWS = 512
42
+ CHUNK = 64 * 1024
43
+ # The chunks of shared strings between two minor GCs (see #read_shared_strings).
44
+ GC_CHUNKS = 32
45
+ # The kinds of XlsxSheet's cells; the text of a SHARED cell is its shared string.
46
+ NUMBER = 0
47
+ SHARED = 1
48
+ INLINE = 2
49
+ BOOLEAN = 3
50
+ ERROR = 4
51
+ DATE = 5
52
+ # The inflated total of ZipContainer, restored before calcsheet's parser reads the sheet again.
53
+ TOTAL = :@inflated_total
54
+
55
+ @fallbacks = 0
56
+
57
+ class << self
58
+ # @return [Integer] the sheet reads handed to calcsheet's parser in this process, for specs and diagnostics
59
+ attr_accessor :fallbacks
60
+ end
61
+
62
+ private
63
+
64
+ def each_row(index, cast, types, start_row, &block)
65
+ path = @meta.sheet_path(index)
66
+ return super unless @zip.entry?(path)
67
+
68
+ charged = @zip.instance_variable_get(TOTAL)
69
+ strings = @shared_strings
70
+ last = 0
71
+ finished = catch(FALLBACK) do
72
+ read_natively(path, cast, types, start_row) do |row|
73
+ last = row.number
74
+ yield row
75
+ end
76
+ true
77
+ end
78
+ return if finished
79
+
80
+ self.class.fallbacks += 1
81
+ @zip.instance_variable_set(TOTAL, charged)
82
+ @shared_strings = strings
83
+ super(index, cast, types, start_row) { |row| yield row if row.number > last }
84
+ end
85
+
86
+ # Yields the rows read by XlsxSheet; throws FALLBACK when calcsheet's parser must read the sheet. The errors of
87
+ # the caller's block are its own.
88
+ def read_natively(path, cast, types, start_row)
89
+ stream = guarded { @zip.stream(path) }
90
+ sheet = XlsxSheet.new(@max_cell_bytes)
91
+ builder = RowBuilder.new(cast, types, number_errors: :as_read)
92
+ @style_formats = {}
93
+ # Read into one String: a new one per chunk would leave the whole part as garbage before the GC runs.
94
+ buffer = String.new(capacity: CHUNK)
95
+ loop do
96
+ batch = guarded { sheet.rows(ROWS) }
97
+ throw FALLBACK if batch == false
98
+ if batch == :strings
99
+ guarded do
100
+ table = shared_strings
101
+ if table.is_a?(SharedStrings)
102
+ sheet.table(table.native)
103
+ else
104
+ sheet.strings(table.instance_variable_get(:@buffer), table.instance_variable_get(:@offsets))
105
+ end
106
+ end
107
+ next
108
+ end
109
+ if batch.nil?
110
+ chunk = guarded { stream.read(CHUNK, buffer) }
111
+ chunk ? sheet.feed(chunk) : sheet.finish
112
+ next
113
+ end
114
+
115
+ numbers, ends, cells, done = batch
116
+ from = 0
117
+ numbers.each_with_index do |number, position|
118
+ to = ends[position]
119
+ guarded { add_cells(builder, cells, from, to) }
120
+ from = to
121
+ row = builder.finish(number)
122
+ yield row if row && number >= start_row
123
+ end
124
+ break if done
125
+ end
126
+ ensure
127
+ stream&.close
128
+ end
129
+
130
+ # Runs the block, throwing FALLBACK for any error it raises: calcsheet's parser raises it again, where it
131
+ # would.
132
+ def guarded
133
+ yield
134
+ rescue StandardError
135
+ throw FALLBACK
136
+ end
137
+
138
+ # Adds the cells +from...to+ of a batch to the builder, as SheetParser#finish_cell does.
139
+ def add_cells(builder, cells, from, to)
140
+ while from < to
141
+ column = cells[from]
142
+ style = cells[from + 2]
143
+ text = cells[from + 3]
144
+ text = read_text(text) if cells[from + 4]
145
+ case cells[from + 1]
146
+ when NUMBER then builder.add(column, :number, text, format(style))
147
+ when SHARED then builder.add(column, :text, text)
148
+ when INLINE then builder.add(column, :text, Readers::Xlsx::Escapes.decode!(text))
149
+ when BOOLEAN then builder.add(column, :boolean, text)
150
+ when ERROR then builder.add(column, :error, text)
151
+ when DATE then builder.add(column, :iso_date, text, format(style))
152
+ else builder.add(column, :text, text)
153
+ end
154
+ from += 6
155
+ end
156
+ end
157
+
158
+ # The line ends and references of a text, as SheetParser#text reads them.
159
+ def read_text(text)
160
+ text = Readers::References.line_ends(text) if text.include?("\r")
161
+ text.include?("&") ? Readers::References.decode(text) : text
162
+ end
163
+
164
+ # The +[kind, pad]+ of a number or date cell's style (SheetParser#style_kind): 0 without one.
165
+ def format(style)
166
+ return @meta.style_kind(0) if style.nil?
167
+
168
+ @style_formats[style] ||= begin
169
+ throw FALLBACK unless style.zero? || style.between?(1, @meta.style_count - 1)
170
+ @meta.style_kind(style)
171
+ end
172
+ end
173
+
174
+ # The shared strings read in Rust, as calcsheet's SharedStrings; calcsheet loads them itself when the Rust side
175
+ # does not read them, with the inflated total set back first.
176
+ def load_shared_strings
177
+ return super unless @zip.entry?(SHARED_STRINGS)
178
+
179
+ charged = @zip.instance_variable_get(TOTAL)
180
+ table = catch(FALLBACK) { read_shared_strings }
181
+ return table if table
182
+
183
+ @zip.instance_variable_set(TOTAL, charged)
184
+ super
185
+ end
186
+
187
+ def read_shared_strings
188
+ stream = guarded { @zip.stream(SHARED_STRINGS, ratio_floor: false) }
189
+ reader = XlsxStrings.new(@max_cell_bytes)
190
+ buffer = String.new(capacity: CHUNK)
191
+ chunks = 0
192
+ loop do
193
+ result = guarded { reader.read }
194
+ throw FALLBACK if result == false
195
+ return SharedStrings.new(result) if result
196
+
197
+ chunk = guarded { stream.read(CHUNK, buffer) }
198
+ chunk ? reader.feed(chunk) : reader.finish
199
+ # The inflater leaves its pieces as garbage, and this loop allocates too little for the GC to run: a
200
+ # minor GC every GC_CHUNKS keeps the peak memory at the Ruby backend's.
201
+ GC.start(full_mark: false) if (chunks += 1) % GC_CHUNKS == 0
202
+ end
203
+ ensure
204
+ stream&.close
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "calcsheet"
4
+ require_relative "native/version"
5
+
6
+ begin
7
+ # A precompiled platform gem holds one extension per Ruby minor version; a gem built from source holds one.
8
+ require_relative "native/#{RUBY_VERSION[/\A\d+\.\d+/]}/calcsheet_native"
9
+ rescue LoadError
10
+ require_relative "native/calcsheet_native"
11
+ end
12
+ require_relative "native/csv_reader"
13
+ require_relative "native/ods_reader"
14
+ require_relative "native/xlsx_reader"
15
+
16
+ module Calcsheet
17
+ # The native reader backends of calcsheet, which Calcsheet.open finds through +require "calcsheet/native"+ (see
18
+ # Calcsheet::NativeBackends in calcsheet).
19
+ module Native
20
+ # The version of the interface with calcsheet: Calcsheet::NativeBackends::PROTOCOLS lists those it accepts.
21
+ PROTOCOL = 1
22
+
23
+ # The formats read natively.
24
+ BACKENDS = { csv: CsvReader, xlsx: XlsxReader, ods: OdsReader }.freeze
25
+
26
+ # @return [Hash{Symbol => Class}] the backend class of each format read natively
27
+ def self.backends
28
+ BACKENDS
29
+ end
30
+ end
31
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: calcsheet-native
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Demetra Opinioni.net Srl
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: calcsheet
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '0.4'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '0.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '0.4'
32
+ - !ruby/object:Gem::Dependency
33
+ name: rb_sys
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: 0.9.130
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: 0.9.130
46
+ description: |
47
+ calcsheet-native reads CSV, XLSX and ODS for calcsheet with a Rust core that runs without the GVL, and gives
48
+ the same rows as calcsheet's Ruby backends.
49
+ email:
50
+ - developers@opinioni.net
51
+ executables: []
52
+ extensions:
53
+ - ext/calcsheet_native/extconf.rb
54
+ extra_rdoc_files: []
55
+ files:
56
+ - CHANGELOG.md
57
+ - Cargo.lock
58
+ - Cargo.toml
59
+ - LICENSE.txt
60
+ - README.md
61
+ - ext/calcsheet_native/Cargo.toml
62
+ - ext/calcsheet_native/extconf.rb
63
+ - ext/calcsheet_native/src/csv.rs
64
+ - ext/calcsheet_native/src/gvl.rs
65
+ - ext/calcsheet_native/src/lib.rs
66
+ - ext/calcsheet_native/src/ods.rs
67
+ - ext/calcsheet_native/src/xlsx.rs
68
+ - ext/calcsheet_native/src/xml.rs
69
+ - lib/calcsheet/native.rb
70
+ - lib/calcsheet/native/csv_reader.rb
71
+ - lib/calcsheet/native/ods_reader.rb
72
+ - lib/calcsheet/native/version.rb
73
+ - lib/calcsheet/native/xlsx_reader.rb
74
+ homepage: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet-native
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ allowed_push_host: https://rubygems.org
79
+ source_code_uri: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet-native/-/tree/main
80
+ bug_tracker_uri: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet-native/-/issues
81
+ changelog_uri: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet-native/-/blob/main/CHANGELOG.md
82
+ rubygems_mfa_required: 'true'
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '3.3'
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 4.0.16
98
+ specification_version: 4
99
+ summary: Native reader backends for calcsheet.
100
+ test_files: []