hypertabular 0.7.0-x64-mingw-ucrt
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/LICENSE +21 -0
- data/README.md +179 -0
- data/lib/hypertabular/3.4/hypertabular_native.so +0 -0
- data/lib/hypertabular/4.0/hypertabular_native.so +0 -0
- data/lib/hypertabular/batch.rb +204 -0
- data/lib/hypertabular/column.rb +170 -0
- data/lib/hypertabular/delimited_reader.rb +309 -0
- data/lib/hypertabular/dialect.rb +42 -0
- data/lib/hypertabular/runtime/columns.rb +46 -0
- data/lib/hypertabular/runtime/delimited.rb +204 -0
- data/lib/hypertabular/runtime/workbook.rb +299 -0
- data/lib/hypertabular/runtime.rb +142 -0
- data/lib/hypertabular/tabular_error.rb +99 -0
- data/lib/hypertabular/workbook.rb +173 -0
- data/lib/hypertabular.rb +103 -0
- metadata +111 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
module HyperTabular
|
|
2
|
+
module Runtime
|
|
3
|
+
# The core's workbook reader as objects: every byte of native memory a workbook read
|
|
4
|
+
# needs and every native call it makes — the Fiddle crossing behind Workbook and Sheet,
|
|
5
|
+
# which never see a pointer.
|
|
6
|
+
#
|
|
7
|
+
# A workbook call works in buffers the caller hands it (a Buffers block of seven pointer
|
|
8
|
+
# and size pairs), and when one is too small it says which and how large, having undone
|
|
9
|
+
# nothing: the buffer is grown with what it held kept, and the same call made again.
|
|
10
|
+
module Book
|
|
11
|
+
class << self
|
|
12
|
+
# For the specs: every buffer a workbook call works in starts with room for one
|
|
13
|
+
# element and no shared-strings bound is asked for, so every call that can stop and
|
|
14
|
+
# resume does — the grow-and-keep path exercised mid-part.
|
|
15
|
+
attr_accessor :stingy
|
|
16
|
+
|
|
17
|
+
# For the specs: how many times the window, the arena and the cell table were grown,
|
|
18
|
+
# as [window, arena, cells].
|
|
19
|
+
attr_accessor :grown
|
|
20
|
+
end
|
|
21
|
+
self.stingy = false
|
|
22
|
+
self.grown = [0, 0, 0]
|
|
23
|
+
|
|
24
|
+
OK = Delimited::OK
|
|
25
|
+
ERR_STRUCTURE = Delimited::ERR_STRUCTURE
|
|
26
|
+
ERR_ARENA = Delimited::ERR_ARENA
|
|
27
|
+
ERR_CELLS = Delimited::ERR_CELLS
|
|
28
|
+
# The window is too small to inflate and tokenize in.
|
|
29
|
+
ERR_WINDOW = -5
|
|
30
|
+
|
|
31
|
+
# The smallest window the core works in.
|
|
32
|
+
WINDOW_MIN = 64 * 1024
|
|
33
|
+
# Bytes in one span, and in one slot of the row a read assembles.
|
|
34
|
+
SPAN_BYTES = 8
|
|
35
|
+
SLOT_BYTES = 16
|
|
36
|
+
# Buffers: seven pointer and size pairs — the same block a delimited fill takes.
|
|
37
|
+
BUFFERS = Delimited::BUFFERS
|
|
38
|
+
BUFFERS_BYTES = Delimited::BUFFERS_BYTES
|
|
39
|
+
# Opened: format, epoch, strings_bytes, strings_count, needed, then Failure.
|
|
40
|
+
OPENED = "L<2Q<3L<2Q<2L<2".freeze
|
|
41
|
+
OPENED_BYTES = 64
|
|
42
|
+
# Filled: rows, consumed, arena_used, needed, then Failure.
|
|
43
|
+
FILLED = Delimited::FILLED
|
|
44
|
+
FILLED_BYTES = Delimited::FILLED_BYTES
|
|
45
|
+
|
|
46
|
+
# The workbook's tables as a sheet's calls are handed them: strings, the span of each,
|
|
47
|
+
# the kind of each cell format. Pointers and sizes.
|
|
48
|
+
Tables = Struct.new(:strings, :strings_len, :table, :table_len, :kinds, :kinds_len)
|
|
49
|
+
|
|
50
|
+
# The buffers a call to the core may ask to have grown: one set for a workbook while
|
|
51
|
+
# it opens, one for each sheet.
|
|
52
|
+
class Scratch
|
|
53
|
+
# The buffers, each a Fiddle::Pointer, and their sizes (in bytes, spans, slots).
|
|
54
|
+
attr_reader :window, :arena, :cells, :arena_cap, :cells_cap
|
|
55
|
+
|
|
56
|
+
def initialize(window, arena, cells, row)
|
|
57
|
+
@window_cap = window
|
|
58
|
+
@window = Runtime.buffer([window, 1].max)
|
|
59
|
+
@arena_cap = arena
|
|
60
|
+
@arena = Runtime.buffer([arena, 1].max)
|
|
61
|
+
@cells_cap = cells
|
|
62
|
+
@cells = Runtime.buffer([SPAN_BYTES * cells, 1].max)
|
|
63
|
+
@row_cap = row
|
|
64
|
+
@row = Runtime.buffer([SLOT_BYTES * row, 1].max)
|
|
65
|
+
@buffers = Runtime.buffer(BUFFERS_BYTES)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# This scratch as the core takes it, with the workbook's tables when a sheet is
|
|
69
|
+
# being read.
|
|
70
|
+
def buffers(tables = nil)
|
|
71
|
+
words = [@window.to_i, @window_cap, @arena.to_i, @arena_cap, @cells.to_i, @cells_cap, @row.to_i, @row_cap]
|
|
72
|
+
words.concat(tables ? tables.to_a : [0] * 6)
|
|
73
|
+
@buffers[0, BUFFERS_BYTES] = words.pack(BUFFERS)
|
|
74
|
+
@buffers
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Makes the window at least +needed+ bytes, what it held kept.
|
|
78
|
+
def grow_window(needed)
|
|
79
|
+
@window, @window_cap = grown(@window, @window_cap, needed, 1)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Makes the arena at least +needed+ bytes, what it held kept.
|
|
83
|
+
def grow_arena(needed)
|
|
84
|
+
@arena, @arena_cap = grown(@arena, @arena_cap, needed, 1)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Makes the cell table at least +needed+ spans, what it held kept.
|
|
88
|
+
def grow_cells(needed)
|
|
89
|
+
@cells, @cells_cap = grown(@cells, @cells_cap, needed, SPAN_BYTES)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Makes +call+ (a block taking the Buffers block and the out block) until it stops
|
|
93
|
+
# asking for room, growing the buffer it names each time. Returns the code it ended
|
|
94
|
+
# on and the Filled fields: rows, consumed, arena_used, needed, then the failure.
|
|
95
|
+
def drive(out, tables = nil)
|
|
96
|
+
loop do
|
|
97
|
+
code = yield buffers(tables)
|
|
98
|
+
filled = out[0, FILLED_BYTES].unpack(FILLED)
|
|
99
|
+
case code
|
|
100
|
+
when ERR_WINDOW then grow_window(filled[3]).then { Book.grown[0] += 1 }
|
|
101
|
+
when ERR_ARENA then grow_arena(filled[3]).then { Book.grown[1] += 1 }
|
|
102
|
+
when ERR_CELLS then grow_cells(filled[3]).then { Book.grown[2] += 1 }
|
|
103
|
+
else return [code, filled]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def grown(old, capacity, needed, unit)
|
|
111
|
+
length = [needed, capacity + 1].max
|
|
112
|
+
larger = Runtime.buffer(unit * length)
|
|
113
|
+
larger[0, unit * capacity] = old[0, unit * capacity] if capacity.positive?
|
|
114
|
+
[larger, length]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A workbook opened in memory: its state, its sheets, its tables.
|
|
119
|
+
class Opened
|
|
120
|
+
CALLS = {
|
|
121
|
+
sheets: :hypertabular_workbook_sheets, strings: :hypertabular_workbook_strings,
|
|
122
|
+
styles: :hypertabular_workbook_styles
|
|
123
|
+
}.freeze
|
|
124
|
+
|
|
125
|
+
# :xlsx or :ods; the date system's code (1 for 1900, 2 for 1904); the sheets as
|
|
126
|
+
# [name, hidden, part, index]; the shared strings' bytes; the state template every
|
|
127
|
+
# sheet copies; and the tables a sheet's calls are handed.
|
|
128
|
+
attr_reader :format, :epoch, :sheets, :strings, :state, :tables, :container, :length
|
|
129
|
+
|
|
130
|
+
# Opens +container+, a String the workbook holds unmodified for its whole life. Raises
|
|
131
|
+
# the failure as [code, line, record, byte, expected, found] in a StructureError.
|
|
132
|
+
def initialize(container)
|
|
133
|
+
@container_string = container
|
|
134
|
+
@container = Runtime.pin(container)
|
|
135
|
+
@length = container.bytesize
|
|
136
|
+
@state_size = Runtime.function(:hypertabular_workbook_state_size).call
|
|
137
|
+
@state = Runtime.buffer(@state_size)
|
|
138
|
+
@out = Runtime.buffer([OPENED_BYTES, FILLED_BYTES].max)
|
|
139
|
+
scratch = Book.stingy ? Scratch.new(1, 1, 1, 0) : Scratch.new(WINDOW_MIN, 1024, 64, 0)
|
|
140
|
+
strings_bytes = open(scratch)
|
|
141
|
+
@sheets = listed(scratch)
|
|
142
|
+
load_tables(scratch, strings_bytes)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A copy of the opened state, which the core allows: a sheet's read of its own.
|
|
146
|
+
def copy_of_state
|
|
147
|
+
copy = Runtime.buffer(@state_size)
|
|
148
|
+
copy[0, @state_size] = @state[0, @state_size]
|
|
149
|
+
copy
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def open(scratch)
|
|
155
|
+
call = Runtime.function(:hypertabular_workbook_open)
|
|
156
|
+
loop do
|
|
157
|
+
code = call.call(@state, @container, @length, scratch.buffers, @out)
|
|
158
|
+
format, epoch, strings_bytes, _count, needed, *failure = @out[0, OPENED_BYTES].unpack(OPENED)
|
|
159
|
+
case code
|
|
160
|
+
when OK
|
|
161
|
+
@format = format == 2 ? :ods : :xlsx
|
|
162
|
+
@epoch = epoch
|
|
163
|
+
return strings_bytes
|
|
164
|
+
when ERR_WINDOW then scratch.grow_window(needed)
|
|
165
|
+
when ERR_ARENA then scratch.grow_arena(needed)
|
|
166
|
+
when ERR_STRUCTURE then raise StructureError, failure
|
|
167
|
+
else raise Delimited::CONTRACT
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def run(scratch, name)
|
|
173
|
+
call = Runtime.function(CALLS.fetch(name))
|
|
174
|
+
code, filled = scratch.drive(@out) { |buffers| call.call(@state, @container, @length, buffers, @out) }
|
|
175
|
+
Book.settle(code, filled)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The sheets the core listed: three spans each — name, part, then one whose offset's
|
|
179
|
+
# low bit says hidden and whose length is the sheet's index.
|
|
180
|
+
def listed(scratch)
|
|
181
|
+
rows = run(scratch, :sheets).first
|
|
182
|
+
spans = scratch.cells[0, SPAN_BYTES * 3 * rows].unpack("L<*")
|
|
183
|
+
arena = scratch.arena[0, scratch.arena_cap]
|
|
184
|
+
Array.new(rows) do |index|
|
|
185
|
+
name_at, name_len, part_at, part_len, flags, sheet = spans[index * 6, 6]
|
|
186
|
+
[arena.byteslice(name_at, name_len).force_encoding(Encoding::UTF_8).freeze, flags.odd?,
|
|
187
|
+
arena.byteslice(part_at, part_len), sheet]
|
|
188
|
+
end.freeze
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def load_tables(scratch, strings_bytes)
|
|
192
|
+
# The shared strings take no more room than their part inflates to; asking for it
|
|
193
|
+
# once saves growing into it.
|
|
194
|
+
bound = [strings_bytes, 1 << 28].min
|
|
195
|
+
scratch.grow_arena(bound) if !Book.stingy && scratch.arena_cap < bound
|
|
196
|
+
rows, _consumed, arena_used = run(scratch, :strings)
|
|
197
|
+
@strings = scratch.arena[0, arena_used].force_encoding(Encoding::UTF_8).freeze
|
|
198
|
+
@strings_buffer = copy(scratch.arena, arena_used)
|
|
199
|
+
@table = copy(scratch.cells, SPAN_BYTES * rows)
|
|
200
|
+
table_len = rows
|
|
201
|
+
kinds_len = run(scratch, :styles).first
|
|
202
|
+
@kinds = copy(scratch.arena, kinds_len)
|
|
203
|
+
@tables = Tables.new(@strings_buffer.to_i, arena_used, @table.to_i, table_len, @kinds.to_i, kinds_len)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def copy(from, bytes)
|
|
207
|
+
buffer = Runtime.buffer([bytes, 1].max)
|
|
208
|
+
buffer[0, bytes] = from[0, bytes] if bytes.positive?
|
|
209
|
+
buffer
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# One sheet being read: its own copy of the state, its plan's arrays, its scratch.
|
|
214
|
+
class Reading
|
|
215
|
+
# The plan's arrays, and the cell-table entries one row takes: one per plan column,
|
|
216
|
+
# and one for the row's number.
|
|
217
|
+
attr_reader :columns, :per_row, :header
|
|
218
|
+
|
|
219
|
+
def initialize(book, sheet, has_header, skip_empty_rows, specs, sizes, batch_rows, width)
|
|
220
|
+
@book = book
|
|
221
|
+
@state = book.copy_of_state
|
|
222
|
+
@columns = Columns.new(specs, sizes, batch_rows)
|
|
223
|
+
@per_row = specs.size + 1
|
|
224
|
+
@scratch = Book.stingy ? Scratch.new(1, 1, 1, width) : Scratch.new(0, 4096, batch_rows * @per_row, width)
|
|
225
|
+
@out = Runtime.buffer(FILLED_BYTES)
|
|
226
|
+
_name, _hidden, part, index = sheet
|
|
227
|
+
code = Runtime.function(:hypertabular_workbook_sheet).call(
|
|
228
|
+
@state, book.container, book.length, Runtime.pin(part), part.bytesize, index,
|
|
229
|
+
has_header ? 1 : 0, skip_empty_rows ? 1 : 0, @out
|
|
230
|
+
)
|
|
231
|
+
Book.settle(code, @out[0, FILLED_BYTES].unpack(FILLED))
|
|
232
|
+
@header = read_header if has_header
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The next batch's rows, its cell table, the arena as far as the core wrote into it
|
|
236
|
+
# (which is as far as any of the batch's spans reach), and the failure — [code, line,
|
|
237
|
+
# record, byte, expected, found] — that came with them, if one did: the caller raises
|
|
238
|
+
# it after them. The table and the arena are copies.
|
|
239
|
+
def fill
|
|
240
|
+
call = Runtime.function(:hypertabular_workbook_fill)
|
|
241
|
+
code, filled = @scratch.drive(@out, @book.tables) do |buffers|
|
|
242
|
+
call.call(@state, @book.container, @book.length, @columns.specs, @columns.table,
|
|
243
|
+
@columns.count, @columns.batch_rows, buffers, @out)
|
|
244
|
+
end
|
|
245
|
+
raise Delimited::CONTRACT unless [OK, ERR_STRUCTURE].include?(code)
|
|
246
|
+
|
|
247
|
+
rows, _consumed, arena_used = filled
|
|
248
|
+
[rows, @scratch.cells[0, SPAN_BYTES * @per_row * rows], @scratch.arena[0, arena_used],
|
|
249
|
+
code == OK ? nil : filled[4..]]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def read_header
|
|
255
|
+
call = Runtime.function(:hypertabular_workbook_header)
|
|
256
|
+
code, filled = @scratch.drive(@out, @book.tables) do |buffers|
|
|
257
|
+
call.call(@state, @book.container, @book.length, buffers, @out)
|
|
258
|
+
end
|
|
259
|
+
rows = Book.settle(code, filled).first
|
|
260
|
+
spans = @scratch.cells[0, SPAN_BYTES * rows].unpack("L<*")
|
|
261
|
+
arena = nil
|
|
262
|
+
Array.new(rows) do |index|
|
|
263
|
+
offset = spans[index * 2]
|
|
264
|
+
length = spans[index * 2 + 1]
|
|
265
|
+
name =
|
|
266
|
+
if length < Delimited::SPAN_FLAG
|
|
267
|
+
@book.strings.byteslice(offset, length)
|
|
268
|
+
else
|
|
269
|
+
arena ||= @scratch.arena[0, @scratch.arena_cap].force_encoding(Encoding::UTF_8)
|
|
270
|
+
arena.byteslice(offset, length & Delimited::SPAN_LENGTH)
|
|
271
|
+
end
|
|
272
|
+
name.freeze
|
|
273
|
+
end.freeze
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# A structural failure the core reported, as [code, line, record, byte, expected,
|
|
278
|
+
# found], on its way to becoming a TabularError.
|
|
279
|
+
class StructureError < StandardError
|
|
280
|
+
attr_reader :failure
|
|
281
|
+
|
|
282
|
+
def initialize(failure)
|
|
283
|
+
@failure = failure
|
|
284
|
+
super("structural failure #{failure.first}")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# What a call's code means: the Filled fields, or a structural failure. Anything else
|
|
289
|
+
# is this binding's bug.
|
|
290
|
+
def self.settle(code, filled)
|
|
291
|
+
case code
|
|
292
|
+
when OK then filled
|
|
293
|
+
when ERR_STRUCTURE then raise StructureError, filled[4..]
|
|
294
|
+
else raise Delimited::CONTRACT
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Autoloaded, not required, as in hypercast: Fiddle loads the first time the backend runs,
|
|
2
|
+
# and every path there goes through `functions` first, so a missing library is reported as
|
|
3
|
+
# missing_library_message rather than as whatever touching Fiddle raises first.
|
|
4
|
+
autoload :Fiddle, "fiddle"
|
|
5
|
+
|
|
6
|
+
module HyperTabular
|
|
7
|
+
# Fiddle plumbing for the native libhypertabular shared library — dlopen/dlsym plus raw
|
|
8
|
+
# C-ABI calls, no runtime bridge. A gem's files are plain files on disk once installed, so
|
|
9
|
+
# native/{rid}/{lib} is dlopen'ed directly — no extraction.
|
|
10
|
+
module Runtime
|
|
11
|
+
NATIVE_DIR = File.join(__dir__, "native")
|
|
12
|
+
|
|
13
|
+
# Every export of the library (rust/src/kernel/exports.rs), with its C signature spelled
|
|
14
|
+
# in Symbols — resolved to Fiddle types only in load_functions, so nothing here touches
|
|
15
|
+
# Fiddle until the backend actually runs.
|
|
16
|
+
EXPORTS = {
|
|
17
|
+
hypertabular_version: [[], :uint32],
|
|
18
|
+
hypertabular_delimited_state_size: [[], :size],
|
|
19
|
+
# (state, dialect)
|
|
20
|
+
hypertabular_delimited_init: [%i[pointer pointer], :int32],
|
|
21
|
+
# (state, input, input_len, last, names, names_cap, arena, arena_cap, out)
|
|
22
|
+
hypertabular_delimited_header:
|
|
23
|
+
[%i[pointer pointer size uint32 pointer size pointer size pointer], :int32],
|
|
24
|
+
# (state, input, input_len, last, specs, columns, column_count, max_rows, buffers, out) —
|
|
25
|
+
# buffers is the Buffers block the workbook calls take, of which a delimited fill reads
|
|
26
|
+
# only the arena and the cell table.
|
|
27
|
+
hypertabular_delimited_fill:
|
|
28
|
+
[%i[pointer pointer size uint32 pointer pointer size size pointer pointer], :int32],
|
|
29
|
+
# (cell, len, out, cap)
|
|
30
|
+
hypertabular_delimited_unescape: [%i[pointer size pointer size], :size],
|
|
31
|
+
hypertabular_workbook_state_size: [[], :size],
|
|
32
|
+
# (state, container, container_len, buffers, out)
|
|
33
|
+
hypertabular_workbook_open: [%i[pointer pointer size pointer pointer], :int32],
|
|
34
|
+
hypertabular_workbook_sheets: [%i[pointer pointer size pointer pointer], :int32],
|
|
35
|
+
hypertabular_workbook_strings: [%i[pointer pointer size pointer pointer], :int32],
|
|
36
|
+
hypertabular_workbook_styles: [%i[pointer pointer size pointer pointer], :int32],
|
|
37
|
+
# (state, container, container_len, part, part_len, index, has_header, skip_empty_rows, out)
|
|
38
|
+
hypertabular_workbook_sheet:
|
|
39
|
+
[%i[pointer pointer size pointer size uint32 uint32 uint32 pointer], :int32],
|
|
40
|
+
hypertabular_workbook_header: [%i[pointer pointer size pointer pointer], :int32],
|
|
41
|
+
# (state, container, container_len, specs, columns, column_count, max_rows, buffers, out)
|
|
42
|
+
hypertabular_workbook_fill: [%i[pointer pointer size pointer pointer size size pointer pointer], :int32]
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
@functions = nil
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# The export's Fiddle::Function, for the caller to invoke directly.
|
|
50
|
+
def function(symbol)
|
|
51
|
+
functions.fetch(symbol)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Every native allocation goes through here, and loads the library first: that is what
|
|
55
|
+
# keeps a missing library reported as missing_library_message instead of as whatever
|
|
56
|
+
# touching Fiddle raises first. Zeroed, freed with the object that holds it.
|
|
57
|
+
def buffer(size)
|
|
58
|
+
functions
|
|
59
|
+
Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A pointer to a String's own bytes — nothing is copied. While the returned object is
|
|
63
|
+
# alive the String is held where it is: Fiddle marks what it wraps as unmovable, so
|
|
64
|
+
# the garbage collector's compaction cannot relocate an embedded String under a native
|
|
65
|
+
# call. The address is good until the String is next modified.
|
|
66
|
+
def pin(string)
|
|
67
|
+
functions
|
|
68
|
+
Fiddle::Pointer[string]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A quoted delimited cell — the bytes a flagged cell-table entry names — with its
|
|
72
|
+
# quotes resolved, as the core cast it.
|
|
73
|
+
def unescape(quoted)
|
|
74
|
+
input = pin(quoted)
|
|
75
|
+
out = buffer([quoted.bytesize, 1].max)
|
|
76
|
+
written = function(:hypertabular_delimited_unescape).call(input, quoted.bytesize, out, quoted.bytesize)
|
|
77
|
+
out[0, written]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# The shared library to dlopen: this install's native/{rid}/{lib}, or — the
|
|
83
|
+
# development loop — the in-repo cargo build, exactly what the other bindings' local
|
|
84
|
+
# staging does. Nil when neither exists.
|
|
85
|
+
def library_path
|
|
86
|
+
HyperCast::Interop.library_path("hypertabular", NATIVE_DIR, File.expand_path("../../..", __dir__))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Why Fiddle found nothing to load. A precompiled platform gem is the one install where
|
|
90
|
+
# that is by design rather than a gap: it carries only its Magnus extensions, and the
|
|
91
|
+
# Fiddle backend is reached there only by forcing it (HYPERTABULAR_PURE) or because none
|
|
92
|
+
# of its extensions loaded — a gem RubyGems matched to a Ruby it was not built for. So
|
|
93
|
+
# that case names its fix, the universal gem, which carries every platform's library,
|
|
94
|
+
# instead of a missing path that reads like a packaging bug — hypercast's wording for
|
|
95
|
+
# the same case. Both arguments are parameters only so the specs can ask for every
|
|
96
|
+
# wording.
|
|
97
|
+
def missing_library_message(gem_platform = Gem.loaded_specs["hypertabular"]&.platform,
|
|
98
|
+
forced = ENV.key?("HYPERTABULAR_PURE"))
|
|
99
|
+
rid, lib_name = HyperCast::NativePlatform.rid_and_library_name(library: "hypertabular")
|
|
100
|
+
missing = File.join(NATIVE_DIR, rid, lib_name)
|
|
101
|
+
if gem_platform && gem_platform.to_s != Gem::Platform::RUBY
|
|
102
|
+
reason =
|
|
103
|
+
if forced
|
|
104
|
+
"HYPERTABULAR_PURE forces the Fiddle backend (unset it to use the extension)"
|
|
105
|
+
else
|
|
106
|
+
"none of its extensions loads on this Ruby (#{RUBY_VERSION}, #{RUBY_PLATFORM})"
|
|
107
|
+
end
|
|
108
|
+
"hypertabular: this #{gem_platform} platform gem carries only Magnus extensions, no " \
|
|
109
|
+
"Fiddle library, and #{reason}. The universal gem has the Fiddle backend for every " \
|
|
110
|
+
"platform: `gem install hypertabular --platform ruby`, or Bundler's " \
|
|
111
|
+
"force_ruby_platform (#{missing} not found)"
|
|
112
|
+
else
|
|
113
|
+
"hypertabular: #{missing} not found (unsupported platform, or this gem was built " \
|
|
114
|
+
"without a native library for it)"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Loaded lazily and exactly once; the native library and its function pointers live
|
|
119
|
+
# for the process's lifetime (never dlclose'd). The unsynchronized read is the fast
|
|
120
|
+
# path; the benign race re-checks under the lock.
|
|
121
|
+
def functions
|
|
122
|
+
@functions || @mutex.synchronize { @functions ||= load_functions }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def load_functions
|
|
126
|
+
path = library_path
|
|
127
|
+
raise LoadError, missing_library_message if path.nil?
|
|
128
|
+
|
|
129
|
+
handle = Fiddle.dlopen(path)
|
|
130
|
+
types = {
|
|
131
|
+
pointer: Fiddle::TYPE_VOIDP, size: Fiddle::TYPE_SIZE_T,
|
|
132
|
+
uint32: Fiddle::TYPE_UINT32_T, int32: Fiddle::TYPE_INT32_T
|
|
133
|
+
}
|
|
134
|
+
EXPORTS.to_h do |name, (arguments, result)|
|
|
135
|
+
[name, Fiddle::Function.new(handle[name.to_s], arguments.map { |type|
|
|
136
|
+
types.fetch(type)
|
|
137
|
+
}, types.fetch(result))]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module HyperTabular
|
|
2
|
+
# A structural failure: the input is not rows of cells — a record of the wrong width,
|
|
3
|
+
# input that ends inside a quoted cell, a workbook whose container or parts cannot be
|
|
4
|
+
# read. Never a cell's verdict: a value that does not cast is a HyperCast::Fault in its
|
|
5
|
+
# column, and the read goes on. A structural failure ends the input, after every intact
|
|
6
|
+
# row before it has been delivered.
|
|
7
|
+
#
|
|
8
|
+
# For a workbook, #record is the part the failure is in, #line the sheet row and #byte the
|
|
9
|
+
# offset within the part's inflated bytes.
|
|
10
|
+
#
|
|
11
|
+
# begin
|
|
12
|
+
# reader.read
|
|
13
|
+
# rescue HyperTabular::TabularError => e
|
|
14
|
+
# case e
|
|
15
|
+
# in { kind: :column_count, line:, expected:, found: }
|
|
16
|
+
# warn "line #{line}: #{found} cells, not #{expected}"
|
|
17
|
+
# in { kind: :unclosed_quote, line: }
|
|
18
|
+
# warn "line #{line}: a quote is never closed"
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
class TabularError < StandardError
|
|
22
|
+
# What the core's failure codes mean.
|
|
23
|
+
KINDS = {
|
|
24
|
+
1 => :unclosed_quote, 2 => :column_count, 3 => :row_too_long, 16 => :not_a_zip, 17 => :container,
|
|
25
|
+
18 => :encrypted, 19 => :method, 20 => :missing_part, 21 => :xml, 22 => :deflate,
|
|
26
|
+
23 => :not_a_workbook, 24 => :shared_string, 25 => :too_large
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# What is wrong: :unclosed_quote (the input ended inside a quoted cell), :column_count
|
|
30
|
+
# (a record's cell count disagrees with the first record's), :row_too_long (a single
|
|
31
|
+
# record is larger than DelimitedReader::MAX_ROW_BYTES); for a workbook :not_a_zip,
|
|
32
|
+
# :container (the zip's own structure is broken), :encrypted, :method (a part is
|
|
33
|
+
# compressed by neither stored nor deflate), :missing_part, :xml (a part ends inside an
|
|
34
|
+
# XML construct), :deflate (a part is not a whole deflate stream), :not_a_workbook,
|
|
35
|
+
# :shared_string (a cell names a shared string the table does not have) or :too_large.
|
|
36
|
+
attr_reader :kind
|
|
37
|
+
|
|
38
|
+
# Zero-based index of the offending record — the header and skipped blank lines
|
|
39
|
+
# included.
|
|
40
|
+
attr_reader :record
|
|
41
|
+
|
|
42
|
+
# One-based line the offending record starts on.
|
|
43
|
+
attr_reader :line
|
|
44
|
+
|
|
45
|
+
# Absolute byte offset of the offending record's start, a byte-order mark included.
|
|
46
|
+
attr_reader :byte
|
|
47
|
+
|
|
48
|
+
# Cells in the first record, for :column_count; 0 otherwise.
|
|
49
|
+
attr_reader :expected
|
|
50
|
+
|
|
51
|
+
# Cells in this record, for :column_count; 0 otherwise.
|
|
52
|
+
attr_reader :found
|
|
53
|
+
|
|
54
|
+
# The failure the core reported, as [code, line, record, byte, expected, found]. A code
|
|
55
|
+
# this binding does not know is the container's: the most general refusal.
|
|
56
|
+
def self.from(failure)
|
|
57
|
+
code, line, record, byte, expected, found = failure
|
|
58
|
+
new(KINDS.fetch(code, :container), record, line, byte, expected, found)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Raised by the readers; nothing else builds one.
|
|
62
|
+
def initialize(kind, record, line, byte, expected = 0, found = 0)
|
|
63
|
+
@kind = kind
|
|
64
|
+
@record = record
|
|
65
|
+
@line = line
|
|
66
|
+
@byte = byte
|
|
67
|
+
@expected = expected
|
|
68
|
+
@found = found
|
|
69
|
+
super(describe)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The failure as a Hash, which is what lets `case error in { kind:, line: }` match.
|
|
73
|
+
def deconstruct_keys(_keys)
|
|
74
|
+
{ kind: kind, record: record, line: line, byte: byte, expected: expected, found: found }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# The message, in the words the other bindings use.
|
|
80
|
+
def describe
|
|
81
|
+
at = "#{record} (line #{line}, byte #{byte})"
|
|
82
|
+
case kind
|
|
83
|
+
when :column_count then "Record #{at} has #{found} cells; the first record had #{expected}."
|
|
84
|
+
when :unclosed_quote then "The input ended inside a quoted cell in record #{at}."
|
|
85
|
+
when :row_too_long then "Record #{at} exceeds the #{DelimitedReader::MAX_ROW_BYTES}-byte row ceiling."
|
|
86
|
+
when :not_a_zip then "The workbook is not a zip file."
|
|
87
|
+
when :encrypted then "The workbook is encrypted."
|
|
88
|
+
when :method then "Part #{record} of the workbook is compressed by method #{found}, neither stored nor deflate."
|
|
89
|
+
when :missing_part then "Part #{record}, which the workbook cannot be read without, is missing."
|
|
90
|
+
when :xml then "Part #{record} of the workbook ends inside an XML construct (byte #{byte})."
|
|
91
|
+
when :deflate then "Part #{record} of the workbook is not a whole deflate stream (byte #{byte})."
|
|
92
|
+
when :not_a_workbook then "The zip is neither an XLSX nor an ODS workbook."
|
|
93
|
+
when :shared_string then "Row #{line} names shared string #{found}; the table has #{expected}."
|
|
94
|
+
when :too_large then "The workbook holds more text than a batch can address."
|
|
95
|
+
else "The workbook's zip structure is broken."
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|